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

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