add configurable gift menu overlays

Add tenant-scoped gift menu settings, catalog-backed triggers, infinite OBS rendering, and guard assets. Normalize legacy and protobuf gift values for blind-box, battery-tier, and transaction-aware matching.
This commit is contained in:
2026-07-21 19:41:49 -07:00
parent c4eca7b8bf
commit 716c6f3f2f
29 changed files with 2764 additions and 91 deletions
+89
View File
@@ -12,6 +12,9 @@ import type {
ComponentSummary,
CookieCloudSource,
GiftEffectSettings,
GiftCatalogItem,
GiftMenuItem,
GiftMenuSettings,
Invitation,
OverlaySettings,
Session,
@@ -22,8 +25,10 @@ import type {
} from './types'
import { normalizeThemeId } from './themes'
import { normalizeGiftEffectThemeId } from './giftThemes'
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
import {
defaultGiftEffectSettings,
defaultGiftMenuSettings,
defaultOverlaySettings,
defaultSongRequestSettings,
} from './types'
@@ -236,6 +241,90 @@ export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings
}
}
function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined {
const item = object(value)
const trigger = object(item.trigger)
if (typeof item.id !== 'string' || typeof item.description !== 'string') return undefined
if (trigger.kind === 'gift') {
const giftId = Number(trigger.giftId)
if (!Number.isSafeInteger(giftId) || giftId <= 0 || typeof trigger.giftName !== 'string')
return undefined
return {
id: item.id,
description: item.description,
trigger: {
kind: 'gift',
giftId,
giftName: trigger.giftName,
unitPrice: Number(trigger.unitPrice ?? 0),
imageUrl: typeof trigger.imageUrl === 'string' ? trigger.imageUrl : undefined,
},
}
}
if (
trigger.kind === 'guard' &&
['captain', 'admiral', 'governor'].includes(String(trigger.level))
) {
return {
id: item.id,
description: item.description,
trigger: {
kind: 'guard',
level: trigger.level as 'captain' | 'admiral' | 'governor',
},
}
}
const amount = Number(trigger.amount)
if (trigger.kind === 'battery' && Number.isSafeInteger(amount) && amount > 0) {
return {
id: item.id,
description: item.description,
trigger: { kind: 'battery', amount },
}
}
return undefined
}
export function normalizeGiftMenuSettings(value: unknown): GiftMenuSettings {
const root = object(value)
const settings = object(root.settings ?? value)
const items = Array.isArray(settings.items)
? settings.items.map(normalizeGiftMenuItem).filter(item => item !== undefined)
: []
return {
...defaultGiftMenuSettings,
...(settings as Partial<GiftMenuSettings>),
themeId: normalizeGiftMenuThemeId(settings.themeId),
items,
}
}
export function normalizeGiftCatalog(value: unknown): GiftCatalogItem[] {
const root = object(value)
if (!Array.isArray(root.gifts)) return []
return root.gifts.flatMap(candidate => {
const gift = object(candidate)
const id = Number(gift.id)
if (!Number.isSafeInteger(id) || id <= 0 || typeof gift.name !== 'string') return []
return [
{
id,
name: gift.name,
coinType: String(gift.coinType ?? 'gold'),
batteryValue: Number(gift.batteryValue ?? Number(gift.unitPrice ?? 0) / 100),
unitPrice: Number(gift.unitPrice ?? 0),
imageUrl:
typeof gift.imageUrl === 'string'
? gift.imageUrl
: typeof gift.animationUrl === 'string'
? gift.animationUrl
: undefined,
animationUrl: typeof gift.animationUrl === 'string' ? gift.animationUrl : undefined,
},
]
})
}
export function normalizeSongRequestItem(value: unknown): SongRequestItem | undefined {
const item = object(value)
const requester = object(item.requester)