// 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), } }