add full-screen gift effects

This commit is contained in:
2026-07-19 00:39:56 -07:00
parent f79852d8e6
commit c4eca7b8bf
23 changed files with 1666 additions and 33 deletions
+2 -1
View File
@@ -81,7 +81,7 @@ impl AppState {
let repository =
TenantRepository::new(db.clone(), registry.clone(), component_cache.clone());
repository
.ensure_song_request_components()
.ensure_builtin_components()
.await
.map_err(|error| error.to_string())?;
repository
@@ -291,6 +291,7 @@ async fn migrate(db: &Db) -> Result<(), String> {
8_i32,
include_str!("../migrations/008_account_language.sql"),
),
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
] {
let applied = transaction
.query_one(
+18
View File
@@ -26,6 +26,7 @@ use uuid::Uuid;
use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
db::{Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
i18n,
overlay::OverlaySettings,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -650,6 +651,23 @@ impl AuthService {
&[&user_id, &song_component_id],
)
.await?;
let gift_component_id = Uuid::new_v4();
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
.expect("GiftEffectSettings is always JSON serializable");
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&gift_component_id,
&user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
)
.await?;
transaction
.execute(
"DELETE FROM pending_registrations WHERE id=$1",
+26
View File
@@ -20,6 +20,7 @@ use uuid::Uuid;
use crate::{
domain::{ComponentMessage, LiveEvent, LiveEventKind},
gift_effect::GiftEffectDefinition,
overlay::OverlaySettings,
song_request::{SongRequestDefinition, SongRequestProjection},
};
@@ -382,6 +383,12 @@ impl ComponentRegistry {
)
.expect("built-in component kinds are unique");
registry
.register(
Arc::new(GiftEffectDefinition),
Arc::new(PassthroughProjection),
)
.expect("built-in component kinds are unique");
registry
}
pub fn register(
@@ -548,6 +555,25 @@ mod tests {
assert!(!subscriptions.contains(LiveEventKind::Gift));
}
#[test]
fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() {
let registry = ComponentRegistry::default();
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
let runtime = registry.runtime("gift_effect").unwrap();
let instance = ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
"gift_effect",
"礼物星雨",
1,
runtime.definition().default_settings(),
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn duplicate_component_kinds_are_rejected() {
let registry = ComponentRegistry::default();
+178
View File
@@ -0,0 +1,178 @@
//! Full-screen gift and membership effect component.
//!
//! The component is deliberately passive: it subscribes to canonical gift and
//! guard-purchase events and projects them to its own authenticated OBS stream.
//! All visual differentiation is settings-driven in the browser, so receiving
//! an effect never creates database writes or depends on an OBS connection.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
components::{ComponentDefinition, ComponentError, EventSubscription},
domain::LiveEventKind,
};
pub const GIFT_EFFECT_KIND: &str = "gift_effect";
pub const GIFT_EFFECT_NAME: &str = "礼物星雨";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GiftEffectThemeId {
#[default]
JadeStarfall,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeteorTierSettings {
pub count: u8,
pub size: u16,
pub speed: u16,
}
impl MeteorTierSettings {
fn sanitize(mut self) -> Self {
self.count = self.count.clamp(1, 24);
self.size = self.size.clamp(24, 480);
self.speed = self.speed.clamp(100, 2_500);
self
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftEffectSettings {
#[serde(default)]
pub theme_id: GiftEffectThemeId,
pub high_value_threshold: i64,
pub featured_value_threshold: i64,
pub normal: MeteorTierSettings,
pub high: MeteorTierSettings,
pub featured: MeteorTierSettings,
pub trail_intensity: u8,
pub guard_star_count: u8,
pub guard_effect_duration_ms: u16,
pub max_concurrent_effects: u8,
pub low_performance_mode: bool,
}
impl Default for GiftEffectSettings {
fn default() -> Self {
Self {
theme_id: GiftEffectThemeId::default(),
high_value_threshold: 10_000,
featured_value_threshold: 100_000,
normal: MeteorTierSettings {
count: 3,
size: 88,
speed: 560,
},
high: MeteorTierSettings {
count: 6,
size: 126,
speed: 720,
},
featured: MeteorTierSettings {
count: 10,
size: 168,
speed: 880,
},
trail_intensity: 78,
guard_star_count: 48,
guard_effect_duration_ms: 5_200,
max_concurrent_effects: 8,
low_performance_mode: false,
}
}
}
impl GiftEffectSettings {
pub fn sanitize(mut self) -> Self {
self.high_value_threshold = self.high_value_threshold.max(0);
self.featured_value_threshold =
self.featured_value_threshold.max(self.high_value_threshold);
self.normal = self.normal.sanitize();
self.high = self.high.sanitize();
self.featured = self.featured.sanitize();
self.trail_intensity = self.trail_intensity.min(100);
self.guard_star_count = self.guard_star_count.clamp(8, 96);
self.guard_effect_duration_ms = self.guard_effect_duration_ms.clamp(1_000, 15_000);
self.max_concurrent_effects = self.max_concurrent_effects.clamp(1, 12);
self
}
}
pub struct GiftEffectDefinition;
impl GiftEffectDefinition {
fn parse(&self, settings: Value) -> Result<GiftEffectSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_EFFECT_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GiftEffectDefinition {
fn kind(&self) -> &'static str {
GIFT_EFFECT_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GiftEffectSettings::default())
.expect("GiftEffectSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
ComponentError::InvalidSettings {
kind: GIFT_EFFECT_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,
]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_bound_each_value_tier_and_keep_threshold_order() {
let definition = GiftEffectDefinition;
let mut settings = definition.default_settings();
settings["normal"]["count"] = Value::from(0);
settings["featured"]["size"] = Value::from(9_999);
settings["highValueThreshold"] = Value::from(50_000);
settings["featuredValueThreshold"] = Value::from(10_000);
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["normal"]["count"], 1);
assert_eq!(sanitized["featured"]["size"], 480);
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
}
#[test]
fn component_only_subscribes_to_durable_gifts_and_guards() {
let definition = GiftEffectDefinition;
let subscriptions = definition
.subscriptions(&definition.default_settings())
.unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
}
}
+21 -1
View File
@@ -34,7 +34,7 @@ use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie},
domain::{
COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent,
GiftDetails, GiftEvent, LiveEvent, LiveEventPayload, PlatformViewer,
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEvent, LiveEventPayload, PlatformViewer,
},
repository::{ComponentView, RepositoryError},
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
@@ -910,6 +910,14 @@ enum TestEventRequest {
battery: i32,
quantity: i32,
},
Guard {
uid: String,
name: String,
#[serde(rename = "guardName")]
guard_name: String,
quantity: i32,
price: i64,
},
}
async fn component_test_event(
@@ -959,6 +967,18 @@ async fn component_test_event(
source_event_id: format!("test-{}", Uuid::new_v4()),
})
}
TestEventRequest::Guard {
uid,
name,
guard_name,
quantity,
price,
} => LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
viewer: viewer(uid, name),
guard_name,
quantity: quantity.max(1),
price: price.max(0),
}),
};
let mut event = LiveEvent::new(
component.owner_id,
+1
View File
@@ -12,6 +12,7 @@ pub mod config;
pub mod credentials;
pub mod db;
pub mod domain;
pub mod gift_effect;
pub mod http_api;
pub mod i18n;
pub mod live;
+23 -6
View File
@@ -15,6 +15,7 @@ use uuid::Uuid;
use crate::{
components::{ComponentInstance, ComponentRegistry},
db::{ComponentRecord, Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
i18n,
realtime::InMemoryComponentStore,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
@@ -47,10 +48,10 @@ impl TenantRepository {
Ok(())
}
/// Ensure every active tenant has the built-in singleton song component.
/// The partial unique index makes this safe across concurrent application
/// starts; the state row is repaired independently for existing instances.
pub async fn ensure_song_request_components(&self) -> Result<(), RepositoryError> {
/// Ensure every active tenant has every required singleton component.
/// Partial unique indexes make concurrent starts idempotent. The song
/// component additionally owns a relational revision state row.
pub async fn ensure_builtin_components(&self) -> Result<(), RepositoryError> {
for tenant in self.db.list_active_tenants().await? {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
@@ -96,6 +97,22 @@ impl TenantRepository {
&[&tenant.user_id, &component_id],
)
.await?;
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
)
.await?;
transaction.commit().await?;
}
Ok(())
@@ -131,7 +148,7 @@ impl TenantRepository {
// Every tenant receives this singleton during registration/startup.
// Keeping creation internal prevents a second instance from racing the
// partial unique index and turning a domain conflict into a DB error.
if kind == SONG_REQUEST_KIND {
if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND) {
return Err(RepositoryError::Forbidden);
}
let runtime = self
@@ -192,7 +209,7 @@ impl TenantRepository {
.await?
.ok_or(RepositoryError::NotFound)?
.get(0);
if kind == SONG_REQUEST_KIND {
if matches!(kind.as_str(), SONG_REQUEST_KIND | GIFT_EFFECT_KIND) {
return Err(RepositoryError::Forbidden);
}
let changed = transaction