mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] add more staff gift options
This commit is contained in:
@@ -260,12 +260,15 @@ inconsistency here without checking the client first.
|
||||
refresh relationship state: sent alone, the request "worked" server-side and the target
|
||||
saw nothing. Same pattern as the cheer.
|
||||
- A gift box's `AvatarItemType` (`econ`: the stored box in `GET /api/avatar/v2/gifts`, `buyItem`'s
|
||||
`BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame)
|
||||
must be NULL for anything that is not an avatar item — a consumable, a skin, a box. The client
|
||||
routes the box on that field before it reads what else the box names, so a 0 sends it looking
|
||||
for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists every
|
||||
non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies it on
|
||||
every surface, so don't coalesce it with `?? 0` anywhere a box is built.
|
||||
`BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame;
|
||||
`www`'s staff boxes; `api`'s `gifts/generate`) must be NULL for anything that is not an avatar
|
||||
item — a consumable, a skin, a box, tokens, XP — and a NUMBER (the type, or 0) when the box holds
|
||||
one, whether a baked item by `AvatarItemDesc` or a custom item by `CustomAvatarItemId`. The
|
||||
client routes the box on that field before it reads what else the box names, so a 0 sends it
|
||||
looking for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists
|
||||
every non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies
|
||||
it on every surface, so don't coalesce it with `?? 0` anywhere a box is built. This holds for
|
||||
every worker that mints a box, not only `econ`.
|
||||
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||
|
||||
@@ -848,7 +848,7 @@ export const GeneratedGift = z.object({
|
||||
ConsumableItemDesc: z.string(),
|
||||
AvatarItemDesc: z.string(),
|
||||
FriendlyName: z.string(),
|
||||
AvatarItemType: z.int(),
|
||||
AvatarItemType: z.int().nullable().describe('Null — a token box holds no avatar item'),
|
||||
EquipmentPrefabName: z.string(),
|
||||
EquipmentModificationGuid: z.string(),
|
||||
CurrencyType: z.int(),
|
||||
|
||||
@@ -372,7 +372,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
FriendlyName: '',
|
||||
AvatarItemType: 0,
|
||||
// A token box holds no avatar item: null, or the client goes looking for type 0.
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
CurrencyType: 2,
|
||||
|
||||
@@ -1100,18 +1100,24 @@ function truncateGiftMessage(message: string): string {
|
||||
* own context — a box the server handed over on nobody's behalf.
|
||||
*/
|
||||
/**
|
||||
* `AvatarItemType` as a gift box carries it: the drop's own for an avatar item, NULL for
|
||||
* anything else — a consumable, a skin, a box, a token bundle.
|
||||
* `AvatarItemType` as a gift box carries it: the drop's own (0 when it names none) for an
|
||||
* AVATAR ITEM — a baked one by `AvatarItemDesc` or a custom one by `CustomAvatarItemId` — and
|
||||
* NULL for anything else: a consumable, a skin, a box, a token bundle.
|
||||
*
|
||||
* The client routes a box by this field before it looks at what else the box names: a 0
|
||||
* reads as "an avatar item of type 0", and a consumable or skin box sent with 0 fails with
|
||||
* "can't find avatar item" because there is no such item to find. The 2025 store lists every
|
||||
* non-avatar drop with `AvatarItemType: null` for exactly that reason, and this is what the
|
||||
* old `?? 0` coalescing used to erase. Applied at every surface a box is serialized on — the
|
||||
* stored box, both purchase responses and the hub frame — so they cannot disagree.
|
||||
* old `?? 0` coalescing used to erase. A custom item's box is the other way round: it needs
|
||||
* the number, as the bag's guid-keyed box ({@link toCustomGiftContent}) sends it. Applied at
|
||||
* every surface a box is serialized on — the stored box, both purchase responses and the hub
|
||||
* frame — so they cannot disagree.
|
||||
*/
|
||||
function boxAvatarItemType(drop: StoreGiftDrop): number | null {
|
||||
return drop.AvatarItemDesc !== '' ? (drop.AvatarItemType ?? 0) : null
|
||||
const isAvatarItem =
|
||||
drop.AvatarItemDesc !== '' ||
|
||||
(typeof drop.CustomAvatarItemId === 'string' && drop.CustomAvatarItemId !== '')
|
||||
return isAvatarItem ? (drop.AvatarItemType ?? 0) : null
|
||||
}
|
||||
|
||||
function toGiftContent(
|
||||
|
||||
+58
-60
@@ -1198,15 +1198,35 @@ function PlayerPage({
|
||||
* `audit_log`. Clearing a password asks twice, since it logs the player out of password
|
||||
* sign-in until they set a new one; the rest are one click.
|
||||
*/
|
||||
/** The optional message on the box a staff gift comes in — one field, shared by every gift below it. */
|
||||
function GiftMessageField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<label>
|
||||
Gift box message
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
maxLength={256}
|
||||
placeholder="A gift from the staff!"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<span className="hint">
|
||||
Goes on every gift box sent below. Optional; blank sends the default.
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) {
|
||||
const [tokens, setTokens] = useState('')
|
||||
const [tokenMessage, setTokenMessage] = useState('')
|
||||
// One message for every box sent from this card; it stays put between sends.
|
||||
const [message, setMessage] = useState('')
|
||||
const [confirmingClear, setConfirmingClear] = useState(false)
|
||||
const [customItemId, setCustomItemId] = useState('')
|
||||
const [itemId, setItemId] = useState('')
|
||||
const [xp, setXp] = useState('')
|
||||
const gift = useAction()
|
||||
const xpGift = useAction()
|
||||
const customGift = useAction()
|
||||
const itemGift = useAction()
|
||||
const usernameChange = useAction()
|
||||
const clearPassword = useAction()
|
||||
const base = `/api/staff/players/${account.accountId}`
|
||||
@@ -1217,6 +1237,8 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
||||
{/* The gifts are developer-only; www refuses them to moderators. */}
|
||||
{isDeveloper() && (
|
||||
<>
|
||||
<GiftMessageField value={message} onChange={setMessage} />
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -1224,10 +1246,9 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
||||
const amount = Number(tokens)
|
||||
const res = await call<{ balance: number }>(`${base}/gift-tokens`, {
|
||||
authed: true,
|
||||
json: { amount, message: tokenMessage.trim() },
|
||||
json: { amount, message: message.trim() },
|
||||
})
|
||||
setTokens('')
|
||||
setTokenMessage('')
|
||||
const what =
|
||||
amount < 0
|
||||
? `Took ${Math.abs(amount).toLocaleString()} tokens back`
|
||||
@@ -1258,17 +1279,6 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
||||
they have. Zero sends an empty box and moves nothing.
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
Gift box message
|
||||
<input
|
||||
type="text"
|
||||
value={tokenMessage}
|
||||
maxLength={256}
|
||||
placeholder="A gift from the staff!"
|
||||
onChange={(e) => setTokenMessage(e.target.value)}
|
||||
/>
|
||||
<span className="hint">Optional. Blank sends the default.</span>
|
||||
</label>
|
||||
{gift.error && <p className="error">{gift.error}</p>}
|
||||
{gift.done && <p className="ok">{gift.done}</p>}
|
||||
</form>
|
||||
@@ -1280,7 +1290,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
||||
const amount = Number(xp)
|
||||
const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, {
|
||||
authed: true,
|
||||
json: { amount },
|
||||
json: { amount, message: message.trim() },
|
||||
})
|
||||
setXp('')
|
||||
const levels =
|
||||
@@ -1319,38 +1329,52 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void customGift.run(async () => {
|
||||
const res = await call<{ name: string }>(`${base}/gift-custom-item`, {
|
||||
void itemGift.run(async () => {
|
||||
const res = await call<{
|
||||
kind: 'custom_item' | 'skin' | 'consumable'
|
||||
name: string
|
||||
prefabName?: string
|
||||
owned?: number
|
||||
}>(`${base}/gift-item`, {
|
||||
authed: true,
|
||||
json: { customAvatarItemId: customItemId.trim() },
|
||||
json: { itemId: itemId.trim(), message: message.trim() },
|
||||
})
|
||||
setCustomItemId('')
|
||||
return `Sent “${res.name}” to @${account.username}.`
|
||||
setItemId('')
|
||||
switch (res.kind) {
|
||||
case 'skin':
|
||||
return `Sent the skin “${res.name}” (${res.prefabName}) to @${account.username}.`
|
||||
case 'consumable':
|
||||
return `Sent one “${res.name}” to @${account.username}. They now have ${(res.owned ?? 1).toLocaleString()}.`
|
||||
default:
|
||||
return `Sent the custom item “${res.name}” to @${account.username}.`
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift custom item
|
||||
Gift item
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="text"
|
||||
value={customItemId}
|
||||
placeholder="Custom avatar item id"
|
||||
value={itemId}
|
||||
placeholder="Custom item, skin or consumable id"
|
||||
spellCheck={false}
|
||||
required
|
||||
onChange={(e) => setCustomItemId(e.target.value)}
|
||||
onChange={(e) => setItemId(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={customGift.pending}>
|
||||
{customGift.pending ? 'Sending…' : 'Send'}
|
||||
<button type="submit" disabled={itemGift.pending}>
|
||||
{itemGift.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.
|
||||
A custom avatar item’s guid, a skin’s modification guid, a consumable’s item desc,
|
||||
or a store id — the kind is worked out from the id. Arrives as a gift box, as if
|
||||
bought from the store; free to them, and a creator isn’t paid. Consumables stack:
|
||||
send again for another.
|
||||
</span>
|
||||
</label>
|
||||
{customGift.error && <p className="error">{customGift.error}</p>}
|
||||
{customGift.done && <p className="ok">{customGift.done}</p>}
|
||||
{itemGift.error && <p className="error">{itemGift.error}</p>}
|
||||
{itemGift.done && <p className="ok">{itemGift.done}</p>}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
@@ -2094,7 +2118,8 @@ function RoomPage({
|
||||
* A room as anyone sees it: the same hero the owner's page draws, with the creator linked
|
||||
* to their profile in place of the upload control, then the room's public facts and the
|
||||
* photos players took in it — read-only, since nothing here is the viewer's to change.
|
||||
* Staff also see its subrooms; the owner gets them on their own page instead.
|
||||
* No subroom list: a visitor has no use for the room's internals, and the owner gets them
|
||||
* (with the scene-data tool) on their own page instead.
|
||||
*/
|
||||
function PublicRoomView({
|
||||
room,
|
||||
@@ -2108,7 +2133,6 @@ function PublicRoomView({
|
||||
const [creator, setCreator] = useState<{ username: string; displayName: string } | null>(null)
|
||||
const [photos, setPhotos] = useState<PublicPhoto[] | null>(null)
|
||||
const created = new Date(room.CreatedAt)
|
||||
const subRooms = room.SubRooms ?? []
|
||||
|
||||
useEffect(() => {
|
||||
setCreator(null)
|
||||
@@ -2178,32 +2202,6 @@ function PublicRoomView({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Staff only: a visitor has no use for the room's internals. The owner sees them on
|
||||
their own page (RoomDetail), never this one. */}
|
||||
{isAdmin() && (
|
||||
<section className="card">
|
||||
<h2>Subrooms</h2>
|
||||
{subRooms.length === 0 ? (
|
||||
<p className="muted">This room has no subrooms.</p>
|
||||
) : (
|
||||
<ul className="subrooms">
|
||||
{subRooms.map((sub) => (
|
||||
<li className="subroom" key={sub.SubRoomId}>
|
||||
<div className="room-head">
|
||||
<span className="subroom-name">{sub.Name}</span>
|
||||
<VisibilityBadge accessibility={sub.Accessibility} />
|
||||
</div>
|
||||
<p className="subroom-meta">
|
||||
Up to {sub.MaxPlayers} player{sub.MaxPlayers === 1 ? '' : 's'}
|
||||
{sub.IsSandbox ? ' · sandbox' : ''}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's
|
||||
role itself on the DELETE. */}
|
||||
{isAdmin() && (
|
||||
|
||||
+244
-24
@@ -41,6 +41,15 @@ import {
|
||||
getBalance,
|
||||
spendCurrency,
|
||||
} from '../../econ/src/balance-db'
|
||||
// The item catalog and the two inventories a skin or a consumable gift writes, all `econ`'s.
|
||||
import {
|
||||
CatalogKind,
|
||||
getCatalogItem,
|
||||
getCatalogItemById,
|
||||
toCatalogSkin,
|
||||
} from '../../econ/src/catalog-db'
|
||||
import { countConsumable, grantConsumable } from '../../econ/src/consumables-db'
|
||||
import { getEquipment, grantEquipment } from '../../econ/src/equipment-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.
|
||||
@@ -48,7 +57,9 @@ import { NotificationType } from '../../notify/src/notification-types'
|
||||
|
||||
import type { Context, MiddlewareHandler } from 'hono'
|
||||
import type { GiftContent } from '@repo/domain'
|
||||
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
||||
import type { ReportRow, ReportSearch } from '../../api/src/reports-db'
|
||||
import type { CatalogRow } from '../../econ/src/catalog-db'
|
||||
import type {
|
||||
BalanceResponsePayload,
|
||||
GiftPackagePayload,
|
||||
@@ -554,7 +565,7 @@ export const DEFAULT_MAX_XP_GIFT = 100
|
||||
*/
|
||||
export const MAX_TOKEN_DROP = 1_000
|
||||
|
||||
/** The longest message a staff token box may carry — the same cap a client message has. */
|
||||
/** The longest message a staff box may carry — the same cap a client message has. */
|
||||
const MAX_GIFT_MESSAGE = 256
|
||||
|
||||
/**
|
||||
@@ -601,6 +612,10 @@ 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.
|
||||
*
|
||||
* `AvatarItemType` is NULL: no staff box holds an avatar item, and the client routes a box on
|
||||
* that field before reading the rest — a 0 sends it after "avatar item type 0", which fails
|
||||
* with "can't find avatar item". Same rule as econ's `boxAvatarItemType`.
|
||||
*/
|
||||
function staffGiftContent(fields: Partial<GiftContent>): GiftContent {
|
||||
return {
|
||||
@@ -609,7 +624,7 @@ function staffGiftContent(fields: Partial<GiftContent>): GiftContent {
|
||||
ConsumableItemDesc: '',
|
||||
ConsumableCount: 0,
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: 0,
|
||||
AvatarItemType: null,
|
||||
CurrencyType: 0,
|
||||
Currency: 0,
|
||||
Xp: 0,
|
||||
@@ -739,7 +754,7 @@ function tokenAmountRefusal(c: Context<App>, amount: number): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* The message a token box should carry, from the body's optional `message`: the staffer's
|
||||
* The message a staff box should carry, from the body's optional `message`: the staffer's
|
||||
* own words, trimmed, or {@link STAFF_GIFT_MESSAGE} when they wrote none. `undefined` means
|
||||
* a message was written but is too long to send — the caller refuses rather than truncating
|
||||
* what the player is going to read.
|
||||
@@ -948,7 +963,7 @@ 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 body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: 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)
|
||||
@@ -957,6 +972,8 @@ export async function giftXpHandler(c: Context<App>) {
|
||||
if (amount > maxGift) {
|
||||
return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} XP` }, 400)
|
||||
}
|
||||
const message = giftMessage(body)
|
||||
if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400)
|
||||
if ((await getAccount(c.env.DB, playerId)) === null) {
|
||||
return c.json({ error: 'No such player' }, 404)
|
||||
}
|
||||
@@ -965,12 +982,14 @@ export async function giftXpHandler(c: Context<App>) {
|
||||
const content = staffGiftContent({
|
||||
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
|
||||
Xp: amount,
|
||||
Message: message,
|
||||
})
|
||||
const gift = await createGift(c.env.DB, playerId, content)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_xp', {
|
||||
playerId,
|
||||
amount,
|
||||
message,
|
||||
level: progression.Level,
|
||||
levelsGained,
|
||||
giftId: gift.id,
|
||||
@@ -1006,35 +1025,123 @@ export async function giftXpHandler(c: Context<App>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* How many of a consumable one staff gift carries — one, as a store purchase grants one
|
||||
* (econ's `CONSUMABLE_GRANT_COUNT`). Consumables stack, so more is more gifts.
|
||||
*/
|
||||
export async function giftCustomItemHandler(c: Context<App>) {
|
||||
const CONSUMABLE_GIFT_COUNT = 1
|
||||
|
||||
/**
|
||||
* What an item gift's id resolved to. `avatar_item` is a catalog row nothing here can grant;
|
||||
* `ambiguous` is an id found in BOTH tables, which nothing here can pick between.
|
||||
*/
|
||||
type GiftableItem =
|
||||
| { kind: 'custom_item'; item: CustomAvatarItem }
|
||||
| { kind: 'skin'; row: CatalogRow }
|
||||
| { kind: 'consumable'; row: CatalogRow }
|
||||
| { kind: 'avatar_item'; row: CatalogRow }
|
||||
| { kind: 'ambiguous' }
|
||||
|
||||
/**
|
||||
* Resolve the one id the staff card takes to whatever it names, looking in BOTH tables an
|
||||
* item can live in: `custom_avatar_item` by guid, in either case (a GUID's case is not its
|
||||
* identity; ids are stored as the export spelled them), and `catalog` by KEY — a skin's
|
||||
* `ModificationGuid` or a consumable's `ConsumableItemDesc`, spelled as the export spells it
|
||||
* (the short pre-GUID ids are case-sensitive, so nothing is folded there) — or, for a bare
|
||||
* number, by `catalog_id`, the store's `PurchasableItemId`.
|
||||
*
|
||||
* A UUID could be anything, so neither lookup is skipped on the strength of the other: an id
|
||||
* that answers in both tables is `ambiguous` rather than whichever was asked first. In
|
||||
* practice they don't collide — no catalog key is a bare number, and a skin and a consumable
|
||||
* never share a key — but the check is what makes that a fact rather than an assumption.
|
||||
*
|
||||
* A DRAFT custom item (`Accessibility` 0, visible to its creator alone — handing it out would
|
||||
* publish it for them) is treated as unknown, exactly as the store would refuse to sell it.
|
||||
*/
|
||||
async function findGiftableItem(db: D1Database, requested: string): Promise<GiftableItem | null> {
|
||||
const [custom, byKey] = await Promise.all([
|
||||
getCustomAvatarItem(db, requested).then(
|
||||
(item) => item ?? getCustomAvatarItem(db, requested.toLowerCase())
|
||||
),
|
||||
getCatalogItem(db, requested),
|
||||
])
|
||||
const item = custom !== null && custom.Accessibility !== 0 ? custom : null
|
||||
const row =
|
||||
byKey ?? (/^\d{1,9}$/.test(requested) ? await getCatalogItemById(db, Number(requested)) : null)
|
||||
|
||||
if (item !== null && row !== null) return { kind: 'ambiguous' }
|
||||
if (item !== null) return { kind: 'custom_item', item }
|
||||
if (row === null) return null
|
||||
switch (row.kind) {
|
||||
case CatalogKind.Skin:
|
||||
return { kind: 'skin', row }
|
||||
case CatalogKind.Consumable:
|
||||
return { kind: 'consumable', row }
|
||||
case CatalogKind.AvatarItem:
|
||||
return { kind: 'avatar_item', row }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a player an item — a custom avatar item, an equipment skin or a consumable — by any
|
||||
* one of their ids, delivered the way a store purchase delivers it: ownership in the inventory
|
||||
* `econ` reads that kind from, and a gift box that names it, without which the player is
|
||||
* shown nothing. Nobody pays, and a custom item's creator is not paid: a grant, not a sale.
|
||||
* Which kind the id turned out to be is on the reply as `kind`, and the audit row is filed
|
||||
* per kind (`gift_custom_item`, `gift_skin`, `gift_consumable`).
|
||||
*
|
||||
* A baked avatar item's id is refused outright: nothing here grants one yet, and a 404 would
|
||||
* send the staffer checking an id that is right.
|
||||
*/
|
||||
export async function giftItemHandler(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)
|
||||
const body = (await c.req.json().catch(() => ({}))) as { itemId?: unknown; message?: unknown }
|
||||
const requested = typeof body.itemId === 'string' ? body.itemId.trim() : ''
|
||||
if (requested === '') return c.json({ error: 'Enter an item id' }, 400)
|
||||
const message = giftMessage(body)
|
||||
if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 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)
|
||||
const found = await findGiftableItem(c.env.DB, requested)
|
||||
if (found === null) return c.json({ error: 'No such item' }, 404)
|
||||
switch (found.kind) {
|
||||
case 'custom_item':
|
||||
return await giftCustomItem(c, playerId, found.item, message)
|
||||
case 'skin':
|
||||
return await giftSkin(c, playerId, found.row, message)
|
||||
case 'consumable':
|
||||
return await giftConsumable(c, playerId, found.row, message)
|
||||
case 'avatar_item':
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'That is a baked avatar item; only custom items, skins and consumables can be gifted',
|
||||
},
|
||||
400
|
||||
)
|
||||
case 'ambiguous':
|
||||
return c.json({ error: 'That id names both a custom item and a catalog item' }, 409)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom avatar item: ownership is a row in `inventory_custom` (what `econ`'s owned read
|
||||
* serves the item from), and the box names it by `CustomAvatarItemId`.
|
||||
*
|
||||
* Refused as the store refuses a sale to this player: the item's own creator (who owns it
|
||||
* already), or a player who already has it.
|
||||
*/
|
||||
async function giftCustomItem(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
item: CustomAvatarItem,
|
||||
message: string
|
||||
) {
|
||||
if (item.CreatorAccountId === playerId) {
|
||||
return c.json({ error: 'This player created that item, so they already have it' }, 409)
|
||||
}
|
||||
@@ -1047,6 +1154,10 @@ export async function giftCustomItemHandler(c: Context<App>) {
|
||||
const content = staffGiftContent({
|
||||
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
|
||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||
// 0, not the null the other staff boxes carry: a custom item IS an avatar item, and this
|
||||
// matches the box econ mints for a bought one, which is the shape seen to open.
|
||||
AvatarItemType: 0,
|
||||
Message: message,
|
||||
})
|
||||
const gift = await createGift(c.env.DB, playerId, content)
|
||||
|
||||
@@ -1054,6 +1165,7 @@ export async function giftCustomItemHandler(c: Context<App>) {
|
||||
playerId,
|
||||
customAvatarItemId: item.CustomAvatarItemId,
|
||||
name: item.Name,
|
||||
message,
|
||||
giftId: gift.id,
|
||||
})
|
||||
logger.info('staff gifted a custom item', {
|
||||
@@ -1064,6 +1176,7 @@ export async function giftCustomItemHandler(c: Context<App>) {
|
||||
await announceGift(c, playerId, gift.id, content)
|
||||
|
||||
return c.json({
|
||||
kind: 'custom_item',
|
||||
playerId,
|
||||
customAvatarItemId: item.CustomAvatarItemId,
|
||||
name: item.Name,
|
||||
@@ -1071,6 +1184,113 @@ export async function giftCustomItemHandler(c: Context<App>) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* An equipment skin: ownership is a row in `equipment` (what `GET /api/equipment/v2/getUnlocked`
|
||||
* serves), and the box names it by `EquipmentPrefabName`/`EquipmentModificationGuid` with
|
||||
* `AvatarItemType` NULL — a 0 there sends the client looking for an avatar item that does not
|
||||
* exist, and the box fails to open.
|
||||
*
|
||||
* Refused for a player who already owns it: owning a skin is boolean, so a second grant would
|
||||
* hand them a box with nothing new in it.
|
||||
*/
|
||||
async function giftSkin(c: Context<App>, playerId: number, row: CatalogRow, message: string) {
|
||||
const skin = toCatalogSkin(row)
|
||||
const owned = await getEquipment(c.env.DB, playerId)
|
||||
if (owned.some((eq) => eq.ModificationGuid === skin.ModificationGuid)) {
|
||||
return c.json({ error: 'This player already owns that skin' }, 409)
|
||||
}
|
||||
|
||||
// The same DTO a purchase grants (econ's `toEquipment`), built from the catalog's view.
|
||||
await grantEquipment(c.env.DB, playerId, {
|
||||
ModificationGuid: skin.ModificationGuid,
|
||||
PrefabName: skin.PrefabName,
|
||||
FriendlyName: skin.FriendlyName,
|
||||
Tooltip: skin.Tooltip ?? '',
|
||||
Rarity: skin.Rarity,
|
||||
PlatformMask: -1,
|
||||
Favorited: false,
|
||||
})
|
||||
const content = staffGiftContent({
|
||||
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
|
||||
EquipmentPrefabName: skin.PrefabName,
|
||||
EquipmentModificationGuid: skin.ModificationGuid,
|
||||
GiftRarity: skin.Rarity,
|
||||
Message: message,
|
||||
})
|
||||
const gift = await createGift(c.env.DB, playerId, content)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_skin', {
|
||||
playerId,
|
||||
modificationGuid: skin.ModificationGuid,
|
||||
prefabName: skin.PrefabName,
|
||||
name: skin.FriendlyName,
|
||||
message,
|
||||
giftId: gift.id,
|
||||
})
|
||||
logger.info('staff gifted a skin', {
|
||||
moderatorId: staffId(c),
|
||||
playerId,
|
||||
modificationGuid: skin.ModificationGuid,
|
||||
})
|
||||
await announceGift(c, playerId, gift.id, content)
|
||||
|
||||
return c.json({
|
||||
kind: 'skin',
|
||||
playerId,
|
||||
modificationGuid: skin.ModificationGuid,
|
||||
prefabName: skin.PrefabName,
|
||||
name: skin.FriendlyName,
|
||||
giftId: gift.id,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One of a consumable: a fresh `consumable` row (they stack, so there is no "already owns"
|
||||
* refusal — a second gift is a second one), and a box naming it by `ConsumableItemDesc` with
|
||||
* `AvatarItemType` NULL, as a skin's is. The box also carries the granted row's id and the
|
||||
* player's count BEFORE this one, which is what lets opening it fire an accurate
|
||||
* `ConsumableMappingAdded`.
|
||||
*/
|
||||
async function giftConsumable(c: Context<App>, playerId: number, row: CatalogRow, message: string) {
|
||||
const preExisting = await countConsumable(c.env.DB, playerId, row.item_key)
|
||||
const mappingId = await grantConsumable(c.env.DB, playerId, row.item_key, CONSUMABLE_GIFT_COUNT)
|
||||
const content = staffGiftContent({
|
||||
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
|
||||
ConsumableItemDesc: row.item_key,
|
||||
ConsumableCount: CONSUMABLE_GIFT_COUNT,
|
||||
ConsumableMappingId: mappingId,
|
||||
ConsumablePreExistingCount: preExisting,
|
||||
GiftRarity: row.rarity,
|
||||
Message: message,
|
||||
})
|
||||
const gift = await createGift(c.env.DB, playerId, content)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_consumable', {
|
||||
playerId,
|
||||
consumableItemDesc: row.item_key,
|
||||
name: row.friendly_name,
|
||||
count: CONSUMABLE_GIFT_COUNT,
|
||||
message,
|
||||
giftId: gift.id,
|
||||
})
|
||||
logger.info('staff gifted a consumable', {
|
||||
moderatorId: staffId(c),
|
||||
playerId,
|
||||
consumableItemDesc: row.item_key,
|
||||
})
|
||||
await announceGift(c, playerId, gift.id, content)
|
||||
|
||||
return c.json({
|
||||
kind: 'consumable',
|
||||
playerId,
|
||||
consumableItemDesc: row.item_key,
|
||||
name: row.friendly_name,
|
||||
count: CONSUMABLE_GIFT_COUNT,
|
||||
owned: preExisting + CONSUMABLE_GIFT_COUNT,
|
||||
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.
|
||||
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
getBalance,
|
||||
} from '../../../../econ/src/balance-db'
|
||||
import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db'
|
||||
import { CONSUMABLE_SCHEMA_DDL, getConsumables } from '../../../../econ/src/consumables-db'
|
||||
import { EQUIPMENT_SCHEMA_DDL, getEquipment } from '../../../../econ/src/equipment-db'
|
||||
import {
|
||||
grantCustomAvatarItem,
|
||||
INVENTORY_CUSTOM_SCHEMA_DDL,
|
||||
@@ -115,6 +118,11 @@ beforeAll(async () => {
|
||||
// `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()
|
||||
// A skin or consumable gift resolves its item in `catalog` and writes `equipment` or
|
||||
// `consumable` — all three `econ`'s.
|
||||
for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CONSUMABLE_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()
|
||||
})
|
||||
@@ -715,7 +723,7 @@ 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-item',
|
||||
'/api/staff/players/1/gift-xp',
|
||||
'/api/staff/players/1/username-changes',
|
||||
'/api/staff/players/1/clear-password',
|
||||
@@ -1316,6 +1324,8 @@ it('gifts a player tokens in a gift box', async () => {
|
||||
FromPlayerId: 1,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Currency: 500,
|
||||
// No staff box holds an avatar item; a 0 here sends the client after one.
|
||||
AvatarItemType: null,
|
||||
})
|
||||
|
||||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
@@ -1680,21 +1690,49 @@ const seedCustomItem = (id: string, creatorAccountId: number, accessibility = 1)
|
||||
thumbnailImageFilename: 'thumb.png',
|
||||
})
|
||||
|
||||
/** A catalog row — a skin (with `prefabName`), a consumable or an avatar item — as `runx catalog load` writes one. */
|
||||
const seedCatalogRow = (
|
||||
itemKey: string,
|
||||
catalogId: number,
|
||||
kind: 'skin' | 'consumable' | 'avatar_item',
|
||||
friendlyName: string,
|
||||
prefabName: string | null = null
|
||||
) =>
|
||||
env.DB.prepare(
|
||||
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, prefab_name)
|
||||
VALUES (?1, ?2, ?3, ?4, NULL, 3, -1, ?5)`
|
||||
)
|
||||
.bind(itemKey, catalogId, kind, friendlyName, prefabName)
|
||||
.run()
|
||||
|
||||
/** Every frame the hub was handed, after clearing it. */
|
||||
const hubFrames = async () =>
|
||||
(await (
|
||||
await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/all')
|
||||
).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
const clearHub = () =>
|
||||
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/all', { method: 'DELETE' })
|
||||
|
||||
/** The one item gift, by whichever id. */
|
||||
const giftItem = (playerId: number, itemId: unknown, message?: string) =>
|
||||
devPost(`/api/staff/players/${playerId}/gift-item`, 8110, { itemId, message })
|
||||
|
||||
// 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 clearHub()
|
||||
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 devPost('/api/staff/players/8330/gift-custom-item', 8110, {
|
||||
customAvatarItemId: item.CustomAvatarItemId.toUpperCase(),
|
||||
})
|
||||
const res = await giftItem(8330, item.CustomAvatarItemId.toUpperCase())
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { giftId: number; name: string }
|
||||
expect(body.name).toBe(item.Name)
|
||||
const body = (await res.json()) as { kind: string; giftId: number; name: string }
|
||||
expect(body).toMatchObject({ kind: 'custom_item', name: item.Name })
|
||||
|
||||
await expect(ownedCustomAvatarItemIds(env.DB, 8330, [item.CustomAvatarItemId])).resolves.toEqual(
|
||||
new Set([item.CustomAvatarItemId])
|
||||
@@ -1706,18 +1744,17 @@ it('gifts a player a custom item in a gift box', async () => {
|
||||
FromPlayerId: 1,
|
||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||
AvatarItemDesc: '',
|
||||
// As econ boxes a bought custom item — an avatar item, so not the null the others carry.
|
||||
AvatarItemType: 0,
|
||||
Currency: 0,
|
||||
Message: 'A gift from the staff!',
|
||||
// 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>
|
||||
}>
|
||||
const frames = await hubFrames()
|
||||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8330, 31]])
|
||||
expect(frames[0].data).toMatchObject({
|
||||
Id: body.giftId,
|
||||
@@ -1732,6 +1769,7 @@ it('gifts a player a custom item in a gift box', async () => {
|
||||
playerId: 8330,
|
||||
customAvatarItemId: item.CustomAvatarItemId,
|
||||
name: item.Name,
|
||||
message: 'A gift from the staff!',
|
||||
giftId: body.giftId,
|
||||
},
|
||||
},
|
||||
@@ -1745,21 +1783,212 @@ it('refuses a custom item gift the store would refuse', async () => {
|
||||
const own = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000004', 8340)
|
||||
await grantCustomAvatarItem(env.DB, 8340, published.CustomAvatarItemId)
|
||||
|
||||
const gift = (customAvatarItemId: unknown, playerId = 8340) =>
|
||||
devPost(`/api/staff/players/${playerId}/gift-custom-item`, 8110, { customAvatarItemId })
|
||||
|
||||
expect((await gift('')).status).toBe(400)
|
||||
expect((await gift('no-such-item')).status).toBe(404)
|
||||
expect((await giftItem(8340, '')).status).toBe(400)
|
||||
expect((await giftItem(8340, 7)).status).toBe(400)
|
||||
expect((await giftItem(8340, published.CustomAvatarItemId, 'x'.repeat(257))).status).toBe(400)
|
||||
expect((await giftItem(8340, '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 giftItem(8340, draft.CustomAvatarItemId)).status).toBe(404)
|
||||
expect((await giftItem(8340, own.CustomAvatarItemId)).status).toBe(409)
|
||||
expect((await giftItem(8340, published.CustomAvatarItemId)).status).toBe(409)
|
||||
expect((await giftItem(8396, published.CustomAvatarItemId)).status).toBe(404)
|
||||
|
||||
expect(await getPendingGifts(env.DB, 8340)).toEqual([])
|
||||
expect(await auditRows('gift_custom_item', 8340)).toEqual([])
|
||||
})
|
||||
|
||||
// Delivered as a purchase delivers one — an `equipment` row, and a box keyed by the prefab and
|
||||
// guid with `AvatarItemType` NULL (a 0 sends the client after an avatar item that isn't there).
|
||||
it('gifts a player a skin in a gift box', async () => {
|
||||
await clearHub()
|
||||
await updateAccount(env.DB, 8420, { username: 'Armed' })
|
||||
await seedCatalogRow('0dM2SfqGR0SmtO5ufTWfUQ', 91001, 'skin', 'Bow Skin (Dryad)', '[Bow]')
|
||||
await seedCatalogRow(
|
||||
'a1b2c3d4-0000-4000-8000-000000000001',
|
||||
91002,
|
||||
'skin',
|
||||
'Pen Skin',
|
||||
'[MakerPen]'
|
||||
)
|
||||
|
||||
// By key, as the export spells it — the short ids are case-sensitive, so nothing is folded.
|
||||
let res = await giftItem(8420, '0dM2SfqGR0SmtO5ufTWfUQ', 'Nice shot')
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
kind: string
|
||||
giftId: number
|
||||
name: string
|
||||
prefabName: string
|
||||
}
|
||||
expect(body).toMatchObject({ kind: 'skin', name: 'Bow Skin (Dryad)', prefabName: '[Bow]' })
|
||||
|
||||
expect(await getEquipment(env.DB, 8420)).toEqual([
|
||||
{
|
||||
ModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ',
|
||||
PrefabName: '[Bow]',
|
||||
FriendlyName: 'Bow Skin (Dryad)',
|
||||
Tooltip: '',
|
||||
Rarity: 3,
|
||||
PlatformMask: -1,
|
||||
Favorited: false,
|
||||
},
|
||||
])
|
||||
const gifts = await getPendingGifts(env.DB, 8420)
|
||||
expect(gifts).toHaveLength(1)
|
||||
expect(gifts[0]).toMatchObject({
|
||||
Id: body.giftId,
|
||||
FromPlayerId: 1,
|
||||
EquipmentPrefabName: '[Bow]',
|
||||
EquipmentModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ',
|
||||
AvatarItemType: null,
|
||||
AvatarItemDesc: '',
|
||||
GiftRarity: 3,
|
||||
GiftContext: 500,
|
||||
Message: 'Nice shot',
|
||||
})
|
||||
const frames = await hubFrames()
|
||||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8420, 31]])
|
||||
expect(frames[0].data).toMatchObject({
|
||||
Id: body.giftId,
|
||||
EquipmentModificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ',
|
||||
AvatarItemType: null,
|
||||
GiftContext: 500,
|
||||
})
|
||||
expect(await auditRows('gift_skin', 8420)).toEqual([
|
||||
{
|
||||
actor: 8110,
|
||||
data: {
|
||||
playerId: 8420,
|
||||
modificationGuid: '0dM2SfqGR0SmtO5ufTWfUQ',
|
||||
prefabName: '[Bow]',
|
||||
name: 'Bow Skin (Dryad)',
|
||||
message: 'Nice shot',
|
||||
giftId: body.giftId,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Or by the store's numeric id.
|
||||
res = await giftItem(8420, '91002')
|
||||
expect(res.status).toBe(200)
|
||||
expect((await getEquipment(env.DB, 8420)).map((eq) => eq.ModificationGuid)).toEqual([
|
||||
'0dM2SfqGR0SmtO5ufTWfUQ',
|
||||
'a1b2c3d4-0000-4000-8000-000000000001',
|
||||
])
|
||||
|
||||
// Owning a skin is boolean: a second gift would be a box with nothing new in it.
|
||||
expect((await giftItem(8420, 'skin-owned-8420', 'x'.repeat(257))).status).toBe(400)
|
||||
expect((await giftItem(8420, '0dM2SfqGR0SmtO5ufTWfUQ')).status).toBe(409)
|
||||
expect((await giftItem(8420, '99999999')).status).toBe(404)
|
||||
expect((await giftItem(8394, '0dM2SfqGR0SmtO5ufTWfUQ')).status).toBe(404)
|
||||
expect(await getEquipment(env.DB, 8420)).toHaveLength(2)
|
||||
expect(await getPendingGifts(env.DB, 8420)).toHaveLength(2)
|
||||
})
|
||||
|
||||
// A consumable stacks: each gift is a fresh row, and the box carries that row's id and the
|
||||
// count the player had before it, which is what opening the box reports.
|
||||
it('gifts a player a consumable in a gift box, and again', async () => {
|
||||
await clearHub()
|
||||
await updateAccount(env.DB, 8430, { username: 'Hungry' })
|
||||
await seedCatalogRow('0DsHcPhR_Eybn53UxKJhJw', 91010, 'consumable', 'Supreme Pizza')
|
||||
|
||||
let res = await giftItem(8430, '0DsHcPhR_Eybn53UxKJhJw')
|
||||
expect(res.status).toBe(200)
|
||||
const first = (await res.json()) as {
|
||||
kind: string
|
||||
giftId: number
|
||||
name: string
|
||||
count: number
|
||||
owned: number
|
||||
}
|
||||
expect(first).toMatchObject({ kind: 'consumable', name: 'Supreme Pizza', count: 1, owned: 1 })
|
||||
|
||||
let owned = await getConsumables(env.DB, 8430)
|
||||
expect(owned).toHaveLength(1)
|
||||
expect(owned[0]).toMatchObject({ ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw', Count: 1 })
|
||||
let gifts = await getPendingGifts(env.DB, 8430)
|
||||
expect(gifts).toHaveLength(1)
|
||||
expect(gifts[0]).toMatchObject({
|
||||
Id: first.giftId,
|
||||
FromPlayerId: 1,
|
||||
ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw',
|
||||
ConsumableCount: 1,
|
||||
ConsumableMappingId: owned[0].Ids[0],
|
||||
ConsumablePreExistingCount: 0,
|
||||
AvatarItemType: null,
|
||||
EquipmentModificationGuid: '',
|
||||
GiftRarity: 3,
|
||||
GiftContext: 500,
|
||||
Message: 'A gift from the staff!',
|
||||
})
|
||||
const frames = await hubFrames()
|
||||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8430, 31]])
|
||||
expect(frames[0].data).toMatchObject({
|
||||
Id: first.giftId,
|
||||
ConsumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw',
|
||||
AvatarItemType: null,
|
||||
})
|
||||
expect(await auditRows('gift_consumable', 8430)).toEqual([
|
||||
{
|
||||
actor: 8110,
|
||||
data: {
|
||||
playerId: 8430,
|
||||
consumableItemDesc: '0DsHcPhR_Eybn53UxKJhJw',
|
||||
name: 'Supreme Pizza',
|
||||
count: 1,
|
||||
message: 'A gift from the staff!',
|
||||
giftId: first.giftId,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Again, by the store's id this time: a second instance, and the box knows they had one.
|
||||
res = await giftItem(8430, '91010', 'Seconds?')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({ kind: 'consumable', owned: 2 })
|
||||
owned = await getConsumables(env.DB, 8430)
|
||||
expect(owned).toHaveLength(1)
|
||||
expect(owned[0]).toMatchObject({ Count: 2 })
|
||||
expect(owned[0].Ids).toHaveLength(2)
|
||||
gifts = await getPendingGifts(env.DB, 8430)
|
||||
expect(gifts).toHaveLength(2)
|
||||
expect(gifts.map((g) => g.ConsumablePreExistingCount ?? -1).sort((a, b) => a - b)).toEqual([0, 1])
|
||||
expect(gifts.map((g) => g.Message).sort()).toEqual(['A gift from the staff!', 'Seconds?'])
|
||||
|
||||
expect((await giftItem(8430, '0DsHcPhR_Eybn53UxKJhJw', 'x'.repeat(257))).status).toBe(400)
|
||||
expect((await giftItem(8393, '0DsHcPhR_Eybn53UxKJhJw')).status).toBe(404)
|
||||
expect(await getConsumables(env.DB, 8430)).toEqual(owned)
|
||||
})
|
||||
|
||||
// One id field, so the id decides the kind — and a UUID could be anything, so BOTH tables are
|
||||
// asked. A baked avatar item is a real row nothing here can grant, and an id in both tables
|
||||
// is nobody's to pick between.
|
||||
it('refuses an item gift for a baked avatar item, or an id that names two things', async () => {
|
||||
await updateAccount(env.DB, 8440, { username: 'Ambiguous' })
|
||||
await seedCatalogRow('Hat,0,0,', 91020, 'avatar_item', 'A Hat')
|
||||
const twin = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000005', 8441)
|
||||
await seedCatalogRow(twin.CustomAvatarItemId, 91021, 'skin', 'Twin Skin', '[Bow]')
|
||||
|
||||
let res = await giftItem(8440, 'Hat,0,0,')
|
||||
expect(res.status).toBe(400)
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/baked avatar item/)
|
||||
res = await giftItem(8440, '91020')
|
||||
expect(res.status).toBe(400)
|
||||
|
||||
res = await giftItem(8440, twin.CustomAvatarItemId)
|
||||
expect(res.status).toBe(409)
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/both/)
|
||||
// The catalog id names only the skin, so that way round it goes through.
|
||||
res = await giftItem(8440, '91021')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({ kind: 'skin', name: 'Twin Skin' })
|
||||
|
||||
expect(await getPendingGifts(env.DB, 8440)).toHaveLength(1)
|
||||
await expect(ownedCustomAvatarItemIds(env.DB, 8440, [twin.CustomAvatarItemId])).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
@@ -1767,7 +1996,10 @@ it('gifts a player XP in a gift box', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await updateAccount(env.DB, 8350, { username: 'Climber' })
|
||||
|
||||
const res = await devPost('/api/staff/players/8350/gift-xp', 8110, { amount: 25 })
|
||||
const res = await devPost('/api/staff/players/8350/gift-xp', 8110, {
|
||||
amount: 25,
|
||||
message: 'Keep climbing!',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { giftId: number }
|
||||
expect(body).toMatchObject({ level: 3, xp: 5, levelsGained: 2 })
|
||||
@@ -1780,7 +2012,9 @@ it('gifts a player XP in a gift box', async () => {
|
||||
FromPlayerId: 1,
|
||||
Xp: 25,
|
||||
Currency: 0,
|
||||
AvatarItemType: null,
|
||||
GiftContext: 500,
|
||||
Message: 'Keep climbing!',
|
||||
})
|
||||
|
||||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
@@ -1793,12 +2027,24 @@ it('gifts a player XP in a gift box', async () => {
|
||||
[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(frames[1].data).toMatchObject({
|
||||
Id: body.giftId,
|
||||
Xp: 25,
|
||||
GiftContext: 500,
|
||||
Message: 'Keep climbing!',
|
||||
})
|
||||
|
||||
expect(await auditRows('gift_xp', 8350)).toEqual([
|
||||
{
|
||||
actor: 8110,
|
||||
data: { playerId: 8350, amount: 25, level: 3, levelsGained: 2, giftId: body.giftId },
|
||||
data: {
|
||||
playerId: 8350,
|
||||
amount: 25,
|
||||
message: 'Keep climbing!',
|
||||
level: 3,
|
||||
levelsGained: 2,
|
||||
giftId: body.giftId,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -1808,6 +2054,14 @@ it('refuses an XP gift that is not a positive whole number, too large, or to nob
|
||||
for (const amount of [0, -5, 1.5, 'lots', 101]) {
|
||||
expect((await devPost('/api/staff/players/8351/gift-xp', 8110, { amount })).status).toBe(400)
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await devPost('/api/staff/players/8351/gift-xp', 8110, {
|
||||
amount: 5,
|
||||
message: 'x'.repeat(257),
|
||||
})
|
||||
).status
|
||||
).toBe(400)
|
||||
expect((await devPost('/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,7 @@ import {
|
||||
clearPasswordHandler,
|
||||
createReportHandler,
|
||||
getReportHandler,
|
||||
giftCustomItemHandler,
|
||||
giftItemHandler,
|
||||
giftOnlineTokensHandler,
|
||||
giftRoomTokensHandler,
|
||||
giftTokensHandler,
|
||||
@@ -486,7 +486,8 @@ const app = new Hono<App>()
|
||||
// The staff card on a player's profile page — account fixes the game has no endpoint for.
|
||||
// The gifts create value from nothing, so they are developer-only, not all staff.
|
||||
.post('/api/staff/players/:id/gift-tokens', requireDeveloper, giftTokensHandler)
|
||||
.post('/api/staff/players/:id/gift-custom-item', requireDeveloper, giftCustomItemHandler)
|
||||
// One id field for a custom avatar item, a skin or a consumable; the kind is resolved here.
|
||||
.post('/api/staff/players/:id/gift-item', requireDeveloper, giftItemHandler)
|
||||
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
|
||||
// Everyone standing in a room, across all of its instances.
|
||||
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
|
||||
|
||||
Reference in New Issue
Block a user