1072 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"slices"
"strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
)
const (
roomRocketLevelCount = 5
roomRocketDefaultFuelSource = "heat_value"
roomRocketDefaultLaunchDelayMS = int64(30_000)
roomRocketDefaultBroadcastScope = "region"
roomRocketDefaultRewardStackPolicy = "allow_stack"
roomRocketFuelHeatValue = "heat_value"
roomRocketBroadcastNone = "none"
roomRocketBroadcastRegion = "region"
roomRocketBroadcastGlobal = "global"
roomRocketRewardStackAllow = "allow_stack"
roomRocketRewardStackPriority = "priority_only"
roomRocketRewardInRoom = "in_room"
roomRocketRewardTop1 = "top1"
roomRocketRewardIgniter = "igniter"
roomRocketGrantSource = "room_rocket"
roomRocketGrantStatus = "succeeded"
)
var roomRocketDefaultThresholds = []int64{1_000, 3_000, 6_000, 10_000, 15_000}
// GetRoomRocket 返回房间火箭 UI 初始化数据;它只读 Room Cell不刷新 presence。
func (s *Service) GetRoomRocket(ctx context.Context, req *roomv1.GetRoomRocketRequest) (*roomv1.GetRoomRocketResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
roomID := strings.TrimSpace(req.GetRoomId())
viewerUserID := req.GetViewerUserId()
if !roomid.ValidStringID(roomID) {
return nil, xerr.New(xerr.InvalidArgument, "room_id is invalid")
}
if viewerUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
}
snapshot, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return nil, err
}
if snapshot == nil || snapshot.GetRoomId() == "" {
return nil, xerr.New(xerr.NotFound, "room not found")
}
if snapshot.GetStatus() != state.RoomStatusActive {
return nil, xerr.New(xerr.RoomClosed, "room closed")
}
if snapshotUserBanned(snapshot, viewerUserID, s.clock.Now().UnixMilli()) || findProtoUser(snapshot, viewerUserID) == nil {
// 火箭状态属于房间内活动信息,只允许仍在业务 presence 内的用户读取。
return nil, xerr.New(xerr.PermissionDenied, "viewer is not in room")
}
config, err := s.roomRocketConfig(ctx)
if err != nil {
return nil, err
}
now := s.clock.Now().UTC()
info := roomRocketInfoFromConfig(config, snapshot.GetRocket(), now)
return &roomv1.GetRoomRocketResponse{
Rocket: info,
ServerTimeMs: now.UnixMilli(),
}, nil
}
func (s *Service) roomRocketConfig(ctx context.Context) (RoomRocketConfig, error) {
config, exists, err := s.repository.GetRoomRocketConfig(ctx)
if err != nil {
return RoomRocketConfig{}, err
}
if !exists {
config = defaultRoomRocketConfig(appcode.FromContext(ctx))
}
return normalizeRoomRocketConfig(config)
}
func defaultRoomRocketConfig(appCode string) RoomRocketConfig {
levels := make([]RoomRocketLevelConfig, 0, roomRocketLevelCount)
for index, threshold := range roomRocketDefaultThresholds {
levels = append(levels, RoomRocketLevelConfig{
Level: int32(index + 1),
FuelThreshold: threshold,
})
}
return RoomRocketConfig{
AppCode: appcode.Normalize(appCode),
FuelSource: roomRocketDefaultFuelSource,
LaunchDelayMS: roomRocketDefaultLaunchDelayMS,
BroadcastEnabled: true,
BroadcastScope: roomRocketDefaultBroadcastScope,
RewardStackPolicy: roomRocketDefaultRewardStackPolicy,
Levels: levels,
}
}
func normalizeRoomRocketConfig(config RoomRocketConfig) (RoomRocketConfig, error) {
config.AppCode = appcode.Normalize(config.AppCode)
if config.AppCode == "" {
config.AppCode = appcode.Default
}
if config.ConfigVersion < 0 {
return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket config_version is invalid")
}
config.FuelSource = defaultRoomRocketString(config.FuelSource, roomRocketDefaultFuelSource)
if config.FuelSource != roomRocketFuelHeatValue {
return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket fuel_source is invalid")
}
if config.LaunchDelayMS < 0 || config.BroadcastDelayMS < 0 {
return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket delay is invalid")
}
config.BroadcastScope = defaultRoomRocketString(config.BroadcastScope, roomRocketDefaultBroadcastScope)
if config.BroadcastScope != roomRocketBroadcastNone && config.BroadcastScope != roomRocketBroadcastRegion && config.BroadcastScope != roomRocketBroadcastGlobal {
return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket broadcast_scope is invalid")
}
if !config.BroadcastEnabled {
config.BroadcastScope = roomRocketBroadcastNone
}
config.RewardStackPolicy = defaultRoomRocketString(config.RewardStackPolicy, roomRocketDefaultRewardStackPolicy)
if config.RewardStackPolicy != roomRocketRewardStackAllow && config.RewardStackPolicy != roomRocketRewardStackPriority {
return RoomRocketConfig{}, xerr.New(xerr.InvalidArgument, "room rocket reward_stack_policy is invalid")
}
levels, err := normalizeRoomRocketLevels(config.Levels)
if err != nil {
return RoomRocketConfig{}, err
}
config.Levels = levels
config.GiftFuelRules = normalizeRoomRocketFuelRules(config.GiftFuelRules)
return config, nil
}
func normalizeRoomRocketLevels(levels []RoomRocketLevelConfig) ([]RoomRocketLevelConfig, error) {
if len(levels) == 0 {
return defaultRoomRocketConfig(appcode.Default).Levels, nil
}
if len(levels) != roomRocketLevelCount {
return nil, xerr.New(xerr.InvalidArgument, "room rocket levels must be 5")
}
seen := make(map[int32]bool, roomRocketLevelCount)
normalized := make([]RoomRocketLevelConfig, 0, roomRocketLevelCount)
for _, level := range levels {
if level.Level < 1 || level.Level > roomRocketLevelCount || seen[level.Level] {
return nil, xerr.New(xerr.InvalidArgument, "room rocket level is invalid")
}
if level.FuelThreshold <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "room rocket fuel_threshold is invalid")
}
seen[level.Level] = true
level.CoverURL = strings.TrimSpace(level.CoverURL)
level.AnimationURL = strings.TrimSpace(level.AnimationURL)
level.LaunchAnimationURL = strings.TrimSpace(level.LaunchAnimationURL)
level.LaunchedImageURL = strings.TrimSpace(level.LaunchedImageURL)
level.InRoomRewards = normalizeRoomRocketRewardPool(level.InRoomRewards)
level.Top1Rewards = normalizeRoomRocketRewardPool(level.Top1Rewards)
level.IgniterRewards = normalizeRoomRocketRewardPool(level.IgniterRewards)
normalized = append(normalized, level)
}
slices.SortFunc(normalized, func(left, right RoomRocketLevelConfig) int {
return int(left.Level - right.Level)
})
return normalized, nil
}
func normalizeRoomRocketRewardPool(input []RoomRocketRewardItem) []RoomRocketRewardItem {
out := make([]RoomRocketRewardItem, 0, len(input))
for _, reward := range input {
reward.RewardItemID = strings.TrimSpace(reward.RewardItemID)
reward.DisplayName = strings.TrimSpace(reward.DisplayName)
reward.IconURL = strings.TrimSpace(reward.IconURL)
if reward.ResourceGroupID <= 0 || reward.Weight <= 0 {
// 后台保存层会拒绝非法奖励;运行时跳过历史脏项,避免一个奖励池配置污染送礼主链路。
continue
}
out = append(out, reward)
}
return out
}
func normalizeRoomRocketFuelRules(input []GiftFuelRuleConfig) []GiftFuelRuleConfig {
out := make([]GiftFuelRuleConfig, 0, len(input))
for _, rule := range input {
rule.RuleID = strings.TrimSpace(rule.RuleID)
rule.GiftID = strings.TrimSpace(rule.GiftID)
rule.GiftTypeCode = strings.TrimSpace(rule.GiftTypeCode)
if rule.GiftID == "" && rule.GiftTypeCode == "" {
continue
}
if rule.MultiplierPPM < 0 || rule.FixedFuel < 0 {
continue
}
out = append(out, rule)
}
return out
}
func defaultRoomRocketString(value string, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
return value
}
func roomRocketInfoFromConfig(config RoomRocketConfig, current *roomv1.RoomRocketState, now time.Time) *roomv1.RoomRocketInfo {
stateView := roomRocketProtoStateForView(current, config, now)
return &roomv1.RoomRocketInfo{
Enabled: config.Enabled,
Levels: roomRocketLevelsToProto(config.Levels),
State: stateView,
ServerTimeMs: now.UnixMilli(),
BroadcastScope: config.BroadcastScope,
LaunchDelayMs: config.LaunchDelayMS,
BroadcastDelayMs: config.BroadcastDelayMS,
RewardStackPolicy: config.RewardStackPolicy,
}
}
func roomRocketProtoStateForView(current *roomv1.RoomRocketState, config RoomRocketConfig, now time.Time) *roomv1.RoomRocketState {
internal := state.RocketState{}
if current != nil {
internal = state.RocketState{
CurrentLevel: current.GetCurrentLevel(),
CurrentFuel: current.GetCurrentFuel(),
FuelThreshold: current.GetFuelThreshold(),
Status: current.GetStatus(),
IgnitedAtMS: current.GetIgnitedAtMs(),
LaunchAtMS: current.GetLaunchAtMs(),
LaunchedAtMS: current.GetLaunchedAtMs(),
ResetAtMS: current.GetResetAtMs(),
Top1UserID: current.GetTop1UserId(),
IgniterUserID: current.GetIgniterUserId(),
RocketID: current.GetRocketId(),
ConfigVersion: current.GetConfigVersion(),
PendingLaunches: statePendingLaunchesFromProto(current.GetPendingLaunches()),
LastRewards: stateRewardsFromProto(current.GetLastRewards()),
}
}
normalized := rocketStateForNow(&internal, config, now, false)
return rocketStateToRoomProto(normalized)
}
func stateRewardsFromProto(input []*roomv1.RoomRocketRewardGrant) []state.RocketRewardGrant {
out := make([]state.RocketRewardGrant, 0, len(input))
for _, reward := range input {
out = append(out, state.RocketRewardGrant{
RewardRole: reward.GetRewardRole(),
UserID: reward.GetUserId(),
RewardItemID: reward.GetRewardItemId(),
ResourceGroupID: reward.GetResourceGroupId(),
DisplayName: reward.GetDisplayName(),
IconURL: reward.GetIconUrl(),
GrantID: reward.GetGrantId(),
Status: reward.GetStatus(),
})
}
return out
}
func statePendingLaunchesFromProto(input []*roomv1.RoomRocketPendingLaunch) []state.RocketPendingLaunch {
out := make([]state.RocketPendingLaunch, 0, len(input))
for _, launch := range input {
out = append(out, state.RocketPendingLaunch{
RocketID: launch.GetRocketId(),
Level: launch.GetLevel(),
FuelThreshold: launch.GetFuelThreshold(),
IgnitedAtMS: launch.GetIgnitedAtMs(),
LaunchAtMS: launch.GetLaunchAtMs(),
ResetAtMS: launch.GetResetAtMs(),
Top1UserID: launch.GetTop1UserId(),
IgniterUserID: launch.GetIgniterUserId(),
ConfigVersion: launch.GetConfigVersion(),
CoverURL: launch.GetCoverUrl(),
})
}
return out
}
func roomRocketLevelsToProto(levels []RoomRocketLevelConfig) []*roomv1.RoomRocketLevel {
out := make([]*roomv1.RoomRocketLevel, 0, len(levels))
for _, level := range levels {
out = append(out, &roomv1.RoomRocketLevel{
Level: level.Level,
FuelThreshold: level.FuelThreshold,
CoverUrl: level.CoverURL,
AnimationUrl: level.AnimationURL,
LaunchAnimationUrl: level.LaunchAnimationURL,
LaunchedImageUrl: level.LaunchedImageURL,
InRoomRewards: roomRocketRewardsToProto(level.InRoomRewards),
Top1Rewards: roomRocketRewardsToProto(level.Top1Rewards),
IgniterRewards: roomRocketRewardsToProto(level.IgniterRewards),
})
}
return out
}
func roomRocketRewardsToProto(input []RoomRocketRewardItem) []*roomv1.RoomRocketRewardItem {
out := make([]*roomv1.RoomRocketRewardItem, 0, len(input))
for _, reward := range input {
out = append(out, &roomv1.RoomRocketRewardItem{
RewardItemId: reward.RewardItemID,
ResourceGroupId: reward.ResourceGroupID,
Weight: reward.Weight,
DisplayName: reward.DisplayName,
IconUrl: reward.IconURL,
})
}
return out
}
type rocketGiftApplyResult struct {
touched bool
progressEvent *roomeventsv1.RoomRocketFuelChanged
ignited *roomeventsv1.RoomRocketIgnited
}
func (s *Service) applyRoomRocketGift(now time.Time, current *state.RoomState, cfg RoomRocketConfig, cmd command.SendGift, settled *command.SendGift, billing *walletv1.DebitGiftResponse, roomMeta RoomMeta) (rocketGiftApplyResult, error) {
if !cfg.Enabled {
return rocketGiftApplyResult{}, nil
}
now = now.UTC()
nowMS := now.UnixMilli()
rocket := rocketStateForNow(current.Rocket, cfg, now, true)
result := rocketGiftApplyResult{touched: current.Rocket == nil || rocket.ResetAtMS != current.Rocket.ResetAtMS}
addedFuel := roomRocketFuel(cfg, cmd.GiftID, billing.GetGiftTypeCode(), billing.GetHeatValue())
effectiveFuel := int64(0)
overflowFuel := int64(0)
progressRocketID := rocket.RocketID
progressLevel := rocket.CurrentLevel
progressFuel := rocket.CurrentFuel
progressThreshold := rocket.FuelThreshold
progressStatus := rocket.Status
if rocket.Status == state.RocketStatusCompleted {
// 当天最高 5 级已满后,继续送礼不再产生新火箭;整笔燃料按产品规则直接作废。
overflowFuel = addedFuel
} else if addedFuel > 0 {
remaining := rocket.FuelThreshold - rocket.CurrentFuel
if remaining < 0 {
remaining = 0
}
effectiveFuel = minInt64(addedFuel, remaining)
overflowFuel = addedFuel - effectiveFuel
if effectiveFuel > 0 {
rocket.CurrentFuel += effectiveFuel
progressFuel = rocket.CurrentFuel
result.touched = true
}
if rocket.CurrentFuel >= rocket.FuelThreshold {
levelCfg := roomRocketLevelByNumber(cfg, rocket.CurrentLevel)
pending := state.RocketPendingLaunch{
RocketID: rocket.RocketID,
Level: rocket.CurrentLevel,
FuelThreshold: rocket.FuelThreshold,
IgnitedAtMS: nowMS,
LaunchAtMS: nowMS + cfg.LaunchDelayMS,
ResetAtMS: rocket.ResetAtMS,
Top1UserID: firstRankUserID(current.GiftRank),
IgniterUserID: cmd.ActorUserID(),
ConfigVersion: cfg.ConfigVersion,
CoverURL: levelCfg.CoverURL,
}
rocket.CurrentFuel = rocket.FuelThreshold
rocket.IgnitedAtMS = pending.IgnitedAtMS
rocket.LaunchAtMS = pending.LaunchAtMS
rocket.Top1UserID = pending.Top1UserID
rocket.IgniterUserID = pending.IgniterUserID
rocket.ConfigVersion = cfg.ConfigVersion
rocket.PendingLaunches = append(rocket.PendingLaunches, pending)
progressRocketID = pending.RocketID
progressLevel = pending.Level
progressFuel = pending.FuelThreshold
progressThreshold = pending.FuelThreshold
progressStatus = state.RocketStatusIgnited
result.ignited = &roomeventsv1.RoomRocketIgnited{
RocketId: pending.RocketID,
Level: pending.Level,
CurrentFuel: pending.FuelThreshold,
FuelThreshold: pending.FuelThreshold,
IgnitedAtMs: pending.IgnitedAtMS,
LaunchAtMs: pending.LaunchAtMS,
BroadcastScope: cfg.BroadcastScope,
VisibleRegionId: roomMeta.VisibleRegionID,
Top1UserId: pending.Top1UserID,
IgniterUserId: pending.IgniterUserID,
CommandId: cmd.ID(),
RoomShortId: roomMeta.RoomShortID,
RocketCoverUrl: pending.CoverURL,
ResetAtMs: pending.ResetAtMS,
}
if rocket.CurrentLevel < roomRocketLevelCount {
nextLevel := rocket.CurrentLevel + 1
nextLevelCfg := roomRocketLevelByNumber(cfg, nextLevel)
rocket.CurrentLevel = nextLevel
rocket.CurrentFuel = 0
rocket.FuelThreshold = nextLevelCfg.FuelThreshold
rocket.Status = state.RocketStatusCharging
rocket.RocketID = idgen.New("room_rocket")
} else {
// 5 级是当天最高等级;点火后不再生成第 6 级UTC 日界统一重置回 1 级。
rocket.Status = state.RocketStatusCompleted
rocket.RocketID = ""
}
}
}
if result.touched {
current.Rocket = rocket
}
if effectiveFuel > 0 {
result.progressEvent = &roomeventsv1.RoomRocketFuelChanged{
RocketId: progressRocketID,
Level: progressLevel,
AddedFuel: addedFuel,
EffectiveAddedFuel: effectiveFuel,
OverflowFuel: overflowFuel,
CurrentFuel: progressFuel,
FuelThreshold: progressThreshold,
Status: progressStatus,
ResetAtMs: rocket.ResetAtMS,
SenderUserId: cmd.ActorUserID(),
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
CommandId: cmd.ID(),
VisibleRegionId: roomMeta.VisibleRegionID,
}
}
if result.touched {
settled.GiftTypeCode = billing.GetGiftTypeCode()
settled.RocketTouched = true
settled.RocketAddedFuel = addedFuel
settled.RocketEffectiveFuel = effectiveFuel
settled.RocketOverflowFuel = overflowFuel
settled.RocketLevel = rocket.CurrentLevel
settled.RocketFuel = rocket.CurrentFuel
settled.RocketStatus = rocket.Status
settled.RocketFuelThreshold = rocket.FuelThreshold
settled.RocketIgnitedAtMS = rocket.IgnitedAtMS
settled.RocketLaunchAtMS = rocket.LaunchAtMS
settled.RocketResetAtMS = rocket.ResetAtMS
settled.RocketTop1UserID = rocket.Top1UserID
settled.RocketIgniterUserID = rocket.IgniterUserID
settled.RocketID = rocket.RocketID
settled.RocketConfigVersion = rocket.ConfigVersion
settled.RocketPendingLaunches = rocketPendingLaunchesToCommand(rocket.PendingLaunches)
}
return result, nil
}
func rocketStateForNow(input *state.RocketState, cfg RoomRocketConfig, now time.Time, allocateRocketID bool) *state.RocketState {
now = now.UTC()
nowMS := now.UnixMilli()
resetAtMS := nextUTCResetMS(now)
level := int32(1)
if input != nil && input.CurrentLevel >= 1 && input.CurrentLevel <= roomRocketLevelCount {
level = input.CurrentLevel
}
if input == nil || input.ResetAtMS <= 0 || nowMS >= input.ResetAtMS {
return newRocketState(levelForUTCReset(), roomRocketLevelByNumber(cfg, levelForUTCReset()), resetAtMS, cfg.ConfigVersion, allocateRocketID)
}
rocket := input.Clone()
levelConfig := roomRocketLevelByNumber(cfg, level)
rocket.CurrentLevel = level
rocket.FuelThreshold = levelConfig.FuelThreshold
rocket.ResetAtMS = input.ResetAtMS
if rocket.CurrentFuel < 0 {
rocket.CurrentFuel = 0
}
if rocket.CurrentFuel > rocket.FuelThreshold {
rocket.CurrentFuel = rocket.FuelThreshold
}
rocket.PendingLaunches = validRocketPendingLaunches(rocket.PendingLaunches, rocket.ResetAtMS)
if rocket.Status == state.RocketStatusCompleted {
rocket.CurrentLevel = roomRocketLevelCount
rocket.FuelThreshold = roomRocketLevelByNumber(cfg, roomRocketLevelCount).FuelThreshold
rocket.CurrentFuel = rocket.FuelThreshold
rocket.RocketID = ""
} else {
rocket.Status = state.RocketStatusCharging
}
if rocket.RocketID == "" && allocateRocketID && rocket.Status != state.RocketStatusCompleted {
rocket.RocketID = idgen.New("room_rocket")
}
return rocket
}
func validRocketPendingLaunches(input []state.RocketPendingLaunch, resetAtMS int64) []state.RocketPendingLaunch {
out := make([]state.RocketPendingLaunch, 0, len(input))
seen := make(map[string]bool, len(input))
for _, launch := range input {
if launch.RocketID == "" || launch.Level <= 0 || launch.LaunchAtMS <= 0 {
continue
}
if resetAtMS > 0 && launch.ResetAtMS > 0 && launch.ResetAtMS != resetAtMS {
continue
}
key := fmt.Sprintf("%s:%d", launch.RocketID, launch.Level)
if seen[key] {
continue
}
seen[key] = true
out = append(out, launch)
}
slices.SortFunc(out, func(left, right state.RocketPendingLaunch) int {
if left.LaunchAtMS == right.LaunchAtMS {
return int(left.Level - right.Level)
}
if left.LaunchAtMS < right.LaunchAtMS {
return -1
}
return 1
})
return out
}
func newRocketState(level int32, levelConfig RoomRocketLevelConfig, resetAtMS int64, configVersion int64, allocateRocketID bool) *state.RocketState {
rocketID := ""
if allocateRocketID {
rocketID = idgen.New("room_rocket")
}
return &state.RocketState{
CurrentLevel: level,
FuelThreshold: levelConfig.FuelThreshold,
Status: state.RocketStatusCharging,
ResetAtMS: resetAtMS,
RocketID: rocketID,
ConfigVersion: configVersion,
}
}
func levelForUTCReset() int32 {
return 1
}
func nextUTCResetMS(now time.Time) int64 {
utc := now.UTC()
next := time.Date(utc.Year(), utc.Month(), utc.Day()+1, 0, 0, 0, 0, time.UTC)
return next.UnixMilli()
}
func roomRocketLevelByNumber(cfg RoomRocketConfig, level int32) RoomRocketLevelConfig {
for _, item := range cfg.Levels {
if item.Level == level {
return item
}
}
return defaultRoomRocketConfig(cfg.AppCode).Levels[0]
}
func roomRocketFuel(cfg RoomRocketConfig, giftID string, giftTypeCode string, heatValue int64) int64 {
// 火箭燃料只读取 wallet-service 按真实扣费和贡献比例计算出的 heat_value避免历史 GIFT_POINT 字段污染活动进度。
base := heatValue
if base < 0 {
base = 0
}
for _, rule := range cfg.GiftFuelRules {
if rule.GiftID != "" && rule.GiftID == giftID {
return roomRocketFuelByRule(base, rule)
}
}
for _, rule := range cfg.GiftFuelRules {
if rule.GiftID == "" && rule.GiftTypeCode != "" && rule.GiftTypeCode == giftTypeCode {
return roomRocketFuelByRule(base, rule)
}
}
return base
}
func roomRocketFuelByRule(base int64, rule GiftFuelRuleConfig) int64 {
if rule.Excluded {
return 0
}
fuel := int64(0)
if rule.MultiplierPPM > 0 {
fuel += base * rule.MultiplierPPM / 1_000_000
}
fuel += rule.FixedFuel
if fuel < 0 {
return 0
}
return fuel
}
func firstRankUserID(items []state.RankItem) int64 {
if len(items) == 0 {
return 0
}
return items[0].UserID
}
func minInt64(left int64, right int64) int64 {
if left < right {
return left
}
return right
}
func rocketStateToRoomProto(input *state.RocketState) *roomv1.RoomRocketState {
if input == nil {
return nil
}
rewards := make([]*roomv1.RoomRocketRewardGrant, 0, len(input.LastRewards))
for _, reward := range input.LastRewards {
rewards = append(rewards, &roomv1.RoomRocketRewardGrant{
RewardRole: reward.RewardRole,
UserId: reward.UserID,
RewardItemId: reward.RewardItemID,
ResourceGroupId: reward.ResourceGroupID,
DisplayName: reward.DisplayName,
IconUrl: reward.IconURL,
GrantId: reward.GrantID,
Status: reward.Status,
})
}
pending := make([]*roomv1.RoomRocketPendingLaunch, 0, len(input.PendingLaunches))
for _, launch := range input.PendingLaunches {
pending = append(pending, &roomv1.RoomRocketPendingLaunch{
RocketId: launch.RocketID,
Level: launch.Level,
FuelThreshold: launch.FuelThreshold,
IgnitedAtMs: launch.IgnitedAtMS,
LaunchAtMs: launch.LaunchAtMS,
ResetAtMs: launch.ResetAtMS,
Top1UserId: launch.Top1UserID,
IgniterUserId: launch.IgniterUserID,
ConfigVersion: launch.ConfigVersion,
CoverUrl: launch.CoverURL,
})
}
return &roomv1.RoomRocketState{
CurrentLevel: input.CurrentLevel,
CurrentFuel: input.CurrentFuel,
FuelThreshold: input.FuelThreshold,
Status: input.Status,
IgnitedAtMs: input.IgnitedAtMS,
LaunchAtMs: input.LaunchAtMS,
LaunchedAtMs: input.LaunchedAtMS,
ResetAtMs: input.ResetAtMS,
Top1UserId: input.Top1UserID,
IgniterUserId: input.IgniterUserID,
RocketId: input.RocketID,
ConfigVersion: input.ConfigVersion,
LastRewards: rewards,
PendingLaunches: pending,
}
}
func rocketStateToEventRewards(input []state.RocketRewardGrant) []*roomeventsv1.RoomRocketRewardGrant {
out := make([]*roomeventsv1.RoomRocketRewardGrant, 0, len(input))
for _, reward := range input {
out = append(out, &roomeventsv1.RoomRocketRewardGrant{
RewardRole: reward.RewardRole,
UserId: reward.UserID,
RewardItemId: reward.RewardItemID,
ResourceGroupId: reward.ResourceGroupID,
DisplayName: reward.DisplayName,
IconUrl: reward.IconURL,
GrantId: reward.GrantID,
Status: reward.Status,
})
}
return out
}
// RunRoomRocketLaunchWorker 周期性扫描已装载房间,把到点的待发射火箭走命令链路发射。
func (s *Service) RunRoomRocketLaunchWorker(ctx context.Context, interval time.Duration) {
if interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = s.SweepRoomRocketLaunchings(ctx)
}
}
}
// SweepRoomRocketLaunchings 只处理本节点仍持有 lease 的已装载 Room Cell。
func (s *Service) SweepRoomRocketLaunchings(ctx context.Context) error {
now := s.clock.Now().UTC()
nowMS := now.UnixMilli()
for _, roomRef := range s.loadedRoomRefs() {
roomCtx := appcode.WithContext(ctx, roomRef.AppCode)
lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now)
if err != nil {
return err
}
if !owned {
continue
}
roomCell := s.loadCell(roomCtx, roomRef.RoomID)
if roomCell == nil {
continue
}
currentState, _, err := roomCell.Snapshot(roomCtx)
if err != nil {
return err
}
owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
if err != nil {
return err
}
if !owned || currentState == nil || currentState.Rocket == nil {
continue
}
rocket := currentState.Rocket
if rocket.ResetAtMS > 0 && nowMS >= rocket.ResetAtMS {
// UTC 切日优先级高于延迟发射;跨日倒计时火箭不再结算,下一次查询或送礼会按新 UTC 日投影/持久化一级新进度。
continue
}
for _, pending := range append([]state.RocketPendingLaunch(nil), rocket.PendingLaunches...) {
if pending.LaunchAtMS <= 0 || pending.LaunchAtMS > nowMS {
continue
}
actorID := pending.IgniterUserID
if actorID <= 0 {
actorID = roomRocketSystemActor(currentState)
}
cmd := command.LaunchRoomRocket{
Base: command.Base{
AppCode: roomRef.AppCode,
RequestID: idgen.New("req_room_rocket_launch"),
CommandID: roomRocketLaunchCommandID(pending.RocketID),
ActorID: actorID,
Room: roomRef.RoomID,
GatewayNodeID: s.nodeID,
SessionID: "room-rocket-launch",
SentAtMS: nowMS,
},
RocketID: pending.RocketID,
Level: pending.Level,
}
if _, err := s.launchRoomRocket(roomCtx, cmd); err != nil {
return err
}
}
}
return nil
}
func roomRocketLaunchCommandID(rocketID string) string {
if strings.TrimSpace(rocketID) == "" {
return idgen.New("cmd_room_rocket_launch")
}
return "cmd_room_rocket_launch_" + rocketID
}
func roomRocketSystemActor(current *state.RoomState) int64 {
if current == nil {
return 0
}
if current.OwnerUserID > 0 {
return current.OwnerUserID
}
if current.Rocket != nil && current.Rocket.IgniterUserID > 0 {
return current.Rocket.IgniterUserID
}
return 0
}
func (s *Service) launchRoomRocket(ctx context.Context, cmd command.LaunchRoomRocket) (*roomv1.RoomRocketState, error) {
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
now = now.UTC()
nowMS := now.UnixMilli()
if current.Rocket == nil {
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
pending, pendingIndex, ok := findRocketPendingLaunch(current.Rocket.PendingLaunches, cmd.RocketID, cmd.Level)
if !ok {
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if pending.ResetAtMS > 0 && nowMS >= pending.ResetAtMS {
// 手动或延迟到达的发射命令也不能越过 UTC 日界结算昨天的火箭。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if pending.LaunchAtMS <= 0 || pending.LaunchAtMS > nowMS {
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
cfg, err := s.roomRocketConfig(ctx)
if err != nil {
return mutationResult{}, nil, err
}
levelCfg := roomRocketLevelByNumber(cfg, pending.Level)
inRoomUserIDs := sortedRocketOnlineUserIDs(current.OnlineUsers)
rewards, err := s.settleRoomRocketRewards(ctx, now, current, pending, cfg, levelCfg, inRoomUserIDs)
if err != nil {
return mutationResult{}, nil, err
}
pendingAfter := removeRocketPendingLaunch(current.Rocket.PendingLaunches, pendingIndex)
current.Rocket.PendingLaunches = pendingAfter
current.Rocket.LaunchedAtMS = nowMS
current.Rocket.LastRewards = rewards
settledCommand := cmd
settledCommand.CurrentLevel = current.Rocket.CurrentLevel
settledCommand.CurrentFuel = current.Rocket.CurrentFuel
settledCommand.CurrentFuelThreshold = current.Rocket.FuelThreshold
settledCommand.CurrentStatus = current.Rocket.Status
settledCommand.CurrentRocketID = current.Rocket.RocketID
settledCommand.ConfigVersion = current.Rocket.ConfigVersion
settledCommand.ResetAtMS = current.Rocket.ResetAtMS
settledCommand.Top1UserID = pending.Top1UserID
settledCommand.IgniterUserID = pending.IgniterUserID
settledCommand.LaunchedAtMS = nowMS
settledCommand.InRoomUserIDs = inRoomUserIDs
settledCommand.Rewards = rocketRewardsToCommand(rewards)
settledCommand.PendingLaunches = rocketPendingLaunchesToCommand(pendingAfter)
commandPayload, err := command.Serialize(settledCommand)
if err != nil {
return mutationResult{}, nil, err
}
current.Version++
launchedEvent, err := outbox.Build(current.RoomID, "RoomRocketLaunched", current.Version, now, &roomeventsv1.RoomRocketLaunched{
RocketId: pending.RocketID,
Level: pending.Level,
NextLevel: current.Rocket.CurrentLevel,
LaunchedAtMs: nowMS,
ResetAtMs: pending.ResetAtMS,
Top1UserId: pending.Top1UserID,
IgniterUserId: pending.IgniterUserID,
InRoomUserIds: inRoomUserIDs,
Rewards: rocketStateToEventRewards(rewards),
})
if err != nil {
return mutationResult{}, nil, err
}
records := []outbox.Record{launchedEvent}
if len(rewards) > 0 {
rewardEvent, err := outbox.Build(current.RoomID, "RoomRocketRewardGranted", current.Version, now, &roomeventsv1.RoomRocketRewardGranted{
RocketId: pending.RocketID,
Level: pending.Level,
Rewards: rocketStateToEventRewards(rewards),
})
if err != nil {
return mutationResult{}, nil, err
}
records = append(records, rewardEvent)
}
return mutationResult{
snapshot: current.ToProto(),
commandPayload: commandPayload,
}, records, nil
})
if err != nil {
return nil, err
}
return result.snapshot.GetRocket(), nil
}
func findRocketPendingLaunch(input []state.RocketPendingLaunch, rocketID string, level int32) (state.RocketPendingLaunch, int, bool) {
for index, launch := range input {
if launch.RocketID == rocketID && launch.Level == level {
return launch, index, true
}
}
return state.RocketPendingLaunch{}, -1, false
}
func removeRocketPendingLaunch(input []state.RocketPendingLaunch, index int) []state.RocketPendingLaunch {
if index < 0 || index >= len(input) {
return append([]state.RocketPendingLaunch(nil), input...)
}
out := make([]state.RocketPendingLaunch, 0, len(input)-1)
out = append(out, input[:index]...)
out = append(out, input[index+1:]...)
return out
}
func sortedRocketOnlineUserIDs(users map[int64]*state.RoomUserState) []int64 {
userIDs := make([]int64, 0, len(users))
for userID := range users {
if userID > 0 {
userIDs = append(userIDs, userID)
}
}
slices.Sort(userIDs)
return userIDs
}
func (s *Service) settleRoomRocketRewards(ctx context.Context, now time.Time, current *state.RoomState, launch state.RocketPendingLaunch, cfg RoomRocketConfig, levelCfg RoomRocketLevelConfig, inRoomUserIDs []int64) ([]state.RocketRewardGrant, error) {
if current == nil {
return nil, nil
}
rewards := make([]state.RocketRewardGrant, 0, len(inRoomUserIDs)+2)
claimed := make(map[int64]bool, len(inRoomUserIDs)+2)
addReward := func(role string, userID int64, pool []RoomRocketRewardItem) error {
if userID <= 0 {
return nil
}
if cfg.RewardStackPolicy == roomRocketRewardStackPriority && claimed[userID] {
return nil
}
item, ok := selectRoomRocketReward(launch.RocketID, role, userID, pool)
if !ok {
return nil
}
grantID, err := s.grantRoomRocketReward(ctx, now, current, launch.RocketID, role, userID, item)
if err != nil {
return err
}
rewards = append(rewards, state.RocketRewardGrant{
RewardRole: role,
UserID: userID,
RewardItemID: item.RewardItemID,
ResourceGroupID: item.ResourceGroupID,
DisplayName: item.DisplayName,
IconURL: item.IconURL,
GrantID: grantID,
Status: roomRocketGrantStatus,
})
claimed[userID] = true
return nil
}
if cfg.RewardStackPolicy == roomRocketRewardStackPriority {
// 去重策略下先发特殊身份奖励,再发普通在房奖励,避免 top1/点火人被普通池占位。
if err := addReward(roomRocketRewardIgniter, launch.IgniterUserID, levelCfg.IgniterRewards); err != nil {
return nil, err
}
if err := addReward(roomRocketRewardTop1, launch.Top1UserID, levelCfg.Top1Rewards); err != nil {
return nil, err
}
for _, userID := range inRoomUserIDs {
if err := addReward(roomRocketRewardInRoom, userID, levelCfg.InRoomRewards); err != nil {
return nil, err
}
}
return rewards, nil
}
for _, userID := range inRoomUserIDs {
if err := addReward(roomRocketRewardInRoom, userID, levelCfg.InRoomRewards); err != nil {
return nil, err
}
}
if err := addReward(roomRocketRewardTop1, launch.Top1UserID, levelCfg.Top1Rewards); err != nil {
return nil, err
}
if err := addReward(roomRocketRewardIgniter, launch.IgniterUserID, levelCfg.IgniterRewards); err != nil {
return nil, err
}
return rewards, nil
}
func (s *Service) grantRoomRocketReward(ctx context.Context, _ time.Time, current *state.RoomState, rocketID string, role string, userID int64, item RoomRocketRewardItem) (string, error) {
if s.wallet == nil {
return "", xerr.New(xerr.Unavailable, "wallet client is not configured")
}
operatorUserID := current.OwnerUserID
if operatorUserID <= 0 {
operatorUserID = userID
}
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
CommandId: roomRocketGrantCommandID(rocketID, role, userID, item.RewardItemID),
AppCode: appcode.FromContext(ctx),
TargetUserId: userID,
GroupId: item.ResourceGroupID,
Reason: fmt.Sprintf("room rocket %s reward", role),
OperatorUserId: operatorUserID,
GrantSource: roomRocketGrantSource,
})
if err != nil {
return "", err
}
if resp == nil || resp.GetGrant() == nil {
return "", xerr.New(xerr.Unavailable, "wallet grant response is empty")
}
return resp.GetGrant().GetGrantId(), nil
}
func selectRoomRocketReward(rocketID string, role string, userID int64, pool []RoomRocketRewardItem) (RoomRocketRewardItem, bool) {
total := int64(0)
for _, item := range pool {
if item.Weight > 0 && item.ResourceGroupID > 0 {
total += item.Weight
}
}
if total <= 0 {
return RoomRocketRewardItem{}, false
}
hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", rocketID, role, userID)))
pick := int64(binary.BigEndian.Uint64(hash[:8]) % uint64(total))
cursor := int64(0)
for _, item := range pool {
if item.Weight <= 0 || item.ResourceGroupID <= 0 {
continue
}
cursor += item.Weight
if pick < cursor {
return item, true
}
}
return pool[len(pool)-1], true
}
func roomRocketGrantCommandID(rocketID string, role string, userID int64, rewardItemID string) string {
hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%s", rocketID, role, userID, rewardItemID)))
return fmt.Sprintf("cmd_room_rocket_grant_%x", hash[:8])
}
func rocketRewardsToCommand(input []state.RocketRewardGrant) []command.RocketRewardGrant {
out := make([]command.RocketRewardGrant, 0, len(input))
for _, reward := range input {
out = append(out, command.RocketRewardGrant{
RewardRole: reward.RewardRole,
UserID: reward.UserID,
RewardItemID: reward.RewardItemID,
ResourceGroupID: reward.ResourceGroupID,
DisplayName: reward.DisplayName,
IconURL: reward.IconURL,
GrantID: reward.GrantID,
Status: reward.Status,
})
}
return out
}
func rocketPendingLaunchesToCommand(input []state.RocketPendingLaunch) []command.RocketPendingLaunch {
out := make([]command.RocketPendingLaunch, 0, len(input))
for _, launch := range input {
out = append(out, command.RocketPendingLaunch{
RocketID: launch.RocketID,
Level: launch.Level,
FuelThreshold: launch.FuelThreshold,
IgnitedAtMS: launch.IgnitedAtMS,
LaunchAtMS: launch.LaunchAtMS,
ResetAtMS: launch.ResetAtMS,
Top1UserID: launch.Top1UserID,
IgniterUserID: launch.IgniterUserID,
ConfigVersion: launch.ConfigVersion,
CoverURL: launch.CoverURL,
})
}
return out
}