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
+510
View File
@@ -0,0 +1,510 @@
//! Configurable gift-to-content menu component.
//!
//! Menu entries live in the component's versioned JSON settings because they
//! are a small, ordered presentation configuration rather than an event log.
//! The projection is authoritative for trigger matching and emits only matched
//! item IDs; the browser never has to reinterpret Bilibili guard names.
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
use crate::{
components::{
ComponentDefinition, ComponentError, ComponentInstance, EventProjection, EventSubscription,
},
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload},
overlay::normalize_image_url,
};
pub const GIFT_MENU_KIND: &str = "gift_menu";
pub const GIFT_MENU_NAME: &str = "礼物菜单";
const MAX_MENU_ITEMS: usize = 100;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GiftMenuThemeId {
#[default]
JadeBanquet,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GuardLevel {
Captain,
Admiral,
Governor,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(
tag = "kind",
rename_all = "lowercase",
rename_all_fields = "camelCase"
)]
pub enum GiftMenuTrigger {
Gift {
gift_id: i64,
gift_name: String,
image_url: Option<String>,
unit_price: i64,
},
Guard {
level: GuardLevel,
},
Battery {
amount: i64,
},
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftMenuItem {
pub id: Uuid,
pub trigger: GiftMenuTrigger,
pub description: String,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftMenuSettings {
#[serde(default)]
pub theme_id: GiftMenuThemeId,
#[serde(default)]
pub items: Vec<GiftMenuItem>,
pub visible_rows: u8,
pub row_height: u16,
pub scroll_speed_pixels_per_second: u16,
pub highlight_duration_ms: u16,
pub font_scale: u16,
pub motion_intensity: u8,
pub low_performance_mode: bool,
}
impl Default for GiftMenuSettings {
fn default() -> Self {
Self {
theme_id: GiftMenuThemeId::default(),
items: Vec::new(),
visible_rows: 4,
row_height: 88,
scroll_speed_pixels_per_second: 28,
highlight_duration_ms: 3_800,
font_scale: 100,
motion_intensity: 78,
low_performance_mode: false,
}
}
}
impl GiftMenuSettings {
fn sanitize(mut self) -> Result<Self, String> {
if self.items.len() > MAX_MENU_ITEMS {
return Err(format!("gift menu accepts at most {MAX_MENU_ITEMS} items"));
}
let mut ids = HashSet::with_capacity(self.items.len());
let mut triggers = HashSet::with_capacity(self.items.len());
for item in &mut self.items {
if item.id.is_nil() || !ids.insert(item.id) {
return Err("gift menu item IDs must be unique and non-zero".into());
}
item.description = normalize_text(&item.description, 200)?;
let trigger_key = sanitize_trigger(&mut item.trigger)?;
if !triggers.insert(trigger_key) {
return Err("gift menu triggers must be unique".into());
}
}
self.visible_rows = self.visible_rows.clamp(1, 20);
self.row_height = self.row_height.clamp(44, 240);
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(240);
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
self.font_scale = self.font_scale.clamp(50, 220);
self.motion_intensity = self.motion_intensity.min(100);
Ok(self)
}
}
fn normalize_text(value: &str, max_chars: usize) -> Result<String, String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let count = normalized.chars().count();
if count == 0 || count > max_chars {
return Err(format!("text must contain 1-{max_chars} characters"));
}
Ok(normalized)
}
fn sanitize_trigger(trigger: &mut GiftMenuTrigger) -> Result<String, String> {
match trigger {
GiftMenuTrigger::Gift {
gift_id,
gift_name,
image_url,
unit_price,
} => {
if *gift_id <= 0 {
return Err("gift ID must be positive".into());
}
*gift_name = normalize_text(gift_name, 80)?;
*unit_price = (*unit_price).clamp(0, 1_000_000_000);
*image_url = image_url.as_deref().and_then(normalize_image_url);
Ok(format!("gift:{gift_id}"))
}
GiftMenuTrigger::Guard { level } => Ok(format!("guard:{level:?}")),
GiftMenuTrigger::Battery { amount } => {
if *amount <= 0 || *amount > 1_000_000_000 {
return Err("battery amount must be between 1 and 1000000000".into());
}
Ok(format!("battery:{amount}"))
}
}
}
pub struct GiftMenuDefinition;
impl GiftMenuDefinition {
fn parse(&self, settings: Value) -> Result<GiftMenuSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GiftMenuDefinition {
fn kind(&self) -> &'static str {
GIFT_MENU_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GiftMenuSettings::default())
.expect("GiftMenuSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
let settings =
self.parse(settings)?
.sanitize()
.map_err(|detail| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail,
})?;
serde_json::to_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail: error.to_string(),
})
}
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([
LiveEventKind::Gift,
LiveEventKind::GuardPurchase,
]))
}
}
#[derive(Default)]
pub struct GiftMenuProjection;
impl EventProjection for GiftMenuProjection {
fn project(
&self,
component: &ComponentInstance,
event: &LiveEvent,
) -> Result<Option<ComponentMessage>, ComponentError> {
let settings: GiftMenuSettings = serde_json::from_value(component.settings.clone())
.map_err(|error| ComponentError::Projection(error.to_string()))?;
let (matched, viewer) = match &event.payload {
LiveEventPayload::Gift(gift) => {
// Concrete gift identities take precedence over price tiers.
// Only fall back to battery matching when no configured gift
// ID (or name, when the event has no ID) matched.
let concrete = settings
.items
.iter()
.filter(|item| match &item.trigger {
GiftMenuTrigger::Gift {
gift_id, gift_name, ..
} => gift
.gift
.id
.map_or(gift.gift.name == *gift_name, |id| id == *gift_id),
_ => false,
})
.map(|item| item.id)
.collect::<Vec<_>>();
let matched = if concrete.is_empty() {
settings
.items
.iter()
.filter(|item| {
matches!(
&item.trigger,
GiftMenuTrigger::Battery { amount }
if gift.gift.battery_value == *amount
)
})
.map(|item| item.id)
.collect::<Vec<_>>()
} else {
concrete
};
(matched, &gift.viewer)
}
LiveEventPayload::GuardPurchase(guard) => {
let Some(level) = guard_level(&guard.guard_name) else {
return Ok(None);
};
(
settings
.items
.iter()
.filter(|item| {
matches!(&item.trigger, GiftMenuTrigger::Guard { level: candidate } if *candidate == level)
})
.map(|item| item.id)
.collect::<Vec<_>>(),
&guard.viewer,
)
}
_ => return Ok(None),
};
if matched.is_empty() {
return Ok(None);
}
let mut message = ComponentMessage::from_live_event(component.id, event)
.map_err(|error| ComponentError::Projection(error.to_string()))?;
message.event_type = "gift-menu.triggered".into();
message.payload = json!({
"itemIds": matched,
"viewer": viewer,
"sourceEventId": event.id,
});
Ok(Some(message))
}
}
fn guard_level(name: &str) -> Option<GuardLevel> {
let name = name.trim().to_lowercase();
if name.contains("总督") || name.contains("governor") {
Some(GuardLevel::Governor)
} else if name.contains("提督") || name.contains("admiral") {
Some(GuardLevel::Admiral)
} else if name.contains("舰长") || name.contains("captain") {
Some(GuardLevel::Captain)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEventPayload, PlatformViewer,
};
fn viewer() -> PlatformViewer {
PlatformViewer {
uid: "42".into(),
name: "观众".into(),
}
}
fn component(items: Vec<GiftMenuItem>) -> ComponentInstance {
let settings = GiftMenuSettings {
items,
..GiftMenuSettings::default()
};
ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
GIFT_MENU_KIND,
GIFT_MENU_NAME,
1,
serde_json::to_value(settings).unwrap(),
)
}
#[test]
fn settings_reject_duplicate_triggers_and_bound_renderer_values() {
let trigger = GiftMenuTrigger::Battery { amount: 150 };
let items = vec![
GiftMenuItem {
id: Uuid::new_v4(),
trigger: trigger.clone(),
description: "点歌".into(),
},
GiftMenuItem {
id: Uuid::new_v4(),
trigger,
description: "学歌".into(),
},
];
let definition = GiftMenuDefinition;
let settings = GiftMenuSettings {
items,
..GiftMenuSettings::default()
};
assert!(
definition
.validate_settings(serde_json::to_value(settings).unwrap())
.is_err()
);
let settings = GiftMenuSettings {
visible_rows: 255,
row_height: 1,
scroll_speed_pixels_per_second: u16::MAX,
font_scale: 1,
..GiftMenuSettings::default()
};
let sanitized = definition
.validate_settings(serde_json::to_value(settings).unwrap())
.unwrap();
assert_eq!(sanitized["visibleRows"], 20);
assert_eq!(sanitized["rowHeight"], 44);
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
assert_eq!(sanitized["fontScale"], 50);
}
#[test]
fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() {
let specific = Uuid::new_v4();
let battery = Uuid::new_v4();
let component = component(vec![
GiftMenuItem {
id: specific,
trigger: GiftMenuTrigger::Gift {
gift_id: 31039,
gift_name: "心动盲盒".into(),
image_url: None,
unit_price: 15_000,
},
description: "点歌".into(),
},
GiftMenuItem {
id: battery,
trigger: GiftMenuTrigger::Battery { amount: 150 },
description: "任选挑战".into(),
},
]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::Gift(GiftEvent {
viewer: viewer(),
gift: GiftDetails {
id: Some(31039),
name: "心动盲盒".into(),
coin_type: "gold".into(),
battery_value: crate::overlay::gift_price_to_batteries(15_000),
unit_price: 15_000,
total_price: 30_000,
price_cny: 30.0,
image_url: None,
animation_url: None,
effect_type: None,
stay_time: None,
},
quantity: 2,
source_event_id: "event".into(),
}),
);
let message = GiftMenuProjection
.project(&component, &event)
.unwrap()
.unwrap();
assert_eq!(message.event_type, "gift-menu.triggered");
assert_eq!(message.payload["itemIds"], json!([specific]));
}
#[test]
fn gift_projection_falls_back_to_the_unit_battery_value() {
let battery = Uuid::new_v4();
let component = component(vec![
GiftMenuItem {
id: Uuid::new_v4(),
trigger: GiftMenuTrigger::Gift {
gift_id: 999,
gift_name: "其他礼物".into(),
image_url: None,
unit_price: 15_000,
},
description: "其他内容".into(),
},
GiftMenuItem {
id: battery,
trigger: GiftMenuTrigger::Battery { amount: 150 },
description: "任选挑战".into(),
},
]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::Gift(GiftEvent {
viewer: viewer(),
gift: GiftDetails {
id: Some(31039),
name: "心动盲盒".into(),
coin_type: "gold".into(),
battery_value: 150,
unit_price: 15_000,
total_price: 15_000,
price_cny: 15.0,
image_url: None,
animation_url: None,
effect_type: None,
stay_time: None,
},
quantity: 1,
source_event_id: "event".into(),
}),
);
let message = GiftMenuProjection
.project(&component, &event)
.unwrap()
.unwrap();
assert_eq!(message.payload["itemIds"], json!([battery]));
}
#[test]
fn guard_projection_normalizes_all_membership_levels() {
let id = Uuid::new_v4();
let component = component(vec![GiftMenuItem {
id,
trigger: GiftMenuTrigger::Guard {
level: GuardLevel::Admiral,
},
description: "专属节目".into(),
}]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
viewer: viewer(),
guard_name: "提督".into(),
quantity: 1,
price: 199_800,
}),
);
assert!(
GiftMenuProjection
.project(&component, &event)
.unwrap()
.is_some()
);
}
}