feat: game lobby service
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// Race-name intent tests cover the three notification types Lobby emits
|
||||
// across the capability-evaluation and self-service registration boundary:
|
||||
//
|
||||
// - lobby.race_name.registration_eligible — produced when a member's
|
||||
// stats satisfy the capability rule at game finish;
|
||||
// - lobby.race_name.registration_denied — produced when they do not;
|
||||
// - lobby.race_name.registered — produced when the user converts the
|
||||
// pending registration into a permanent registered name.
|
||||
//
|
||||
// The single test below drives a public game through start, publishes the
|
||||
// `gm:lobby_events` snapshot and `game_finished` events directly to Redis,
|
||||
// then performs the user-side registration call. Notification Service is
|
||||
// not booted: the assertion target is the contents of `notification:intents`.
|
||||
package lobbynotification_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
intentTypeRaceNameEligible = "lobby.race_name.registration_eligible"
|
||||
intentTypeRaceNameDenied = "lobby.race_name.registration_denied"
|
||||
intentTypeRaceNameRegistered = "lobby.race_name.registered"
|
||||
)
|
||||
|
||||
func TestRaceNameIntentsAcrossCapabilityAndRegistration(t *testing.T) {
|
||||
h := newLobbyNotificationHarness(t, gmAlwaysOK)
|
||||
|
||||
capableUser := h.ensureUser(t, "race-capable@example.com")
|
||||
incapableUser := h.ensureUser(t, "race-incapable@example.com")
|
||||
|
||||
gameID := h.adminCreatePublicGame(t, "Race Name Galaxy",
|
||||
time.Now().Add(48*time.Hour).Unix())
|
||||
h.openEnrollment(t, gameID)
|
||||
|
||||
capableApp := h.submitApplication(t, capableUser.UserID, gameID, "Capable")
|
||||
h.adminApproveApplication(t, gameID, capableApp["application_id"].(string))
|
||||
incapableApp := h.submitApplication(t, incapableUser.UserID, gameID, "Incapable")
|
||||
h.adminApproveApplication(t, gameID, incapableApp["application_id"].(string))
|
||||
|
||||
h.adminReadyToStart(t, gameID)
|
||||
h.adminStartGame(t, gameID)
|
||||
h.publishRuntimeJobSuccess(t, gameID)
|
||||
|
||||
// Wait for runtime job result + GM register-runtime to flip the game
|
||||
// to `running` before publishing GM stream events. Otherwise the
|
||||
// `game_finished` transition guard in the gmevents consumer rejects
|
||||
// the event for an unexpected status.
|
||||
h.requireGameStatus(t, gameID, "running")
|
||||
|
||||
// First snapshot freezes initial stats for both members.
|
||||
h.publishGMSnapshotUpdate(t, gameID, []playerTurnStat{
|
||||
{UserID: capableUser.UserID, Planets: 1, Population: 100},
|
||||
{UserID: incapableUser.UserID, Planets: 1, Population: 100},
|
||||
})
|
||||
|
||||
// game_finished bumps capable user's stats above the initial values
|
||||
// and leaves the incapable user unchanged. Capability rule is
|
||||
// `max_planets > initial_planets AND max_population > initial_population`.
|
||||
h.publishGMGameFinished(t, gameID, []playerTurnStat{
|
||||
{UserID: capableUser.UserID, Planets: 10, Population: 1000},
|
||||
{UserID: incapableUser.UserID, Planets: 1, Population: 100},
|
||||
})
|
||||
|
||||
// Capability evaluation runs asynchronously after the game_finished
|
||||
// event is consumed. Wait for the registration_eligible intent to
|
||||
// appear before attempting the user-side register call: the call only
|
||||
// succeeds once the pending registration is recorded.
|
||||
h.requireGameStatus(t, gameID, "finished")
|
||||
h.waitForIntent(t, intentTypeRaceNameEligible, capableUser.UserID)
|
||||
|
||||
h.userRegisterRaceName(t, capableUser.UserID, gameID, "Capable")
|
||||
|
||||
h.requireIntents(t,
|
||||
expect(intentTypeApplicationSubmitted, "admin"),
|
||||
expect(intentTypeApplicationSubmitted, "admin"),
|
||||
expect(intentTypeMembershipApproved, capableUser.UserID),
|
||||
expect(intentTypeMembershipApproved, incapableUser.UserID),
|
||||
expect(intentTypeRaceNameEligible, capableUser.UserID),
|
||||
expect(intentTypeRaceNameDenied, incapableUser.UserID),
|
||||
expect(intentTypeRaceNameRegistered, capableUser.UserID),
|
||||
)
|
||||
}
|
||||
|
||||
type playerTurnStat struct {
|
||||
UserID string `json:"user_id"`
|
||||
Planets int64 `json:"planets"`
|
||||
Population int64 `json:"population"`
|
||||
ShipsBuilt int64 `json:"ships_built"`
|
||||
}
|
||||
|
||||
func (h *lobbyNotificationHarness) publishGMSnapshotUpdate(t *testing.T, gameID string, stats []playerTurnStat) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(stats)
|
||||
require.NoError(t, err)
|
||||
_, err = h.redis.XAdd(context.Background(), &redis.XAddArgs{
|
||||
Stream: h.gmEventsStream,
|
||||
Values: map[string]any{
|
||||
"kind": "runtime_snapshot_update",
|
||||
"game_id": gameID,
|
||||
"current_turn": "1",
|
||||
"runtime_status": "healthy",
|
||||
"engine_health_summary": "ok",
|
||||
"player_turn_stats": string(payload),
|
||||
},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (h *lobbyNotificationHarness) publishGMGameFinished(t *testing.T, gameID string, stats []playerTurnStat) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(stats)
|
||||
require.NoError(t, err)
|
||||
_, err = h.redis.XAdd(context.Background(), &redis.XAddArgs{
|
||||
Stream: h.gmEventsStream,
|
||||
Values: map[string]any{
|
||||
"kind": "game_finished",
|
||||
"game_id": gameID,
|
||||
"finished_at_ms": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||
"current_turn": "10",
|
||||
"runtime_status": "finished",
|
||||
"engine_health_summary": "ok",
|
||||
"player_turn_stats": string(payload),
|
||||
},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (h *lobbyNotificationHarness) requireGameStatus(t *testing.T, gameID, want string) {
|
||||
t.Helper()
|
||||
require.Eventuallyf(t, func() bool {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
h.lobbyAdminURL+"/api/v1/internal/games/"+gameID, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp := doRequest(t, req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(resp.Body), &record); err != nil {
|
||||
return false
|
||||
}
|
||||
status, _ := record["status"].(string)
|
||||
return status == want
|
||||
}, 15*time.Second, 100*time.Millisecond,
|
||||
"game %s did not reach status %s", gameID, want)
|
||||
}
|
||||
|
||||
func (h *lobbyNotificationHarness) waitForIntent(t *testing.T, notificationType, recipient string) {
|
||||
t.Helper()
|
||||
require.Eventuallyf(t, func() bool {
|
||||
entries, err := h.redis.XRange(context.Background(), h.intentsStream, "-", "+").Result()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
published := decodePublishedIntents(t, entries)
|
||||
for _, p := range published {
|
||||
if p.NotificationType != notificationType {
|
||||
continue
|
||||
}
|
||||
if recipient == "admin" {
|
||||
if p.AudienceKind == "admin_email" {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slices.Contains(p.RecipientUserIDs, recipient) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 15*time.Second, 100*time.Millisecond,
|
||||
"intent %s for %s not observed on stream %s",
|
||||
notificationType, recipient, h.intentsStream)
|
||||
}
|
||||
|
||||
func (h *lobbyNotificationHarness) userRegisterRaceName(t *testing.T, userID, sourceGameID, raceName string) {
|
||||
t.Helper()
|
||||
resp := postJSON(t,
|
||||
h.lobbyPublicURL+"/api/v1/lobby/race-names/register",
|
||||
map[string]any{
|
||||
"race_name": raceName,
|
||||
"source_game_id": sourceGameID,
|
||||
},
|
||||
http.Header{"X-User-Id": []string{userID}})
|
||||
require.Equalf(t, http.StatusOK, resp.StatusCode, "register race name: %s", resp.Body)
|
||||
}
|
||||
Reference in New Issue
Block a user