phase 0.3

This commit is contained in:
portakal 2026-06-04 20:51:20 +03:00
commit 4a94bfc4e7
52 changed files with 11653 additions and 0 deletions

10
.idea/.gitignore generated vendored Normal file
View file

@ -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

10
.idea/go.imports.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/skyrama_tui.iml" filepath="$PROJECT_DIR$/.idea/skyrama_tui.iml" />
</modules>
</component>
</project>

9
.idea/skyrama_tui.iml generated Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

135
CLAUDE.md Normal file
View file

@ -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.

89
README.md Normal file
View file

@ -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 <your-repo>
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.

BIN
admin.exe Normal file

Binary file not shown.

31
build.bat Normal file
View file

@ -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

38
cmd/admin/main.go Normal file
View file

@ -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)
}
}

159
cmd/skyrama/main.go Normal file
View file

@ -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)
}
}

57
gitignore Normal file
View file

@ -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

36
go.mod Normal file
View file

@ -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
)

63
go.sum Normal file
View file

@ -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=

View file

@ -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()
}

121
internal/adminui/app.go Normal file
View file

@ -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()
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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()
}

334
internal/adminui/planes.go Normal file
View file

@ -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()
}

View file

@ -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()
}

203
internal/adminui/shared.go Normal file
View file

@ -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)
}

238
internal/adminui/users.go Normal file
View file

@ -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()
}

548
internal/db/admin_client.go Normal file
View file

@ -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
}

1384
internal/db/client.go Normal file

File diff suppressed because it is too large Load diff

31
internal/db/db.go Normal file
View file

@ -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,
}
}

704
internal/db/models.go Normal file
View file

@ -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"`
}

108
internal/db/querier.go Normal file
View file

@ -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)

1795
internal/db/queries.sql.go Normal file

File diff suppressed because it is too large Load diff

258
internal/model/model.go Normal file
View file

@ -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"}

317
internal/service/service.go Normal file
View file

@ -0,0 +1,317 @@
// Package service is the game's business-logic / orchestration layer. It sits
// between the presentation layer (internal/ui) and the data-access layer
// (internal/db): the UI depends only on GameService and never touches db
// directly, while db owns all SQL and transactions. GameService composes db
// operations, makes the business decisions the UI used to make, and runs the
// RAM-first dispatch queue with a background SQL worker.
package service
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
"skyrama-tui/internal/db"
"skyrama-tui/internal/model"
)
// pendingDispatch is one plane queued (in RAM) to be launched to a central port.
// The background worker persists it to SQL as bays/runways free up.
type pendingDispatch struct {
airportID uint32
plane model.PlayerPlane
destID uint32
destName string
}
// GameService wraps *db.Client. The mutex guards the in-RAM dispatch state
// (queue, notes, central-port name cache) shared with the background worker.
type GameService struct {
db *db.Client
mu sync.Mutex
queue []pendingDispatch
notes map[uint32]string // airportID → last hard-failure note (cleared on read)
central map[uint32]string // central airport id → display name (lazy cache)
cancel context.CancelFunc
}
// NewGameService wraps the db client and starts the background dispatch worker.
func NewGameService(client *db.Client) *GameService {
ctx, cancel := context.WithCancel(context.Background())
s := &GameService{
db: client,
notes: make(map[uint32]string),
cancel: cancel,
}
go s.worker(ctx)
return s
}
// Close stops the background worker and closes the underlying db connection.
func (s *GameService) Close() {
s.cancel()
s.db.Close()
}
// ─── Pass-throughs (pure delegation; names match db.Client) ────────────────────
func (s *GameService) Login(username, password string) (int32, error) {
return s.db.Login(username, password)
}
func (s *GameService) Register(username, password string) (int64, error) {
return s.db.Register(username, password)
}
func (s *GameService) GetAirportByUserID(userID int32) (*model.Airport, error) {
return s.db.GetAirportByUserID(userID)
}
func (s *GameService) RefreshResources(airportID uint32) error {
return s.db.RefreshResources(airportID)
}
func (s *GameService) GetAllCountries() ([]model.Country, error) { return s.db.GetAllCountries() }
func (s *GameService) GetCentralAirports() ([]model.CentralAirport, error) {
return s.db.GetCentralAirports()
}
func (s *GameService) GetAirportBuildings(airportID uint32) ([]model.AirportBuilding, error) {
return s.db.GetAirportBuildings(airportID)
}
func (s *GameService) GetAllBuildings() ([]model.Building, error) { return s.db.GetAllBuildings() }
func (s *GameService) BuildingCounts(airportID uint32) (int, int, error) {
return s.db.BuildingCounts(airportID)
}
func (s *GameService) GetCaps(airportID uint32) (uint32, uint32, error) {
return s.db.GetCaps(airportID)
}
func (s *GameService) GetActiveContracts(airportID uint32) ([]model.Contract, error) {
return s.db.GetActiveContracts(airportID)
}
func (s *GameService) GetAllPlanes() ([]model.Plane, error) { return s.db.GetAllPlanes() }
func (s *GameService) SellPlane(airportID, playerPlaneID uint32) (int64, error) {
return s.db.SellPlane(airportID, playerPlaneID)
}
func (s *GameService) ConstructBuilding(airportID, buildingID uint32) error {
return s.db.ConstructBuilding(airportID, buildingID)
}
func (s *GameService) DemolishBuilding(airportID, airportBuildingID uint32) (int64, error) {
return s.db.DemolishBuilding(airportID, airportBuildingID)
}
func (s *GameService) BuyCapUpgrade(airportID uint32, buildingType string) error {
return s.db.BuyCapUpgrade(airportID, buildingType)
}
func (s *GameService) AdvanceServiceStages(airportID uint32) error {
return s.db.AdvanceServiceStages(airportID)
}
func (s *GameService) Facilities(airportID uint32) ([]model.FacilityStatus, error) {
return s.db.GetFacilities(airportID)
}
// ─── Orchestration (was in the UI) ─────────────────────────────────────────────
// BuyPlane finds a free hangar then buys the plane into it (one UI intent).
func (s *GameService) BuyPlane(airportID, planeID uint32) error {
hangarID, err := s.db.FindAvailableHangar(airportID)
if err != nil {
return err
}
return s.db.BuyPlane(airportID, planeID, hangarID, hangarID)
}
// LandReadyPlanes lands every plane whose flight has elapsed, returning how many
// landed and the total cash reward (replaces the UI's land loop).
func (s *GameService) LandReadyPlanes(airportID uint32) (int, int64, error) {
ready, err := s.db.GetPlanesReadyToLand(airportID)
if err != nil {
return 0, 0, err
}
var cash int64
landed := 0
for _, pp := range ready {
if err := s.db.LandFlight(airportID, pp); err != nil {
return landed, cash, err
}
landed++
if pp.CashReward.Valid {
cash += int64(pp.CashReward.Int32)
}
}
return landed, cash, nil
}
// ─── RAM-first dispatch ────────────────────────────────────────────────────────
// DispatchFlights accepts a dispatch instantly into the RAM queue (no SQL yet)
// and returns how many idle planes were queued. The background worker launches
// them as bays/runways free. UI feedback is immediate; SQL follows.
func (s *GameService) DispatchFlights(airportID uint32, planes []model.PlayerPlane, destID uint32) int {
name := s.centralName(destID)
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for i := range planes {
if planes[i].Status != "idle" {
continue
}
s.queue = append(s.queue, pendingDispatch{
airportID: airportID,
plane: planes[i],
destID: destID,
destName: name,
})
n++
}
return n
}
// GetFleet returns the DB fleet with the pending dispatch queue overlaid: planes
// still idle in SQL but queued in RAM are surfaced with status "queued" + their
// destination, so they leave the Waiting pool the instant a dispatch is accepted.
func (s *GameService) GetFleet(airportID uint32) ([]model.PlayerPlane, error) {
fleet, err := s.db.GetFleet(airportID)
if err != nil {
return nil, err
}
s.mu.Lock()
queued := make(map[uint32]string)
for _, p := range s.queue {
if p.airportID == airportID {
queued[p.plane.ID] = p.destName
}
}
s.mu.Unlock()
if len(queued) == 0 {
return fleet, nil
}
for i := range fleet {
if fleet[i].Status != "idle" {
continue
}
if name, ok := queued[fleet[i].ID]; ok {
fleet[i].Status = "queued"
fleet[i].DestinationName = sql.NullString{String: name, Valid: true}
}
}
return fleet, nil
}
// CancelQueue drops all not-yet-launched dispatches for an airport from the RAM
// queue and returns how many were removed. Planes the worker already launched
// (now boarding/flying in SQL) are unaffected.
func (s *GameService) CancelQueue(airportID uint32) int {
s.mu.Lock()
defer s.mu.Unlock()
kept := s.queue[:0]
removed := 0
for _, p := range s.queue {
if p.airportID == airportID {
removed++
continue
}
kept = append(kept, p)
}
s.queue = kept
return removed
}
// DispatchNote returns (and clears) the last hard-failure note for an airport,
// e.g. a queued plane that couldn't launch because of insufficient fuel.
func (s *GameService) DispatchNote(airportID uint32) string {
s.mu.Lock()
defer s.mu.Unlock()
note := s.notes[airportID]
delete(s.notes, airportID)
return note
}
// centralName resolves a central airport id to its display name (lazy-cached).
func (s *GameService) centralName(destID uint32) string {
s.mu.Lock()
cached := s.central
s.mu.Unlock()
if cached == nil {
cached = make(map[uint32]string)
if cs, err := s.db.GetCentralAirports(); err == nil {
for _, c := range cs {
cached[c.AirportID] = c.Name
}
}
s.mu.Lock()
s.central = cached
s.mu.Unlock()
}
return cached[destID]
}
// ─── Background worker ──────────────────────────────────────────────────────────
func (s *GameService) worker(ctx context.Context) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.drainQueue()
}
}
}
// drainQueue tries to launch every queued plane once. Planes blocked only by
// bay/runway contention are re-queued for the next tick; planes that fail a
// validation check (fuel/passengers) are dropped with a note.
func (s *GameService) drainQueue() {
s.mu.Lock()
pending := s.queue
s.queue = nil
s.mu.Unlock()
if len(pending) == 0 {
return
}
var retry []pendingDispatch
for _, p := range pending {
launched, retryable, note := s.tryLaunch(p)
switch {
case launched:
// persisted to SQL; drop from queue
case retryable:
retry = append(retry, p)
default:
s.mu.Lock()
s.notes[p.airportID] = note
s.mu.Unlock()
}
}
if len(retry) > 0 {
s.mu.Lock()
s.queue = append(retry, s.queue...) // keep retries ahead of newly-queued
s.mu.Unlock()
}
}
// tryLaunch attempts one queued plane. Returns (launched, retryable, note):
// retryable=true means "no bay/runway free, try again later".
func (s *GameService) tryLaunch(p pendingDispatch) (bool, bool, string) {
pl := p.plane
if pl.BoardingTimeSec > 0 || pl.FuelingTimeSec > 0 {
bay, err := s.db.FindAvailableServiceBay(p.airportID, pl.AircraftSize)
if err != nil {
return false, true, "" // contention (or no bay yet) → retry
}
if err := s.db.StartBoarding(p.airportID, pl.ID, pl.FuelCost, bay, p.destID); err != nil {
return false, false, fmt.Sprintf("couldn't dispatch %s: %v", pl.PlaneName, err)
}
return true, false, ""
}
runway, err := s.db.FindAvailableRunway(p.airportID, pl.AircraftSize)
if err != nil {
return false, true, ""
}
if err := s.db.LaunchFlight(p.airportID, pl.ID, pl.FuelCost, runway, p.destID); err != nil {
return false, false, fmt.Sprintf("couldn't dispatch %s: %v", pl.PlaneName, err)
}
return true, false, ""
}

182
internal/style/style.go Normal file
View file

@ -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
}

125
internal/ui/airport_view.go Normal file
View file

@ -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
}

375
internal/ui/app.go Normal file
View file

@ -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}
}
}

559
internal/ui/buildings.go Normal file
View file

@ -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 "🏢"
}
}

176
internal/ui/contracts.go Normal file
View file

@ -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)
}

810
internal/ui/fleet.go Normal file
View file

@ -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)
}

178
internal/ui/login.go Normal file
View file

@ -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}
}
}

203
internal/ui/shop.go Normal file
View file

@ -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
}

17
run_admin.bat Normal file
View file

@ -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

17
run_game.bat Normal file
View file

@ -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

7
seed.bat Normal file
View file

@ -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.

BIN
skyrama.exe Normal file

Binary file not shown.

View file

@ -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;

View file

@ -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;

View file

@ -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)
);

382
sql/queries.sql Normal file
View file

@ -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 (?, ?, ?, ?, ?, ?, ?);

290
sql/schema.sql Normal file
View file

@ -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)
);

177
sql/seed_factions.sql Normal file
View file

@ -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');

43
sqlc.yaml Normal file
View file

@ -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"

71
sqlc_generate.bat Normal file
View file

@ -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

64
to-do.md Normal file
View file

@ -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.)