skyrama_tui/internal/ui/fleet.go
2026-06-04 20:51:20 +03:00

810 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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