1384 lines
45 KiB
Go
1384 lines
45 KiB
Go
// Package db is a thin wrapper around the sqlc-generated Queries type.
|
|
// All SELECT / INSERT / UPDATE calls go through c.q (sqlc). This file only
|
|
// adds three things that sqlc cannot express on its own:
|
|
// 1. bcrypt login / register helpers
|
|
// 2. transactional composite operations (BuyPlane, LaunchFlight, LandFlight,
|
|
// ConstructBuilding) that need to atomically touch multiple tables
|
|
// 3. row-to-domain-model mapping (sqlc row → internal/model type)
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"skyrama-tui/internal/model"
|
|
)
|
|
|
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
|
|
// Client wraps the sqlc *Queries object. All DB access goes through c.q.
|
|
type Client struct {
|
|
db *sql.DB
|
|
q *Queries
|
|
ctx context.Context
|
|
}
|
|
|
|
// NewClient opens and pings a MySQL connection then returns a ready Client.
|
|
func NewClient(dsn string) (*Client, error) {
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open db: %w", err)
|
|
}
|
|
db.SetMaxOpenConns(10)
|
|
db.SetMaxIdleConns(5)
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
|
|
|
ctx := context.Background()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("ping db: %w", err)
|
|
}
|
|
return &Client{db: db, q: New(db), ctx: ctx}, nil
|
|
}
|
|
|
|
func (c *Client) Close() { c.db.Close() }
|
|
|
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
|
|
|
// Login verifies credentials and returns the user's ID.
|
|
// sqlc: GetUserByUsername
|
|
func (c *Client) Login(username, password string) (int32, error) {
|
|
row, err := c.q.GetUserByUsername(c.ctx, username)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("user not found")
|
|
}
|
|
return 0, fmt.Errorf("login: %w", err)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(row.PasswordHash), []byte(password)); err != nil {
|
|
return 0, fmt.Errorf("invalid password")
|
|
}
|
|
return row.ID, nil
|
|
}
|
|
|
|
// Register creates a user with a bcrypt-hashed password and returns the new ID.
|
|
// sqlc: CreateUser
|
|
func (c *Client) Register(username, password string) (int64, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("hash: %w", err)
|
|
}
|
|
res, err := c.q.CreateUser(c.ctx, CreateUserParams{
|
|
Username: username,
|
|
PasswordHash: string(hash),
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("register: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// ─── Airport ─────────────────────────────────────────────────────────────────
|
|
|
|
// GetAirportByUserID returns the airport for a given user ID.
|
|
// sqlc: GetAirportByUserId
|
|
func (c *Client) GetAirportByUserID(userID int32) (*model.Airport, error) {
|
|
row, err := c.q.GetAirportByUserId(c.ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return mapAirport(row), nil
|
|
}
|
|
|
|
// RefreshResources regenerates fuel and passengers using TIMESTAMPDIFF inside MySQL.
|
|
// sqlc: UpdateAirportResources
|
|
func (c *Client) RefreshResources(airportID uint32) error {
|
|
return c.q.UpdateAirportResources(c.ctx, airportID)
|
|
}
|
|
|
|
// ─── Fleet ───────────────────────────────────────────────────────────────────
|
|
|
|
// GetFleet returns all player planes with joined plane details including service times.
|
|
func (c *Client) GetFleet(airportID uint32) ([]model.PlayerPlane, error) {
|
|
rows, err := c.db.QueryContext(c.ctx, `
|
|
SELECT pp.id, pp.status, pp.updated_at, pp.purchased_at, pp.flight_start_time,
|
|
pp.current_building_id, pp.assigned_hangar_id,
|
|
pp.destination_airport_id, dest.name,
|
|
p.id, p.name, p.aircraft_size, p.aircraft_type, p.operation_type,
|
|
p.travel_time_inseconds, p.operation_cost_fuel, p.buying_cost_cash, p.max_passengers,
|
|
p.completed_flight_cash_reward, p.completed_flight_experience,
|
|
p.boarding_time_seconds, p.fueling_time_seconds,
|
|
GREATEST(0, CASE pp.status
|
|
WHEN 'boarding' THEN CAST(p.boarding_time_seconds AS SIGNED) - TIMESTAMPDIFF(SECOND, pp.flight_start_time, NOW())
|
|
WHEN 'taxiing' THEN CAST(p.fueling_time_seconds AS SIGNED) - TIMESTAMPDIFF(SECOND, pp.flight_start_time, NOW())
|
|
WHEN 'flying' THEN CAST(p.travel_time_inseconds AS SIGNED) - TIMESTAMPDIFF(SECOND, pp.flight_start_time, NOW())
|
|
WHEN 'unloading' THEN CAST(p.fueling_time_seconds AS SIGNED) - TIMESTAMPDIFF(SECOND, pp.flight_start_time, NOW())
|
|
ELSE 0
|
|
END) AS remaining_seconds
|
|
FROM player_planes pp
|
|
JOIN planes p ON pp.plane_id = p.id
|
|
LEFT JOIN airports dest ON pp.destination_airport_id = dest.id
|
|
WHERE pp.airport_id = ?
|
|
ORDER BY pp.id`, airportID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []model.PlayerPlane
|
|
for rows.Next() {
|
|
var pp model.PlayerPlane
|
|
var updatedAt, purchasedAt sql.NullTime
|
|
if err := rows.Scan(
|
|
&pp.ID, &pp.Status, &updatedAt, &purchasedAt, &pp.FlightStartTime,
|
|
&pp.CurrentBuildingID, &pp.AssignedHangarID,
|
|
&pp.DestinationAirportID, &pp.DestinationName,
|
|
&pp.PlaneID, &pp.PlaneName, &pp.AircraftSize, &pp.AircraftType, &pp.OperationType,
|
|
&pp.TravelTimeSec, &pp.FuelCost, &pp.BuyingCost, &pp.MaxPassengers,
|
|
&pp.CashReward, &pp.ExpReward,
|
|
&pp.BoardingTimeSec, &pp.FuelingTimeSec,
|
|
&pp.RemainingSec,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if updatedAt.Valid {
|
|
pp.UpdatedAt = updatedAt.Time
|
|
}
|
|
if purchasedAt.Valid {
|
|
pp.PurchasedAt = purchasedAt.Time
|
|
}
|
|
out = append(out, pp)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// nullableID converts a uint32 id into a NULL-able value: 0 means "no id" (NULL),
|
|
// which keeps the destination_airport_id FK valid when a plane has no destination.
|
|
func nullableID(id uint32) sql.NullInt32 {
|
|
if id == 0 {
|
|
return sql.NullInt32{}
|
|
}
|
|
return sql.NullInt32{Int32: int32(id), Valid: true}
|
|
}
|
|
|
|
// GetCentralAirports lists the system-owned destination ports (one per continent)
|
|
// with their owning faction, for the Fleet dispatch picker.
|
|
func (c *Client) GetCentralAirports() ([]model.CentralAirport, error) {
|
|
rows, err := c.db.QueryContext(c.ctx, `
|
|
SELECT a.id, a.name,
|
|
COALESCE(f.continent, ''), COALESCE(f.name, ''), COALESCE(f.leader_name, '')
|
|
FROM airports a
|
|
LEFT JOIN factions f ON f.central_airport_id = a.id
|
|
WHERE a.is_central = TRUE
|
|
ORDER BY f.continent`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []model.CentralAirport
|
|
for rows.Next() {
|
|
var ca model.CentralAirport
|
|
if err := rows.Scan(&ca.AirportID, &ca.Name, &ca.Continent, &ca.FactionName, &ca.LeaderName); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, ca)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetPlanesReadyToLand returns flying planes whose ADDTIME has elapsed.
|
|
// The time check runs entirely inside MySQL.
|
|
// sqlc: GetPlanesReadyToLand (filtered by airportID in Go since the query is global)
|
|
func (c *Client) GetPlanesReadyToLand(airportID uint32) ([]model.PlayerPlane, error) {
|
|
rows, err := c.q.GetPlanesReadyToLand(c.ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ready []model.PlayerPlane
|
|
for _, r := range rows {
|
|
if r.AirportID == airportID {
|
|
ready = append(ready, mapReadyPlane(r))
|
|
}
|
|
}
|
|
return ready, nil
|
|
}
|
|
|
|
// FindAvailableServiceBay finds an idle service bay matching the plane's aircraft size.
|
|
func (c *Client) FindAvailableServiceBay(airportID uint32, size string) (uint32, error) {
|
|
id, err := c.q.FindAvailableInfrastructure(c.ctx, FindAvailableInfrastructureParams{
|
|
AirportID: airportID,
|
|
OperationType: NullBuildingsOperationType{Valid: true, BuildingsOperationType: BuildingsOperationTypeService},
|
|
OperationSize: NullBuildingsOperationSize{Valid: true, BuildingsOperationSize: BuildingsOperationSize(size)},
|
|
})
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("no available %s service bay (build one first)", size)
|
|
}
|
|
return 0, err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// FindAvailableRunway finds an idle runway matching the plane's aircraft size.
|
|
// sqlc: FindAvailableInfrastructure
|
|
func (c *Client) FindAvailableRunway(airportID uint32, size string) (uint32, error) {
|
|
id, err := c.q.FindAvailableInfrastructure(c.ctx, FindAvailableInfrastructureParams{
|
|
AirportID: airportID,
|
|
OperationType: NullBuildingsOperationType{Valid: true, BuildingsOperationType: BuildingsOperationTypeRunway},
|
|
OperationSize: NullBuildingsOperationSize{Valid: true, BuildingsOperationSize: BuildingsOperationSize(size)},
|
|
})
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("no available %s runway (build one first)", size)
|
|
}
|
|
return 0, err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// FindAvailableHangar returns a plane hangar that still has free space.
|
|
// Only storage buildings whose storage_type is 'plane' count as hangars —
|
|
// fuel tanks and product warehouses are storage too but must never hold planes.
|
|
// A hangar with NULL capacity is treated as unlimited. Returns an error when
|
|
// every hangar is full (or none exist).
|
|
func (c *Client) FindAvailableHangar(airportID uint32) (uint32, error) {
|
|
var id uint32
|
|
err := c.db.QueryRowContext(c.ctx, `
|
|
SELECT ab.id
|
|
FROM airport_buildings ab
|
|
JOIN buildings b ON ab.building_id = b.id
|
|
WHERE ab.airport_id = ?
|
|
AND b.building_type = 'storage'
|
|
AND b.storage_type = 'plane'
|
|
AND (b.capacity IS NULL OR
|
|
(SELECT COUNT(*) FROM player_planes pp WHERE pp.assigned_hangar_id = ab.id) < b.capacity)
|
|
ORDER BY ab.id
|
|
LIMIT 1`, airportID).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("all hangars are full — build another storage hangar")
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// BuyPlane deducts cash and inserts a player_plane row, all in one transaction.
|
|
// sqlc: GetPlaneById, GetAirportByUserId, UpdateAirportCash,
|
|
//
|
|
// BuyPlaneForPlayer, LogCurrencyTransaction
|
|
func (c *Client) BuyPlane(airportID, planeID, hangarID, buildingID uint32) error {
|
|
tx, err := c.db.BeginTx(c.ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qtx := c.q.WithTx(tx)
|
|
|
|
plane, err := qtx.GetPlaneById(c.ctx, planeID)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("plane not found: %w", err)
|
|
}
|
|
|
|
var currentCash uint64
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT cash FROM airports WHERE id = ?`, airportID,
|
|
).Scan(¤tCash); err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("airport not found: %w", err)
|
|
}
|
|
cost := uint64(plane.BuyingCostCash)
|
|
if currentCash < cost {
|
|
tx.Rollback()
|
|
return fmt.Errorf("insufficient funds (need $%d, have $%d)", cost, currentCash)
|
|
}
|
|
|
|
// Enforce hangar capacity: a NULL capacity means unlimited. The target must
|
|
// be a plane hangar — never a fuel tank or product warehouse.
|
|
var capacity sql.NullInt32
|
|
var storageType sql.NullString
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT b.capacity, b.storage_type FROM airport_buildings ab
|
|
JOIN buildings b ON ab.building_id = b.id
|
|
WHERE ab.id = ? AND b.building_type = 'storage'`, hangarID,
|
|
).Scan(&capacity, &storageType); err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("hangar not found: %w", err)
|
|
}
|
|
if !storageType.Valid || storageType.String != "plane" {
|
|
tx.Rollback()
|
|
return fmt.Errorf("target building is not a plane hangar")
|
|
}
|
|
if capacity.Valid {
|
|
var inHangar int
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT COUNT(*) FROM player_planes WHERE assigned_hangar_id = ?`, hangarID,
|
|
).Scan(&inHangar); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if inHangar >= int(capacity.Int32) {
|
|
tx.Rollback()
|
|
return fmt.Errorf("hangar is full (%d/%d) — build another hangar", inHangar, capacity.Int32)
|
|
}
|
|
}
|
|
|
|
if err := qtx.UpdateAirportCash(c.ctx, UpdateAirportCashParams{
|
|
Cash: currentCash - cost,
|
|
ID: airportID,
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
if _, err := qtx.BuyPlaneForPlayer(c.ctx, BuyPlaneForPlayerParams{
|
|
AirportID: airportID,
|
|
PlaneID: planeID,
|
|
AssignedHangarID: hangarID,
|
|
CurrentBuildingID: sql.NullInt32{Int32: int32(buildingID), Valid: true},
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
if err := qtx.LogCurrencyTransaction(c.ctx, LogCurrencyTransactionParams{
|
|
AirportID: airportID,
|
|
AmountChanged: -int32(cost),
|
|
CurrencyType: CurrencyTransactionLogsCurrencyTypeCash,
|
|
Reason: fmt.Sprintf("bought_plane_%d", planeID),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// planePassengerLoad returns how many passengers a player plane boards at launch.
|
|
// Returns 0 for cargo planes or planes with no max_passengers configured.
|
|
func (c *Client) planePassengerLoad(tx *sql.Tx, playerPlaneID uint32) (uint32, error) {
|
|
var opType string
|
|
var maxPax sql.NullInt32
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT p.operation_type, p.max_passengers
|
|
FROM planes p JOIN player_planes pp ON pp.plane_id = p.id
|
|
WHERE pp.id = ?`, playerPlaneID,
|
|
).Scan(&opType, &maxPax); err != nil {
|
|
return 0, err
|
|
}
|
|
if opType != "passenger" || !maxPax.Valid || maxPax.Int32 <= 0 {
|
|
return 0, nil
|
|
}
|
|
return uint32(maxPax.Int32), nil
|
|
}
|
|
|
|
// LaunchFlight deducts fuel + passengers, sets the plane flying, logs the event.
|
|
// sqlc: GetAirportByUserId, UpdateAirportFuel, UpdatePlaneState,
|
|
//
|
|
// LogCurrencyTransaction
|
|
func (c *Client) LaunchFlight(airportID, playerPlaneID, fuelCost, _, destinationAirportID uint32) error {
|
|
tx, err := c.db.BeginTx(c.ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qtx := c.q.WithTx(tx)
|
|
|
|
var currentFuel, currentPax uint32
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT current_fuel, current_passengers FROM airports WHERE id = ?`, airportID,
|
|
).Scan(¤tFuel, ¤tPax); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if currentFuel < fuelCost {
|
|
tx.Rollback()
|
|
return fmt.Errorf("not enough fuel (need %d, have %d)", fuelCost, currentFuel)
|
|
}
|
|
|
|
paxLoad, err := c.planePassengerLoad(tx, playerPlaneID)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if currentPax < paxLoad {
|
|
tx.Rollback()
|
|
return fmt.Errorf("not enough passengers (need %d, have %d)", paxLoad, currentPax)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`UPDATE airports SET current_fuel = ?, current_passengers = ? WHERE id = ?`,
|
|
currentFuel-fuelCost, currentPax-paxLoad, airportID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
// Use MySQL NOW() (not Go time.Now()) so flight_start_time shares the exact
|
|
// clock used by the landing check — otherwise Go/MySQL clock skew can make
|
|
// the plane "ready to land" the instant it launches.
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`UPDATE player_planes SET current_building_id = NULL, status = 'flying',
|
|
flight_start_time = NOW(), destination_airport_id = ? WHERE id = ?`,
|
|
nullableID(destinationAirportID), playerPlaneID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
if err := qtx.LogCurrencyTransaction(c.ctx, LogCurrencyTransactionParams{
|
|
AirportID: airportID,
|
|
AmountChanged: -int32(fuelCost),
|
|
CurrencyType: CurrencyTransactionLogsCurrencyTypeFuel,
|
|
Reason: fmt.Sprintf("launched_plane_%d", playerPlaneID),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// StartBoarding deducts fuel and moves a plane into the boarding phase at a service bay.
|
|
func (c *Client) StartBoarding(airportID, playerPlaneID, fuelCost, serviceBayID, destinationAirportID uint32) error {
|
|
tx, err := c.db.BeginTx(c.ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qtx := c.q.WithTx(tx)
|
|
|
|
var currentFuel, currentPax uint32
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT current_fuel, current_passengers FROM airports WHERE id = ?`, airportID,
|
|
).Scan(¤tFuel, ¤tPax); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if currentFuel < fuelCost {
|
|
tx.Rollback()
|
|
return fmt.Errorf("not enough fuel (need %d, have %d)", fuelCost, currentFuel)
|
|
}
|
|
paxLoad, err := c.planePassengerLoad(tx, playerPlaneID)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if currentPax < paxLoad {
|
|
tx.Rollback()
|
|
return fmt.Errorf("not enough passengers (need %d, have %d)", paxLoad, currentPax)
|
|
}
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`UPDATE airports SET current_fuel = ?, current_passengers = ? WHERE id = ?`,
|
|
currentFuel-fuelCost, currentPax-paxLoad, airportID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
// MySQL NOW() keeps boarding/fueling timers on the same clock as the
|
|
// service-advance and landing checks (see LaunchFlight comment).
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`UPDATE player_planes SET current_building_id = ?, status = 'boarding',
|
|
flight_start_time = NOW(), destination_airport_id = ? WHERE id = ?`,
|
|
serviceBayID, nullableID(destinationAirportID), playerPlaneID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if err := qtx.LogCurrencyTransaction(c.ctx, LogCurrencyTransactionParams{
|
|
AirportID: airportID,
|
|
AmountChanged: -int32(fuelCost),
|
|
CurrencyType: CurrencyTransactionLogsCurrencyTypeFuel,
|
|
Reason: fmt.Sprintf("boarding_plane_%d", playerPlaneID),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// AdvanceServiceStages moves planes through boarding→taxiing→flying transitions.
|
|
// Called each tick. Planes waiting for a runway stay in their current state until
|
|
// one becomes available.
|
|
func (c *Client) AdvanceServiceStages(airportID uint32) error {
|
|
type ps struct {
|
|
id uint32
|
|
size string
|
|
}
|
|
|
|
// ── Boarding done, fueling needed → taxiing ───────────────────────────
|
|
rows, err := c.db.QueryContext(c.ctx, `
|
|
SELECT pp.id FROM player_planes pp
|
|
JOIN planes p ON pp.plane_id = p.id
|
|
WHERE pp.airport_id = ? AND pp.status = 'boarding'
|
|
AND p.boarding_time_seconds > 0 AND p.fueling_time_seconds > 0
|
|
AND DATE_ADD(pp.flight_start_time, INTERVAL p.boarding_time_seconds SECOND) <= NOW()`,
|
|
airportID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var toTaxiing []uint32
|
|
for rows.Next() {
|
|
var id uint32
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
toTaxiing = append(toTaxiing, id)
|
|
}
|
|
rows.Close()
|
|
for _, id := range toTaxiing {
|
|
if _, err := c.db.ExecContext(c.ctx,
|
|
`UPDATE player_planes SET status='taxiing', flight_start_time=NOW() WHERE id=?`, id,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// ── Boarding done, no fueling → fly directly ──────────────────────────
|
|
rows2, err := c.db.QueryContext(c.ctx, `
|
|
SELECT pp.id, p.aircraft_size FROM player_planes pp
|
|
JOIN planes p ON pp.plane_id = p.id
|
|
WHERE pp.airport_id = ? AND pp.status = 'boarding'
|
|
AND p.boarding_time_seconds > 0 AND p.fueling_time_seconds = 0
|
|
AND DATE_ADD(pp.flight_start_time, INTERVAL p.boarding_time_seconds SECOND) <= NOW()`,
|
|
airportID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var boardNoFuel []ps
|
|
for rows2.Next() {
|
|
var p ps
|
|
if err := rows2.Scan(&p.id, &p.size); err != nil {
|
|
rows2.Close()
|
|
return err
|
|
}
|
|
boardNoFuel = append(boardNoFuel, p)
|
|
}
|
|
rows2.Close()
|
|
for _, p := range boardNoFuel {
|
|
if _, err := c.FindAvailableRunway(airportID, p.size); err != nil {
|
|
continue // wait for runway
|
|
}
|
|
if _, err := c.db.ExecContext(c.ctx,
|
|
`UPDATE player_planes SET status='flying', current_building_id=NULL, flight_start_time=NOW() WHERE id=?`, p.id,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// ── Fueling done → fly ────────────────────────────────────────────────
|
|
rows3, err := c.db.QueryContext(c.ctx, `
|
|
SELECT pp.id, p.aircraft_size FROM player_planes pp
|
|
JOIN planes p ON pp.plane_id = p.id
|
|
WHERE pp.airport_id = ? AND pp.status = 'taxiing'
|
|
AND p.fueling_time_seconds > 0
|
|
AND DATE_ADD(pp.flight_start_time, INTERVAL p.fueling_time_seconds SECOND) <= NOW()`,
|
|
airportID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var fuelDone []ps
|
|
for rows3.Next() {
|
|
var p ps
|
|
if err := rows3.Scan(&p.id, &p.size); err != nil {
|
|
rows3.Close()
|
|
return err
|
|
}
|
|
fuelDone = append(fuelDone, p)
|
|
}
|
|
rows3.Close()
|
|
for _, p := range fuelDone {
|
|
if _, err := c.FindAvailableRunway(airportID, p.size); err != nil {
|
|
continue // wait for runway
|
|
}
|
|
if _, err := c.db.ExecContext(c.ctx,
|
|
`UPDATE player_planes SET status='flying', current_building_id=NULL, flight_start_time=NOW() WHERE id=?`, p.id,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// ── Unloading done → idle in hangar ───────────────────────────────────
|
|
// Unload duration reuses the plane's fueling_time_seconds (0 ⇒ immediate).
|
|
if _, err := c.db.ExecContext(c.ctx, `
|
|
UPDATE player_planes pp
|
|
JOIN planes p ON pp.plane_id = p.id
|
|
SET pp.status='idle', pp.flight_start_time=NULL
|
|
WHERE pp.airport_id = ? AND pp.status = 'unloading'
|
|
AND DATE_ADD(pp.flight_start_time, INTERVAL p.fueling_time_seconds SECOND) <= NOW()`,
|
|
airportID); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LandFlight awards cash + XP and parks the plane in its hangar.
|
|
func (c *Client) LandFlight(airportID uint32, pp model.PlayerPlane) error {
|
|
tx, err := c.db.BeginTx(c.ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qtx := c.q.WithTx(tx)
|
|
|
|
// Guard: skip if already landed (prevents double-landing on rapid Enter presses).
|
|
var currentStatus string
|
|
var destID sql.NullInt32
|
|
var flightStart sql.NullTime
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT status, destination_airport_id, flight_start_time FROM player_planes WHERE id = ?`, pp.ID,
|
|
).Scan(¤tStatus, &destID, &flightStart); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if currentStatus != "flying" {
|
|
tx.Rollback()
|
|
return nil
|
|
}
|
|
|
|
cashReward := uint64(0)
|
|
if pp.CashReward.Valid {
|
|
cashReward = uint64(pp.CashReward.Int32)
|
|
}
|
|
|
|
var apCash uint64
|
|
var apPassengers, apFuel uint32
|
|
var apXP uint64
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT cash, current_passengers, current_fuel, experience_points FROM airports WHERE id = ?`, airportID,
|
|
).Scan(&apCash, &apPassengers, &apFuel, &apXP); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
if err := qtx.UpdateAirportCurrencies(c.ctx, UpdateAirportCurrenciesParams{
|
|
Cash: apCash + cashReward,
|
|
CurrentPassengers: apPassengers,
|
|
CurrentFuel: apFuel,
|
|
ID: airportID,
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
// Award XP and recompute the player level from the new total so that
|
|
// crossing a threshold actually bumps player_level (it never did before).
|
|
newXP := apXP + uint64(pp.ExpReward)
|
|
newLevel := model.LevelForXP(newXP)
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`UPDATE airports SET experience_points = ?, player_level = ? WHERE id = ?`,
|
|
newXP, newLevel, airportID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
// Record the completed route (origin = player airport, destination = central
|
|
// port). Skip legacy flights that have no destination set.
|
|
if destID.Valid {
|
|
if flightStart.Valid {
|
|
_, err = tx.ExecContext(c.ctx,
|
|
`INSERT INTO completed_flight_routes_log
|
|
(origin_airport_id, destination_airport_id, start_time, end_time)
|
|
VALUES (?, ?, ?, NOW())`, airportID, destID.Int32, flightStart.Time)
|
|
} else {
|
|
_, err = tx.ExecContext(c.ctx,
|
|
`INSERT INTO completed_flight_routes_log
|
|
(origin_airport_id, destination_airport_id, start_time, end_time)
|
|
VALUES (?, ?, NOW(), NOW())`, airportID, destID.Int32)
|
|
}
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Land into an unloading phase at the home hangar (timed via the tick); the
|
|
// plane returns to idle once unloading completes (see AdvanceServiceStages).
|
|
// flight_start_time uses MySQL NOW() to share one clock (bug #16). The
|
|
// destination is cleared now that the plane is home.
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`UPDATE player_planes
|
|
SET status='unloading', current_building_id=?, flight_start_time=NOW(),
|
|
destination_airport_id=NULL
|
|
WHERE id=?`, pp.AssignedHangarID, pp.ID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
if err := qtx.LogCurrencyTransaction(c.ctx, LogCurrencyTransactionParams{
|
|
AirportID: airportID,
|
|
AmountChanged: int32(cashReward),
|
|
CurrencyType: CurrencyTransactionLogsCurrencyTypeCash,
|
|
Reason: fmt.Sprintf("completed_flight_plane_%d", pp.ID),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ─── Buildings ───────────────────────────────────────────────────────────────
|
|
|
|
// GetAirportBuildings returns owned buildings with joined building details.
|
|
// sqlc: GetAirportBuildingsByAirportId (new query added in sql/queries.sql section 8)
|
|
// Falls back to raw scan if the generated function isn't available yet.
|
|
// GetFacilities reports occupancy of the airport's operation buildings (runways
|
|
// and service bays) per aircraft size: total vs. how many are currently busy
|
|
// (a plane physically on them via current_building_id).
|
|
func (c *Client) GetFacilities(airportID uint32) ([]model.FacilityStatus, error) {
|
|
rows, err := c.db.QueryContext(c.ctx, `
|
|
SELECT b.operation_type, b.operation_size,
|
|
COUNT(DISTINCT ab.id) AS total,
|
|
COUNT(DISTINCT pp.current_building_id) AS busy
|
|
FROM airport_buildings ab
|
|
JOIN buildings b ON ab.building_id = b.id
|
|
LEFT JOIN player_planes pp ON pp.current_building_id = ab.id
|
|
WHERE ab.airport_id = ? AND b.building_type = 'operation'
|
|
GROUP BY b.operation_type, b.operation_size
|
|
ORDER BY b.operation_type, b.operation_size`, airportID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []model.FacilityStatus
|
|
for rows.Next() {
|
|
var f model.FacilityStatus
|
|
var opType, size sql.NullString
|
|
if err := rows.Scan(&opType, &size, &f.Total, &f.Busy); err != nil {
|
|
return nil, err
|
|
}
|
|
f.OperationType = opType.String
|
|
f.Size = size.String
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (c *Client) GetAirportBuildings(airportID uint32) ([]model.AirportBuilding, error) {
|
|
rows, err := c.db.QueryContext(c.ctx, `
|
|
SELECT ab.id, ab.airport_id, ab.building_id, ab.level,
|
|
b.name, b.building_type, b.construction_cost_cash,
|
|
b.operation_size, b.operation_type,
|
|
b.capacity, b.storage_type
|
|
FROM airport_buildings ab
|
|
JOIN buildings b ON ab.building_id = b.id
|
|
WHERE ab.airport_id = ?`, airportID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []model.AirportBuilding
|
|
for rows.Next() {
|
|
var ab model.AirportBuilding
|
|
if err := rows.Scan(
|
|
&ab.ID, &ab.AirportID, &ab.BuildingID, &ab.Level,
|
|
&ab.Name, &ab.BuildingType, &ab.ConstructionCostCash,
|
|
&ab.OperationSize, &ab.OperationType,
|
|
&ab.Capacity, &ab.StorageType,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, ab)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetAllBuildings returns the full building catalogue.
|
|
// sqlc: GetAllBuildings (new query added in sql/queries.sql section 8)
|
|
// Falls back to raw scan if the generated function isn't available yet.
|
|
func (c *Client) GetAllBuildings() ([]model.Building, error) {
|
|
rows, err := c.db.QueryContext(c.ctx, `
|
|
SELECT id, name, min_level, construction_cost_cash, upgrade_tier,
|
|
building_type, capacity, storage_type, storage_subtype,
|
|
operation_size, operation_type, passive_type, passive_rate
|
|
FROM buildings ORDER BY building_type, name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []model.Building
|
|
for rows.Next() {
|
|
var b model.Building
|
|
if err := rows.Scan(
|
|
&b.ID, &b.Name, &b.MinLevel, &b.ConstructionCostCash, &b.UpgradeTier,
|
|
&b.BuildingType, &b.Capacity, &b.StorageType, &b.StorageSubtype,
|
|
&b.OperationSize, &b.OperationType, &b.PassiveType, &b.PassiveRate,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ConstructBuilding deducts cash and adds a building to an airport atomically.
|
|
// sqlc: GetAirportByUserId, UpdateAirportCash, LogCurrencyTransaction
|
|
func (c *Client) ConstructBuilding(airportID, buildingID uint32) error {
|
|
tx, err := c.db.BeginTx(c.ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qtx := c.q.WithTx(tx)
|
|
|
|
// Fetch cost + type from buildings table
|
|
var cost uint32
|
|
var buildingType string
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT construction_cost_cash, building_type FROM buildings WHERE id = ?`, buildingID,
|
|
).Scan(&cost, &buildingType); err != nil {
|
|
tx.Rollback()
|
|
return fmt.Errorf("building not found: %w", err)
|
|
}
|
|
|
|
// Enforce per-type building cap (storage / operation) authoritatively inside
|
|
// the tx. Other types are uncapped. The cap is raised via BuyCapUpgrade.
|
|
if capCol := capColumnFor(buildingType); capCol != "" {
|
|
var owned, cap uint32
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
fmt.Sprintf(`SELECT
|
|
(SELECT COUNT(*) FROM airport_buildings ab JOIN buildings b ON ab.building_id = b.id
|
|
WHERE ab.airport_id = ? AND b.building_type = ?),
|
|
%s FROM airports WHERE id = ?`, capCol),
|
|
airportID, buildingType, airportID,
|
|
).Scan(&owned, &cap); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if owned >= cap {
|
|
tx.Rollback()
|
|
return fmt.Errorf("%s cap reached (%d/%d) — buy a capacity upgrade", buildingType, owned, cap)
|
|
}
|
|
}
|
|
|
|
var buildCash uint64
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT cash FROM airports WHERE id = ?`, airportID,
|
|
).Scan(&buildCash); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if buildCash < uint64(cost) {
|
|
tx.Rollback()
|
|
return fmt.Errorf("insufficient funds (need $%d, have $%d)", cost, buildCash)
|
|
}
|
|
|
|
if err := qtx.UpdateAirportCash(c.ctx, UpdateAirportCashParams{
|
|
Cash: buildCash - uint64(cost),
|
|
ID: airportID,
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`INSERT INTO airport_buildings (airport_id, building_id) VALUES (?, ?)`,
|
|
airportID, buildingID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
if err := qtx.LogCurrencyTransaction(c.ctx, LogCurrencyTransactionParams{
|
|
AirportID: airportID,
|
|
AmountChanged: -int32(cost),
|
|
CurrencyType: CurrencyTransactionLogsCurrencyTypeCash,
|
|
Reason: fmt.Sprintf("built_building_%d", buildingID),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ─── Sell / demolish / capacity ───────────────────────────────────────────────
|
|
|
|
// fullRefundWindowSec is how long after purchase a plane sells back for 100%
|
|
// of its buying cost. After this it sells for 50%. Measured on MySQL's clock.
|
|
const fullRefundWindowSec = 5 * 60
|
|
|
|
// demolishFeeDivisor: demolishing a building costs construction_cost / this.
|
|
const demolishFeeDivisor = 4
|
|
|
|
// CapTier is one purchasable +1 increase to a building-type cap, gated by level.
|
|
type CapTier struct {
|
|
BuildingType string
|
|
RequiredLevel uint32
|
|
Cost uint64
|
|
}
|
|
|
|
// CapUpgradeTiers lists capacity upgrades in purchase order per building type.
|
|
// Each entry raises that type's cap by exactly +1 (see BuyCapUpgrade). Tune freely.
|
|
var CapUpgradeTiers = []CapTier{
|
|
{"storage", 5, 1500},
|
|
{"storage", 10, 4000},
|
|
{"storage", 18, 9000},
|
|
{"operation", 5, 2000},
|
|
{"operation", 12, 5000},
|
|
{"operation", 20, 11000},
|
|
}
|
|
|
|
// capColumnFor maps a building type to its airports cap column, or "" if uncapped.
|
|
func capColumnFor(buildingType string) string {
|
|
switch buildingType {
|
|
case "storage":
|
|
return "storage_cap"
|
|
case "operation":
|
|
return "operation_cap"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// baseCapFor is the airport's starting cap for a type (matches schema defaults).
|
|
// Used to derive how many upgrade tiers have already been bought (cap - base).
|
|
func baseCapFor(buildingType string) uint32 {
|
|
switch buildingType {
|
|
case "storage":
|
|
return 3
|
|
case "operation":
|
|
return 2
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// SellPlane removes an idle owned plane and refunds cash: 100% of buying cost if
|
|
// sold within fullRefundWindowSec of purchase, otherwise 50%. Returns the refund.
|
|
func (c *Client) SellPlane(airportID, playerPlaneID uint32) (int64, error) {
|
|
tx, err := c.db.BeginTx(c.ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
qtx := c.q.WithTx(tx)
|
|
|
|
var status string
|
|
var buyingCost uint32
|
|
var elapsedSec int64
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT pp.status, p.buying_cost_cash,
|
|
TIMESTAMPDIFF(SECOND, pp.purchased_at, NOW())
|
|
FROM player_planes pp JOIN planes p ON pp.plane_id = p.id
|
|
WHERE pp.id = ? AND pp.airport_id = ?`, playerPlaneID, airportID,
|
|
).Scan(&status, &buyingCost, &elapsedSec); err != nil {
|
|
tx.Rollback()
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("plane not found")
|
|
}
|
|
return 0, err
|
|
}
|
|
if status != "idle" {
|
|
tx.Rollback()
|
|
return 0, fmt.Errorf("only idle planes can be sold (this one is %s)", status)
|
|
}
|
|
|
|
refund := int64(buyingCost)
|
|
if elapsedSec > fullRefundWindowSec {
|
|
refund = int64(buyingCost) / 2
|
|
}
|
|
|
|
var cash uint64
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT cash FROM airports WHERE id = ?`, airportID,
|
|
).Scan(&cash); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
if err := qtx.UpdateAirportCash(c.ctx, UpdateAirportCashParams{
|
|
Cash: cash + uint64(refund),
|
|
ID: airportID,
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`DELETE FROM player_planes WHERE id = ?`, playerPlaneID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
|
|
if err := qtx.LogCurrencyTransaction(c.ctx, LogCurrencyTransactionParams{
|
|
AirportID: airportID,
|
|
AmountChanged: int32(refund),
|
|
CurrencyType: CurrencyTransactionLogsCurrencyTypeCash,
|
|
Reason: fmt.Sprintf("sold_plane_%d", playerPlaneID),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return refund, nil
|
|
}
|
|
|
|
// DemolishBuilding removes an owned building for a fee (construction_cost /
|
|
// demolishFeeDivisor) with no refund. Returns the fee charged. A building that
|
|
// still has planes assigned/parked is rejected (would violate the FK).
|
|
func (c *Client) DemolishBuilding(airportID, airportBuildingID uint32) (int64, error) {
|
|
tx, err := c.db.BeginTx(c.ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
qtx := c.q.WithTx(tx)
|
|
|
|
var constructionCost uint32
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT b.construction_cost_cash
|
|
FROM airport_buildings ab JOIN buildings b ON ab.building_id = b.id
|
|
WHERE ab.id = ? AND ab.airport_id = ?`, airportBuildingID, airportID,
|
|
).Scan(&constructionCost); err != nil {
|
|
tx.Rollback()
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("building not found")
|
|
}
|
|
return 0, err
|
|
}
|
|
|
|
var inUse int
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT COUNT(*) FROM player_planes
|
|
WHERE assigned_hangar_id = ? OR current_building_id = ?`,
|
|
airportBuildingID, airportBuildingID,
|
|
).Scan(&inUse); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
if inUse > 0 {
|
|
tx.Rollback()
|
|
return 0, fmt.Errorf("building in use by %d plane(s) — sell or relocate them first", inUse)
|
|
}
|
|
|
|
fee := int64(constructionCost) / demolishFeeDivisor
|
|
|
|
var cash uint64
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT cash FROM airports WHERE id = ?`, airportID,
|
|
).Scan(&cash); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
if cash < uint64(fee) {
|
|
tx.Rollback()
|
|
return 0, fmt.Errorf("insufficient funds to demolish (need $%d, have $%d)", fee, cash)
|
|
}
|
|
if err := qtx.UpdateAirportCash(c.ctx, UpdateAirportCashParams{
|
|
Cash: cash - uint64(fee),
|
|
ID: airportID,
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
`DELETE FROM airport_buildings WHERE id = ?`, airportBuildingID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
|
|
if err := qtx.LogCurrencyTransaction(c.ctx, LogCurrencyTransactionParams{
|
|
AirportID: airportID,
|
|
AmountChanged: -int32(fee),
|
|
CurrencyType: CurrencyTransactionLogsCurrencyTypeCash,
|
|
Reason: fmt.Sprintf("demolished_building_%d", airportBuildingID),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return 0, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return fee, nil
|
|
}
|
|
|
|
// BuildingCounts returns how many storage and operation buildings the airport owns.
|
|
func (c *Client) BuildingCounts(airportID uint32) (storage, operation int, err error) {
|
|
rows, err := c.db.QueryContext(c.ctx,
|
|
`SELECT b.building_type, COUNT(*)
|
|
FROM airport_buildings ab JOIN buildings b ON ab.building_id = b.id
|
|
WHERE ab.airport_id = ?
|
|
GROUP BY b.building_type`, airportID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var t string
|
|
var n int
|
|
if err := rows.Scan(&t, &n); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
switch t {
|
|
case "storage":
|
|
storage = n
|
|
case "operation":
|
|
operation = n
|
|
}
|
|
}
|
|
return storage, operation, rows.Err()
|
|
}
|
|
|
|
// GetCaps returns the airport's current per-type building caps.
|
|
func (c *Client) GetCaps(airportID uint32) (storageCap, operationCap uint32, err error) {
|
|
err = c.db.QueryRowContext(c.ctx,
|
|
`SELECT storage_cap, operation_cap FROM airports WHERE id = ?`, airportID,
|
|
).Scan(&storageCap, &operationCap)
|
|
return
|
|
}
|
|
|
|
// NextCapUpgrade returns the next capacity upgrade available for a building type
|
|
// (the tier whose index equals how many have already been bought = cap - base),
|
|
// or ok=false if the type is maxed out. It does not check level/funds.
|
|
func (c *Client) NextCapUpgrade(airportID uint32, buildingType string) (CapTier, bool, error) {
|
|
capCol := capColumnFor(buildingType)
|
|
if capCol == "" {
|
|
return CapTier{}, false, nil
|
|
}
|
|
var cap uint32
|
|
if err := c.db.QueryRowContext(c.ctx,
|
|
fmt.Sprintf(`SELECT %s FROM airports WHERE id = ?`, capCol), airportID,
|
|
).Scan(&cap); err != nil {
|
|
return CapTier{}, false, err
|
|
}
|
|
bought := int(cap - baseCapFor(buildingType))
|
|
idx := 0
|
|
for _, t := range CapUpgradeTiers {
|
|
if t.BuildingType != buildingType {
|
|
continue
|
|
}
|
|
if idx == bought {
|
|
return t, true, nil
|
|
}
|
|
idx++
|
|
}
|
|
return CapTier{}, false, nil
|
|
}
|
|
|
|
// BuyCapUpgrade purchases the next capacity tier for a building type: checks the
|
|
// player level and funds, deducts cash, raises the cap by 1, and logs the spend.
|
|
func (c *Client) BuyCapUpgrade(airportID uint32, buildingType string) error {
|
|
capCol := capColumnFor(buildingType)
|
|
if capCol == "" {
|
|
return fmt.Errorf("%s buildings are not capped", buildingType)
|
|
}
|
|
tier, ok, err := c.NextCapUpgrade(airportID, buildingType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%s capacity is already maxed out", buildingType)
|
|
}
|
|
|
|
tx, err := c.db.BeginTx(c.ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qtx := c.q.WithTx(tx)
|
|
|
|
var level uint32
|
|
var cash uint64
|
|
if err := tx.QueryRowContext(c.ctx,
|
|
`SELECT player_level, cash FROM airports WHERE id = ?`, airportID,
|
|
).Scan(&level, &cash); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if level < tier.RequiredLevel {
|
|
tx.Rollback()
|
|
return fmt.Errorf("requires level %d (you are level %d)", tier.RequiredLevel, level)
|
|
}
|
|
if cash < tier.Cost {
|
|
tx.Rollback()
|
|
return fmt.Errorf("insufficient funds (need $%d, have $%d)", tier.Cost, cash)
|
|
}
|
|
|
|
if err := qtx.UpdateAirportCash(c.ctx, UpdateAirportCashParams{
|
|
Cash: cash - tier.Cost,
|
|
ID: airportID,
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(c.ctx,
|
|
fmt.Sprintf(`UPDATE airports SET %s = %s + 1 WHERE id = ?`, capCol, capCol), airportID,
|
|
); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
if err := qtx.LogCurrencyTransaction(c.ctx, LogCurrencyTransactionParams{
|
|
AirportID: airportID,
|
|
AmountChanged: -int32(tier.Cost),
|
|
CurrencyType: CurrencyTransactionLogsCurrencyTypeCash,
|
|
Reason: fmt.Sprintf("cap_upgrade_%s", buildingType),
|
|
}); err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ─── Planes catalogue ─────────────────────────────────────────────────────────
|
|
|
|
// GetAllPlanes returns every plane template.
|
|
// sqlc: GetAllPlanes (returns []Plane — sqlc uses the model struct directly for SELECT *)
|
|
func (c *Client) GetAllPlanes() ([]model.Plane, error) {
|
|
rows, err := c.q.GetAllPlanes(c.ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.Plane, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = mapPlane(r)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ─── Countries ────────────────────────────────────────────────────────────────
|
|
|
|
// GetAllCountries returns all countries.
|
|
// sqlc: GetAllCountries
|
|
func (c *Client) GetAllCountries() ([]model.Country, error) {
|
|
rows, err := c.q.GetAllCountries(c.ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.Country, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = model.Country{ID: uint16(r.ID), Name: r.Name}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ─── Contracts ────────────────────────────────────────────────────────────────
|
|
|
|
// GetActiveContracts returns unexpired, incomplete contracts with product name.
|
|
// Uses a raw scan because the existing GetActiveContracts sqlc query does not
|
|
// join products. After running sqlc generate with the new
|
|
// GetActiveContractsWithProduct query, replace the body with:
|
|
//
|
|
// rows, _ := c.q.GetActiveContractsWithProduct(c.ctx, airportID)
|
|
func (c *Client) GetActiveContracts(airportID uint32) ([]model.Contract, error) {
|
|
rows, err := c.db.QueryContext(c.ctx, `
|
|
SELECT ac.id, ac.title, ac.required_product_id, ac.required_amount,
|
|
ac.current_amount_delivered, ac.is_completed, ac.expires_at,
|
|
p.name
|
|
FROM airport_contracts ac
|
|
JOIN products p ON ac.required_product_id = p.id
|
|
WHERE ac.airport_id = ?
|
|
AND ac.is_completed = FALSE
|
|
AND ac.expires_at > NOW()
|
|
ORDER BY ac.expires_at`, airportID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []model.Contract
|
|
for rows.Next() {
|
|
var co model.Contract
|
|
if err := rows.Scan(
|
|
&co.ID, &co.Title, &co.RequiredProductID, &co.RequiredAmount,
|
|
&co.CurrentAmountDelivered, &co.IsCompleted, &co.ExpiresAt,
|
|
&co.ProductName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, co)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ─── Row mappers (sqlc generated row → internal/model) ───────────────────────
|
|
|
|
// mapAirport converts the sqlc GetAirportByUserIdRow to model.Airport.
|
|
// Note: sqlc names the return type after the query: GetAirportByUserIdRow.
|
|
func mapAirport(r GetAirportByUserIdRow) *model.Airport {
|
|
return &model.Airport{
|
|
ID: uint32(r.ID),
|
|
Name: r.Name,
|
|
UserID: uint32(r.UserID),
|
|
OriginCountryID: uint16(r.OriginCountryID),
|
|
Cash: r.Cash,
|
|
CurrentPassengers: r.CurrentPassengers,
|
|
MaxPassengerCapacity: r.MaxPassengerCapacity,
|
|
CurrentFuel: r.CurrentFuel,
|
|
MaxFuelCapacity: r.MaxFuelCapacity,
|
|
FuelGenerationRate: r.FuelGenerationRate,
|
|
PassengerGenerationRate: r.PassengerGenerationRate,
|
|
ResourcesLastUpdatedAt: r.ResourcesLastUpdatedAt,
|
|
IsAvailable: r.IsAvailable,
|
|
PlayerLevel: r.PlayerLevel,
|
|
ExperiencePoints: r.ExperiencePoints,
|
|
IsDeleted: r.IsDeleted.Bool,
|
|
}
|
|
}
|
|
|
|
// mapPlane converts the sqlc Plane model struct to model.Plane.
|
|
// For SELECT * queries sqlc returns the table struct directly (not a *Row type).
|
|
func mapPlane(r Plane) model.Plane {
|
|
return model.Plane{
|
|
ID: uint32(r.ID),
|
|
Name: r.Name,
|
|
MinLevel: r.MinLevel,
|
|
OperationCostFuel: r.OperationCostFuel,
|
|
OperationCostCash: r.OperationCostCash,
|
|
MaxPassengers: r.MaxPassengers,
|
|
TravelTimeInSeconds: r.TravelTimeInseconds,
|
|
OperationType: string(r.OperationType),
|
|
AircraftSize: string(r.AircraftSize),
|
|
AircraftType: string(r.AircraftType),
|
|
BuyingCostCash: r.BuyingCostCash,
|
|
CompletedFlightExperience: r.CompletedFlightExperience,
|
|
CompletedFlightCashReward: r.CompletedFlightCashReward,
|
|
CompletedFlightCargoReward: r.CompletedFlightCargoReward,
|
|
}
|
|
}
|
|
|
|
// mapPlayerPlane converts GetPlayerFleetRow to model.PlayerPlane.
|
|
func mapPlayerPlane(r GetPlayerFleetRow) model.PlayerPlane {
|
|
return model.PlayerPlane{
|
|
ID: uint32(r.PlayerPlaneID),
|
|
|
|
PlaneID: uint32(r.ID),
|
|
AssignedHangarID: r.AssignedHangarID,
|
|
CurrentBuildingID: r.CurrentBuildingID,
|
|
Status: string(r.Status),
|
|
UpdatedAt: r.UpdatedAt.Time,
|
|
FlightStartTime: r.FlightStartTime,
|
|
// Joined from planes
|
|
PlaneName: r.Name,
|
|
AircraftSize: string(r.AircraftSize),
|
|
AircraftType: string(r.AircraftType),
|
|
OperationType: string(r.OperationType),
|
|
TravelTimeSec: r.TravelTimeInseconds,
|
|
FuelCost: r.OperationCostFuel,
|
|
CashReward: r.CompletedFlightCashReward,
|
|
ExpReward: r.CompletedFlightExperience,
|
|
}
|
|
}
|
|
|
|
// mapReadyPlane converts GetPlanesReadyToLandRow to model.PlayerPlane.
|
|
// This row only has landing-relevant fields (no full plane join).
|
|
func mapReadyPlane(r GetPlanesReadyToLandRow) model.PlayerPlane {
|
|
return model.PlayerPlane{
|
|
ID: uint32(r.PlayerPlaneID),
|
|
AirportID: r.AirportID,
|
|
AssignedHangarID: r.AssignedHangarID,
|
|
CashReward: r.CompletedFlightCashReward,
|
|
ExpReward: r.CompletedFlightExperience,
|
|
OperationType: string(r.OperationType),
|
|
}
|
|
}
|