skyrama_tui/internal/ui/buildings.go

559 lines
16 KiB
Go
Raw Permalink Normal View History

2026-06-04 20:51:20 +03:00
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 "🏢"
}
}