810 lines
22 KiB
Go
810 lines
22 KiB
Go
|
|
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)
|
|||
|
|
}
|