532 lines
18 KiB
Rust
532 lines
18 KiB
Rust
//! 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,
|
|
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
|
};
|
|
|
|
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,
|
|
MoonlitWater,
|
|
}
|
|
|
|
#[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 font_family: FontFamilyId,
|
|
#[serde(default = "default_font_brightness")]
|
|
pub font_brightness: u16,
|
|
#[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(),
|
|
font_family: FontFamilyId::default(),
|
|
font_brightness: default_font_brightness(),
|
|
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.font_brightness = sanitize_font_brightness(self.font_brightness);
|
|
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(),
|
|
avatar_url: None,
|
|
}
|
|
}
|
|
|
|
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,
|
|
font_brightness: 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);
|
|
assert_eq!(sanitized["fontBrightness"], 70);
|
|
}
|
|
|
|
#[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()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn moonlit_water_theme_is_accepted_and_serialized() {
|
|
let definition = GiftMenuDefinition;
|
|
let mut settings = definition.default_settings();
|
|
settings["themeId"] = Value::from("moonlit-water");
|
|
let sanitized = definition.validate_settings(settings).unwrap();
|
|
assert_eq!(sanitized["themeId"], "moonlit-water");
|
|
}
|
|
}
|