810 lines
33 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 roomrps
import (
"context"
"fmt"
"testing"
"time"
"google.golang.org/grpc"
gamev1 "hyapp.local/api/proto/game/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
)
func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) {
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
"2001": roomRPSWalletGift("2001", "Rose", "https://cdn.example.test/rose.png", "", "https://cdn.example.test/rose.mp4", 100),
"2002": roomRPSWalletGift("2002", "Bell", "https://cdn.example.test/bell.mp4", "https://cdn.example.test/bell-preview.png", "https://cdn.example.test/bell.svga", 300),
"2003": roomRPSWalletGift("2003", "Crown", "", "", "https://cdn.example.test/crown.svga", 500),
"2004": roomRPSWalletGift("2004", "Cup", "https://cdn.example.test/cup.png", "", "", 1000),
})
svc := New(Config{}, nil, nil, catalog)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
config, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
Status: "active",
ChallengeTimeoutMs: 600000,
RevealCountdownMs: 3000,
StakeGifts: []*gamev1.RoomRPSStakeGift{
{GiftIdText: "2001", Enabled: true, SortOrder: 1},
{GiftIdText: "2002", Enabled: true, SortOrder: 2},
{GiftIdText: "2003", Enabled: true, SortOrder: 3},
{GiftIdText: "2004", Enabled: true, SortOrder: 4},
},
})
if err != nil {
t.Fatalf("UpdateConfig() error = %v", err)
}
if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rose" || got.GetGiftIconUrl() != "https://cdn.example.test/rose.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/rose.mp4" || got.GetGiftPriceCoin() != 100 {
t.Fatalf("first gift was not enriched from wallet catalog: %+v", got)
}
if got := config.GetStakeGifts()[1]; got.GetGiftIconUrl() != "https://cdn.example.test/bell-preview.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/bell.svga" {
t.Fatalf("second gift should use preview_url as icon and animation_url as animation: %+v", got)
}
if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "" || got.GetGiftAnimationUrl() != "https://cdn.example.test/crown.svga" {
t.Fatalf("third gift should not use animation_url as display icon: %+v", got)
}
if len(catalog.requests) != 4 {
t.Fatalf("expected one exact catalog lookup per missing gift, got %d", len(catalog.requests))
}
if req := catalog.requests[0]; req.GetAppCode() != "lalu" || req.GetKeyword() != "2001" || req.GetPage() != 1 || req.GetPageSize() != 100 {
t.Fatalf("catalog lookup request mismatch: %+v", req)
}
if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "2001" || got.GetGiftId() != 2001 {
t.Fatalf("numeric gift should keep string primary ID and legacy number: %+v", got)
}
}
func TestGetConfigBackfillsLegacyEmptyGiftSnapshot(t *testing.T) {
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
"Gifi-3": roomRPSWalletGift("Gifi-3", "Rocket", "https://cdn.example.test/rocket.png", "", "", 900),
})
svc := New(Config{}, nil, nil, catalog)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
svc.configs["lalu"] = &gamev1.RoomRPSConfig{
AppCode: "lalu",
GameId: GameID,
Status: "active",
ChallengeTimeoutMs: 600000,
RevealCountdownMs: 3000,
StakeGifts: []*gamev1.RoomRPSStakeGift{
{GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1},
{GiftId: 10002, Enabled: true, SortOrder: 2},
{GiftId: 10003, Enabled: true, SortOrder: 3},
{GiftId: 10004, Enabled: true, SortOrder: 4},
},
CreatedAtMs: 1000,
UpdatedAtMs: 2000,
}
config, _, err := svc.GetConfig(context.Background(), "lalu")
if err != nil {
t.Fatalf("GetConfig() error = %v", err)
}
if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rocket" || got.GetGiftIconUrl() != "https://cdn.example.test/rocket.png" || got.GetGiftPriceCoin() != 900 {
t.Fatalf("legacy config response was not enriched: %+v", got)
}
if cached := svc.configs["lalu"].GetStakeGifts()[0]; cached.GetGiftIconUrl() == "" {
t.Fatalf("legacy config was not backfilled into service cache: %+v", cached)
}
}
func TestGetConfigMovesLegacyAnimatedIconIntoAnimationURL(t *testing.T) {
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
"Gifi-3": roomRPSWalletGift("Gifi-3", "Rocket", "https://cdn.example.test/rocket-cover.png", "", "https://cdn.example.test/rocket.mp4", 900),
})
svc := New(Config{}, nil, nil, catalog)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
svc.configs["lalu"] = &gamev1.RoomRPSConfig{
AppCode: "lalu",
GameId: GameID,
Status: "active",
ChallengeTimeoutMs: 600000,
RevealCountdownMs: 3000,
StakeGifts: []*gamev1.RoomRPSStakeGift{
{GiftIdText: "Gifi-3", GiftName: "Rocket", GiftIconUrl: "https://cdn.example.test/rocket.mp4", GiftPriceCoin: 900, Enabled: true, SortOrder: 1},
{GiftId: 10002, Enabled: true, SortOrder: 2},
{GiftId: 10003, Enabled: true, SortOrder: 3},
{GiftId: 10004, Enabled: true, SortOrder: 4},
},
CreatedAtMs: 1000,
UpdatedAtMs: 2000,
}
config, _, err := svc.GetConfig(context.Background(), "lalu")
if err != nil {
t.Fatalf("GetConfig() error = %v", err)
}
if got := config.GetStakeGifts()[0]; got.GetGiftIconUrl() != "https://cdn.example.test/rocket-cover.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/rocket.mp4" {
t.Fatalf("legacy animated icon was not split into cover and animation fields: %+v", got)
}
}
func TestCreateChallengeMatchesStringGiftID(t *testing.T) {
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
"Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 1),
"Gifi-2": roomRPSWalletGift("Gifi-2", "Cowboy", "https://cdn.example.test/cowboy.png", "", "", 1),
"Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 1),
"lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 111),
})
wallet := newFakeRoomRPSWallet(map[int64]int64{42: 10})
svc := New(Config{}, nil, nil, catalog, wallet)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
Status: "active",
ChallengeTimeoutMs: 600000,
RevealCountdownMs: 3000,
StakeGifts: []*gamev1.RoomRPSStakeGift{
{GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1},
{GiftIdText: "Gifi-2", Enabled: true, SortOrder: 2},
{GiftIdText: "Gifi-1", Enabled: true, SortOrder: 3},
{GiftIdText: "lw1", Enabled: true, SortOrder: 4},
},
})
if err != nil {
t.Fatalf("UpdateConfig() error = %v", err)
}
challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu"},
UserId: 42,
RoomId: "room-1",
RegionId: 1001,
GiftIdText: "Gifi-3",
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
if challenge.GetStakeGiftIdText() != "Gifi-3" || challenge.GetStakeGiftId() != 0 || challenge.GetStakeCoin() != 1 {
t.Fatalf("string gift challenge mismatch: %+v", challenge)
}
if challenge.GetInitiator().GetBalanceAfter() != 9 || wallet.balances[42] != 9 {
t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator())
}
}
func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) {
catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{
"Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 100),
"Gifi-2": roomRPSWalletGift("Gifi-2", "Cowboy", "https://cdn.example.test/cowboy.png", "", "", 200),
"Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 300),
"lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 400),
})
wallet := newFakeRoomRPSWallet(map[int64]int64{42: 99})
svc := New(Config{}, nil, nil, catalog, wallet)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
Status: "active",
ChallengeTimeoutMs: 600000,
RevealCountdownMs: 3000,
StakeGifts: []*gamev1.RoomRPSStakeGift{
{GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1},
{GiftIdText: "Gifi-2", Enabled: true, SortOrder: 2},
{GiftIdText: "Gifi-1", Enabled: true, SortOrder: 3},
{GiftIdText: "lw1", Enabled: true, SortOrder: 4},
},
})
if err != nil {
t.Fatalf("UpdateConfig() error = %v", err)
}
_, _, err = svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-low-balance"},
UserId: 42,
RoomId: "room-1",
GiftIdText: "Gifi-3",
Gesture: "rock",
})
if !xerr.IsCode(err, xerr.InsufficientBalance) {
t.Fatalf("CreateChallenge() error = %v, want InsufficientBalance", err)
}
if len(svc.challenges) != 0 {
t.Fatalf("insufficient balance must not create pending challenge: %+v", svc.challenges)
}
if wallet.balances[42] != 99 || len(wallet.applies) != 1 {
t.Fatalf("insufficient create must not mutate wallet balance: balance=%d applies=%d", wallet.balances[42], len(wallet.applies))
}
}
func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) {
store := newFakeRoomRPSConfigStore()
svc := New(Config{}, nil, nil, store)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
Status: "active",
ChallengeTimeoutMs: 600000,
RevealCountdownMs: 3000,
StakeGifts: []*gamev1.RoomRPSStakeGift{
{GiftIdText: "persist-1", GiftName: "Persist One", GiftIconUrl: "https://cdn.example.test/one.png", GiftPriceCoin: 11, Enabled: true, SortOrder: 1},
{GiftIdText: "persist-2", GiftName: "Persist Two", GiftIconUrl: "https://cdn.example.test/two.png", GiftPriceCoin: 22, Enabled: true, SortOrder: 2},
{GiftIdText: "persist-3", GiftName: "Persist Three", GiftIconUrl: "https://cdn.example.test/three.png", GiftPriceCoin: 33, Enabled: true, SortOrder: 3},
{GiftIdText: "persist-4", GiftName: "Persist Four", GiftIconUrl: "https://cdn.example.test/four.png", GiftPriceCoin: 44, Enabled: true, SortOrder: 4},
},
})
if err != nil {
t.Fatalf("UpdateConfig() error = %v", err)
}
if store.upserts != 1 {
t.Fatalf("expected one persistent upsert, got %d", store.upserts)
}
restarted := New(Config{}, nil, nil, store)
restarted.now = func() time.Time { return time.UnixMilli(1770000060000) }
config, _, err := restarted.GetConfig(context.Background(), "lalu")
if err != nil {
t.Fatalf("GetConfig() after restart error = %v", err)
}
if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "persist-1" || got.GetGiftPriceCoin() != 11 {
t.Fatalf("persistent config was not reused after restart: %+v", got)
}
}
func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) {
store := newFakeRoomRPSConfigStore()
store.configs["lalu:"+GameID] = &gamev1.RoomRPSConfig{
AppCode: "lalu",
GameId: GameID,
Status: "active",
ChallengeTimeoutMs: 600000,
RevealCountdownMs: 3000,
StakeGifts: []*gamev1.RoomRPSStakeGift{
{GiftIdText: "db-gift", GiftName: "DB Gift", GiftIconUrl: "https://cdn.example.test/db.png", GiftPriceCoin: 222, Enabled: true, SortOrder: 1},
{GiftIdText: "db-gift-2", GiftName: "DB Gift 2", GiftIconUrl: "https://cdn.example.test/db2.png", GiftPriceCoin: 333, Enabled: true, SortOrder: 2},
{GiftIdText: "db-gift-3", GiftName: "DB Gift 3", GiftIconUrl: "https://cdn.example.test/db3.png", GiftPriceCoin: 444, Enabled: true, SortOrder: 3},
{GiftIdText: "db-gift-4", GiftName: "DB Gift 4", GiftIconUrl: "https://cdn.example.test/db4.png", GiftPriceCoin: 555, Enabled: true, SortOrder: 4},
},
CreatedAtMs: 1000,
UpdatedAtMs: 2000,
}
wallet := newFakeRoomRPSWallet(map[int64]int64{42: 1000})
svc := New(Config{}, nil, nil, store, wallet)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu"},
UserId: 42,
RoomId: "room-1",
GiftIdText: "db-gift",
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
if challenge.GetStakeGiftIdText() != "db-gift" || challenge.GetStakeCoin() != 222 {
t.Fatalf("challenge did not use persistent config: %+v", challenge)
}
if challenge.GetInitiator().GetBalanceAfter() != 778 || wallet.balances[42] != 778 {
t.Fatalf("persistent config challenge did not debit stake: challenge=%+v wallet=%d", challenge, wallet.balances[42])
}
}
func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) {
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 99})
svc := New(Config{}, nil, nil, wallet)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"},
UserId: 41,
RoomId: "room-1",
GiftId: 10001,
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-low-balance"},
UserId: 42,
ChallengeId: created.GetChallengeId(),
Gesture: "paper",
})
if !xerr.IsCode(err, xerr.InsufficientBalance) {
t.Fatalf("AcceptChallenge() error = %v, want InsufficientBalance", err)
}
current, _, err := svc.GetChallenge(context.Background(), "lalu", created.GetChallengeId())
if err != nil {
t.Fatalf("GetChallenge() error = %v", err)
}
if current.GetStatus() != StatusPending || current.GetChallenger().GetUserId() != 0 {
t.Fatalf("insufficient balance must leave challenge pending without challenger: %+v", current)
}
if wallet.balances[41] != 900 || wallet.balances[42] != 99 {
t.Fatalf("accept failure should keep initiator debit and avoid challenger debit: balances=%+v", wallet.balances)
}
}
func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) {
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500})
room := &fakeRoomRPSRoom{}
svc := New(Config{}, nil, nil, wallet, room)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"},
UserId: 41,
RoomId: "room-1",
GiftId: 10001,
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept"},
UserId: 42,
ChallengeId: created.GetChallengeId(),
Gesture: "paper",
})
if err != nil {
t.Fatalf("AcceptChallenge() error = %v", err)
}
if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementSettled || accepted.GetInitiator().GetBalanceAfter() != 900 || accepted.GetChallenger().GetBalanceAfter() != 500 {
t.Fatalf("accepted challenge balance snapshot mismatch: %+v", accepted)
}
if wallet.balances[41] != 900 || wallet.balances[42] != 500 {
t.Fatalf("winner refund and loser debit mismatch: balances=%+v", wallet.balances)
}
if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund]" {
t.Fatalf("winner settlement should only debit both and refund winner, got ops=%v", got)
}
if len(room.requests) != 1 {
t.Fatalf("settled PK must apply exactly one loser gift to room rocket, got %d", len(room.requests))
}
rocketGift := room.requests[0]
if rocketGift.GetSenderUserId() != 41 || rocketGift.GetTargetUserId() != 42 || rocketGift.GetGiftId() != "10001" || rocketGift.GetHeatValue() != 100 {
t.Fatalf("room rocket gift must describe loser -> winner and server stake: %+v", rocketGift)
}
}
func TestRetrySettlementReplaysIdempotentWalletAndRoomCommands(t *testing.T) {
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500})
// 模拟 room 已落地 command_id但响应在网络上丢失的未知结果这是
// 最容易因盲目重试造成双充能的边界,必须靠稳定 room command_id 收敛。
room := &fakeRoomRPSRoom{failAfterApply: 1}
publisher := &fakeRoomRPSPublisher{}
svc := New(Config{}, nil, publisher, wallet, room)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-retry"},
UserId: 41,
RoomId: "room-retry",
GiftId: 10001,
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-retry"},
UserId: 42,
ChallengeId: created.GetChallengeId(),
Gesture: "paper",
})
if !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("AcceptChallenge() error = %v, want room Unavailable", err)
}
failed, _, err := svc.GetChallenge(context.Background(), "lalu", created.GetChallengeId())
if err != nil {
t.Fatalf("GetChallenge() failed state error = %v", err)
}
if failed.GetStatus() != StatusSettlementFailed || failed.GetSettlementStatus() != SettlementFailed {
t.Fatalf("room unknown result must remain retryable: %+v", failed)
}
if wallet.balances[41] != 900 || wallet.balances[42] != 500 {
t.Fatalf("first settlement must debit loser and refund winner once: balances=%+v", wallet.balances)
}
if len(room.requests) != 1 || len(publisher.messages) != 1 {
t.Fatalf("failed settlement must not publish terminal IM: room_calls=%d messages=%v", len(room.requests), publisher.messageTypes())
}
retried, _, err := svc.RetrySettlement(context.Background(), "lalu", created.GetChallengeId())
if err != nil {
t.Fatalf("RetrySettlement() error = %v", err)
}
if retried.GetStatus() != StatusFinished || retried.GetSettlementStatus() != SettlementSettled || retried.GetChallenger().GetBalanceAfter() != 500 {
t.Fatalf("retry did not converge settled snapshot: %+v", retried)
}
if wallet.balances[41] != 900 || wallet.balances[42] != 500 {
t.Fatalf("wallet replay must not refund winner twice: balances=%+v", wallet.balances)
}
if len(wallet.applies) != 4 || wallet.applies[2].GetCommandId() != wallet.applies[3].GetCommandId() {
t.Fatalf("retry must replay the same wallet refund command: applies=%+v", wallet.applies)
}
if len(room.requests) != 2 || room.requests[0].GetMeta().GetCommandId() != room.requests[1].GetMeta().GetCommandId() {
t.Fatalf("retry must replay the same room gift command: requests=%+v", room.requests)
}
roomCommandID := room.requests[0].GetMeta().GetCommandId()
if room.applied[roomCommandID] != 1 {
t.Fatalf("room unknown-result replay must apply one gift, command_id=%q applied=%d", roomCommandID, room.applied[roomCommandID])
}
if got := publisher.messageTypes(); fmt.Sprint(got) != "[room_rps_challenge_created room_rps_finished]" {
t.Fatalf("retry must publish exactly one terminal IM after all owners settle, got %v", got)
}
walletCalls := len(wallet.applies)
roomCalls := len(room.requests)
messageCount := len(publisher.messages)
if _, _, err := svc.RetrySettlement(context.Background(), "lalu", created.GetChallengeId()); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("second RetrySettlement() error = %v, want Conflict", err)
}
if len(wallet.applies) != walletCalls || len(room.requests) != roomCalls || len(publisher.messages) != messageCount {
t.Fatalf("settled retry must have no wallet, room, or IM side effects")
}
}
func TestAcceptChallengeReturnsTakenWhenAnotherUserAlreadyAccepted(t *testing.T) {
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500, 43: 500})
svc := New(Config{}, nil, nil, wallet)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"},
UserId: 41,
RoomId: "room-1",
GiftId: 10001,
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-first-accept"},
UserId: 42,
ChallengeId: created.GetChallengeId(),
Gesture: "paper",
})
if err != nil {
t.Fatalf("first AcceptChallenge() error = %v", err)
}
_, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-late-accept"},
UserId: 43,
ChallengeId: created.GetChallengeId(),
Gesture: "scissors",
})
if !xerr.IsCode(err, xerr.RoomRPSChallengeTaken) {
t.Fatalf("late AcceptChallenge() error = %v, want RoomRPSChallengeTaken", err)
}
if wallet.balances[43] != 500 {
t.Fatalf("late challenger must not be debited: balance=%d", wallet.balances[43])
}
}
func TestCreateChallengeFreezesConfiguredTimeoutForEachNewOrder(t *testing.T) {
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 1000})
store := newFakeRoomRPSConfigStore()
svc := New(Config{}, nil, nil, wallet, store)
now := time.UnixMilli(1770000000000)
svc.now = func() time.Time { return now }
updateTimeout := func(timeoutMS int64) {
t.Helper()
_, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{
Status: "active",
ChallengeTimeoutMs: timeoutMS,
RevealCountdownMs: 3000,
StakeGifts: defaultGifts(),
})
if err != nil {
t.Fatalf("UpdateConfig(%d) error = %v", timeoutMS, err)
}
}
create := func(userID int64, requestID string) *gamev1.RoomRPSChallenge {
t.Helper()
challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: requestID},
UserId: userID,
RoomId: "room-1",
GiftId: 10001,
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
return challenge
}
updateTimeout(60000)
first := create(41, "req-create-60s")
if got, want := first.GetTimeoutAtMs(), now.UnixMilli()+60000; got != want {
t.Fatalf("first timeout_at_ms = %d, want configured boundary %d", got, want)
}
updateTimeout(120000)
second := create(42, "req-create-120s")
if got, want := second.GetTimeoutAtMs(), now.UnixMilli()+120000; got != want {
t.Fatalf("second timeout_at_ms = %d, want configured boundary %d", got, want)
}
if got, want := first.GetTimeoutAtMs(), now.UnixMilli()+60000; got != want {
t.Fatalf("existing challenge timeout changed after config update: got %d want %d", got, want)
}
}
func TestAcceptChallengeReturnsExpiredAtConfiguredBoundary(t *testing.T) {
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 1000})
svc := New(Config{ChallengeTimeout: 60 * time.Second}, nil, nil, wallet)
now := time.UnixMilli(1770000000000)
svc.now = func() time.Time { return now }
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-expiring"},
UserId: 41,
RoomId: "room-1",
GiftId: 10001,
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
// 截止毫秒是排他边界:到达 timeout_at_ms 即失效,不能再多放行一个毫秒。
now = time.UnixMilli(created.GetTimeoutAtMs())
accept := func(requestID string) error {
t.Helper()
_, _, acceptErr := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: requestID},
UserId: 42,
ChallengeId: created.GetChallengeId(),
Gesture: "paper",
})
return acceptErr
}
if err := accept("req-accept-expired"); !xerr.IsCode(err, xerr.RoomRPSChallengeExpired) {
t.Fatalf("AcceptChallenge() error = %v, want RoomRPSChallengeExpired", err)
}
if wallet.balances[41] != 1000 || wallet.balances[42] != 1000 {
t.Fatalf("expired challenge must refund initiator without debiting challenger: balances=%+v", wallet.balances)
}
// 已收敛为 timeout 的旧挑战再次点击时仍返回同一业务码,不能退化回通用 CONFLICT。
if err := accept("req-accept-expired-again"); !xerr.IsCode(err, xerr.RoomRPSChallengeExpired) {
t.Fatalf("repeated AcceptChallenge() error = %v, want RoomRPSChallengeExpired", err)
}
}
func TestAcceptChallengeDrawRefundsBothPlayers(t *testing.T) {
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500})
svc := New(Config{}, nil, nil, wallet)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-draw"},
UserId: 41,
RoomId: "room-1",
GiftId: 10001,
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-draw"},
UserId: 42,
ChallengeId: created.GetChallengeId(),
Gesture: "rock",
})
if err != nil {
t.Fatalf("AcceptChallenge() error = %v", err)
}
if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementRefunded || accepted.GetWinnerUserId() != 0 {
t.Fatalf("draw challenge settlement mismatch: %+v", accepted)
}
if accepted.GetInitiator().GetBalanceAfter() != 1000 || accepted.GetChallenger().GetBalanceAfter() != 500 {
t.Fatalf("draw must refund both players in response: %+v", accepted)
}
if wallet.balances[41] != 1000 || wallet.balances[42] != 500 {
t.Fatalf("draw must refund both players in wallet: balances=%+v", wallet.balances)
}
if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund refund]" {
t.Fatalf("draw should only debit both and refund both without gift op, got ops=%v", got)
}
}
func TestExpireChallengeRefundsInitiatorDebit(t *testing.T) {
wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000})
svc := New(Config{}, nil, nil, wallet)
svc.now = func() time.Time { return time.UnixMilli(1770000000000) }
created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{
Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-expire"},
UserId: 41,
RoomId: "room-1",
GiftId: 10001,
Gesture: "rock",
})
if err != nil {
t.Fatalf("CreateChallenge() error = %v", err)
}
if wallet.balances[41] != 900 || created.GetInitiator().GetBalanceAfter() != 900 {
t.Fatalf("create should debit initiator before pending challenge: challenge=%+v wallet=%d", created, wallet.balances[41])
}
expired, _, err := svc.ExpireChallenge(context.Background(), "lalu", created.GetChallengeId())
if err != nil {
t.Fatalf("ExpireChallenge() error = %v", err)
}
if expired.GetStatus() != StatusTimeout || expired.GetSettlementStatus() != SettlementRefunded || expired.GetInitiator().GetBalanceAfter() != 1000 {
t.Fatalf("expired challenge should refund initiator: %+v", expired)
}
if wallet.balances[41] != 1000 {
t.Fatalf("expired challenge wallet balance mismatch: %d", wallet.balances[41])
}
if got := wallet.ops(); fmt.Sprint(got) != "[debit refund]" {
t.Fatalf("expire should only debit then refund initiator, got ops=%v", got)
}
}
type fakeGiftCatalog struct {
gifts map[string]*walletv1.GiftConfig
requests []*walletv1.ListGiftConfigsRequest
}
func newFakeGiftCatalog(gifts map[string]*walletv1.GiftConfig) *fakeGiftCatalog {
return &fakeGiftCatalog{gifts: gifts}
}
func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
f.requests = append(f.requests, req)
if gift := f.gifts[req.GetKeyword()]; gift != nil {
return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{gift}, Total: 1}, nil
}
return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil
}
type fakeRoomRPSWallet struct {
balances map[int64]int64
requests []*walletv1.GetBalancesRequest
applies []*walletv1.ApplyGameCoinChangeRequest
replays map[string]*walletv1.ApplyGameCoinChangeResponse
}
type fakeRoomRPSRoom struct {
requests []*roomv1.ApplyRoomRPSGiftRequest
applied map[string]int
failAfterApply int
}
func (f *fakeRoomRPSRoom) ApplyRoomRPSGift(_ context.Context, req *roomv1.ApplyRoomRPSGiftRequest, _ ...grpc.CallOption) (*roomv1.ApplyRoomRPSGiftResponse, error) {
f.requests = append(f.requests, req)
if f.applied == nil {
f.applied = make(map[string]int)
}
commandID := req.GetMeta().GetCommandId()
if f.applied[commandID] == 0 {
f.applied[commandID]++
}
if f.failAfterApply > 0 {
f.failAfterApply--
return nil, xerr.New(xerr.Unavailable, "room response lost after apply")
}
return &roomv1.ApplyRoomRPSGiftResponse{}, nil
}
type fakeRoomRPSPublisher struct {
messages []tencentim.CustomGroupMessage
}
func (f *fakeRoomRPSPublisher) EnsureGroup(context.Context, tencentim.GroupSpec) error { return nil }
func (f *fakeRoomRPSPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
f.messages = append(f.messages, message)
return nil
}
func (f *fakeRoomRPSPublisher) messageTypes() []string {
types := make([]string, 0, len(f.messages))
for _, message := range f.messages {
types = append(types, message.Desc)
}
return types
}
func newFakeRoomRPSWallet(balances map[int64]int64) *fakeRoomRPSWallet {
return &fakeRoomRPSWallet{balances: balances, replays: make(map[string]*walletv1.ApplyGameCoinChangeResponse)}
}
func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
f.requests = append(f.requests, req)
return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{
AppCode: req.GetAppCode(),
AssetType: "COIN",
AvailableAmount: f.balances[req.GetUserId()],
}}}, nil
}
func (f *fakeRoomRPSWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
f.applies = append(f.applies, req)
if replay := f.replays[req.GetCommandId()]; replay != nil {
return replay, nil
}
switch req.GetOpType() {
case roomRPSOpDebit:
if f.balances[req.GetUserId()] < req.GetCoinAmount() {
return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
f.balances[req.GetUserId()] -= req.GetCoinAmount()
case roomRPSOpRefund:
f.balances[req.GetUserId()] += req.GetCoinAmount()
default:
return nil, xerr.New(xerr.InvalidArgument, "unsupported op_type")
}
resp := &walletv1.ApplyGameCoinChangeResponse{
WalletTransactionId: "wtx-" + req.GetCommandId(),
BalanceAfter: f.balances[req.GetUserId()],
}
f.replays[req.GetCommandId()] = resp
return resp, nil
}
func (f *fakeRoomRPSWallet) ops() []string {
out := make([]string, 0, len(f.applies))
for _, req := range f.applies {
out = append(out, req.GetOpType())
}
return out
}
func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig {
return &walletv1.GiftConfig{
AppCode: "lalu",
GiftId: giftID,
Name: name,
Status: "active",
CoinPrice: coinPrice,
Resource: &walletv1.Resource{
ResourceId: 100,
ResourceType: "gift",
Name: name,
Status: "active",
AssetUrl: assetURL,
PreviewUrl: previewURL,
AnimationUrl: animationURL,
},
}
}
type fakeRoomRPSConfigStore struct {
configs map[string]*gamev1.RoomRPSConfig
upserts int
}
func newFakeRoomRPSConfigStore() *fakeRoomRPSConfigStore {
return &fakeRoomRPSConfigStore{configs: make(map[string]*gamev1.RoomRPSConfig)}
}
func (f *fakeRoomRPSConfigStore) GetRoomRPSConfig(_ context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error) {
if gameID == "" {
gameID = GameID
}
config := f.configs[appCode+":"+gameID]
if config == nil {
return nil, xerr.New(xerr.NotFound, "room rps config not found")
}
return cloneConfig(config), nil
}
func (f *fakeRoomRPSConfigStore) UpsertRoomRPSConfig(_ context.Context, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) {
if config.GetGameId() == "" {
config.GameId = GameID
}
f.upserts++
f.configs[config.GetAppCode()+":"+config.GetGameId()] = cloneConfig(config)
return cloneConfig(config), nil
}