559 lines
16 KiB
Go
559 lines
16 KiB
Go
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 "🏢"
|
||
}
|
||
}
|