ui: plan 01-27 done #1
@@ -106,6 +106,10 @@ func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logg
|
|||||||
dummyIDs = append(dummyIDs, id)
|
dummyIDs = append(dummyIDs, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
|
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -158,6 +162,37 @@ func terminalSandboxStatus(status string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
|
||||||
|
// the dev user owns that has reached a terminal state
|
||||||
|
// (cancelled / finished / start_failed). The cascade declared in
|
||||||
|
// `00001_init.sql` removes the matching memberships, applications,
|
||||||
|
// invites, runtime records, and player mappings in the same write,
|
||||||
|
// so the developer's lobby never piles up dead tiles between
|
||||||
|
// `make rebuild` cycles. Non-terminal games are left untouched —
|
||||||
|
// a `running` sandbox from a previous boot is the happy path.
|
||||||
|
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
|
||||||
|
games, err := svc.ListMyGames(ctx, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dev_sandbox: list my games: %w", err)
|
||||||
|
}
|
||||||
|
for _, g := range games {
|
||||||
|
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !terminalSandboxStatus(g.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
|
||||||
|
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
|
||||||
|
}
|
||||||
|
logger.Info("purged terminal sandbox game",
|
||||||
|
zap.String("game_id", g.GameID.String()),
|
||||||
|
zap.String("status", g.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
|
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
|
||||||
games, err := svc.ListMyGames(ctx, ownerID)
|
games, err := svc.ListMyGames(ctx, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,9 +202,9 @@ func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uu
|
|||||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if terminalSandboxStatus(g.Status) {
|
// `purgeTerminalSandboxGames` ran before us, so any sandbox
|
||||||
continue
|
// game still in the list is either a live one we should
|
||||||
}
|
// reuse or a transient state we can drive forward.
|
||||||
return g, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
|
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ func TestBootstrapRejectsMissingDeps(t *testing.T) {
|
|||||||
var errMissingDepsSentinel = errors.New("sentinel")
|
var errMissingDepsSentinel = errors.New("sentinel")
|
||||||
|
|
||||||
// TestTerminalSandboxStatus pins the contract that decides whether a
|
// TestTerminalSandboxStatus pins the contract that decides whether a
|
||||||
// previously created sandbox game is reusable. Terminal states force
|
// previously created sandbox game gets purged on the next boot.
|
||||||
// the bootstrap to create a new game on the next boot rather than
|
// Terminal states are deleted (cascade-style) so the developer's
|
||||||
// hand the developer a dead lobby tile.
|
// lobby never piles up dead tiles between `make rebuild` cycles.
|
||||||
func TestTerminalSandboxStatus(t *testing.T) {
|
func TestTerminalSandboxStatus(t *testing.T) {
|
||||||
terminal := []string{"cancelled", "finished", "start_failed"}
|
terminal := []string{"cancelled", "finished", "start_failed"}
|
||||||
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
|
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
|
||||||
|
|||||||
@@ -233,6 +233,24 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco
|
|||||||
return s.deps.Store.ListMyGames(ctx, userID)
|
return s.deps.Store.ListMyGames(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteGame removes the game and every referencing row (memberships,
|
||||||
|
// applications, invites, runtime_records, player_mappings) via the
|
||||||
|
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
||||||
|
// Idempotent: returns nil when no game matches.
|
||||||
|
//
|
||||||
|
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
|
||||||
|
// terminal "Dev Sandbox" tile from a previous local-dev session can
|
||||||
|
// be scrubbed before a fresh game spawns. Production callers must
|
||||||
|
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
|
||||||
|
// destructive and bypasses the cascade-notification machinery.
|
||||||
|
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.deps.Cache.RemoveGame(gameID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// State-machine transition handlers below take the same shape: load the
|
// State-machine transition handlers below take the same shape: load the
|
||||||
// game (cache or store), check owner, validate the current status, run
|
// game (cache or store), check owner, validate the current status, run
|
||||||
// the transition write, refresh the cache, optionally tell the runtime
|
// the transition write, refresh the cache, optionally tell the runtime
|
||||||
|
|||||||
@@ -244,6 +244,70 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
|
||||||
|
// bootstrap relies on: removing a game wipes every referencing row
|
||||||
|
// (memberships, applications, invites, runtime_records,
|
||||||
|
// player_mappings) in a single SQL statement. Before this is wired
|
||||||
|
// the developer's lobby pile up cancelled tiles between
|
||||||
|
// `make rebuild` cycles; with it, every boot starts from a clean
|
||||||
|
// slate.
|
||||||
|
func TestDeleteGameCascadesEverything(t *testing.T) {
|
||||||
|
db := startPostgres(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
clock := func() time.Time { return now }
|
||||||
|
svc := newServiceForTest(t, db, clock, 5)
|
||||||
|
|
||||||
|
owner := uuid.New()
|
||||||
|
seedAccount(t, db, owner)
|
||||||
|
game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
|
||||||
|
OwnerUserID: &owner,
|
||||||
|
Visibility: lobby.VisibilityPrivate,
|
||||||
|
GameName: "Doomed",
|
||||||
|
MinPlayers: 1,
|
||||||
|
MaxPlayers: 4,
|
||||||
|
StartGapHours: 1,
|
||||||
|
StartGapPlayers: 1,
|
||||||
|
EnrollmentEndsAt: now.Add(time.Hour),
|
||||||
|
TurnSchedule: "0 0 * * *",
|
||||||
|
TargetEngineVersion: "1.0.0",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create game: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil {
|
||||||
|
t.Fatalf("open enrollment: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.InsertMembershipDirect(context.Background(), lobby.InsertMembershipDirectInput{
|
||||||
|
GameID: game.GameID,
|
||||||
|
UserID: owner,
|
||||||
|
RaceName: "Owner",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert membership: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.DeleteGame(context.Background(), game.GameID); err != nil {
|
||||||
|
t.Fatalf("delete game: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify cascade: the game must be gone, ListMyGames must drop
|
||||||
|
// it, and re-deleting the same id is a no-op.
|
||||||
|
if _, err := svc.GetGame(context.Background(), game.GameID); !errors.Is(err, lobby.ErrNotFound) {
|
||||||
|
t.Fatalf("get after delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
games, err := svc.ListMyGames(context.Background(), owner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list my games: %v", err)
|
||||||
|
}
|
||||||
|
for _, g := range games {
|
||||||
|
if g.GameID == game.GameID {
|
||||||
|
t.Fatalf("ListMyGames still lists the deleted game")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := svc.DeleteGame(context.Background(), game.GameID); err != nil {
|
||||||
|
t.Fatalf("delete idempotent: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEndToEndPublicGameApplicationApproval(t *testing.T) {
|
func TestEndToEndPublicGameApplicationApproval(t *testing.T) {
|
||||||
db := startPostgres(t)
|
db := startPostgres(t)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|||||||
@@ -232,6 +232,22 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
|
|||||||
return modelsToGameRecords(rows)
|
return modelsToGameRecords(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteGame removes the row at gameID. Cascades through every
|
||||||
|
// referencing table (memberships / applications / invites /
|
||||||
|
// runtime_records / player_mappings — all declared with ON DELETE
|
||||||
|
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
||||||
|
// matches. Used by the dev-sandbox bootstrap to scrub terminal
|
||||||
|
// games on every backend boot so the developer's lobby never piles
|
||||||
|
// up cancelled tiles.
|
||||||
|
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
g := table.Games
|
||||||
|
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("lobby store: delete game %s: %w", gameID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// gameUpdate is the parameter struct for UpdateGame. Nil pointers leave
|
// gameUpdate is the parameter struct for UpdateGame. Nil pointers leave
|
||||||
// the corresponding column alone.
|
// the corresponding column alone.
|
||||||
type gameUpdate struct {
|
type gameUpdate struct {
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ CREATE INDEX race_names_pending_eligible_idx
|
|||||||
-- finished) and the container-state escape hatch (removed) used by
|
-- finished) and the container-state escape hatch (removed) used by
|
||||||
-- reconciliation when the recorded container has disappeared.
|
-- reconciliation when the recorded container has disappeared.
|
||||||
CREATE TABLE runtime_records (
|
CREATE TABLE runtime_records (
|
||||||
game_id uuid PRIMARY KEY,
|
game_id uuid PRIMARY KEY REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
status text NOT NULL,
|
status text NOT NULL,
|
||||||
current_container_id text,
|
current_container_id text,
|
||||||
current_image_ref text,
|
current_image_ref text,
|
||||||
@@ -465,7 +465,7 @@ CREATE TABLE engine_versions (
|
|||||||
-- roster reads. The partial UNIQUE on (game_id, race_name) enforces the
|
-- roster reads. The partial UNIQUE on (game_id, race_name) enforces the
|
||||||
-- one-race-per-game invariant at the storage boundary.
|
-- one-race-per-game invariant at the storage boundary.
|
||||||
CREATE TABLE player_mappings (
|
CREATE TABLE player_mappings (
|
||||||
game_id uuid NOT NULL,
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
user_id uuid NOT NULL,
|
user_id uuid NOT NULL,
|
||||||
race_name text NOT NULL,
|
race_name text NOT NULL,
|
||||||
engine_player_uuid uuid NOT NULL,
|
engine_player_uuid uuid NOT NULL,
|
||||||
|
|||||||
@@ -99,6 +99,16 @@ To disable the bootstrap, clear `BACKEND_DEV_SANDBOX_EMAIL` in
|
|||||||
`tools/local-dev/.env` and `docker compose up -d backend` (or
|
`tools/local-dev/.env` and `docker compose up -d backend` (or
|
||||||
`make rebuild`). Existing users / games are not removed.
|
`make rebuild`). Existing users / games are not removed.
|
||||||
|
|
||||||
|
Terminal sandbox games — anything in `cancelled`, `finished`, or
|
||||||
|
`start_failed` — are deleted on every boot before find-or-create
|
||||||
|
runs. The cascade declared in `00001_init.sql` removes the
|
||||||
|
matching memberships, applications, invites, runtime records,
|
||||||
|
and player mappings in the same write, so the dev user's lobby
|
||||||
|
shows exactly one running tile at all times. Cancelling the
|
||||||
|
sandbox manually and running `docker compose restart backend`
|
||||||
|
(or `make rebuild`) yields a fresh game without leaving dead
|
||||||
|
tiles behind.
|
||||||
|
|
||||||
The bootstrap requires:
|
The bootstrap requires:
|
||||||
- `galaxy-engine:local-dev` Docker image (`make build-engine`).
|
- `galaxy-engine:local-dev` Docker image (`make build-engine`).
|
||||||
- `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver
|
- `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver
|
||||||
|
|||||||
Reference in New Issue
Block a user