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