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

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
}