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

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
}