skyrama_tui/internal/db/client.go

1384 lines
45 KiB
Go
Raw Normal View History

2026-06-04 20:51:20 +03:00
// 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(&currentCash); 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(&currentFuel, &currentPax); 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(&currentFuel, &currentPax); 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(&currentStatus, &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),
}
}