317 lines
10 KiB
Go
317 lines
10 KiB
Go
// 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, ""
|
|
}
|