commit 4a94bfc4e76d24ce72268b314436f40e83eb3de4 Author: portakal Date: Thu Jun 4 20:51:20 2026 +0300 phase 0.3 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..5cb71ef --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..8c39806 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/skyrama_tui.iml b/.idea/skyrama_tui.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/skyrama_tui.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d40fc77 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,135 @@ +# Skyrama TUI + +A terminal user interface for a Skyrama-clone airport management game, built with BubbleTea. + +## Tech Stack + +- **Go 1.22** +- **github.com/charmbracelet/bubbletea** — TUI framework +- **github.com/charmbracelet/lipgloss** — terminal styling +- **github.com/charmbracelet/bubbles** — list, textinput, etc. +- **github.com/go-sql-driver/mysql** — MySQL driver +- **golang.org/x/crypto/bcrypt** — password hashing +- **sqlc** — SQL → Go codegen (generated files in `internal/db/`) + +## Project Structure + +``` +cmd/skyrama/main.go # Entry point: rootModel (login → app screen transition) +internal/ + db/ + client.go # Thin wrapper over sqlc Queries; auth, transactions, row mappers + db.go / querier.go / + queries.sql.go / models.go # sqlc-generated files (do not edit by hand) + model/model.go # Domain types: Airport, Plane, PlayerPlane, Building, Contract, … + style/style.go # All lipgloss styles and colors + ui/ + app.go # AppModel: tab bar, 10s tick, window sizing + login.go # Login / register screen + airport_view.go # Tab 0: resources, XP bar + fleet.go # Tab 1: planes, launch/land flights + buildings.go # Tab 2: owned buildings + build catalogue + contracts.go # Tab 3: active delivery contracts + shop.go # Tab 4: buy planes +sql/ + schema.sql # MySQL DDL (source of truth) + queries.sql # sqlc input queries +sqlc.yaml # sqlc configuration +buidl.bat # build script (go build -o skyrama ./cmd/skyrama/) +``` + +## Database + +- **Engine:** MySQL +- **Default DSN:** `skyramaUser:Skyrama1234.@tcp(127.0.0.1:3306)/skyrama_clone?parseTime=true` +- Override via `--dsn` flag or `SKYRAMA_DSN` environment variable. +- Schema must be applied before first run: `sql/schema.sql` + +## Build & Run + +```bat +# Build +buidl.bat +# or +go build -o skyrama.exe ./cmd/skyrama/ + +# Run (uses default DSN if SKYRAMA_DSN not set) +.\skyrama.exe + +# Run with custom DSN +.\skyrama.exe --dsn "user:pass@tcp(host:port)/dbname?parseTime=true" + +# Regenerate sqlc files after editing sql/queries.sql +sqlc_generate.bat +``` + +## Architecture + +### Screen flow + +`rootModel` (main.go) owns two screens: +1. `screenLogin` → `ui.LoginModel` — handles `LoginSuccessMsg` / `LoginErrMsg` +2. `screenApp` → `ui.AppModel` — full game TUI after login + +On `LoginSuccessMsg`, `rootModel` builds `AppModel` and injects all sub-models (Fleet, Buildings, Contracts, Shop) with the resolved `airportID`. + +### AppModel (ui/app.go) + +- Owns all tab sub-models as value/pointer fields. +- Routes `tea.Msg` to the active tab sub-model. +- Fires a 10-second tick that: + 1. Calls `RefreshResources` (regenerates fuel/passengers via MySQL `TIMESTAMPDIFF`) + 2. Auto-lands planes ready to land (`GetPlanesReadyToLand` + `LandFlight`) + +### db.Client (internal/db/client.go) + +Wraps the sqlc `*Queries` object. All multi-step DB operations are wrapped in transactions: +- `BuyPlane` — deducts cash, inserts `player_planes`, logs transaction +- `LaunchFlight` — deducts fuel, sets plane status to `flying`, logs transaction +- `LandFlight` — awards cash + XP, parks plane in hangar, logs transaction +- `ConstructBuilding` — deducts cash, inserts `airport_buildings`, logs transaction + +Some queries use raw `database/sql` (e.g. `GetAirportBuildings`, `GetActiveContracts`) because the sqlc-generated join queries are not yet wired up. + +### Domain model (internal/model/model.go) + +Key types: `Airport`, `Plane`, `PlayerPlane`, `Building`, `AirportBuilding`, `Contract`, `Country`, `User`. + +`PlayerPlane.TimeRemainingSeconds()` computes seconds until landing client-side. +`Contract.ProgressPct()` returns delivery progress as an integer percentage. + +Tab index constants: `TabAirport=0`, `TabFleet=1`, `TabBuildings=2`, `TabContracts=3`, `TabShop=4`. + +## Key Bindings + +| Key | Action | +|-----|--------| +| `1-5` / `tab` / `shift+tab` | Switch tabs | +| `↑↓` / `j k` | Navigate lists | +| `enter` / `space` | Select / action | +| `/` | Filter list | +| `r` | Refresh current tab | +| `h / l` | Switch sub-tabs (Buildings) | +| `q` / `ctrl+c` | Quit | + +**Fleet tab:** `enter` on idle plane → launch; `enter` on flying plane → check/land if ready. +**Buildings tab:** `h/l` or `←→` → toggle Owned ↔ Build catalogue; `enter` in catalogue → construct. +**Shop tab:** `enter` / `b` → buy selected plane (deducts cash, parks in first hangar). + +## DB Schema Overview + +Core tables: `users`, `airports`, `planes`, `player_planes`, `buildings`, `airport_buildings`, `countries`, `products`, `airport_contracts`, `airport_inventory`. +Log tables: `currency_transaction_logs`, `completed_flight_routes_log`. +Stage 1 (not yet active): `recipes`, `building_products`. + +**Plane statuses:** `idle` | `maintenance` | `boarding` | `taxiing` | `flying` +**Building types:** `none` | `storage` | `support` | `operation` | `passive` | `shop` | `production` +**Aircraft sizes:** `small` | `medium` | `large` | `huge` +**Aircraft types:** `plane` | `helicopter` | `seaplane` | `spaceship` + +## Development Stages + +- **Stage 0 (current):** Launch planes to country's base airport, basic resources, buildings, shop. +- **Stage 1:** NPCs, cargo planes, shop buildings, recipes, inventory. +- **Stage 2:** Player progression, building upgrades, effects, complex recipes. +- **Stage 3:** TBD. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c837308 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Skyrama TUI + +A terminal user interface for Skyrama Clone built with [BubbleTea](https://github.com/charmbracelet/bubbletea). + +## Structure + +``` +skyrama-tui/ +├── cmd/ +│ └── skyrama/ +│ └── main.go # Entry point, login → app wiring +├── internal/ +│ ├── db/ +│ │ └── client.go # MySQL client wrapping all queries +│ ├── model/ +│ │ └── model.go # Domain types (Airport, Plane, Building, …) +│ ├── style/ +│ │ └── style.go # All lipgloss styles, colors, resource bars +│ └── ui/ +│ ├── app.go # Root AppModel: tab bar, tick, window sizing +│ ├── airport_view.go # Airport overview panel (resources, XP bar) +│ ├── buildings.go # Buildings tab: owned list + build catalogue +│ ├── contracts.go # Contracts tab: active delivery contracts +│ ├── fleet.go # Fleet tab: planes, launch / land flights +│ ├── login.go # Login / register screen +│ └── shop.go # Shop tab: buy planes from catalogue +└── go.mod +``` + +## Setup + +```bash +# 1. Clone / copy into your project +git clone +cd skyrama-tui + +# 2. Install dependencies +go mod tidy + +# 3. Run (DB must already have the schema applied) +SKYRAMA_DSN='skyramaUser:Skyrama1234.@tcp(127.0.0.1:3306)/skyrama_clone?parseTime=true' go run ./cmd/skyrama/ + +# Or compile and run: +go build -o skyrama ./cmd/skyrama/ +./skyrama --dsn 'user:pass@tcp(host:port)/dbname?parseTime=true' +``` + +## Key Bindings + +| Key | Action | +|-----|--------| +| `1-5` / `tab` / `shift+tab` | Switch tabs | +| `↑↓` / `j k` | Navigate lists | +| `enter` / `space` | Select / action | +| `/` | Filter current list | +| `r` | Refresh current tab | +| `h / l` | Switch sub-tabs (Buildings) | +| `q` / `ctrl+c` | Quit | + +### Fleet tab +| Key | Action | +|-----|--------| +| `enter` on idle plane | Launch flight (deducts fuel, finds runway) | +| `enter` on flying plane | Check & land if travel time elapsed | + +### Buildings tab +| Key | Action | +|-----|--------| +| `h / l` or `→ ←` | Toggle Owned ↔ Build catalogue | +| `enter` in Build catalogue | Construct building (deducts cash) | + +### Shop tab +| Key | Action | +|-----|--------| +| `enter` / `b` | Buy selected plane (deducts cash, parks in first hangar) | + +## Auto-refresh + +Every **10 seconds** the TUI: +1. Runs `UPDATE airports SET current_fuel = ..., current_passengers = ...` to regenerate resources. +2. Checks for planes ready to land (`flying` status + elapsed travel time) and processes landings automatically. + +## Architecture Notes + +- **No sqlc dependency at runtime** — `internal/db/client.go` talks raw `database/sql` for full + control and to avoid a second build step. Swap it for your sqlc `Queries` type any time. +- All DB mutations are wrapped in transactions (buy plane, build, launch, land). +- BubbleTea message types are defined in each file that owns them (`FleetLoadedMsg`, `ShopErrMsg`, …). +- The `AppModel` owns all sub-models as value fields and forwards `tea.Msg` to the active one. diff --git a/admin.exe b/admin.exe new file mode 100644 index 0000000..f3c15b3 Binary files /dev/null and b/admin.exe differ diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..4ff4221 --- /dev/null +++ b/build.bat @@ -0,0 +1,31 @@ +@echo off +setlocal EnableDelayedExpansion +cd /d "%~dp0" + +:: ── Check Go ───────────────────────────────────────────────── +where go >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Go is not installed or not on PATH. + pause & exit /b 1 +) + +:: ── Deps ───────────────────────────────────────────────────── +echo [*] go mod tidy... +go mod tidy +if errorlevel 1 ( echo [ERROR] go mod tidy failed. & pause & exit /b 1 ) + +:: ── Build game ─────────────────────────────────────────────── +echo [*] Building skyrama.exe ... +go build -o skyrama.exe .\cmd\skyrama\ +if errorlevel 1 ( echo [ERROR] Game build failed. & pause & exit /b 1 ) + +:: ── Build admin ────────────────────────────────────────────── +echo [*] Building admin.exe ... +go build -o admin.exe .\cmd\admin\ +if errorlevel 1 ( echo [ERROR] Admin build failed. & pause & exit /b 1 ) + +echo. +echo [OK] skyrama.exe and admin.exe built successfully. +echo. +pause +endlocal diff --git a/cmd/admin/main.go b/cmd/admin/main.go new file mode 100644 index 0000000..444b8a5 --- /dev/null +++ b/cmd/admin/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + tea "github.com/charmbracelet/bubbletea" + "skyrama-tui/internal/adminui" + "skyrama-tui/internal/db" +) + +func main() { + dsn := flag.String("dsn", "", + "MySQL DSN. Overrides SKYRAMA_DSN env var.\n"+ + " Format: user:pass@tcp(host:port)/dbname?parseTime=true") + flag.Parse() + + if *dsn == "" { + *dsn = os.Getenv("SKYRAMA_DSN") + } + if *dsn == "" { + *dsn = "skyramaUser:Skyrama1234.@tcp(127.0.0.1:3306)/skyrama_clone?parseTime=true" + } + + client, err := db.NewAdminClient(*dsn) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot connect to database: %v\n", err) + os.Exit(1) + } + defer client.Close() + + p := tea.NewProgram(adminui.NewApp(client), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + log.Fatalf("fatal: %v", err) + } +} diff --git a/cmd/skyrama/main.go b/cmd/skyrama/main.go new file mode 100644 index 0000000..3ce0ea1 --- /dev/null +++ b/cmd/skyrama/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "skyrama-tui/internal/db" + "skyrama-tui/internal/service" + "skyrama-tui/internal/ui" +) + +// ─── Root model ─────────────────────────────────────────────────────────────── + +type screen int + +const ( + screenLogin screen = iota + screenApp +) + +type rootModel struct { + client *service.GameService + screen screen + login ui.LoginModel + app ui.AppModel + winWidth int + winHeight int +} + +func newRootModel(client *service.GameService) rootModel { + return rootModel{ + client: client, + screen: screenLogin, + login: ui.NewLoginModel(client), + } +} + +func (r rootModel) Init() tea.Cmd { + return r.login.Init() +} + +func (r rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Always track window size so we can replay it into AppModel on login. + if ws, ok := msg.(tea.WindowSizeMsg); ok { + r.winWidth, r.winHeight = ws.Width, ws.Height + } + + switch r.screen { + // ── Login screen ────────────────────────────────────────────────────── + case screenLogin: + // Handle login result messages at this level so we can transition screens + switch m := msg.(type) { + case ui.LoginSuccessMsg: + // Build the full AppModel and inject sub-models + app := ui.NewAppModel(r.client, m.UserID, m.Username) + fleet := ui.NewFleetModel(r.client, 0) + app.Fleet(&fleet) + buildings := ui.NewBuildingsModel(r.client, 0) + app.SetBuildings(&buildings) + contracts := ui.NewContractsModel(r.client, 0) + app.SetContracts(&contracts) + shop := ui.NewShopModel(r.client, 0, 1) + app.SetShop(&shop) + + r.screen = screenApp + r.app = app + + // Replay the window size into AppModel so list viewports are + // sized correctly — the initial WindowSizeMsg arrived while + // the login screen was active and was never forwarded to AppModel. + var cmds []tea.Cmd + cmds = append(cmds, r.app.Init()) + if r.winWidth > 0 { + updated, cmd := r.app.Update(tea.WindowSizeMsg{ + Width: r.winWidth, Height: r.winHeight, + }) + r.app = updated.(ui.AppModel) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return r, tea.Batch(cmds...) + + case ui.LoginErrMsg: + // Inject the error then re-run update for cursor blink etc. + r.login.SetErr(m.Err.Error()) + updated, cmd := r.login.Update(msg) + r.login = updated + return r, cmd + + default: + updated, cmd := r.login.Update(msg) + r.login = updated + return r, cmd + } + + // ── Main app ────────────────────────────────────────────────────────── + case screenApp: + updated, cmd := r.app.Update(msg) + r.app = updated.(ui.AppModel) + return r, cmd + } + + return r, nil +} + +func (r rootModel) View() string { + switch r.screen { + case screenLogin: + return r.login.View() + case screenApp: + return r.app.View() + } + return "" +} + +// ─── Entry point ────────────────────────────────────────────────────────────── + +func main() { + dsn := flag.String("dsn", "", + "MySQL DSN string. Overrides SKYRAMA_DSN env var.\n"+ + " Format: user:pass@tcp(host:port)/dbname?parseTime=true") + flag.Parse() + + if *dsn == "" { + *dsn = os.Getenv("SKYRAMA_DSN") + } + // Fall back to the same DSN used in db_manager.go + if *dsn == "" { + *dsn = "skyramaUser:Skyrama1234.@tcp(127.0.0.1:3306)/skyrama_clone?parseTime=true" + } + + client, err := db.NewClient(*dsn) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot connect to database: %v\n\n", err) + fmt.Fprintln(os.Stderr, "Set the connection string via --dsn flag or the SKYRAMA_DSN environment variable.") + fmt.Fprintln(os.Stderr, "Example:") + fmt.Fprintln(os.Stderr, " SKYRAMA_DSN='skyramaUser:Skyrama1234.@tcp(127.0.0.1:3306)/skyrama_clone?parseTime=true' ./skyrama-tui") + os.Exit(1) + } + // Wrap the data-access client in the business-logic service the UI depends on. + // Central ports / factions are seeded out-of-band via sql/seed_factions.sql. + svc := service.NewGameService(client) + defer svc.Close() + + p := tea.NewProgram( + newRootModel(svc), + tea.WithAltScreen(), // use alternate screen buffer (hides shell history) + tea.WithMouseCellMotion(), // mouse support for scroll + ) + + if _, err := p.Run(); err != nil { + log.Fatalf("fatal: %v", err) + } +} diff --git a/gitignore b/gitignore new file mode 100644 index 0000000..b2e0230 --- /dev/null +++ b/gitignore @@ -0,0 +1,57 @@ +# ======================== +# JetBrains IDEs (IntelliJ IDEA, GoLand, etc.) +# ======================== +.idea/ +*.iml +*.ipr +*.iws +out/ +.idea_modules/ + +# ======================== +# Visual Studio Code +# ======================== +.vscode/ +*.code-workspace +.history/ + +# ======================== +# Go +# ======================== +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of go build +*.out + +# Dependency directory +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Go module download cache +/go/pkg/mod/ + +# Build output +bin/ +dist/ + +# Environment files +.env +.env.local +.env.*.local + +# OS generated +.DS_Store +.DS_Store? +Thumbs.db +ehthumbs.db diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..94c04ed --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module skyrama-tui + +go 1.22 + +require ( + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.26.4 + github.com/charmbracelet/lipgloss v0.11.0 + github.com/go-sql-driver/mysql v1.8.1 + golang.org/x/crypto v0.24.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.2 // indirect + github.com/charmbracelet/x/input v0.1.0 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..70b4bd7 --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40= +github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= +github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= +github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= +github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= +github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= +github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/internal/adminui/airports.go b/internal/adminui/airports.go new file mode 100644 index 0000000..a2dac12 --- /dev/null +++ b/internal/adminui/airports.go @@ -0,0 +1,275 @@ +package adminui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "skyrama-tui/internal/db" +) + +// ── Messages ────────────────────────────────────────────────────────────────── + +type airportLoadedMsg []db.AdminAirport +type airportDoneMsg struct{ err error } + +// ── Action mode ─────────────────────────────────────────────────────────────── + +type airportAction int + +const ( + airportActionNone airportAction = iota + airportActionGiveCash + airportActionGiveBuilding +) + +// ── Model ───────────────────────────────────────────────────────────────────── + +// field indices for airport edit form +const ( + aeqCash = 0 + aeqLevel = 1 + aeqMaxFuel = 2 + aeqMaxPax = 3 +) + +type airportsTab struct { + client *db.AdminClient + items []db.AdminAirport + cursor int + // simple single-input actions (give cash / give building) + action airportAction + input textinput.Model + // full edit form + inForm bool + editID uint32 + fields []field + focusIdx int + status string + isErr bool + w, h int +} + +func newAirportsTab(client *db.AdminClient) *airportsTab { + ti := textinput.New() + ti.CharLimit = 20 + return &airportsTab{client: client, input: ti} +} + +func (t *airportsTab) InForm() bool { return t.action != airportActionNone || t.inForm } +func (t *airportsTab) Init() tea.Cmd { return t.load() } + +func (t *airportsTab) load() tea.Cmd { + return func() tea.Msg { + items, err := t.client.ListAirports() + if err != nil { + return airportDoneMsg{err} + } + return airportLoadedMsg(items) + } +} + +func (t *airportsTab) openEditForm(a db.AdminAirport) { + t.fields = []field{ + prefill("Cash (set)", strconv.FormatUint(a.Cash, 10)), + prefill("Player Level", strconv.Itoa(int(a.Level))), + prefill("Max Fuel Capacity", strconv.Itoa(int(a.MaxFuel))), + prefill("Max Passenger Cap", strconv.Itoa(int(a.MaxPax))), + } + t.fields[aeqCash].input.Focus() + t.focusIdx = aeqCash + t.editID = a.ID + t.inForm = true + t.status = "" +} + +func (t *airportsTab) submitEdit() tea.Cmd { + cash, e1 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqCash].value()), 10, 64) + level, e2 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqLevel].value()), 10, 32) + maxFuel, e3 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqMaxFuel].value()), 10, 32) + maxPax, e4 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqMaxPax].value()), 10, 32) + if e1 != nil || e2 != nil || e3 != nil || e4 != nil { + t.status = "all fields must be valid numbers" + t.isErr = true + return nil + } + id := t.editID + return func() tea.Msg { + return airportDoneMsg{t.client.UpdateAirport(id, cash, uint32(level), uint32(maxFuel), uint32(maxPax))} + } +} + +func (t *airportsTab) openAction(a airportAction, placeholder string) { + t.action = a + t.input.Reset() + t.input.Placeholder = placeholder + t.input.Focus() + t.status = "" +} + +func (t *airportsTab) submitAction() tea.Cmd { + if len(t.items) == 0 { + return nil + } + airport := t.items[t.cursor] + raw := strings.TrimSpace(t.input.Value()) + + switch t.action { + case airportActionGiveCash: + amount, err := strconv.ParseUint(raw, 10, 64) + if err != nil || amount == 0 { + t.status = "enter a positive cash amount" + t.isErr = true + return nil + } + id := airport.ID + return func() tea.Msg { + return airportDoneMsg{t.client.GiveAirportCash(id, amount)} + } + + case airportActionGiveBuilding: + bID, err := strconv.ParseUint(raw, 10, 32) + if err != nil || bID == 0 { + t.status = "enter a valid building ID" + t.isErr = true + return nil + } + id := airport.ID + return func() tea.Msg { + return airportDoneMsg{t.client.GiveAirportBuilding(id, uint32(bID))} + } + } + return nil +} + +func (t *airportsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + t.w, t.h = m.Width, m.Height + return t, nil + + case airportLoadedMsg: + t.items = []db.AdminAirport(m) + if t.cursor >= len(t.items) && t.cursor > 0 { + t.cursor = len(t.items) - 1 + } + return t, nil + + case airportDoneMsg: + if m.err != nil { + t.status = m.err.Error() + t.isErr = true + return t, nil + } + t.action = airportActionNone + t.inForm = false + t.status = "done" + t.isErr = false + return t, t.load() + + case tea.KeyMsg: + if t.inForm { + switch m.String() { + case "esc": + t.inForm = false + t.status = "" + case "ctrl+s": + return t, t.submitEdit() + default: + var cmd tea.Cmd + t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m) + return t, cmd + } + return t, nil + } + if t.action != airportActionNone { + switch m.String() { + case "esc": + t.action = airportActionNone + t.status = "" + case "enter", "ctrl+s": + return t, t.submitAction() + default: + var cmd tea.Cmd + t.input, cmd = t.input.Update(m) + return t, cmd + } + return t, nil + } + switch m.String() { + case "q": + return t, tea.Quit + case "j", "down": + if t.cursor < len(t.items)-1 { + t.cursor++ + } + case "k", "up": + if t.cursor > 0 { + t.cursor-- + } + case "e": + if len(t.items) > 0 { + t.openEditForm(t.items[t.cursor]) + } + case "g": + t.openAction(airportActionGiveCash, "amount, e.g. 5000") + case "b": + t.openAction(airportActionGiveBuilding, "building ID, e.g. 3") + case "r": + return t, t.load() + } + } + return t, nil +} + +func (t *airportsTab) View() string { + var sb strings.Builder + sb.WriteString(hdrSt.Render(" Airports") + "\n\n") + + if t.inForm { + sb.WriteString(titleSt.Render(fmt.Sprintf(" Edit Airport #%d", t.editID)) + "\n\n") + sb.WriteString(renderForm(t.fields, t.focusIdx)) + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpForm)) + return sb.String() + } + + if t.action != airportActionNone { + if len(t.items) > 0 { + ap := t.items[t.cursor] + sb.WriteString(fmt.Sprintf(" Selected: %s (owner: %s, cash: %d)\n\n", + titleSt.Render(ap.Name), ap.Username, ap.Cash)) + } + label := "Give Cash Amount:" + if t.action == airportActionGiveBuilding { + label = "Building ID to add:" + } + sb.WriteString(" " + labelSt.Render(label) + " " + t.input.View() + "\n") + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render("enter:confirm esc:cancel")) + return sb.String() + } + + header := fmt.Sprintf(" %-5s %-20s %-16s %-12s %-8s %s", + "ID", "Airport Name", "Owner", "Cash", "Fuel", "Lvl") + sb.WriteString(dimSt.Render(header) + "\n") + sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 70)) + "\n") + + if len(t.items) == 0 { + sb.WriteString(dimSt.Render(" (no airports)") + "\n") + } + for i, a := range t.items { + line := fmt.Sprintf(" %-5d %-20s %-16s %-12d %-8d %d", + a.ID, a.Name, a.Username, a.Cash, a.Fuel, a.Level) + if i == t.cursor { + sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n") + } else { + sb.WriteString(line + "\n") + } + } + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render("e:edit g:give cash b:give building j/k:nav r:reload q:quit")) + return sb.String() +} diff --git a/internal/adminui/app.go b/internal/adminui/app.go new file mode 100644 index 0000000..22566b7 --- /dev/null +++ b/internal/adminui/app.go @@ -0,0 +1,121 @@ +package adminui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "skyrama-tui/internal/db" +) + +var tabNames = []string{"Products", "Countries", "Buildings", "Planes", "Users", "Airports", "Factions"} + +// App is the root admin model. It owns the tab bar and routes messages. +type App struct { + tabs []Tab + activeTab int + width int + height int +} + +// NewApp constructs the admin app and wires up all tab sub-models. +func NewApp(client *db.AdminClient) App { + return App{ + tabs: []Tab{ + newProductsTab(client), + newCountriesTab(client), + newBuildingsTab(client), + newPlanesTab(client), + newUsersTab(client), + newAirportsTab(client), + newFactionsTab(client), + }, + } +} + +func (a App) Init() tea.Cmd { + var cmds []tea.Cmd + for _, t := range a.tabs { + if cmd := t.Init(); cmd != nil { + cmds = append(cmds, cmd) + } + } + return tea.Batch(cmds...) +} + +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + a.width, a.height = m.Width, m.Height + // Fan out window size to all tabs + for i, t := range a.tabs { + updated, _ := t.Update(msg) + a.tabs[i] = updated.(Tab) + } + return a, nil + + case tea.KeyMsg: + activeInForm := a.tabs[a.activeTab].InForm() + + // Global quit always works + if m.String() == "ctrl+c" { + return a, tea.Quit + } + + // Tab switching only when not in a form + if !activeInForm { + switch m.String() { + case "1", "2", "3", "4", "5", "6", "7": + idx := int(m.String()[0] - '1') + if idx < len(a.tabs) { + a.activeTab = idx + } + return a, nil + case "tab": + a.activeTab = (a.activeTab + 1) % len(a.tabs) + return a, nil + case "shift+tab": + a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs) + return a, nil + } + } + } + + // Fan out non-key messages to all tabs; key messages only go to active tab + if _, isKey := msg.(tea.KeyMsg); !isKey { + var cmds []tea.Cmd + for i, t := range a.tabs { + updated, cmd := t.Update(msg) + a.tabs[i] = updated.(Tab) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return a, tea.Batch(cmds...) + } + + updated, cmd := a.tabs[a.activeTab].Update(msg) + a.tabs[a.activeTab] = updated.(Tab) + return a, cmd +} + +func (a App) View() string { + // Tab bar + var tabs []string + for i, name := range tabNames { + key := dimSt.Render(string(rune('1'+i))) + " " + if i == a.activeTab { + tabs = append(tabs, lipgloss.NewStyle().Bold(true). + Foreground(lipgloss.Color("#E94560")). + Render("["+key+name+"]")) + } else { + tabs = append(tabs, dimSt.Render(" "+key+name+" ")) + } + } + bar := strings.Join(tabs, dimSt.Render("│")) + header := titleSt.Render("✈ SKYRAMA ADMIN") + " " + bar + + divider := dimSt.Render(strings.Repeat("─", a.width)) + + return header + "\n" + divider + "\n" + a.tabs[a.activeTab].View() +} diff --git a/internal/adminui/buildings.go b/internal/adminui/buildings.go new file mode 100644 index 0000000..1a6c5ca --- /dev/null +++ b/internal/adminui/buildings.go @@ -0,0 +1,310 @@ +package adminui + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "skyrama-tui/internal/db" +) + +// ── Messages ────────────────────────────────────────────────────────────────── + +type bldgLoadedMsg []db.AdminBuilding +type bldgDoneMsg struct{ err error } + +// ── Field indices ───────────────────────────────────────────────────────────── + +const ( + bfName = 0 + bfMinLevel = 1 + bfCost = 2 + bfType = 3 + // storage + bfCapacity = 4 + bfStorType = 5 + bfStorSubtype = 6 + // operation + bfOpType = 7 + bfOpSize = 8 + // passive + bfPassiveType = 9 + bfPassiveRate = 10 +) + +// ── Model ───────────────────────────────────────────────────────────────────── + +type buildingsTab struct { + client *db.AdminClient + items []db.AdminBuilding + cursor int + inForm bool + isEdit bool + editID uint32 + fields []field + focusIdx int + status string + isErr bool + w, h int +} + +func newBuildingsTab(client *db.AdminClient) *buildingsTab { + return &buildingsTab{client: client} +} + +func (t *buildingsTab) InForm() bool { return t.inForm } +func (t *buildingsTab) Init() tea.Cmd { return t.load() } + +func (t *buildingsTab) load() tea.Cmd { + return func() tea.Msg { + items, err := t.client.ListBuildings() + if err != nil { + return bldgDoneMsg{err} + } + return bldgLoadedMsg(items) + } +} + +func (t *buildingsTab) initFields() { + t.fields = []field{ + newTextField("Name", "e.g. Small Hangar"), + newTextField("Min Level", "1"), + newTextField("Cost (cash)", "100"), + newEnumField("Type", []string{"none", "storage", "operation", "passive"}), + // storage + newTextField("Capacity", "10"), + newEnumField("Storage Type", []string{"fuel", "plane", "product"}), + newEnumField("Storage Subtype", []string{"none", "airplane", "helicopter", "seaplane", "spaceship"}), + // operation + newEnumField("Op Type", []string{"runway", "service", "production"}), + newEnumField("Op Size", []string{"small", "medium", "large", "huge"}), + // passive + newEnumField("Passive Type", []string{"passengers", "fuel"}), + newTextField("Passive Rate", "10"), + } +} + +func (t *buildingsTab) openForm() { + t.initFields() + t.fields[bfName].input.Focus() + t.focusIdx = bfName + t.isEdit = false + t.updateHidden() + t.inForm = true + t.status = "" +} + +func (t *buildingsTab) openEditForm(b db.AdminBuilding) { + t.initFields() + t.fields[bfName].input.SetValue(b.Name) + t.fields[bfMinLevel].input.SetValue(strconv.Itoa(int(b.MinLevel))) + t.fields[bfCost].input.SetValue(strconv.Itoa(int(b.Cost))) + setEnumSel(&t.fields[bfType], b.BuildingType) + t.fields[bfCapacity].input.SetValue(strconv.Itoa(b.Capacity)) + setEnumSel(&t.fields[bfStorType], b.StorageType) + setEnumSel(&t.fields[bfStorSubtype], b.StorageSubtype) + setEnumSel(&t.fields[bfOpType], b.OperationType) + setEnumSel(&t.fields[bfOpSize], b.OperationSize) + setEnumSel(&t.fields[bfPassiveType], b.PassiveType) + t.fields[bfPassiveRate].input.SetValue(strconv.Itoa(b.PassiveRate)) + t.fields[bfName].input.Focus() + t.focusIdx = bfName + t.isEdit = true + t.editID = b.ID + t.updateHidden() + t.inForm = true + t.status = "" +} + +// updateHidden shows/hides type-specific fields based on the type enum selection. +func (t *buildingsTab) updateHidden() { + btype := t.fields[bfType].options[t.fields[bfType].sel] + for i := range t.fields { + switch i { + case bfCapacity, bfStorType, bfStorSubtype: + t.fields[i].hidden = btype != "storage" + case bfOpType, bfOpSize: + t.fields[i].hidden = btype != "operation" + case bfPassiveType, bfPassiveRate: + t.fields[i].hidden = btype != "passive" + } + } +} + +func (t *buildingsTab) submit() tea.Cmd { + name := strings.TrimSpace(t.fields[bfName].value()) + if name == "" { + t.status = "name is required" + t.isErr = true + return nil + } + lvl, err := strconv.ParseUint(strings.TrimSpace(t.fields[bfMinLevel].value()), 10, 32) + if err != nil { + t.status = "min level must be a number" + t.isErr = true + return nil + } + cost, err := strconv.ParseUint(strings.TrimSpace(t.fields[bfCost].value()), 10, 32) + if err != nil { + t.status = "cost must be a number" + t.isErr = true + return nil + } + btype := t.fields[bfType].value() + + p := db.AddBuildingParams{ + Name: name, + MinLevel: uint32(lvl), + Cost: uint32(cost), + BuildingType: btype, + } + + switch btype { + case "storage": + cap, err := strconv.Atoi(strings.TrimSpace(t.fields[bfCapacity].value())) + if err != nil || cap <= 0 { + t.status = "capacity must be a positive number" + t.isErr = true + return nil + } + p.Capacity = cap + p.StorageType = t.fields[bfStorType].value() + p.StorageSubtype = t.fields[bfStorSubtype].value() + case "operation": + p.OperationType = t.fields[bfOpType].value() + p.OperationSize = t.fields[bfOpSize].value() + case "passive": + rate, err := strconv.Atoi(strings.TrimSpace(t.fields[bfPassiveRate].value())) + if err != nil { + t.status = "passive rate must be a number" + t.isErr = true + return nil + } + p.PassiveType = t.fields[bfPassiveType].value() + p.PassiveRate = rate + } + + if t.isEdit { + id := t.editID + return func() tea.Msg { return bldgDoneMsg{t.client.UpdateBuilding(id, p)} } + } + return func() tea.Msg { return bldgDoneMsg{t.client.AddBuilding(p)} } +} + +func (t *buildingsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + t.w, t.h = m.Width, m.Height + return t, nil + + case bldgLoadedMsg: + t.items = []db.AdminBuilding(m) + if t.cursor >= len(t.items) && t.cursor > 0 { + t.cursor = len(t.items) - 1 + } + return t, nil + + case bldgDoneMsg: + if m.err != nil { + t.status = m.err.Error() + t.isErr = true + return t, nil + } + t.inForm = false + t.status = "saved" + t.isErr = false + return t, t.load() + + case tea.KeyMsg: + if t.inForm { + switch m.String() { + case "esc": + t.inForm = false + t.status = "" + case "ctrl+s": + return t, t.submit() + default: + prevType := t.fields[bfType].sel + var cmd tea.Cmd + t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m) + // If type enum changed, update field visibility + if t.fields[bfType].sel != prevType { + t.updateHidden() + // If current focus is now hidden, jump to next visible + if t.focusIdx < len(t.fields) && t.fields[t.focusIdx].hidden { + t.focusIdx = nextVisible(t.fields, t.focusIdx) + applyFocus(t.fields, t.focusIdx) + } + } + return t, cmd + } + return t, nil + } + switch m.String() { + case "q": + return t, tea.Quit + case "j", "down": + if t.cursor < len(t.items)-1 { + t.cursor++ + } + case "k", "up": + if t.cursor > 0 { + t.cursor-- + } + case "a": + t.openForm() + case "e": + if len(t.items) > 0 { + t.openEditForm(t.items[t.cursor]) + } + case "d": + if len(t.items) > 0 { + id := t.items[t.cursor].ID + return t, func() tea.Msg { + return bldgDoneMsg{t.client.DeleteBuilding(id)} + } + } + case "r": + return t, t.load() + } + } + return t, nil +} + +func (t *buildingsTab) View() string { + var sb strings.Builder + sb.WriteString(hdrSt.Render(" Buildings") + "\n\n") + + if t.inForm { + formTitle := "Add Building" + if t.isEdit { + formTitle = fmt.Sprintf("Edit Building #%d", t.editID) + } + sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n") + sb.WriteString(renderForm(t.fields, t.focusIdx)) + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpForm)) + return sb.String() + } + + header := fmt.Sprintf(" %-5s %-24s %-12s %-8s %s", "ID", "Name", "Type", "Cost", "Details") + sb.WriteString(dimSt.Render(header) + "\n") + sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 65)) + "\n") + + if len(t.items) == 0 { + sb.WriteString(dimSt.Render(" (no buildings — press a to add)") + "\n") + } + for i, b := range t.items { + line := fmt.Sprintf(" %-5d %-24s %-12s %-8d %s", + b.ID, b.Name, b.BuildingType, b.Cost, b.Details) + if i == t.cursor { + sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n") + } else { + sb.WriteString(line + "\n") + } + } + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpList)) + return sb.String() +} diff --git a/internal/adminui/countries.go b/internal/adminui/countries.go new file mode 100644 index 0000000..d6b7019 --- /dev/null +++ b/internal/adminui/countries.go @@ -0,0 +1,226 @@ +package adminui + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "skyrama-tui/internal/db" +) + +// ── Messages ────────────────────────────────────────────────────────────────── + +type cntryLoadedMsg []db.AdminCountry +type cntryDoneMsg struct{ err error } + +// ── Field indices ───────────────────────────────────────────────────────────── + +const ( + cfName = 0 + cfContinent = 1 + cfP1 = 2 + cfP2 = 3 + cfP3 = 4 +) + +var continentOptions = []string{ + "void", "Africa", "Antarctica", "Asia", + "Europe", "North America", "Oceania", "South America", +} + +// ── Model ───────────────────────────────────────────────────────────────────── + +type countriesTab struct { + client *db.AdminClient + items []db.AdminCountry + cursor int + inForm bool + isEdit bool + editID uint16 + fields []field + focusIdx int + status string + isErr bool + w, h int +} + +func newCountriesTab(client *db.AdminClient) *countriesTab { + return &countriesTab{client: client} +} + +func (t *countriesTab) InForm() bool { return t.inForm } +func (t *countriesTab) Init() tea.Cmd { return t.load() } + +func (t *countriesTab) load() tea.Cmd { + return func() tea.Msg { + items, err := t.client.ListCountries() + if err != nil { + return cntryDoneMsg{err} + } + return cntryLoadedMsg(items) + } +} + +func (t *countriesTab) openForm() { + t.fields = []field{ + newTextField("Name", "e.g. Turkey"), + newEnumField("Continent", continentOptions), + newTextField("Product 1 ID", "1"), + newTextField("Product 2 ID", "2"), + newTextField("Product 3 ID", "3"), + } + t.fields[cfName].input.Focus() + t.focusIdx = cfName + t.isEdit = false + t.inForm = true + t.status = "" +} + +func (t *countriesTab) openEditForm(c db.AdminCountry) { + t.fields = []field{ + prefill("Name", c.Name), + newEnumField("Continent", continentOptions), + prefill("Product 1 ID", strconv.Itoa(int(c.P1))), + prefill("Product 2 ID", strconv.Itoa(int(c.P2))), + prefill("Product 3 ID", strconv.Itoa(int(c.P3))), + } + setEnumSel(&t.fields[cfContinent], c.Continent) + t.fields[cfName].input.Focus() + t.focusIdx = cfName + t.isEdit = true + t.editID = c.ID + t.inForm = true + t.status = "" +} + +func (t *countriesTab) submit() tea.Cmd { + name := strings.TrimSpace(t.fields[cfName].value()) + continent := t.fields[cfContinent].value() + p1s := strings.TrimSpace(t.fields[cfP1].value()) + p2s := strings.TrimSpace(t.fields[cfP2].value()) + p3s := strings.TrimSpace(t.fields[cfP3].value()) + + if name == "" { + t.status = "name is required" + t.isErr = true + return nil + } + p1, e1 := strconv.ParseUint(p1s, 10, 16) + p2, e2 := strconv.ParseUint(p2s, 10, 16) + p3, e3 := strconv.ParseUint(p3s, 10, 16) + if e1 != nil || e2 != nil || e3 != nil { + t.status = "product IDs must be valid numbers" + t.isErr = true + return nil + } + if t.isEdit { + id := t.editID + return func() tea.Msg { + return cntryDoneMsg{t.client.UpdateCountry(id, name, continent, uint16(p1), uint16(p2), uint16(p3))} + } + } + return func() tea.Msg { + return cntryDoneMsg{t.client.AddCountry(name, continent, uint16(p1), uint16(p2), uint16(p3))} + } +} + +func (t *countriesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + t.w, t.h = m.Width, m.Height + return t, nil + + case cntryLoadedMsg: + t.items = []db.AdminCountry(m) + if t.cursor >= len(t.items) && t.cursor > 0 { + t.cursor = len(t.items) - 1 + } + return t, nil + + case cntryDoneMsg: + if m.err != nil { + t.status = m.err.Error() + t.isErr = true + return t, nil + } + t.inForm = false + t.status = "saved" + t.isErr = false + return t, t.load() + + case tea.KeyMsg: + if t.inForm { + switch m.String() { + case "esc": + t.inForm = false + t.status = "" + case "ctrl+s": + return t, t.submit() + default: + var cmd tea.Cmd + t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m) + return t, cmd + } + return t, nil + } + switch m.String() { + case "q": + return t, tea.Quit + case "j", "down": + if t.cursor < len(t.items)-1 { + t.cursor++ + } + case "k", "up": + if t.cursor > 0 { + t.cursor-- + } + case "a": + t.openForm() + case "e": + if len(t.items) > 0 { + t.openEditForm(t.items[t.cursor]) + } + case "r": + return t, t.load() + } + } + return t, nil +} + +func (t *countriesTab) View() string { + var sb strings.Builder + sb.WriteString(hdrSt.Render(" Countries") + "\n\n") + + if t.inForm { + formTitle := "Add Country" + if t.isEdit { + formTitle = fmt.Sprintf("Edit Country #%d", t.editID) + } + sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n") + sb.WriteString(renderForm(t.fields, t.focusIdx)) + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpForm)) + return sb.String() + } + + header := fmt.Sprintf(" %-5s %-20s %-14s %s", "ID", "Name", "Continent", "Products (P1/P2/P3)") + sb.WriteString(dimSt.Render(header) + "\n") + sb.WriteString(dimSt.Render(" " + strings.Repeat("─", 60)) + "\n") + + if len(t.items) == 0 { + sb.WriteString(dimSt.Render(" (no countries)") + "\n") + } + for i, c := range t.items { + line := fmt.Sprintf(" %-5d %-20s %-14s %d / %d / %d", + c.ID, c.Name, c.Continent, c.P1, c.P2, c.P3) + if i == t.cursor { + sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n") + } else { + sb.WriteString(line + "\n") + } + } + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render("a:add e:edit j/k:nav r:reload q:quit")) + return sb.String() +} diff --git a/internal/adminui/factions.go b/internal/adminui/factions.go new file mode 100644 index 0000000..4bc48c1 --- /dev/null +++ b/internal/adminui/factions.go @@ -0,0 +1,232 @@ +package adminui + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "skyrama-tui/internal/db" +) + +// ── Messages ────────────────────────────────────────────────────────────────── + +type factionLoadedMsg []db.AdminFaction +type factionDoneMsg struct{ err error } + +// ── Field indices ───────────────────────────────────────────────────────────── + +const ( + ffName = 0 + ffContinent = 1 + ffLeader = 2 + ffBackground = 3 + ffUser = 4 + ffAirport = 5 +) + +// ── Model ───────────────────────────────────────────────────────────────────── + +type factionsTab struct { + client *db.AdminClient + items []db.AdminFaction + cursor int + inForm bool + isEdit bool + editID uint32 + fields []field + focusIdx int + status string + isErr bool + w, h int +} + +func newFactionsTab(client *db.AdminClient) *factionsTab { + return &factionsTab{client: client} +} + +func (t *factionsTab) InForm() bool { return t.inForm } +func (t *factionsTab) Init() tea.Cmd { return t.load() } + +func (t *factionsTab) load() tea.Cmd { + return func() tea.Msg { + items, err := t.client.ListFactions() + if err != nil { + return factionDoneMsg{err} + } + return factionLoadedMsg(items) + } +} + +func (t *factionsTab) openForm() { + t.fields = []field{ + newTextField("Name", "e.g. Monsoon Alliance"), + newEnumField("Continent", continentOptions), + newTextField("Leader", "e.g. Mei-Lin Zhao"), + newTextField("Background", "short bio"), + newTextField("Owner User ID", "NPC user id"), + newTextField("Central Airport ID", "central airport id"), + } + t.fields[ffName].input.Focus() + t.focusIdx = ffName + t.isEdit = false + t.inForm = true + t.status = "" +} + +func (t *factionsTab) openEditForm(f db.AdminFaction) { + t.fields = []field{ + prefill("Name", f.Name), + newEnumField("Continent", continentOptions), + prefill("Leader", f.Leader), + prefill("Background", f.Background), + prefill("Owner User ID", strconv.Itoa(int(f.UserID))), + prefill("Central Airport ID", strconv.Itoa(int(f.CentralAirportID))), + } + setEnumSel(&t.fields[ffContinent], f.Continent) + t.fields[ffName].input.Focus() + t.focusIdx = ffName + t.isEdit = true + t.editID = f.ID + t.inForm = true + t.status = "" +} + +func (t *factionsTab) submit() tea.Cmd { + name := strings.TrimSpace(t.fields[ffName].value()) + continent := t.fields[ffContinent].value() + leader := strings.TrimSpace(t.fields[ffLeader].value()) + background := strings.TrimSpace(t.fields[ffBackground].value()) + userStr := strings.TrimSpace(t.fields[ffUser].value()) + airportStr := strings.TrimSpace(t.fields[ffAirport].value()) + + if name == "" || leader == "" { + t.status = "name and leader are required" + t.isErr = true + return nil + } + userID, e1 := strconv.ParseInt(userStr, 10, 32) + airportID, e2 := strconv.ParseUint(airportStr, 10, 32) + if e1 != nil || e2 != nil { + t.status = "owner user ID and central airport ID must be valid numbers" + t.isErr = true + return nil + } + if t.isEdit { + id := t.editID + return func() tea.Msg { + return factionDoneMsg{t.client.UpdateFaction(id, name, continent, leader, background, int32(userID), uint32(airportID))} + } + } + return func() tea.Msg { + return factionDoneMsg{t.client.AddFaction(name, continent, leader, background, int32(userID), uint32(airportID))} + } +} + +func (t *factionsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + t.w, t.h = m.Width, m.Height + return t, nil + + case factionLoadedMsg: + t.items = []db.AdminFaction(m) + if t.cursor >= len(t.items) && t.cursor > 0 { + t.cursor = len(t.items) - 1 + } + return t, nil + + case factionDoneMsg: + if m.err != nil { + t.status = m.err.Error() + t.isErr = true + return t, nil + } + t.inForm = false + t.status = "saved" + t.isErr = false + return t, t.load() + + case tea.KeyMsg: + if t.inForm { + switch m.String() { + case "esc": + t.inForm = false + t.status = "" + case "ctrl+s": + return t, t.submit() + default: + var cmd tea.Cmd + t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m) + return t, cmd + } + return t, nil + } + switch m.String() { + case "q": + return t, tea.Quit + case "j", "down": + if t.cursor < len(t.items)-1 { + t.cursor++ + } + case "k", "up": + if t.cursor > 0 { + t.cursor-- + } + case "a": + t.openForm() + case "e": + if len(t.items) > 0 { + t.openEditForm(t.items[t.cursor]) + } + case "d": + if len(t.items) > 0 { + id := t.items[t.cursor].ID + return t, func() tea.Msg { + return factionDoneMsg{t.client.DeleteFaction(id)} + } + } + case "r": + return t, t.load() + } + } + return t, nil +} + +func (t *factionsTab) View() string { + var sb strings.Builder + sb.WriteString(hdrSt.Render(" Factions") + "\n\n") + + if t.inForm { + formTitle := "Add Faction" + if t.isEdit { + formTitle = fmt.Sprintf("Edit Faction #%d", t.editID) + } + sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n") + sb.WriteString(renderForm(t.fields, t.focusIdx)) + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpForm)) + return sb.String() + } + + header := fmt.Sprintf(" %-4s %-14s %-22s %-18s %-7s %s", + "ID", "Continent", "Faction", "Leader", "UserID", "AirportID") + sb.WriteString(dimSt.Render(header) + "\n") + sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 78)) + "\n") + + if len(t.items) == 0 { + sb.WriteString(dimSt.Render(" (no factions — run sql/seed_factions.sql)") + "\n") + } + for i, f := range t.items { + line := fmt.Sprintf(" %-4d %-14s %-22s %-18s %-7d %d", + f.ID, f.Continent, f.Name, f.Leader, f.UserID, f.CentralAirportID) + if i == t.cursor { + sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n") + } else { + sb.WriteString(line + "\n") + } + } + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render("a:add e:edit d:delete j/k:nav r:reload q:quit")) + return sb.String() +} diff --git a/internal/adminui/planes.go b/internal/adminui/planes.go new file mode 100644 index 0000000..2169baa --- /dev/null +++ b/internal/adminui/planes.go @@ -0,0 +1,334 @@ +package adminui + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "skyrama-tui/internal/db" +) + +// ── Messages ────────────────────────────────────────────────────────────────── + +type planeLoadedMsg []db.AdminPlane +type planeDoneMsg struct{ err error } + +// ── Field indices ───────────────────────────────────────────────────────────── + +const ( + pfName = 0 + pfSize = 1 + pfType = 2 + pfOpType = 3 + pfMinLevel = 4 + pfTravelSec = 5 + pfFuelCost = 6 + pfCashCost = 7 + pfMaxPax = 8 + pfBuyCost = 9 + pfXP = 10 + pfCashRew = 11 + pfCargoRew = 12 + pfBoardingTime = 13 + pfFuelingTime = 14 +) + +// ── Model ───────────────────────────────────────────────────────────────────── + +type planesTab struct { + client *db.AdminClient + items []db.AdminPlane + cursor int + inForm bool + isEdit bool + editID uint32 + fields []field + focusIdx int + status string + isErr bool + w, h int +} + +func newPlanesTab(client *db.AdminClient) *planesTab { + return &planesTab{client: client} +} + +func (t *planesTab) InForm() bool { return t.inForm } +func (t *planesTab) Init() tea.Cmd { return t.load() } + +func (t *planesTab) load() tea.Cmd { + return func() tea.Msg { + items, err := t.client.ListPlanes() + if err != nil { + return planeDoneMsg{err} + } + return planeLoadedMsg(items) + } +} + +func (t *planesTab) initFields() []field { + return []field{ + newTextField("Name", "e.g. Cessna 172"), + newEnumField("Aircraft Size", []string{"small", "medium", "large", "huge"}), + newEnumField("Aircraft Type", []string{"plane", "helicopter", "seaplane", "spaceship"}), + newEnumField("Operation Type", []string{"passenger", "cargo"}), + newTextField("Min Level", "1"), + newTextField("Travel Time (sec)", "300"), + newTextField("Fuel Cost", "50"), + newTextField("Cash Cost", "10"), + newTextField("Max Passengers (0=null)", "0"), + newTextField("Buy Cost (cash)", "1000"), + newTextField("XP Reward", "20"), + newTextField("Cash Reward (0=null)", "50"), + newTextField("Cargo Reward (0=null)", "0"), + newTextField("Boarding Time (sec, 0=skip)", "0"), + newTextField("Fueling Time (sec, 0=skip)", "0"), + } +} + +func (t *planesTab) openForm() { + t.fields = t.initFields() + t.fields[pfName].input.Focus() + t.focusIdx = pfName + t.isEdit = false + t.inForm = true + t.status = "" +} + +func (t *planesTab) openEditForm(p db.AdminPlane) { + t.fields = t.initFields() + t.fields[pfName].input.SetValue(p.Name) + setEnumSel(&t.fields[pfSize], p.Size) + setEnumSel(&t.fields[pfType], p.AircraftType) + setEnumSel(&t.fields[pfOpType], p.OperationType) + t.fields[pfMinLevel].input.SetValue(strconv.Itoa(int(p.MinLevel))) + t.fields[pfTravelSec].input.SetValue(strconv.Itoa(int(p.TravelSec))) + t.fields[pfFuelCost].input.SetValue(strconv.Itoa(int(p.FuelCost))) + t.fields[pfCashCost].input.SetValue(strconv.Itoa(int(p.CashCost))) + t.fields[pfMaxPax].input.SetValue(strconv.Itoa(p.MaxPassengers)) + t.fields[pfBuyCost].input.SetValue(strconv.Itoa(int(p.BuyCost))) + t.fields[pfXP].input.SetValue(strconv.Itoa(int(p.XP))) + t.fields[pfCashRew].input.SetValue(strconv.Itoa(p.CashReward)) + t.fields[pfCargoRew].input.SetValue(strconv.Itoa(p.CargoReward)) + t.fields[pfBoardingTime].input.SetValue(strconv.Itoa(int(p.BoardingTimeSec))) + t.fields[pfFuelingTime].input.SetValue(strconv.Itoa(int(p.FuelingTimeSec))) + t.fields[pfName].input.Focus() + t.focusIdx = pfName + t.isEdit = true + t.editID = p.ID + t.inForm = true + t.status = "" +} + +func mustInt(s string) (int, error) { + return strconv.Atoi(strings.TrimSpace(s)) +} + +func (t *planesTab) submit() tea.Cmd { + name := strings.TrimSpace(t.fields[pfName].value()) + if name == "" { + t.status = "name is required" + t.isErr = true + return nil + } + + lvl, err := mustInt(t.fields[pfMinLevel].value()) + if err != nil { + t.status = "min level must be a number" + t.isErr = true + return nil + } + travelSec, err := mustInt(t.fields[pfTravelSec].value()) + if err != nil || travelSec <= 0 { + t.status = "travel time must be a positive number" + t.isErr = true + return nil + } + fuelCost, err := mustInt(t.fields[pfFuelCost].value()) + if err != nil { + t.status = "fuel cost must be a number" + t.isErr = true + return nil + } + cashCost, err := mustInt(t.fields[pfCashCost].value()) + if err != nil { + t.status = "cash cost must be a number" + t.isErr = true + return nil + } + maxPax, err := mustInt(t.fields[pfMaxPax].value()) + if err != nil { + t.status = "max passengers must be a number (0 for none)" + t.isErr = true + return nil + } + buyCost, err := mustInt(t.fields[pfBuyCost].value()) + if err != nil { + t.status = "buy cost must be a number" + t.isErr = true + return nil + } + xp, err := mustInt(t.fields[pfXP].value()) + if err != nil { + t.status = "XP reward must be a number" + t.isErr = true + return nil + } + cashRew, err := mustInt(t.fields[pfCashRew].value()) + if err != nil { + t.status = "cash reward must be a number (0 for none)" + t.isErr = true + return nil + } + cargoRew, err := mustInt(t.fields[pfCargoRew].value()) + if err != nil { + t.status = "cargo reward must be a number (0 for none)" + t.isErr = true + return nil + } + boardingTime, err := mustInt(t.fields[pfBoardingTime].value()) + if err != nil { + t.status = "boarding time must be a number" + t.isErr = true + return nil + } + fuelingTime, err := mustInt(t.fields[pfFuelingTime].value()) + if err != nil { + t.status = "fueling time must be a number" + t.isErr = true + return nil + } + + p := db.AddPlaneParams{ + Name: name, + Size: t.fields[pfSize].value(), + AircraftType: t.fields[pfType].value(), + OperationType: t.fields[pfOpType].value(), + MinLevel: uint32(lvl), + FuelCost: uint32(fuelCost), + CashCost: uint32(cashCost), + MaxPassengers: maxPax, + TravelSec: uint32(travelSec), + BuyCost: uint32(buyCost), + XP: uint32(xp), + CashReward: cashRew, + CargoReward: cargoRew, + BoardingTimeSec: boardingTime, + FuelingTimeSec: fuelingTime, + } + if t.isEdit { + id := t.editID + return func() tea.Msg { return planeDoneMsg{t.client.UpdatePlane(id, p)} } + } + return func() tea.Msg { return planeDoneMsg{t.client.AddPlane(p)} } +} + +func (t *planesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + t.w, t.h = m.Width, m.Height + return t, nil + + case planeLoadedMsg: + t.items = []db.AdminPlane(m) + if t.cursor >= len(t.items) && t.cursor > 0 { + t.cursor = len(t.items) - 1 + } + return t, nil + + case planeDoneMsg: + if m.err != nil { + t.status = m.err.Error() + t.isErr = true + return t, nil + } + t.inForm = false + t.status = "saved" + t.isErr = false + return t, t.load() + + case tea.KeyMsg: + if t.inForm { + switch m.String() { + case "esc": + t.inForm = false + t.status = "" + case "ctrl+s": + return t, t.submit() + default: + var cmd tea.Cmd + t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m) + return t, cmd + } + return t, nil + } + switch m.String() { + case "q": + return t, tea.Quit + case "j", "down": + if t.cursor < len(t.items)-1 { + t.cursor++ + } + case "k", "up": + if t.cursor > 0 { + t.cursor-- + } + case "a": + t.openForm() + case "e": + if len(t.items) > 0 { + t.openEditForm(t.items[t.cursor]) + } + case "d": + if len(t.items) > 0 { + id := t.items[t.cursor].ID + return t, func() tea.Msg { + return planeDoneMsg{t.client.DeletePlane(id)} + } + } + case "r": + return t, t.load() + } + } + return t, nil +} + +func (t *planesTab) View() string { + var sb strings.Builder + sb.WriteString(hdrSt.Render(" Planes") + "\n\n") + + if t.inForm { + formTitle := "Add Plane" + if t.isEdit { + formTitle = fmt.Sprintf("Edit Plane #%d", t.editID) + } + sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n") + sb.WriteString(renderForm(t.fields, t.focusIdx)) + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpForm)) + return sb.String() + } + + header := fmt.Sprintf(" %-5s %-20s %-8s %-12s %-10s %-5s %-8s %s", + "ID", "Name", "Size", "Type", "Op", "Lvl", "Travel", "Buy$") + sb.WriteString(dimSt.Render(header) + "\n") + sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 80)) + "\n") + + if len(t.items) == 0 { + sb.WriteString(dimSt.Render(" (no planes — press a to add)") + "\n") + } + for i, p := range t.items { + travelFmt := fmt.Sprintf("%ds", p.TravelSec) + line := fmt.Sprintf(" %-5d %-20s %-8s %-12s %-10s %-5d %-8s %d", + p.ID, p.Name, p.Size, p.AircraftType, p.OperationType, p.MinLevel, travelFmt, p.BuyCost) + if i == t.cursor { + sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n") + } else { + sb.WriteString(line + "\n") + } + } + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpList)) + return sb.String() +} diff --git a/internal/adminui/products.go b/internal/adminui/products.go new file mode 100644 index 0000000..39f5213 --- /dev/null +++ b/internal/adminui/products.go @@ -0,0 +1,187 @@ +package adminui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "skyrama-tui/internal/db" +) + +// ── Messages ────────────────────────────────────────────────────────────────── + +type prodLoadedMsg []db.AdminProduct +type prodDoneMsg struct{ err error } + +// ── Model ───────────────────────────────────────────────────────────────────── + +type productsTab struct { + client *db.AdminClient + items []db.AdminProduct + cursor int + inForm bool + isEdit bool + editID uint16 + fields []field + focusIdx int + status string + isErr bool + w, h int +} + +func newProductsTab(client *db.AdminClient) *productsTab { + return &productsTab{client: client} +} + +func (t *productsTab) InForm() bool { return t.inForm } + +func (t *productsTab) Init() tea.Cmd { return t.load() } + +func (t *productsTab) load() tea.Cmd { + return func() tea.Msg { + items, err := t.client.ListProducts() + if err != nil { + return prodDoneMsg{err} + } + return prodLoadedMsg(items) + } +} + +func (t *productsTab) openForm() { + f := newTextField("Name", "e.g. Milk") + f.input.Focus() + t.fields = []field{f} + t.focusIdx = 0 + t.isEdit = false + t.inForm = true + t.status = "" +} + +func (t *productsTab) openEditForm(p db.AdminProduct) { + f := prefill("Name", p.Name) + f.input.Focus() + t.fields = []field{f} + t.focusIdx = 0 + t.isEdit = true + t.editID = p.ID + t.inForm = true + t.status = "" +} + +func (t *productsTab) submit() tea.Cmd { + name := strings.TrimSpace(t.fields[0].value()) + if name == "" { + t.status = "name is required" + t.isErr = true + return nil + } + if t.isEdit { + id := t.editID + return func() tea.Msg { return prodDoneMsg{t.client.UpdateProduct(id, name)} } + } + return func() tea.Msg { return prodDoneMsg{t.client.AddProduct(name)} } +} + +func (t *productsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + t.w, t.h = m.Width, m.Height + return t, nil + + case prodLoadedMsg: + t.items = []db.AdminProduct(m) + if t.cursor >= len(t.items) && t.cursor > 0 { + t.cursor = len(t.items) - 1 + } + return t, nil + + case prodDoneMsg: + if m.err != nil { + t.status = m.err.Error() + t.isErr = true + return t, nil + } + t.inForm = false + t.status = "saved" + t.isErr = false + return t, t.load() + + case tea.KeyMsg: + if t.inForm { + switch m.String() { + case "esc": + t.inForm = false + t.status = "" + case "ctrl+s", "enter": + return t, t.submit() + default: + var cmd tea.Cmd + t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m) + return t, cmd + } + return t, nil + } + + // List mode + switch m.String() { + case "q": + return t, tea.Quit + case "j", "down": + if t.cursor < len(t.items)-1 { + t.cursor++ + } + case "k", "up": + if t.cursor > 0 { + t.cursor-- + } + case "a": + t.openForm() + case "e": + if len(t.items) > 0 { + t.openEditForm(t.items[t.cursor]) + } + case "d": + if len(t.items) > 0 { + id := t.items[t.cursor].ID + return t, func() tea.Msg { + return prodDoneMsg{t.client.DeleteProduct(id)} + } + } + case "r": + return t, t.load() + } + } + return t, nil +} + +func (t *productsTab) View() string { + var sb strings.Builder + sb.WriteString(hdrSt.Render(" Products") + "\n\n") + + if t.inForm { + formTitle := "Add Product" + if t.isEdit { + formTitle = fmt.Sprintf("Edit Product #%d", t.editID) + } + sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n") + sb.WriteString(renderForm(t.fields, t.focusIdx)) + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpForm)) + return sb.String() + } + + if len(t.items) == 0 { + sb.WriteString(dimSt.Render(" (no products — press a to add)") + "\n") + } + for i, p := range t.items { + line := fmt.Sprintf(" #%-4d %s", p.ID, p.Name) + if i == t.cursor { + sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n") + } else { + sb.WriteString(line + "\n") + } + } + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpList)) + return sb.String() +} diff --git a/internal/adminui/shared.go b/internal/adminui/shared.go new file mode 100644 index 0000000..bea95e0 --- /dev/null +++ b/internal/adminui/shared.go @@ -0,0 +1,203 @@ +package adminui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Tab extends tea.Model with an InForm indicator used by the root app to +// suppress tab-switch shortcuts while a form is open. +type Tab interface { + tea.Model + InForm() bool +} + +var ( + titleSt = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00BFFF")) + activeTS = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#39D353")) + dimSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")) + errSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4444")) + okSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#39D353")) + cursorSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + labelSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#7EC8E3")) + hdrSt = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#E94560")) +) + +const ( + helpList = "a:add e:edit d:del j/k:nav r:reload q:quit" + helpForm = "tab/shift+tab:nav ←/→:cycle ctrl+s:save esc:cancel" +) + +// prefill creates a pre-filled text field (value shown, not just placeholder). +func prefill(label, value string) field { + f := newTextField(label, "") + f.input.SetValue(value) + return f +} + +// setEnumSel sets the sel index of an enum field to match value. +func setEnumSel(f *field, value string) { + for i, opt := range f.options { + if opt == value { + f.sel = i + return + } + } +} + +// field is a form entry: either a text input or an enum cycle selector. +type field struct { + label string + isEnum bool + input textinput.Model + options []string + sel int + hidden bool +} + +func newTextField(label, placeholder string) field { + ti := textinput.New() + ti.Placeholder = placeholder + ti.CharLimit = 256 + return field{label: label, input: ti} +} + +func newPasswordField(label string) field { + ti := textinput.New() + ti.EchoMode = textinput.EchoPassword + ti.Placeholder = "password" + ti.CharLimit = 128 + return field{label: label, input: ti} +} + +func newEnumField(label string, options []string) field { + return field{label: label, isEnum: true, options: options} +} + +func (f *field) value() string { + if f.isEnum { + return f.options[f.sel] + } + return f.input.Value() +} + +func (f *field) cycleNext() { + if f.isEnum { + f.sel = (f.sel + 1) % len(f.options) + } +} + +func (f *field) cyclePrev() { + if f.isEnum { + f.sel = (f.sel - 1 + len(f.options)) % len(f.options) + } +} + +// applyFocus focuses the field at idx and blurs all others. +func applyFocus(fields []field, idx int) { + for i := range fields { + if !fields[i].isEnum { + fields[i].input.Blur() + } + } + if idx >= 0 && idx < len(fields) && !fields[idx].isEnum { + fields[idx].input.Focus() + } +} + +// nextVisible returns the index of the next non-hidden field after from. +func nextVisible(fields []field, from int) int { + n := len(fields) + for i := 1; i < n; i++ { + idx := (from + i) % n + if !fields[idx].hidden { + return idx + } + } + return from +} + +// prevVisible returns the index of the previous non-hidden field before from. +func prevVisible(fields []field, from int) int { + n := len(fields) + for i := 1; i < n; i++ { + idx := (from - i + n) % n + if !fields[idx].hidden { + return idx + } + } + return from +} + +// handleFormKey routes a key message to the form: navigates fields or updates +// the focused one. Returns (newFocusIdx, cmd). +func handleFormKey(fields []field, focusIdx int, msg tea.KeyMsg) (int, tea.Cmd) { + switch msg.String() { + case "tab", "down": + next := nextVisible(fields, focusIdx) + applyFocus(fields, next) + return next, nil + case "shift+tab", "up": + prev := prevVisible(fields, focusIdx) + applyFocus(fields, prev) + return prev, nil + } + // Route to focused field + f := &fields[focusIdx] + if f.isEnum { + switch msg.String() { + case "left", "h": + f.cyclePrev() + case "right", "l", " ": + f.cycleNext() + } + return focusIdx, nil + } + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + return focusIdx, cmd +} + +// renderForm renders all non-hidden form fields. +func renderForm(fields []field, focusIdx int) string { + var sb strings.Builder + for i := range fields { + f := &fields[i] + if f.hidden { + continue + } + prefix := " " + if i == focusIdx { + prefix = cursorSt.Render("▸ ") + } + lbl := labelSt.Render(f.label + ":") + if f.isEnum { + var parts []string + for j, opt := range f.options { + if j == f.sel { + parts = append(parts, activeTS.Render("["+opt+"]")) + } else { + parts = append(parts, dimSt.Render(" "+opt+" ")) + } + } + sb.WriteString(prefix + lbl + " " + strings.Join(parts, "") + "\n") + } else { + sb.WriteString(prefix + lbl + " " + f.input.View() + "\n") + } + } + return sb.String() +} + +// renderStatus renders the status line (ok or error style). +func renderStatus(msg string, isErr bool) string { + if msg == "" { + return "" + } + if isErr { + return errSt.Render("✗ " + msg) + } + return okSt.Render("✓ " + msg) +} diff --git a/internal/adminui/users.go b/internal/adminui/users.go new file mode 100644 index 0000000..2170a48 --- /dev/null +++ b/internal/adminui/users.go @@ -0,0 +1,238 @@ +package adminui + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "skyrama-tui/internal/db" +) + +// ── Messages ────────────────────────────────────────────────────────────────── + +type userLoadedMsg []db.AdminUser +type userDoneMsg struct{ err error } + +// ── Field indices ───────────────────────────────────────────────────────────── + +const ( + ufUsername = 0 + ufPassword = 1 + ufAirportName = 2 + ufCountryID = 3 +) + +// ── Model ───────────────────────────────────────────────────────────────────── + +type usersTab struct { + client *db.AdminClient + items []db.AdminUser + cursor int + inForm bool + isEdit bool + editUsername string + fields []field + focusIdx int + status string + isErr bool + w, h int +} + +func newUsersTab(client *db.AdminClient) *usersTab { + return &usersTab{client: client} +} + +func (t *usersTab) InForm() bool { return t.inForm } +func (t *usersTab) Init() tea.Cmd { return t.load() } + +func (t *usersTab) load() tea.Cmd { + return func() tea.Msg { + items, err := t.client.ListUsers() + if err != nil { + return userDoneMsg{err} + } + return userLoadedMsg(items) + } +} + +func (t *usersTab) openForm() { + t.fields = []field{ + newTextField("Username", "e.g. alice"), + newPasswordField("Password"), + newTextField("Airport Name", "e.g. Alice's Airport"), + newTextField("Country ID", "1"), + } + t.fields[ufUsername].input.Focus() + t.focusIdx = ufUsername + t.isEdit = false + t.inForm = true + t.status = "" +} + +func (t *usersTab) openEditForm(u db.AdminUser) { + f := newPasswordField("New Password") + f.input.Focus() + t.fields = []field{f} + t.focusIdx = 0 + t.isEdit = true + t.editUsername = u.Username + t.inForm = true + t.status = "" +} + +func (t *usersTab) submit() tea.Cmd { + if t.isEdit { + password := t.fields[0].value() + if len(password) < 4 { + t.status = "password must be at least 4 characters" + t.isErr = true + return nil + } + username := t.editUsername + return func() tea.Msg { + return userDoneMsg{t.client.UpdateUserPassword(username, password)} + } + } + + username := strings.TrimSpace(t.fields[ufUsername].value()) + password := t.fields[ufPassword].value() + airportName := strings.TrimSpace(t.fields[ufAirportName].value()) + countryIDStr := strings.TrimSpace(t.fields[ufCountryID].value()) + + if username == "" { + t.status = "username is required" + t.isErr = true + return nil + } + if len(password) < 4 { + t.status = "password must be at least 4 characters" + t.isErr = true + return nil + } + if airportName == "" { + t.status = "airport name is required" + t.isErr = true + return nil + } + countryID, err := strconv.Atoi(countryIDStr) + if err != nil || countryID <= 0 { + t.status = "country ID must be a positive number" + t.isErr = true + return nil + } + return func() tea.Msg { + return userDoneMsg{t.client.CreateUserWithAirport(username, password, airportName, countryID)} + } +} + +func (t *usersTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case tea.WindowSizeMsg: + t.w, t.h = m.Width, m.Height + return t, nil + + case userLoadedMsg: + t.items = []db.AdminUser(m) + if t.cursor >= len(t.items) && t.cursor > 0 { + t.cursor = len(t.items) - 1 + } + return t, nil + + case userDoneMsg: + if m.err != nil { + t.status = m.err.Error() + t.isErr = true + return t, nil + } + t.inForm = false + t.status = "saved" + t.isErr = false + return t, t.load() + + case tea.KeyMsg: + if t.inForm { + switch m.String() { + case "esc": + t.inForm = false + t.status = "" + case "ctrl+s": + return t, t.submit() + default: + var cmd tea.Cmd + t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m) + return t, cmd + } + return t, nil + } + switch m.String() { + case "q": + return t, tea.Quit + case "j", "down": + if t.cursor < len(t.items)-1 { + t.cursor++ + } + case "k", "up": + if t.cursor > 0 { + t.cursor-- + } + case "a": + t.openForm() + case "e": + if len(t.items) > 0 { + t.openEditForm(t.items[t.cursor]) + } + case "d": + if len(t.items) > 0 { + username := t.items[t.cursor].Username + return t, func() tea.Msg { + return userDoneMsg{t.client.DeleteUser(username)} + } + } + case "r": + return t, t.load() + } + } + return t, nil +} + +func (t *usersTab) View() string { + var sb strings.Builder + sb.WriteString(hdrSt.Render(" Users") + "\n\n") + + if t.inForm { + formTitle := "Add User + Airport" + if t.isEdit { + formTitle = fmt.Sprintf("Change Password — %s", t.editUsername) + } + sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n") + sb.WriteString(renderForm(t.fields, t.focusIdx)) + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render(helpForm)) + return sb.String() + } + + header := fmt.Sprintf(" %-5s %-20s %-20s %s", "ID", "Username", "Created", "Status") + sb.WriteString(dimSt.Render(header) + "\n") + sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 60)) + "\n") + + if len(t.items) == 0 { + sb.WriteString(dimSt.Render(" (no users — press a to add)") + "\n") + } + for i, u := range t.items { + status := okSt.Render("active") + if u.Deleted { + status = errSt.Render("deleted") + } + created := u.CreatedAt.Format("2006-01-02") + line := fmt.Sprintf(" %-5d %-20s %-20s ", u.ID, u.Username, created) + if i == t.cursor { + sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + status + "\n") + } else { + sb.WriteString(line + status + "\n") + } + } + sb.WriteString("\n " + renderStatus(t.status, t.isErr)) + sb.WriteString("\n\n " + dimSt.Render("a:add e:edit(pw) d:soft-delete j/k:nav r:reload q:quit")) + return sb.String() +} diff --git a/internal/db/admin_client.go b/internal/db/admin_client.go new file mode 100644 index 0000000..74224fc --- /dev/null +++ b/internal/db/admin_client.go @@ -0,0 +1,548 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/go-sql-driver/mysql" + "golang.org/x/crypto/bcrypt" +) + +// AdminClient provides raw-SQL operations for the admin TUI. +type AdminClient struct { + db *sql.DB + ctx context.Context +} + +// NewAdminClient opens and pings a MySQL connection. +func NewAdminClient(dsn string) (*AdminClient, error) { + d, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + d.SetMaxOpenConns(5) + d.SetMaxIdleConns(3) + d.SetConnMaxLifetime(5 * time.Minute) + ctx := context.Background() + if err := d.PingContext(ctx); err != nil { + d.Close() + return nil, fmt.Errorf("ping db: %w", err) + } + return &AdminClient{db: d, ctx: ctx}, nil +} + +func (c *AdminClient) Close() { c.db.Close() } + +// ── Products ────────────────────────────────────────────────────────────────── + +type AdminProduct struct { + ID uint16 + Name string +} + +func (c *AdminClient) ListProducts() ([]AdminProduct, error) { + rows, err := c.db.QueryContext(c.ctx, `SELECT id, name FROM products ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminProduct + for rows.Next() { + var p AdminProduct + if err := rows.Scan(&p.ID, &p.Name); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (c *AdminClient) AddProduct(name string) error { + _, err := c.db.ExecContext(c.ctx, `INSERT INTO products (name) VALUES (?)`, name) + return err +} + +func (c *AdminClient) DeleteProduct(id uint16) error { + _, err := c.db.ExecContext(c.ctx, `DELETE FROM products WHERE id = ?`, id) + return err +} + +// ── Countries ───────────────────────────────────────────────────────────────── + +type AdminCountry struct { + ID uint16 + Name string + Continent string + P1, P2, P3 uint16 +} + +func (c *AdminClient) ListCountries() ([]AdminCountry, error) { + rows, err := c.db.QueryContext(c.ctx, + `SELECT id, name, continent, produce_1_id, produce_2_id, produce_3_id FROM countries ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminCountry + for rows.Next() { + var co AdminCountry + if err := rows.Scan(&co.ID, &co.Name, &co.Continent, &co.P1, &co.P2, &co.P3); err != nil { + return nil, err + } + out = append(out, co) + } + return out, rows.Err() +} + +func (c *AdminClient) AddCountry(name, continent string, p1, p2, p3 uint16) error { + _, err := c.db.ExecContext(c.ctx, + `INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) VALUES (?,?,?,?,?)`, + name, continent, p1, p2, p3) + return err +} + +// ── Factions ────────────────────────────────────────────────────────────────── + +type AdminFaction struct { + ID uint32 + Name string + Continent string + Leader string + Background string + UserID int32 + CentralAirportID uint32 +} + +func (c *AdminClient) ListFactions() ([]AdminFaction, error) { + rows, err := c.db.QueryContext(c.ctx, + `SELECT id, name, continent, leader_name, COALESCE(background,''), user_id, central_airport_id + FROM factions ORDER BY continent`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminFaction + for rows.Next() { + var f AdminFaction + if err := rows.Scan(&f.ID, &f.Name, &f.Continent, &f.Leader, &f.Background, + &f.UserID, &f.CentralAirportID); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +func (c *AdminClient) AddFaction(name, continent, leader, background string, userID int32, centralAirportID uint32) error { + _, err := c.db.ExecContext(c.ctx, + `INSERT INTO factions (name, continent, leader_name, background, user_id, central_airport_id) + VALUES (?,?,?,?,?,?)`, + name, continent, leader, background, userID, centralAirportID) + return err +} + +func (c *AdminClient) UpdateFaction(id uint32, name, continent, leader, background string, userID int32, centralAirportID uint32) error { + _, err := c.db.ExecContext(c.ctx, + `UPDATE factions SET name=?, continent=?, leader_name=?, background=?, user_id=?, central_airport_id=? + WHERE id=?`, + name, continent, leader, background, userID, centralAirportID, id) + return err +} + +func (c *AdminClient) DeleteFaction(id uint32) error { + _, err := c.db.ExecContext(c.ctx, `DELETE FROM factions WHERE id = ?`, id) + return err +} + +// ── Buildings ───────────────────────────────────────────────────────────────── + +type AdminBuilding struct { + ID uint32 + Name string + MinLevel uint32 + Cost uint32 + BuildingType string + Details string + // raw values used by the edit form + Capacity int + StorageType string + StorageSubtype string + OperationType string + OperationSize string + PassiveType string + PassiveRate int +} + +func (c *AdminClient) ListBuildings() ([]AdminBuilding, error) { + rows, err := c.db.QueryContext(c.ctx, ` + SELECT id, name, min_level, construction_cost_cash, building_type, + COALESCE(storage_type,''), COALESCE(operation_type,''), COALESCE(passive_type,''), + COALESCE(capacity,0), COALESCE(passive_rate,0), COALESCE(operation_size,''), + COALESCE(storage_subtype,'') + FROM buildings ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminBuilding + for rows.Next() { + var b AdminBuilding + if err := rows.Scan(&b.ID, &b.Name, &b.MinLevel, &b.Cost, &b.BuildingType, + &b.StorageType, &b.OperationType, &b.PassiveType, + &b.Capacity, &b.PassiveRate, &b.OperationSize, &b.StorageSubtype); err != nil { + return nil, err + } + switch b.BuildingType { + case "storage": + b.Details = fmt.Sprintf("cap:%d type:%s", b.Capacity, b.StorageType) + case "operation": + b.Details = fmt.Sprintf("%s/%s", b.OperationType, b.OperationSize) + case "passive": + b.Details = fmt.Sprintf("%s +%d/s", b.PassiveType, b.PassiveRate) + } + out = append(out, b) + } + return out, rows.Err() +} + +type AddBuildingParams struct { + Name string + MinLevel uint32 + Cost uint32 + BuildingType string + Capacity int + StorageType string + StorageSubtype string + OperationType string + OperationSize string + PassiveType string + PassiveRate int +} + +func (c *AdminClient) AddBuilding(p AddBuildingParams) error { + switch p.BuildingType { + case "storage": + _, err := c.db.ExecContext(c.ctx, + `INSERT INTO buildings (name,min_level,construction_cost_cash,building_type,capacity,storage_type,storage_subtype) + VALUES (?,?,?,'storage',?,?,?)`, + p.Name, p.MinLevel, p.Cost, p.Capacity, p.StorageType, p.StorageSubtype) + return err + case "operation": + _, err := c.db.ExecContext(c.ctx, + `INSERT INTO buildings (name,min_level,construction_cost_cash,building_type,operation_type,operation_size) + VALUES (?,?,?,'operation',?,?)`, + p.Name, p.MinLevel, p.Cost, p.OperationType, p.OperationSize) + return err + case "passive": + _, err := c.db.ExecContext(c.ctx, + `INSERT INTO buildings (name,min_level,construction_cost_cash,building_type,passive_type,passive_rate) + VALUES (?,?,?,'passive',?,?)`, + p.Name, p.MinLevel, p.Cost, p.PassiveType, p.PassiveRate) + return err + default: + _, err := c.db.ExecContext(c.ctx, + `INSERT INTO buildings (name,min_level,construction_cost_cash,building_type) VALUES (?,?,?,'none')`, + p.Name, p.MinLevel, p.Cost) + return err + } +} + +func (c *AdminClient) DeleteBuilding(id uint32) error { + _, err := c.db.ExecContext(c.ctx, `DELETE FROM buildings WHERE id = ?`, id) + return err +} + +// ── Planes ──────────────────────────────────────────────────────────────────── + +type AdminPlane struct { + ID uint32 + Name string + Size string + AircraftType string + OperationType string + MinLevel uint32 + TravelSec uint32 + FuelCost uint32 + BuyCost uint32 + // extra fields for edit form + CashCost uint32 + MaxPassengers int + XP uint32 + CashReward int + CargoReward int + BoardingTimeSec uint32 + FuelingTimeSec uint32 +} + +func (c *AdminClient) ListPlanes() ([]AdminPlane, error) { + rows, err := c.db.QueryContext(c.ctx, ` + SELECT id, name, aircraft_size, aircraft_type, operation_type, min_level, + travel_time_inSeconds, operation_cost_fuel, buying_cost_cash, + operation_cost_cash, COALESCE(max_passengers,0), + completed_flight_experience, + COALESCE(completed_flight_cash_reward,0), + COALESCE(completed_flight_cargo_reward,0), + boarding_time_seconds, fueling_time_seconds + FROM planes ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminPlane + for rows.Next() { + var p AdminPlane + if err := rows.Scan(&p.ID, &p.Name, &p.Size, &p.AircraftType, &p.OperationType, + &p.MinLevel, &p.TravelSec, &p.FuelCost, &p.BuyCost, + &p.CashCost, &p.MaxPassengers, &p.XP, &p.CashReward, &p.CargoReward, + &p.BoardingTimeSec, &p.FuelingTimeSec); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +type AddPlaneParams struct { + Name string + Size string + AircraftType string + OperationType string + MinLevel uint32 + FuelCost uint32 + CashCost uint32 + MaxPassengers int + TravelSec uint32 + BuyCost uint32 + XP uint32 + CashReward int + CargoReward int + BoardingTimeSec int + FuelingTimeSec int +} + +func (c *AdminClient) AddPlane(p AddPlaneParams) error { + var maxPax, cashRew, cargoRew interface{} + if p.MaxPassengers > 0 { + maxPax = p.MaxPassengers + } + if p.CashReward > 0 { + cashRew = p.CashReward + } + if p.CargoReward > 0 { + cargoRew = p.CargoReward + } + _, err := c.db.ExecContext(c.ctx, ` + INSERT INTO planes + (name,aircraft_size,operation_type,aircraft_type,min_level,operation_cost_fuel, + operation_cost_cash,max_passengers,travel_time_inSeconds,buying_cost_cash, + completed_flight_experience,completed_flight_cash_reward,completed_flight_cargo_reward, + boarding_time_seconds,fueling_time_seconds) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + p.Name, p.Size, p.OperationType, p.AircraftType, p.MinLevel, + p.FuelCost, p.CashCost, maxPax, p.TravelSec, p.BuyCost, p.XP, cashRew, cargoRew, + p.BoardingTimeSec, p.FuelingTimeSec) + return err +} + +func (c *AdminClient) DeletePlane(id uint32) error { + _, err := c.db.ExecContext(c.ctx, `DELETE FROM planes WHERE id = ?`, id) + return err +} + +// ── Users ───────────────────────────────────────────────────────────────────── + +type AdminUser struct { + ID int32 + Username string + CreatedAt time.Time + Deleted bool +} + +func (c *AdminClient) ListUsers() ([]AdminUser, error) { + rows, err := c.db.QueryContext(c.ctx, + `SELECT id, username, created_at, (deleted_at IS NOT NULL) FROM users WHERE is_npc = FALSE ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminUser + for rows.Next() { + var u AdminUser + if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt, &u.Deleted); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (c *AdminClient) CreateUserWithAirport(username, password, airportName string, countryID int) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + tx, err := c.db.BeginTx(c.ctx, nil) + if err != nil { + return err + } + res, err := tx.ExecContext(c.ctx, + `INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, string(hash)) + if err != nil { + tx.Rollback() + return err + } + userID, _ := res.LastInsertId() + _, err = tx.ExecContext(c.ctx, + `INSERT INTO airports (name, user_id, origin_country_id, cash) VALUES (?, ?, ?, 10000)`, + airportName, userID, countryID) + if err != nil { + tx.Rollback() + return err + } + return tx.Commit() +} + +func (c *AdminClient) DeleteUser(username string) error { + _, err := c.db.ExecContext(c.ctx, + `UPDATE users SET deleted_at = CURRENT_TIMESTAMP WHERE username = ?`, username) + return err +} + +// ── Airports ────────────────────────────────────────────────────────────────── + +type AdminAirport struct { + ID uint32 + Name string + Username string + Cash uint64 + Fuel uint32 + Level uint32 + MaxFuel uint32 + MaxPax uint32 +} + +func (c *AdminClient) ListAirports() ([]AdminAirport, error) { + rows, err := c.db.QueryContext(c.ctx, ` + SELECT a.id, a.name, u.username, a.cash, a.current_fuel, a.player_level, + a.max_fuel_capacity, a.max_passenger_capacity + FROM airports a JOIN users u ON a.user_id = u.id + WHERE a.is_deleted = FALSE ORDER BY a.id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminAirport + for rows.Next() { + var a AdminAirport + if err := rows.Scan(&a.ID, &a.Name, &a.Username, &a.Cash, &a.Fuel, &a.Level, + &a.MaxFuel, &a.MaxPax); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (c *AdminClient) GiveAirportCash(airportID uint32, amount uint64) error { + _, err := c.db.ExecContext(c.ctx, + `UPDATE airports SET cash = cash + ? WHERE id = ?`, amount, airportID) + return err +} + +func (c *AdminClient) GiveAirportBuilding(airportID, buildingID uint32) error { + _, err := c.db.ExecContext(c.ctx, + `INSERT INTO airport_buildings (airport_id, building_id) VALUES (?, ?)`, airportID, buildingID) + return err +} + +// ── Update methods ──────────────────────────────────────────────────────────── + +func (c *AdminClient) UpdateProduct(id uint16, name string) error { + _, err := c.db.ExecContext(c.ctx, `UPDATE products SET name=? WHERE id=?`, name, id) + return err +} + +func (c *AdminClient) UpdateCountry(id uint16, name, continent string, p1, p2, p3 uint16) error { + _, err := c.db.ExecContext(c.ctx, + `UPDATE countries SET name=?, continent=?, produce_1_id=?, produce_2_id=?, produce_3_id=? WHERE id=?`, + name, continent, p1, p2, p3, id) + return err +} + +func (c *AdminClient) UpdateBuilding(id uint32, p AddBuildingParams) error { + var cap, passRate interface{} + var storType, storSubtype, opType, opSize, passType interface{} + if p.BuildingType == "storage" { + cap = p.Capacity + storType = p.StorageType + storSubtype = p.StorageSubtype + } + if p.BuildingType == "operation" { + opType = p.OperationType + opSize = p.OperationSize + } + if p.BuildingType == "passive" { + passType = p.PassiveType + passRate = p.PassiveRate + } + _, err := c.db.ExecContext(c.ctx, ` + UPDATE buildings SET + name=?, min_level=?, construction_cost_cash=?, building_type=?, + capacity=?, storage_type=?, storage_subtype=?, + operation_type=?, operation_size=?, + passive_type=?, passive_rate=? + WHERE id=?`, + p.Name, p.MinLevel, p.Cost, p.BuildingType, + cap, storType, storSubtype, + opType, opSize, + passType, passRate, + id) + return err +} + +func (c *AdminClient) UpdatePlane(id uint32, p AddPlaneParams) error { + var maxPax, cashRew, cargoRew interface{} + if p.MaxPassengers > 0 { + maxPax = p.MaxPassengers + } + if p.CashReward > 0 { + cashRew = p.CashReward + } + if p.CargoReward > 0 { + cargoRew = p.CargoReward + } + _, err := c.db.ExecContext(c.ctx, ` + UPDATE planes SET + name=?, aircraft_size=?, operation_type=?, aircraft_type=?, min_level=?, + operation_cost_fuel=?, operation_cost_cash=?, max_passengers=?, + travel_time_inSeconds=?, buying_cost_cash=?, + completed_flight_experience=?, completed_flight_cash_reward=?, + completed_flight_cargo_reward=?, + boarding_time_seconds=?, fueling_time_seconds=? + WHERE id=?`, + p.Name, p.Size, p.OperationType, p.AircraftType, p.MinLevel, + p.FuelCost, p.CashCost, maxPax, p.TravelSec, p.BuyCost, + p.XP, cashRew, cargoRew, + p.BoardingTimeSec, p.FuelingTimeSec, id) + return err +} + +func (c *AdminClient) UpdateUserPassword(username, newPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = c.db.ExecContext(c.ctx, + `UPDATE users SET password_hash=? WHERE username=?`, string(hash), username) + return err +} + +func (c *AdminClient) UpdateAirport(id uint32, cash uint64, level, maxFuel, maxPax uint32) error { + _, err := c.db.ExecContext(c.ctx, ` + UPDATE airports SET cash=?, player_level=?, max_fuel_capacity=?, max_passenger_capacity=? + WHERE id=?`, + cash, level, maxFuel, maxPax, id) + return err +} diff --git a/internal/db/client.go b/internal/db/client.go new file mode 100644 index 0000000..735bcb1 --- /dev/null +++ b/internal/db/client.go @@ -0,0 +1,1384 @@ +// 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), + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..4e232ce --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,704 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "database/sql" + "database/sql/driver" + "fmt" + "time" +) + +type BuildingsBuildingType string + +const ( + BuildingsBuildingTypeNone BuildingsBuildingType = "none" + BuildingsBuildingTypeStorage BuildingsBuildingType = "storage" + BuildingsBuildingTypeSupport BuildingsBuildingType = "support" + BuildingsBuildingTypeOperation BuildingsBuildingType = "operation" + BuildingsBuildingTypePassive BuildingsBuildingType = "passive" + BuildingsBuildingTypeShop BuildingsBuildingType = "shop" + BuildingsBuildingTypeProduction BuildingsBuildingType = "production" +) + +func (e *BuildingsBuildingType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = BuildingsBuildingType(s) + case string: + *e = BuildingsBuildingType(s) + default: + return fmt.Errorf("unsupported scan type for BuildingsBuildingType: %T", src) + } + return nil +} + +type NullBuildingsBuildingType struct { + BuildingsBuildingType BuildingsBuildingType `json:"buildings_building_type"` + Valid bool `json:"valid"` // Valid is true if BuildingsBuildingType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullBuildingsBuildingType) Scan(value interface{}) error { + if value == nil { + ns.BuildingsBuildingType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.BuildingsBuildingType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullBuildingsBuildingType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.BuildingsBuildingType), nil +} + +type BuildingsOperationSize string + +const ( + BuildingsOperationSizeSmall BuildingsOperationSize = "small" + BuildingsOperationSizeMedium BuildingsOperationSize = "medium" + BuildingsOperationSizeLarge BuildingsOperationSize = "large" + BuildingsOperationSizeHuge BuildingsOperationSize = "huge" +) + +func (e *BuildingsOperationSize) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = BuildingsOperationSize(s) + case string: + *e = BuildingsOperationSize(s) + default: + return fmt.Errorf("unsupported scan type for BuildingsOperationSize: %T", src) + } + return nil +} + +type NullBuildingsOperationSize struct { + BuildingsOperationSize BuildingsOperationSize `json:"buildings_operation_size"` + Valid bool `json:"valid"` // Valid is true if BuildingsOperationSize is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullBuildingsOperationSize) Scan(value interface{}) error { + if value == nil { + ns.BuildingsOperationSize, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.BuildingsOperationSize.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullBuildingsOperationSize) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.BuildingsOperationSize), nil +} + +type BuildingsOperationType string + +const ( + BuildingsOperationTypeRunway BuildingsOperationType = "runway" + BuildingsOperationTypeService BuildingsOperationType = "service" + BuildingsOperationTypeProduction BuildingsOperationType = "production" +) + +func (e *BuildingsOperationType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = BuildingsOperationType(s) + case string: + *e = BuildingsOperationType(s) + default: + return fmt.Errorf("unsupported scan type for BuildingsOperationType: %T", src) + } + return nil +} + +type NullBuildingsOperationType struct { + BuildingsOperationType BuildingsOperationType `json:"buildings_operation_type"` + Valid bool `json:"valid"` // Valid is true if BuildingsOperationType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullBuildingsOperationType) Scan(value interface{}) error { + if value == nil { + ns.BuildingsOperationType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.BuildingsOperationType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullBuildingsOperationType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.BuildingsOperationType), nil +} + +type BuildingsPassiveType string + +const ( + BuildingsPassiveTypePassengers BuildingsPassiveType = "passengers" + BuildingsPassiveTypeFuel BuildingsPassiveType = "fuel" +) + +func (e *BuildingsPassiveType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = BuildingsPassiveType(s) + case string: + *e = BuildingsPassiveType(s) + default: + return fmt.Errorf("unsupported scan type for BuildingsPassiveType: %T", src) + } + return nil +} + +type NullBuildingsPassiveType struct { + BuildingsPassiveType BuildingsPassiveType `json:"buildings_passive_type"` + Valid bool `json:"valid"` // Valid is true if BuildingsPassiveType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullBuildingsPassiveType) Scan(value interface{}) error { + if value == nil { + ns.BuildingsPassiveType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.BuildingsPassiveType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullBuildingsPassiveType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.BuildingsPassiveType), nil +} + +type BuildingsStorageSubtype string + +const ( + BuildingsStorageSubtypeNone BuildingsStorageSubtype = "none" + BuildingsStorageSubtypeAirplane BuildingsStorageSubtype = "airplane" + BuildingsStorageSubtypeHelicopter BuildingsStorageSubtype = "helicopter" + BuildingsStorageSubtypeSeaplane BuildingsStorageSubtype = "seaplane" + BuildingsStorageSubtypeSpaceship BuildingsStorageSubtype = "spaceship" +) + +func (e *BuildingsStorageSubtype) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = BuildingsStorageSubtype(s) + case string: + *e = BuildingsStorageSubtype(s) + default: + return fmt.Errorf("unsupported scan type for BuildingsStorageSubtype: %T", src) + } + return nil +} + +type NullBuildingsStorageSubtype struct { + BuildingsStorageSubtype BuildingsStorageSubtype `json:"buildings_storage_subtype"` + Valid bool `json:"valid"` // Valid is true if BuildingsStorageSubtype is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullBuildingsStorageSubtype) Scan(value interface{}) error { + if value == nil { + ns.BuildingsStorageSubtype, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.BuildingsStorageSubtype.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullBuildingsStorageSubtype) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.BuildingsStorageSubtype), nil +} + +type BuildingsStorageType string + +const ( + BuildingsStorageTypeFuel BuildingsStorageType = "fuel" + BuildingsStorageTypePlane BuildingsStorageType = "plane" + BuildingsStorageTypeProduct BuildingsStorageType = "product" +) + +func (e *BuildingsStorageType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = BuildingsStorageType(s) + case string: + *e = BuildingsStorageType(s) + default: + return fmt.Errorf("unsupported scan type for BuildingsStorageType: %T", src) + } + return nil +} + +type NullBuildingsStorageType struct { + BuildingsStorageType BuildingsStorageType `json:"buildings_storage_type"` + Valid bool `json:"valid"` // Valid is true if BuildingsStorageType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullBuildingsStorageType) Scan(value interface{}) error { + if value == nil { + ns.BuildingsStorageType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.BuildingsStorageType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullBuildingsStorageType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.BuildingsStorageType), nil +} + +type CountriesContinent string + +const ( + CountriesContinentVoid CountriesContinent = "void" + CountriesContinentAfrica CountriesContinent = "Africa" + CountriesContinentAntarctica CountriesContinent = "Antarctica" + CountriesContinentAsia CountriesContinent = "Asia" + CountriesContinentEurope CountriesContinent = "Europe" + CountriesContinentNorthAmerica CountriesContinent = "North America" + CountriesContinentOceania CountriesContinent = "Oceania" + CountriesContinentSouthAmerica CountriesContinent = "South America" +) + +func (e *CountriesContinent) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = CountriesContinent(s) + case string: + *e = CountriesContinent(s) + default: + return fmt.Errorf("unsupported scan type for CountriesContinent: %T", src) + } + return nil +} + +type NullCountriesContinent struct { + CountriesContinent CountriesContinent `json:"countries_continent"` + Valid bool `json:"valid"` // Valid is true if CountriesContinent is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullCountriesContinent) Scan(value interface{}) error { + if value == nil { + ns.CountriesContinent, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.CountriesContinent.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullCountriesContinent) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.CountriesContinent), nil +} + +type CurrencyTransactionLogsCurrencyType string + +const ( + CurrencyTransactionLogsCurrencyTypeCash CurrencyTransactionLogsCurrencyType = "cash" + CurrencyTransactionLogsCurrencyTypeFuel CurrencyTransactionLogsCurrencyType = "fuel" + CurrencyTransactionLogsCurrencyTypePassengers CurrencyTransactionLogsCurrencyType = "passengers" +) + +func (e *CurrencyTransactionLogsCurrencyType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = CurrencyTransactionLogsCurrencyType(s) + case string: + *e = CurrencyTransactionLogsCurrencyType(s) + default: + return fmt.Errorf("unsupported scan type for CurrencyTransactionLogsCurrencyType: %T", src) + } + return nil +} + +type NullCurrencyTransactionLogsCurrencyType struct { + CurrencyTransactionLogsCurrencyType CurrencyTransactionLogsCurrencyType `json:"currency_transaction_logs_currency_type"` + Valid bool `json:"valid"` // Valid is true if CurrencyTransactionLogsCurrencyType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullCurrencyTransactionLogsCurrencyType) Scan(value interface{}) error { + if value == nil { + ns.CurrencyTransactionLogsCurrencyType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.CurrencyTransactionLogsCurrencyType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullCurrencyTransactionLogsCurrencyType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.CurrencyTransactionLogsCurrencyType), nil +} + +type PlanesAircraftSize string + +const ( + PlanesAircraftSizeSmall PlanesAircraftSize = "small" + PlanesAircraftSizeMedium PlanesAircraftSize = "medium" + PlanesAircraftSizeLarge PlanesAircraftSize = "large" + PlanesAircraftSizeHuge PlanesAircraftSize = "huge" +) + +func (e *PlanesAircraftSize) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PlanesAircraftSize(s) + case string: + *e = PlanesAircraftSize(s) + default: + return fmt.Errorf("unsupported scan type for PlanesAircraftSize: %T", src) + } + return nil +} + +type NullPlanesAircraftSize struct { + PlanesAircraftSize PlanesAircraftSize `json:"planes_aircraft_size"` + Valid bool `json:"valid"` // Valid is true if PlanesAircraftSize is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPlanesAircraftSize) Scan(value interface{}) error { + if value == nil { + ns.PlanesAircraftSize, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PlanesAircraftSize.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPlanesAircraftSize) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PlanesAircraftSize), nil +} + +type PlanesAircraftType string + +const ( + PlanesAircraftTypePlane PlanesAircraftType = "plane" + PlanesAircraftTypeHelicopter PlanesAircraftType = "helicopter" + PlanesAircraftTypeSeaplane PlanesAircraftType = "seaplane" + PlanesAircraftTypeSpaceship PlanesAircraftType = "spaceship" +) + +func (e *PlanesAircraftType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PlanesAircraftType(s) + case string: + *e = PlanesAircraftType(s) + default: + return fmt.Errorf("unsupported scan type for PlanesAircraftType: %T", src) + } + return nil +} + +type NullPlanesAircraftType struct { + PlanesAircraftType PlanesAircraftType `json:"planes_aircraft_type"` + Valid bool `json:"valid"` // Valid is true if PlanesAircraftType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPlanesAircraftType) Scan(value interface{}) error { + if value == nil { + ns.PlanesAircraftType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PlanesAircraftType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPlanesAircraftType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PlanesAircraftType), nil +} + +type PlanesOperationType string + +const ( + PlanesOperationTypePassenger PlanesOperationType = "passenger" + PlanesOperationTypeCargo PlanesOperationType = "cargo" +) + +func (e *PlanesOperationType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PlanesOperationType(s) + case string: + *e = PlanesOperationType(s) + default: + return fmt.Errorf("unsupported scan type for PlanesOperationType: %T", src) + } + return nil +} + +type NullPlanesOperationType struct { + PlanesOperationType PlanesOperationType `json:"planes_operation_type"` + Valid bool `json:"valid"` // Valid is true if PlanesOperationType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPlanesOperationType) Scan(value interface{}) error { + if value == nil { + ns.PlanesOperationType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PlanesOperationType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPlanesOperationType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PlanesOperationType), nil +} + +type PlayerPlanesStatus string + +const ( + PlayerPlanesStatusIdle PlayerPlanesStatus = "idle" + PlayerPlanesStatusMaintenance PlayerPlanesStatus = "maintenance" + PlayerPlanesStatusBoarding PlayerPlanesStatus = "boarding" + PlayerPlanesStatusTaxiing PlayerPlanesStatus = "taxiing" + PlayerPlanesStatusFlying PlayerPlanesStatus = "flying" +) + +func (e *PlayerPlanesStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PlayerPlanesStatus(s) + case string: + *e = PlayerPlanesStatus(s) + default: + return fmt.Errorf("unsupported scan type for PlayerPlanesStatus: %T", src) + } + return nil +} + +type NullPlayerPlanesStatus struct { + PlayerPlanesStatus PlayerPlanesStatus `json:"player_planes_status"` + Valid bool `json:"valid"` // Valid is true if PlayerPlanesStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPlayerPlanesStatus) Scan(value interface{}) error { + if value == nil { + ns.PlayerPlanesStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PlayerPlanesStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPlayerPlanesStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PlayerPlanesStatus), nil +} + +type Airport struct { + ID uint32 `json:"id"` + Name string `json:"name"` + UserID int32 `json:"user_id"` + OriginCountryID uint16 `json:"origin_country_id"` + Cash uint64 `json:"cash"` + CurrentPassengers uint32 `json:"current_passengers"` + MaxPassengerCapacity uint32 `json:"max_passenger_capacity"` + CurrentFuel uint32 `json:"current_fuel"` + MaxFuelCapacity uint32 `json:"max_fuel_capacity"` + FuelGenerationRate uint32 `json:"fuel_generation_rate"` + PassengerGenerationRate uint32 `json:"passenger_generation_rate"` + ResourcesLastUpdatedAt time.Time `json:"resources_last_updated_at"` + IsAvailable bool `json:"is_available"` + PlayerLevel uint32 `json:"player_level"` + ExperiencePoints uint64 `json:"experience_points"` + IsDeleted sql.NullBool `json:"is_deleted"` +} + +type AirportBuilding struct { + ID uint32 `json:"id"` + AirportID uint32 `json:"airport_id"` + BuildingID uint32 `json:"building_id"` + RecipeID sql.NullInt32 `json:"recipe_id"` + StorageProduct sql.NullInt32 `json:"storage_product"` + Storage1 sql.NullInt32 `json:"storage_1"` + Storage2 sql.NullInt32 `json:"storage_2"` + Storage3 sql.NullInt32 `json:"storage_3"` + Level uint32 `json:"level"` +} + +type AirportContract struct { + ID uint32 `json:"id"` + AirportID uint32 `json:"airport_id"` + Title string `json:"title"` + RequiredProductID uint16 `json:"required_product_id"` + RequiredAmount uint32 `json:"required_amount"` + CurrentAmountDelivered uint32 `json:"current_amount_delivered"` + IsCompleted bool `json:"is_completed"` + ExpiresAt time.Time `json:"expires_at"` +} + +type AirportInventory struct { + AirportID uint32 `json:"airport_id"` + ProductID uint16 `json:"product_id"` + Quantity uint32 `json:"quantity"` +} + +type Building struct { + ID uint32 `json:"id"` + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + ConstructionCostCash uint32 `json:"construction_cost_cash"` + UpgradeTier sql.NullInt16 `json:"upgrade_tier"` + UpgradeCostCashToUptier sql.NullInt32 `json:"upgrade_cost_cash_to_uptier"` + BuildingType BuildingsBuildingType `json:"building_type"` + Capacity sql.NullInt32 `json:"capacity"` + StorageType NullBuildingsStorageType `json:"storage_type"` + StorageSubtype NullBuildingsStorageSubtype `json:"storage_subtype"` + EffectID sql.NullInt16 `json:"effect_id"` + EffectValue sql.NullInt32 `json:"effect_value"` + OperationSize NullBuildingsOperationSize `json:"operation_size"` + OperationType NullBuildingsOperationType `json:"operation_type"` + PassiveType NullBuildingsPassiveType `json:"passive_type"` + PassiveRate sql.NullInt32 `json:"passive_rate"` +} + +type BuildingProduct struct { + ID uint32 `json:"id"` + BuildingID uint32 `json:"building_id"` + RecipeID sql.NullInt32 `json:"recipe_id"` + StorageProduct sql.NullInt32 `json:"storage_product"` + Storage1 sql.NullInt32 `json:"storage_1"` + Storage2 sql.NullInt32 `json:"storage_2"` + Storage3 sql.NullInt32 `json:"storage_3"` +} + +type CompletedFlightRoutesLog struct { + ID uint32 `json:"id"` + OriginAirportID uint32 `json:"origin_airport_id"` + DestinationAirportID uint32 `json:"destination_airport_id"` + StartTime time.Time `json:"start_time"` + EndTime sql.NullTime `json:"end_time"` +} + +type Country struct { + ID uint16 `json:"id"` + Name string `json:"name"` + Continent CountriesContinent `json:"continent"` + Produce1ID uint16 `json:"produce_1_id"` + Produce2ID uint16 `json:"produce_2_id"` + Produce3ID uint16 `json:"produce_3_id"` +} + +type CurrencyTransactionLog struct { + ID uint64 `json:"id"` + AirportID uint32 `json:"airport_id"` + AmountChanged int32 `json:"amount_changed"` + CurrencyType CurrencyTransactionLogsCurrencyType `json:"currency_type"` + Reason string `json:"reason"` + CreatedAt sql.NullTime `json:"created_at"` +} + +type Effect struct { + ID uint16 `json:"id"` + Name string `json:"name"` +} + +type Plane struct { + ID uint32 `json:"id"` + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + OperationCostFuel uint32 `json:"operation_cost_fuel"` + OperationCostCash uint32 `json:"operation_cost_cash"` + MaxPassengers sql.NullInt32 `json:"max_passengers"` + TravelTimeInseconds uint32 `json:"travel_time_inseconds"` + OperationType PlanesOperationType `json:"operation_type"` + AircraftSize PlanesAircraftSize `json:"aircraft_size"` + AircraftType PlanesAircraftType `json:"aircraft_type"` + BuyingCostCash uint32 `json:"buying_cost_cash"` + CompletedFlightExperience uint32 `json:"completed_flight_experience"` + CompletedFlightCashReward sql.NullInt32 `json:"completed_flight_cash_reward"` + CompletedFlightCargoReward sql.NullInt32 `json:"completed_flight_cargo_reward"` +} + +type PlayerPlane struct { + ID uint32 `json:"id"` + AirportID uint32 `json:"airport_id"` + PlaneID uint32 `json:"plane_id"` + AssignedHangarID uint32 `json:"assigned_hangar_id"` + CurrentBuildingID sql.NullInt32 `json:"current_building_id"` + Status PlayerPlanesStatus `json:"status"` + UpdatedAt sql.NullTime `json:"updated_at"` + FlightStartTime sql.NullTime `json:"flight_start_time"` +} + +type Product struct { + ID uint16 `json:"id"` + Name string `json:"name"` +} + +type Recipe struct { + ID uint32 `json:"id"` + Name string `json:"name"` + Ingredient1ID sql.NullInt16 `json:"ingredient_1_id"` + Ingredient1Amount sql.NullInt32 `json:"ingredient_1_amount"` + Ingredient2ID sql.NullInt16 `json:"ingredient_2_id"` + Ingredient2Amount sql.NullInt32 `json:"ingredient_2_amount"` + Ingredient3ID sql.NullInt16 `json:"ingredient_3_id"` + Ingredient3Amount sql.NullInt32 `json:"ingredient_3_amount"` + ProductID uint16 `json:"product_id"` + YieldAmount uint32 `json:"yield_amount"` +} + +type User struct { + ID int32 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + CreatedAt sql.NullTime `json:"created_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} diff --git a/internal/db/querier.go b/internal/db/querier.go new file mode 100644 index 0000000..736fc72 --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type Querier interface { + AddBuildingToAirportByUsername(ctx context.Context, arg AddBuildingToAirportByUsernameParams) (sql.Result, error) + AddContractToAirport(ctx context.Context, arg AddContractToAirportParams) (sql.Result, error) + AddCountry(ctx context.Context, arg AddCountryParams) (sql.Result, error) + // ============================================================================ + // 4. BUILDINGS & PRODUCTION ENGINE + // ============================================================================ + AddRecipe(ctx context.Context, arg AddRecipeParams) (sql.Result, error) + Addproduct(ctx context.Context, name string) (sql.Result, error) + // ============================================================================ + // 5. FLEET & DYNAMIC INFRASTRUCTURE OPERATIONS + // ============================================================================ + BuyPlaneForPlayer(ctx context.Context, arg BuyPlaneForPlayerParams) (sql.Result, error) + CheckHangarOccupancy(ctx context.Context, currentBuildingID sql.NullInt32) (int64, error) + CheckHangarOccupancyByUsername(ctx context.Context, arg CheckHangarOccupancyByUsernameParams) (int64, error) + // specific hangar + CheckRunwayOccupancyByUsername(ctx context.Context, arg CheckRunwayOccupancyByUsernameParams) (int64, error) + CompleteContract(ctx context.Context, id uint32) error + // ============================================================================ + // 2. AIRPORT CORE MANAGEMENT + // ============================================================================ + // airport row management + CreateAirportByUserName(ctx context.Context, arg CreateAirportByUserNameParams) (sql.Result, error) + // ============================================================================ + // 1. AUTHENTICATION & USERS + // ============================================================================ + CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error) + DeleteAirportByUserName(ctx context.Context, username string) error + DeleteUserByUsername(ctx context.Context, username string) error + FindAvailableInfrastructure(ctx context.Context, arg FindAvailableInfrastructureParams) (uint32, error) + FindAvailableInfrastructureForPlane(ctx context.Context, arg FindAvailableInfrastructureForPlaneParams) (uint32, error) + // ============================================================================ + // 7. PROGRESSION CONTRACTS & ECONOMY LOGS + // ============================================================================ + GetActiveContracts(ctx context.Context, airportID uint32) ([]GetActiveContractsRow, error) + GetActiveContractsWithProduct(ctx context.Context, airportID uint32) ([]GetActiveContractsWithProductRow, error) + // ============================================================================ + // 8. MISSING QUERIES (added for TUI — regenerate sqlc after adding these) + // ============================================================================ + GetAirportBuildingsByAirportId(ctx context.Context, airportID uint32) ([]GetAirportBuildingsByAirportIdRow, error) + GetAirportBuildingsByUsername(ctx context.Context, username string) ([]GetAirportBuildingsByUsernameRow, error) + GetAirportByUserId(ctx context.Context, userID int32) (GetAirportByUserIdRow, error) + GetAirportByUserName(ctx context.Context, username string) (GetAirportByUserNameRow, error) + GetAllBuildings(ctx context.Context) ([]GetAllBuildingsRow, error) + // ============================================================================ + // 3. country + // ============================================================================ + GetAllCountries(ctx context.Context) ([]GetAllCountriesRow, error) + GetAllPlanes(ctx context.Context) ([]Plane, error) + GetAllProducts(ctx context.Context) ([]Product, error) + GetCountryById(ctx context.Context, id uint16) (GetCountryByIdRow, error) + GetCountryByName(ctx context.Context, name string) (GetCountryByNameRow, error) + GetPlaneById(ctx context.Context, id uint32) (Plane, error) + GetPlanesByAircraftSize(ctx context.Context, aircraftSize PlanesAircraftSize) ([]Plane, error) + GetPlanesByAircraftType(ctx context.Context, aircraftType PlanesAircraftType) ([]Plane, error) + GetPlanesByMinLevel(ctx context.Context, minLevel uint32) ([]Plane, error) + GetPlanesByOperationType(ctx context.Context, operationType PlanesOperationType) ([]Plane, error) + // ============================================================================ + // 6. TIME-BASED FLIGHT ENGINE + // ============================================================================ + GetPlanesReadyToLand(ctx context.Context) ([]GetPlanesReadyToLandRow, error) + // specific runway + GetPlanesReadyToLandByUsername(ctx context.Context, username string) ([]GetPlanesReadyToLandByUsernameRow, error) + GetPlayerFleet(ctx context.Context, airportID uint32) ([]GetPlayerFleetRow, error) + GetPlayerPlanesByUsername(ctx context.Context, username string) ([]GetPlayerPlanesByUsernameRow, error) + GetProductById(ctx context.Context, id uint16) (Product, error) + GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) + GetUserIdByUsername(ctx context.Context, username string) (int32, error) + LogCompletedFlightRoute(ctx context.Context, arg LogCompletedFlightRouteParams) error + LogCurrencyTransaction(ctx context.Context, arg LogCurrencyTransactionParams) error + UpdateAirportCash(ctx context.Context, arg UpdateAirportCashParams) error + // airport resource management + UpdateAirportCurrencies(ctx context.Context, arg UpdateAirportCurrenciesParams) error + UpdateAirportFuel(ctx context.Context, arg UpdateAirportFuelParams) error + UpdateAirportFuelRate(ctx context.Context, arg UpdateAirportFuelRateParams) error + UpdateAirportMaxFuel(ctx context.Context, arg UpdateAirportMaxFuelParams) error + UpdateAirportPassengerRate(ctx context.Context, arg UpdateAirportPassengerRateParams) error + UpdateAirportResources(ctx context.Context, id uint32) error + UpdateAirportResourcesUpdated(ctx context.Context, arg UpdateAirportResourcesUpdatedParams) error + UpdateAirportmaxPassengers(ctx context.Context, arg UpdateAirportmaxPassengersParams) error + UpdateAirportpassengers(ctx context.Context, arg UpdateAirportpassengersParams) error + UpdateContractDelivery(ctx context.Context, arg UpdateContractDeliveryParams) error + UpdatePlaneState(ctx context.Context, arg UpdatePlaneStateParams) error + UpdatePlayerPlaneState(ctx context.Context, arg UpdatePlayerPlaneStateParams) error + UpdateUserPasswordByUserName(ctx context.Context, arg UpdateUserPasswordByUserNameParams) error + addBuilding(ctx context.Context, arg addBuildingParams) (sql.Result, error) + addOperationalBuilding(ctx context.Context, arg addOperationalBuildingParams) (sql.Result, error) + addPassiveProductionBuilding(ctx context.Context, arg addPassiveProductionBuildingParams) (sql.Result, error) + // planes & fleet management + addPlane(ctx context.Context, arg addPlaneParams) (sql.Result, error) + addPlaneToAirportByUsername(ctx context.Context, arg addPlaneToAirportByUsernameParams) (sql.Result, error) + addStorageBuilding(ctx context.Context, arg addStorageBuildingParams) (sql.Result, error) + updatePlayerExperience(ctx context.Context, arg updatePlayerExperienceParams) error + updatePlayerLevel(ctx context.Context, arg updatePlayerLevelParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go new file mode 100644 index 0000000..046956b --- /dev/null +++ b/internal/db/queries.sql.go @@ -0,0 +1,1795 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queries.sql + +package db + +import ( + "context" + "database/sql" + "time" +) + +const AddBuildingToAirportByUsername = `-- name: AddBuildingToAirportByUsername :execresult +INSERT INTO airport_buildings (airport_id, building_id) +VALUES ((select id from airports where user_id = (select id from users where username = ?)), ?) +` + +type AddBuildingToAirportByUsernameParams struct { + Username string `json:"username"` + BuildingID uint32 `json:"building_id"` +} + +func (q *Queries) AddBuildingToAirportByUsername(ctx context.Context, arg AddBuildingToAirportByUsernameParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddBuildingToAirportByUsername, arg.Username, arg.BuildingID) +} + +const AddContractToAirport = `-- name: AddContractToAirport :execresult +INSERT INTO airport_contracts (airport_id, title, required_product_id, required_amount, expires_at) +VALUES (?, ?, ?, ?, ?) +` + +type AddContractToAirportParams struct { + AirportID uint32 `json:"airport_id"` + Title string `json:"title"` + RequiredProductID uint16 `json:"required_product_id"` + RequiredAmount uint32 `json:"required_amount"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (q *Queries) AddContractToAirport(ctx context.Context, arg AddContractToAirportParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddContractToAirport, + arg.AirportID, + arg.Title, + arg.RequiredProductID, + arg.RequiredAmount, + arg.ExpiresAt, + ) +} + +const AddCountry = `-- name: AddCountry :execresult +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) +VALUES (?, ?, ?, ?, ?) +` + +type AddCountryParams struct { + Name string `json:"name"` + Continent CountriesContinent `json:"continent"` + Produce1ID uint16 `json:"produce_1_id"` + Produce2ID uint16 `json:"produce_2_id"` + Produce3ID uint16 `json:"produce_3_id"` +} + +func (q *Queries) AddCountry(ctx context.Context, arg AddCountryParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddCountry, + arg.Name, + arg.Continent, + arg.Produce1ID, + arg.Produce2ID, + arg.Produce3ID, + ) +} + +const AddRecipe = `-- name: AddRecipe :execresult +INSERT INTO recipes (name, ingredient_1_id, ingredient_1_amount, ingredient_2_id, ingredient_2_amount, ingredient_3_id, ingredient_3_amount, product_id, yield_amount) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type AddRecipeParams struct { + Name string `json:"name"` + Ingredient1ID sql.NullInt16 `json:"ingredient_1_id"` + Ingredient1Amount sql.NullInt32 `json:"ingredient_1_amount"` + Ingredient2ID sql.NullInt16 `json:"ingredient_2_id"` + Ingredient2Amount sql.NullInt32 `json:"ingredient_2_amount"` + Ingredient3ID sql.NullInt16 `json:"ingredient_3_id"` + Ingredient3Amount sql.NullInt32 `json:"ingredient_3_amount"` + ProductID uint16 `json:"product_id"` + YieldAmount uint32 `json:"yield_amount"` +} + +// ============================================================================ +// 4. BUILDINGS & PRODUCTION ENGINE +// ============================================================================ +func (q *Queries) AddRecipe(ctx context.Context, arg AddRecipeParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddRecipe, + arg.Name, + arg.Ingredient1ID, + arg.Ingredient1Amount, + arg.Ingredient2ID, + arg.Ingredient2Amount, + arg.Ingredient3ID, + arg.Ingredient3Amount, + arg.ProductID, + arg.YieldAmount, + ) +} + +const Addproduct = `-- name: Addproduct :execresult +insert into products (name) values (?) +` + +func (q *Queries) Addproduct(ctx context.Context, name string) (sql.Result, error) { + return q.db.ExecContext(ctx, Addproduct, name) +} + +const BuyPlaneForPlayer = `-- name: BuyPlaneForPlayer :execresult + +INSERT INTO player_planes (airport_id, plane_id, assigned_hangar_id, current_building_id, status) +VALUES (?, ?, ?, ?, 'idle') +` + +type BuyPlaneForPlayerParams struct { + AirportID uint32 `json:"airport_id"` + PlaneID uint32 `json:"plane_id"` + AssignedHangarID uint32 `json:"assigned_hangar_id"` + CurrentBuildingID sql.NullInt32 `json:"current_building_id"` +} + +// ============================================================================ +// 5. FLEET & DYNAMIC INFRASTRUCTURE OPERATIONS +// ============================================================================ +func (q *Queries) BuyPlaneForPlayer(ctx context.Context, arg BuyPlaneForPlayerParams) (sql.Result, error) { + return q.db.ExecContext(ctx, BuyPlaneForPlayer, + arg.AirportID, + arg.PlaneID, + arg.AssignedHangarID, + arg.CurrentBuildingID, + ) +} + +const CheckHangarOccupancy = `-- name: CheckHangarOccupancy :one +SELECT COUNT(*) AS total_parked +FROM player_planes +WHERE current_building_id = ? +` + +func (q *Queries) CheckHangarOccupancy(ctx context.Context, currentBuildingID sql.NullInt32) (int64, error) { + row := q.db.QueryRowContext(ctx, CheckHangarOccupancy, currentBuildingID) + var total_parked int64 + err := row.Scan(&total_parked) + return total_parked, err +} + +const CheckHangarOccupancyByUsername = `-- name: CheckHangarOccupancyByUsername :one +SELECT COUNT(*) AS total_parked +FROM player_planes pp +JOIN airport_buildings ab ON pp.current_building_id = ab.id +WHERE ab.airport_id = (select id from airports where user_id = (select id from users where username = ?)) + AND ab.building_id = ? +` + +type CheckHangarOccupancyByUsernameParams struct { + Username string `json:"username"` + BuildingID uint32 `json:"building_id"` +} + +func (q *Queries) CheckHangarOccupancyByUsername(ctx context.Context, arg CheckHangarOccupancyByUsernameParams) (int64, error) { + row := q.db.QueryRowContext(ctx, CheckHangarOccupancyByUsername, arg.Username, arg.BuildingID) + var total_parked int64 + err := row.Scan(&total_parked) + return total_parked, err +} + +const CheckRunwayOccupancyByUsername = `-- name: CheckRunwayOccupancyByUsername :one + + -- +SELECT COUNT(*) AS total_occupied +FROM player_planes pp +JOIN airport_buildings ab ON pp.current_building_id = ab.id +WHERE ab.airport_id = (select id from airports where user_id = (select id from users where username = ?)) + AND ab.building_id = ? +` + +type CheckRunwayOccupancyByUsernameParams struct { + Username string `json:"username"` + BuildingID uint32 `json:"building_id"` +} + +// specific hangar +func (q *Queries) CheckRunwayOccupancyByUsername(ctx context.Context, arg CheckRunwayOccupancyByUsernameParams) (int64, error) { + row := q.db.QueryRowContext(ctx, CheckRunwayOccupancyByUsername, arg.Username, arg.BuildingID) + var total_occupied int64 + err := row.Scan(&total_occupied) + return total_occupied, err +} + +const CompleteContract = `-- name: CompleteContract :exec +UPDATE airport_contracts +SET is_completed = TRUE +WHERE id = ? +` + +func (q *Queries) CompleteContract(ctx context.Context, id uint32) error { + _, err := q.db.ExecContext(ctx, CompleteContract, id) + return err +} + +const CreateAirportByUserName = `-- name: CreateAirportByUserName :execresult + + +INSERT INTO airports (name, user_id, origin_country_id) +VALUES (?, (SELECT id FROM users WHERE username = ?), ?) +` + +type CreateAirportByUserNameParams struct { + Name string `json:"name"` + Username string `json:"username"` + OriginCountryID uint16 `json:"origin_country_id"` +} + +// ============================================================================ +// 2. AIRPORT CORE MANAGEMENT +// ============================================================================ +// airport row management +func (q *Queries) CreateAirportByUserName(ctx context.Context, arg CreateAirportByUserNameParams) (sql.Result, error) { + return q.db.ExecContext(ctx, CreateAirportByUserName, arg.Name, arg.Username, arg.OriginCountryID) +} + +const CreateUser = `-- name: CreateUser :execresult + +INSERT INTO users (username, password_hash) +VALUES (?, ?) +` + +type CreateUserParams struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` +} + +// ============================================================================ +// 1. AUTHENTICATION & USERS +// ============================================================================ +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error) { + return q.db.ExecContext(ctx, CreateUser, arg.Username, arg.PasswordHash) +} + +const DeleteAirportByUserName = `-- name: DeleteAirportByUserName :exec +UPDATE airports +SET is_deleted = TRUE +WHERE user_id = (SELECT id FROM users WHERE username = ?) +` + +func (q *Queries) DeleteAirportByUserName(ctx context.Context, username string) error { + _, err := q.db.ExecContext(ctx, DeleteAirportByUserName, username) + return err +} + +const DeleteUserByUsername = `-- name: DeleteUserByUsername :exec +UPDATE users +SET deleted_at = CURRENT_TIMESTAMP +WHERE username = ? +` + +func (q *Queries) DeleteUserByUsername(ctx context.Context, username string) error { + _, err := q.db.ExecContext(ctx, DeleteUserByUsername, username) + return err +} + +const FindAvailableInfrastructure = `-- name: FindAvailableInfrastructure :one +SELECT ab.id +FROM airport_buildings ab +JOIN buildings b ON ab.building_id = b.id +LEFT JOIN player_planes pp ON ab.id = pp.current_building_id +WHERE ab.airport_id = ? + AND b.operation_type = ? -- 'runway' or 'service' (service bay) + AND b.operation_size = ? -- matches plane's aircraft_size requirement + AND pp.id IS NULL -- Ensures no other plane is physically here right now +LIMIT 1 +` + +type FindAvailableInfrastructureParams struct { + AirportID uint32 `json:"airport_id"` + OperationType NullBuildingsOperationType `json:"operation_type"` + OperationSize NullBuildingsOperationSize `json:"operation_size"` +} + +func (q *Queries) FindAvailableInfrastructure(ctx context.Context, arg FindAvailableInfrastructureParams) (uint32, error) { + row := q.db.QueryRowContext(ctx, FindAvailableInfrastructure, arg.AirportID, arg.OperationType, arg.OperationSize) + var id uint32 + err := row.Scan(&id) + return id, err +} + +const FindAvailableInfrastructureForPlane = `-- name: FindAvailableInfrastructureForPlane :one +SELECT ab.id +FROM airport_buildings ab +JOIN buildings b ON ab.building_id = b.id +LEFT JOIN player_planes pp ON ab.id = pp.current_building_id +WHERE ab.airport_id = (select id from airports where user_id = (select id from users where username = ?)) + AND b.operation_type = ? -- 'runway' or 'service' (service bay) + AND b.operation_size = ? -- matches plane's aircraft_size requirement + AND pp.id IS NULL -- Ensures no other plane is physically here right now +LIMIT 1 +` + +type FindAvailableInfrastructureForPlaneParams struct { + Username string `json:"username"` + OperationType NullBuildingsOperationType `json:"operation_type"` + OperationSize NullBuildingsOperationSize `json:"operation_size"` +} + +func (q *Queries) FindAvailableInfrastructureForPlane(ctx context.Context, arg FindAvailableInfrastructureForPlaneParams) (uint32, error) { + row := q.db.QueryRowContext(ctx, FindAvailableInfrastructureForPlane, arg.Username, arg.OperationType, arg.OperationSize) + var id uint32 + err := row.Scan(&id) + return id, err +} + +const GetActiveContracts = `-- name: GetActiveContracts :many + +SELECT id, title, required_product_id, required_amount, current_amount_delivered, is_completed, expires_at +FROM airport_contracts +WHERE airport_id = ? AND is_completed = FALSE AND expires_at > NOW() +` + +type GetActiveContractsRow struct { + ID uint32 `json:"id"` + Title string `json:"title"` + RequiredProductID uint16 `json:"required_product_id"` + RequiredAmount uint32 `json:"required_amount"` + CurrentAmountDelivered uint32 `json:"current_amount_delivered"` + IsCompleted bool `json:"is_completed"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ============================================================================ +// 7. PROGRESSION CONTRACTS & ECONOMY LOGS +// ============================================================================ +func (q *Queries) GetActiveContracts(ctx context.Context, airportID uint32) ([]GetActiveContractsRow, error) { + rows, err := q.db.QueryContext(ctx, GetActiveContracts, airportID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetActiveContractsRow{} + for rows.Next() { + var i GetActiveContractsRow + if err := rows.Scan( + &i.ID, + &i.Title, + &i.RequiredProductID, + &i.RequiredAmount, + &i.CurrentAmountDelivered, + &i.IsCompleted, + &i.ExpiresAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetActiveContractsWithProduct = `-- name: GetActiveContractsWithProduct :many +SELECT ac.id, ac.title, ac.required_product_id, ac.required_amount, + ac.current_amount_delivered, ac.is_completed, ac.expires_at, + p.name AS product_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 +` + +type GetActiveContractsWithProductRow struct { + ID uint32 `json:"id"` + Title string `json:"title"` + RequiredProductID uint16 `json:"required_product_id"` + RequiredAmount uint32 `json:"required_amount"` + CurrentAmountDelivered uint32 `json:"current_amount_delivered"` + IsCompleted bool `json:"is_completed"` + ExpiresAt time.Time `json:"expires_at"` + ProductName string `json:"product_name"` +} + +func (q *Queries) GetActiveContractsWithProduct(ctx context.Context, airportID uint32) ([]GetActiveContractsWithProductRow, error) { + rows, err := q.db.QueryContext(ctx, GetActiveContractsWithProduct, airportID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetActiveContractsWithProductRow{} + for rows.Next() { + var i GetActiveContractsWithProductRow + if err := rows.Scan( + &i.ID, + &i.Title, + &i.RequiredProductID, + &i.RequiredAmount, + &i.CurrentAmountDelivered, + &i.IsCompleted, + &i.ExpiresAt, + &i.ProductName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAirportBuildingsByAirportId = `-- name: GetAirportBuildingsByAirportId :many + +SELECT ab.id, ab.airport_id, ab.building_id, ab.level, + b.name, b.building_type, + 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 = ? +` + +type GetAirportBuildingsByAirportIdRow struct { + ID uint32 `json:"id"` + AirportID uint32 `json:"airport_id"` + BuildingID uint32 `json:"building_id"` + Level uint32 `json:"level"` + Name string `json:"name"` + BuildingType BuildingsBuildingType `json:"building_type"` + OperationSize NullBuildingsOperationSize `json:"operation_size"` + OperationType NullBuildingsOperationType `json:"operation_type"` + Capacity sql.NullInt32 `json:"capacity"` + StorageType NullBuildingsStorageType `json:"storage_type"` +} + +// ============================================================================ +// 8. MISSING QUERIES (added for TUI — regenerate sqlc after adding these) +// ============================================================================ +func (q *Queries) GetAirportBuildingsByAirportId(ctx context.Context, airportID uint32) ([]GetAirportBuildingsByAirportIdRow, error) { + rows, err := q.db.QueryContext(ctx, GetAirportBuildingsByAirportId, airportID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetAirportBuildingsByAirportIdRow{} + for rows.Next() { + var i GetAirportBuildingsByAirportIdRow + if err := rows.Scan( + &i.ID, + &i.AirportID, + &i.BuildingID, + &i.Level, + &i.Name, + &i.BuildingType, + &i.OperationSize, + &i.OperationType, + &i.Capacity, + &i.StorageType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAirportBuildingsByUsername = `-- name: GetAirportBuildingsByUsername :many +SELECT ab.id AS airport_building_id,b.building_type , b.id, b.name, b.min_level, b.construction_cost_cash, b.upgrade_tier, b.upgrade_cost_cash_to_uptier, b.building_type, b.capacity, b.storage_type, b.storage_subtype, b.effect_id, b.effect_value, b.operation_size, b.operation_type, b.passive_type, b.passive_rate +FROM airport_buildings ab +JOIN buildings b ON ab.building_id = b.id +WHERE ab.airport_id = (select id from airports where user_id = (select id from users where username = ?)) +` + +type GetAirportBuildingsByUsernameRow struct { + AirportBuildingID uint32 `json:"airport_building_id"` + BuildingType BuildingsBuildingType `json:"building_type"` + ID uint32 `json:"id"` + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + ConstructionCostCash uint32 `json:"construction_cost_cash"` + UpgradeTier sql.NullInt16 `json:"upgrade_tier"` + UpgradeCostCashToUptier sql.NullInt32 `json:"upgrade_cost_cash_to_uptier"` + BuildingType_2 BuildingsBuildingType `json:"building_type_2"` + Capacity sql.NullInt32 `json:"capacity"` + StorageType NullBuildingsStorageType `json:"storage_type"` + StorageSubtype NullBuildingsStorageSubtype `json:"storage_subtype"` + EffectID sql.NullInt16 `json:"effect_id"` + EffectValue sql.NullInt32 `json:"effect_value"` + OperationSize NullBuildingsOperationSize `json:"operation_size"` + OperationType NullBuildingsOperationType `json:"operation_type"` + PassiveType NullBuildingsPassiveType `json:"passive_type"` + PassiveRate sql.NullInt32 `json:"passive_rate"` +} + +func (q *Queries) GetAirportBuildingsByUsername(ctx context.Context, username string) ([]GetAirportBuildingsByUsernameRow, error) { + rows, err := q.db.QueryContext(ctx, GetAirportBuildingsByUsername, username) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetAirportBuildingsByUsernameRow{} + for rows.Next() { + var i GetAirportBuildingsByUsernameRow + if err := rows.Scan( + &i.AirportBuildingID, + &i.BuildingType, + &i.ID, + &i.Name, + &i.MinLevel, + &i.ConstructionCostCash, + &i.UpgradeTier, + &i.UpgradeCostCashToUptier, + &i.BuildingType_2, + &i.Capacity, + &i.StorageType, + &i.StorageSubtype, + &i.EffectID, + &i.EffectValue, + &i.OperationSize, + &i.OperationType, + &i.PassiveType, + &i.PassiveRate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAirportByUserId = `-- name: GetAirportByUserId :one +SELECT id, name, user_id, origin_country_id, cash, current_passengers, max_passenger_capacity, current_fuel, max_fuel_capacity, is_available, player_level, experience_points, passenger_generation_rate, fuel_generation_rate, resources_last_updated_at, is_deleted +FROM airports +WHERE user_id = ? LIMIT 1 +` + +type GetAirportByUserIdRow struct { + ID uint32 `json:"id"` + Name string `json:"name"` + UserID int32 `json:"user_id"` + OriginCountryID uint16 `json:"origin_country_id"` + Cash uint64 `json:"cash"` + CurrentPassengers uint32 `json:"current_passengers"` + MaxPassengerCapacity uint32 `json:"max_passenger_capacity"` + CurrentFuel uint32 `json:"current_fuel"` + MaxFuelCapacity uint32 `json:"max_fuel_capacity"` + IsAvailable bool `json:"is_available"` + PlayerLevel uint32 `json:"player_level"` + ExperiencePoints uint64 `json:"experience_points"` + PassengerGenerationRate uint32 `json:"passenger_generation_rate"` + FuelGenerationRate uint32 `json:"fuel_generation_rate"` + ResourcesLastUpdatedAt time.Time `json:"resources_last_updated_at"` + IsDeleted sql.NullBool `json:"is_deleted"` +} + +func (q *Queries) GetAirportByUserId(ctx context.Context, userID int32) (GetAirportByUserIdRow, error) { + row := q.db.QueryRowContext(ctx, GetAirportByUserId, userID) + var i GetAirportByUserIdRow + err := row.Scan( + &i.ID, + &i.Name, + &i.UserID, + &i.OriginCountryID, + &i.Cash, + &i.CurrentPassengers, + &i.MaxPassengerCapacity, + &i.CurrentFuel, + &i.MaxFuelCapacity, + &i.IsAvailable, + &i.PlayerLevel, + &i.ExperiencePoints, + &i.PassengerGenerationRate, + &i.FuelGenerationRate, + &i.ResourcesLastUpdatedAt, + &i.IsDeleted, + ) + return i, err +} + +const GetAirportByUserName = `-- name: GetAirportByUserName :one +SELECT id, name, user_id, origin_country_id, cash, current_passengers, max_passenger_capacity, current_fuel, max_fuel_capacity, is_available, player_level, experience_points ,passenger_generation_rate, fuel_generation_rate, resources_last_updated_at, is_deleted +FROM airports +WHERE user_id = (SELECT id FROM users WHERE username = ?) LIMIT 1 +` + +type GetAirportByUserNameRow struct { + ID uint32 `json:"id"` + Name string `json:"name"` + UserID int32 `json:"user_id"` + OriginCountryID uint16 `json:"origin_country_id"` + Cash uint64 `json:"cash"` + CurrentPassengers uint32 `json:"current_passengers"` + MaxPassengerCapacity uint32 `json:"max_passenger_capacity"` + CurrentFuel uint32 `json:"current_fuel"` + MaxFuelCapacity uint32 `json:"max_fuel_capacity"` + IsAvailable bool `json:"is_available"` + PlayerLevel uint32 `json:"player_level"` + ExperiencePoints uint64 `json:"experience_points"` + PassengerGenerationRate uint32 `json:"passenger_generation_rate"` + FuelGenerationRate uint32 `json:"fuel_generation_rate"` + ResourcesLastUpdatedAt time.Time `json:"resources_last_updated_at"` + IsDeleted sql.NullBool `json:"is_deleted"` +} + +func (q *Queries) GetAirportByUserName(ctx context.Context, username string) (GetAirportByUserNameRow, error) { + row := q.db.QueryRowContext(ctx, GetAirportByUserName, username) + var i GetAirportByUserNameRow + err := row.Scan( + &i.ID, + &i.Name, + &i.UserID, + &i.OriginCountryID, + &i.Cash, + &i.CurrentPassengers, + &i.MaxPassengerCapacity, + &i.CurrentFuel, + &i.MaxFuelCapacity, + &i.IsAvailable, + &i.PlayerLevel, + &i.ExperiencePoints, + &i.PassengerGenerationRate, + &i.FuelGenerationRate, + &i.ResourcesLastUpdatedAt, + &i.IsDeleted, + ) + return i, err +} + +const GetAllBuildings = `-- name: GetAllBuildings :many +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 +` + +type GetAllBuildingsRow struct { + ID uint32 `json:"id"` + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + ConstructionCostCash uint32 `json:"construction_cost_cash"` + UpgradeTier sql.NullInt16 `json:"upgrade_tier"` + BuildingType BuildingsBuildingType `json:"building_type"` + Capacity sql.NullInt32 `json:"capacity"` + StorageType NullBuildingsStorageType `json:"storage_type"` + StorageSubtype NullBuildingsStorageSubtype `json:"storage_subtype"` + OperationSize NullBuildingsOperationSize `json:"operation_size"` + OperationType NullBuildingsOperationType `json:"operation_type"` + PassiveType NullBuildingsPassiveType `json:"passive_type"` + PassiveRate sql.NullInt32 `json:"passive_rate"` +} + +func (q *Queries) GetAllBuildings(ctx context.Context) ([]GetAllBuildingsRow, error) { + rows, err := q.db.QueryContext(ctx, GetAllBuildings) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetAllBuildingsRow{} + for rows.Next() { + var i GetAllBuildingsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MinLevel, + &i.ConstructionCostCash, + &i.UpgradeTier, + &i.BuildingType, + &i.Capacity, + &i.StorageType, + &i.StorageSubtype, + &i.OperationSize, + &i.OperationType, + &i.PassiveType, + &i.PassiveRate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAllCountries = `-- name: GetAllCountries :many +SELECT id, name FROM countries +` + +type GetAllCountriesRow struct { + ID uint16 `json:"id"` + Name string `json:"name"` +} + +// ============================================================================ +// 3. country +// ============================================================================ +func (q *Queries) GetAllCountries(ctx context.Context) ([]GetAllCountriesRow, error) { + rows, err := q.db.QueryContext(ctx, GetAllCountries) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetAllCountriesRow{} + for rows.Next() { + var i GetAllCountriesRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAllPlanes = `-- name: GetAllPlanes :many +SELECT id, name, min_level, operation_cost_fuel, operation_cost_cash, max_passengers, travel_time_inseconds, operation_type, aircraft_size, aircraft_type, buying_cost_cash, completed_flight_experience, completed_flight_cash_reward, completed_flight_cargo_reward FROM planes +` + +func (q *Queries) GetAllPlanes(ctx context.Context) ([]Plane, error) { + rows, err := q.db.QueryContext(ctx, GetAllPlanes) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Plane{} + for rows.Next() { + var i Plane + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MinLevel, + &i.OperationCostFuel, + &i.OperationCostCash, + &i.MaxPassengers, + &i.TravelTimeInseconds, + &i.OperationType, + &i.AircraftSize, + &i.AircraftType, + &i.BuyingCostCash, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAllProducts = `-- name: GetAllProducts :many +select id, name from products +` + +func (q *Queries) GetAllProducts(ctx context.Context) ([]Product, error) { + rows, err := q.db.QueryContext(ctx, GetAllProducts) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Product{} + for rows.Next() { + var i Product + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetCountryById = `-- name: GetCountryById :one +SELECT id, name FROM countries WHERE id = ? +` + +type GetCountryByIdRow struct { + ID uint16 `json:"id"` + Name string `json:"name"` +} + +func (q *Queries) GetCountryById(ctx context.Context, id uint16) (GetCountryByIdRow, error) { + row := q.db.QueryRowContext(ctx, GetCountryById, id) + var i GetCountryByIdRow + err := row.Scan(&i.ID, &i.Name) + return i, err +} + +const GetCountryByName = `-- name: GetCountryByName :one +SELECT id, name FROM countries WHERE name = ? +` + +type GetCountryByNameRow struct { + ID uint16 `json:"id"` + Name string `json:"name"` +} + +func (q *Queries) GetCountryByName(ctx context.Context, name string) (GetCountryByNameRow, error) { + row := q.db.QueryRowContext(ctx, GetCountryByName, name) + var i GetCountryByNameRow + err := row.Scan(&i.ID, &i.Name) + return i, err +} + +const GetPlaneById = `-- name: GetPlaneById :one +SELECT id, name, min_level, operation_cost_fuel, operation_cost_cash, max_passengers, travel_time_inseconds, operation_type, aircraft_size, aircraft_type, buying_cost_cash, completed_flight_experience, completed_flight_cash_reward, completed_flight_cargo_reward FROM planes WHERE id = ? +` + +func (q *Queries) GetPlaneById(ctx context.Context, id uint32) (Plane, error) { + row := q.db.QueryRowContext(ctx, GetPlaneById, id) + var i Plane + err := row.Scan( + &i.ID, + &i.Name, + &i.MinLevel, + &i.OperationCostFuel, + &i.OperationCostCash, + &i.MaxPassengers, + &i.TravelTimeInseconds, + &i.OperationType, + &i.AircraftSize, + &i.AircraftType, + &i.BuyingCostCash, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + ) + return i, err +} + +const GetPlanesByAircraftSize = `-- name: GetPlanesByAircraftSize :many +SELECT id, name, min_level, operation_cost_fuel, operation_cost_cash, max_passengers, travel_time_inseconds, operation_type, aircraft_size, aircraft_type, buying_cost_cash, completed_flight_experience, completed_flight_cash_reward, completed_flight_cargo_reward FROM planes WHERE aircraft_size = ? +` + +func (q *Queries) GetPlanesByAircraftSize(ctx context.Context, aircraftSize PlanesAircraftSize) ([]Plane, error) { + rows, err := q.db.QueryContext(ctx, GetPlanesByAircraftSize, aircraftSize) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Plane{} + for rows.Next() { + var i Plane + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MinLevel, + &i.OperationCostFuel, + &i.OperationCostCash, + &i.MaxPassengers, + &i.TravelTimeInseconds, + &i.OperationType, + &i.AircraftSize, + &i.AircraftType, + &i.BuyingCostCash, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPlanesByAircraftType = `-- name: GetPlanesByAircraftType :many +SELECT id, name, min_level, operation_cost_fuel, operation_cost_cash, max_passengers, travel_time_inseconds, operation_type, aircraft_size, aircraft_type, buying_cost_cash, completed_flight_experience, completed_flight_cash_reward, completed_flight_cargo_reward FROM planes WHERE aircraft_type = ? +` + +func (q *Queries) GetPlanesByAircraftType(ctx context.Context, aircraftType PlanesAircraftType) ([]Plane, error) { + rows, err := q.db.QueryContext(ctx, GetPlanesByAircraftType, aircraftType) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Plane{} + for rows.Next() { + var i Plane + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MinLevel, + &i.OperationCostFuel, + &i.OperationCostCash, + &i.MaxPassengers, + &i.TravelTimeInseconds, + &i.OperationType, + &i.AircraftSize, + &i.AircraftType, + &i.BuyingCostCash, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPlanesByMinLevel = `-- name: GetPlanesByMinLevel :many +SELECT id, name, min_level, operation_cost_fuel, operation_cost_cash, max_passengers, travel_time_inseconds, operation_type, aircraft_size, aircraft_type, buying_cost_cash, completed_flight_experience, completed_flight_cash_reward, completed_flight_cargo_reward FROM planes WHERE min_level <= ? +` + +func (q *Queries) GetPlanesByMinLevel(ctx context.Context, minLevel uint32) ([]Plane, error) { + rows, err := q.db.QueryContext(ctx, GetPlanesByMinLevel, minLevel) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Plane{} + for rows.Next() { + var i Plane + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MinLevel, + &i.OperationCostFuel, + &i.OperationCostCash, + &i.MaxPassengers, + &i.TravelTimeInseconds, + &i.OperationType, + &i.AircraftSize, + &i.AircraftType, + &i.BuyingCostCash, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPlanesByOperationType = `-- name: GetPlanesByOperationType :many +SELECT id, name, min_level, operation_cost_fuel, operation_cost_cash, max_passengers, travel_time_inseconds, operation_type, aircraft_size, aircraft_type, buying_cost_cash, completed_flight_experience, completed_flight_cash_reward, completed_flight_cargo_reward FROM planes WHERE operation_type = ? +` + +func (q *Queries) GetPlanesByOperationType(ctx context.Context, operationType PlanesOperationType) ([]Plane, error) { + rows, err := q.db.QueryContext(ctx, GetPlanesByOperationType, operationType) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Plane{} + for rows.Next() { + var i Plane + if err := rows.Scan( + &i.ID, + &i.Name, + &i.MinLevel, + &i.OperationCostFuel, + &i.OperationCostCash, + &i.MaxPassengers, + &i.TravelTimeInseconds, + &i.OperationType, + &i.AircraftSize, + &i.AircraftType, + &i.BuyingCostCash, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPlanesReadyToLand = `-- name: GetPlanesReadyToLand :many +SELECT pp.id AS player_plane_id, pp.airport_id, pp.assigned_hangar_id, + p.completed_flight_experience, p.completed_flight_cash_reward, p.completed_flight_cargo_reward, p.operation_type +FROM player_planes pp +JOIN planes p ON pp.plane_id = p.id +WHERE pp.status = 'flying' + AND DATE_ADD(pp.flight_start_time, INTERVAL p.travel_time_inSeconds SECOND) <= NOW() +` + +type GetPlanesReadyToLandRow struct { + PlayerPlaneID uint32 `json:"player_plane_id"` + AirportID uint32 `json:"airport_id"` + AssignedHangarID uint32 `json:"assigned_hangar_id"` + CompletedFlightExperience uint32 `json:"completed_flight_experience"` + CompletedFlightCashReward sql.NullInt32 `json:"completed_flight_cash_reward"` + CompletedFlightCargoReward sql.NullInt32 `json:"completed_flight_cargo_reward"` + OperationType PlanesOperationType `json:"operation_type"` +} + +// ============================================================================ +// 6. TIME-BASED FLIGHT ENGINE +// ============================================================================ +func (q *Queries) GetPlanesReadyToLand(ctx context.Context) ([]GetPlanesReadyToLandRow, error) { + rows, err := q.db.QueryContext(ctx, GetPlanesReadyToLand) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetPlanesReadyToLandRow{} + for rows.Next() { + var i GetPlanesReadyToLandRow + if err := rows.Scan( + &i.PlayerPlaneID, + &i.AirportID, + &i.AssignedHangarID, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + &i.OperationType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPlanesReadyToLandByUsername = `-- name: GetPlanesReadyToLandByUsername :many +SELECT pp.id AS player_plane_id, pp.airport_id, pp.assigned_hangar_id, + p.completed_flight_experience, p.completed_flight_cash_reward, p.completed_flight_cargo_reward, p.operation_type +FROM player_planes pp +JOIN planes p ON pp.plane_id = p.id +WHERE pp.status = 'flying' + AND DATE_ADD(pp.flight_start_time, INTERVAL p.travel_time_inSeconds SECOND) <= NOW() + AND pp.airport_id = (select id from airports where user_id = (select id from users where username = ?)) +` + +type GetPlanesReadyToLandByUsernameRow struct { + PlayerPlaneID uint32 `json:"player_plane_id"` + AirportID uint32 `json:"airport_id"` + AssignedHangarID uint32 `json:"assigned_hangar_id"` + CompletedFlightExperience uint32 `json:"completed_flight_experience"` + CompletedFlightCashReward sql.NullInt32 `json:"completed_flight_cash_reward"` + CompletedFlightCargoReward sql.NullInt32 `json:"completed_flight_cargo_reward"` + OperationType PlanesOperationType `json:"operation_type"` +} + +// specific runway +func (q *Queries) GetPlanesReadyToLandByUsername(ctx context.Context, username string) ([]GetPlanesReadyToLandByUsernameRow, error) { + rows, err := q.db.QueryContext(ctx, GetPlanesReadyToLandByUsername, username) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetPlanesReadyToLandByUsernameRow{} + for rows.Next() { + var i GetPlanesReadyToLandByUsernameRow + if err := rows.Scan( + &i.PlayerPlaneID, + &i.AirportID, + &i.AssignedHangarID, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + &i.OperationType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPlayerFleet = `-- name: GetPlayerFleet :many +SELECT pp.id AS player_plane_id, pp.status, pp.updated_at, pp.flight_start_time, pp.current_building_id, pp.assigned_hangar_id,p.id, p.name, p.min_level, p.operation_cost_fuel, p.operation_cost_cash, p.max_passengers, p.travel_time_inseconds, p.operation_type, p.aircraft_size, p.aircraft_type, p.buying_cost_cash, p.completed_flight_experience, p.completed_flight_cash_reward, p.completed_flight_cargo_reward +FROM player_planes pp +JOIN planes p ON pp.plane_id = p.id +WHERE pp.airport_id = ? +` + +type GetPlayerFleetRow struct { + PlayerPlaneID uint32 `json:"player_plane_id"` + Status PlayerPlanesStatus `json:"status"` + UpdatedAt sql.NullTime `json:"updated_at"` + FlightStartTime sql.NullTime `json:"flight_start_time"` + CurrentBuildingID sql.NullInt32 `json:"current_building_id"` + AssignedHangarID uint32 `json:"assigned_hangar_id"` + ID uint32 `json:"id"` + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + OperationCostFuel uint32 `json:"operation_cost_fuel"` + OperationCostCash uint32 `json:"operation_cost_cash"` + MaxPassengers sql.NullInt32 `json:"max_passengers"` + TravelTimeInseconds uint32 `json:"travel_time_inseconds"` + OperationType PlanesOperationType `json:"operation_type"` + AircraftSize PlanesAircraftSize `json:"aircraft_size"` + AircraftType PlanesAircraftType `json:"aircraft_type"` + BuyingCostCash uint32 `json:"buying_cost_cash"` + CompletedFlightExperience uint32 `json:"completed_flight_experience"` + CompletedFlightCashReward sql.NullInt32 `json:"completed_flight_cash_reward"` + CompletedFlightCargoReward sql.NullInt32 `json:"completed_flight_cargo_reward"` +} + +func (q *Queries) GetPlayerFleet(ctx context.Context, airportID uint32) ([]GetPlayerFleetRow, error) { + rows, err := q.db.QueryContext(ctx, GetPlayerFleet, airportID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetPlayerFleetRow{} + for rows.Next() { + var i GetPlayerFleetRow + if err := rows.Scan( + &i.PlayerPlaneID, + &i.Status, + &i.UpdatedAt, + &i.FlightStartTime, + &i.CurrentBuildingID, + &i.AssignedHangarID, + &i.ID, + &i.Name, + &i.MinLevel, + &i.OperationCostFuel, + &i.OperationCostCash, + &i.MaxPassengers, + &i.TravelTimeInseconds, + &i.OperationType, + &i.AircraftSize, + &i.AircraftType, + &i.BuyingCostCash, + &i.CompletedFlightExperience, + &i.CompletedFlightCashReward, + &i.CompletedFlightCargoReward, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetPlayerPlanesByUsername = `-- name: GetPlayerPlanesByUsername :many +SELECT pp.id AS player_plane_id, pp.id, pp.airport_id, pp.plane_id, pp.assigned_hangar_id, pp.current_building_id, pp.status, pp.updated_at, pp.flight_start_time +FROM player_planes pp +JOIN planes p ON pp.plane_id = p.id +WHERE pp.airport_id = (select id from airports where user_id = (select id from users where username = ?)) +` + +type GetPlayerPlanesByUsernameRow struct { + PlayerPlaneID uint32 `json:"player_plane_id"` + ID uint32 `json:"id"` + AirportID uint32 `json:"airport_id"` + PlaneID uint32 `json:"plane_id"` + AssignedHangarID uint32 `json:"assigned_hangar_id"` + CurrentBuildingID sql.NullInt32 `json:"current_building_id"` + Status PlayerPlanesStatus `json:"status"` + UpdatedAt sql.NullTime `json:"updated_at"` + FlightStartTime sql.NullTime `json:"flight_start_time"` +} + +func (q *Queries) GetPlayerPlanesByUsername(ctx context.Context, username string) ([]GetPlayerPlanesByUsernameRow, error) { + rows, err := q.db.QueryContext(ctx, GetPlayerPlanesByUsername, username) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetPlayerPlanesByUsernameRow{} + for rows.Next() { + var i GetPlayerPlanesByUsernameRow + if err := rows.Scan( + &i.PlayerPlaneID, + &i.ID, + &i.AirportID, + &i.PlaneID, + &i.AssignedHangarID, + &i.CurrentBuildingID, + &i.Status, + &i.UpdatedAt, + &i.FlightStartTime, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetProductById = `-- name: GetProductById :one +select id, name from products where id = ? +` + +func (q *Queries) GetProductById(ctx context.Context, id uint16) (Product, error) { + row := q.db.QueryRowContext(ctx, GetProductById, id) + var i Product + err := row.Scan(&i.ID, &i.Name) + return i, err +} + +const GetUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, password_hash +FROM users +WHERE username = ? LIMIT 1 +` + +type GetUserByUsernameRow struct { + ID int32 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` +} + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) { + row := q.db.QueryRowContext(ctx, GetUserByUsername, username) + var i GetUserByUsernameRow + err := row.Scan(&i.ID, &i.Username, &i.PasswordHash) + return i, err +} + +const GetUserIdByUsername = `-- name: GetUserIdByUsername :one +SELECT id +FROM users +WHERE username = ? LIMIT 1 +` + +func (q *Queries) GetUserIdByUsername(ctx context.Context, username string) (int32, error) { + row := q.db.QueryRowContext(ctx, GetUserIdByUsername, username) + var id int32 + err := row.Scan(&id) + return id, err +} + +const LogCompletedFlightRoute = `-- name: LogCompletedFlightRoute :exec +INSERT INTO completed_flight_routes_log (origin_airport_id, destination_airport_id, start_time, end_time) +VALUES (?, ?, ?, NOW()) +` + +type LogCompletedFlightRouteParams struct { + OriginAirportID uint32 `json:"origin_airport_id"` + DestinationAirportID uint32 `json:"destination_airport_id"` + StartTime time.Time `json:"start_time"` +} + +func (q *Queries) LogCompletedFlightRoute(ctx context.Context, arg LogCompletedFlightRouteParams) error { + _, err := q.db.ExecContext(ctx, LogCompletedFlightRoute, arg.OriginAirportID, arg.DestinationAirportID, arg.StartTime) + return err +} + +const LogCurrencyTransaction = `-- name: LogCurrencyTransaction :exec +INSERT INTO currency_transaction_logs (airport_id, amount_changed, currency_type, reason) +VALUES (?, ?, ?, ?) +` + +type LogCurrencyTransactionParams struct { + AirportID uint32 `json:"airport_id"` + AmountChanged int32 `json:"amount_changed"` + CurrencyType CurrencyTransactionLogsCurrencyType `json:"currency_type"` + Reason string `json:"reason"` +} + +func (q *Queries) LogCurrencyTransaction(ctx context.Context, arg LogCurrencyTransactionParams) error { + _, err := q.db.ExecContext(ctx, LogCurrencyTransaction, + arg.AirportID, + arg.AmountChanged, + arg.CurrencyType, + arg.Reason, + ) + return err +} + +const UpdateAirportCash = `-- name: UpdateAirportCash :exec +UPDATE airports +SET cash = ? +WHERE id = ? +` + +type UpdateAirportCashParams struct { + Cash uint64 `json:"cash"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateAirportCash(ctx context.Context, arg UpdateAirportCashParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportCash, arg.Cash, arg.ID) + return err +} + +const UpdateAirportCurrencies = `-- name: UpdateAirportCurrencies :exec + + +UPDATE airports +SET cash = ?, + current_passengers = ?, + current_fuel = ? +WHERE id = ? +` + +type UpdateAirportCurrenciesParams struct { + Cash uint64 `json:"cash"` + CurrentPassengers uint32 `json:"current_passengers"` + CurrentFuel uint32 `json:"current_fuel"` + ID uint32 `json:"id"` +} + +// airport resource management +func (q *Queries) UpdateAirportCurrencies(ctx context.Context, arg UpdateAirportCurrenciesParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportCurrencies, + arg.Cash, + arg.CurrentPassengers, + arg.CurrentFuel, + arg.ID, + ) + return err +} + +const UpdateAirportFuel = `-- name: UpdateAirportFuel :exec +UPDATE airports +SET current_fuel = ? +WHERE id = ? +` + +type UpdateAirportFuelParams struct { + CurrentFuel uint32 `json:"current_fuel"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateAirportFuel(ctx context.Context, arg UpdateAirportFuelParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportFuel, arg.CurrentFuel, arg.ID) + return err +} + +const UpdateAirportFuelRate = `-- name: UpdateAirportFuelRate :exec +UPDATE airports +SET fuel_generation_rate = ? +WHERE id = ? +` + +type UpdateAirportFuelRateParams struct { + FuelGenerationRate uint32 `json:"fuel_generation_rate"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateAirportFuelRate(ctx context.Context, arg UpdateAirportFuelRateParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportFuelRate, arg.FuelGenerationRate, arg.ID) + return err +} + +const UpdateAirportMaxFuel = `-- name: UpdateAirportMaxFuel :exec +UPDATE airports +SET max_fuel_capacity = ? +WHERE id = ? +` + +type UpdateAirportMaxFuelParams struct { + MaxFuelCapacity uint32 `json:"max_fuel_capacity"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateAirportMaxFuel(ctx context.Context, arg UpdateAirportMaxFuelParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportMaxFuel, arg.MaxFuelCapacity, arg.ID) + return err +} + +const UpdateAirportPassengerRate = `-- name: UpdateAirportPassengerRate :exec +UPDATE airports +SET passenger_generation_rate = ? +WHERE id = ? +` + +type UpdateAirportPassengerRateParams struct { + PassengerGenerationRate uint32 `json:"passenger_generation_rate"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateAirportPassengerRate(ctx context.Context, arg UpdateAirportPassengerRateParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportPassengerRate, arg.PassengerGenerationRate, arg.ID) + return err +} + +const UpdateAirportResources = `-- name: UpdateAirportResources :exec +UPDATE airports +SET + current_fuel = LEAST( + max_fuel_capacity, + current_fuel + (fuel_generation_rate * (TIMESTAMPDIFF(SECOND, resources_last_updated_at, NOW()) DIV 60)) + ), + current_passengers = LEAST( + max_passenger_capacity, + current_passengers + (passenger_generation_rate * (TIMESTAMPDIFF(SECOND, resources_last_updated_at, NOW()) DIV 60)) + ), + resources_last_updated_at = resources_last_updated_at + + INTERVAL (TIMESTAMPDIFF(SECOND, resources_last_updated_at, NOW()) DIV 60) MINUTE +WHERE id = ? +` + +func (q *Queries) UpdateAirportResources(ctx context.Context, id uint32) error { + _, err := q.db.ExecContext(ctx, UpdateAirportResources, id) + return err +} + +const UpdateAirportResourcesUpdated = `-- name: UpdateAirportResourcesUpdated :exec +update airports +set resources_last_updated_at = ? +where id = ? +` + +type UpdateAirportResourcesUpdatedParams struct { + ResourcesLastUpdatedAt time.Time `json:"resources_last_updated_at"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateAirportResourcesUpdated(ctx context.Context, arg UpdateAirportResourcesUpdatedParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportResourcesUpdated, arg.ResourcesLastUpdatedAt, arg.ID) + return err +} + +const UpdateAirportmaxPassengers = `-- name: UpdateAirportmaxPassengers :exec +UPDATE airports +SET max_passenger_capacity = ? +WHERE id = ? +` + +type UpdateAirportmaxPassengersParams struct { + MaxPassengerCapacity uint32 `json:"max_passenger_capacity"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateAirportmaxPassengers(ctx context.Context, arg UpdateAirportmaxPassengersParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportmaxPassengers, arg.MaxPassengerCapacity, arg.ID) + return err +} + +const UpdateAirportpassengers = `-- name: UpdateAirportpassengers :exec +UPDATE airports +SET current_passengers = ? +WHERE id = ? +` + +type UpdateAirportpassengersParams struct { + CurrentPassengers uint32 `json:"current_passengers"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateAirportpassengers(ctx context.Context, arg UpdateAirportpassengersParams) error { + _, err := q.db.ExecContext(ctx, UpdateAirportpassengers, arg.CurrentPassengers, arg.ID) + return err +} + +const UpdateContractDelivery = `-- name: UpdateContractDelivery :exec +UPDATE airport_contracts +SET current_amount_delivered = current_amount_delivered + ? +WHERE id = ? AND is_completed = FALSE +` + +type UpdateContractDeliveryParams struct { + CurrentAmountDelivered uint32 `json:"current_amount_delivered"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdateContractDelivery(ctx context.Context, arg UpdateContractDeliveryParams) error { + _, err := q.db.ExecContext(ctx, UpdateContractDelivery, arg.CurrentAmountDelivered, arg.ID) + return err +} + +const UpdatePlaneState = `-- name: UpdatePlaneState :exec +UPDATE player_planes +SET current_building_id = ?, + status = ?, + + flight_start_time = ? +WHERE id = ? +` + +type UpdatePlaneStateParams struct { + CurrentBuildingID sql.NullInt32 `json:"current_building_id"` + Status PlayerPlanesStatus `json:"status"` + FlightStartTime sql.NullTime `json:"flight_start_time"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdatePlaneState(ctx context.Context, arg UpdatePlaneStateParams) error { + _, err := q.db.ExecContext(ctx, UpdatePlaneState, + arg.CurrentBuildingID, + arg.Status, + arg.FlightStartTime, + arg.ID, + ) + return err +} + +const UpdatePlayerPlaneState = `-- name: UpdatePlayerPlaneState :exec +UPDATE player_planes +SET current_building_id = ?, + status = ?, + flight_start_time = ? +WHERE id = ? +` + +type UpdatePlayerPlaneStateParams struct { + CurrentBuildingID sql.NullInt32 `json:"current_building_id"` + Status PlayerPlanesStatus `json:"status"` + FlightStartTime sql.NullTime `json:"flight_start_time"` + ID uint32 `json:"id"` +} + +func (q *Queries) UpdatePlayerPlaneState(ctx context.Context, arg UpdatePlayerPlaneStateParams) error { + _, err := q.db.ExecContext(ctx, UpdatePlayerPlaneState, + arg.CurrentBuildingID, + arg.Status, + arg.FlightStartTime, + arg.ID, + ) + return err +} + +const UpdateUserPasswordByUserName = `-- name: UpdateUserPasswordByUserName :exec +UPDATE users +SET password_hash = ? +WHERE username = ? +` + +type UpdateUserPasswordByUserNameParams struct { + PasswordHash string `json:"password_hash"` + Username string `json:"username"` +} + +func (q *Queries) UpdateUserPasswordByUserName(ctx context.Context, arg UpdateUserPasswordByUserNameParams) error { + _, err := q.db.ExecContext(ctx, UpdateUserPasswordByUserName, arg.PasswordHash, arg.Username) + return err +} + +const AddBuilding = `-- name: addBuilding :execresult +INSERT INTO buildings (name,min_level,construction_cost_cash) +VALUES (?, ?, ?) +` + +type addBuildingParams struct { + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + ConstructionCostCash uint32 `json:"construction_cost_cash"` +} + +func (q *Queries) addBuilding(ctx context.Context, arg addBuildingParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddBuilding, arg.Name, arg.MinLevel, arg.ConstructionCostCash) +} + +const AddOperationalBuilding = `-- name: addOperationalBuilding :execresult +INSERT INTO buildings (name,min_level,construction_cost_cash,operation_type,operation_size) +VALUES (?, ?, ?, ?, ?) +` + +type addOperationalBuildingParams struct { + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + ConstructionCostCash uint32 `json:"construction_cost_cash"` + OperationType NullBuildingsOperationType `json:"operation_type"` + OperationSize NullBuildingsOperationSize `json:"operation_size"` +} + +func (q *Queries) addOperationalBuilding(ctx context.Context, arg addOperationalBuildingParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddOperationalBuilding, + arg.Name, + arg.MinLevel, + arg.ConstructionCostCash, + arg.OperationType, + arg.OperationSize, + ) +} + +const AddPassiveProductionBuilding = `-- name: addPassiveProductionBuilding :execresult +INSERT INTO buildings (name,min_level,construction_cost_cash,passive_type,passive_rate) +VALUES (?, ?, ?, ?, ?) +` + +type addPassiveProductionBuildingParams struct { + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + ConstructionCostCash uint32 `json:"construction_cost_cash"` + PassiveType NullBuildingsPassiveType `json:"passive_type"` + PassiveRate sql.NullInt32 `json:"passive_rate"` +} + +func (q *Queries) addPassiveProductionBuilding(ctx context.Context, arg addPassiveProductionBuildingParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddPassiveProductionBuilding, + arg.Name, + arg.MinLevel, + arg.ConstructionCostCash, + arg.PassiveType, + arg.PassiveRate, + ) +} + +const AddPlane = `-- name: addPlane :execresult + +INSERT INTO planes (name, aircraft_size,operation_type,aircraft_type ,min_level, operation_cost_fuel, operation_cost_cash, max_passengers, travel_time_inSeconds,buying_cost_cash, completed_flight_experience, completed_flight_cash_reward, completed_flight_cargo_reward) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type addPlaneParams struct { + Name string `json:"name"` + AircraftSize PlanesAircraftSize `json:"aircraft_size"` + OperationType PlanesOperationType `json:"operation_type"` + AircraftType PlanesAircraftType `json:"aircraft_type"` + MinLevel uint32 `json:"min_level"` + OperationCostFuel uint32 `json:"operation_cost_fuel"` + OperationCostCash uint32 `json:"operation_cost_cash"` + MaxPassengers sql.NullInt32 `json:"max_passengers"` + TravelTimeInseconds uint32 `json:"travel_time_inseconds"` + BuyingCostCash uint32 `json:"buying_cost_cash"` + CompletedFlightExperience uint32 `json:"completed_flight_experience"` + CompletedFlightCashReward sql.NullInt32 `json:"completed_flight_cash_reward"` + CompletedFlightCargoReward sql.NullInt32 `json:"completed_flight_cargo_reward"` +} + +// planes & fleet management +func (q *Queries) addPlane(ctx context.Context, arg addPlaneParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddPlane, + arg.Name, + arg.AircraftSize, + arg.OperationType, + arg.AircraftType, + arg.MinLevel, + arg.OperationCostFuel, + arg.OperationCostCash, + arg.MaxPassengers, + arg.TravelTimeInseconds, + arg.BuyingCostCash, + arg.CompletedFlightExperience, + arg.CompletedFlightCashReward, + arg.CompletedFlightCargoReward, + ) +} + +const AddPlaneToAirportByUsername = `-- name: addPlaneToAirportByUsername :execresult +INSERT INTO player_planes (airport_id, plane_id, assigned_hangar_id, current_building_id, status) +VALUES ((select id from airports where user_id = (select id from users where username = ?)), ?, ?, ?, 'idle') +` + +type addPlaneToAirportByUsernameParams struct { + Username string `json:"username"` + PlaneID uint32 `json:"plane_id"` + AssignedHangarID uint32 `json:"assigned_hangar_id"` + CurrentBuildingID sql.NullInt32 `json:"current_building_id"` +} + +func (q *Queries) addPlaneToAirportByUsername(ctx context.Context, arg addPlaneToAirportByUsernameParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddPlaneToAirportByUsername, + arg.Username, + arg.PlaneID, + arg.AssignedHangarID, + arg.CurrentBuildingID, + ) +} + +const AddStorageBuilding = `-- name: addStorageBuilding :execresult +INSERT INTO buildings (name,min_level,construction_cost_cash,capacity,storage_type,storage_subtype) +VALUES (?, ?, ?, ?, ?, ?) +` + +type addStorageBuildingParams struct { + Name string `json:"name"` + MinLevel uint32 `json:"min_level"` + ConstructionCostCash uint32 `json:"construction_cost_cash"` + Capacity sql.NullInt32 `json:"capacity"` + StorageType NullBuildingsStorageType `json:"storage_type"` + StorageSubtype NullBuildingsStorageSubtype `json:"storage_subtype"` +} + +func (q *Queries) addStorageBuilding(ctx context.Context, arg addStorageBuildingParams) (sql.Result, error) { + return q.db.ExecContext(ctx, AddStorageBuilding, + arg.Name, + arg.MinLevel, + arg.ConstructionCostCash, + arg.Capacity, + arg.StorageType, + arg.StorageSubtype, + ) +} + +const UpdatePlayerExperience = `-- name: updatePlayerExperience :exec +UPDATE airports +SET experience_points = ? +WHERE id = ? +` + +type updatePlayerExperienceParams struct { + ExperiencePoints uint64 `json:"experience_points"` + ID uint32 `json:"id"` +} + +func (q *Queries) updatePlayerExperience(ctx context.Context, arg updatePlayerExperienceParams) error { + _, err := q.db.ExecContext(ctx, UpdatePlayerExperience, arg.ExperiencePoints, arg.ID) + return err +} + +const UpdatePlayerLevel = `-- name: updatePlayerLevel :exec +UPDATE airports +SET player_level = ? +WHERE id = ? +` + +type updatePlayerLevelParams struct { + PlayerLevel uint32 `json:"player_level"` + ID uint32 `json:"id"` +} + +func (q *Queries) updatePlayerLevel(ctx context.Context, arg updatePlayerLevelParams) error { + _, err := q.db.ExecContext(ctx, UpdatePlayerLevel, arg.PlayerLevel, arg.ID) + return err +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..5c7ee61 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,258 @@ +package model + +import ( + "database/sql" + "time" +) + +// ─── User ──────────────────────────────────────────────────────────────────── + +type User struct { + ID int32 + Username string + PasswordHash string + CreatedAt time.Time + DeletedAt sql.NullTime +} + +// ─── Airport ───────────────────────────────────────────────────────────────── + +type Airport struct { + ID uint32 + Name string + UserID uint32 + OriginCountryID uint16 + Cash uint64 + CurrentPassengers uint32 + MaxPassengerCapacity uint32 + CurrentFuel uint32 + MaxFuelCapacity uint32 + FuelGenerationRate uint32 + PassengerGenerationRate uint32 + ResourcesLastUpdatedAt time.Time + IsAvailable bool + IsCentral bool + PlayerLevel uint32 + ExperiencePoints uint64 + StorageCap uint32 + OperationCap uint32 + IsDeleted bool +} + +// CentralAirport is a system-owned destination port (one per continent), shown +// in the Fleet dispatch picker. Joined with its owning faction for flavour. +type CentralAirport struct { + AirportID uint32 + Continent string + Name string + FactionName string + LeaderName string +} + +// FacilityStatus is the occupancy of one operation-building class (a runway or +// service bay of a given aircraft size): how many exist vs. how many are busy. +type FacilityStatus struct { + OperationType string // "runway" | "service" + Size string // "small" | "medium" | "large" | "huge" + Total int + Busy int +} + +// Faction is the NPC owner of a continent's central port — lore / future content. +type Faction struct { + ID uint32 + Name string + Continent string + LeaderName string + Background string + UserID int32 + CentralAirportID uint32 +} + +// ─── Country ───────────────────────────────────────────────────────────────── + +type Country struct { + ID uint16 + Name string +} + +// ─── Plane (template) ──────────────────────────────────────────────────────── + +type Plane struct { + ID uint32 + Name string + MinLevel uint32 + OperationCostFuel uint32 + OperationCostCash uint32 + MaxPassengers sql.NullInt32 + TravelTimeInSeconds uint32 + OperationType string // "passenger" | "cargo" + AircraftSize string // "small" | "medium" | "large" | "huge" + AircraftType string // "plane" | "helicopter" | "seaplane" | "spaceship" + BuyingCostCash uint32 + CompletedFlightExperience uint32 + CompletedFlightCashReward sql.NullInt32 + CompletedFlightCargoReward sql.NullInt32 + BoardingTimeSec uint32 + FuelingTimeSec uint32 +} + +// ─── Player Plane ───────────────────────────────────────────────────────────── + +type PlayerPlane struct { + ID uint32 + AirportID uint32 + PlaneID uint32 + AssignedHangarID uint32 + CurrentBuildingID sql.NullInt32 + Status string // "idle"|"maintenance"|"boarding"|"taxiing"|"flying"|"unloading" + UpdatedAt time.Time + PurchasedAt time.Time + FlightStartTime sql.NullTime + DestinationAirportID sql.NullInt32 + DestinationName sql.NullString + + // Joined from planes + PlaneName string + AircraftSize string + AircraftType string + OperationType string + TravelTimeSec uint32 + FuelCost uint32 + BuyingCost uint32 + MaxPassengers sql.NullInt32 + CashReward sql.NullInt32 + ExpReward uint32 + BoardingTimeSec uint32 + FuelingTimeSec uint32 + + // RemainingSec is the seconds left in the current timed phase, computed + // server-side (MySQL NOW()) in GetFleet so the countdown shares the exact + // clock used by the landing / service-advance checks. Avoids Go-vs-MySQL + // clock skew that made planes land early. + RemainingSec int +} + +// TimeRemainingSeconds returns seconds left in the current timed phase +// (boarding, taxiing/fueling, or flying). The value is computed in SQL +// (see GetFleet) so it agrees with the DB-side landing check. +func (p *PlayerPlane) TimeRemainingSeconds() int { + if p.RemainingSec < 0 { + return 0 + } + return p.RemainingSec +} + +// ─── Leveling ───────────────────────────────────────────────────────────────── +// +// XP is cumulative. Advancing from level L to L+1 costs 500*(L+1) XP, so: +// L0→L1 = 500, L1→L2 = 1000, L2→L3 = 1500 … +// Total XP to reach the start of level L = 500 * L*(L+1)/2. + +// XPToNextLevel is the XP span of the current level (level → level+1). +func XPToNextLevel(level uint32) int { + return 500 * (int(level) + 1) +} + +// cumulativeXP is the total XP required to be at the start of `level`. +func cumulativeXP(level uint32) uint64 { + l := uint64(level) + return 500 * l * (l + 1) / 2 +} + +// LevelForXP returns the level that the given total XP corresponds to. +func LevelForXP(xp uint64) uint32 { + level := uint32(0) + for cumulativeXP(level+1) <= xp { + level++ + } + return level +} + +// XPIntoLevel returns how much of the current level's span has been earned. +func XPIntoLevel(level uint32, xp uint64) int { + base := cumulativeXP(level) + if xp < base { + return 0 + } + return int(xp - base) +} + +// ─── Building ───────────────────────────────────────────────────────────────── + +type Building struct { + ID uint32 + Name string + MinLevel uint32 + ConstructionCostCash uint32 + UpgradeTier uint8 + BuildingType string + // storage + Capacity sql.NullInt32 + StorageType sql.NullString + StorageSubtype sql.NullString + // operation + OperationSize sql.NullString + OperationType sql.NullString + // passive + PassiveType sql.NullString + PassiveRate sql.NullInt32 +} + +// AirportBuilding is an instance owned by a player +type AirportBuilding struct { + ID uint32 + AirportID uint32 + BuildingID uint32 + Level uint32 + + // Joined from buildings + Name string + BuildingType string + ConstructionCostCash uint32 + OperationSize sql.NullString + OperationType sql.NullString + Capacity sql.NullInt32 + StorageType sql.NullString +} + +// ─── Product ────────────────────────────────────────────────────────────────── + +type Product struct { + ID uint16 + Name string +} + +// ─── Contract ──────────────────────────────────────────────────────────────── + +type Contract struct { + ID uint32 + Title string + RequiredProductID uint16 + RequiredAmount uint32 + CurrentAmountDelivered uint32 + IsCompleted bool + ExpiresAt time.Time + // Joined + ProductName string +} + +func (c *Contract) ProgressPct() int { + if c.RequiredAmount == 0 { + return 100 + } + return int((c.CurrentAmountDelivered * 100) / c.RequiredAmount) +} + +// ─── App state helpers ──────────────────────────────────────────────────────── + +// Tab indices +const ( + TabAirport = 0 + TabFleet = 1 + TabBuildings = 2 + TabContracts = 3 + TabShop = 4 +) + +var TabNames = []string{"✈ Airport", "🛩 Fleet", "🏗 Buildings", "📋 Contracts", "🛒 Shop"} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..07309b5 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,317 @@ +// Package service is the game's business-logic / orchestration layer. It sits +// between the presentation layer (internal/ui) and the data-access layer +// (internal/db): the UI depends only on GameService and never touches db +// directly, while db owns all SQL and transactions. GameService composes db +// operations, makes the business decisions the UI used to make, and runs the +// RAM-first dispatch queue with a background SQL worker. +package service + +import ( + "context" + "database/sql" + "fmt" + "sync" + "time" + + "skyrama-tui/internal/db" + "skyrama-tui/internal/model" +) + +// pendingDispatch is one plane queued (in RAM) to be launched to a central port. +// The background worker persists it to SQL as bays/runways free up. +type pendingDispatch struct { + airportID uint32 + plane model.PlayerPlane + destID uint32 + destName string +} + +// GameService wraps *db.Client. The mutex guards the in-RAM dispatch state +// (queue, notes, central-port name cache) shared with the background worker. +type GameService struct { + db *db.Client + mu sync.Mutex + queue []pendingDispatch + notes map[uint32]string // airportID → last hard-failure note (cleared on read) + central map[uint32]string // central airport id → display name (lazy cache) + cancel context.CancelFunc +} + +// NewGameService wraps the db client and starts the background dispatch worker. +func NewGameService(client *db.Client) *GameService { + ctx, cancel := context.WithCancel(context.Background()) + s := &GameService{ + db: client, + notes: make(map[uint32]string), + cancel: cancel, + } + go s.worker(ctx) + return s +} + +// Close stops the background worker and closes the underlying db connection. +func (s *GameService) Close() { + s.cancel() + s.db.Close() +} + +// ─── Pass-throughs (pure delegation; names match db.Client) ──────────────────── + +func (s *GameService) Login(username, password string) (int32, error) { + return s.db.Login(username, password) +} +func (s *GameService) Register(username, password string) (int64, error) { + return s.db.Register(username, password) +} + +func (s *GameService) GetAirportByUserID(userID int32) (*model.Airport, error) { + return s.db.GetAirportByUserID(userID) +} +func (s *GameService) RefreshResources(airportID uint32) error { + return s.db.RefreshResources(airportID) +} +func (s *GameService) GetAllCountries() ([]model.Country, error) { return s.db.GetAllCountries() } +func (s *GameService) GetCentralAirports() ([]model.CentralAirport, error) { + return s.db.GetCentralAirports() +} +func (s *GameService) GetAirportBuildings(airportID uint32) ([]model.AirportBuilding, error) { + return s.db.GetAirportBuildings(airportID) +} +func (s *GameService) GetAllBuildings() ([]model.Building, error) { return s.db.GetAllBuildings() } +func (s *GameService) BuildingCounts(airportID uint32) (int, int, error) { + return s.db.BuildingCounts(airportID) +} +func (s *GameService) GetCaps(airportID uint32) (uint32, uint32, error) { + return s.db.GetCaps(airportID) +} +func (s *GameService) GetActiveContracts(airportID uint32) ([]model.Contract, error) { + return s.db.GetActiveContracts(airportID) +} +func (s *GameService) GetAllPlanes() ([]model.Plane, error) { return s.db.GetAllPlanes() } +func (s *GameService) SellPlane(airportID, playerPlaneID uint32) (int64, error) { + return s.db.SellPlane(airportID, playerPlaneID) +} +func (s *GameService) ConstructBuilding(airportID, buildingID uint32) error { + return s.db.ConstructBuilding(airportID, buildingID) +} +func (s *GameService) DemolishBuilding(airportID, airportBuildingID uint32) (int64, error) { + return s.db.DemolishBuilding(airportID, airportBuildingID) +} +func (s *GameService) BuyCapUpgrade(airportID uint32, buildingType string) error { + return s.db.BuyCapUpgrade(airportID, buildingType) +} +func (s *GameService) AdvanceServiceStages(airportID uint32) error { + return s.db.AdvanceServiceStages(airportID) +} +func (s *GameService) Facilities(airportID uint32) ([]model.FacilityStatus, error) { + return s.db.GetFacilities(airportID) +} + +// ─── Orchestration (was in the UI) ───────────────────────────────────────────── + +// BuyPlane finds a free hangar then buys the plane into it (one UI intent). +func (s *GameService) BuyPlane(airportID, planeID uint32) error { + hangarID, err := s.db.FindAvailableHangar(airportID) + if err != nil { + return err + } + return s.db.BuyPlane(airportID, planeID, hangarID, hangarID) +} + +// LandReadyPlanes lands every plane whose flight has elapsed, returning how many +// landed and the total cash reward (replaces the UI's land loop). +func (s *GameService) LandReadyPlanes(airportID uint32) (int, int64, error) { + ready, err := s.db.GetPlanesReadyToLand(airportID) + if err != nil { + return 0, 0, err + } + var cash int64 + landed := 0 + for _, pp := range ready { + if err := s.db.LandFlight(airportID, pp); err != nil { + return landed, cash, err + } + landed++ + if pp.CashReward.Valid { + cash += int64(pp.CashReward.Int32) + } + } + return landed, cash, nil +} + +// ─── RAM-first dispatch ──────────────────────────────────────────────────────── + +// DispatchFlights accepts a dispatch instantly into the RAM queue (no SQL yet) +// and returns how many idle planes were queued. The background worker launches +// them as bays/runways free. UI feedback is immediate; SQL follows. +func (s *GameService) DispatchFlights(airportID uint32, planes []model.PlayerPlane, destID uint32) int { + name := s.centralName(destID) + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for i := range planes { + if planes[i].Status != "idle" { + continue + } + s.queue = append(s.queue, pendingDispatch{ + airportID: airportID, + plane: planes[i], + destID: destID, + destName: name, + }) + n++ + } + return n +} + +// GetFleet returns the DB fleet with the pending dispatch queue overlaid: planes +// still idle in SQL but queued in RAM are surfaced with status "queued" + their +// destination, so they leave the Waiting pool the instant a dispatch is accepted. +func (s *GameService) GetFleet(airportID uint32) ([]model.PlayerPlane, error) { + fleet, err := s.db.GetFleet(airportID) + if err != nil { + return nil, err + } + s.mu.Lock() + queued := make(map[uint32]string) + for _, p := range s.queue { + if p.airportID == airportID { + queued[p.plane.ID] = p.destName + } + } + s.mu.Unlock() + if len(queued) == 0 { + return fleet, nil + } + for i := range fleet { + if fleet[i].Status != "idle" { + continue + } + if name, ok := queued[fleet[i].ID]; ok { + fleet[i].Status = "queued" + fleet[i].DestinationName = sql.NullString{String: name, Valid: true} + } + } + return fleet, nil +} + +// CancelQueue drops all not-yet-launched dispatches for an airport from the RAM +// queue and returns how many were removed. Planes the worker already launched +// (now boarding/flying in SQL) are unaffected. +func (s *GameService) CancelQueue(airportID uint32) int { + s.mu.Lock() + defer s.mu.Unlock() + kept := s.queue[:0] + removed := 0 + for _, p := range s.queue { + if p.airportID == airportID { + removed++ + continue + } + kept = append(kept, p) + } + s.queue = kept + return removed +} + +// DispatchNote returns (and clears) the last hard-failure note for an airport, +// e.g. a queued plane that couldn't launch because of insufficient fuel. +func (s *GameService) DispatchNote(airportID uint32) string { + s.mu.Lock() + defer s.mu.Unlock() + note := s.notes[airportID] + delete(s.notes, airportID) + return note +} + +// centralName resolves a central airport id to its display name (lazy-cached). +func (s *GameService) centralName(destID uint32) string { + s.mu.Lock() + cached := s.central + s.mu.Unlock() + if cached == nil { + cached = make(map[uint32]string) + if cs, err := s.db.GetCentralAirports(); err == nil { + for _, c := range cs { + cached[c.AirportID] = c.Name + } + } + s.mu.Lock() + s.central = cached + s.mu.Unlock() + } + return cached[destID] +} + +// ─── Background worker ────────────────────────────────────────────────────────── + +func (s *GameService) worker(ctx context.Context) { + t := time.NewTicker(time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.drainQueue() + } + } +} + +// drainQueue tries to launch every queued plane once. Planes blocked only by +// bay/runway contention are re-queued for the next tick; planes that fail a +// validation check (fuel/passengers) are dropped with a note. +func (s *GameService) drainQueue() { + s.mu.Lock() + pending := s.queue + s.queue = nil + s.mu.Unlock() + if len(pending) == 0 { + return + } + + var retry []pendingDispatch + for _, p := range pending { + launched, retryable, note := s.tryLaunch(p) + switch { + case launched: + // persisted to SQL; drop from queue + case retryable: + retry = append(retry, p) + default: + s.mu.Lock() + s.notes[p.airportID] = note + s.mu.Unlock() + } + } + + if len(retry) > 0 { + s.mu.Lock() + s.queue = append(retry, s.queue...) // keep retries ahead of newly-queued + s.mu.Unlock() + } +} + +// tryLaunch attempts one queued plane. Returns (launched, retryable, note): +// retryable=true means "no bay/runway free, try again later". +func (s *GameService) tryLaunch(p pendingDispatch) (bool, bool, string) { + pl := p.plane + if pl.BoardingTimeSec > 0 || pl.FuelingTimeSec > 0 { + bay, err := s.db.FindAvailableServiceBay(p.airportID, pl.AircraftSize) + if err != nil { + return false, true, "" // contention (or no bay yet) → retry + } + if err := s.db.StartBoarding(p.airportID, pl.ID, pl.FuelCost, bay, p.destID); err != nil { + return false, false, fmt.Sprintf("couldn't dispatch %s: %v", pl.PlaneName, err) + } + return true, false, "" + } + runway, err := s.db.FindAvailableRunway(p.airportID, pl.AircraftSize) + if err != nil { + return false, true, "" + } + if err := s.db.LaunchFlight(p.airportID, pl.ID, pl.FuelCost, runway, p.destID); err != nil { + return false, false, fmt.Sprintf("couldn't dispatch %s: %v", pl.PlaneName, err) + } + return true, false, "" +} diff --git a/internal/style/style.go b/internal/style/style.go new file mode 100644 index 0000000..9333aa8 --- /dev/null +++ b/internal/style/style.go @@ -0,0 +1,182 @@ +package style + +import "github.com/charmbracelet/lipgloss" + +// Palette +const ( + ColorSky = lipgloss.Color("#00BFFF") + ColorRunway = lipgloss.Color("#4A4A4A") + ColorGold = lipgloss.Color("#FFD700") + ColorGreen = lipgloss.Color("#39D353") + ColorRed = lipgloss.Color("#FF4444") + ColorMuted = lipgloss.Color("#626262") + ColorWhite = lipgloss.Color("#F5F5F5") + ColorDark = lipgloss.Color("#1A1A2E") + ColorPanel = lipgloss.Color("#16213E") + ColorHighlight = lipgloss.Color("#0F3460") + ColorAccent = lipgloss.Color("#E94560") + ColorFuel = lipgloss.Color("#FF8C00") + ColorPassenger = lipgloss.Color("#7EC8E3") + ColorCash = lipgloss.Color("#FFD700") +) + +// Base Styles +var ( + Bold = lipgloss.NewStyle().Bold(true) + + Title = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorSky). + BorderStyle(lipgloss.DoubleBorder()). + BorderForeground(ColorSky). + Padding(0, 2). + Align(lipgloss.Center) + + Subtitle = lipgloss.NewStyle(). + Foreground(ColorMuted). + Italic(true) + + // Panels + Panel = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorHighlight). + Padding(0, 1) + + PanelFocused = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorSky). + Padding(0, 1) + + PanelHeader = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorSky). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(ColorHighlight). + MarginBottom(1) + + // Status bar + StatusBar = lipgloss.NewStyle(). + Background(ColorHighlight). + Foreground(ColorWhite). + Padding(0, 1) + + StatusBarKey = lipgloss.NewStyle(). + Background(ColorAccent). + Foreground(ColorWhite). + Padding(0, 1). + Bold(true) + + // Resource chips + FuelChip = lipgloss.NewStyle(). + Foreground(ColorFuel). + Bold(true) + + PassengerChip = lipgloss.NewStyle(). + Foreground(ColorPassenger). + Bold(true) + + CashChip = lipgloss.NewStyle(). + Foreground(ColorCash). + Bold(true) + + // Menu / list items + MenuItem = lipgloss.NewStyle(). + Foreground(ColorWhite). + PaddingLeft(2) + + MenuItemSelected = lipgloss.NewStyle(). + Foreground(ColorSky). + Bold(true). + PaddingLeft(1). + SetString("> ") + + // Plane status colors + StatusIdle = lipgloss.NewStyle().Foreground(ColorGreen) + StatusFly = lipgloss.NewStyle().Foreground(ColorSky) + StatusMaint = lipgloss.NewStyle().Foreground(ColorGold) + StatusBoard = lipgloss.NewStyle().Foreground(ColorPassenger) + StatusTaxi = lipgloss.NewStyle().Foreground(ColorFuel) + + // Table + TableHeader = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorSky). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(ColorMuted) + + TableRow = lipgloss.NewStyle().Foreground(ColorWhite) + + TableRowAlt = lipgloss.NewStyle(). + Foreground(ColorWhite). + Background(lipgloss.Color("#0D1B2A")) + + // Alerts + Success = lipgloss.NewStyle(). + Foreground(ColorGreen). + Bold(true). + SetString("✓ ") + + Error = lipgloss.NewStyle(). + Foreground(ColorRed). + Bold(true). + SetString("✗ ") + + Warning = lipgloss.NewStyle(). + Foreground(ColorGold). + Bold(true). + SetString("⚠ ") + + Info = lipgloss.NewStyle(). + Foreground(ColorSky). + Bold(true). + SetString("ℹ ") + + // Help text + HelpKey = lipgloss.NewStyle().Foreground(ColorSky).Bold(true) + HelpDesc = lipgloss.NewStyle().Foreground(ColorMuted) + + // Muted / dimmed + Muted = lipgloss.NewStyle().Foreground(ColorMuted) +) + +// Plane status badge +func PlaneStatusStyle(status string) lipgloss.Style { + switch status { + case "idle": + return StatusIdle + case "flying": + return StatusFly + case "maintenance": + return StatusMaint + case "boarding": + return StatusBoard + case "taxiing": + return StatusTaxi + default: + return Muted + } +} + +// Resource bar (e.g. fuel/passenger capacity) +func ResourceBar(current, max int, width int, color lipgloss.Color) string { + if max == 0 { + return "" + } + filled := (current * width) / max + if filled > width { + filled = width + } + bar := lipgloss.NewStyle().Foreground(color).Render(repeat("█", filled)) + + lipgloss.NewStyle().Foreground(ColorMuted).Render(repeat("░", width-filled)) + return bar +} + +func repeat(s string, n int) string { + result := "" + for i := 0; i < n; i++ { + result += s + } + return result +} diff --git a/internal/ui/airport_view.go b/internal/ui/airport_view.go new file mode 100644 index 0000000..f68e458 --- /dev/null +++ b/internal/ui/airport_view.go @@ -0,0 +1,125 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "skyrama-tui/internal/model" + "skyrama-tui/internal/style" +) + +// AirportView renders the full airport overview panel. +// Uses a single panel with two sections to avoid nested-panel line-count overhead. +func AirportView(airport *model.Airport, country string, width int) string { + if airport == nil { + return lipgloss.JoinVertical(lipgloss.Left, + style.PanelHeader.Render("🛫 Airport"), + "", + style.Muted.Render("No airport found for your account."), + style.Muted.Render("Ask an admin to create one, or use the db_manager tool."), + ) + } + + barW := 22 + + fuelBar := style.ResourceBar( + int(airport.CurrentFuel), atLeast1(int(airport.MaxFuelCapacity)), barW, style.ColorFuel) + fuelLine := row( + style.FuelChip.Width(14).Render("⛽ Fuel"), + fuelBar, + style.Muted.Render(fmt.Sprintf(" %d / %d", airport.CurrentFuel, airport.MaxFuelCapacity)), + style.Muted.Render(fmt.Sprintf(" +%d/min", airport.FuelGenerationRate)), + ) + + paxBar := style.ResourceBar( + int(airport.CurrentPassengers), atLeast1(int(airport.MaxPassengerCapacity)), barW, style.ColorPassenger) + paxLine := row( + style.PassengerChip.Width(14).Render("👤 Passengers"), + paxBar, + style.Muted.Render(fmt.Sprintf(" %d / %d", airport.CurrentPassengers, airport.MaxPassengerCapacity)), + style.Muted.Render(fmt.Sprintf(" +%d/min", airport.PassengerGenerationRate)), + ) + + cashLine := style.CashChip.Render(fmt.Sprintf("💰 Cash $%s", formatCash(airport.Cash))) + + xpNeeded := model.XPToNextLevel(airport.PlayerLevel) + xpCurrent := model.XPIntoLevel(airport.PlayerLevel, airport.ExperiencePoints) + xpBar := style.ResourceBar(xpCurrent, atLeast1(xpNeeded), barW, style.ColorGreen) + xpLine := row( + style.Bold.Width(14).Render("✨ XP"), + xpBar, + style.Muted.Render(fmt.Sprintf(" %d / %d", xpCurrent, xpNeeded)), + ) + + avail := style.StatusIdle.Render("Open ✓") + if !airport.IsAvailable { + avail = style.StatusMaint.Render("Closed ✗") + } + + sinceSec := time.Since(airport.ResourcesLastUpdatedAt) + syncStr := "just now" + if sinceSec >= time.Second { + syncStr = sinceSec.Round(time.Second).String() + " ago" + } + + // Single panel containing both sections — avoids double-border line overhead. + panel := style.Panel.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, + style.PanelHeader.Render("Resources"), + fuelLine, + paxLine, + cashLine, + xpLine, + "", + style.PanelHeader.Render("Airport Info"), + kv("Name", airport.Name), + kv("Country", country), + kv("Level", fmt.Sprintf("Lv.%d", airport.PlayerLevel)), + kv("Status", avail), + kv("Last sync", syncStr), + )) + + // 1-line header to keep total view height minimal. + header := lipgloss.JoinHorizontal(lipgloss.Top, + style.Bold.Foreground(style.ColorSky).Width(width/2).Render("🛫 "+airport.Name), + style.Muted.Width(width/2).Align(lipgloss.Right). + Render(fmt.Sprintf("Lv.%d 📍 %s", airport.PlayerLevel, country)), + ) + + return lipgloss.JoinVertical(lipgloss.Left, header, panel) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +func row(parts ...string) string { + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} + +func kv(key, val string) string { + return lipgloss.JoinHorizontal(lipgloss.Top, + style.Muted.Width(14).Render(key+":"), + val, + ) +} + +func formatCash(v uint64) string { + s := fmt.Sprintf("%d", v) + var b strings.Builder + for i, ch := range s { + if i > 0 && (len(s)-i)%3 == 0 { + b.WriteRune(',') + } + b.WriteRune(ch) + } + return b.String() +} + +// atLeast1 prevents division-by-zero in progress bar calculations. +func atLeast1(n int) int { + if n <= 0 { + return 1 + } + return n +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..8246a94 --- /dev/null +++ b/internal/ui/app.go @@ -0,0 +1,375 @@ +package ui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "skyrama-tui/internal/model" + "skyrama-tui/internal/service" + "skyrama-tui/internal/style" +) + +type tickMsg time.Time + +func tick() tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +type AirportFetchedMsg struct{ Airport *model.Airport } +type AirportFetchErrMsg struct{ Err error } +type CountriesFetchedMsg struct{ Countries []model.Country } + +type AppModel struct { + client *service.GameService + userID int32 + username string + + airport *model.Airport + countries map[uint16]string + + activeTab int + fleet FleetModel + buildings BuildingsModel + contracts ContractsModel + shop ShopModel + + width int + height int + globalErr string + loading bool +} + +func NewAppModel(client *service.GameService, userID int32, username string) AppModel { + return AppModel{ + client: client, + userID: userID, + username: username, + countries: make(map[uint16]string), + loading: true, + } +} + +func (m AppModel) Init() tea.Cmd { + return tea.Batch(m.fetchAirport(), m.fetchCountries(), tick(), m.fleet.LoadDestinations()) +} + +func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + h := m.contentHeight() + m.fleet.width = msg.Width + m.fleet.height = h + m.buildings.width = msg.Width + m.buildings.height = h + m.buildings.ownedList.SetSize(msg.Width-4, h-8) + m.buildings.shopList.SetSize(msg.Width-4, h-8) + m.shop.width = msg.Width + m.shop.height = h + m.shop.list.SetSize(msg.Width-4, h-7) + // return immediately — no need to forward resize to sub-models twice + return m, nil + + case tickMsg: + cmds = append(cmds, m.fetchAirport(), m.fleet.CheckLanding(), m.fleet.CheckServiceProgress(), tick()) + + case AirportFetchedMsg: + m.loading = false + m.airport = msg.Airport + m.globalErr = "" + if m.airport != nil { + m.shop.playerLevel = m.airport.PlayerLevel + m.shop.airportID = m.airport.ID + m.fleet.airportID = m.airport.ID + m.buildings.airportID = m.airport.ID + m.contracts.airportID = m.airport.ID + } + + case AirportFetchErrMsg: + m.loading = false + m.globalErr = msg.Err.Error() + + case CountriesFetchedMsg: + for _, c := range msg.Countries { + m.countries[c.ID] = c.Name + } + + // Fleet messages must always reach fleet regardless of the active tab, + // so auto-landing and fleet reloads work while the user browses other tabs. + case FlightLaunchedMsg, FlightLandedMsg, FleetLoadedMsg, FleetErrMsg, ServiceProgressMsg, PlaneSoldMsg, DispatchResultMsg, DestinationsLoadedMsg: + updated, cmd := m.fleet.Update(msg) + m.fleet = updated + cmds = append(cmds, cmd) + // Reload the airport header after resource-changing actions. + switch msg.(type) { + case FlightLandedMsg, PlaneSoldMsg, DispatchResultMsg: + cmds = append(cmds, m.fetchAirport()) + } + return m, tea.Batch(cmds...) + + // Buildings/shop result messages always reach their sub-model so a reload + // fires even if the user switched tabs, and refresh the airport header. + case BuildingDemolishedMsg, CapUpgradedMsg, BuildingConstructedMsg: + updated, cmd := m.buildings.Update(msg) + m.buildings = updated + cmds = append(cmds, cmd, m.fetchAirport()) + return m, tea.Batch(cmds...) + + case PlaneBoughtMsg: + updated, cmd := m.shop.Update(msg) + m.shop = updated + cmds = append(cmds, cmd, m.fetchAirport()) + return m, tea.Batch(cmds...) + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "q": + // Don't quit if a list sub-model is in filter mode, or the fleet + // dispatch modal is open (it consumes q to cancel). + if m.fleet.dispatchMode || + m.buildings.ownedList.FilterState() == list.Filtering || + m.buildings.shopList.FilterState() == list.Filtering || + m.shop.list.FilterState() == list.Filtering { + break + } + return m, tea.Quit + case "1": + return m.switchTab(model.TabAirport), nil + case "2": + m2 := m.switchTab(model.TabFleet) + return m2, m2.fleet.LoadFleet() + case "3": + m2 := m.switchTab(model.TabBuildings) + return m2, m2.buildings.Load() + case "4": + m2 := m.switchTab(model.TabContracts) + return m2, m2.contracts.Load() + case "5": + m2 := m.switchTab(model.TabShop) + return m2, m2.shop.Load() + case "tab": + next := (m.activeTab + 1) % len(model.TabNames) + m2 := m.switchTab(next) + return m2, m2.tabRefreshCmd(next) + case "shift+tab": + prev := (m.activeTab - 1 + len(model.TabNames)) % len(model.TabNames) + m2 := m.switchTab(prev) + return m2, m2.tabRefreshCmd(prev) + } + } + + switch m.activeTab { + case model.TabFleet: + updated, cmd := m.fleet.Update(msg) + m.fleet = updated + cmds = append(cmds, cmd) + case model.TabBuildings: + updated, cmd := m.buildings.Update(msg) + m.buildings = updated + cmds = append(cmds, cmd) + case model.TabContracts: + updated, cmd := m.contracts.Update(msg) + m.contracts = updated + cmds = append(cmds, cmd) + case model.TabShop: + updated, cmd := m.shop.Update(msg) + m.shop = updated + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m AppModel) View() string { + //if m.width == 0 { + // return "Initializing…" + //} + if m.loading { + return lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + style.PanelHeader.Render("Connecting to database…")) + } + + tabs := m.renderTabBar() + content := style.Panel. + Width(m.width - 2). + Height(m.contentHeight()). + MaxHeight(m.contentHeight() + 2). + Render(m.renderContent()) + view := lipgloss.JoinVertical(lipgloss.Left, tabs, content, m.renderStatusBar()) + // Clamp to the terminal height so an over-tall tab can never push the frame + // past the screen and leave stale lines from the previous tab visible. + if m.height > 0 { + view = lipgloss.NewStyle().MaxHeight(m.height).Render(view) + } + return view +} + +func (m AppModel) contentHeight() int { + h := m.height - 4 + if h < 4 { + h = 4 + } + return h +} + +func (m AppModel) renderTabBar() string { + var tabs []string + for i, name := range model.TabNames { + if i == m.activeTab { + tabs = append(tabs, lipgloss.NewStyle(). + Foreground(style.ColorSky).Bold(true). + Background(style.ColorHighlight). + Padding(0, 1).Render(name)) + } else { + tabs = append(tabs, lipgloss.NewStyle(). + Foreground(style.ColorMuted).Padding(0, 1).Render(name)) + } + } + bar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + user := style.Muted.Render(" " + m.username + " ") + errStr := "" + if m.globalErr != "" { + errStr = " " + style.Warning.String() + style.Muted.Render(m.globalErr) + " " + } + spacerW := m.width - lipgloss.Width(bar) - lipgloss.Width(user) - lipgloss.Width(errStr) + if spacerW < 0 { + spacerW = 0 + } + return lipgloss.NewStyle().Background(style.ColorDark).Width(m.width). + Render(lipgloss.JoinHorizontal(lipgloss.Top, + bar, + lipgloss.NewStyle().Width(spacerW).Render(""), + errStr, + user, + )) +} + +func (m AppModel) renderContent() string { + switch m.activeTab { + case model.TabAirport: + country := "" + if m.airport != nil { + country = m.countries[m.airport.OriginCountryID] + } + return AirportView(m.airport, country, m.width-4) + case model.TabFleet: + return m.fleet.View() + case model.TabBuildings: + return m.buildings.View() + case model.TabContracts: + return m.contracts.View() + case model.TabShop: + return m.shop.View() + } + return "" +} + +func (m AppModel) renderStatusBar() string { + cash, fuel, pax := "—", "—", "—" + if m.airport != nil { + cash = fmt.Sprintf("💰 $%s", formatCash(m.airport.Cash)) + fuel = fmt.Sprintf("⛽ %d/%d", m.airport.CurrentFuel, m.airport.MaxFuelCapacity) + pax = fmt.Sprintf("👤 %d/%d", m.airport.CurrentPassengers, m.airport.MaxPassengerCapacity) + } + left := style.StatusBar.Render(fmt.Sprintf(" %s %s %s ", cash, fuel, pax)) + right := style.StatusBarKey.Render(" 1-5 ") + style.StatusBar.Render(" tabs ") + + style.StatusBarKey.Render(" tab ") + style.StatusBar.Render(" cycle ") + + style.StatusBarKey.Render(" q ") + style.StatusBar.Render(" quit ") + spacerW := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if spacerW < 0 { + spacerW = 0 + } + return lipgloss.JoinHorizontal(lipgloss.Top, + left, + lipgloss.NewStyle().Background(style.ColorHighlight).Width(spacerW).Render(""), + right, + ) +} + +func (m AppModel) switchTab(tab int) AppModel { + m.fleet.focused = false + m.fleet.statusMsg = "" + m.fleet.sellArmed = false + m.buildings.focused = false + m.buildings.statusMsg = "" + m.buildings.demolishArmed = false + m.contracts.focused = false + m.contracts.statusMsg = "" + m.shop.focused = false + m.shop.statusMsg = "" + m.activeTab = tab + switch tab { + case model.TabFleet: + m.fleet.focused = true + case model.TabBuildings: + m.buildings.focused = true + case model.TabContracts: + m.contracts.focused = true + case model.TabShop: + m.shop.focused = true + } + return m +} + +func (m AppModel) tabRefreshCmd(tab int) tea.Cmd { + switch tab { + case model.TabFleet: + return m.fleet.LoadFleet() + case model.TabBuildings: + return m.buildings.Load() + case model.TabContracts: + return m.contracts.Load() + case model.TabShop: + return m.shop.Load() + } + return nil +} + +func (m *AppModel) Fleet(f *FleetModel) { m.fleet = *f } +func (m *AppModel) SetBuildings(b *BuildingsModel) { m.buildings = *b } +func (m *AppModel) SetContracts(c *ContractsModel) { m.contracts = *c } +func (m *AppModel) SetShop(s *ShopModel) { m.shop = *s } + +func (m AppModel) fetchAirport() tea.Cmd { + client := m.client + userID := m.userID + airportID := uint32(0) + if m.airport != nil { + airportID = m.airport.ID + } + return func() tea.Msg { + airport, err := client.GetAirportByUserID(userID) + if err != nil { + return AirportFetchErrMsg{Err: fmt.Errorf("no airport yet — create one via db_manager")} + } + if airportID != 0 { + _ = client.RefreshResources(airportID) + if a2, err2 := client.GetAirportByUserID(userID); err2 == nil { + airport = a2 + } + } + return AirportFetchedMsg{Airport: airport} + } +} + +func (m AppModel) fetchCountries() tea.Cmd { + client := m.client + return func() tea.Msg { + countries, err := client.GetAllCountries() + if err != nil { + return nil + } + return CountriesFetchedMsg{Countries: countries} + } +} diff --git a/internal/ui/buildings.go b/internal/ui/buildings.go new file mode 100644 index 0000000..2aed0fd --- /dev/null +++ b/internal/ui/buildings.go @@ -0,0 +1,559 @@ +package ui + +import ( + "database/sql" + "fmt" + "io" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "skyrama-tui/internal/model" + "skyrama-tui/internal/service" + "skyrama-tui/internal/style" +) + +// ─── Messages ───────────────────────────────────────────────────────────────── + +type BuildingsLoadedMsg struct { + Owned []model.AirportBuilding + Catalogue []model.Building + StorageCount, OperationCount int + StorageCap, OperationCap uint32 +} +type BuildingsErrMsg struct{ Err error } +type BuildingConstructedMsg struct{ Name string } +type BuildingDemolishedMsg struct{ Fee int64 } +type CapUpgradedMsg struct{ Type string } + +// ─── List items ─────────────────────────────────────────────────────────────── + +type ownedBuildingItem struct{ b model.AirportBuilding } + +func (o ownedBuildingItem) Title() string { + return buildingEmoji(o.b.BuildingType) + " " + o.b.Name +} +func (o ownedBuildingItem) Description() string { + parts := []string{ + style.Muted.Render("type:" + o.b.BuildingType), + style.Muted.Render(fmt.Sprintf("lv.%d", o.b.Level)), + } + if o.b.Capacity.Valid { + parts = append(parts, style.FuelChip.Render(fmt.Sprintf("cap:%d", o.b.Capacity.Int32))) + } + if o.b.OperationType.Valid { + parts = append(parts, style.PassengerChip.Render("op:"+o.b.OperationType.String)) + } + if o.b.OperationSize.Valid { + parts = append(parts, style.Muted.Render("size:"+o.b.OperationSize.String)) + } + return strings.Join(parts, " ") +} +func (o ownedBuildingItem) FilterValue() string { return o.b.Name } + +type catalogueItem struct{ b model.Building } + +func (c catalogueItem) Title() string { + return buildingEmoji(c.b.BuildingType) + " " + c.b.Name + + style.CashChip.Render(fmt.Sprintf(" ($%d)", c.b.ConstructionCostCash)) +} +func (c catalogueItem) Description() string { + parts := []string{ + "type:" + c.b.BuildingType, + fmt.Sprintf("min.lv%d", c.b.MinLevel), + } + if c.b.Capacity.Valid { + parts = append(parts, fmt.Sprintf("cap:%d", c.b.Capacity.Int32)) + } + if c.b.OperationType.Valid { + parts = append(parts, "op:"+c.b.OperationType.String) + } + if c.b.OperationSize.Valid { + parts = append(parts, "size:"+c.b.OperationSize.String) + } + if c.b.PassiveRate.Valid && c.b.PassiveType.Valid { + parts = append(parts, fmt.Sprintf("+%d/s %s", c.b.PassiveRate.Int32, c.b.PassiveType.String)) + } + return style.Muted.Render(strings.Join(parts, " ")) +} +func (c catalogueItem) FilterValue() string { return c.b.Name } + +// ─── BuildingsModel ─────────────────────────────────────────────────────────── + +type BuildingsModel struct { + client *service.GameService + airportID uint32 + ownedList list.Model + shopList list.Model + subTab int // 0 = owned, 1 = build catalogue + owned []model.AirportBuilding + catalogue []model.Building + storageCount, operationCount int + storageCap, operationCap uint32 + statusMsg string + demolishArmed bool // a demolish confirmation is pending (next y confirms) + focused bool + width int + height int +} + +func NewBuildingsModel(client *service.GameService, airportID uint32) BuildingsModel { + mkList := func() list.Model { + base := list.NewDefaultDelegate() + base.Styles.SelectedTitle = base.Styles.SelectedTitle. + Foreground(style.ColorSky). + BorderForeground(style.ColorSky) + base.Styles.SelectedDesc = base.Styles.SelectedDesc. + Foreground(style.ColorPassenger). + BorderForeground(style.ColorSky) + d := buildingDelegate{DefaultDelegate: base} + l := list.New(nil, d, 0, 0) + l.Title = "" + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + return l + } + return BuildingsModel{ + client: client, + airportID: airportID, + ownedList: mkList(), + shopList: mkList(), + } +} + +func (m BuildingsModel) Init() tea.Cmd { return m.load() } + +func (m BuildingsModel) Update(msg tea.Msg) (BuildingsModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + h := m.height - 13 + if h < 4 { + h = 4 + } + m.ownedList.SetSize(m.width-4, h) + m.shopList.SetSize(m.width-4, h) + + case BuildingsLoadedMsg: + m.owned = msg.Owned + m.catalogue = msg.Catalogue + m.storageCount, m.operationCount = msg.StorageCount, msg.OperationCount + m.storageCap, m.operationCap = msg.StorageCap, msg.OperationCap + ownedItems := ownedToItems(msg.Owned) + shopItems := catalogueToItems(msg.Catalogue) + m.ownedList.SetItems(ownedItems) + m.shopList.SetItems(shopItems) + m.ownedList.Select(firstSelectable(ownedItems)) + m.shopList.Select(firstSelectable(shopItems)) + + case BuildingConstructedMsg: + m.statusMsg = style.Success.String() + "Built: " + msg.Name + return m, m.load() + + case BuildingDemolishedMsg: + m.statusMsg = style.Success.String() + fmt.Sprintf("Demolished (−$%d)", msg.Fee) + return m, m.load() + + case CapUpgradedMsg: + m.statusMsg = style.Success.String() + msg.Type + " capacity increased!" + return m, m.load() + + case BuildingsErrMsg: + m.statusMsg = style.Error.String() + msg.Err.Error() + + case tea.KeyMsg: + if !m.focused { + break + } + // A demolish confirmation is pending: y confirms, anything else cancels. + if m.demolishArmed { + m.demolishArmed = false + if msg.String() == "y" || msg.String() == "Y" { + return m, m.demolishSelected() + } + m.statusMsg = style.Muted.Render("Demolition cancelled.") + return m, nil + } + switch msg.String() { + case "h", "left": + m.subTab = 0 + case "l", "right": + m.subTab = 1 + case "r": + m.statusMsg = "" + return m, m.load() + case "d", "D": + if m.subTab == 0 { + item, ok := m.ownedList.SelectedItem().(ownedBuildingItem) + if !ok { + break + } + m.demolishArmed = true + m.statusMsg = style.Warning.String() + + fmt.Sprintf("Demolish %s for $%d (no refund)? (y/n)", + item.b.Name, demolishFee(item.b)) + return m, nil + } + case "c", "C": + if m.subTab == 1 { + item, ok := m.shopList.SelectedItem().(catalogueItem) + if !ok { + break + } + return m, m.buyCapUpgrade(item.b.BuildingType) + } + case "enter", " ": + if m.subTab == 1 { + return m, m.buildSelected() + } + } + } + + var cmd tea.Cmd + if m.subTab == 0 { + m.ownedList, cmd = m.ownedList.Update(msg) + } else { + m.shopList, cmd = m.shopList.Update(msg) + } + // Keep the cursor off non-selectable section headers. + if key, ok := msg.(tea.KeyMsg); ok { + if m.subTab == 0 { + skipHeaders(&m.ownedList, key.String()) + } else { + skipHeaders(&m.shopList, key.String()) + } + } + return m, cmd +} + +func (m BuildingsModel) View() string { + // Sub-tab headers + ownedTab := style.Muted.Render(" Owned ") + buildTab := style.Muted.Render(" Build ") + if m.subTab == 0 { + ownedTab = style.CashChip.Render(" ▶ Owned ") + } else { + buildTab = style.CashChip.Render(" ▶ Build ") + } + tabs := lipgloss.JoinHorizontal(lipgloss.Top, + ownedTab, + style.Muted.Render(" │ "), + buildTab, + ) + + statusLine := "" + if m.statusMsg != "" { + statusLine = "\n" + m.statusMsg + "\n" + } + + var help string + if m.subTab == 0 { + help = style.HelpKey.Render("h/l") + style.HelpDesc.Render(" switch ") + + style.HelpKey.Render("d") + style.HelpDesc.Render(" demolish ") + + style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh ") + + style.HelpKey.Render("/") + style.HelpDesc.Render(" filter") + } else { + help = style.HelpKey.Render("h/l") + style.HelpDesc.Render(" switch ") + + style.HelpKey.Render("enter") + style.HelpDesc.Render(" build ") + + style.HelpKey.Render("c") + style.HelpDesc.Render(" expand cap ") + + style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh") + } + + capLine := style.Muted.Render(fmt.Sprintf("Caps: 📦 storage %d/%d 🛬 operation %d/%d", + m.storageCount, m.storageCap, m.operationCount, m.operationCap)) + + var content string + if m.subTab == 0 { + if len(m.owned) == 0 { + content = style.Muted.Render("No buildings yet. Switch to Build tab (l / →) to construct some.") + } else { + content = m.ownedList.View() + } + } else { + if len(m.catalogue) == 0 { + content = style.Muted.Render("No buildings available in the catalogue.") + } else { + content = m.shopList.View() + } + } + + return lipgloss.JoinVertical(lipgloss.Left, + style.PanelHeader.Render("🏗 Buildings"), + tabs, + capLine, + statusLine, + content, + "", + style.Muted.Render(help), + ) +} + +// ─── Exported for parent AppModel ───────────────────────────────────────────── + +// Load is the exported version for use by parent AppModel. +func (m BuildingsModel) Load() tea.Cmd { return m.load() } + +func (m BuildingsModel) load() tea.Cmd { + client := m.client + airportID := m.airportID + return func() tea.Msg { + owned, err := client.GetAirportBuildings(airportID) + if err != nil { + return BuildingsErrMsg{Err: err} + } + cat, err := client.GetAllBuildings() + if err != nil { + return BuildingsErrMsg{Err: err} + } + storageCount, operationCount, err := client.BuildingCounts(airportID) + if err != nil { + return BuildingsErrMsg{Err: err} + } + storageCap, operationCap, err := client.GetCaps(airportID) + if err != nil { + return BuildingsErrMsg{Err: err} + } + return BuildingsLoadedMsg{ + Owned: owned, Catalogue: cat, + StorageCount: storageCount, OperationCount: operationCount, + StorageCap: storageCap, OperationCap: operationCap, + } + } +} + +func (m BuildingsModel) buildSelected() tea.Cmd { + item, ok := m.shopList.SelectedItem().(catalogueItem) + if !ok { + return nil + } + client := m.client + airportID := m.airportID + b := item.b + return func() tea.Msg { + if err := client.ConstructBuilding(airportID, b.ID); err != nil { + return BuildingsErrMsg{Err: err} + } + return BuildingConstructedMsg{Name: b.Name} + } +} + +func (m BuildingsModel) demolishSelected() tea.Cmd { + item, ok := m.ownedList.SelectedItem().(ownedBuildingItem) + if !ok { + return nil + } + client := m.client + airportID := m.airportID + id := item.b.ID + return func() tea.Msg { + fee, err := client.DemolishBuilding(airportID, id) + if err != nil { + return BuildingsErrMsg{Err: err} + } + return BuildingDemolishedMsg{Fee: fee} + } +} + +func (m BuildingsModel) buyCapUpgrade(buildingType string) tea.Cmd { + client := m.client + airportID := m.airportID + return func() tea.Msg { + if err := client.BuyCapUpgrade(airportID, buildingType); err != nil { + return BuildingsErrMsg{Err: err} + } + return CapUpgradedMsg{Type: buildingType} + } +} + +// demolishFee previews the cost to demolish: a quarter of construction cost. +// (Authoritative value is computed by DemolishBuilding.) +func demolishFee(b model.AirportBuilding) int64 { + return int64(b.ConstructionCostCash) / 4 +} + +// ─── Grouping ───────────────────────────────────────────────────────────────── + +// buildingHeader is a non-actionable section / sub-section label inserted between +// grouped building rows. Empty FilterValue so it disappears during text filtering. +type buildingHeader struct { + label string + top bool // true = building-type header, false = storage/operation subtype +} + +func (h buildingHeader) Title() string { return h.label } +func (h buildingHeader) Description() string { return "" } +func (h buildingHeader) FilterValue() string { return "" } + +// buildingDelegate renders header rows as plain section labels and delegates +// every other row to the default delegate. +type buildingDelegate struct{ list.DefaultDelegate } + +func (d buildingDelegate) Render(w io.Writer, m list.Model, index int, it list.Item) { + if h, ok := it.(buildingHeader); ok { + if h.top { + fmt.Fprint(w, " "+style.PanelHeader.Render(h.label)) + } else { + fmt.Fprint(w, " "+style.Muted.Render(h.label)) + } + return + } + d.DefaultDelegate.Render(w, m, index, it) +} + +// typeRank orders building-type sections. +func typeRank(t string) int { + switch t { + case "storage": + return 0 + case "operation": + return 1 + case "passive": + return 2 + case "support": + return 3 + case "shop": + return 4 + case "production": + return 5 + default: + return 6 + } +} + +// groupable is one row plus the keys used to sort + section it. +type groupable struct { + item list.Item + typ string + subtype string + name string +} + +// buildGrouped sorts rows by type → subtype → name and inserts section headers +// (a type header for every type; a subtype header within storage/operation). +func buildGrouped(rows []groupable) []list.Item { + sort.SliceStable(rows, func(i, j int) bool { + if ri, rj := typeRank(rows[i].typ), typeRank(rows[j].typ); ri != rj { + return ri < rj + } + if rows[i].subtype != rows[j].subtype { + return rows[i].subtype < rows[j].subtype + } + return rows[i].name < rows[j].name + }) + + var out []list.Item + curType, curSub := "", "" + first := true + for _, r := range rows { + if first || r.typ != curType { + out = append(out, buildingHeader{label: buildingEmoji(r.typ) + " " + strings.ToUpper(r.typ), top: true}) + curType, curSub, first = r.typ, "", false + } + if (r.typ == "storage" || r.typ == "operation") && r.subtype != "" && r.subtype != curSub { + out = append(out, buildingHeader{label: " • " + r.subtype}) + curSub = r.subtype + } + out = append(out, r.item) + } + return out +} + +// firstSelectable returns the index of the first non-header item (or 0). +func firstSelectable(items []list.Item) int { + for i, it := range items { + if _, isHeader := it.(buildingHeader); !isHeader { + return i + } + } + return 0 +} + +// skipHeaders nudges the list cursor off a header row in the travel direction, +// so selection always lands on a real building. +func skipHeaders(l *list.Model, key string) { + if _, isHeader := l.SelectedItem().(buildingHeader); !isHeader { + return + } + up := key == "up" || key == "k" + for range l.Items() { + if _, isHeader := l.SelectedItem().(buildingHeader); !isHeader { + return + } + if up { + l.CursorUp() + } else { + l.CursorDown() + } + } +} + +// ─── List converters ────────────────────────────────────────────────────────── + +func ownedToItems(owned []model.AirportBuilding) []list.Item { + rows := make([]groupable, len(owned)) + for i, b := range owned { + rows[i] = groupable{ + item: ownedBuildingItem{b: b}, + typ: b.BuildingType, + subtype: buildingSubtype(b.BuildingType, b.StorageType, b.OperationType), + name: b.Name, + } + } + return buildGrouped(rows) +} + +func catalogueToItems(cat []model.Building) []list.Item { + rows := make([]groupable, len(cat)) + for i, b := range cat { + rows[i] = groupable{ + item: catalogueItem{b: b}, + typ: b.BuildingType, + subtype: buildingSubtype(b.BuildingType, b.StorageType, b.OperationType), + name: b.Name, + } + } + return buildGrouped(rows) +} + +// buildingSubtype is the storage_type (storage) or operation_type (operation), +// used to sub-group within those sections. +func buildingSubtype(buildingType string, storageType, operationType sql.NullString) string { + switch buildingType { + case "storage": + if storageType.Valid { + return storageType.String + } + case "operation": + if operationType.Valid { + return operationType.String + } + } + return "" +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +func buildingEmoji(buildingType string) string { + switch buildingType { + case "storage": + return "📦" + case "operation": + return "🛬" + case "passive": + return "⚡" + case "support": + return "🔧" + case "shop": + return "🏪" + case "production": + return "🏭" + default: + return "🏢" + } +} diff --git a/internal/ui/contracts.go b/internal/ui/contracts.go new file mode 100644 index 0000000..124149e --- /dev/null +++ b/internal/ui/contracts.go @@ -0,0 +1,176 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "skyrama-tui/internal/model" + "skyrama-tui/internal/service" + "skyrama-tui/internal/style" +) + +// ─── Messages ───────────────────────────────────────────────────────────────── + +type ContractsLoadedMsg struct{ Contracts []model.Contract } +type ContractsErrMsg struct{ Err error } + +// ─── ContractsModel ─────────────────────────────────────────────────────────── + +type ContractsModel struct { + client *service.GameService + airportID uint32 + contracts []model.Contract + cursor int + statusMsg string + focused bool + width int + height int +} + +func NewContractsModel(client *service.GameService, airportID uint32) ContractsModel { + return ContractsModel{client: client, airportID: airportID} +} + +func (m ContractsModel) Init() tea.Cmd { return m.load() } + +func (m ContractsModel) Update(msg tea.Msg) (ContractsModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case ContractsLoadedMsg: + m.contracts = msg.Contracts + if m.cursor >= len(m.contracts) { + m.cursor = clampMin(len(m.contracts)-1, 0) + } + + case ContractsErrMsg: + m.statusMsg = style.Error.String() + msg.Err.Error() + + case tea.KeyMsg: + if !m.focused { + break + } + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.contracts)-1 { + m.cursor++ + } + case "r": + m.statusMsg = "" + return m, m.load() + } + } + return m, nil +} + +func (m ContractsModel) View() string { + header := style.PanelHeader.Render("📋 Active Contracts") + + statusLine := "" + if m.statusMsg != "" { + statusLine = "\n" + m.statusMsg + "\n" + } + + if len(m.contracts) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, + header, + statusLine, + "", + style.Muted.Render("No active contracts right now."), + ) + } + + rows := make([]string, len(m.contracts)) + for i, c := range m.contracts { + rows[i] = m.renderContract(c, i == m.cursor) + } + + help := style.HelpKey.Render("↑↓ / jk") + style.HelpDesc.Render(" navigate ") + + style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh") + + return lipgloss.JoinVertical(lipgloss.Left, + header, + statusLine, + strings.Join(rows, "\n"), + "", + style.Muted.Render(help), + ) +} + +func (m ContractsModel) renderContract(c model.Contract, selected bool) string { + pct := c.ProgressPct() + barW := 24 + bar := style.ResourceBar(int(c.CurrentAmountDelivered), int(c.RequiredAmount), barW, style.ColorGreen) + + expiresIn := time.Until(c.ExpiresAt).Round(time.Minute) + urgency := style.Muted + if expiresIn < 30*time.Minute { + urgency = style.Error + } else if expiresIn < 2*time.Hour { + urgency = style.Warning + } + + inner := lipgloss.JoinVertical(lipgloss.Left, + style.Bold.Render(c.Title), + style.Muted.Render(fmt.Sprintf("Deliver %d × %s", c.RequiredAmount, c.ProductName)), + lipgloss.JoinHorizontal(lipgloss.Top, + bar, + style.Muted.Render(fmt.Sprintf(" %d/%d (%d%%)", + c.CurrentAmountDelivered, c.RequiredAmount, pct)), + ), + urgency.Render(fmt.Sprintf("⏰ Expires in %s", fmtContractDuration(expiresIn))), + ) + + if selected { + return style.PanelFocused.Render(inner) + } + return style.Panel.Render(inner) +} + +// ─── Exported for parent AppModel ───────────────────────────────────────────── + +// Load is the exported version for use by parent AppModel. +func (m ContractsModel) Load() tea.Cmd { return m.load() } + +func (m ContractsModel) load() tea.Cmd { + client := m.client + airportID := m.airportID + return func() tea.Msg { + contracts, err := client.GetActiveContracts(airportID) + if err != nil { + return ContractsErrMsg{Err: err} + } + return ContractsLoadedMsg{Contracts: contracts} + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +func clampMin(v, min int) int { + if v < min { + return min + } + return v +} + +func fmtContractDuration(d time.Duration) string { + if d <= 0 { + return "expired" + } + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm", h, m) + } + return fmt.Sprintf("%dm", m) +} diff --git a/internal/ui/fleet.go b/internal/ui/fleet.go new file mode 100644 index 0000000..bf0a232 --- /dev/null +++ b/internal/ui/fleet.go @@ -0,0 +1,810 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "skyrama-tui/internal/model" + "skyrama-tui/internal/service" + "skyrama-tui/internal/style" +) + +// ─── Messages ───────────────────────────────────────────────────────────────── + +type FleetLoadedMsg struct { + Fleet []model.PlayerPlane + Facilities []model.FacilityStatus + Note string // surfaced dispatch failure (e.g. out of fuel), if any +} +type FleetErrMsg struct{ Err error } +type FlightLaunchedMsg struct{ PlaneID uint32 } +type FlightLandedMsg struct { + Count int + CashEarned int64 +} +type ServiceProgressMsg struct{} +type PlaneSoldMsg struct{ Refund int64 } +type DestinationsLoadedMsg struct{ Dest []model.CentralAirport } +type DispatchResultMsg struct{ Queued int } +type DispatchCancelledMsg struct{ Count int } + +// ─── Columns ────────────────────────────────────────────────────────────────── + +// fleetColumns are the status lanes shown side-by-side, in lifecycle order. +var fleetColumns = []struct{ status, title string }{ + {"idle", "Waiting"}, + {"boarding", "Boarding"}, + {"taxiing", "Refueling"}, + {"flying", "In-flight"}, + {"unloading", "Unloading"}, +} + +// colEntry is one plane-template's planes within a single status column. +// Identical templates (same plane_id) are interchangeable, so they collapse to +// one row with a count. +type colEntry struct { + planeID uint32 + name string + aircraft string + size string + opType string + fuelCost uint32 + maxPax int32 + cash int32 + buyingCost uint32 + members []model.PlayerPlane // members in THIS status only +} + +// ─── FleetModel ─────────────────────────────────────────────────────────────── + +type FleetModel struct { + client *service.GameService + airportID uint32 + fleet []model.PlayerPlane + facilities []model.FacilityStatus + cols [][]colEntry + queued []colEntry // RAM-queued dispatches (status "queued"), shown in Waiting + activeCol int + sel []int // cursor index per column + + // attribute filters ("" = all) + filterSize string + filterType string + filterOp string + + // dispatch sub-mode + dispatchMode bool + dispatchEntry colEntry + destinations []model.CentralAirport + destIdx int + dispatchCount int + + statusMsg string + sellArmed bool + focused bool + width int + height int +} + +func NewFleetModel(client *service.GameService, airportID uint32) FleetModel { + return FleetModel{ + client: client, + airportID: airportID, + sel: make([]int, len(fleetColumns)), + } +} + +func (m FleetModel) Init() tea.Cmd { + return tea.Batch(m.loadFleet(), m.loadDestinations()) +} + +func (m FleetModel) Update(msg tea.Msg) (FleetModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case FleetLoadedMsg: + m.fleet = msg.Fleet + m.facilities = msg.Facilities + m.cols = m.buildColumns() + m.queued = m.buildQueued() + m.clampSel() + if msg.Note != "" { + m.statusMsg = style.Warning.String() + msg.Note + } + + case DestinationsLoadedMsg: + m.destinations = msg.Dest + + case ServiceProgressMsg: + return m, m.loadFleet() + + case FlightLaunchedMsg: + m.statusMsg = style.Success.String() + fmt.Sprintf("Plane #%d launched!", msg.PlaneID) + return m, m.loadFleet() + + case FlightLandedMsg: + m.statusMsg = style.Success.String() + + fmt.Sprintf("%d plane(s) landed! +$%d", msg.Count, msg.CashEarned) + return m, m.loadFleet() + + case PlaneSoldMsg: + m.statusMsg = style.Success.String() + fmt.Sprintf("Sold plane for $%d", msg.Refund) + return m, m.loadFleet() + + case DispatchResultMsg: + m.statusMsg = style.Success.String() + + fmt.Sprintf("Queued %d plane(s) for dispatch", msg.Queued) + return m, m.loadFleet() + + case DispatchCancelledMsg: + m.statusMsg = style.Success.String() + + fmt.Sprintf("Cancelled %d queued dispatch(es)", msg.Count) + return m, m.loadFleet() + + case FleetErrMsg: + m.statusMsg = style.Error.String() + msg.Err.Error() + + case tea.KeyMsg: + if !m.focused { + break + } + return m.handleKey(msg) + } + + return m, nil +} + +// handleKey routes a keypress depending on the current sub-mode. +func (m FleetModel) handleKey(msg tea.KeyMsg) (FleetModel, tea.Cmd) { + // Dispatch modal has its own bindings. + if m.dispatchMode { + return m.handleDispatchKey(msg) + } + + // A sell confirmation is pending: y confirms, anything else cancels. + if m.sellArmed { + m.sellArmed = false + if msg.String() == "y" || msg.String() == "Y" { + if e := m.currentEntry(); e != nil && len(e.members) > 0 { + return m, m.sellPlane(&e.members[0]) + } + m.statusMsg = style.Muted.Render("Nothing to sell.") + return m, nil + } + m.statusMsg = style.Muted.Render("Sale cancelled.") + return m, nil + } + + switch msg.String() { + case "r", "R": + m.statusMsg = "" + return m, m.loadFleet() + case "left", "h": + if m.activeCol > 0 { + m.activeCol-- + } + case "right", "l": + if m.activeCol < len(fleetColumns)-1 { + m.activeCol++ + } + case "up", "k": + if m.sel[m.activeCol] > 0 { + m.sel[m.activeCol]-- + } + case "down", "j": + if m.sel[m.activeCol] < len(m.cols[m.activeCol])-1 { + m.sel[m.activeCol]++ + } + case "z": + m.filterSize = cycle(m.filterSize, "small", "medium", "large", "huge") + m.cols = m.buildColumns() + m.clampSel() + case "t": + m.filterType = cycle(m.filterType, "plane", "helicopter", "seaplane", "spaceship") + m.cols = m.buildColumns() + m.clampSel() + case "o": + m.filterOp = cycle(m.filterOp, "passenger", "cargo") + m.cols = m.buildColumns() + m.clampSel() + case "s", "S": + // Sell only makes sense on a Waiting (idle) plane. + if fleetColumns[m.activeCol].status != "idle" { + m.statusMsg = style.Muted.Render("Select a Waiting plane to sell.") + return m, nil + } + e := m.currentEntry() + if e == nil || len(e.members) == 0 { + break + } + m.sellArmed = true + m.statusMsg = style.Warning.String() + + fmt.Sprintf("Sell one %s for $%d? (y/n)", e.name, resaleValue(&e.members[0])) + return m, nil + case "x", "X": + if len(m.queued) == 0 { + m.statusMsg = style.Muted.Render("Nothing queued to cancel.") + return m, nil + } + return m, m.cancelQueue() + case " ", "enter": + return m.onSelect() + } + return m, nil +} + +// onSelect handles enter/space on the focused column entry. +func (m FleetModel) onSelect() (FleetModel, tea.Cmd) { + e := m.currentEntry() + if e == nil { + return m, nil + } + switch fleetColumns[m.activeCol].status { + case "idle": + // Open the dispatch picker for this template. + if len(m.destinations) == 0 { + m.statusMsg = style.Muted.Render("No destinations available yet.") + return m, nil + } + m.dispatchMode = true + m.dispatchEntry = *e + m.destIdx = 0 + m.dispatchCount = 1 + m.statusMsg = "" + case "flying": + // Try to land any ready members. + return m, m.CheckLanding() + default: + m.statusMsg = style.Muted.Render("These planes are in service — wait for them to free up.") + } + return m, nil +} + +// handleDispatchKey drives the destination/count modal. +func (m FleetModel) handleDispatchKey(msg tea.KeyMsg) (FleetModel, tea.Cmd) { + idle := len(m.dispatchEntry.members) + switch msg.String() { + case "esc", "q": + m.dispatchMode = false + m.statusMsg = style.Muted.Render("Dispatch cancelled.") + case "up", "k": + if m.destIdx > 0 { + m.destIdx-- + } + case "down", "j": + if m.destIdx < len(m.destinations)-1 { + m.destIdx++ + } + case "left", "h", "-": + if m.dispatchCount > 1 { + m.dispatchCount-- + } + case "right", "l", "+", "=": + if m.dispatchCount < idle { + m.dispatchCount++ + } + case "enter", " ": + dest := m.destinations[m.destIdx].AirportID + members := m.dispatchEntry.members + count := m.dispatchCount + m.dispatchMode = false + return m, m.dispatchFlights(members, dest, count) + } + return m, nil +} + +func (m FleetModel) View() string { + if m.dispatchMode { + return m.dispatchView() + } + if len(m.fleet) == 0 { + return style.Muted.Render("No planes in your fleet. Buy some in the Shop tab (5).") + } + + header := style.PanelHeader.Render(fmt.Sprintf("🛩 Your Fleet (%d planes)", len(m.fleet))) + + // Active filters + facilities (runway/service-bay free/total). + filterLine := m.filterLine() + facilitiesLine := m.facilitiesLine() + + status := "" + if m.statusMsg != "" { + status = m.statusMsg + } + + cols := m.renderColumns() + + help := style.HelpKey.Render("←→/hl") + style.HelpDesc.Render(" column ") + + style.HelpKey.Render("↑↓/jk") + style.HelpDesc.Render(" select ") + + style.HelpKey.Render("enter") + style.HelpDesc.Render(" dispatch/land ") + + style.HelpKey.Render("s") + style.HelpDesc.Render(" sell ") + + style.HelpKey.Render("x") + style.HelpDesc.Render(" cancel queue ") + + style.HelpKey.Render("z/t/o") + style.HelpDesc.Render(" filter ") + + style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh") + + return lipgloss.JoinVertical(lipgloss.Left, + header, + filterLine, + facilitiesLine, + status, + cols, + "", + style.Muted.Render(help), + ) +} + +// ─── Rendering ────────────────────────────────────────────────────────────── + +func (m FleetModel) renderColumns() string { + n := len(fleetColumns) + colW := 18 + if m.width > 4 { + colW = (m.width - 4) / n + } + if colW < 12 { + colW = 12 + } + bodyH := m.height - 11 + if bodyH < 3 { + bodyH = 3 + } + + titleSel := lipgloss.NewStyle().Foreground(style.ColorSky).Bold(true) + titleDim := lipgloss.NewStyle().Foreground(style.ColorPassenger) + rowSel := lipgloss.NewStyle().Foreground(style.ColorSky).Bold(true) + + rendered := make([]string, n) + for ci := range fleetColumns { + var b strings.Builder + title := fmt.Sprintf("%s (%d)", fleetColumns[ci].title, columnPlaneCount(m.cols[ci])) + // Show queued count alongside the Waiting lane. + if ci == 0 && len(m.queued) > 0 { + title += fmt.Sprintf(" +%d⏳", columnPlaneCount(m.queued)) + } + if ci == m.activeCol { + b.WriteString(titleSel.Render("▌" + title)) + } else { + b.WriteString(titleDim.Render(" " + title)) + } + b.WriteString("\n") + + if len(m.cols[ci]) == 0 { + b.WriteString(style.Muted.Render(" —")) + } + for ri, e := range m.cols[ci] { + line := fmt.Sprintf("%s %s ×%d", planeIcon(e.aircraft), e.name, len(e.members)) + // In-flight / service columns show the soonest countdown. + if r, ok := soonestRemaining(e.members); ok { + if r == 0 { + line += " ✓" + } else { + line += " " + fmtDuration(r) + } + } + line = truncate(line, colW-2) + if ci == m.activeCol && ri == m.sel[ci] { + b.WriteString(rowSel.Render("›" + line)) + } else { + b.WriteString(" " + line) + } + b.WriteString("\n") + } + // Queued (RAM-accepted, not yet launched) dispatches hang under Waiting, + // dimmed and non-selectable, showing their destination. + if ci == 0 && len(m.queued) > 0 { + dim := lipgloss.NewStyle().Foreground(style.ColorMuted) + for _, e := range m.queued { + dest := "" + if len(e.members) > 0 && e.members[0].DestinationName.Valid { + dest = " → " + e.members[0].DestinationName.String + } + line := truncate(fmt.Sprintf("⏳ %s ×%d%s", e.name, len(e.members), dest), colW-2) + b.WriteString(" " + dim.Render(line) + "\n") + } + } + colStyle := lipgloss.NewStyle().Width(colW).MaxHeight(bodyH) + rendered[ci] = colStyle.Render(b.String()) + } + return lipgloss.JoinHorizontal(lipgloss.Top, rendered...) +} + +func (m FleetModel) dispatchView() string { + e := m.dispatchEntry + header := style.PanelHeader.Render( + fmt.Sprintf("🛫 Dispatch %s %s", planeIcon(e.aircraft), e.name)) + + info := style.Muted.Render(fmt.Sprintf("%d idle · ⛽%d each", len(e.members), e.fuelCost)) + if e.maxPax > 0 { + info += style.Muted.Render(fmt.Sprintf(" · 👤%d each", e.maxPax)) + } + + var b strings.Builder + b.WriteString(style.PanelHeader.Render("Destination") + "\n") + for i, d := range m.destinations { + label := d.Name + if d.FactionName != "" { + label += " — " + d.FactionName + } + if i == m.destIdx { + b.WriteString(lipgloss.NewStyle().Foreground(style.ColorSky).Bold(true).Render("› "+label) + "\n") + } else { + b.WriteString(" " + style.Muted.Render(label) + "\n") + } + } + + count := style.CashChip.Render(fmt.Sprintf(" Count: %d / %d ", m.dispatchCount, len(e.members))) + + help := style.HelpKey.Render("↑↓") + style.HelpDesc.Render(" destination ") + + style.HelpKey.Render("←→") + style.HelpDesc.Render(" count ") + + style.HelpKey.Render("enter") + style.HelpDesc.Render(" confirm ") + + style.HelpKey.Render("esc") + style.HelpDesc.Render(" cancel") + + return lipgloss.JoinVertical(lipgloss.Left, + header, + info, + "", + b.String(), + count, + "", + style.Muted.Render(help), + ) +} + +func (m FleetModel) filterLine() string { + val := func(label, v string) string { + if v == "" { + v = "all" + } + return style.Muted.Render(label+":") + style.HelpKey.Render(v) + } + return style.Muted.Render("Filters ") + + val("size", m.filterSize) + " " + + val("type", m.filterType) + " " + + val("op", m.filterOp) +} + +// facilitiesLine shows runway / service-bay availability (free/total) per size, +// so the player can see why dispatched planes sit queued. +func (m FleetModel) facilitiesLine() string { + if len(m.facilities) == 0 { + return style.Muted.Render("Facilities none — build runways & service bays in the Buildings tab") + } + byType := make(map[string][]string) + var order []string + for _, f := range m.facilities { + if _, ok := byType[f.OperationType]; !ok { + order = append(order, f.OperationType) + } + size := "?" + if f.Size != "" { + size = f.Size[:1] + } + byType[f.OperationType] = append(byType[f.OperationType], + fmt.Sprintf("%s %d/%d", size, f.Total-f.Busy, f.Total)) + } + var segs []string + for _, t := range order { + icon, label := "🅿", "Service" + if t == "runway" { + icon, label = "🛫", "Runway" + } + segs = append(segs, fmt.Sprintf("%s %s %s", icon, label, strings.Join(byType[t], " "))) + } + return style.Muted.Render("Facilities " + strings.Join(segs, " ")) +} + +// ─── Column building / selection ─────────────────────────────────────────── + +func (m FleetModel) buildColumns() [][]colEntry { + cols := make([][]colEntry, len(fleetColumns)) + for ci, col := range fleetColumns { + idx := make(map[uint32]int) + for i := range m.fleet { + p := m.fleet[i] + if p.Status != col.status || !m.passesFilter(p) { + continue + } + j, ok := idx[p.PlaneID] + if !ok { + pax := int32(0) + if p.MaxPassengers.Valid { + pax = p.MaxPassengers.Int32 + } + cash := int32(0) + if p.CashReward.Valid { + cash = p.CashReward.Int32 + } + cols[ci] = append(cols[ci], colEntry{ + planeID: p.PlaneID, + name: p.PlaneName, + aircraft: p.AircraftType, + size: p.AircraftSize, + opType: p.OperationType, + fuelCost: p.FuelCost, + maxPax: pax, + cash: cash, + buyingCost: p.BuyingCost, + }) + j = len(cols[ci]) - 1 + idx[p.PlaneID] = j + } + cols[ci][j].members = append(cols[ci][j].members, p) + } + } + return cols +} + +// buildQueued groups RAM-queued planes (synthetic status "queued") by template, +// for the dimmed rows under the Waiting column. +func (m FleetModel) buildQueued() []colEntry { + idx := make(map[uint32]int) + var out []colEntry + for i := range m.fleet { + p := m.fleet[i] + if p.Status != "queued" || !m.passesFilter(p) { + continue + } + j, ok := idx[p.PlaneID] + if !ok { + out = append(out, colEntry{ + planeID: p.PlaneID, + name: p.PlaneName, + aircraft: p.AircraftType, + size: p.AircraftSize, + }) + j = len(out) - 1 + idx[p.PlaneID] = j + } + out[j].members = append(out[j].members, p) + } + return out +} + +func (m FleetModel) passesFilter(p model.PlayerPlane) bool { + if m.filterSize != "" && p.AircraftSize != m.filterSize { + return false + } + if m.filterType != "" && p.AircraftType != m.filterType { + return false + } + if m.filterOp != "" && p.OperationType != m.filterOp { + return false + } + return true +} + +func (m *FleetModel) clampSel() { + if len(m.sel) != len(fleetColumns) { + m.sel = make([]int, len(fleetColumns)) + } + for ci := range m.cols { + if m.sel[ci] >= len(m.cols[ci]) { + m.sel[ci] = len(m.cols[ci]) - 1 + } + if m.sel[ci] < 0 { + m.sel[ci] = 0 + } + } +} + +func (m FleetModel) currentEntry() *colEntry { + if m.activeCol < 0 || m.activeCol >= len(m.cols) { + return nil + } + col := m.cols[m.activeCol] + i := m.sel[m.activeCol] + if i < 0 || i >= len(col) { + return nil + } + return &col[i] +} + +// ─── Commands ──────────────────────────────────────────────────────────────── + +func (m FleetModel) loadFleet() tea.Cmd { + client := m.client + airportID := m.airportID + return func() tea.Msg { + fleet, err := client.GetFleet(airportID) + if err != nil { + return FleetErrMsg{Err: err} + } + facilities, err := client.Facilities(airportID) + if err != nil { + return FleetErrMsg{Err: err} + } + return FleetLoadedMsg{Fleet: fleet, Facilities: facilities, Note: client.DispatchNote(airportID)} + } +} + +func (m FleetModel) loadDestinations() tea.Cmd { + client := m.client + return func() tea.Msg { + dest, err := client.GetCentralAirports() + if err != nil { + return FleetErrMsg{Err: err} + } + return DestinationsLoadedMsg{Dest: dest} + } +} + +// dispatchFlights queues up to count idle members for dispatch to destID. The +// service accepts them into RAM instantly and its background worker launches +// them to SQL as bays/runways free — so the UI never blocks on bay contention. +func (m FleetModel) dispatchFlights(members []model.PlayerPlane, destID uint32, count int) tea.Cmd { + client := m.client + airportID := m.airportID + var chosen []model.PlayerPlane + for _, p := range members { + if p.Status != "idle" { + continue + } + chosen = append(chosen, p) + if len(chosen) >= count { + break + } + } + return func() tea.Msg { + queued := client.DispatchFlights(airportID, chosen, destID) + return DispatchResultMsg{Queued: queued} + } +} + +// cancelQueue clears all not-yet-launched dispatches for this airport. +func (m FleetModel) cancelQueue() tea.Cmd { + client := m.client + airportID := m.airportID + return func() tea.Msg { + return DispatchCancelledMsg{Count: client.CancelQueue(airportID)} + } +} + +func (m FleetModel) sellPlane(pp *model.PlayerPlane) tea.Cmd { + client := m.client + airportID := m.airportID + id := pp.ID + return func() tea.Msg { + refund, err := client.SellPlane(airportID, id) + if err != nil { + return FleetErrMsg{Err: err} + } + return PlaneSoldMsg{Refund: refund} + } +} + +// ─── Exported for parent AppModel ───────────────────────────────────────────── + +// LoadFleet fetches the current fleet from the DB. +func (m FleetModel) LoadFleet() tea.Cmd { return m.loadFleet() } + +// LoadDestinations fetches the central-port dispatch destinations (global, so +// it does not depend on the airport id being resolved yet). +func (m FleetModel) LoadDestinations() tea.Cmd { return m.loadDestinations() } + +// CheckServiceProgress advances planes through service stages. +func (m FleetModel) CheckServiceProgress() tea.Cmd { + client := m.client + airportID := m.airportID + return func() tea.Msg { + if err := client.AdvanceServiceStages(airportID); err != nil { + return FleetErrMsg{Err: err} + } + return ServiceProgressMsg{} + } +} + +// CheckLanding lands every plane whose flight has elapsed (the service does the +// query + land loop). Returns nil when nothing was ready, so the tick is quiet. +func (m FleetModel) CheckLanding() tea.Cmd { + client := m.client + airportID := m.airportID + return func() tea.Msg { + landed, cash, err := client.LandReadyPlanes(airportID) + if err != nil { + return FleetErrMsg{Err: err} + } + if landed == 0 { + return nil + } + return FlightLandedMsg{Count: landed, CashEarned: cash} + } +} + +// ─── Misc helpers ───────────────────────────────────────────────────────────── + +// resaleValue previews what a plane sells for: full buying cost within the 5-min +// purchase window, otherwise half. (Authoritative value is computed in SQL by +// SellPlane on MySQL's clock; this preview uses Go time and may be ~seconds off.) +func resaleValue(pp *model.PlayerPlane) int64 { + if !pp.PurchasedAt.IsZero() && time.Since(pp.PurchasedAt) <= 5*time.Minute { + return int64(pp.BuyingCost) + } + return int64(pp.BuyingCost) / 2 +} + +// soonestRemaining returns the smallest TimeRemainingSeconds among members that +// are in a timed phase (flight/service). ok=false if none are timed. +func soonestRemaining(members []model.PlayerPlane) (int, bool) { + best := -1 + for i := range members { + switch members[i].Status { + case "boarding", "taxiing", "flying", "unloading": + r := members[i].TimeRemainingSeconds() + if best < 0 || r < best { + best = r + } + } + } + return best, best >= 0 +} + +func columnPlaneCount(entries []colEntry) int { + n := 0 + for _, e := range entries { + n += len(e.members) + } + return n +} + +// cycle advances v through "" → options[0] → … → "" (wraps back to all). +func cycle(v string, options ...string) string { + if v == "" { + return options[0] + } + for i, o := range options { + if o == v { + if i+1 < len(options) { + return options[i+1] + } + return "" + } + } + return "" +} + +func truncate(s string, max int) string { + if max < 1 { + max = 1 + } + if len([]rune(s)) <= max { + return s + } + r := []rune(s) + if max <= 1 { + return string(r[:max]) + } + return string(r[:max-1]) + "…" +} + +func planeIcon(aircraft string) string { + switch aircraft { + case "helicopter": + return "🚁" + case "seaplane": + return "🛥" + case "spaceship": + return "🚀" + default: + return "✈" + } +} + +func fmtDuration(secs int) string { + d := time.Duration(secs) * time.Second + h := int(d.Hours()) + mn := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%dh%dm", h, mn) + } + if mn > 0 { + return fmt.Sprintf("%dm%ds", mn, s) + } + return fmt.Sprintf("%ds", s) +} diff --git a/internal/ui/login.go b/internal/ui/login.go new file mode 100644 index 0000000..c10b9ef --- /dev/null +++ b/internal/ui/login.go @@ -0,0 +1,178 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "skyrama-tui/internal/service" + "skyrama-tui/internal/style" +) + +// ─── Messages ───────────────────────────────────────────────────────────────── + +type LoginSuccessMsg struct { + UserID int32 + Username string +} +type LoginErrMsg struct{ Err error } + +// ─── Model ──────────────────────────────────────────────────────────────────── + +type LoginModel struct { + client *service.GameService + inputs []textinput.Model // 0=username, 1=password + focus int + mode string // "login" | "register" + err string + width int +} + +func NewLoginModel(client *service.GameService) LoginModel { + user := textinput.New() + user.Placeholder = "username" + user.Focus() + user.CharLimit = 64 + user.Width = 30 + + pass := textinput.New() + pass.Placeholder = "password" + pass.EchoMode = textinput.EchoPassword + pass.EchoCharacter = '•' + pass.CharLimit = 128 + pass.Width = 30 + + return LoginModel{ + client: client, + inputs: []textinput.Model{user, pass}, + mode: "login", + } +} + +func (m LoginModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + + case tea.KeyMsg: + switch msg.String() { + case "tab", "down": + m.focus = (m.focus + 1) % len(m.inputs) + for i := range m.inputs { + if i == m.focus { + m.inputs[i].Focus() + } else { + m.inputs[i].Blur() + } + } + case "shift+tab", "up": + m.focus = (m.focus - 1 + len(m.inputs)) % len(m.inputs) + for i := range m.inputs { + if i == m.focus { + m.inputs[i].Focus() + } else { + m.inputs[i].Blur() + } + } + case "ctrl+r": + m.mode = "register" + m.err = "" + case "ctrl+l": + m.mode = "login" + m.err = "" + case "enter": + username := strings.TrimSpace(m.inputs[0].Value()) + password := m.inputs[1].Value() + if username == "" || password == "" { + m.err = "username and password required" + return m, nil + } + if m.mode == "login" { + return m, m.doLogin(username, password) + } + return m, m.doRegister(username, password) + } + } + + // Forward input events to focused field + var cmds []tea.Cmd + for i := range m.inputs { + var cmd tea.Cmd + m.inputs[i], cmd = m.inputs[i].Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) +} + +func (m LoginModel) View() string { + banner := style.Title.Render("✈ SKYRAMA ✈") + + modeLabel := style.PanelHeader.Render("Login") + if m.mode == "register" { + modeLabel = style.PanelHeader.Render("Register") + } + + form := lipgloss.JoinVertical(lipgloss.Left, + style.Muted.Render("Username"), + m.inputs[0].View(), + "", + style.Muted.Render("Password"), + m.inputs[1].View(), + ) + + errLine := "" + if m.err != "" { + errLine = "\n" + style.Error.String() + style.Muted.Render(m.err) + } + + hints := "\n" + + style.HelpKey.Render("enter") + style.HelpDesc.Render(" confirm ") + + style.HelpKey.Render("ctrl+r") + style.HelpDesc.Render(" register ") + + style.HelpKey.Render("ctrl+l") + style.HelpDesc.Render(" login ") + + style.HelpKey.Render("tab") + style.HelpDesc.Render(" next field") + + box := style.Panel.Padding(1, 3).Render( + lipgloss.JoinVertical(lipgloss.Left, modeLabel, "", form, errLine, hints), + ) + + content := lipgloss.JoinVertical(lipgloss.Center, banner, "", box) + + return lipgloss.Place(m.width, 24, lipgloss.Center, lipgloss.Center, content) +} + +// SetErr injects an error string from outside (e.g. from main after a LoginErrMsg). +func (m *LoginModel) SetErr(e string) { m.err = e } + +// ─── Commands ───────────────────────────────────────────────────────────────── + +func (m LoginModel) doLogin(username, password string) tea.Cmd { + return func() tea.Msg { + id, err := m.client.Login(username, password) + if err != nil { + return LoginErrMsg{Err: err} + } + return LoginSuccessMsg{UserID: id, Username: username} + } +} + +func (m LoginModel) doRegister(username, password string) tea.Cmd { + return func() tea.Msg { + _, err := m.client.Register(username, password) + if err != nil { + return LoginErrMsg{Err: fmt.Errorf("register: %w", err)} + } + // Auto-login after register + id, err := m.client.Login(username, password) + if err != nil { + return LoginErrMsg{Err: err} + } + return LoginSuccessMsg{UserID: id, Username: username} + } +} diff --git a/internal/ui/shop.go b/internal/ui/shop.go new file mode 100644 index 0000000..e9e1e53 --- /dev/null +++ b/internal/ui/shop.go @@ -0,0 +1,203 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "skyrama-tui/internal/model" + "skyrama-tui/internal/service" + "skyrama-tui/internal/style" +) + +// ─── Messages ───────────────────────────────────────────────────────────────── + +type ShopLoadedMsg struct{ Planes []model.Plane } +type ShopErrMsg struct{ Err error } +type PlaneBoughtMsg struct{ Name string } + +// ─── List item ──────────────────────────────────────────────────────────────── + +type shopPlaneItem struct{ p model.Plane } + +func (s shopPlaneItem) Title() string { + return fmt.Sprintf("%s %s %s", + planeIcon(s.p.AircraftType), + s.p.Name, + style.CashChip.Render(fmt.Sprintf("$%d", s.p.BuyingCostCash)), + ) +} + +func (s shopPlaneItem) Description() string { + parts := []string{ + style.Muted.Render(fmt.Sprintf("lv.%d", s.p.MinLevel)), + style.Muted.Render(s.p.AircraftSize + "/" + s.p.AircraftType), + style.FuelChip.Render(fmt.Sprintf("⛽%d", s.p.OperationCostFuel)), + style.Muted.Render(fmt.Sprintf("⏱%s", fmtDuration(int(s.p.TravelTimeInSeconds)))), + style.Muted.Render(fmt.Sprintf("✨%d xp", s.p.CompletedFlightExperience)), + } + if s.p.MaxPassengers.Valid && s.p.MaxPassengers.Int32 > 0 { + parts = append(parts, + style.PassengerChip.Render(fmt.Sprintf("👤%d", s.p.MaxPassengers.Int32))) + } + if s.p.CompletedFlightCashReward.Valid { + parts = append(parts, + style.CashChip.Render(fmt.Sprintf("💰%d/flight", s.p.CompletedFlightCashReward.Int32))) + } + return strings.Join(parts, " ") +} + +func (s shopPlaneItem) FilterValue() string { return s.p.Name } + +// ─── ShopModel ──────────────────────────────────────────────────────────────── + +type ShopModel struct { + client *service.GameService + airportID uint32 + playerLevel uint32 + list list.Model + planes []model.Plane + statusMsg string + focused bool + width int + height int +} + +func NewShopModel(client *service.GameService, airportID uint32, playerLevel uint32) ShopModel { + d := list.NewDefaultDelegate() + d.Styles.SelectedTitle = d.Styles.SelectedTitle. + Foreground(style.ColorGold). + BorderForeground(style.ColorGold) + d.Styles.SelectedDesc = d.Styles.SelectedDesc. + Foreground(style.ColorPassenger). + BorderForeground(style.ColorGold) + + l := list.New(nil, d, 0, 0) + l.Title = "" + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + + return ShopModel{ + client: client, + airportID: airportID, + playerLevel: playerLevel, + list: l, + } +} + +func (m ShopModel) Init() tea.Cmd { return m.load() } + +func (m ShopModel) Update(msg tea.Msg) (ShopModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.list.SetSize(m.width-4, m.height-12) + + case ShopLoadedMsg: + m.planes = msg.Planes + m.list.SetItems(m.planesToItems()) + + case PlaneBoughtMsg: + m.statusMsg = style.Success.String() + "Bought: " + msg.Name + "!" + return m, m.load() + + case ShopErrMsg: + m.statusMsg = style.Error.String() + msg.Err.Error() + + case tea.KeyMsg: + if !m.focused { + break + } + switch msg.String() { + case "r": + m.statusMsg = "" + return m, m.load() + case "enter", " ", "b": + item, ok := m.list.SelectedItem().(shopPlaneItem) + if !ok { + break + } + if item.p.MinLevel > m.playerLevel { + m.statusMsg = style.Warning.String() + + fmt.Sprintf("Requires level %d (you are level %d)", + item.p.MinLevel, m.playerLevel) + break + } + return m, m.buy(item.p) + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m ShopModel) View() string { + header := style.PanelHeader.Render("🛒 Plane Shop") + + levelNote := style.Info.String() + style.Muted.Render( + fmt.Sprintf("Your level: %d", m.playerLevel), + ) + + statusLine := "" + if m.statusMsg != "" { + statusLine = "\n" + m.statusMsg + "\n" + } + + help := style.HelpKey.Render("enter / b") + style.HelpDesc.Render(" buy ") + + style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh ") + + style.HelpKey.Render("/") + style.HelpDesc.Render(" filter") + + return lipgloss.JoinVertical(lipgloss.Left, + header, + levelNote, + statusLine, + m.list.View(), + "", + style.Muted.Render(help), + ) +} + +// ─── Exported for parent AppModel ───────────────────────────────────────────── + +// Load is the exported version for use by parent AppModel. +func (m ShopModel) Load() tea.Cmd { return m.load() } + +func (m ShopModel) load() tea.Cmd { + client := m.client + return func() tea.Msg { + planes, err := client.GetAllPlanes() + if err != nil { + return ShopErrMsg{Err: err} + } + return ShopLoadedMsg{Planes: planes} + } +} + +func (m ShopModel) buy(p model.Plane) tea.Cmd { + client := m.client + airportID := m.airportID + planeID := p.ID + planeName := p.Name + return func() tea.Msg { + // The service finds a free hangar and parks the plane in one intent. + if err := client.BuyPlane(airportID, planeID); err != nil { + return ShopErrMsg{Err: err} + } + return PlaneBoughtMsg{Name: planeName} + } +} + +func (m ShopModel) planesToItems() []list.Item { + items := make([]list.Item, len(m.planes)) + for i, p := range m.planes { + items[i] = shopPlaneItem{p: p} + } + return items +} diff --git a/run_admin.bat b/run_admin.bat new file mode 100644 index 0000000..600a49e --- /dev/null +++ b/run_admin.bat @@ -0,0 +1,17 @@ +@echo off +setlocal EnableDelayedExpansion +cd /d "%~dp0" + +set "SKYRAMA_DSN=skyramaUser:Skyrama1234.@tcp(127.0.0.1:3306)/skyrama_clone?parseTime=true" + +if not exist admin.exe ( + echo [*] admin.exe not found, building... + go build -o admin.exe .\cmd\admin\ + if errorlevel 1 ( echo [ERROR] Build failed. & pause & exit /b 1 ) +) + +admin.exe +echo. +echo [Admin exited - press any key to close] +pause >nul +endlocal diff --git a/run_game.bat b/run_game.bat new file mode 100644 index 0000000..c5b29bd --- /dev/null +++ b/run_game.bat @@ -0,0 +1,17 @@ +@echo off +setlocal EnableDelayedExpansion +cd /d "%~dp0" + +set "SKYRAMA_DSN=skyramaUser:Skyrama1234.@tcp(127.0.0.1:3306)/skyrama_clone?parseTime=true" + +if not exist skyrama.exe ( + echo [*] skyrama.exe not found, building... + go build -o skyrama.exe .\cmd\skyrama\ + if errorlevel 1 ( echo [ERROR] Build failed. & pause & exit /b 1 ) +) + +skyrama.exe +echo. +echo [Skyrama exited - press any key to close] +pause >nul +endlocal diff --git a/seed.bat b/seed.bat new file mode 100644 index 0000000..42d50f0 --- /dev/null +++ b/seed.bat @@ -0,0 +1,7 @@ +@echo off +REM Seed the central ports + NPC factions. Run ONCE, after applying +REM sql\migrations\004_central_dispatch.sql. Idempotent (safe to re-run). +REM Requires the mysql client on PATH. You can also just paste +REM sql\seed_factions.sql into MySQL Workbench instead. +mysql -u skyramaUser -pSkyrama1234. -h 127.0.0.1 skyrama_clone < sql\seed_factions.sql +echo Done seeding factions. diff --git a/skyrama.exe b/skyrama.exe new file mode 100644 index 0000000..987ad90 Binary files /dev/null and b/skyrama.exe differ diff --git a/sql/migrations/001_add_service_times.sql b/sql/migrations/001_add_service_times.sql new file mode 100644 index 0000000..32649f1 --- /dev/null +++ b/sql/migrations/001_add_service_times.sql @@ -0,0 +1,6 @@ +-- Migration 001: add per-plane service bay timing +-- Run once against an existing skyrama_clone database. + +ALTER TABLE planes + ADD COLUMN boarding_time_seconds INTEGER UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN fueling_time_seconds INTEGER UNSIGNED NOT NULL DEFAULT 0; diff --git a/sql/migrations/003_sell_demolish_caps.sql b/sql/migrations/003_sell_demolish_caps.sql new file mode 100644 index 0000000..d95ef5b --- /dev/null +++ b/sql/migrations/003_sell_demolish_caps.sql @@ -0,0 +1,16 @@ +-- Migration 003: sell planes, demolish buildings, per-type building caps +-- Run once against an existing skyrama_clone database. + +-- Stable purchase timestamp for the 5-minute full-refund sell window. +-- (player_planes.updated_at bumps on every status change, so it can't be used.) +ALTER TABLE player_planes + ADD COLUMN purchased_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER updated_at; + +-- Existing planes get NOW() from the default, so they immediately fall outside +-- the 5-minute window and sell at the 50% rate (intended). + +-- Per-type building caps. Raised by level-gated capacity upgrades +-- (see internal/db CapUpgradeTiers). Base values match the schema defaults. +ALTER TABLE airports + ADD COLUMN storage_cap INTEGER UNSIGNED NOT NULL DEFAULT 3, + ADD COLUMN operation_cap INTEGER UNSIGNED NOT NULL DEFAULT 2; diff --git a/sql/migrations/004_central_dispatch.sql b/sql/migrations/004_central_dispatch.sql new file mode 100644 index 0000000..5089364 --- /dev/null +++ b/sql/migrations/004_central_dispatch.sql @@ -0,0 +1,33 @@ +-- Migration 004: central-port dispatch + NPC factions + unload lifecycle +-- Run once against an existing skyrama_clone database. +-- Row seeding (NPC users, hub countries, central airports, factions) is done +-- idempotently in Go by Client.EnsureStage0World() at startup, not here. + +-- Central ports are system-owned (faction NPC) airports, one per continent. +ALTER TABLE airports + ADD COLUMN is_central BOOLEAN NOT NULL DEFAULT FALSE; + +-- NPC faction-leader users: cannot log in (empty password_hash), hidden from +-- login + user lists. +ALTER TABLE users + ADD COLUMN is_npc BOOLEAN NOT NULL DEFAULT FALSE; + +-- Where a dispatched plane is flying to, plus a post-landing 'unloading' phase. +ALTER TABLE player_planes + MODIFY COLUMN status ENUM('idle','maintenance','boarding','taxiing','flying','unloading') NOT NULL DEFAULT 'idle', + ADD COLUMN destination_airport_id INT UNSIGNED DEFAULT NULL, + ADD FOREIGN KEY (destination_airport_id) REFERENCES airports(id); + +-- One NPC faction per continent; owns that continent's central port. +CREATE TABLE IF NOT EXISTS factions ( + id integer unsigned primary key auto_increment, + name varchar(255) not null, + continent ENUM('void','Africa','Antarctica','Asia','Europe','North America','Oceania','South America') NOT NULL UNIQUE, + leader_name varchar(255) not null, + background TEXT, + user_id integer not null, + central_airport_id integer unsigned not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + foreign key (user_id) references users(id), + foreign key (central_airport_id) references airports(id) +); diff --git a/sql/queries.sql b/sql/queries.sql new file mode 100644 index 0000000..27734a6 --- /dev/null +++ b/sql/queries.sql @@ -0,0 +1,382 @@ +-- ============================================================================ +-- 1. AUTHENTICATION & USERS +-- ============================================================================ + +-- name: CreateUser :execresult +INSERT INTO users (username, password_hash) +VALUES (?, ?); + +-- name: UpdateUserPasswordByUserName :exec +UPDATE users +SET password_hash = ? +WHERE username = ?; + +-- name: GetUserByUsername :one +SELECT id, username, password_hash +FROM users +WHERE username = ? LIMIT 1; + +-- name: GetUserIdByUsername :one +SELECT id +FROM users +WHERE username = ? LIMIT 1; + +-- name: DeleteUserByUsername :exec +UPDATE users +SET deleted_at = CURRENT_TIMESTAMP +WHERE username = ?; + + +-- name: DeleteAirportByUserName :exec +UPDATE airports +SET is_deleted = TRUE +WHERE user_id = (SELECT id FROM users WHERE username = ?); + +-- ============================================================================ +-- 2. AIRPORT CORE MANAGEMENT +-- ============================================================================ + +-- airport row management + +-- name: CreateAirportByUserName :execresult +INSERT INTO airports (name, user_id, origin_country_id) +VALUES (?, (SELECT id FROM users WHERE username = ?), ?); + + +-- name: GetAirportByUserId :one +SELECT id, name, user_id, origin_country_id, cash, current_passengers, max_passenger_capacity, current_fuel, max_fuel_capacity, is_available, player_level, experience_points, passenger_generation_rate, fuel_generation_rate, resources_last_updated_at, is_deleted +FROM airports +WHERE user_id = ? LIMIT 1; +-- name: GetAirportByUserName :one +SELECT id, name, user_id, origin_country_id, cash, current_passengers, max_passenger_capacity, current_fuel, max_fuel_capacity, is_available, player_level, experience_points ,passenger_generation_rate, fuel_generation_rate, resources_last_updated_at, is_deleted +FROM airports +WHERE user_id = (SELECT id FROM users WHERE username = ?) LIMIT 1; + + +-- airport resource management + + +-- name: UpdateAirportCurrencies :exec +UPDATE airports +SET cash = ?, + current_passengers = ?, + current_fuel = ? +WHERE id = ?; + +-- name: UpdateAirportResources :exec +UPDATE airports +SET + current_fuel = LEAST( + max_fuel_capacity, + current_fuel + (fuel_generation_rate * (TIMESTAMPDIFF(SECOND, resources_last_updated_at, NOW()) DIV 60)) + ), + current_passengers = LEAST( + max_passenger_capacity, + current_passengers + (passenger_generation_rate * (TIMESTAMPDIFF(SECOND, resources_last_updated_at, NOW()) DIV 60)) + ), + resources_last_updated_at = resources_last_updated_at + + INTERVAL (TIMESTAMPDIFF(SECOND, resources_last_updated_at, NOW()) DIV 60) MINUTE +WHERE id = ?; + +-- name: UpdateAirportCash :exec +UPDATE airports +SET cash = ? +WHERE id = ?; +-- name: UpdateAirportpassengers :exec +UPDATE airports +SET current_passengers = ? +WHERE id = ?; +-- name: UpdateAirportFuel :exec +UPDATE airports +SET current_fuel = ? +WHERE id = ?; +-- name: UpdateAirportmaxPassengers :exec +UPDATE airports +SET max_passenger_capacity = ? +WHERE id = ?; +-- name: UpdateAirportMaxFuel :exec +UPDATE airports +SET max_fuel_capacity = ? +WHERE id = ?; + +-- name: UpdateAirportFuelRate :exec +UPDATE airports +SET fuel_generation_rate = ? +WHERE id = ?; +-- name: UpdateAirportPassengerRate :exec +UPDATE airports +SET passenger_generation_rate = ? +WHERE id = ?; + +-- name: UpdateAirportResourcesUpdated :exec +update airports +set resources_last_updated_at = ? +where id = ?; + + +-- name: updatePlayerExperience :exec +UPDATE airports +SET experience_points = ? +WHERE id = ?; + +-- name: updatePlayerLevel :exec +UPDATE airports +SET player_level = ? +WHERE id = ?; + + + +-- ============================================================================ +-- 3. country +-- ============================================================================ +-- name: GetAllCountries :many +SELECT id, name FROM countries; +-- name: GetCountryById :one +SELECT id, name FROM countries WHERE id = ?; +-- name: GetCountryByName :one +SELECT id, name FROM countries WHERE name = ?; + +-- name: AddCountry :execresult +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) +VALUES (?, ?, ?, ?, ?); + + + +-- ============================================================================ +-- 4. BUILDINGS & PRODUCTION ENGINE +-- ============================================================================ + +-- name: Addproduct :execresult +insert into products (name) values (?); + +-- name: GetAllProducts :many +select id, name from products; + +-- name: GetProductById :one +select id, name from products where id = ?; + + +-- name: addStorageBuilding :execresult +INSERT INTO buildings (name,min_level,construction_cost_cash,capacity,storage_type,storage_subtype) +VALUES (?, ?, ?, ?, ?, ?); + +-- name: addOperationalBuilding :execresult +INSERT INTO buildings (name,min_level,construction_cost_cash,operation_type,operation_size) +VALUES (?, ?, ?, ?, ?); + +-- name: addPassiveProductionBuilding :execresult +INSERT INTO buildings (name,min_level,construction_cost_cash,passive_type,passive_rate) +VALUES (?, ?, ?, ?, ?); + +-- name: addBuildingBasic :execresult +INSERT INTO buildings (name,min_level,construction_cost_cash,building_type) +VALUES (?, ?, ?,'none'); +-- name: UpdateBuildingAsStorage :execresult +update buildings +set + building_type = 'storage' + capacity = ? + storage_type = ? +storage_subtype= ?; + +VALUES (?, ?, ?); + + + + + +-- name: AddBuildingToAirportByUsername :execresult +INSERT INTO airport_buildings (airport_id, building_id) +VALUES ((select id from airports where user_id = (select id from users where username = ?)), ?); + +-- name: GetAirportBuildingsByUsername :many +SELECT ab.id AS airport_building_id,b.building_type , b.* +FROM airport_buildings ab +JOIN buildings b ON ab.building_id = b.id +WHERE ab.airport_id = (select id from airports where user_id = (select id from users where username = ?)); + + + +-- planes & fleet management + +-- name: addPlane :execresult +INSERT INTO planes (name, aircraft_size,operation_type,aircraft_type ,min_level, operation_cost_fuel, operation_cost_cash, max_passengers, travel_time_inSeconds,buying_cost_cash, completed_flight_experience, completed_flight_cash_reward, completed_flight_cargo_reward) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + + +-- name: GetPlaneById :one +SELECT * FROM planes WHERE id = ?; + +-- name: GetAllPlanes :many +SELECT * FROM planes; + +-- name: GetPlanesByOperationType :many +SELECT * FROM planes WHERE operation_type = ?; + +-- name: GetPlanesByMinLevel :many +SELECT * FROM planes WHERE min_level <= ?; + +-- name: GetPlanesByAircraftSize :many +SELECT * FROM planes WHERE aircraft_size = ?; + +-- name: GetPlanesByAircraftType :many +SELECT * FROM planes WHERE aircraft_type = ?; + +-- name: addPlaneToAirportByUsername :execresult +INSERT INTO player_planes (airport_id, plane_id, assigned_hangar_id, current_building_id, status) +VALUES ((select id from airports where user_id = (select id from users where username = ?)), ?, ?, ?, 'idle'); + +-- name: GetPlayerPlanesByUsername :many +SELECT pp.id AS player_plane_id, pp.* +FROM player_planes pp +JOIN planes p ON pp.plane_id = p.id +WHERE pp.airport_id = (select id from airports where user_id = (select id from users where username = ?)); + + +-- name: FindAvailableInfrastructureForPlane :one +SELECT ab.id +FROM airport_buildings ab +JOIN buildings b ON ab.building_id = b.id +LEFT JOIN player_planes pp ON ab.id = pp.current_building_id +WHERE ab.airport_id = (select id from airports where user_id = (select id from users where username = ?)) + AND b.operation_type = ? -- 'runway' or 'service' (service bay) + AND b.operation_size = ? -- matches plane's aircraft_size requirement + AND pp.id IS NULL -- Ensures no other plane is physically here right now +LIMIT 1; + +-- name: UpdatePlayerPlaneState :exec +UPDATE player_planes +SET current_building_id = ?, + status = ?, + flight_start_time = ? +WHERE id = ?; + +-- name: CheckHangarOccupancyByUsername :one +SELECT COUNT(*) AS total_parked +FROM player_planes pp +JOIN airport_buildings ab ON pp.current_building_id = ab.id +WHERE ab.airport_id = (select id from airports where user_id = (select id from users where username = ?)) + AND ab.building_id = ?; -- specific hangar + + -- +-- name: CheckRunwayOccupancyByUsername :one +SELECT COUNT(*) AS total_occupied +FROM player_planes pp +JOIN airport_buildings ab ON pp.current_building_id = ab.id +WHERE ab.airport_id = (select id from airports where user_id = (select id from users where username = ?)) + AND ab.building_id = ?; -- specific runway + +-- name: GetPlanesReadyToLandByUsername :many +SELECT pp.id AS player_plane_id, pp.airport_id, pp.assigned_hangar_id, + p.completed_flight_experience, p.completed_flight_cash_reward, p.completed_flight_cargo_reward, p.operation_type +FROM player_planes pp +JOIN planes p ON pp.plane_id = p.id +WHERE pp.status = 'flying' + AND DATE_ADD(pp.flight_start_time, INTERVAL p.travel_time_inSeconds SECOND) <= NOW() + AND pp.airport_id = (select id from airports where user_id = (select id from users where username = ?)); + + + + + + + + + + + + + + + +-- ============================================================================ +-- 5. FLEET & DYNAMIC INFRASTRUCTURE OPERATIONS +-- ============================================================================ + +-- name: BuyPlaneForPlayer :execresult +INSERT INTO player_planes (airport_id, plane_id, assigned_hangar_id, current_building_id, status) +VALUES (?, ?, ?, ?, 'idle'); + +-- name: GetPlayerFleet :many +SELECT pp.id AS player_plane_id, pp.status, pp.updated_at, pp.flight_start_time, pp.current_building_id, pp.assigned_hangar_id,p.* +FROM player_planes pp +JOIN planes p ON pp.plane_id = p.id +WHERE pp.airport_id = ?; + +-- name: FindAvailableInfrastructure :one +SELECT ab.id +FROM airport_buildings ab +JOIN buildings b ON ab.building_id = b.id +LEFT JOIN player_planes pp ON ab.id = pp.current_building_id +WHERE ab.airport_id = ? + AND b.operation_type = ? -- 'runway' or 'service' (service bay) + AND b.operation_size = ? -- matches plane's aircraft_size requirement + AND pp.id IS NULL -- Ensures no other plane is physically here right now +LIMIT 1; + +-- name: UpdatePlaneState :exec +UPDATE player_planes +SET current_building_id = ?, + status = ?, + + flight_start_time = ? +WHERE id = ?; + +-- name: CheckHangarOccupancy :one +SELECT COUNT(*) AS total_parked +FROM player_planes +WHERE current_building_id = ?; + + +-- ============================================================================ +-- 6. TIME-BASED FLIGHT ENGINE +-- ============================================================================ + +-- name: GetPlanesReadyToLand :many +SELECT pp.id AS player_plane_id, pp.airport_id, pp.assigned_hangar_id, + p.completed_flight_experience, p.completed_flight_cash_reward, p.completed_flight_cargo_reward, p.operation_type +FROM player_planes pp +JOIN planes p ON pp.plane_id = p.id +WHERE pp.status = 'flying' + AND DATE_ADD(pp.flight_start_time, INTERVAL p.travel_time_inSeconds SECOND) <= NOW(); + + +-- ============================================================================ +-- 7. PROGRESSION CONTRACTS & ECONOMY LOGS +-- ============================================================================ + +-- name: GetActiveContracts :many +SELECT id, title, required_product_id, required_amount, current_amount_delivered, is_completed, expires_at +FROM airport_contracts +WHERE airport_id = ? AND is_completed = FALSE AND expires_at > NOW(); + +-- name: UpdateContractDelivery :exec +UPDATE airport_contracts +SET current_amount_delivered = current_amount_delivered + ? +WHERE id = ? AND is_completed = FALSE; + +-- name: CompleteContract :exec +UPDATE airport_contracts +SET is_completed = TRUE +WHERE id = ?; + +-- name: LogCurrencyTransaction :exec +INSERT INTO currency_transaction_logs (airport_id, amount_changed, currency_type, reason) +VALUES (?, ?, ?, ?); + +-- name: LogCompletedFlightRoute :exec +INSERT INTO completed_flight_routes_log (origin_airport_id, destination_airport_id, start_time, end_time) +VALUES (?, ?, ?, NOW()); + + +-- name: AddRecipe :execresult +INSERT INTO recipes (name, ingredient_1_id, ingredient_1_amount, ingredient_2_id, ingredient_2_amount, ingredient_3_id, ingredient_3_amount, product_id, yield_amount) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: AddContract :execresult +INSERT INTO airport_contracts (airport_id, title, required_product_id, required_amount, expires_at) +VALUES (?, ?, ?, ?, ?); + +-- name: AddCompleteBuilding :execresult +INSERT INTO buildings (name, min_level, construction_cost_cash, building_type, capacity, storage_type, storage_subtype) +VALUES (?, ?, ?, ?, ?, ?, ?); \ No newline at end of file diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..8cc88f3 --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,290 @@ +-- stages +-- stage 0 : launch planes to country's base airport (accepts everyhing sendds nothing back) +-- stage 1 : add NPCs and cargo planes and shop buildings. +-- stage 2 : add player progression, building upgrades, effects and more complex recipes. +-- stage 3 : TBD + + + +-- good +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + username varchar(255) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + -- NPC users (central-port faction leaders) have is_npc=TRUE and an empty + -- password_hash so they can never log in; excluded from login + user lists. + is_npc BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL +); + +-- product table act as a product identifier ie egg, milk, flour etc +create table if not exists products ( + id smallint unsigned primary key auto_increment, + name varchar(255) not null +); + +-- for stage 2 will not be used +-- increasing speed (crafting,currency type generation etc)), +CREATE table if not exists effects ( + id smallint unsigned primary key auto_increment, + name varchar(255) not null +); + +-- used airport grouping and for contracts, each country has 3 produce they can export there will not be cap +create table if not exists countries ( + id smallint unsigned primary key auto_increment, + name varchar(255) not null, + continent ENUM('void','Africa', 'Antarctica', 'Asia', 'Europe', 'North America', 'Oceania', 'South America') NOT NULL, -- universal enum + produce_1_id smallint unsigned not null, + produce_2_id smallint unsigned not null, + produce_3_id smallint unsigned not null, + foreign key (produce_1_id) references products(id), + foreign key (produce_2_id) references products(id), + foreign key (produce_3_id) references products(id) +); + +-- main table +-- primary access table act as a player identifier, each player can only have one airport, but they can have multiple planes and buildings +create table if not exists airports ( + id integer unsigned primary key auto_increment, + name varchar(255) not null, + user_id integer unique not null, + origin_country_id smallint unsigned not null, + -- resources + cash bigint unsigned not null default 100, + current_passengers INTEGER UNSIGNED NOT NULL DEFAULT 100, + max_passenger_capacity INTEGER UNSIGNED NOT NULL DEFAULT 100, + current_fuel INTEGER UNSIGNED NOT NULL DEFAULT 500, + max_fuel_capacity INTEGER UNSIGNED NOT NULL DEFAULT 1000, + -- resource generation + fuel_generation_rate INT UNSIGNED NOT NULL DEFAULT 20, + passenger_generation_rate INT UNSIGNED NOT NULL DEFAULT 5, + resources_last_updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- progression + is_available boolean not null default true, -- if true can be sent planes from anyone + -- central ports are system-owned (faction NPC) airports, one per continent + is_central boolean not null default false, + player_level integer unsigned not null default 1, + experience_points bigint unsigned not null default 0, + -- per-type building caps (raised by level-gated capacity upgrades, see internal/db CapUpgradeTiers) + storage_cap integer unsigned not null default 3, + operation_cap integer unsigned not null default 2, + is_deleted BOOLEAN DEFAULT FALSE, + foreign key (origin_country_id) references countries(id), + foreign key (user_id) references users(id), + constraint chk_fuel_capacity CHECK (current_fuel <= max_fuel_capacity), + constraint chk_positive_fuel CHECK (current_fuel >= 0), + CONSTRAINT chk_passenger_capacity CHECK (current_passengers <= max_passenger_capacity), + CONSTRAINT chk_positive_passenger CHECK (current_passengers >= 0) +); + + +-- one NPC faction per continent; owns that continent's central port. Lore / +-- progression hook for later stages (major faction leaders). +create table if not exists factions ( + id integer unsigned primary key auto_increment, + name varchar(255) not null, + continent ENUM('void','Africa', 'Antarctica', 'Asia', 'Europe', 'North America', 'Oceania', 'South America') NOT NULL UNIQUE, + leader_name varchar(255) not null, + background TEXT, + user_id integer not null, + central_airport_id integer unsigned not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + foreign key (user_id) references users(id), + foreign key (central_airport_id) references airports(id) +); + +-- for stage 1, used as template for recipes. +create table if not exists recipes ( + id integer unsigned primary key auto_increment, + name varchar(255) not null, + ingredient_1_id smallint unsigned , + ingredient_1_amount integer unsigned , + ingredient_2_id smallint unsigned, + ingredient_2_amount integer unsigned , + ingredient_3_id smallint unsigned , + ingredient_3_amount integer unsigned , + product_id smallint unsigned not null, + yield_amount integer unsigned not null, + foreign key (ingredient_1_id) references products(id), + foreign key (ingredient_2_id) references products(id), + foreign key (ingredient_3_id) references products(id), + foreign key (product_id) references products(id) +); + + + + + +-- BUILDNGS + +-- hangars fuel tanks etc +CREATE table if not exists buildings ( + -- shared + id integer unsigned primary key auto_increment, + name varchar(255) not null, + min_level integer unsigned not null DEfAULT 1, + construction_cost_cash integer unsigned not null default 100, + upgrade_tier TINYINT UNSIGNED DEFAULT 0, + upgrade_cost_cash_to_uptier integer unsigned default null, + building_type ENUM('none','storage', 'support', 'operation', 'passive', 'shop', 'production') not null default 'none', + -- production specific + -- storage + capacity integer unsigned , + storage_type ENUM( 'fuel' , 'plane', 'product'), + storage_subtype ENUM('none', 'airplane', 'helicopter', 'seaplane','spaceship'), + -- support + effect_id smallint unsigned, + effect_value integer, + -- operation + operation_size enum('small','medium' ,'large', 'huge'), + operation_type enum('runway', 'service', 'production'), + + -- passive income + passive_type enum('passengers', 'fuel'), + passive_rate integer , + + foreign key (effect_id) references effects(id) + + ); + +-- stage 1 +create table if not exists building_products ( + id integer unsigned primary key auto_increment, + building_id integer unsigned not null, + + recipe_id integer unsigned, + storage_product integer unsigned , + storage_1 integer unsigned , + storage_2 integer unsigned , + storage_3 integer unsigned , + foreign key (building_id) references buildings(id), + foreign key (recipe_id) references recipes(id) +); + + +-- many to many relation between airport and buildings +create table if not exists airport_buildings ( + id integer unsigned primary key auto_increment, + airport_id integer unsigned not null, + building_id integer unsigned not null, + -- shop specific + recipe_id integer unsigned , + storage_product integer unsigned , + storage_1 integer unsigned , + storage_2 integer unsigned , + storage_3 integer unsigned , + level integer unsigned not null default 1, + foreign key (airport_id) references airports(id) , + foreign key (building_id) references buildings(id) +); + + + + +-- planes +-- template for planes +create table if not exists planes ( + id integer unsigned primary key auto_increment, + name varchar(255) not null, + min_level integer unsigned not null default 1, + operation_cost_fuel integer unsigned not null, + operation_cost_cash integer unsigned not null DEFAULT 10, + max_passengers integer unsigned, + travel_time_inSeconds integer unsigned not null, + operation_type enum('passenger', 'cargo') not null, + aircraft_size enum('small', 'medium', 'large', 'huge') not null, + aircraft_type enum('plane', 'helicopter', 'seaplane','spaceship') not null, + buying_cost_cash integer unsigned not null DEFAULT 100, + completed_flight_experience integer unsigned not null DEFAULT 20, + completed_flight_cash_reward integer unsigned DEFAULT 50, + completed_flight_cargo_reward integer unsigned DEFAULT null, + boarding_time_seconds integer unsigned not null DEFAULT 0, + fueling_time_seconds integer unsigned not null DEFAULT 0 + +); + +-- actual planes owned by players, tracks location, status, assigned hangar etc +CREATE TABLE IF NOT EXISTS player_planes ( + id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + airport_id INT UNSIGNED NOT NULL, + plane_id INTEGER UNSIGNED NOT NULL, + + -- 1. assignment (Must always point to a hangar they own they move between suitable hangars) + assigned_hangar_id INT UNSIGNED NOT NULL, + + -- 2. Real-time physical location (stored, Service Bay, Runway, or NULL if flying) + current_building_id INT UNSIGNED DEFAULT NULL, + -- assigned_cargo_id INTEGER UNSIGNED DEFAULT NULL, -- stage 1 For cargo planes, track what they're carrying + + status ENUM('idle', 'maintenance', 'boarding', 'taxiing', 'flying', 'unloading') NOT NULL DEFAULT 'idle', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + -- fixed at purchase (no ON UPDATE) so the 5-min full-refund sell window is reliable + purchased_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + flight_start_time TIMESTAMP NULL DEFAULT NULL, + -- where a dispatched plane is flying to (a central port); NULL when idle/home + destination_airport_id INT UNSIGNED DEFAULT NULL, + + + FOREIGN KEY (airport_id) REFERENCES airports(id), + FOREIGN KEY (plane_id) REFERENCES planes(id), + FOREIGN KEY (assigned_hangar_id) REFERENCES airport_buildings(id), + FOREIGN KEY (current_building_id) REFERENCES airport_buildings(id), + FOREIGN KEY (destination_airport_id) REFERENCES airports(id) + +); + +-- stage 1 +CREATE TABLE IF NOT EXISTS airport_inventory ( + airport_id INTEGER unsigned NOT NULL, + product_id SMALLINT UNSIGNED NOT NULL, + quantity INTEGER UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (airport_id, product_id), + FOREIGN KEY (airport_id) REFERENCES airports(id), + FOREIGN KEY (product_id) REFERENCES products(id) +); + + +-- stage ? tbd +CREATE TABLE IF NOT EXISTS airport_contracts ( + id INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT, + airport_id INTEGER unsigned NOT NULL, + title VARCHAR(255) NOT NULL, + + -- What they need to deliver + required_product_id SMALLINT UNSIGNED NOT NULL, + required_amount INTEGER UNSIGNED NOT NULL, + current_amount_delivered INTEGER UNSIGNED NOT NULL DEFAULT 0, + + + -- Status + is_completed BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMP NOT NULL, + + FOREIGN KEY (airport_id) REFERENCES airports(id), + FOREIGN KEY (required_product_id) REFERENCES products(id) + +); + + +-- logs + +create table if not exists completed_flight_routes_log ( + id integer unsigned primary key auto_increment, + origin_airport_id integer unsigned not null, + destination_airport_id integer unsigned not null, + start_time timestamp not null default current_timestamp, + end_time timestamp default null, + foreign key (origin_airport_id) references airports(id), + foreign key (destination_airport_id) references airports(id) +); +CREATE TABLE IF NOT EXISTS currency_transaction_logs ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + airport_id INT UNSIGNED NOT NULL, + amount_changed INT NOT NULL, -- Negative for buying, positive for earning + currency_type ENUM('cash', 'fuel', 'passengers') NOT NULL, + reason VARCHAR(255) NOT NULL, -- e.g., 'bought_plane_id_12', 'completed_flight_34' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (airport_id) REFERENCES airports(id) +); \ No newline at end of file diff --git a/sql/seed_factions.sql b/sql/seed_factions.sql new file mode 100644 index 0000000..77b4011 --- /dev/null +++ b/sql/seed_factions.sql @@ -0,0 +1,177 @@ +-- Seed: central ports + NPC faction leaders (one per continent). +-- Run ONCE, after migration 004_central_dispatch.sql, against skyrama_clone. +-- Idempotent: every statement is guarded by WHERE NOT EXISTS, so re-running is a +-- no-op. Order per continent: product -> hub country -> NPC user -> central +-- airport -> faction. (Replaces the old Go Client.EnsureStage0World.) + +-- Placeholder product so hub countries have a produce FK to point at. +INSERT INTO products (name) SELECT 'Misc Cargo' + WHERE NOT EXISTS (SELECT 1 FROM products); + +-- ─── void → Orbital Concord / Cmdr. Vega Solaris ─────────────────────────────── +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) + SELECT 'void Hub','void', p.id, p.id, p.id + FROM (SELECT id FROM products ORDER BY id LIMIT 1) p + WHERE NOT EXISTS (SELECT 1 FROM countries WHERE continent='void'); +INSERT INTO users (username,password_hash,is_npc) + SELECT 'vega_solaris','',TRUE + WHERE NOT EXISTS (SELECT 1 FROM users WHERE username='vega_solaris'); +INSERT INTO airports (name,user_id,origin_country_id,is_central) + SELECT 'Orbital Spaceport', u.id, c.id, TRUE + FROM (SELECT id FROM users WHERE username='vega_solaris') u, + (SELECT id FROM countries WHERE continent='void' ORDER BY id LIMIT 1) c + WHERE NOT EXISTS (SELECT 1 FROM airports WHERE user_id=(SELECT id FROM users WHERE username='vega_solaris')); +INSERT INTO factions (name,continent,leader_name,background,user_id,central_airport_id) + SELECT 'Orbital Concord','void','Cmdr. Vega Solaris', + 'Runs the only spaceport beyond the sky and keeps the orbital lanes clear for every flag.', + u.id, a.id + FROM (SELECT id FROM users WHERE username='vega_solaris') u, + (SELECT id FROM airports WHERE user_id=(SELECT id FROM users WHERE username='vega_solaris')) a + WHERE NOT EXISTS (SELECT 1 FROM factions WHERE continent='void'); + +-- ─── Africa → Savannah Pact / Amara Okonkwo ──────────────────────────────────── +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) + SELECT 'Africa Hub','Africa', p.id, p.id, p.id + FROM (SELECT id FROM products ORDER BY id LIMIT 1) p + WHERE NOT EXISTS (SELECT 1 FROM countries WHERE continent='Africa'); +INSERT INTO users (username,password_hash,is_npc) + SELECT 'amara_okonkwo','',TRUE + WHERE NOT EXISTS (SELECT 1 FROM users WHERE username='amara_okonkwo'); +INSERT INTO airports (name,user_id,origin_country_id,is_central) + SELECT 'Africa Central', u.id, c.id, TRUE + FROM (SELECT id FROM users WHERE username='amara_okonkwo') u, + (SELECT id FROM countries WHERE continent='Africa' ORDER BY id LIMIT 1) c + WHERE NOT EXISTS (SELECT 1 FROM airports WHERE user_id=(SELECT id FROM users WHERE username='amara_okonkwo')); +INSERT INTO factions (name,continent,leader_name,background,user_id,central_airport_id) + SELECT 'Savannah Pact','Africa','Amara Okonkwo', + 'Unites the continent''s scattered bush-strip airfields under a single banner.', + u.id, a.id + FROM (SELECT id FROM users WHERE username='amara_okonkwo') u, + (SELECT id FROM airports WHERE user_id=(SELECT id FROM users WHERE username='amara_okonkwo')) a + WHERE NOT EXISTS (SELECT 1 FROM factions WHERE continent='Africa'); + +-- ─── Antarctica → Polar Frontier / Dr. Erik Frost ────────────────────────────── +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) + SELECT 'Antarctica Hub','Antarctica', p.id, p.id, p.id + FROM (SELECT id FROM products ORDER BY id LIMIT 1) p + WHERE NOT EXISTS (SELECT 1 FROM countries WHERE continent='Antarctica'); +INSERT INTO users (username,password_hash,is_npc) + SELECT 'erik_frost','',TRUE + WHERE NOT EXISTS (SELECT 1 FROM users WHERE username='erik_frost'); +INSERT INTO airports (name,user_id,origin_country_id,is_central) + SELECT 'Antarctica Central', u.id, c.id, TRUE + FROM (SELECT id FROM users WHERE username='erik_frost') u, + (SELECT id FROM countries WHERE continent='Antarctica' ORDER BY id LIMIT 1) c + WHERE NOT EXISTS (SELECT 1 FROM airports WHERE user_id=(SELECT id FROM users WHERE username='erik_frost')); +INSERT INTO factions (name,continent,leader_name,background,user_id,central_airport_id) + SELECT 'Polar Frontier','Antarctica','Dr. Erik Frost', + 'Keeps the ice runways open against the harshest weather on Earth.', + u.id, a.id + FROM (SELECT id FROM users WHERE username='erik_frost') u, + (SELECT id FROM airports WHERE user_id=(SELECT id FROM users WHERE username='erik_frost')) a + WHERE NOT EXISTS (SELECT 1 FROM factions WHERE continent='Antarctica'); + +-- ─── Asia → Monsoon Alliance / Mei-Lin Zhao ──────────────────────────────────── +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) + SELECT 'Asia Hub','Asia', p.id, p.id, p.id + FROM (SELECT id FROM products ORDER BY id LIMIT 1) p + WHERE NOT EXISTS (SELECT 1 FROM countries WHERE continent='Asia'); +INSERT INTO users (username,password_hash,is_npc) + SELECT 'meilin_zhao','',TRUE + WHERE NOT EXISTS (SELECT 1 FROM users WHERE username='meilin_zhao'); +INSERT INTO airports (name,user_id,origin_country_id,is_central) + SELECT 'Asia Central', u.id, c.id, TRUE + FROM (SELECT id FROM users WHERE username='meilin_zhao') u, + (SELECT id FROM countries WHERE continent='Asia' ORDER BY id LIMIT 1) c + WHERE NOT EXISTS (SELECT 1 FROM airports WHERE user_id=(SELECT id FROM users WHERE username='meilin_zhao')); +INSERT INTO factions (name,continent,leader_name,background,user_id,central_airport_id) + SELECT 'Monsoon Alliance','Asia','Mei-Lin Zhao', + 'Commands the busiest and most fiercely contested skies on the planet.', + u.id, a.id + FROM (SELECT id FROM users WHERE username='meilin_zhao') u, + (SELECT id FROM airports WHERE user_id=(SELECT id FROM users WHERE username='meilin_zhao')) a + WHERE NOT EXISTS (SELECT 1 FROM factions WHERE continent='Asia'); + +-- ─── Europe → Continental Union / Lars Vandenberg ────────────────────────────── +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) + SELECT 'Europe Hub','Europe', p.id, p.id, p.id + FROM (SELECT id FROM products ORDER BY id LIMIT 1) p + WHERE NOT EXISTS (SELECT 1 FROM countries WHERE continent='Europe'); +INSERT INTO users (username,password_hash,is_npc) + SELECT 'lars_vandenberg','',TRUE + WHERE NOT EXISTS (SELECT 1 FROM users WHERE username='lars_vandenberg'); +INSERT INTO airports (name,user_id,origin_country_id,is_central) + SELECT 'Europe Central', u.id, c.id, TRUE + FROM (SELECT id FROM users WHERE username='lars_vandenberg') u, + (SELECT id FROM countries WHERE continent='Europe' ORDER BY id LIMIT 1) c + WHERE NOT EXISTS (SELECT 1 FROM airports WHERE user_id=(SELECT id FROM users WHERE username='lars_vandenberg')); +INSERT INTO factions (name,continent,leader_name,background,user_id,central_airport_id) + SELECT 'Continental Union','Europe','Lars Vandenberg', + 'Old-money hub baron who controls the western air corridors.', + u.id, a.id + FROM (SELECT id FROM users WHERE username='lars_vandenberg') u, + (SELECT id FROM airports WHERE user_id=(SELECT id FROM users WHERE username='lars_vandenberg')) a + WHERE NOT EXISTS (SELECT 1 FROM factions WHERE continent='Europe'); + +-- ─── North America → Liberty Air Coalition / Jack Sullivan ───────────────────── +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) + SELECT 'North America Hub','North America', p.id, p.id, p.id + FROM (SELECT id FROM products ORDER BY id LIMIT 1) p + WHERE NOT EXISTS (SELECT 1 FROM countries WHERE continent='North America'); +INSERT INTO users (username,password_hash,is_npc) + SELECT 'jack_sullivan','',TRUE + WHERE NOT EXISTS (SELECT 1 FROM users WHERE username='jack_sullivan'); +INSERT INTO airports (name,user_id,origin_country_id,is_central) + SELECT 'North America Central', u.id, c.id, TRUE + FROM (SELECT id FROM users WHERE username='jack_sullivan') u, + (SELECT id FROM countries WHERE continent='North America' ORDER BY id LIMIT 1) c + WHERE NOT EXISTS (SELECT 1 FROM airports WHERE user_id=(SELECT id FROM users WHERE username='jack_sullivan')); +INSERT INTO factions (name,continent,leader_name,background,user_id,central_airport_id) + SELECT 'Liberty Air Coalition','North America','Jack Sullivan', + 'Brash operator of the great transcontinental gateways.', + u.id, a.id + FROM (SELECT id FROM users WHERE username='jack_sullivan') u, + (SELECT id FROM airports WHERE user_id=(SELECT id FROM users WHERE username='jack_sullivan')) a + WHERE NOT EXISTS (SELECT 1 FROM factions WHERE continent='North America'); + +-- ─── Oceania → Coral Compass / Talia Reef ────────────────────────────────────── +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) + SELECT 'Oceania Hub','Oceania', p.id, p.id, p.id + FROM (SELECT id FROM products ORDER BY id LIMIT 1) p + WHERE NOT EXISTS (SELECT 1 FROM countries WHERE continent='Oceania'); +INSERT INTO users (username,password_hash,is_npc) + SELECT 'talia_reef','',TRUE + WHERE NOT EXISTS (SELECT 1 FROM users WHERE username='talia_reef'); +INSERT INTO airports (name,user_id,origin_country_id,is_central) + SELECT 'Oceania Central', u.id, c.id, TRUE + FROM (SELECT id FROM users WHERE username='talia_reef') u, + (SELECT id FROM countries WHERE continent='Oceania' ORDER BY id LIMIT 1) c + WHERE NOT EXISTS (SELECT 1 FROM airports WHERE user_id=(SELECT id FROM users WHERE username='talia_reef')); +INSERT INTO factions (name,continent,leader_name,background,user_id,central_airport_id) + SELECT 'Coral Compass','Oceania','Talia Reef', + 'Island-hopping seaplane magnate of the southern seas.', + u.id, a.id + FROM (SELECT id FROM users WHERE username='talia_reef') u, + (SELECT id FROM airports WHERE user_id=(SELECT id FROM users WHERE username='talia_reef')) a + WHERE NOT EXISTS (SELECT 1 FROM factions WHERE continent='Oceania'); + +-- ─── South America → Andean Wings / Mateo Rivera ─────────────────────────────── +INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) + SELECT 'South America Hub','South America', p.id, p.id, p.id + FROM (SELECT id FROM products ORDER BY id LIMIT 1) p + WHERE NOT EXISTS (SELECT 1 FROM countries WHERE continent='South America'); +INSERT INTO users (username,password_hash,is_npc) + SELECT 'mateo_rivera','',TRUE + WHERE NOT EXISTS (SELECT 1 FROM users WHERE username='mateo_rivera'); +INSERT INTO airports (name,user_id,origin_country_id,is_central) + SELECT 'South America Central', u.id, c.id, TRUE + FROM (SELECT id FROM users WHERE username='mateo_rivera') u, + (SELECT id FROM countries WHERE continent='South America' ORDER BY id LIMIT 1) c + WHERE NOT EXISTS (SELECT 1 FROM airports WHERE user_id=(SELECT id FROM users WHERE username='mateo_rivera')); +INSERT INTO factions (name,continent,leader_name,background,user_id,central_airport_id) + SELECT 'Andean Wings','South America','Mateo Rivera', + 'Flies the high mountain passes that nobody else dares.', + u.id, a.id + FROM (SELECT id FROM users WHERE username='mateo_rivera') u, + (SELECT id FROM airports WHERE user_id=(SELECT id FROM users WHERE username='mateo_rivera')) a + WHERE NOT EXISTS (SELECT 1 FROM factions WHERE continent='South America'); diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..3df0aae --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,43 @@ +version: "2" +sql: + - engine: "mysql" + schema: "sql/schema.sql" + queries: "sql/queries.sql" + gen: + go: + package: "db" + out: "internal/db" + sql_package: "database/sql" + + # Emit a Querier interface so you can mock the DB layer in tests + emit_interface: true + + # Emit empty slices instead of nil for :many queries + emit_empty_slices: true + + # Add JSON tags to generated structs (useful for debugging / logging) + emit_json_tags: true + + # Keep struct names singular (airport not airports) + emit_exact_table_names: false + + # Expose the raw SQL strings (handy for logging slow queries) + emit_exported_queries: true + + # Override specific MySQL → Go type mappings + overrides: + # BIGINT UNSIGNED → uint64 (cash, experience_points) + - db_type: "bigint unsigned" + go_type: "uint64" + + # INT UNSIGNED → uint32 (most IDs and resource fields) + - db_type: "int unsigned" + go_type: "uint32" + + # SMALLINT UNSIGNED → uint16 (country / product IDs) + - db_type: "smallint unsigned" + go_type: "uint16" + + # TINYINT UNSIGNED → uint8 (upgrade_tier) + - db_type: "tinyint unsigned" + go_type: "uint8" diff --git a/sqlc_generate.bat b/sqlc_generate.bat new file mode 100644 index 0000000..ac7af34 --- /dev/null +++ b/sqlc_generate.bat @@ -0,0 +1,71 @@ +@echo off +setlocal + +:: ============================================================ +:: Skyrama TUI - sqlc code generator +:: Run this from the skyrama-tui\ root folder whenever you +:: change sql\schema.sql or sql\queries.sql. +:: +:: Output goes to: internal\db\ (db.go, models.go, query.sql.go) +:: ============================================================ + +cd /d "%~dp0" + +:: ── 1. Make sure sqlc is installed ─────────────────────────── +where sqlc >nul 2>&1 +if errorlevel 1 ( + echo. + echo [*] sqlc not found. Installing via go install... + echo ^(requires Go 1.21+ and an internet connection^) + echo. + go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest + if errorlevel 1 ( + echo. + echo [ERROR] Could not install sqlc automatically. + echo. + echo Manual options: + echo A^) go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest + echo B^) Download the Windows binary from: + echo https://github.com/sqlc-dev/sqlc/releases/latest + echo and place sqlc.exe somewhere on your PATH. + echo. + pause + exit /b 1 + ) + echo [OK] sqlc installed. + echo. +) + +:: ── 2. Show the sqlc version we're using ───────────────────── +echo [*] Using: +sqlc version +echo. + +:: ── 3. Run the generator ───────────────────────────────────── +echo [*] Generating Go code from sql\schema.sql + sql\queries.sql ... +sqlc generate +if errorlevel 1 ( + echo. + echo [ERROR] sqlc generate failed. Fix the errors above, then re-run. + echo. + echo Common causes: + echo - SQL syntax error in sql\queries.sql + echo - Query references a column that does not exist in sql\schema.sql + echo - Missing -- name: QueryName :one/many/exec annotation + echo. + pause + exit /b 1 +) + +echo. +echo [OK] Done! Generated files are in internal\db\ +echo db.go - DB and Querier interface +echo models.go - Struct types for every table +echo query.sql.go - One function per named query +echo. +echo Rebuild the app now: +echo run.bat +echo. +pause >nul + +endlocal diff --git a/to-do.md b/to-do.md new file mode 100644 index 0000000..0170435 --- /dev/null +++ b/to-do.md @@ -0,0 +1,64 @@ +### stage 0 : core mechanics — plane flight and other core components ## + +> Plan locked 2026-06-01. Central ports = system-owned airport rows (owned by a hard-coded +> `SystemUserID`, flagged `is_central`); the backend treats any airport whose `user_id == SystemUserID` +> as a system airport and hides it from all player UI. One central port per continent (8, incl. void). +> Reward stays the plane's flat `completed_flight_cash_reward` for stage 0 — destination only drives +> routing, the route log, and the UI. Cargo / country-selection and airport-to-airport sending are +> deferred to stage 1. + +## planes ## +- [x] fleet screen (where owned planes are displayed with their status) +- [ ] flight screen (planes dispatched from here: select where to send and how many planes of the selected model go there) +- [ ] where to send planes is continent (main divider) -> country (where you select resources if it is a cargo plane — stage 1) -> airport (either the central airport, which accepts every aircraft, or other airports if they have a supporting building) + - stage 0 path = continent -> central port (passenger). country/cargo step is stage 1. +- [ ] (stage 1) if another airport sends the user's airport a plane (checked whether it can be sent beforehand), accept that aircraft and service it (right now just refuel; if fuel is not enough, divide the reward by the fuelled percentage). The aircraft gives double the amount it normally can, then is sent back. Either way — whether the airport received the aircraft or not — the aircraft acts normally (like a normal flight to the central ports). +- [~] proper skyrama-like plane management (planes idle in hangar; when dispatched, wait for an available service bay, get refuelled and boarded with passengers, then move to the runway, then fly; after a set amount of time the plane lands on the runway, goes to a service bay, unloads passengers and cargo, then waits for another dispatch) + - done: idle -> boarding -> taxiing(fuelling) -> flying. todo: land -> service bay -> unload -> idle. +- [ ] planes screen divided into vertical columns (waiting, refuelling, passenger in/out, at runway, and in-flight) each with their own groups +- [ ] add filters for size, type, operation + +## buildings ## +- separated into storage (ie. hangars, fuel tanks, warehouses for products), operation (runways and service bays), passive (buildings that add fuel or passenger income-rate increases), crafting (stage 1 or 2 — workshops or plane-upgrade buildings) +- passive buildings can have storage for their kind, like a passive fuel building stores fuel etc. +- buildings, like planes, are stored in groups (storage etc.) and, when selected, further grouped into aircraft, fuel etc. +- [ ] group the buildings screen by type, then sub-group (storage -> plane/fuel/product, operation -> runway/service) + + +## definitions +- Central port : one airport per continent (in the case of "void" it is a spaceport). Gives no bonus to the sender airport, is not owned or controllable by any player, and accepts every aircraft. +- Normal airport : user-owned airports. Can send aircraft to other airports if serviced by the receiving airport; the sender collects double the amount (if not a cargo aircraft) and the receiver gets half the nominal reward upon service. +- Void : the entirety of space — low earth orbit, lunar orbit, solar orbit etc. + + + + +### stage 1 : Cargo + +## resources ## +- required materials for crafting, shops and upgrades (if the required path is chosen) +- crafting : 100 silver to 20 rings in 20 hours +- shops : sells 1 ring per plane +- upgrades : 4 turbofans for a speed upgrade for large plane 1 +- as of stage 0 integration there is no defined plan for resource usage, but base resource collection will be through cargo aircraft +- in the original skyrama there are misc items; if you collect enough you trade them for certain things, be it a special plane, upgrade, or decoration. + Normal cargo is collected from cargo planes. In the hamburger shop there are a couple of recipes: the first one requires flour, the second one steak and flour etc. After passenger planes finish servicing, while collecting cash, the hamburger end-product is consumed in exchange for cash. + There can be other types but I don't remember. + + +### stage 2 : contracts and upgrades + + +## contracts ## +- divided into trigger, repeating, major event, minor event, level, and custom contracts +- act as missions +- trigger missions have certain triggers (TBD) +- repeating : long-lasting missions +- major event : like Christmas +- minor event : 2x fuel rate or 2x cash income from planes; can be triggered by admins server-wide +- level : push players toward progression +- custom : custom +- can be set as an unlock condition, like unlocking the large class or going to the void + +## upgrades ## +- certain paths to make each building and plane more efficient (more entities can be added). Each has 3 paths (ie. faster flight, cheaper flight, and more rewarding flight) with costs scaling either exponentially or linearly, then requiring materials or milestones (fly 100 times etc.)