phase 0.3
This commit is contained in:
commit
4a94bfc4e7
52 changed files with 11653 additions and 0 deletions
317
internal/service/service.go
Normal file
317
internal/service/service.go
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
// 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, ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue