add full-screen gift effects
This commit is contained in:
@@ -22,6 +22,7 @@ crate 导出。
|
||||
| `realtime` | account event routing 与 component-scoped fanout |
|
||||
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
||||
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
|
||||
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
|
||||
|
||||
## 重要不变量
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Every account owns exactly one built-in full-screen gift-effect component.
|
||||
-- Existing accounts are backfilled from the validated Rust defaults during
|
||||
-- startup; the partial index keeps concurrent starts idempotent.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS component_instances_single_gift_effect
|
||||
ON component_instances(owner_user_id, kind)
|
||||
WHERE kind = 'gift_effect';
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user