Compare commits

...

4 Commits

Author SHA1 Message Date
zhx
75b867ca16 Merge branch 'main' of gitea.haiyihy.com:hy/hyapp-server 2026-06-12 20:12:49 +08:00
zhx
2556316852 增加黑色必胜 2026-06-12 20:12:47 +08:00
zhx
13240f4880 Merge branch 'test' into main 2026-06-12 19:55:55 +08:00
zhx
acb5225de9 fix: allow mic seat animation equipment 2026-06-12 19:51:43 +08:00
20 changed files with 333 additions and 50 deletions

View File

@ -4806,6 +4806,7 @@ type SelfGameNewUserPolicy struct {
CreatedAtMs int64 `protobuf:"varint,13,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,14,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
NormalPhaseTargetWinRatePercent int32 `protobuf:"varint,15,opt,name=normal_phase_target_win_rate_percent,json=normalPhaseTargetWinRatePercent,proto3" json:"normal_phase_target_win_rate_percent,omitempty"`
BlackPoolRobotForceWinEnabled bool `protobuf:"varint,16,opt,name=black_pool_robot_force_win_enabled,json=blackPoolRobotForceWinEnabled,proto3" json:"black_pool_robot_force_win_enabled,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -4945,6 +4946,13 @@ func (x *SelfGameNewUserPolicy) GetNormalPhaseTargetWinRatePercent() int32 {
return 0
}
func (x *SelfGameNewUserPolicy) GetBlackPoolRobotForceWinEnabled() bool {
if x != nil {
return x.BlackPoolRobotForceWinEnabled
}
return false
}
type GetSelfGameNewUserPolicyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -6758,7 +6766,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"|\n" +
"\x17UpdateDiceConfigRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x121\n" +
"\x06config\x18\x02 \x01(\v2\x19.hyapp.game.v1.DiceConfigR\x06config\"\xce\x05\n" +
"\x06config\x18\x02 \x01(\v2\x19.hyapp.game.v1.DiceConfigR\x06config\"\x99\x06\n" +
"\x15SelfGameNewUserPolicy\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12\x18\n" +
@ -6775,7 +6783,8 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\x13lose_streak_trigger\x18\f \x01(\x05R\x11loseStreakTrigger\x12\"\n" +
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\x12M\n" +
"$normal_phase_target_win_rate_percent\x18\x0f \x01(\x05R\x1fnormalPhaseTargetWinRatePercent\"j\n" +
"$normal_phase_target_win_rate_percent\x18\x0f \x01(\x05R\x1fnormalPhaseTargetWinRatePercent\x12I\n" +
"\"black_pool_robot_force_win_enabled\x18\x10 \x01(\bR\x1dblackPoolRobotForceWinEnabled\"j\n" +
"\x1fGetSelfGameNewUserPolicyRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\agame_id\x18\x02 \x01(\tR\x06gameId\"\x92\x01\n" +

View File

@ -530,6 +530,7 @@ message SelfGameNewUserPolicy {
int64 created_at_ms = 13;
int64 updated_at_ms = 14;
int32 normal_phase_target_win_rate_percent = 15;
bool black_pool_robot_force_win_enabled = 16;
}
message GetSelfGameNewUserPolicyRequest {

View File

@ -82,7 +82,7 @@ Query
| `gift` | 礼物权益 | 否 |
| `floating_screen` | 飘屏 | 否 |
| `mic_seat_icon` | 麦位图标 | 否 |
| `mic_seat_animation` | 麦位动画 | |
| `mic_seat_animation` | 麦位动画 | |
| `emoji_pack` | 表情包 | 否 |
接口只返回当前有效权益:`status=active`、已到生效时间、未过期、`remaining_quantity > 0``expires_at_ms = 0` 表示永久有效。
@ -219,11 +219,12 @@ Body
| 规则 | 说明 |
| --- | --- |
| 可佩戴类型 | `avatar_frame``profile_card``vehicle``chat_bubble``badge`。 |
| 不可佩戴类型 | `gift``floating_screen``mic_seat_icon``mic_seat_animation`、`emoji_pack`。 |
| 可佩戴类型 | `avatar_frame``profile_card``vehicle``chat_bubble``badge``mic_seat_animation`。 |
| 不可佩戴类型 | `gift``floating_screen``mic_seat_icon``emoji_pack`。 |
| 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 |
| 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 |
| 徽章展示 | 背包佩戴的 `badge` 是单装备槽;资料页多徽章展示仍使用 `/api/v1/badges/me``/api/v1/badges/display`。 |
| 发放默认佩戴 | `badge``mic_seat_animation` 发放成功后默认佩戴。 |
| 徽章展示 | `badge` 是拥有即佩戴的多装备资源;资料页多徽章展示仍使用 `/api/v1/badges/me``/api/v1/badges/display`。 |
常见错误:

View File

@ -235,7 +235,8 @@ Body 参数:
说明:
- 可佩戴类型:`avatar_frame``profile_card``vehicle``chat_bubble``badge`
- 可佩戴类型:`avatar_frame``profile_card``vehicle``chat_bubble``badge``mic_seat_animation`
- `badge``mic_seat_animation` 发放成功后服务端默认佩戴。
- 过期资源不能佩戴。
- 不传 `entitlement_id` 时,服务端会选择当前有效权益。

View File

@ -425,8 +425,9 @@ POST /api/v1/users/me/resources/{resource_id}/equip
装备资源的边界:
- `equip` 只适用于头像框、资料卡、座驾、气泡、徽章等可装备资源。
- `equip` 只适用于头像框、资料卡、座驾、气泡、徽章、麦位声波等可装备资源。
- `floating_screen``gift``coin` 不能走 `equip`;金币只落钱包账本,礼物和飘屏后续通过使用命令扣减 `remaining_quantity`
- `badge``mic_seat_animation` 发放成功后默认写入当前佩戴。
- `user-service.users.avatar` 仍然只是头像 URL不承载头像框。
- 当前装备状态放在 `wallet-service` resource domain 的 `user_resource_equipment` 表,避免污染用户主数据。
- 房间展示用户资料时需要读已装备资源快照,不能让客户端自报头像框、气泡或徽章。

View File

@ -116,6 +116,7 @@ type selfGameNewUserPolicyDTO struct {
LoseStreakProtectionEnabled bool `json:"loseStreakProtectionEnabled"`
LoseStreakTrigger int32 `json:"loseStreakTrigger"`
NormalPhaseTargetWinRatePercent int32 `json:"normalPhaseTargetWinRatePercent"`
BlackPoolRobotForceWinEnabled bool `json:"blackPoolRobotForceWinEnabled"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
@ -322,6 +323,7 @@ func selfGameNewUserPolicyFromProto(item *gamev1.SelfGameNewUserPolicy) selfGame
LoseStreakProtectionEnabled: item.GetLoseStreakProtectionEnabled(),
LoseStreakTrigger: item.GetLoseStreakTrigger(),
NormalPhaseTargetWinRatePercent: item.GetNormalPhaseTargetWinRatePercent(),
BlackPoolRobotForceWinEnabled: item.GetBlackPoolRobotForceWinEnabled(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}

View File

@ -36,19 +36,27 @@ func TestSelfGameNewUserPolicyKeepsNormalPhaseTargetWinRate(t *testing.T) {
LoseStreakProtectionEnabled: true,
LoseStreakTrigger: 2,
NormalPhaseTargetWinRatePercent: 57,
BlackPoolRobotForceWinEnabled: true,
}).toProto("dice")
if proto.GetNormalPhaseTargetWinRatePercent() != 57 {
t.Fatalf("request normal phase target = %d, want 57", proto.GetNormalPhaseTargetWinRatePercent())
}
if !proto.GetBlackPoolRobotForceWinEnabled() {
t.Fatal("request black pool robot force win switch was not preserved")
}
dto := selfGameNewUserPolicyFromProto(&gamev1.SelfGameNewUserPolicy{
GameId: "dice",
NormalPhaseTargetWinRatePercent: 57,
BlackPoolRobotForceWinEnabled: true,
})
if dto.NormalPhaseTargetWinRatePercent != 57 {
t.Fatalf("dto normal phase target = %d, want 57", dto.NormalPhaseTargetWinRatePercent)
}
if !dto.BlackPoolRobotForceWinEnabled {
t.Fatal("dto black pool robot force win switch was not preserved")
}
}
func TestRoomRPSConfigRequestRequiresFourGiftTiers(t *testing.T) {

View File

@ -92,6 +92,7 @@ type selfGameNewUserPolicyRequest struct {
LoseStreakProtectionEnabled bool `json:"loseStreakProtectionEnabled"`
LoseStreakTrigger int32 `json:"loseStreakTrigger"`
NormalPhaseTargetWinRatePercent int32 `json:"normalPhaseTargetWinRatePercent"`
BlackPoolRobotForceWinEnabled bool `json:"blackPoolRobotForceWinEnabled"`
}
type diceGenerateRobotsRequest struct {
@ -195,6 +196,7 @@ func (r selfGameNewUserPolicyRequest) toProto(gameID string) *gamev1.SelfGameNew
LoseStreakProtectionEnabled: r.LoseStreakProtectionEnabled,
LoseStreakTrigger: r.LoseStreakTrigger,
NormalPhaseTargetWinRatePercent: r.NormalPhaseTargetWinRatePercent,
BlackPoolRobotForceWinEnabled: r.BlackPoolRobotForceWinEnabled,
}
}

View File

@ -363,6 +363,7 @@ CREATE TABLE IF NOT EXISTS game_self_game_new_user_policies (
lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用新手连输保护',
lose_streak_trigger INT NOT NULL DEFAULT 2 COMMENT '触发新手连输保护的连续输局数',
normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53 COMMENT '成熟期有效机器人局目标胜率百分比50 表示完全自然随机',
black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '黑色水位是否允许机器人补位并强制机器人获胜',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY(app_code, game_id)
@ -472,6 +473,15 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_new_user_policies' AND COLUMN_NAME = 'black_pool_robot_force_win_enabled') = 0,
'ALTER TABLE game_self_game_new_user_policies ADD COLUMN black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''黑色水位是否允许机器人补位并强制机器人获胜'' AFTER normal_phase_target_win_rate_percent',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 机器人使用时间和累计次数只用于后台观察随机命中分布;旧表补默认 0避免存量机器人资料读取失败。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND COLUMN_NAME = 'last_used_at_ms') = 0,

View File

@ -172,6 +172,7 @@ type NewUserPolicy struct {
LoseStreakProtectionEnabled bool
LoseStreakTrigger int32
NormalPhaseTargetWinRatePercent int32
BlackPoolRobotForceWinEnabled bool
CreatedAtMS int64
UpdatedAtMS int64
}
@ -389,8 +390,16 @@ func EvaluateRobotStrategy(input StrategyInput, randomInt RandomIntFunc) (Strate
base := StrategyDecision{StrategyCode: StrategyCodeNormalRandom, Decision: DecisionNone, ReasonCode: "NORMAL_RANDOM", PoolLevel: level, RequiredPoolCoin: input.RequiredPoolCoin}
if level == PoolLevelBlack {
// 黑色水位已经无法覆盖本档位真人赢机器人时的最大净支出;策略层直接熔断机器人补位,
// service 收到 none+POOL_BLACK 后不会抽机器人,用户只继续等待真人,避免并发局把档位奖池扣穿。
if input.StakePool.Status != "" && input.StakePool.Status != "active" {
// 档位被运营手动停用时,不论策略开关如何都不能补机器人;停用是显式运营指令,优先级高于黑色水位强制输。
return withReason(input, base, StrategyCodePoolGuard, DecisionNone, "", "POOL_BLACK")
}
if input.NewUserPolicy.BlackPoolRobotForceWinEnabled {
// 黑色水位已经无法覆盖本档位真人赢机器人时的最大净支出;开关开启后,机器人仍可补位,
// 但只能写入“机器人获胜/真人失败”的强制结果,保证本局不会继续消耗该档位奖池。
return withReason(input, base, StrategyCodePoolGuard, DecisionForceHumanLose, ForceResultHumanLose, "POOL_BLACK_ROBOT_FORCE_WIN")
}
// 默认行为保持保守:黑色水位直接熔断机器人补位,用户继续等待真人,避免并发局把档位奖池扣穿。
return withReason(input, base, StrategyCodePoolGuard, DecisionNone, "", "POOL_BLACK")
}
if input.RiskResult.BlockProtection {

View File

@ -15,6 +15,40 @@ func TestEvaluateRobotStrategyBlackPoolSkipsRobot(t *testing.T) {
}
}
func TestEvaluateRobotStrategyBlackPoolCanForceRobotWin(t *testing.T) {
input := baseStrategyInput(87, 88)
input.NewUserPolicy.BlackPoolRobotForceWinEnabled = true
decision, err := EvaluateRobotStrategy(input, func(int) (int, error) {
t.Fatal("black pool robot force-win must not roll strategy probability")
return 0, nil
})
if err != nil {
t.Fatalf("evaluate strategy failed: %v", err)
}
if decision.StrategyCode != StrategyCodePoolGuard ||
decision.ReasonCode != "POOL_BLACK_ROBOT_FORCE_WIN" ||
decision.Decision != DecisionForceHumanLose ||
decision.ForceResult != ForceResultHumanLose {
t.Fatalf("black pool force robot win decision mismatch: %+v", decision)
}
}
func TestEvaluateRobotStrategyDisabledPoolSkipsEvenWhenBlackForceEnabled(t *testing.T) {
input := baseStrategyInput(200, 88)
input.StakePool.Status = "disabled"
input.NewUserPolicy.BlackPoolRobotForceWinEnabled = true
decision, err := EvaluateRobotStrategy(input, func(int) (int, error) {
t.Fatal("disabled pool must not roll strategy probability")
return 0, nil
})
if err != nil {
t.Fatalf("evaluate strategy failed: %v", err)
}
if decision.StrategyCode != StrategyCodePoolGuard || decision.ReasonCode != "POOL_BLACK" || decision.Decision != DecisionNone {
t.Fatalf("disabled pool should still skip robot, got %+v", decision)
}
}
func TestEvaluateRobotStrategyNewUserProtectionHit(t *testing.T) {
decision, err := EvaluateRobotStrategy(baseStrategyInput(200, 88), func(max int) (int, error) {
if max != 100 {

View File

@ -423,6 +423,10 @@ func (s *Service) ListSelfGameStakePools(ctx context.Context, appCode string, ga
if err != nil {
return nil, 0, err
}
policy, err := s.repository.GetSelfGameNewUserPolicy(ctx, app, gameID, nowMS)
if err != nil {
return nil, 0, err
}
persisted, err := s.repository.ListSelfGameStakePools(ctx, app, gameID, nowMS)
if err != nil {
return nil, 0, err
@ -444,7 +448,7 @@ func (s *Service) ListSelfGameStakePools(ctx context.Context, appCode string, ga
// 运营保存或加扣时才真正落库,避免只浏览页面就产生无意义写入。
pool = selfgame.DefaultStakePool(app, gameID, option.StakeCoin, nowMS)
}
views = append(views, s.stakePoolView(config, pool, option.Enabled))
views = append(views, s.stakePoolView(config, pool, option.Enabled, policy.BlackPoolRobotForceWinEnabled))
seen[option.StakeCoin] = struct{}{}
}
for _, pool := range persisted {
@ -452,7 +456,7 @@ func (s *Service) ListSelfGameStakePools(ctx context.Context, appCode string, ga
continue
}
// 旧配置遗留档位仍展示出来,方便运营把非下注档位的余额迁出或停用。
views = append(views, s.stakePoolView(config, pool, false))
views = append(views, s.stakePoolView(config, pool, false, policy.BlackPoolRobotForceWinEnabled))
}
sort.SliceStable(views, func(left int, right int) bool {
return views[left].StakeCoin < views[right].StakeCoin
@ -494,6 +498,10 @@ func (s *Service) UpdateSelfGameStakePool(ctx context.Context, pool selfgame.Sta
if err != nil {
return selfgame.StakePoolView{}, 0, err
}
policy, err := s.repository.GetSelfGameNewUserPolicy(ctx, pool.AppCode, pool.GameID, nowMS)
if err != nil {
return selfgame.StakePoolView{}, 0, err
}
enabled := false
for _, option := range config.StakeOptions {
if option.StakeCoin == pool.StakeCoin {
@ -506,7 +514,7 @@ func (s *Service) UpdateSelfGameStakePool(ctx context.Context, pool selfgame.Sta
if err != nil {
return selfgame.StakePoolView{}, 0, err
}
return s.stakePoolView(config, saved, enabled), nowMS, nil
return s.stakePoolView(config, saved, enabled, policy.BlackPoolRobotForceWinEnabled), nowMS, nil
}
func (s *Service) AdjustPool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, dicedomain.Config, int64, error) {
@ -1097,10 +1105,16 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match
if err != nil {
return dicedomain.Match{}, 0, err
}
newUserPolicy, err := s.repository.GetSelfGameNewUserPolicy(ctx, match.AppCode, match.GameID, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
}
poolLevel := selfgame.PoolLevelFor(stakePool, requiredPool)
if poolLevel == selfgame.PoolLevelBlack {
poolStatus := strings.TrimSpace(stakePool.Status)
poolDisabled := poolStatus != "" && poolStatus != "active"
if poolLevel == selfgame.PoolLevelBlack && (poolDisabled || !newUserPolicy.BlackPoolRobotForceWinEnabled) {
// 黑色水位代表当前档位可用奖池已经覆盖不了本局真人赢机器人时的最大净支出。
// 这里不抽 robot-service也不写机器人入座用户保持等待真人匹配这样能从源头避免“先补位后结算打穿奖池”。
// 默认不开机器人补位,用户保持等待真人;只有策略配置显式开启,并且档位不是手动停用时,下面才会继续抽机器人强制其获胜
decision, err := selfgame.EvaluateRobotStrategy(selfgame.StrategyInput{
AppCode: match.AppCode,
GameID: match.GameID,
@ -1111,7 +1125,7 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match
RequiredPoolCoin: requiredPool,
AllowsDraw: dicedomain.IsRPSGameID(match.GameID),
StakePool: stakePool,
NewUserPolicy: selfgame.DefaultNewUserPolicy(match.AppCode, match.GameID, nowMS),
NewUserPolicy: newUserPolicy,
ProtectionState: selfgame.ProtectionState{AppCode: match.AppCode, GameID: match.GameID, UserID: human.UserID, Status: "active"},
RiskResult: selfgame.RiskResult{},
}, s.randomInt)
@ -1130,10 +1144,6 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match
}
return dicedomain.Match{}, 0, err
}
newUserPolicy, err := s.repository.GetSelfGameNewUserPolicy(ctx, match.AppCode, match.GameID, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
}
protectionState, err := s.repository.GetSelfGameProtectionState(ctx, match.AppCode, match.GameID, human.UserID, nowMS)
if err != nil {
return dicedomain.Match{}, 0, err
@ -1242,7 +1252,7 @@ func requiredPoolCoinForStake(stakeCoin int64, feeBPS int32, poolBPS int32) int6
return required
}
func (s *Service) stakePoolView(config dicedomain.Config, pool selfgame.StakePool, stakeEnabled bool) selfgame.StakePoolView {
func (s *Service) stakePoolView(config dicedomain.Config, pool selfgame.StakePool, stakeEnabled bool, blackPoolRobotForceWinEnabled bool) selfgame.StakePoolView {
pool = selfgame.NormalizeStakePool(pool)
required := requiredPoolCoinForStake(pool.StakeCoin, config.FeeBPS, config.PoolBPS)
level := selfgame.PoolLevelFor(pool, required)
@ -1250,20 +1260,21 @@ func (s *Service) stakePoolView(config dicedomain.Config, pool selfgame.StakePoo
if required > 0 {
capacity = pool.AvailableCoin() / required
}
poolDisabled := pool.Status != "" && pool.Status != "active"
blackReason := ""
if pool.Status != "" && pool.Status != "active" {
if poolDisabled {
blackReason = "status_disabled"
} else if level == selfgame.PoolLevelBlack {
blackReason = "available_below_required_pool_coin"
}
// botMatchEnabled 是后台给运营看的当前结论:档位配置关闭、奖池状态停用、或可用余额不足时都不能补机器人。
// 黄色和红色仍允许补位,但策略会降级到缩小保护或自然随机,因此这里不把它们视为熔断。
// 黄色和红色仍允许补位;黑色只有在策略显式开启机器人强制获胜时允许补位,手动停用始终优先熔断。
return selfgame.StakePoolView{
StakePool: pool,
PoolLevel: level,
RequiredPoolCoin: required,
HumanWinCapacity: capacity,
BotMatchEnabled: stakeEnabled && level != selfgame.PoolLevelBlack,
BotMatchEnabled: stakeEnabled && !poolDisabled && (level != selfgame.PoolLevelBlack || blackPoolRobotForceWinEnabled),
BlackReason: blackReason,
}
}

View File

@ -548,6 +548,77 @@ func TestStakePoolBlackSkipsRobotAttachment(t *testing.T) {
}
}
func TestStakePoolBlackCanForceRobotWinWhenEnabled(t *testing.T) {
repo := newFakeDiceRepository()
repo.config.PoolBalanceCoin = 87
repo.poolBalance = 87
repo.stakePool.BalanceCoin = 87
repo.newUserPolicy.BlackPoolRobotForceWinEnabled = true
repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9004, Status: dicedomain.RobotStatusActive}
wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
svc := New(Config{}, repo, wallet)
nowMS := int64(1700000000000)
svc.now = func() time.Time { return time.UnixMilli(nowMS) }
created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
AppCode: "lalu",
RequestID: "req-black-force-robot-win-create",
UserID: 101,
GameID: "dice",
StakeCoin: 100,
MinPlayers: 2,
MaxPlayers: 2,
})
if err != nil {
t.Fatalf("CreateMatch failed: %v", err)
}
nowMS += dicedomain.DefaultRobotWaitMS
ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
if err != nil {
t.Fatalf("GetMatch attach robot failed: %v", err)
}
if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerLose {
t.Fatalf("black pool force switch should attach robot and force player lose, got %+v", ready)
}
if len(repo.strategyLogs) != 1 ||
repo.strategyLogs[0].ReasonCode != "POOL_BLACK_ROBOT_FORCE_WIN" ||
repo.strategyLogs[0].ForceResult != dicedomain.ForcedResultPlayerLose {
t.Fatalf("black pool force switch should write force-lose strategy log, logs=%+v", repo.strategyLogs)
}
rolls := []struct {
max int
value int
}{
{max: 6, value: 0},
{max: 6, value: 5},
{max: 14, value: 11},
}
svc.randomInt = func(max int) (int, error) {
if len(rolls) == 0 {
t.Fatal("randomInt called too many times")
}
next := rolls[0]
rolls = rolls[1:]
if max != next.max {
t.Fatalf("randomInt max = %d, want %d", max, next.max)
}
return next.value, nil
}
settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-black-force-robot-win-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
if err != nil {
t.Fatalf("RollMatch failed: %v", err)
}
human := participantByUser(settled.Participants, 101)
robot := participantByUser(settled.Participants, 9004)
if human.Result != dicedomain.ParticipantResultLose || robot.Result != dicedomain.ParticipantResultWin || len(human.DicePoints) != 1 || len(robot.DicePoints) != 1 || human.DicePoints[0] >= robot.DicePoints[0] {
t.Fatalf("black pool force switch should settle as robot win with random losing pair, human=%+v robot=%+v", human, robot)
}
if repo.poolBalance != 182 || repo.stakePool.BalanceCoin != 182 {
t.Fatalf("black pool force robot win should put user stake net of fee into pool, legacy=%d stake=%d", repo.poolBalance, repo.stakePool.BalanceCoin)
}
}
func TestNewUserRobotRockChoosesLosingGestureWhenProtectionHits(t *testing.T) {
repo := newFakeDiceRepository()
configureFakeRock(repo)

View File

@ -305,11 +305,12 @@ func (r *Repository) Migrate(ctx context.Context) error {
day1_subsidy_quota_coin BIGINT NOT NULL DEFAULT 15000,
single_round_subsidy_cap_coin BIGINT NOT NULL DEFAULT 5000,
strategy_level VARCHAR(32) NOT NULL DEFAULT 'soft_boost',
lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1,
lose_streak_trigger INT NOT NULL DEFAULT 2,
normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1,
lose_streak_trigger INT NOT NULL DEFAULT 2,
normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53,
black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_self_game_user_protection_states (
@ -505,6 +506,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "normal_phase_target_win_rate_percent", "normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53 AFTER lose_streak_trigger"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "black_pool_robot_force_win_enabled", "black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER normal_phase_target_win_rate_percent"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil {
return err
}

View File

@ -161,6 +161,7 @@ func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) {
LoseStreakProtectionEnabled: true,
LoseStreakTrigger: 2,
NormalPhaseTargetWinRatePercent: 57,
BlackPoolRobotForceWinEnabled: true,
}, nowMS+4)
if err != nil {
t.Fatalf("upsert new user policy failed: %v", err)
@ -168,6 +169,9 @@ func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) {
if savedPolicy.NormalPhaseTargetWinRatePercent != 57 {
t.Fatalf("normal phase target win rate was not persisted: %+v", savedPolicy)
}
if !savedPolicy.BlackPoolRobotForceWinEnabled {
t.Fatalf("black pool robot force-win switch was not persisted: %+v", savedPolicy)
}
readPolicy, err := repo.GetSelfGameNewUserPolicy(ctx, "lalu", dicedomain.DefaultGameID, nowMS+5)
if err != nil {
t.Fatalf("get new user policy failed: %v", err)
@ -175,6 +179,9 @@ func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) {
if readPolicy.NormalPhaseTargetWinRatePercent != 57 {
t.Fatalf("normal phase target win rate readback mismatch: %+v", readPolicy)
}
if !readPolicy.BlackPoolRobotForceWinEnabled {
t.Fatalf("black pool robot force-win switch readback mismatch: %+v", readPolicy)
}
if err := repo.RecordSelfGameStrategyLog(ctx, selfgame.StrategyLog{
AppCode: "lalu",

View File

@ -113,7 +113,7 @@ func (r *Repository) GetSelfGameNewUserPolicy(ctx context.Context, appCode strin
row := r.db.QueryRowContext(ctx,
`SELECT app_code, game_id, enabled, protection_rounds, protection_hours, max_stake_coin,
lifetime_subsidy_quota_coin, day1_subsidy_quota_coin, single_round_subsidy_cap_coin,
strategy_level, lose_streak_protection_enabled, lose_streak_trigger, normal_phase_target_win_rate_percent,
strategy_level, lose_streak_protection_enabled, lose_streak_trigger, normal_phase_target_win_rate_percent, black_pool_robot_force_win_enabled,
created_at_ms, updated_at_ms
FROM game_self_game_new_user_policies
WHERE app_code = ? AND game_id = ?`,
@ -123,7 +123,7 @@ func (r *Repository) GetSelfGameNewUserPolicy(ctx context.Context, appCode strin
if err := row.Scan(
&policy.AppCode, &policy.GameID, &policy.Enabled, &policy.ProtectionRounds, &policy.ProtectionHours, &policy.MaxStakeCoin,
&policy.LifetimeSubsidyQuotaCoin, &policy.Day1SubsidyQuotaCoin, &policy.SingleRoundSubsidyCap,
&policy.StrategyLevel, &policy.LoseStreakProtectionEnabled, &policy.LoseStreakTrigger, &policy.NormalPhaseTargetWinRatePercent,
&policy.StrategyLevel, &policy.LoseStreakProtectionEnabled, &policy.LoseStreakTrigger, &policy.NormalPhaseTargetWinRatePercent, &policy.BlackPoolRobotForceWinEnabled,
&policy.CreatedAtMS, &policy.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@ -149,9 +149,9 @@ func (r *Repository) UpsertSelfGameNewUserPolicy(ctx context.Context, policy sel
`INSERT INTO game_self_game_new_user_policies (
app_code, game_id, enabled, protection_rounds, protection_hours, max_stake_coin,
lifetime_subsidy_quota_coin, day1_subsidy_quota_coin, single_round_subsidy_cap_coin,
strategy_level, lose_streak_protection_enabled, lose_streak_trigger, normal_phase_target_win_rate_percent,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
strategy_level, lose_streak_protection_enabled, lose_streak_trigger, normal_phase_target_win_rate_percent, black_pool_robot_force_win_enabled,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
protection_rounds = VALUES(protection_rounds),
@ -161,13 +161,14 @@ func (r *Repository) UpsertSelfGameNewUserPolicy(ctx context.Context, policy sel
day1_subsidy_quota_coin = VALUES(day1_subsidy_quota_coin),
single_round_subsidy_cap_coin = VALUES(single_round_subsidy_cap_coin),
strategy_level = VALUES(strategy_level),
lose_streak_protection_enabled = VALUES(lose_streak_protection_enabled),
lose_streak_trigger = VALUES(lose_streak_trigger),
normal_phase_target_win_rate_percent = VALUES(normal_phase_target_win_rate_percent),
updated_at_ms = VALUES(updated_at_ms)`,
lose_streak_protection_enabled = VALUES(lose_streak_protection_enabled),
lose_streak_trigger = VALUES(lose_streak_trigger),
normal_phase_target_win_rate_percent = VALUES(normal_phase_target_win_rate_percent),
black_pool_robot_force_win_enabled = VALUES(black_pool_robot_force_win_enabled),
updated_at_ms = VALUES(updated_at_ms)`,
policy.AppCode, policy.GameID, policy.Enabled, policy.ProtectionRounds, policy.ProtectionHours, policy.MaxStakeCoin,
policy.LifetimeSubsidyQuotaCoin, policy.Day1SubsidyQuotaCoin, policy.SingleRoundSubsidyCap,
policy.StrategyLevel, policy.LoseStreakProtectionEnabled, policy.LoseStreakTrigger, policy.NormalPhaseTargetWinRatePercent,
policy.StrategyLevel, policy.LoseStreakProtectionEnabled, policy.LoseStreakTrigger, policy.NormalPhaseTargetWinRatePercent, policy.BlackPoolRobotForceWinEnabled,
policy.CreatedAtMS, policy.UpdatedAtMS,
); err != nil {
return selfgame.NewUserPolicy{}, err

View File

@ -867,6 +867,7 @@ func selfGameNewUserPolicyToProto(policy selfgame.NewUserPolicy) *gamev1.SelfGam
LoseStreakProtectionEnabled: policy.LoseStreakProtectionEnabled,
LoseStreakTrigger: policy.LoseStreakTrigger,
NormalPhaseTargetWinRatePercent: policy.NormalPhaseTargetWinRatePercent,
BlackPoolRobotForceWinEnabled: policy.BlackPoolRobotForceWinEnabled,
CreatedAtMs: policy.CreatedAtMS,
UpdatedAtMs: policy.UpdatedAtMS,
}
@ -890,6 +891,7 @@ func selfGameNewUserPolicyFromProto(policy *gamev1.SelfGameNewUserPolicy) selfga
LoseStreakProtectionEnabled: policy.GetLoseStreakProtectionEnabled(),
LoseStreakTrigger: policy.GetLoseStreakTrigger(),
NormalPhaseTargetWinRatePercent: policy.GetNormalPhaseTargetWinRatePercent(),
BlackPoolRobotForceWinEnabled: policy.GetBlackPoolRobotForceWinEnabled(),
CreatedAtMS: policy.GetCreatedAtMs(),
UpdatedAtMS: policy.GetUpdatedAtMs(),
}

View File

@ -2998,6 +2998,99 @@ func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) {
}
}
// TestGrantMicSeatAnimationAutoEquips 验证麦位声波资源在发放事务内默认佩戴。
func TestGrantMicSeatAnimationAutoEquips(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
firstMic, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "mic_wave_blue",
ResourceType: resourcedomain.TypeMicSeatAnimation,
Name: "Blue Mic Wave",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"mic_seat"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create first mic seat animation resource failed: %v", err)
}
secondMic, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
ResourceCode: "mic_wave_gold",
ResourceType: resourcedomain.TypeMicSeatAnimation,
Name: "Gold Mic Wave",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"mic_seat"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create second mic seat animation resource failed: %v", err)
}
firstGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-grant-mic-wave-blue",
TargetUserID: 42003,
ResourceID: firstMic.ResourceID,
Quantity: 1,
Reason: "test mic auto equip",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
if err != nil {
t.Fatalf("grant first mic seat animation resource failed: %v", err)
}
resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
UserID: 42003,
ResourceType: resourcedomain.TypeMicSeatAnimation,
ActiveOnly: true,
})
if err != nil {
t.Fatalf("list mic seat animation resources failed: %v", err)
}
if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != firstGrant.Items[0].EntitlementID {
t.Fatalf("single resource grant should auto equip mic seat animation: resources=%+v grant=%+v", resources, firstGrant)
}
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
GroupCode: "mic_wave_group",
Name: "Mic Wave Group",
Status: resourcedomain.StatusActive,
Items: []resourcedomain.ResourceGroupItemInput{{ResourceID: secondMic.ResourceID, Quantity: 1, SortOrder: 1}},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create mic seat animation group failed: %v", err)
}
groupGrant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
CommandID: "cmd-grant-mic-wave-group",
TargetUserID: 42003,
GroupID: group.GroupID,
Reason: "test group mic auto equip",
OperatorUserID: 90001,
GrantSource: resourcedomain.GrantSourceAdmin,
})
if err != nil {
t.Fatalf("grant mic seat animation group failed: %v", err)
}
equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
UserIDs: []int64{42003},
ResourceTypes: []string{resourcedomain.TypeMicSeatAnimation},
})
if err != nil {
t.Fatalf("batch get mic seat animation equipment failed: %v", err)
}
if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].EntitlementID != groupGrant.Items[0].EntitlementID {
t.Fatalf("group grant should auto equip the latest mic seat animation: equipped=%+v group_grant=%+v", equippedResources, groupGrant)
}
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(42003), resourcedomain.TypeMicSeatAnimation); got != 1 {
t.Fatalf("mic seat animation equipment should keep one active row, got %d", got)
}
}
// TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。
func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
repository := mysqltest.NewRepository(t)

View File

@ -1048,7 +1048,7 @@ func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedo
if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) {
return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
}
if isAutoEquippedBadgeResourceType(command.ResourceType) {
if resourcedomain.NormalizeResourceType(command.ResourceType) == resourcedomain.TypeBadge {
// 徽章是“拥有即佩戴”的展示资源,不再支持按类型整类卸下。
// 旧客户端如果仍调用 badge unequip按幂等无变更返回避免把用户全部徽章一次删掉。
return resourcedomain.UnequipUserResourceResult{
@ -1699,7 +1699,7 @@ func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID str
}
item.ResultType = resourcedomain.ResultEntitlement
item.EntitlementID = entitlementID
if err := r.autoEquipGrantedBadgeEntitlementTx(ctx, tx, targetUserID, resource, entitlementID, nowMs); err != nil {
if err := r.autoEquipGrantedEntitlementTx(ctx, tx, targetUserID, resource, entitlementID, nowMs); err != nil {
return resourcedomain.ResourceGrantItem{}, err
}
}
@ -1848,8 +1848,8 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in
return entitlementID, nil
}
func (r *Repository) autoEquipGrantedBadgeEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error {
if !isAutoEquippedBadgeResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" {
func (r *Repository) autoEquipGrantedEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error {
if !isAutoEquippedOnGrantResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" {
return nil
}
entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
@ -1860,7 +1860,8 @@ func (r *Repository) autoEquipGrantedBadgeEntitlementTx(ctx context.Context, tx
return err
}
entitlement.Equipped = true
// 徽章发放来自商店购买、VIP/活动资源组和后台资源发放,统一在 wallet 授权事务内自动佩戴。
// 徽章和麦位声波都属于“拿到即可默认展示”的装扮资源;写 equipment 必须和授权在同一事务内完成,
// 否则发放成功但佩戴失败会让背包、麦位表现和后续投影读到不一致的状态。
// 单独发 UserResourceChanged 事件,让只监听外观变化的投影不用重新理解 ResourceGranted 的来源差异。
eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano())
return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
@ -3171,8 +3172,13 @@ func allowsMultipleEquippedResources(resourceType string) bool {
return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge
}
func isAutoEquippedBadgeResourceType(resourceType string) bool {
return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge
func isAutoEquippedOnGrantResourceType(resourceType string) bool {
switch resourcedomain.NormalizeResourceType(resourceType) {
case resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation:
return true
default:
return false
}
}
func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error {
@ -3604,7 +3610,7 @@ func validateActiveResourceGroupResource(resource resourcedomain.Resource) error
func isEquipableResourceType(resourceType string) bool {
switch resourcedomain.NormalizeResourceType(resourceType) {
case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge:
case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation:
return true
default:
return false

View File

@ -6,8 +6,18 @@ import (
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
)
func TestProfileCardResourceIsEquipable(t *testing.T) {
if !isEquipableResourceType(resourcedomain.TypeProfileCard) {
t.Fatalf("profile_card resources must use the equipment slot just like other profile decorations")
func TestDecorativeResourceTypesAreEquipable(t *testing.T) {
for _, resourceType := range []string{resourcedomain.TypeProfileCard, resourcedomain.TypeMicSeatAnimation} {
if !isEquipableResourceType(resourceType) {
t.Fatalf("%s resources must use the equipment slot just like other profile decorations", resourceType)
}
}
}
func TestGrantDefaultEquippedResourceTypes(t *testing.T) {
for _, resourceType := range []string{resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation} {
if !isAutoEquippedOnGrantResourceType(resourceType) {
t.Fatalf("%s resources must auto equip inside resource grant transactions", resourceType)
}
}
}