skyrama_tui/internal/service/service.go

317 lines
10 KiB
Go
Raw Permalink Normal View History

2026-06-04 20:51:20 +03:00
// 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, ""
}