// Package service is the game's business-logic / orchestration layer. It sits // between the presentation layer (internal/ui) and the data-access layer // (internal/db): the UI depends only on GameService and never touches db // directly, while db owns all SQL and transactions. GameService composes db // operations, makes the business decisions the UI used to make, and runs the // RAM-first dispatch queue with a background SQL worker. package service import ( "context" "database/sql" "fmt" "sync" "time" "skyrama-tui/internal/db" "skyrama-tui/internal/model" ) // pendingDispatch is one plane queued (in RAM) to be launched to a central port. // The background worker persists it to SQL as bays/runways free up. type pendingDispatch struct { airportID uint32 plane model.PlayerPlane destID uint32 destName string } // GameService wraps *db.Client. The mutex guards the in-RAM dispatch state // (queue, notes, central-port name cache) shared with the background worker. type GameService struct { db *db.Client mu sync.Mutex queue []pendingDispatch notes map[uint32]string // airportID → last hard-failure note (cleared on read) central map[uint32]string // central airport id → display name (lazy cache) cancel context.CancelFunc } // NewGameService wraps the db client and starts the background dispatch worker. func NewGameService(client *db.Client) *GameService { ctx, cancel := context.WithCancel(context.Background()) s := &GameService{ db: client, notes: make(map[uint32]string), cancel: cancel, } go s.worker(ctx) return s } // Close stops the background worker and closes the underlying db connection. func (s *GameService) Close() { s.cancel() s.db.Close() } // ─── Pass-throughs (pure delegation; names match db.Client) ──────────────────── func (s *GameService) Login(username, password string) (int32, error) { return s.db.Login(username, password) } func (s *GameService) Register(username, password string) (int64, error) { return s.db.Register(username, password) } func (s *GameService) GetAirportByUserID(userID int32) (*model.Airport, error) { return s.db.GetAirportByUserID(userID) } func (s *GameService) RefreshResources(airportID uint32) error { return s.db.RefreshResources(airportID) } func (s *GameService) GetAllCountries() ([]model.Country, error) { return s.db.GetAllCountries() } func (s *GameService) GetCentralAirports() ([]model.CentralAirport, error) { return s.db.GetCentralAirports() } func (s *GameService) GetAirportBuildings(airportID uint32) ([]model.AirportBuilding, error) { return s.db.GetAirportBuildings(airportID) } func (s *GameService) GetAllBuildings() ([]model.Building, error) { return s.db.GetAllBuildings() } func (s *GameService) BuildingCounts(airportID uint32) (int, int, error) { return s.db.BuildingCounts(airportID) } func (s *GameService) GetCaps(airportID uint32) (uint32, uint32, error) { return s.db.GetCaps(airportID) } func (s *GameService) GetActiveContracts(airportID uint32) ([]model.Contract, error) { return s.db.GetActiveContracts(airportID) } func (s *GameService) GetAllPlanes() ([]model.Plane, error) { return s.db.GetAllPlanes() } func (s *GameService) SellPlane(airportID, playerPlaneID uint32) (int64, error) { return s.db.SellPlane(airportID, playerPlaneID) } func (s *GameService) ConstructBuilding(airportID, buildingID uint32) error { return s.db.ConstructBuilding(airportID, buildingID) } func (s *GameService) DemolishBuilding(airportID, airportBuildingID uint32) (int64, error) { return s.db.DemolishBuilding(airportID, airportBuildingID) } func (s *GameService) BuyCapUpgrade(airportID uint32, buildingType string) error { return s.db.BuyCapUpgrade(airportID, buildingType) } func (s *GameService) AdvanceServiceStages(airportID uint32) error { return s.db.AdvanceServiceStages(airportID) } func (s *GameService) Facilities(airportID uint32) ([]model.FacilityStatus, error) { return s.db.GetFacilities(airportID) } // ─── Orchestration (was in the UI) ───────────────────────────────────────────── // BuyPlane finds a free hangar then buys the plane into it (one UI intent). func (s *GameService) BuyPlane(airportID, planeID uint32) error { hangarID, err := s.db.FindAvailableHangar(airportID) if err != nil { return err } return s.db.BuyPlane(airportID, planeID, hangarID, hangarID) } // LandReadyPlanes lands every plane whose flight has elapsed, returning how many // landed and the total cash reward (replaces the UI's land loop). func (s *GameService) LandReadyPlanes(airportID uint32) (int, int64, error) { ready, err := s.db.GetPlanesReadyToLand(airportID) if err != nil { return 0, 0, err } var cash int64 landed := 0 for _, pp := range ready { if err := s.db.LandFlight(airportID, pp); err != nil { return landed, cash, err } landed++ if pp.CashReward.Valid { cash += int64(pp.CashReward.Int32) } } return landed, cash, nil } // ─── RAM-first dispatch ──────────────────────────────────────────────────────── // DispatchFlights accepts a dispatch instantly into the RAM queue (no SQL yet) // and returns how many idle planes were queued. The background worker launches // them as bays/runways free. UI feedback is immediate; SQL follows. func (s *GameService) DispatchFlights(airportID uint32, planes []model.PlayerPlane, destID uint32) int { name := s.centralName(destID) s.mu.Lock() defer s.mu.Unlock() n := 0 for i := range planes { if planes[i].Status != "idle" { continue } s.queue = append(s.queue, pendingDispatch{ airportID: airportID, plane: planes[i], destID: destID, destName: name, }) n++ } return n } // GetFleet returns the DB fleet with the pending dispatch queue overlaid: planes // still idle in SQL but queued in RAM are surfaced with status "queued" + their // destination, so they leave the Waiting pool the instant a dispatch is accepted. func (s *GameService) GetFleet(airportID uint32) ([]model.PlayerPlane, error) { fleet, err := s.db.GetFleet(airportID) if err != nil { return nil, err } s.mu.Lock() queued := make(map[uint32]string) for _, p := range s.queue { if p.airportID == airportID { queued[p.plane.ID] = p.destName } } s.mu.Unlock() if len(queued) == 0 { return fleet, nil } for i := range fleet { if fleet[i].Status != "idle" { continue } if name, ok := queued[fleet[i].ID]; ok { fleet[i].Status = "queued" fleet[i].DestinationName = sql.NullString{String: name, Valid: true} } } return fleet, nil } // CancelQueue drops all not-yet-launched dispatches for an airport from the RAM // queue and returns how many were removed. Planes the worker already launched // (now boarding/flying in SQL) are unaffected. func (s *GameService) CancelQueue(airportID uint32) int { s.mu.Lock() defer s.mu.Unlock() kept := s.queue[:0] removed := 0 for _, p := range s.queue { if p.airportID == airportID { removed++ continue } kept = append(kept, p) } s.queue = kept return removed } // DispatchNote returns (and clears) the last hard-failure note for an airport, // e.g. a queued plane that couldn't launch because of insufficient fuel. func (s *GameService) DispatchNote(airportID uint32) string { s.mu.Lock() defer s.mu.Unlock() note := s.notes[airportID] delete(s.notes, airportID) return note } // centralName resolves a central airport id to its display name (lazy-cached). func (s *GameService) centralName(destID uint32) string { s.mu.Lock() cached := s.central s.mu.Unlock() if cached == nil { cached = make(map[uint32]string) if cs, err := s.db.GetCentralAirports(); err == nil { for _, c := range cs { cached[c.AirportID] = c.Name } } s.mu.Lock() s.central = cached s.mu.Unlock() } return cached[destID] } // ─── Background worker ────────────────────────────────────────────────────────── func (s *GameService) worker(ctx context.Context) { t := time.NewTicker(time.Second) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: s.drainQueue() } } } // drainQueue tries to launch every queued plane once. Planes blocked only by // bay/runway contention are re-queued for the next tick; planes that fail a // validation check (fuel/passengers) are dropped with a note. func (s *GameService) drainQueue() { s.mu.Lock() pending := s.queue s.queue = nil s.mu.Unlock() if len(pending) == 0 { return } var retry []pendingDispatch for _, p := range pending { launched, retryable, note := s.tryLaunch(p) switch { case launched: // persisted to SQL; drop from queue case retryable: retry = append(retry, p) default: s.mu.Lock() s.notes[p.airportID] = note s.mu.Unlock() } } if len(retry) > 0 { s.mu.Lock() s.queue = append(retry, s.queue...) // keep retries ahead of newly-queued s.mu.Unlock() } } // tryLaunch attempts one queued plane. Returns (launched, retryable, note): // retryable=true means "no bay/runway free, try again later". func (s *GameService) tryLaunch(p pendingDispatch) (bool, bool, string) { pl := p.plane if pl.BoardingTimeSec > 0 || pl.FuelingTimeSec > 0 { bay, err := s.db.FindAvailableServiceBay(p.airportID, pl.AircraftSize) if err != nil { return false, true, "" // contention (or no bay yet) → retry } if err := s.db.StartBoarding(p.airportID, pl.ID, pl.FuelCost, bay, p.destID); err != nil { return false, false, fmt.Sprintf("couldn't dispatch %s: %v", pl.PlaneName, err) } return true, false, "" } runway, err := s.db.FindAvailableRunway(p.airportID, pl.AircraftSize) if err != nil { return false, true, "" } if err := s.db.LaunchFlight(p.airportID, pl.ID, pl.FuelCost, runway, p.destID); err != nil { return false, false, fmt.Sprintf("couldn't dispatch %s: %v", pl.PlaneName, err) } return true, false, "" }