local-dev: boot-time dev sandbox provisions a runnable game on up
Adds backend/internal/devsandbox: an idempotent boot-time hook that, when BACKEND_DEV_SANDBOX_EMAIL is set, ensures (1) the configured engine_version row, (2) the real dev user, (3) PlayerCount-1 deterministic dummy users, (4) a private "Dev Sandbox" game with a year-out turn schedule, (5) memberships for every participant via the new lobby.Service.InsertMembershipDirect helper, (6) a drive of the lifecycle to running. Re-running on a populated DB is a no-op; partial states from earlier crashes are recovered. tools/local-dev gains the matching env vars in .env, surfaces them in compose, and acquires a `make build-engine` target that builds galaxy-engine:local-dev from game/Dockerfile (a prerequisite of `up`/`rebuild`). The compose game-state mount is changed from a named volume to a host bind on /tmp/galaxy-game-state so backend's bind-mount source for spawned engine containers resolves on the docker daemon. After `make -C tools/local-dev up`, login as dev@local.test with the dev code 123456 and the Dev Sandbox already shows up in My Games. Per-user behaviour for the same email survives a backend restart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// InsertMembershipDirectInput is the parameter struct for
|
||||
// Service.InsertMembershipDirect.
|
||||
type InsertMembershipDirectInput struct {
|
||||
GameID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
RaceName string
|
||||
}
|
||||
|
||||
// InsertMembershipDirect grants a membership to userID inside gameID
|
||||
// bypassing the application/approval flow. It performs the same DB
|
||||
// writes as ApproveApplication: the per-game race-name reservation
|
||||
// row plus the membership row, and refreshes the in-memory caches.
|
||||
//
|
||||
// The method is intended for boot-time provisioning by
|
||||
// `backend/internal/devsandbox` and similar trusted callers. It is
|
||||
// not exposed through any HTTP handler. The caller must guarantee
|
||||
// game.Status == GameStatusEnrollmentOpen — the function returns
|
||||
// ErrConflict otherwise — and that the race-name policy and
|
||||
// canonical-key invariants are honoured (the implementation reuses
|
||||
// the lobby's own Policy and assertRaceNameAvailable so a duplicate
|
||||
// or unsuitable name still fails).
|
||||
//
|
||||
// Idempotency: if a membership for (GameID, UserID) already exists
|
||||
// the function returns the existing row without modifying state.
|
||||
// This makes the helper safe to call on every backend boot from
|
||||
// devsandbox.Bootstrap.
|
||||
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
||||
displayName, err := ValidateDisplayName(in.RaceName)
|
||||
if err != nil {
|
||||
return Membership{}, err
|
||||
}
|
||||
game, err := s.GetGame(ctx, in.GameID)
|
||||
if err != nil {
|
||||
return Membership{}, err
|
||||
}
|
||||
if game.Status != GameStatusEnrollmentOpen {
|
||||
return Membership{}, fmt.Errorf("%w: game status is %q, want enrollment_open", ErrConflict, game.Status)
|
||||
}
|
||||
canonical, err := s.deps.Policy.Canonical(displayName)
|
||||
if err != nil {
|
||||
return Membership{}, err
|
||||
}
|
||||
existing, err := s.deps.Store.ListMembershipsForGame(ctx, in.GameID)
|
||||
if err != nil {
|
||||
return Membership{}, err
|
||||
}
|
||||
for _, m := range existing {
|
||||
if m.UserID == in.UserID && m.Status == MembershipStatusActive {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if err := s.assertRaceNameAvailable(ctx, canonical, in.UserID, in.GameID); err != nil {
|
||||
return Membership{}, err
|
||||
}
|
||||
now := s.deps.Now().UTC()
|
||||
if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{
|
||||
Name: displayName,
|
||||
Canonical: canonical,
|
||||
Status: RaceNameStatusReservation,
|
||||
OwnerUserID: in.UserID,
|
||||
GameID: in.GameID,
|
||||
ReservedAt: &now,
|
||||
}); err != nil {
|
||||
return Membership{}, err
|
||||
}
|
||||
membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{
|
||||
MembershipID: uuid.New(),
|
||||
GameID: in.GameID,
|
||||
UserID: in.UserID,
|
||||
RaceName: displayName,
|
||||
CanonicalKey: canonical,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.deps.Store.DeleteRaceName(ctx, canonical, in.GameID)
|
||||
return Membership{}, err
|
||||
}
|
||||
s.deps.Cache.PutMembership(membership)
|
||||
s.deps.Cache.PutRaceName(RaceNameEntry{
|
||||
Name: displayName,
|
||||
Canonical: canonical,
|
||||
Status: RaceNameStatusReservation,
|
||||
OwnerUserID: in.UserID,
|
||||
GameID: in.GameID,
|
||||
ReservedAt: &now,
|
||||
})
|
||||
return membership, nil
|
||||
}
|
||||
Reference in New Issue
Block a user