fix room rocket in-room reward limits

This commit is contained in:
zhx 2026-06-11 18:07:57 +08:00
parent b55a01c0d6
commit b025ac4dda
9 changed files with 146 additions and 16 deletions

View File

@ -774,8 +774,10 @@ type RoomRocketRewardItem struct {
Weight int64 `protobuf:"varint,3,opt,name=weight,proto3" json:"weight,omitempty"`
DisplayName string `protobuf:"bytes,4,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
IconUrl string `protobuf:"bytes,5,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// quantity 表示该奖项在一次火箭发射中最多发给多少个在房用户;为空或 0 时兼容旧配置按 1 份处理。
Quantity int64 `protobuf:"varint,6,opt,name=quantity,proto3" json:"quantity,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RoomRocketRewardItem) Reset() {
@ -843,6 +845,13 @@ func (x *RoomRocketRewardItem) GetIconUrl() string {
return ""
}
func (x *RoomRocketRewardItem) GetQuantity() int64 {
if x != nil {
return x.Quantity
}
return 0
}
// RoomRocketLevel 暴露单个等级火箭的物料、阈值和三类奖励池。
type RoomRocketLevel struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -9520,13 +9529,14 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\x122\n" +
"\x15wallet_transaction_id\x18\x12 \x01(\tR\x13walletTransactionId\x12,\n" +
"\x12coin_balance_after\x18\x13 \x01(\x03R\x10coinBalanceAfter\x12$\n" +
"\x0etarget_user_id\x18\x14 \x01(\x03R\ftargetUserId\"\xbe\x01\n" +
"\x0etarget_user_id\x18\x14 \x01(\x03R\ftargetUserId\"\xda\x01\n" +
"\x14RoomRocketRewardItem\x12$\n" +
"\x0ereward_item_id\x18\x01 \x01(\tR\frewardItemId\x12*\n" +
"\x11resource_group_id\x18\x02 \x01(\x03R\x0fresourceGroupId\x12\x16\n" +
"\x06weight\x18\x03 \x01(\x03R\x06weight\x12!\n" +
"\fdisplay_name\x18\x04 \x01(\tR\vdisplayName\x12\x19\n" +
"\bicon_url\x18\x05 \x01(\tR\aiconUrl\"\xd3\x03\n" +
"\bicon_url\x18\x05 \x01(\tR\aiconUrl\x12\x1a\n" +
"\bquantity\x18\x06 \x01(\x03R\bquantity\"\xd3\x03\n" +
"\x0fRoomRocketLevel\x12\x14\n" +
"\x05level\x18\x01 \x01(\x05R\x05level\x12%\n" +
"\x0efuel_threshold\x18\x02 \x01(\x03R\rfuelThreshold\x12\x1b\n" +

View File

@ -112,6 +112,8 @@ message RoomRocketRewardItem {
int64 weight = 3;
string display_name = 4;
string icon_url = 5;
// quantity 0 1
int64 quantity = 6;
}
// RoomRocketLevel

View File

@ -127,6 +127,7 @@ type RoomRocketRewardItem struct {
RewardItemID string
ResourceGroupID int64
Weight int64
Quantity int64
DisplayName string
IconURL string
}
@ -623,6 +624,7 @@ func roomRocketRewardsFromProto(input []*roomv1.RoomRocketRewardItem) []RoomRock
RewardItemID: reward.GetRewardItemId(),
ResourceGroupID: reward.GetResourceGroupId(),
Weight: reward.GetWeight(),
Quantity: reward.GetQuantity(),
DisplayName: reward.GetDisplayName(),
IconURL: reward.GetIconUrl(),
})
@ -637,6 +639,7 @@ func roomRocketRewardsToProto(input []RoomRocketRewardItem) []*roomv1.RoomRocket
RewardItemId: reward.RewardItemID,
ResourceGroupId: reward.ResourceGroupID,
Weight: reward.Weight,
Quantity: reward.Quantity,
DisplayName: reward.DisplayName,
IconUrl: reward.IconURL,
})

View File

@ -63,6 +63,7 @@ type RoomRocketRewardItem struct {
RewardItemID string `json:"rewardItemId"`
ResourceGroupID int64 `json:"resourceGroupId"`
Weight int64 `json:"weight"`
Quantity int64 `json:"quantity"`
DisplayName string `json:"displayName"`
IconURL string `json:"iconUrl"`
}
@ -246,7 +247,7 @@ func normalizeRewardPool(level int32, role string, rewards []RoomRocketRewardIte
reward.RewardItemID = strings.TrimSpace(reward.RewardItemID)
reward.DisplayName = strings.TrimSpace(reward.DisplayName)
reward.IconURL = strings.TrimSpace(reward.IconURL)
if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.DisplayName == "" && reward.IconURL == "" {
if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.Quantity == 0 && reward.DisplayName == "" && reward.IconURL == "" {
continue
}
if reward.ResourceGroupID <= 0 {
@ -255,6 +256,10 @@ func normalizeRewardPool(level int32, role string, rewards []RoomRocketRewardIte
if reward.Weight <= 0 {
return nil, fmt.Errorf("%w: 第 %d 级奖励权重必须大于 0", ErrInvalidArgument, level)
}
if reward.Quantity <= 0 {
// 历史配置没有 quantity 字段;后台读写都按 1 份兼容,避免旧配置打开页面即失败。
reward.Quantity = 1
}
if reward.RewardItemID == "" {
reward.RewardItemID = fmt.Sprintf("level_%d_%s_%d", level, role, index+1)
}
@ -382,6 +387,7 @@ func rewardsFromClient(input []roomclient.RoomRocketRewardItem) []RoomRocketRewa
RewardItemID: reward.RewardItemID,
ResourceGroupID: reward.ResourceGroupID,
Weight: reward.Weight,
Quantity: reward.Quantity,
DisplayName: reward.DisplayName,
IconURL: reward.IconURL,
})
@ -396,6 +402,7 @@ func rewardsToClient(input []RoomRocketRewardItem) []roomclient.RoomRocketReward
RewardItemID: reward.RewardItemID,
ResourceGroupID: reward.ResourceGroupID,
Weight: reward.Weight,
Quantity: reward.Quantity,
DisplayName: reward.DisplayName,
IconURL: reward.IconURL,
})

View File

@ -42,6 +42,22 @@ func TestNormalizeConfigDefaultsAndSortsLevels(t *testing.T) {
}
}
func TestNormalizeConfigKeepsRewardQuantityAndDefaultsOldItems(t *testing.T) {
config := defaultConfig("lalu")
config.Levels[0].InRoomRewards = []RoomRocketRewardItem{
{RewardItemID: "coin", ResourceGroupID: 1001, Weight: 100, Quantity: 2},
{RewardItemID: "mic-wave", ResourceGroupID: 1002, Weight: 100},
}
normalized, err := normalizeConfig(config)
if err != nil {
t.Fatalf("normalizeConfig failed: %v", err)
}
rewards := normalized.Levels[0].InRoomRewards
if rewards[0].Quantity != 2 || rewards[1].Quantity != 1 {
t.Fatalf("reward quantity must be preserved and defaulted: %+v", rewards)
}
}
func TestDefaultConfigKeepsOperationalDefaults(t *testing.T) {
config, err := normalizeConfig(defaultConfig("lalu"))
if err != nil {

View File

@ -113,6 +113,7 @@ func roomRocketRewardsFromProto(input []*roomv1.RoomRocketRewardItem) []RoomRock
RewardItemID: reward.GetRewardItemId(),
ResourceGroupID: reward.GetResourceGroupId(),
Weight: reward.GetWeight(),
Quantity: reward.GetQuantity(),
DisplayName: reward.GetDisplayName(),
IconURL: reward.GetIconUrl(),
})

View File

@ -294,6 +294,7 @@ type RoomRocketRewardItem struct {
RewardItemID string `json:"rewardItemId"`
ResourceGroupID int64 `json:"resourceGroupId"`
Weight int64 `json:"weight"`
Quantity int64 `json:"quantity"`
DisplayName string `json:"displayName"`
IconURL string `json:"iconUrl"`
}

View File

@ -195,6 +195,10 @@ func normalizeRoomRocketRewardPool(input []RoomRocketRewardItem) []RoomRocketRew
// 后台保存层会拒绝非法奖励;运行时跳过历史脏项,避免一个奖励池配置污染送礼主链路。
continue
}
if reward.Quantity <= 0 {
// 旧配置没有 quantity 字段;按 1 份兼容,避免上线后历史火箭奖励池被当成 0 份直接停发。
reward.Quantity = 1
}
out = append(out, reward)
}
return out
@ -340,6 +344,7 @@ func roomRocketRewardsToProto(input []RoomRocketRewardItem) []*roomv1.RoomRocket
RewardItemId: reward.RewardItemID,
ResourceGroupId: reward.ResourceGroupID,
Weight: reward.Weight,
Quantity: reward.Quantity,
DisplayName: reward.DisplayName,
IconUrl: reward.IconURL,
})
@ -1115,6 +1120,9 @@ func validRoomRocketRewardPool(pool []RoomRocketRewardItem) []RoomRocketRewardIt
if item.Weight <= 0 || item.ResourceGroupID <= 0 {
continue
}
if item.Quantity <= 0 {
item.Quantity = 1
}
valid = append(valid, item)
}
return valid
@ -1129,18 +1137,16 @@ func selectRoomRocketRewardsForUsers(rocketID string, role string, userIDs []int
if len(basePool) == 0 {
return assignments
}
available := append([]RoomRocketRewardItem(nil), basePool...)
cycle := 0
available := expandRoomRocketRewardSlots(basePool)
for index, userID := range userIDs {
if userID <= 0 {
continue
}
if len(available) == 0 {
// 一轮内每个配置项最多发一次;房间人数超过奖池大小时再开启下一轮,让大房间仍可继续按配置重复分配。
available = append([]RoomRocketRewardItem(nil), basePool...)
cycle++
// 在房奖励数量是单次发射的硬上限;配置总数发完后,其余在线用户本次拿空,不生成 grant 和 IM 弹窗。
break
}
selectedIndex, ok := selectRoomRocketRewardIndex(rocketID, role, userID, available, fmt.Sprintf("cycle:%d:index:%d", cycle, index))
selectedIndex, ok := selectRoomRocketRewardIndex(rocketID, role, userID, available, fmt.Sprintf("limited:index:%d", index))
if !ok {
continue
}
@ -1150,12 +1156,20 @@ func selectRoomRocketRewardsForUsers(rocketID string, role string, userIDs []int
return assignments
}
func selectRoomRocketReward(rocketID string, role string, userID int64, pool []RoomRocketRewardItem) (RoomRocketRewardItem, bool) {
index, ok := selectRoomRocketRewardIndex(rocketID, role, userID, pool, "")
if !ok {
return RoomRocketRewardItem{}, false
func expandRoomRocketRewardSlots(pool []RoomRocketRewardItem) []RoomRocketRewardItem {
total := 0
for _, item := range pool {
if item.Quantity > 0 {
total += int(item.Quantity)
}
}
return pool[index], true
slots := make([]RoomRocketRewardItem, 0, total)
for _, item := range pool {
for count := int64(0); count < item.Quantity; count++ {
slots = append(slots, item)
}
}
return slots
}
func selectRoomRocketRewardIndex(rocketID string, role string, userID int64, pool []RoomRocketRewardItem, salt string) (int, bool) {

View File

@ -578,6 +578,72 @@ func TestRoomRocketLaunchStacksAllSpecialPoolsAndRandomInRoomReward(t *testing.T
}
}
func TestRoomRocketInRoomRewardQuantityCapsWinners(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 14, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-quantity", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
}}
svc := newRocketTestService(t, repository, wallet, now)
config := rocketConfigForTest(100, 1_000)
config.Levels[0].InRoomRewards = []roomservice.RoomRocketRewardItem{
{RewardItemID: "level-1-in-room-coin", ResourceGroupID: 1001, Weight: 1, Quantity: 2, DisplayName: "Coin"},
{RewardItemID: "level-1-in-room-mic", ResourceGroupID: 1101, Weight: 1, Quantity: 3, DisplayName: "Mic Wave"},
}
config.Levels[0].Top1Rewards = nil
config.Levels[0].IgniterRewards = nil
writeRocketConfig(t, ctx, repository, config)
roomID := "room-rocket-quantity"
ownerID := int64(501)
igniterID := int64(502)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9004)
for userID := igniterID; userID <= 507; userID++ {
joinRocketRoom(t, ctx, svc, roomID, userID)
}
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, igniterID, "quantity"),
TargetType: "user",
TargetUserId: ownerID,
GiftId: "gift-quantity",
GiftCount: 1,
}); err != nil {
t.Fatalf("ignite quantity rocket failed: %v", err)
}
now.now = now.now.Add(1100 * time.Millisecond)
if err := svc.SweepRoomRocketLaunchings(ctx); err != nil {
t.Fatalf("sweep quantity room rocket launches failed: %v", err)
}
info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
ViewerUserId: ownerID,
})
if err != nil {
t.Fatalf("get quantity rocket after launch failed: %v", err)
}
rewards := info.GetRocket().GetState().GetLastRewards()
if countRocketRewardsByRole(rewards, "in_room") != 5 {
t.Fatalf("in-room rewards must stop at configured quantity 5, got %+v", rewards)
}
if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1001) != 2 {
t.Fatalf("coin resource group must be granted exactly 2 times: %+v", rewards)
}
if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1101) != 3 {
t.Fatalf("mic wave resource group must be granted exactly 3 times: %+v", rewards)
}
if len(wallet.grants) != 5 {
t.Fatalf("wallet must only receive 5 in-room grant calls, got %+v", wallet.grants)
}
if len(rocketRewardUserIDs(rewards)) != 5 {
t.Fatalf("only 5 users should have reward rows; remaining online users receive empty: %+v", rewards)
}
}
func TestRoomRocketUTCResetCancelsCrossDayIgnited(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -841,6 +907,16 @@ func countRocketRewardsByRoleAndGroup(rewards []*roomv1.RoomRocketRewardGrant, r
return count
}
func countRocketRewardsByRole(rewards []*roomv1.RoomRocketRewardGrant, role string) int {
count := 0
for _, reward := range rewards {
if reward.GetRewardRole() == role {
count++
}
}
return count
}
func rocketRewardUserIDs(rewards []*roomv1.RoomRocketRewardGrant) []int64 {
userIDs := make([]int64, 0, len(rewards))
for _, reward := range rewards {