phase 0.3
This commit is contained in:
commit
4a94bfc4e7
52 changed files with 11653 additions and 0 deletions
275
internal/adminui/airports.go
Normal file
275
internal/adminui/airports.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type airportLoadedMsg []db.AdminAirport
|
||||
type airportDoneMsg struct{ err error }
|
||||
|
||||
// ── Action mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
type airportAction int
|
||||
|
||||
const (
|
||||
airportActionNone airportAction = iota
|
||||
airportActionGiveCash
|
||||
airportActionGiveBuilding
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// field indices for airport edit form
|
||||
const (
|
||||
aeqCash = 0
|
||||
aeqLevel = 1
|
||||
aeqMaxFuel = 2
|
||||
aeqMaxPax = 3
|
||||
)
|
||||
|
||||
type airportsTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminAirport
|
||||
cursor int
|
||||
// simple single-input actions (give cash / give building)
|
||||
action airportAction
|
||||
input textinput.Model
|
||||
// full edit form
|
||||
inForm bool
|
||||
editID uint32
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newAirportsTab(client *db.AdminClient) *airportsTab {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 20
|
||||
return &airportsTab{client: client, input: ti}
|
||||
}
|
||||
|
||||
func (t *airportsTab) InForm() bool { return t.action != airportActionNone || t.inForm }
|
||||
func (t *airportsTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *airportsTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListAirports()
|
||||
if err != nil {
|
||||
return airportDoneMsg{err}
|
||||
}
|
||||
return airportLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *airportsTab) openEditForm(a db.AdminAirport) {
|
||||
t.fields = []field{
|
||||
prefill("Cash (set)", strconv.FormatUint(a.Cash, 10)),
|
||||
prefill("Player Level", strconv.Itoa(int(a.Level))),
|
||||
prefill("Max Fuel Capacity", strconv.Itoa(int(a.MaxFuel))),
|
||||
prefill("Max Passenger Cap", strconv.Itoa(int(a.MaxPax))),
|
||||
}
|
||||
t.fields[aeqCash].input.Focus()
|
||||
t.focusIdx = aeqCash
|
||||
t.editID = a.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *airportsTab) submitEdit() tea.Cmd {
|
||||
cash, e1 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqCash].value()), 10, 64)
|
||||
level, e2 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqLevel].value()), 10, 32)
|
||||
maxFuel, e3 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqMaxFuel].value()), 10, 32)
|
||||
maxPax, e4 := strconv.ParseUint(strings.TrimSpace(t.fields[aeqMaxPax].value()), 10, 32)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
|
||||
t.status = "all fields must be valid numbers"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
id := t.editID
|
||||
return func() tea.Msg {
|
||||
return airportDoneMsg{t.client.UpdateAirport(id, cash, uint32(level), uint32(maxFuel), uint32(maxPax))}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *airportsTab) openAction(a airportAction, placeholder string) {
|
||||
t.action = a
|
||||
t.input.Reset()
|
||||
t.input.Placeholder = placeholder
|
||||
t.input.Focus()
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *airportsTab) submitAction() tea.Cmd {
|
||||
if len(t.items) == 0 {
|
||||
return nil
|
||||
}
|
||||
airport := t.items[t.cursor]
|
||||
raw := strings.TrimSpace(t.input.Value())
|
||||
|
||||
switch t.action {
|
||||
case airportActionGiveCash:
|
||||
amount, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || amount == 0 {
|
||||
t.status = "enter a positive cash amount"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
id := airport.ID
|
||||
return func() tea.Msg {
|
||||
return airportDoneMsg{t.client.GiveAirportCash(id, amount)}
|
||||
}
|
||||
|
||||
case airportActionGiveBuilding:
|
||||
bID, err := strconv.ParseUint(raw, 10, 32)
|
||||
if err != nil || bID == 0 {
|
||||
t.status = "enter a valid building ID"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
id := airport.ID
|
||||
return func() tea.Msg {
|
||||
return airportDoneMsg{t.client.GiveAirportBuilding(id, uint32(bID))}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *airportsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case airportLoadedMsg:
|
||||
t.items = []db.AdminAirport(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case airportDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.action = airportActionNone
|
||||
t.inForm = false
|
||||
t.status = "done"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submitEdit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
if t.action != airportActionNone {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.action = airportActionNone
|
||||
t.status = ""
|
||||
case "enter", "ctrl+s":
|
||||
return t, t.submitAction()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.input, cmd = t.input.Update(m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "g":
|
||||
t.openAction(airportActionGiveCash, "amount, e.g. 5000")
|
||||
case "b":
|
||||
t.openAction(airportActionGiveBuilding, "building ID, e.g. 3")
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *airportsTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Airports") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
sb.WriteString(titleSt.Render(fmt.Sprintf(" Edit Airport #%d", t.editID)) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
if t.action != airportActionNone {
|
||||
if len(t.items) > 0 {
|
||||
ap := t.items[t.cursor]
|
||||
sb.WriteString(fmt.Sprintf(" Selected: %s (owner: %s, cash: %d)\n\n",
|
||||
titleSt.Render(ap.Name), ap.Username, ap.Cash))
|
||||
}
|
||||
label := "Give Cash Amount:"
|
||||
if t.action == airportActionGiveBuilding {
|
||||
label = "Building ID to add:"
|
||||
}
|
||||
sb.WriteString(" " + labelSt.Render(label) + " " + t.input.View() + "\n")
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("enter:confirm esc:cancel"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-20s %-16s %-12s %-8s %s",
|
||||
"ID", "Airport Name", "Owner", "Cash", "Fuel", "Lvl")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 70)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no airports)") + "\n")
|
||||
}
|
||||
for i, a := range t.items {
|
||||
line := fmt.Sprintf(" %-5d %-20s %-16s %-12d %-8d %d",
|
||||
a.ID, a.Name, a.Username, a.Cash, a.Fuel, a.Level)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("e:edit g:give cash b:give building j/k:nav r:reload q:quit"))
|
||||
return sb.String()
|
||||
}
|
||||
121
internal/adminui/app.go
Normal file
121
internal/adminui/app.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
var tabNames = []string{"Products", "Countries", "Buildings", "Planes", "Users", "Airports", "Factions"}
|
||||
|
||||
// App is the root admin model. It owns the tab bar and routes messages.
|
||||
type App struct {
|
||||
tabs []Tab
|
||||
activeTab int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// NewApp constructs the admin app and wires up all tab sub-models.
|
||||
func NewApp(client *db.AdminClient) App {
|
||||
return App{
|
||||
tabs: []Tab{
|
||||
newProductsTab(client),
|
||||
newCountriesTab(client),
|
||||
newBuildingsTab(client),
|
||||
newPlanesTab(client),
|
||||
newUsersTab(client),
|
||||
newAirportsTab(client),
|
||||
newFactionsTab(client),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, t := range a.tabs {
|
||||
if cmd := t.Init(); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width, a.height = m.Width, m.Height
|
||||
// Fan out window size to all tabs
|
||||
for i, t := range a.tabs {
|
||||
updated, _ := t.Update(msg)
|
||||
a.tabs[i] = updated.(Tab)
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
activeInForm := a.tabs[a.activeTab].InForm()
|
||||
|
||||
// Global quit always works
|
||||
if m.String() == "ctrl+c" {
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
// Tab switching only when not in a form
|
||||
if !activeInForm {
|
||||
switch m.String() {
|
||||
case "1", "2", "3", "4", "5", "6", "7":
|
||||
idx := int(m.String()[0] - '1')
|
||||
if idx < len(a.tabs) {
|
||||
a.activeTab = idx
|
||||
}
|
||||
return a, nil
|
||||
case "tab":
|
||||
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
||||
return a, nil
|
||||
case "shift+tab":
|
||||
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan out non-key messages to all tabs; key messages only go to active tab
|
||||
if _, isKey := msg.(tea.KeyMsg); !isKey {
|
||||
var cmds []tea.Cmd
|
||||
for i, t := range a.tabs {
|
||||
updated, cmd := t.Update(msg)
|
||||
a.tabs[i] = updated.(Tab)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
updated, cmd := a.tabs[a.activeTab].Update(msg)
|
||||
a.tabs[a.activeTab] = updated.(Tab)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a App) View() string {
|
||||
// Tab bar
|
||||
var tabs []string
|
||||
for i, name := range tabNames {
|
||||
key := dimSt.Render(string(rune('1'+i))) + " "
|
||||
if i == a.activeTab {
|
||||
tabs = append(tabs, lipgloss.NewStyle().Bold(true).
|
||||
Foreground(lipgloss.Color("#E94560")).
|
||||
Render("["+key+name+"]"))
|
||||
} else {
|
||||
tabs = append(tabs, dimSt.Render(" "+key+name+" "))
|
||||
}
|
||||
}
|
||||
bar := strings.Join(tabs, dimSt.Render("│"))
|
||||
header := titleSt.Render("✈ SKYRAMA ADMIN") + " " + bar
|
||||
|
||||
divider := dimSt.Render(strings.Repeat("─", a.width))
|
||||
|
||||
return header + "\n" + divider + "\n" + a.tabs[a.activeTab].View()
|
||||
}
|
||||
310
internal/adminui/buildings.go
Normal file
310
internal/adminui/buildings.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type bldgLoadedMsg []db.AdminBuilding
|
||||
type bldgDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
bfName = 0
|
||||
bfMinLevel = 1
|
||||
bfCost = 2
|
||||
bfType = 3
|
||||
// storage
|
||||
bfCapacity = 4
|
||||
bfStorType = 5
|
||||
bfStorSubtype = 6
|
||||
// operation
|
||||
bfOpType = 7
|
||||
bfOpSize = 8
|
||||
// passive
|
||||
bfPassiveType = 9
|
||||
bfPassiveRate = 10
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type buildingsTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminBuilding
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint32
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newBuildingsTab(client *db.AdminClient) *buildingsTab {
|
||||
return &buildingsTab{client: client}
|
||||
}
|
||||
|
||||
func (t *buildingsTab) InForm() bool { return t.inForm }
|
||||
func (t *buildingsTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *buildingsTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListBuildings()
|
||||
if err != nil {
|
||||
return bldgDoneMsg{err}
|
||||
}
|
||||
return bldgLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *buildingsTab) initFields() {
|
||||
t.fields = []field{
|
||||
newTextField("Name", "e.g. Small Hangar"),
|
||||
newTextField("Min Level", "1"),
|
||||
newTextField("Cost (cash)", "100"),
|
||||
newEnumField("Type", []string{"none", "storage", "operation", "passive"}),
|
||||
// storage
|
||||
newTextField("Capacity", "10"),
|
||||
newEnumField("Storage Type", []string{"fuel", "plane", "product"}),
|
||||
newEnumField("Storage Subtype", []string{"none", "airplane", "helicopter", "seaplane", "spaceship"}),
|
||||
// operation
|
||||
newEnumField("Op Type", []string{"runway", "service", "production"}),
|
||||
newEnumField("Op Size", []string{"small", "medium", "large", "huge"}),
|
||||
// passive
|
||||
newEnumField("Passive Type", []string{"passengers", "fuel"}),
|
||||
newTextField("Passive Rate", "10"),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *buildingsTab) openForm() {
|
||||
t.initFields()
|
||||
t.fields[bfName].input.Focus()
|
||||
t.focusIdx = bfName
|
||||
t.isEdit = false
|
||||
t.updateHidden()
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *buildingsTab) openEditForm(b db.AdminBuilding) {
|
||||
t.initFields()
|
||||
t.fields[bfName].input.SetValue(b.Name)
|
||||
t.fields[bfMinLevel].input.SetValue(strconv.Itoa(int(b.MinLevel)))
|
||||
t.fields[bfCost].input.SetValue(strconv.Itoa(int(b.Cost)))
|
||||
setEnumSel(&t.fields[bfType], b.BuildingType)
|
||||
t.fields[bfCapacity].input.SetValue(strconv.Itoa(b.Capacity))
|
||||
setEnumSel(&t.fields[bfStorType], b.StorageType)
|
||||
setEnumSel(&t.fields[bfStorSubtype], b.StorageSubtype)
|
||||
setEnumSel(&t.fields[bfOpType], b.OperationType)
|
||||
setEnumSel(&t.fields[bfOpSize], b.OperationSize)
|
||||
setEnumSel(&t.fields[bfPassiveType], b.PassiveType)
|
||||
t.fields[bfPassiveRate].input.SetValue(strconv.Itoa(b.PassiveRate))
|
||||
t.fields[bfName].input.Focus()
|
||||
t.focusIdx = bfName
|
||||
t.isEdit = true
|
||||
t.editID = b.ID
|
||||
t.updateHidden()
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
// updateHidden shows/hides type-specific fields based on the type enum selection.
|
||||
func (t *buildingsTab) updateHidden() {
|
||||
btype := t.fields[bfType].options[t.fields[bfType].sel]
|
||||
for i := range t.fields {
|
||||
switch i {
|
||||
case bfCapacity, bfStorType, bfStorSubtype:
|
||||
t.fields[i].hidden = btype != "storage"
|
||||
case bfOpType, bfOpSize:
|
||||
t.fields[i].hidden = btype != "operation"
|
||||
case bfPassiveType, bfPassiveRate:
|
||||
t.fields[i].hidden = btype != "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *buildingsTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[bfName].value())
|
||||
if name == "" {
|
||||
t.status = "name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
lvl, err := strconv.ParseUint(strings.TrimSpace(t.fields[bfMinLevel].value()), 10, 32)
|
||||
if err != nil {
|
||||
t.status = "min level must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
cost, err := strconv.ParseUint(strings.TrimSpace(t.fields[bfCost].value()), 10, 32)
|
||||
if err != nil {
|
||||
t.status = "cost must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
btype := t.fields[bfType].value()
|
||||
|
||||
p := db.AddBuildingParams{
|
||||
Name: name,
|
||||
MinLevel: uint32(lvl),
|
||||
Cost: uint32(cost),
|
||||
BuildingType: btype,
|
||||
}
|
||||
|
||||
switch btype {
|
||||
case "storage":
|
||||
cap, err := strconv.Atoi(strings.TrimSpace(t.fields[bfCapacity].value()))
|
||||
if err != nil || cap <= 0 {
|
||||
t.status = "capacity must be a positive number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
p.Capacity = cap
|
||||
p.StorageType = t.fields[bfStorType].value()
|
||||
p.StorageSubtype = t.fields[bfStorSubtype].value()
|
||||
case "operation":
|
||||
p.OperationType = t.fields[bfOpType].value()
|
||||
p.OperationSize = t.fields[bfOpSize].value()
|
||||
case "passive":
|
||||
rate, err := strconv.Atoi(strings.TrimSpace(t.fields[bfPassiveRate].value()))
|
||||
if err != nil {
|
||||
t.status = "passive rate must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
p.PassiveType = t.fields[bfPassiveType].value()
|
||||
p.PassiveRate = rate
|
||||
}
|
||||
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg { return bldgDoneMsg{t.client.UpdateBuilding(id, p)} }
|
||||
}
|
||||
return func() tea.Msg { return bldgDoneMsg{t.client.AddBuilding(p)} }
|
||||
}
|
||||
|
||||
func (t *buildingsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case bldgLoadedMsg:
|
||||
t.items = []db.AdminBuilding(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case bldgDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
prevType := t.fields[bfType].sel
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
// If type enum changed, update field visibility
|
||||
if t.fields[bfType].sel != prevType {
|
||||
t.updateHidden()
|
||||
// If current focus is now hidden, jump to next visible
|
||||
if t.focusIdx < len(t.fields) && t.fields[t.focusIdx].hidden {
|
||||
t.focusIdx = nextVisible(t.fields, t.focusIdx)
|
||||
applyFocus(t.fields, t.focusIdx)
|
||||
}
|
||||
}
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
id := t.items[t.cursor].ID
|
||||
return t, func() tea.Msg {
|
||||
return bldgDoneMsg{t.client.DeleteBuilding(id)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *buildingsTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Buildings") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Building"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Building #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-24s %-12s %-8s %s", "ID", "Name", "Type", "Cost", "Details")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 65)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no buildings — press a to add)") + "\n")
|
||||
}
|
||||
for i, b := range t.items {
|
||||
line := fmt.Sprintf(" %-5d %-24s %-12s %-8d %s",
|
||||
b.ID, b.Name, b.BuildingType, b.Cost, b.Details)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpList))
|
||||
return sb.String()
|
||||
}
|
||||
226
internal/adminui/countries.go
Normal file
226
internal/adminui/countries.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type cntryLoadedMsg []db.AdminCountry
|
||||
type cntryDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
cfName = 0
|
||||
cfContinent = 1
|
||||
cfP1 = 2
|
||||
cfP2 = 3
|
||||
cfP3 = 4
|
||||
)
|
||||
|
||||
var continentOptions = []string{
|
||||
"void", "Africa", "Antarctica", "Asia",
|
||||
"Europe", "North America", "Oceania", "South America",
|
||||
}
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type countriesTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminCountry
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint16
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newCountriesTab(client *db.AdminClient) *countriesTab {
|
||||
return &countriesTab{client: client}
|
||||
}
|
||||
|
||||
func (t *countriesTab) InForm() bool { return t.inForm }
|
||||
func (t *countriesTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *countriesTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListCountries()
|
||||
if err != nil {
|
||||
return cntryDoneMsg{err}
|
||||
}
|
||||
return cntryLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *countriesTab) openForm() {
|
||||
t.fields = []field{
|
||||
newTextField("Name", "e.g. Turkey"),
|
||||
newEnumField("Continent", continentOptions),
|
||||
newTextField("Product 1 ID", "1"),
|
||||
newTextField("Product 2 ID", "2"),
|
||||
newTextField("Product 3 ID", "3"),
|
||||
}
|
||||
t.fields[cfName].input.Focus()
|
||||
t.focusIdx = cfName
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *countriesTab) openEditForm(c db.AdminCountry) {
|
||||
t.fields = []field{
|
||||
prefill("Name", c.Name),
|
||||
newEnumField("Continent", continentOptions),
|
||||
prefill("Product 1 ID", strconv.Itoa(int(c.P1))),
|
||||
prefill("Product 2 ID", strconv.Itoa(int(c.P2))),
|
||||
prefill("Product 3 ID", strconv.Itoa(int(c.P3))),
|
||||
}
|
||||
setEnumSel(&t.fields[cfContinent], c.Continent)
|
||||
t.fields[cfName].input.Focus()
|
||||
t.focusIdx = cfName
|
||||
t.isEdit = true
|
||||
t.editID = c.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *countriesTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[cfName].value())
|
||||
continent := t.fields[cfContinent].value()
|
||||
p1s := strings.TrimSpace(t.fields[cfP1].value())
|
||||
p2s := strings.TrimSpace(t.fields[cfP2].value())
|
||||
p3s := strings.TrimSpace(t.fields[cfP3].value())
|
||||
|
||||
if name == "" {
|
||||
t.status = "name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
p1, e1 := strconv.ParseUint(p1s, 10, 16)
|
||||
p2, e2 := strconv.ParseUint(p2s, 10, 16)
|
||||
p3, e3 := strconv.ParseUint(p3s, 10, 16)
|
||||
if e1 != nil || e2 != nil || e3 != nil {
|
||||
t.status = "product IDs must be valid numbers"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg {
|
||||
return cntryDoneMsg{t.client.UpdateCountry(id, name, continent, uint16(p1), uint16(p2), uint16(p3))}
|
||||
}
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return cntryDoneMsg{t.client.AddCountry(name, continent, uint16(p1), uint16(p2), uint16(p3))}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *countriesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case cntryLoadedMsg:
|
||||
t.items = []db.AdminCountry(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case cntryDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *countriesTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Countries") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Country"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Country #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-20s %-14s %s", "ID", "Name", "Continent", "Products (P1/P2/P3)")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" " + strings.Repeat("─", 60)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no countries)") + "\n")
|
||||
}
|
||||
for i, c := range t.items {
|
||||
line := fmt.Sprintf(" %-5d %-20s %-14s %d / %d / %d",
|
||||
c.ID, c.Name, c.Continent, c.P1, c.P2, c.P3)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("a:add e:edit j/k:nav r:reload q:quit"))
|
||||
return sb.String()
|
||||
}
|
||||
232
internal/adminui/factions.go
Normal file
232
internal/adminui/factions.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type factionLoadedMsg []db.AdminFaction
|
||||
type factionDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
ffName = 0
|
||||
ffContinent = 1
|
||||
ffLeader = 2
|
||||
ffBackground = 3
|
||||
ffUser = 4
|
||||
ffAirport = 5
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type factionsTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminFaction
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint32
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newFactionsTab(client *db.AdminClient) *factionsTab {
|
||||
return &factionsTab{client: client}
|
||||
}
|
||||
|
||||
func (t *factionsTab) InForm() bool { return t.inForm }
|
||||
func (t *factionsTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *factionsTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListFactions()
|
||||
if err != nil {
|
||||
return factionDoneMsg{err}
|
||||
}
|
||||
return factionLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *factionsTab) openForm() {
|
||||
t.fields = []field{
|
||||
newTextField("Name", "e.g. Monsoon Alliance"),
|
||||
newEnumField("Continent", continentOptions),
|
||||
newTextField("Leader", "e.g. Mei-Lin Zhao"),
|
||||
newTextField("Background", "short bio"),
|
||||
newTextField("Owner User ID", "NPC user id"),
|
||||
newTextField("Central Airport ID", "central airport id"),
|
||||
}
|
||||
t.fields[ffName].input.Focus()
|
||||
t.focusIdx = ffName
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *factionsTab) openEditForm(f db.AdminFaction) {
|
||||
t.fields = []field{
|
||||
prefill("Name", f.Name),
|
||||
newEnumField("Continent", continentOptions),
|
||||
prefill("Leader", f.Leader),
|
||||
prefill("Background", f.Background),
|
||||
prefill("Owner User ID", strconv.Itoa(int(f.UserID))),
|
||||
prefill("Central Airport ID", strconv.Itoa(int(f.CentralAirportID))),
|
||||
}
|
||||
setEnumSel(&t.fields[ffContinent], f.Continent)
|
||||
t.fields[ffName].input.Focus()
|
||||
t.focusIdx = ffName
|
||||
t.isEdit = true
|
||||
t.editID = f.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *factionsTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[ffName].value())
|
||||
continent := t.fields[ffContinent].value()
|
||||
leader := strings.TrimSpace(t.fields[ffLeader].value())
|
||||
background := strings.TrimSpace(t.fields[ffBackground].value())
|
||||
userStr := strings.TrimSpace(t.fields[ffUser].value())
|
||||
airportStr := strings.TrimSpace(t.fields[ffAirport].value())
|
||||
|
||||
if name == "" || leader == "" {
|
||||
t.status = "name and leader are required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
userID, e1 := strconv.ParseInt(userStr, 10, 32)
|
||||
airportID, e2 := strconv.ParseUint(airportStr, 10, 32)
|
||||
if e1 != nil || e2 != nil {
|
||||
t.status = "owner user ID and central airport ID must be valid numbers"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg {
|
||||
return factionDoneMsg{t.client.UpdateFaction(id, name, continent, leader, background, int32(userID), uint32(airportID))}
|
||||
}
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return factionDoneMsg{t.client.AddFaction(name, continent, leader, background, int32(userID), uint32(airportID))}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *factionsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case factionLoadedMsg:
|
||||
t.items = []db.AdminFaction(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case factionDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
id := t.items[t.cursor].ID
|
||||
return t, func() tea.Msg {
|
||||
return factionDoneMsg{t.client.DeleteFaction(id)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *factionsTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Factions") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Faction"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Faction #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-4s %-14s %-22s %-18s %-7s %s",
|
||||
"ID", "Continent", "Faction", "Leader", "UserID", "AirportID")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 78)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no factions — run sql/seed_factions.sql)") + "\n")
|
||||
}
|
||||
for i, f := range t.items {
|
||||
line := fmt.Sprintf(" %-4d %-14s %-22s %-18s %-7d %d",
|
||||
f.ID, f.Continent, f.Name, f.Leader, f.UserID, f.CentralAirportID)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("a:add e:edit d:delete j/k:nav r:reload q:quit"))
|
||||
return sb.String()
|
||||
}
|
||||
334
internal/adminui/planes.go
Normal file
334
internal/adminui/planes.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type planeLoadedMsg []db.AdminPlane
|
||||
type planeDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
pfName = 0
|
||||
pfSize = 1
|
||||
pfType = 2
|
||||
pfOpType = 3
|
||||
pfMinLevel = 4
|
||||
pfTravelSec = 5
|
||||
pfFuelCost = 6
|
||||
pfCashCost = 7
|
||||
pfMaxPax = 8
|
||||
pfBuyCost = 9
|
||||
pfXP = 10
|
||||
pfCashRew = 11
|
||||
pfCargoRew = 12
|
||||
pfBoardingTime = 13
|
||||
pfFuelingTime = 14
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type planesTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminPlane
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint32
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newPlanesTab(client *db.AdminClient) *planesTab {
|
||||
return &planesTab{client: client}
|
||||
}
|
||||
|
||||
func (t *planesTab) InForm() bool { return t.inForm }
|
||||
func (t *planesTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *planesTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListPlanes()
|
||||
if err != nil {
|
||||
return planeDoneMsg{err}
|
||||
}
|
||||
return planeLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *planesTab) initFields() []field {
|
||||
return []field{
|
||||
newTextField("Name", "e.g. Cessna 172"),
|
||||
newEnumField("Aircraft Size", []string{"small", "medium", "large", "huge"}),
|
||||
newEnumField("Aircraft Type", []string{"plane", "helicopter", "seaplane", "spaceship"}),
|
||||
newEnumField("Operation Type", []string{"passenger", "cargo"}),
|
||||
newTextField("Min Level", "1"),
|
||||
newTextField("Travel Time (sec)", "300"),
|
||||
newTextField("Fuel Cost", "50"),
|
||||
newTextField("Cash Cost", "10"),
|
||||
newTextField("Max Passengers (0=null)", "0"),
|
||||
newTextField("Buy Cost (cash)", "1000"),
|
||||
newTextField("XP Reward", "20"),
|
||||
newTextField("Cash Reward (0=null)", "50"),
|
||||
newTextField("Cargo Reward (0=null)", "0"),
|
||||
newTextField("Boarding Time (sec, 0=skip)", "0"),
|
||||
newTextField("Fueling Time (sec, 0=skip)", "0"),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *planesTab) openForm() {
|
||||
t.fields = t.initFields()
|
||||
t.fields[pfName].input.Focus()
|
||||
t.focusIdx = pfName
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *planesTab) openEditForm(p db.AdminPlane) {
|
||||
t.fields = t.initFields()
|
||||
t.fields[pfName].input.SetValue(p.Name)
|
||||
setEnumSel(&t.fields[pfSize], p.Size)
|
||||
setEnumSel(&t.fields[pfType], p.AircraftType)
|
||||
setEnumSel(&t.fields[pfOpType], p.OperationType)
|
||||
t.fields[pfMinLevel].input.SetValue(strconv.Itoa(int(p.MinLevel)))
|
||||
t.fields[pfTravelSec].input.SetValue(strconv.Itoa(int(p.TravelSec)))
|
||||
t.fields[pfFuelCost].input.SetValue(strconv.Itoa(int(p.FuelCost)))
|
||||
t.fields[pfCashCost].input.SetValue(strconv.Itoa(int(p.CashCost)))
|
||||
t.fields[pfMaxPax].input.SetValue(strconv.Itoa(p.MaxPassengers))
|
||||
t.fields[pfBuyCost].input.SetValue(strconv.Itoa(int(p.BuyCost)))
|
||||
t.fields[pfXP].input.SetValue(strconv.Itoa(int(p.XP)))
|
||||
t.fields[pfCashRew].input.SetValue(strconv.Itoa(p.CashReward))
|
||||
t.fields[pfCargoRew].input.SetValue(strconv.Itoa(p.CargoReward))
|
||||
t.fields[pfBoardingTime].input.SetValue(strconv.Itoa(int(p.BoardingTimeSec)))
|
||||
t.fields[pfFuelingTime].input.SetValue(strconv.Itoa(int(p.FuelingTimeSec)))
|
||||
t.fields[pfName].input.Focus()
|
||||
t.focusIdx = pfName
|
||||
t.isEdit = true
|
||||
t.editID = p.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func mustInt(s string) (int, error) {
|
||||
return strconv.Atoi(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func (t *planesTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[pfName].value())
|
||||
if name == "" {
|
||||
t.status = "name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
|
||||
lvl, err := mustInt(t.fields[pfMinLevel].value())
|
||||
if err != nil {
|
||||
t.status = "min level must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
travelSec, err := mustInt(t.fields[pfTravelSec].value())
|
||||
if err != nil || travelSec <= 0 {
|
||||
t.status = "travel time must be a positive number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
fuelCost, err := mustInt(t.fields[pfFuelCost].value())
|
||||
if err != nil {
|
||||
t.status = "fuel cost must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
cashCost, err := mustInt(t.fields[pfCashCost].value())
|
||||
if err != nil {
|
||||
t.status = "cash cost must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
maxPax, err := mustInt(t.fields[pfMaxPax].value())
|
||||
if err != nil {
|
||||
t.status = "max passengers must be a number (0 for none)"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
buyCost, err := mustInt(t.fields[pfBuyCost].value())
|
||||
if err != nil {
|
||||
t.status = "buy cost must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
xp, err := mustInt(t.fields[pfXP].value())
|
||||
if err != nil {
|
||||
t.status = "XP reward must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
cashRew, err := mustInt(t.fields[pfCashRew].value())
|
||||
if err != nil {
|
||||
t.status = "cash reward must be a number (0 for none)"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
cargoRew, err := mustInt(t.fields[pfCargoRew].value())
|
||||
if err != nil {
|
||||
t.status = "cargo reward must be a number (0 for none)"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
boardingTime, err := mustInt(t.fields[pfBoardingTime].value())
|
||||
if err != nil {
|
||||
t.status = "boarding time must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
fuelingTime, err := mustInt(t.fields[pfFuelingTime].value())
|
||||
if err != nil {
|
||||
t.status = "fueling time must be a number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
|
||||
p := db.AddPlaneParams{
|
||||
Name: name,
|
||||
Size: t.fields[pfSize].value(),
|
||||
AircraftType: t.fields[pfType].value(),
|
||||
OperationType: t.fields[pfOpType].value(),
|
||||
MinLevel: uint32(lvl),
|
||||
FuelCost: uint32(fuelCost),
|
||||
CashCost: uint32(cashCost),
|
||||
MaxPassengers: maxPax,
|
||||
TravelSec: uint32(travelSec),
|
||||
BuyCost: uint32(buyCost),
|
||||
XP: uint32(xp),
|
||||
CashReward: cashRew,
|
||||
CargoReward: cargoRew,
|
||||
BoardingTimeSec: boardingTime,
|
||||
FuelingTimeSec: fuelingTime,
|
||||
}
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg { return planeDoneMsg{t.client.UpdatePlane(id, p)} }
|
||||
}
|
||||
return func() tea.Msg { return planeDoneMsg{t.client.AddPlane(p)} }
|
||||
}
|
||||
|
||||
func (t *planesTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case planeLoadedMsg:
|
||||
t.items = []db.AdminPlane(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case planeDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
id := t.items[t.cursor].ID
|
||||
return t, func() tea.Msg {
|
||||
return planeDoneMsg{t.client.DeletePlane(id)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *planesTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Planes") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Plane"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Plane #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-20s %-8s %-12s %-10s %-5s %-8s %s",
|
||||
"ID", "Name", "Size", "Type", "Op", "Lvl", "Travel", "Buy$")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 80)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no planes — press a to add)") + "\n")
|
||||
}
|
||||
for i, p := range t.items {
|
||||
travelFmt := fmt.Sprintf("%ds", p.TravelSec)
|
||||
line := fmt.Sprintf(" %-5d %-20s %-8s %-12s %-10s %-5d %-8s %d",
|
||||
p.ID, p.Name, p.Size, p.AircraftType, p.OperationType, p.MinLevel, travelFmt, p.BuyCost)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpList))
|
||||
return sb.String()
|
||||
}
|
||||
187
internal/adminui/products.go
Normal file
187
internal/adminui/products.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type prodLoadedMsg []db.AdminProduct
|
||||
type prodDoneMsg struct{ err error }
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type productsTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminProduct
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editID uint16
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newProductsTab(client *db.AdminClient) *productsTab {
|
||||
return &productsTab{client: client}
|
||||
}
|
||||
|
||||
func (t *productsTab) InForm() bool { return t.inForm }
|
||||
|
||||
func (t *productsTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *productsTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListProducts()
|
||||
if err != nil {
|
||||
return prodDoneMsg{err}
|
||||
}
|
||||
return prodLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *productsTab) openForm() {
|
||||
f := newTextField("Name", "e.g. Milk")
|
||||
f.input.Focus()
|
||||
t.fields = []field{f}
|
||||
t.focusIdx = 0
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *productsTab) openEditForm(p db.AdminProduct) {
|
||||
f := prefill("Name", p.Name)
|
||||
f.input.Focus()
|
||||
t.fields = []field{f}
|
||||
t.focusIdx = 0
|
||||
t.isEdit = true
|
||||
t.editID = p.ID
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *productsTab) submit() tea.Cmd {
|
||||
name := strings.TrimSpace(t.fields[0].value())
|
||||
if name == "" {
|
||||
t.status = "name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if t.isEdit {
|
||||
id := t.editID
|
||||
return func() tea.Msg { return prodDoneMsg{t.client.UpdateProduct(id, name)} }
|
||||
}
|
||||
return func() tea.Msg { return prodDoneMsg{t.client.AddProduct(name)} }
|
||||
}
|
||||
|
||||
func (t *productsTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case prodLoadedMsg:
|
||||
t.items = []db.AdminProduct(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case prodDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s", "enter":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// List mode
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
id := t.items[t.cursor].ID
|
||||
return t, func() tea.Msg {
|
||||
return prodDoneMsg{t.client.DeleteProduct(id)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *productsTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Products") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add Product"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Edit Product #%d", t.editID)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no products — press a to add)") + "\n")
|
||||
}
|
||||
for i, p := range t.items {
|
||||
line := fmt.Sprintf(" #%-4d %s", p.ID, p.Name)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpList))
|
||||
return sb.String()
|
||||
}
|
||||
203
internal/adminui/shared.go
Normal file
203
internal/adminui/shared.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Tab extends tea.Model with an InForm indicator used by the root app to
|
||||
// suppress tab-switch shortcuts while a form is open.
|
||||
type Tab interface {
|
||||
tea.Model
|
||||
InForm() bool
|
||||
}
|
||||
|
||||
var (
|
||||
titleSt = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00BFFF"))
|
||||
activeTS = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#39D353"))
|
||||
dimSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
|
||||
errSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4444"))
|
||||
okSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#39D353"))
|
||||
cursorSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700"))
|
||||
labelSt = lipgloss.NewStyle().Foreground(lipgloss.Color("#7EC8E3"))
|
||||
hdrSt = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#E94560"))
|
||||
)
|
||||
|
||||
const (
|
||||
helpList = "a:add e:edit d:del j/k:nav r:reload q:quit"
|
||||
helpForm = "tab/shift+tab:nav ←/→:cycle ctrl+s:save esc:cancel"
|
||||
)
|
||||
|
||||
// prefill creates a pre-filled text field (value shown, not just placeholder).
|
||||
func prefill(label, value string) field {
|
||||
f := newTextField(label, "")
|
||||
f.input.SetValue(value)
|
||||
return f
|
||||
}
|
||||
|
||||
// setEnumSel sets the sel index of an enum field to match value.
|
||||
func setEnumSel(f *field, value string) {
|
||||
for i, opt := range f.options {
|
||||
if opt == value {
|
||||
f.sel = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// field is a form entry: either a text input or an enum cycle selector.
|
||||
type field struct {
|
||||
label string
|
||||
isEnum bool
|
||||
input textinput.Model
|
||||
options []string
|
||||
sel int
|
||||
hidden bool
|
||||
}
|
||||
|
||||
func newTextField(label, placeholder string) field {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.CharLimit = 256
|
||||
return field{label: label, input: ti}
|
||||
}
|
||||
|
||||
func newPasswordField(label string) field {
|
||||
ti := textinput.New()
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.Placeholder = "password"
|
||||
ti.CharLimit = 128
|
||||
return field{label: label, input: ti}
|
||||
}
|
||||
|
||||
func newEnumField(label string, options []string) field {
|
||||
return field{label: label, isEnum: true, options: options}
|
||||
}
|
||||
|
||||
func (f *field) value() string {
|
||||
if f.isEnum {
|
||||
return f.options[f.sel]
|
||||
}
|
||||
return f.input.Value()
|
||||
}
|
||||
|
||||
func (f *field) cycleNext() {
|
||||
if f.isEnum {
|
||||
f.sel = (f.sel + 1) % len(f.options)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *field) cyclePrev() {
|
||||
if f.isEnum {
|
||||
f.sel = (f.sel - 1 + len(f.options)) % len(f.options)
|
||||
}
|
||||
}
|
||||
|
||||
// applyFocus focuses the field at idx and blurs all others.
|
||||
func applyFocus(fields []field, idx int) {
|
||||
for i := range fields {
|
||||
if !fields[i].isEnum {
|
||||
fields[i].input.Blur()
|
||||
}
|
||||
}
|
||||
if idx >= 0 && idx < len(fields) && !fields[idx].isEnum {
|
||||
fields[idx].input.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// nextVisible returns the index of the next non-hidden field after from.
|
||||
func nextVisible(fields []field, from int) int {
|
||||
n := len(fields)
|
||||
for i := 1; i < n; i++ {
|
||||
idx := (from + i) % n
|
||||
if !fields[idx].hidden {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
// prevVisible returns the index of the previous non-hidden field before from.
|
||||
func prevVisible(fields []field, from int) int {
|
||||
n := len(fields)
|
||||
for i := 1; i < n; i++ {
|
||||
idx := (from - i + n) % n
|
||||
if !fields[idx].hidden {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
// handleFormKey routes a key message to the form: navigates fields or updates
|
||||
// the focused one. Returns (newFocusIdx, cmd).
|
||||
func handleFormKey(fields []field, focusIdx int, msg tea.KeyMsg) (int, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "tab", "down":
|
||||
next := nextVisible(fields, focusIdx)
|
||||
applyFocus(fields, next)
|
||||
return next, nil
|
||||
case "shift+tab", "up":
|
||||
prev := prevVisible(fields, focusIdx)
|
||||
applyFocus(fields, prev)
|
||||
return prev, nil
|
||||
}
|
||||
// Route to focused field
|
||||
f := &fields[focusIdx]
|
||||
if f.isEnum {
|
||||
switch msg.String() {
|
||||
case "left", "h":
|
||||
f.cyclePrev()
|
||||
case "right", "l", " ":
|
||||
f.cycleNext()
|
||||
}
|
||||
return focusIdx, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
f.input, cmd = f.input.Update(msg)
|
||||
return focusIdx, cmd
|
||||
}
|
||||
|
||||
// renderForm renders all non-hidden form fields.
|
||||
func renderForm(fields []field, focusIdx int) string {
|
||||
var sb strings.Builder
|
||||
for i := range fields {
|
||||
f := &fields[i]
|
||||
if f.hidden {
|
||||
continue
|
||||
}
|
||||
prefix := " "
|
||||
if i == focusIdx {
|
||||
prefix = cursorSt.Render("▸ ")
|
||||
}
|
||||
lbl := labelSt.Render(f.label + ":")
|
||||
if f.isEnum {
|
||||
var parts []string
|
||||
for j, opt := range f.options {
|
||||
if j == f.sel {
|
||||
parts = append(parts, activeTS.Render("["+opt+"]"))
|
||||
} else {
|
||||
parts = append(parts, dimSt.Render(" "+opt+" "))
|
||||
}
|
||||
}
|
||||
sb.WriteString(prefix + lbl + " " + strings.Join(parts, "") + "\n")
|
||||
} else {
|
||||
sb.WriteString(prefix + lbl + " " + f.input.View() + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderStatus renders the status line (ok or error style).
|
||||
func renderStatus(msg string, isErr bool) string {
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
if isErr {
|
||||
return errSt.Render("✗ " + msg)
|
||||
}
|
||||
return okSt.Render("✓ " + msg)
|
||||
}
|
||||
238
internal/adminui/users.go
Normal file
238
internal/adminui/users.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package adminui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"skyrama-tui/internal/db"
|
||||
)
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type userLoadedMsg []db.AdminUser
|
||||
type userDoneMsg struct{ err error }
|
||||
|
||||
// ── Field indices ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
ufUsername = 0
|
||||
ufPassword = 1
|
||||
ufAirportName = 2
|
||||
ufCountryID = 3
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type usersTab struct {
|
||||
client *db.AdminClient
|
||||
items []db.AdminUser
|
||||
cursor int
|
||||
inForm bool
|
||||
isEdit bool
|
||||
editUsername string
|
||||
fields []field
|
||||
focusIdx int
|
||||
status string
|
||||
isErr bool
|
||||
w, h int
|
||||
}
|
||||
|
||||
func newUsersTab(client *db.AdminClient) *usersTab {
|
||||
return &usersTab{client: client}
|
||||
}
|
||||
|
||||
func (t *usersTab) InForm() bool { return t.inForm }
|
||||
func (t *usersTab) Init() tea.Cmd { return t.load() }
|
||||
|
||||
func (t *usersTab) load() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := t.client.ListUsers()
|
||||
if err != nil {
|
||||
return userDoneMsg{err}
|
||||
}
|
||||
return userLoadedMsg(items)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *usersTab) openForm() {
|
||||
t.fields = []field{
|
||||
newTextField("Username", "e.g. alice"),
|
||||
newPasswordField("Password"),
|
||||
newTextField("Airport Name", "e.g. Alice's Airport"),
|
||||
newTextField("Country ID", "1"),
|
||||
}
|
||||
t.fields[ufUsername].input.Focus()
|
||||
t.focusIdx = ufUsername
|
||||
t.isEdit = false
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *usersTab) openEditForm(u db.AdminUser) {
|
||||
f := newPasswordField("New Password")
|
||||
f.input.Focus()
|
||||
t.fields = []field{f}
|
||||
t.focusIdx = 0
|
||||
t.isEdit = true
|
||||
t.editUsername = u.Username
|
||||
t.inForm = true
|
||||
t.status = ""
|
||||
}
|
||||
|
||||
func (t *usersTab) submit() tea.Cmd {
|
||||
if t.isEdit {
|
||||
password := t.fields[0].value()
|
||||
if len(password) < 4 {
|
||||
t.status = "password must be at least 4 characters"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
username := t.editUsername
|
||||
return func() tea.Msg {
|
||||
return userDoneMsg{t.client.UpdateUserPassword(username, password)}
|
||||
}
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(t.fields[ufUsername].value())
|
||||
password := t.fields[ufPassword].value()
|
||||
airportName := strings.TrimSpace(t.fields[ufAirportName].value())
|
||||
countryIDStr := strings.TrimSpace(t.fields[ufCountryID].value())
|
||||
|
||||
if username == "" {
|
||||
t.status = "username is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if len(password) < 4 {
|
||||
t.status = "password must be at least 4 characters"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
if airportName == "" {
|
||||
t.status = "airport name is required"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
countryID, err := strconv.Atoi(countryIDStr)
|
||||
if err != nil || countryID <= 0 {
|
||||
t.status = "country ID must be a positive number"
|
||||
t.isErr = true
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return userDoneMsg{t.client.CreateUserWithAirport(username, password, airportName, countryID)}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *usersTab) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
t.w, t.h = m.Width, m.Height
|
||||
return t, nil
|
||||
|
||||
case userLoadedMsg:
|
||||
t.items = []db.AdminUser(m)
|
||||
if t.cursor >= len(t.items) && t.cursor > 0 {
|
||||
t.cursor = len(t.items) - 1
|
||||
}
|
||||
return t, nil
|
||||
|
||||
case userDoneMsg:
|
||||
if m.err != nil {
|
||||
t.status = m.err.Error()
|
||||
t.isErr = true
|
||||
return t, nil
|
||||
}
|
||||
t.inForm = false
|
||||
t.status = "saved"
|
||||
t.isErr = false
|
||||
return t, t.load()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.inForm {
|
||||
switch m.String() {
|
||||
case "esc":
|
||||
t.inForm = false
|
||||
t.status = ""
|
||||
case "ctrl+s":
|
||||
return t, t.submit()
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
t.focusIdx, cmd = handleFormKey(t.fields, t.focusIdx, m)
|
||||
return t, cmd
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
switch m.String() {
|
||||
case "q":
|
||||
return t, tea.Quit
|
||||
case "j", "down":
|
||||
if t.cursor < len(t.items)-1 {
|
||||
t.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if t.cursor > 0 {
|
||||
t.cursor--
|
||||
}
|
||||
case "a":
|
||||
t.openForm()
|
||||
case "e":
|
||||
if len(t.items) > 0 {
|
||||
t.openEditForm(t.items[t.cursor])
|
||||
}
|
||||
case "d":
|
||||
if len(t.items) > 0 {
|
||||
username := t.items[t.cursor].Username
|
||||
return t, func() tea.Msg {
|
||||
return userDoneMsg{t.client.DeleteUser(username)}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return t, t.load()
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *usersTab) View() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(hdrSt.Render(" Users") + "\n\n")
|
||||
|
||||
if t.inForm {
|
||||
formTitle := "Add User + Airport"
|
||||
if t.isEdit {
|
||||
formTitle = fmt.Sprintf("Change Password — %s", t.editUsername)
|
||||
}
|
||||
sb.WriteString(titleSt.Render(" "+formTitle) + "\n\n")
|
||||
sb.WriteString(renderForm(t.fields, t.focusIdx))
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render(helpForm))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
header := fmt.Sprintf(" %-5s %-20s %-20s %s", "ID", "Username", "Created", "Status")
|
||||
sb.WriteString(dimSt.Render(header) + "\n")
|
||||
sb.WriteString(dimSt.Render(" "+strings.Repeat("─", 60)) + "\n")
|
||||
|
||||
if len(t.items) == 0 {
|
||||
sb.WriteString(dimSt.Render(" (no users — press a to add)") + "\n")
|
||||
}
|
||||
for i, u := range t.items {
|
||||
status := okSt.Render("active")
|
||||
if u.Deleted {
|
||||
status = errSt.Render("deleted")
|
||||
}
|
||||
created := u.CreatedAt.Format("2006-01-02")
|
||||
line := fmt.Sprintf(" %-5d %-20s %-20s ", u.ID, u.Username, created)
|
||||
if i == t.cursor {
|
||||
sb.WriteString(cursorSt.Render("▸") + activeTS.Render(line[1:]) + status + "\n")
|
||||
} else {
|
||||
sb.WriteString(line + status + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n " + renderStatus(t.status, t.isErr))
|
||||
sb.WriteString("\n\n " + dimSt.Render("a:add e:edit(pw) d:soft-delete j/k:nav r:reload q:quit"))
|
||||
return sb.String()
|
||||
}
|
||||
548
internal/db/admin_client.go
Normal file
548
internal/db/admin_client.go
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// AdminClient provides raw-SQL operations for the admin TUI.
|
||||
type AdminClient struct {
|
||||
db *sql.DB
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewAdminClient opens and pings a MySQL connection.
|
||||
func NewAdminClient(dsn string) (*AdminClient, error) {
|
||||
d, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
d.SetMaxOpenConns(5)
|
||||
d.SetMaxIdleConns(3)
|
||||
d.SetConnMaxLifetime(5 * time.Minute)
|
||||
ctx := context.Background()
|
||||
if err := d.PingContext(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
return &AdminClient{db: d, ctx: ctx}, nil
|
||||
}
|
||||
|
||||
func (c *AdminClient) Close() { c.db.Close() }
|
||||
|
||||
// ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type AdminProduct struct {
|
||||
ID uint16
|
||||
Name string
|
||||
}
|
||||
|
||||
func (c *AdminClient) ListProducts() ([]AdminProduct, error) {
|
||||
rows, err := c.db.QueryContext(c.ctx, `SELECT id, name FROM products ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminProduct
|
||||
for rows.Next() {
|
||||
var p AdminProduct
|
||||
if err := rows.Scan(&p.ID, &p.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *AdminClient) AddProduct(name string) error {
|
||||
_, err := c.db.ExecContext(c.ctx, `INSERT INTO products (name) VALUES (?)`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) DeleteProduct(id uint16) error {
|
||||
_, err := c.db.ExecContext(c.ctx, `DELETE FROM products WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Countries ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type AdminCountry struct {
|
||||
ID uint16
|
||||
Name string
|
||||
Continent string
|
||||
P1, P2, P3 uint16
|
||||
}
|
||||
|
||||
func (c *AdminClient) ListCountries() ([]AdminCountry, error) {
|
||||
rows, err := c.db.QueryContext(c.ctx,
|
||||
`SELECT id, name, continent, produce_1_id, produce_2_id, produce_3_id FROM countries ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminCountry
|
||||
for rows.Next() {
|
||||
var co AdminCountry
|
||||
if err := rows.Scan(&co.ID, &co.Name, &co.Continent, &co.P1, &co.P2, &co.P3); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, co)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *AdminClient) AddCountry(name, continent string, p1, p2, p3 uint16) error {
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`INSERT INTO countries (name,continent,produce_1_id,produce_2_id,produce_3_id) VALUES (?,?,?,?,?)`,
|
||||
name, continent, p1, p2, p3)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Factions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type AdminFaction struct {
|
||||
ID uint32
|
||||
Name string
|
||||
Continent string
|
||||
Leader string
|
||||
Background string
|
||||
UserID int32
|
||||
CentralAirportID uint32
|
||||
}
|
||||
|
||||
func (c *AdminClient) ListFactions() ([]AdminFaction, error) {
|
||||
rows, err := c.db.QueryContext(c.ctx,
|
||||
`SELECT id, name, continent, leader_name, COALESCE(background,''), user_id, central_airport_id
|
||||
FROM factions ORDER BY continent`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminFaction
|
||||
for rows.Next() {
|
||||
var f AdminFaction
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Continent, &f.Leader, &f.Background,
|
||||
&f.UserID, &f.CentralAirportID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *AdminClient) AddFaction(name, continent, leader, background string, userID int32, centralAirportID uint32) error {
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`INSERT INTO factions (name, continent, leader_name, background, user_id, central_airport_id)
|
||||
VALUES (?,?,?,?,?,?)`,
|
||||
name, continent, leader, background, userID, centralAirportID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) UpdateFaction(id uint32, name, continent, leader, background string, userID int32, centralAirportID uint32) error {
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`UPDATE factions SET name=?, continent=?, leader_name=?, background=?, user_id=?, central_airport_id=?
|
||||
WHERE id=?`,
|
||||
name, continent, leader, background, userID, centralAirportID, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) DeleteFaction(id uint32) error {
|
||||
_, err := c.db.ExecContext(c.ctx, `DELETE FROM factions WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Buildings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type AdminBuilding struct {
|
||||
ID uint32
|
||||
Name string
|
||||
MinLevel uint32
|
||||
Cost uint32
|
||||
BuildingType string
|
||||
Details string
|
||||
// raw values used by the edit form
|
||||
Capacity int
|
||||
StorageType string
|
||||
StorageSubtype string
|
||||
OperationType string
|
||||
OperationSize string
|
||||
PassiveType string
|
||||
PassiveRate int
|
||||
}
|
||||
|
||||
func (c *AdminClient) ListBuildings() ([]AdminBuilding, error) {
|
||||
rows, err := c.db.QueryContext(c.ctx, `
|
||||
SELECT id, name, min_level, construction_cost_cash, building_type,
|
||||
COALESCE(storage_type,''), COALESCE(operation_type,''), COALESCE(passive_type,''),
|
||||
COALESCE(capacity,0), COALESCE(passive_rate,0), COALESCE(operation_size,''),
|
||||
COALESCE(storage_subtype,'')
|
||||
FROM buildings ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminBuilding
|
||||
for rows.Next() {
|
||||
var b AdminBuilding
|
||||
if err := rows.Scan(&b.ID, &b.Name, &b.MinLevel, &b.Cost, &b.BuildingType,
|
||||
&b.StorageType, &b.OperationType, &b.PassiveType,
|
||||
&b.Capacity, &b.PassiveRate, &b.OperationSize, &b.StorageSubtype); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch b.BuildingType {
|
||||
case "storage":
|
||||
b.Details = fmt.Sprintf("cap:%d type:%s", b.Capacity, b.StorageType)
|
||||
case "operation":
|
||||
b.Details = fmt.Sprintf("%s/%s", b.OperationType, b.OperationSize)
|
||||
case "passive":
|
||||
b.Details = fmt.Sprintf("%s +%d/s", b.PassiveType, b.PassiveRate)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type AddBuildingParams struct {
|
||||
Name string
|
||||
MinLevel uint32
|
||||
Cost uint32
|
||||
BuildingType string
|
||||
Capacity int
|
||||
StorageType string
|
||||
StorageSubtype string
|
||||
OperationType string
|
||||
OperationSize string
|
||||
PassiveType string
|
||||
PassiveRate int
|
||||
}
|
||||
|
||||
func (c *AdminClient) AddBuilding(p AddBuildingParams) error {
|
||||
switch p.BuildingType {
|
||||
case "storage":
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`INSERT INTO buildings (name,min_level,construction_cost_cash,building_type,capacity,storage_type,storage_subtype)
|
||||
VALUES (?,?,?,'storage',?,?,?)`,
|
||||
p.Name, p.MinLevel, p.Cost, p.Capacity, p.StorageType, p.StorageSubtype)
|
||||
return err
|
||||
case "operation":
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`INSERT INTO buildings (name,min_level,construction_cost_cash,building_type,operation_type,operation_size)
|
||||
VALUES (?,?,?,'operation',?,?)`,
|
||||
p.Name, p.MinLevel, p.Cost, p.OperationType, p.OperationSize)
|
||||
return err
|
||||
case "passive":
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`INSERT INTO buildings (name,min_level,construction_cost_cash,building_type,passive_type,passive_rate)
|
||||
VALUES (?,?,?,'passive',?,?)`,
|
||||
p.Name, p.MinLevel, p.Cost, p.PassiveType, p.PassiveRate)
|
||||
return err
|
||||
default:
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`INSERT INTO buildings (name,min_level,construction_cost_cash,building_type) VALUES (?,?,?,'none')`,
|
||||
p.Name, p.MinLevel, p.Cost)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AdminClient) DeleteBuilding(id uint32) error {
|
||||
_, err := c.db.ExecContext(c.ctx, `DELETE FROM buildings WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Planes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AdminPlane struct {
|
||||
ID uint32
|
||||
Name string
|
||||
Size string
|
||||
AircraftType string
|
||||
OperationType string
|
||||
MinLevel uint32
|
||||
TravelSec uint32
|
||||
FuelCost uint32
|
||||
BuyCost uint32
|
||||
// extra fields for edit form
|
||||
CashCost uint32
|
||||
MaxPassengers int
|
||||
XP uint32
|
||||
CashReward int
|
||||
CargoReward int
|
||||
BoardingTimeSec uint32
|
||||
FuelingTimeSec uint32
|
||||
}
|
||||
|
||||
func (c *AdminClient) ListPlanes() ([]AdminPlane, error) {
|
||||
rows, err := c.db.QueryContext(c.ctx, `
|
||||
SELECT id, name, aircraft_size, aircraft_type, operation_type, min_level,
|
||||
travel_time_inSeconds, operation_cost_fuel, buying_cost_cash,
|
||||
operation_cost_cash, COALESCE(max_passengers,0),
|
||||
completed_flight_experience,
|
||||
COALESCE(completed_flight_cash_reward,0),
|
||||
COALESCE(completed_flight_cargo_reward,0),
|
||||
boarding_time_seconds, fueling_time_seconds
|
||||
FROM planes ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminPlane
|
||||
for rows.Next() {
|
||||
var p AdminPlane
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Size, &p.AircraftType, &p.OperationType,
|
||||
&p.MinLevel, &p.TravelSec, &p.FuelCost, &p.BuyCost,
|
||||
&p.CashCost, &p.MaxPassengers, &p.XP, &p.CashReward, &p.CargoReward,
|
||||
&p.BoardingTimeSec, &p.FuelingTimeSec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type AddPlaneParams struct {
|
||||
Name string
|
||||
Size string
|
||||
AircraftType string
|
||||
OperationType string
|
||||
MinLevel uint32
|
||||
FuelCost uint32
|
||||
CashCost uint32
|
||||
MaxPassengers int
|
||||
TravelSec uint32
|
||||
BuyCost uint32
|
||||
XP uint32
|
||||
CashReward int
|
||||
CargoReward int
|
||||
BoardingTimeSec int
|
||||
FuelingTimeSec int
|
||||
}
|
||||
|
||||
func (c *AdminClient) AddPlane(p AddPlaneParams) error {
|
||||
var maxPax, cashRew, cargoRew interface{}
|
||||
if p.MaxPassengers > 0 {
|
||||
maxPax = p.MaxPassengers
|
||||
}
|
||||
if p.CashReward > 0 {
|
||||
cashRew = p.CashReward
|
||||
}
|
||||
if p.CargoReward > 0 {
|
||||
cargoRew = p.CargoReward
|
||||
}
|
||||
_, err := c.db.ExecContext(c.ctx, `
|
||||
INSERT INTO planes
|
||||
(name,aircraft_size,operation_type,aircraft_type,min_level,operation_cost_fuel,
|
||||
operation_cost_cash,max_passengers,travel_time_inSeconds,buying_cost_cash,
|
||||
completed_flight_experience,completed_flight_cash_reward,completed_flight_cargo_reward,
|
||||
boarding_time_seconds,fueling_time_seconds)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.Name, p.Size, p.OperationType, p.AircraftType, p.MinLevel,
|
||||
p.FuelCost, p.CashCost, maxPax, p.TravelSec, p.BuyCost, p.XP, cashRew, cargoRew,
|
||||
p.BoardingTimeSec, p.FuelingTimeSec)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) DeletePlane(id uint32) error {
|
||||
_, err := c.db.ExecContext(c.ctx, `DELETE FROM planes WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AdminUser struct {
|
||||
ID int32
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
func (c *AdminClient) ListUsers() ([]AdminUser, error) {
|
||||
rows, err := c.db.QueryContext(c.ctx,
|
||||
`SELECT id, username, created_at, (deleted_at IS NOT NULL) FROM users WHERE is_npc = FALSE ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminUser
|
||||
for rows.Next() {
|
||||
var u AdminUser
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt, &u.Deleted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *AdminClient) CreateUserWithAirport(username, password, airportName string, countryID int) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := c.db.BeginTx(c.ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.ExecContext(c.ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, string(hash))
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
userID, _ := res.LastInsertId()
|
||||
_, err = tx.ExecContext(c.ctx,
|
||||
`INSERT INTO airports (name, user_id, origin_country_id, cash) VALUES (?, ?, ?, 10000)`,
|
||||
airportName, userID, countryID)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *AdminClient) DeleteUser(username string) error {
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`UPDATE users SET deleted_at = CURRENT_TIMESTAMP WHERE username = ?`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Airports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type AdminAirport struct {
|
||||
ID uint32
|
||||
Name string
|
||||
Username string
|
||||
Cash uint64
|
||||
Fuel uint32
|
||||
Level uint32
|
||||
MaxFuel uint32
|
||||
MaxPax uint32
|
||||
}
|
||||
|
||||
func (c *AdminClient) ListAirports() ([]AdminAirport, error) {
|
||||
rows, err := c.db.QueryContext(c.ctx, `
|
||||
SELECT a.id, a.name, u.username, a.cash, a.current_fuel, a.player_level,
|
||||
a.max_fuel_capacity, a.max_passenger_capacity
|
||||
FROM airports a JOIN users u ON a.user_id = u.id
|
||||
WHERE a.is_deleted = FALSE ORDER BY a.id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminAirport
|
||||
for rows.Next() {
|
||||
var a AdminAirport
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Username, &a.Cash, &a.Fuel, &a.Level,
|
||||
&a.MaxFuel, &a.MaxPax); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *AdminClient) GiveAirportCash(airportID uint32, amount uint64) error {
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`UPDATE airports SET cash = cash + ? WHERE id = ?`, amount, airportID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) GiveAirportBuilding(airportID, buildingID uint32) error {
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`INSERT INTO airport_buildings (airport_id, building_id) VALUES (?, ?)`, airportID, buildingID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Update methods ────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *AdminClient) UpdateProduct(id uint16, name string) error {
|
||||
_, err := c.db.ExecContext(c.ctx, `UPDATE products SET name=? WHERE id=?`, name, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) UpdateCountry(id uint16, name, continent string, p1, p2, p3 uint16) error {
|
||||
_, err := c.db.ExecContext(c.ctx,
|
||||
`UPDATE countries SET name=?, continent=?, produce_1_id=?, produce_2_id=?, produce_3_id=? WHERE id=?`,
|
||||
name, continent, p1, p2, p3, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) UpdateBuilding(id uint32, p AddBuildingParams) error {
|
||||
var cap, passRate interface{}
|
||||
var storType, storSubtype, opType, opSize, passType interface{}
|
||||
if p.BuildingType == "storage" {
|
||||
cap = p.Capacity
|
||||
storType = p.StorageType
|
||||
storSubtype = p.StorageSubtype
|
||||
}
|
||||
if p.BuildingType == "operation" {
|
||||
opType = p.OperationType
|
||||
opSize = p.OperationSize
|
||||
}
|
||||
if p.BuildingType == "passive" {
|
||||
passType = p.PassiveType
|
||||
passRate = p.PassiveRate
|
||||
}
|
||||
_, err := c.db.ExecContext(c.ctx, `
|
||||
UPDATE buildings SET
|
||||
name=?, min_level=?, construction_cost_cash=?, building_type=?,
|
||||
capacity=?, storage_type=?, storage_subtype=?,
|
||||
operation_type=?, operation_size=?,
|
||||
passive_type=?, passive_rate=?
|
||||
WHERE id=?`,
|
||||
p.Name, p.MinLevel, p.Cost, p.BuildingType,
|
||||
cap, storType, storSubtype,
|
||||
opType, opSize,
|
||||
passType, passRate,
|
||||
id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) UpdatePlane(id uint32, p AddPlaneParams) error {
|
||||
var maxPax, cashRew, cargoRew interface{}
|
||||
if p.MaxPassengers > 0 {
|
||||
maxPax = p.MaxPassengers
|
||||
}
|
||||
if p.CashReward > 0 {
|
||||
cashRew = p.CashReward
|
||||
}
|
||||
if p.CargoReward > 0 {
|
||||
cargoRew = p.CargoReward
|
||||
}
|
||||
_, err := c.db.ExecContext(c.ctx, `
|
||||
UPDATE planes SET
|
||||
name=?, aircraft_size=?, operation_type=?, aircraft_type=?, min_level=?,
|
||||
operation_cost_fuel=?, operation_cost_cash=?, max_passengers=?,
|
||||
travel_time_inSeconds=?, buying_cost_cash=?,
|
||||
completed_flight_experience=?, completed_flight_cash_reward=?,
|
||||
completed_flight_cargo_reward=?,
|
||||
boarding_time_seconds=?, fueling_time_seconds=?
|
||||
WHERE id=?`,
|
||||
p.Name, p.Size, p.OperationType, p.AircraftType, p.MinLevel,
|
||||
p.FuelCost, p.CashCost, maxPax, p.TravelSec, p.BuyCost,
|
||||
p.XP, cashRew, cargoRew,
|
||||
p.BoardingTimeSec, p.FuelingTimeSec, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) UpdateUserPassword(username, newPassword string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.db.ExecContext(c.ctx,
|
||||
`UPDATE users SET password_hash=? WHERE username=?`, string(hash), username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AdminClient) UpdateAirport(id uint32, cash uint64, level, maxFuel, maxPax uint32) error {
|
||||
_, err := c.db.ExecContext(c.ctx, `
|
||||
UPDATE airports SET cash=?, player_level=?, max_fuel_capacity=?, max_passenger_capacity=?
|
||||
WHERE id=?`,
|
||||
cash, level, maxFuel, maxPax, id)
|
||||
return err
|
||||
}
|
||||
1384
internal/db/client.go
Normal file
1384
internal/db/client.go
Normal file
File diff suppressed because it is too large
Load diff
31
internal/db/db.go
Normal file
31
internal/db/db.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
704
internal/db/models.go
Normal file
704
internal/db/models.go
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BuildingsBuildingType string
|
||||
|
||||
const (
|
||||
BuildingsBuildingTypeNone BuildingsBuildingType = "none"
|
||||
BuildingsBuildingTypeStorage BuildingsBuildingType = "storage"
|
||||
BuildingsBuildingTypeSupport BuildingsBuildingType = "support"
|
||||
BuildingsBuildingTypeOperation BuildingsBuildingType = "operation"
|
||||
BuildingsBuildingTypePassive BuildingsBuildingType = "passive"
|
||||
BuildingsBuildingTypeShop BuildingsBuildingType = "shop"
|
||||
BuildingsBuildingTypeProduction BuildingsBuildingType = "production"
|
||||
)
|
||||
|
||||
func (e *BuildingsBuildingType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = BuildingsBuildingType(s)
|
||||
case string:
|
||||
*e = BuildingsBuildingType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for BuildingsBuildingType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullBuildingsBuildingType struct {
|
||||
BuildingsBuildingType BuildingsBuildingType `json:"buildings_building_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if BuildingsBuildingType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullBuildingsBuildingType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.BuildingsBuildingType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.BuildingsBuildingType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullBuildingsBuildingType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.BuildingsBuildingType), nil
|
||||
}
|
||||
|
||||
type BuildingsOperationSize string
|
||||
|
||||
const (
|
||||
BuildingsOperationSizeSmall BuildingsOperationSize = "small"
|
||||
BuildingsOperationSizeMedium BuildingsOperationSize = "medium"
|
||||
BuildingsOperationSizeLarge BuildingsOperationSize = "large"
|
||||
BuildingsOperationSizeHuge BuildingsOperationSize = "huge"
|
||||
)
|
||||
|
||||
func (e *BuildingsOperationSize) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = BuildingsOperationSize(s)
|
||||
case string:
|
||||
*e = BuildingsOperationSize(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for BuildingsOperationSize: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullBuildingsOperationSize struct {
|
||||
BuildingsOperationSize BuildingsOperationSize `json:"buildings_operation_size"`
|
||||
Valid bool `json:"valid"` // Valid is true if BuildingsOperationSize is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullBuildingsOperationSize) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.BuildingsOperationSize, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.BuildingsOperationSize.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullBuildingsOperationSize) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.BuildingsOperationSize), nil
|
||||
}
|
||||
|
||||
type BuildingsOperationType string
|
||||
|
||||
const (
|
||||
BuildingsOperationTypeRunway BuildingsOperationType = "runway"
|
||||
BuildingsOperationTypeService BuildingsOperationType = "service"
|
||||
BuildingsOperationTypeProduction BuildingsOperationType = "production"
|
||||
)
|
||||
|
||||
func (e *BuildingsOperationType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = BuildingsOperationType(s)
|
||||
case string:
|
||||
*e = BuildingsOperationType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for BuildingsOperationType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullBuildingsOperationType struct {
|
||||
BuildingsOperationType BuildingsOperationType `json:"buildings_operation_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if BuildingsOperationType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullBuildingsOperationType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.BuildingsOperationType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.BuildingsOperationType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullBuildingsOperationType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.BuildingsOperationType), nil
|
||||
}
|
||||
|
||||
type BuildingsPassiveType string
|
||||
|
||||
const (
|
||||
BuildingsPassiveTypePassengers BuildingsPassiveType = "passengers"
|
||||
BuildingsPassiveTypeFuel BuildingsPassiveType = "fuel"
|
||||
)
|
||||
|
||||
func (e *BuildingsPassiveType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = BuildingsPassiveType(s)
|
||||
case string:
|
||||
*e = BuildingsPassiveType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for BuildingsPassiveType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullBuildingsPassiveType struct {
|
||||
BuildingsPassiveType BuildingsPassiveType `json:"buildings_passive_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if BuildingsPassiveType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullBuildingsPassiveType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.BuildingsPassiveType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.BuildingsPassiveType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullBuildingsPassiveType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.BuildingsPassiveType), nil
|
||||
}
|
||||
|
||||
type BuildingsStorageSubtype string
|
||||
|
||||
const (
|
||||
BuildingsStorageSubtypeNone BuildingsStorageSubtype = "none"
|
||||
BuildingsStorageSubtypeAirplane BuildingsStorageSubtype = "airplane"
|
||||
BuildingsStorageSubtypeHelicopter BuildingsStorageSubtype = "helicopter"
|
||||
BuildingsStorageSubtypeSeaplane BuildingsStorageSubtype = "seaplane"
|
||||
BuildingsStorageSubtypeSpaceship BuildingsStorageSubtype = "spaceship"
|
||||
)
|
||||
|
||||
func (e *BuildingsStorageSubtype) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = BuildingsStorageSubtype(s)
|
||||
case string:
|
||||
*e = BuildingsStorageSubtype(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for BuildingsStorageSubtype: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullBuildingsStorageSubtype struct {
|
||||
BuildingsStorageSubtype BuildingsStorageSubtype `json:"buildings_storage_subtype"`
|
||||
Valid bool `json:"valid"` // Valid is true if BuildingsStorageSubtype is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullBuildingsStorageSubtype) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.BuildingsStorageSubtype, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.BuildingsStorageSubtype.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullBuildingsStorageSubtype) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.BuildingsStorageSubtype), nil
|
||||
}
|
||||
|
||||
type BuildingsStorageType string
|
||||
|
||||
const (
|
||||
BuildingsStorageTypeFuel BuildingsStorageType = "fuel"
|
||||
BuildingsStorageTypePlane BuildingsStorageType = "plane"
|
||||
BuildingsStorageTypeProduct BuildingsStorageType = "product"
|
||||
)
|
||||
|
||||
func (e *BuildingsStorageType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = BuildingsStorageType(s)
|
||||
case string:
|
||||
*e = BuildingsStorageType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for BuildingsStorageType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullBuildingsStorageType struct {
|
||||
BuildingsStorageType BuildingsStorageType `json:"buildings_storage_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if BuildingsStorageType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullBuildingsStorageType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.BuildingsStorageType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.BuildingsStorageType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullBuildingsStorageType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.BuildingsStorageType), nil
|
||||
}
|
||||
|
||||
type CountriesContinent string
|
||||
|
||||
const (
|
||||
CountriesContinentVoid CountriesContinent = "void"
|
||||
CountriesContinentAfrica CountriesContinent = "Africa"
|
||||
CountriesContinentAntarctica CountriesContinent = "Antarctica"
|
||||
CountriesContinentAsia CountriesContinent = "Asia"
|
||||
CountriesContinentEurope CountriesContinent = "Europe"
|
||||
CountriesContinentNorthAmerica CountriesContinent = "North America"
|
||||
CountriesContinentOceania CountriesContinent = "Oceania"
|
||||
CountriesContinentSouthAmerica CountriesContinent = "South America"
|
||||
)
|
||||
|
||||
func (e *CountriesContinent) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = CountriesContinent(s)
|
||||
case string:
|
||||
*e = CountriesContinent(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for CountriesContinent: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullCountriesContinent struct {
|
||||
CountriesContinent CountriesContinent `json:"countries_continent"`
|
||||
Valid bool `json:"valid"` // Valid is true if CountriesContinent is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullCountriesContinent) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.CountriesContinent, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.CountriesContinent.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullCountriesContinent) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.CountriesContinent), nil
|
||||
}
|
||||
|
||||
type CurrencyTransactionLogsCurrencyType string
|
||||
|
||||
const (
|
||||
CurrencyTransactionLogsCurrencyTypeCash CurrencyTransactionLogsCurrencyType = "cash"
|
||||
CurrencyTransactionLogsCurrencyTypeFuel CurrencyTransactionLogsCurrencyType = "fuel"
|
||||
CurrencyTransactionLogsCurrencyTypePassengers CurrencyTransactionLogsCurrencyType = "passengers"
|
||||
)
|
||||
|
||||
func (e *CurrencyTransactionLogsCurrencyType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = CurrencyTransactionLogsCurrencyType(s)
|
||||
case string:
|
||||
*e = CurrencyTransactionLogsCurrencyType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for CurrencyTransactionLogsCurrencyType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullCurrencyTransactionLogsCurrencyType struct {
|
||||
CurrencyTransactionLogsCurrencyType CurrencyTransactionLogsCurrencyType `json:"currency_transaction_logs_currency_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if CurrencyTransactionLogsCurrencyType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullCurrencyTransactionLogsCurrencyType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.CurrencyTransactionLogsCurrencyType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.CurrencyTransactionLogsCurrencyType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullCurrencyTransactionLogsCurrencyType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.CurrencyTransactionLogsCurrencyType), nil
|
||||
}
|
||||
|
||||
type PlanesAircraftSize string
|
||||
|
||||
const (
|
||||
PlanesAircraftSizeSmall PlanesAircraftSize = "small"
|
||||
PlanesAircraftSizeMedium PlanesAircraftSize = "medium"
|
||||
PlanesAircraftSizeLarge PlanesAircraftSize = "large"
|
||||
PlanesAircraftSizeHuge PlanesAircraftSize = "huge"
|
||||
)
|
||||
|
||||
func (e *PlanesAircraftSize) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = PlanesAircraftSize(s)
|
||||
case string:
|
||||
*e = PlanesAircraftSize(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for PlanesAircraftSize: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullPlanesAircraftSize struct {
|
||||
PlanesAircraftSize PlanesAircraftSize `json:"planes_aircraft_size"`
|
||||
Valid bool `json:"valid"` // Valid is true if PlanesAircraftSize is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullPlanesAircraftSize) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.PlanesAircraftSize, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.PlanesAircraftSize.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullPlanesAircraftSize) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.PlanesAircraftSize), nil
|
||||
}
|
||||
|
||||
type PlanesAircraftType string
|
||||
|
||||
const (
|
||||
PlanesAircraftTypePlane PlanesAircraftType = "plane"
|
||||
PlanesAircraftTypeHelicopter PlanesAircraftType = "helicopter"
|
||||
PlanesAircraftTypeSeaplane PlanesAircraftType = "seaplane"
|
||||
PlanesAircraftTypeSpaceship PlanesAircraftType = "spaceship"
|
||||
)
|
||||
|
||||
func (e *PlanesAircraftType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = PlanesAircraftType(s)
|
||||
case string:
|
||||
*e = PlanesAircraftType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for PlanesAircraftType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullPlanesAircraftType struct {
|
||||
PlanesAircraftType PlanesAircraftType `json:"planes_aircraft_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if PlanesAircraftType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullPlanesAircraftType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.PlanesAircraftType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.PlanesAircraftType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullPlanesAircraftType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.PlanesAircraftType), nil
|
||||
}
|
||||
|
||||
type PlanesOperationType string
|
||||
|
||||
const (
|
||||
PlanesOperationTypePassenger PlanesOperationType = "passenger"
|
||||
PlanesOperationTypeCargo PlanesOperationType = "cargo"
|
||||
)
|
||||
|
||||
func (e *PlanesOperationType) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = PlanesOperationType(s)
|
||||
case string:
|
||||
*e = PlanesOperationType(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for PlanesOperationType: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullPlanesOperationType struct {
|
||||
PlanesOperationType PlanesOperationType `json:"planes_operation_type"`
|
||||
Valid bool `json:"valid"` // Valid is true if PlanesOperationType is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullPlanesOperationType) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.PlanesOperationType, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.PlanesOperationType.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullPlanesOperationType) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.PlanesOperationType), nil
|
||||
}
|
||||
|
||||
type PlayerPlanesStatus string
|
||||
|
||||
const (
|
||||
PlayerPlanesStatusIdle PlayerPlanesStatus = "idle"
|
||||
PlayerPlanesStatusMaintenance PlayerPlanesStatus = "maintenance"
|
||||
PlayerPlanesStatusBoarding PlayerPlanesStatus = "boarding"
|
||||
PlayerPlanesStatusTaxiing PlayerPlanesStatus = "taxiing"
|
||||
PlayerPlanesStatusFlying PlayerPlanesStatus = "flying"
|
||||
)
|
||||
|
||||
func (e *PlayerPlanesStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = PlayerPlanesStatus(s)
|
||||
case string:
|
||||
*e = PlayerPlanesStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for PlayerPlanesStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullPlayerPlanesStatus struct {
|
||||
PlayerPlanesStatus PlayerPlanesStatus `json:"player_planes_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if PlayerPlanesStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullPlayerPlanesStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.PlayerPlanesStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.PlayerPlanesStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullPlayerPlanesStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.PlayerPlanesStatus), nil
|
||||
}
|
||||
|
||||
type Airport struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UserID int32 `json:"user_id"`
|
||||
OriginCountryID uint16 `json:"origin_country_id"`
|
||||
Cash uint64 `json:"cash"`
|
||||
CurrentPassengers uint32 `json:"current_passengers"`
|
||||
MaxPassengerCapacity uint32 `json:"max_passenger_capacity"`
|
||||
CurrentFuel uint32 `json:"current_fuel"`
|
||||
MaxFuelCapacity uint32 `json:"max_fuel_capacity"`
|
||||
FuelGenerationRate uint32 `json:"fuel_generation_rate"`
|
||||
PassengerGenerationRate uint32 `json:"passenger_generation_rate"`
|
||||
ResourcesLastUpdatedAt time.Time `json:"resources_last_updated_at"`
|
||||
IsAvailable bool `json:"is_available"`
|
||||
PlayerLevel uint32 `json:"player_level"`
|
||||
ExperiencePoints uint64 `json:"experience_points"`
|
||||
IsDeleted sql.NullBool `json:"is_deleted"`
|
||||
}
|
||||
|
||||
type AirportBuilding struct {
|
||||
ID uint32 `json:"id"`
|
||||
AirportID uint32 `json:"airport_id"`
|
||||
BuildingID uint32 `json:"building_id"`
|
||||
RecipeID sql.NullInt32 `json:"recipe_id"`
|
||||
StorageProduct sql.NullInt32 `json:"storage_product"`
|
||||
Storage1 sql.NullInt32 `json:"storage_1"`
|
||||
Storage2 sql.NullInt32 `json:"storage_2"`
|
||||
Storage3 sql.NullInt32 `json:"storage_3"`
|
||||
Level uint32 `json:"level"`
|
||||
}
|
||||
|
||||
type AirportContract struct {
|
||||
ID uint32 `json:"id"`
|
||||
AirportID uint32 `json:"airport_id"`
|
||||
Title string `json:"title"`
|
||||
RequiredProductID uint16 `json:"required_product_id"`
|
||||
RequiredAmount uint32 `json:"required_amount"`
|
||||
CurrentAmountDelivered uint32 `json:"current_amount_delivered"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type AirportInventory struct {
|
||||
AirportID uint32 `json:"airport_id"`
|
||||
ProductID uint16 `json:"product_id"`
|
||||
Quantity uint32 `json:"quantity"`
|
||||
}
|
||||
|
||||
type Building struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MinLevel uint32 `json:"min_level"`
|
||||
ConstructionCostCash uint32 `json:"construction_cost_cash"`
|
||||
UpgradeTier sql.NullInt16 `json:"upgrade_tier"`
|
||||
UpgradeCostCashToUptier sql.NullInt32 `json:"upgrade_cost_cash_to_uptier"`
|
||||
BuildingType BuildingsBuildingType `json:"building_type"`
|
||||
Capacity sql.NullInt32 `json:"capacity"`
|
||||
StorageType NullBuildingsStorageType `json:"storage_type"`
|
||||
StorageSubtype NullBuildingsStorageSubtype `json:"storage_subtype"`
|
||||
EffectID sql.NullInt16 `json:"effect_id"`
|
||||
EffectValue sql.NullInt32 `json:"effect_value"`
|
||||
OperationSize NullBuildingsOperationSize `json:"operation_size"`
|
||||
OperationType NullBuildingsOperationType `json:"operation_type"`
|
||||
PassiveType NullBuildingsPassiveType `json:"passive_type"`
|
||||
PassiveRate sql.NullInt32 `json:"passive_rate"`
|
||||
}
|
||||
|
||||
type BuildingProduct struct {
|
||||
ID uint32 `json:"id"`
|
||||
BuildingID uint32 `json:"building_id"`
|
||||
RecipeID sql.NullInt32 `json:"recipe_id"`
|
||||
StorageProduct sql.NullInt32 `json:"storage_product"`
|
||||
Storage1 sql.NullInt32 `json:"storage_1"`
|
||||
Storage2 sql.NullInt32 `json:"storage_2"`
|
||||
Storage3 sql.NullInt32 `json:"storage_3"`
|
||||
}
|
||||
|
||||
type CompletedFlightRoutesLog struct {
|
||||
ID uint32 `json:"id"`
|
||||
OriginAirportID uint32 `json:"origin_airport_id"`
|
||||
DestinationAirportID uint32 `json:"destination_airport_id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime sql.NullTime `json:"end_time"`
|
||||
}
|
||||
|
||||
type Country struct {
|
||||
ID uint16 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Continent CountriesContinent `json:"continent"`
|
||||
Produce1ID uint16 `json:"produce_1_id"`
|
||||
Produce2ID uint16 `json:"produce_2_id"`
|
||||
Produce3ID uint16 `json:"produce_3_id"`
|
||||
}
|
||||
|
||||
type CurrencyTransactionLog struct {
|
||||
ID uint64 `json:"id"`
|
||||
AirportID uint32 `json:"airport_id"`
|
||||
AmountChanged int32 `json:"amount_changed"`
|
||||
CurrencyType CurrencyTransactionLogsCurrencyType `json:"currency_type"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
type Effect struct {
|
||||
ID uint16 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Plane struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MinLevel uint32 `json:"min_level"`
|
||||
OperationCostFuel uint32 `json:"operation_cost_fuel"`
|
||||
OperationCostCash uint32 `json:"operation_cost_cash"`
|
||||
MaxPassengers sql.NullInt32 `json:"max_passengers"`
|
||||
TravelTimeInseconds uint32 `json:"travel_time_inseconds"`
|
||||
OperationType PlanesOperationType `json:"operation_type"`
|
||||
AircraftSize PlanesAircraftSize `json:"aircraft_size"`
|
||||
AircraftType PlanesAircraftType `json:"aircraft_type"`
|
||||
BuyingCostCash uint32 `json:"buying_cost_cash"`
|
||||
CompletedFlightExperience uint32 `json:"completed_flight_experience"`
|
||||
CompletedFlightCashReward sql.NullInt32 `json:"completed_flight_cash_reward"`
|
||||
CompletedFlightCargoReward sql.NullInt32 `json:"completed_flight_cargo_reward"`
|
||||
}
|
||||
|
||||
type PlayerPlane struct {
|
||||
ID uint32 `json:"id"`
|
||||
AirportID uint32 `json:"airport_id"`
|
||||
PlaneID uint32 `json:"plane_id"`
|
||||
AssignedHangarID uint32 `json:"assigned_hangar_id"`
|
||||
CurrentBuildingID sql.NullInt32 `json:"current_building_id"`
|
||||
Status PlayerPlanesStatus `json:"status"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
FlightStartTime sql.NullTime `json:"flight_start_time"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID uint16 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Recipe struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Ingredient1ID sql.NullInt16 `json:"ingredient_1_id"`
|
||||
Ingredient1Amount sql.NullInt32 `json:"ingredient_1_amount"`
|
||||
Ingredient2ID sql.NullInt16 `json:"ingredient_2_id"`
|
||||
Ingredient2Amount sql.NullInt32 `json:"ingredient_2_amount"`
|
||||
Ingredient3ID sql.NullInt16 `json:"ingredient_3_id"`
|
||||
Ingredient3Amount sql.NullInt32 `json:"ingredient_3_amount"`
|
||||
ProductID uint16 `json:"product_id"`
|
||||
YieldAmount uint32 `json:"yield_amount"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int32 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
DeletedAt sql.NullTime `json:"deleted_at"`
|
||||
}
|
||||
108
internal/db/querier.go
Normal file
108
internal/db/querier.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
AddBuildingToAirportByUsername(ctx context.Context, arg AddBuildingToAirportByUsernameParams) (sql.Result, error)
|
||||
AddContractToAirport(ctx context.Context, arg AddContractToAirportParams) (sql.Result, error)
|
||||
AddCountry(ctx context.Context, arg AddCountryParams) (sql.Result, error)
|
||||
// ============================================================================
|
||||
// 4. BUILDINGS & PRODUCTION ENGINE
|
||||
// ============================================================================
|
||||
AddRecipe(ctx context.Context, arg AddRecipeParams) (sql.Result, error)
|
||||
Addproduct(ctx context.Context, name string) (sql.Result, error)
|
||||
// ============================================================================
|
||||
// 5. FLEET & DYNAMIC INFRASTRUCTURE OPERATIONS
|
||||
// ============================================================================
|
||||
BuyPlaneForPlayer(ctx context.Context, arg BuyPlaneForPlayerParams) (sql.Result, error)
|
||||
CheckHangarOccupancy(ctx context.Context, currentBuildingID sql.NullInt32) (int64, error)
|
||||
CheckHangarOccupancyByUsername(ctx context.Context, arg CheckHangarOccupancyByUsernameParams) (int64, error)
|
||||
// specific hangar
|
||||
CheckRunwayOccupancyByUsername(ctx context.Context, arg CheckRunwayOccupancyByUsernameParams) (int64, error)
|
||||
CompleteContract(ctx context.Context, id uint32) error
|
||||
// ============================================================================
|
||||
// 2. AIRPORT CORE MANAGEMENT
|
||||
// ============================================================================
|
||||
// airport row management
|
||||
CreateAirportByUserName(ctx context.Context, arg CreateAirportByUserNameParams) (sql.Result, error)
|
||||
// ============================================================================
|
||||
// 1. AUTHENTICATION & USERS
|
||||
// ============================================================================
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error)
|
||||
DeleteAirportByUserName(ctx context.Context, username string) error
|
||||
DeleteUserByUsername(ctx context.Context, username string) error
|
||||
FindAvailableInfrastructure(ctx context.Context, arg FindAvailableInfrastructureParams) (uint32, error)
|
||||
FindAvailableInfrastructureForPlane(ctx context.Context, arg FindAvailableInfrastructureForPlaneParams) (uint32, error)
|
||||
// ============================================================================
|
||||
// 7. PROGRESSION CONTRACTS & ECONOMY LOGS
|
||||
// ============================================================================
|
||||
GetActiveContracts(ctx context.Context, airportID uint32) ([]GetActiveContractsRow, error)
|
||||
GetActiveContractsWithProduct(ctx context.Context, airportID uint32) ([]GetActiveContractsWithProductRow, error)
|
||||
// ============================================================================
|
||||
// 8. MISSING QUERIES (added for TUI — regenerate sqlc after adding these)
|
||||
// ============================================================================
|
||||
GetAirportBuildingsByAirportId(ctx context.Context, airportID uint32) ([]GetAirportBuildingsByAirportIdRow, error)
|
||||
GetAirportBuildingsByUsername(ctx context.Context, username string) ([]GetAirportBuildingsByUsernameRow, error)
|
||||
GetAirportByUserId(ctx context.Context, userID int32) (GetAirportByUserIdRow, error)
|
||||
GetAirportByUserName(ctx context.Context, username string) (GetAirportByUserNameRow, error)
|
||||
GetAllBuildings(ctx context.Context) ([]GetAllBuildingsRow, error)
|
||||
// ============================================================================
|
||||
// 3. country
|
||||
// ============================================================================
|
||||
GetAllCountries(ctx context.Context) ([]GetAllCountriesRow, error)
|
||||
GetAllPlanes(ctx context.Context) ([]Plane, error)
|
||||
GetAllProducts(ctx context.Context) ([]Product, error)
|
||||
GetCountryById(ctx context.Context, id uint16) (GetCountryByIdRow, error)
|
||||
GetCountryByName(ctx context.Context, name string) (GetCountryByNameRow, error)
|
||||
GetPlaneById(ctx context.Context, id uint32) (Plane, error)
|
||||
GetPlanesByAircraftSize(ctx context.Context, aircraftSize PlanesAircraftSize) ([]Plane, error)
|
||||
GetPlanesByAircraftType(ctx context.Context, aircraftType PlanesAircraftType) ([]Plane, error)
|
||||
GetPlanesByMinLevel(ctx context.Context, minLevel uint32) ([]Plane, error)
|
||||
GetPlanesByOperationType(ctx context.Context, operationType PlanesOperationType) ([]Plane, error)
|
||||
// ============================================================================
|
||||
// 6. TIME-BASED FLIGHT ENGINE
|
||||
// ============================================================================
|
||||
GetPlanesReadyToLand(ctx context.Context) ([]GetPlanesReadyToLandRow, error)
|
||||
// specific runway
|
||||
GetPlanesReadyToLandByUsername(ctx context.Context, username string) ([]GetPlanesReadyToLandByUsernameRow, error)
|
||||
GetPlayerFleet(ctx context.Context, airportID uint32) ([]GetPlayerFleetRow, error)
|
||||
GetPlayerPlanesByUsername(ctx context.Context, username string) ([]GetPlayerPlanesByUsernameRow, error)
|
||||
GetProductById(ctx context.Context, id uint16) (Product, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
|
||||
GetUserIdByUsername(ctx context.Context, username string) (int32, error)
|
||||
LogCompletedFlightRoute(ctx context.Context, arg LogCompletedFlightRouteParams) error
|
||||
LogCurrencyTransaction(ctx context.Context, arg LogCurrencyTransactionParams) error
|
||||
UpdateAirportCash(ctx context.Context, arg UpdateAirportCashParams) error
|
||||
// airport resource management
|
||||
UpdateAirportCurrencies(ctx context.Context, arg UpdateAirportCurrenciesParams) error
|
||||
UpdateAirportFuel(ctx context.Context, arg UpdateAirportFuelParams) error
|
||||
UpdateAirportFuelRate(ctx context.Context, arg UpdateAirportFuelRateParams) error
|
||||
UpdateAirportMaxFuel(ctx context.Context, arg UpdateAirportMaxFuelParams) error
|
||||
UpdateAirportPassengerRate(ctx context.Context, arg UpdateAirportPassengerRateParams) error
|
||||
UpdateAirportResources(ctx context.Context, id uint32) error
|
||||
UpdateAirportResourcesUpdated(ctx context.Context, arg UpdateAirportResourcesUpdatedParams) error
|
||||
UpdateAirportmaxPassengers(ctx context.Context, arg UpdateAirportmaxPassengersParams) error
|
||||
UpdateAirportpassengers(ctx context.Context, arg UpdateAirportpassengersParams) error
|
||||
UpdateContractDelivery(ctx context.Context, arg UpdateContractDeliveryParams) error
|
||||
UpdatePlaneState(ctx context.Context, arg UpdatePlaneStateParams) error
|
||||
UpdatePlayerPlaneState(ctx context.Context, arg UpdatePlayerPlaneStateParams) error
|
||||
UpdateUserPasswordByUserName(ctx context.Context, arg UpdateUserPasswordByUserNameParams) error
|
||||
addBuilding(ctx context.Context, arg addBuildingParams) (sql.Result, error)
|
||||
addOperationalBuilding(ctx context.Context, arg addOperationalBuildingParams) (sql.Result, error)
|
||||
addPassiveProductionBuilding(ctx context.Context, arg addPassiveProductionBuildingParams) (sql.Result, error)
|
||||
// planes & fleet management
|
||||
addPlane(ctx context.Context, arg addPlaneParams) (sql.Result, error)
|
||||
addPlaneToAirportByUsername(ctx context.Context, arg addPlaneToAirportByUsernameParams) (sql.Result, error)
|
||||
addStorageBuilding(ctx context.Context, arg addStorageBuildingParams) (sql.Result, error)
|
||||
updatePlayerExperience(ctx context.Context, arg updatePlayerExperienceParams) error
|
||||
updatePlayerLevel(ctx context.Context, arg updatePlayerLevelParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
1795
internal/db/queries.sql.go
Normal file
1795
internal/db/queries.sql.go
Normal file
File diff suppressed because it is too large
Load diff
258
internal/model/model.go
Normal file
258
internal/model/model.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── User ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type User struct {
|
||||
ID int32
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
DeletedAt sql.NullTime
|
||||
}
|
||||
|
||||
// ─── Airport ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Airport struct {
|
||||
ID uint32
|
||||
Name string
|
||||
UserID uint32
|
||||
OriginCountryID uint16
|
||||
Cash uint64
|
||||
CurrentPassengers uint32
|
||||
MaxPassengerCapacity uint32
|
||||
CurrentFuel uint32
|
||||
MaxFuelCapacity uint32
|
||||
FuelGenerationRate uint32
|
||||
PassengerGenerationRate uint32
|
||||
ResourcesLastUpdatedAt time.Time
|
||||
IsAvailable bool
|
||||
IsCentral bool
|
||||
PlayerLevel uint32
|
||||
ExperiencePoints uint64
|
||||
StorageCap uint32
|
||||
OperationCap uint32
|
||||
IsDeleted bool
|
||||
}
|
||||
|
||||
// CentralAirport is a system-owned destination port (one per continent), shown
|
||||
// in the Fleet dispatch picker. Joined with its owning faction for flavour.
|
||||
type CentralAirport struct {
|
||||
AirportID uint32
|
||||
Continent string
|
||||
Name string
|
||||
FactionName string
|
||||
LeaderName string
|
||||
}
|
||||
|
||||
// FacilityStatus is the occupancy of one operation-building class (a runway or
|
||||
// service bay of a given aircraft size): how many exist vs. how many are busy.
|
||||
type FacilityStatus struct {
|
||||
OperationType string // "runway" | "service"
|
||||
Size string // "small" | "medium" | "large" | "huge"
|
||||
Total int
|
||||
Busy int
|
||||
}
|
||||
|
||||
// Faction is the NPC owner of a continent's central port — lore / future content.
|
||||
type Faction struct {
|
||||
ID uint32
|
||||
Name string
|
||||
Continent string
|
||||
LeaderName string
|
||||
Background string
|
||||
UserID int32
|
||||
CentralAirportID uint32
|
||||
}
|
||||
|
||||
// ─── Country ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Country struct {
|
||||
ID uint16
|
||||
Name string
|
||||
}
|
||||
|
||||
// ─── Plane (template) ────────────────────────────────────────────────────────
|
||||
|
||||
type Plane struct {
|
||||
ID uint32
|
||||
Name string
|
||||
MinLevel uint32
|
||||
OperationCostFuel uint32
|
||||
OperationCostCash uint32
|
||||
MaxPassengers sql.NullInt32
|
||||
TravelTimeInSeconds uint32
|
||||
OperationType string // "passenger" | "cargo"
|
||||
AircraftSize string // "small" | "medium" | "large" | "huge"
|
||||
AircraftType string // "plane" | "helicopter" | "seaplane" | "spaceship"
|
||||
BuyingCostCash uint32
|
||||
CompletedFlightExperience uint32
|
||||
CompletedFlightCashReward sql.NullInt32
|
||||
CompletedFlightCargoReward sql.NullInt32
|
||||
BoardingTimeSec uint32
|
||||
FuelingTimeSec uint32
|
||||
}
|
||||
|
||||
// ─── Player Plane ─────────────────────────────────────────────────────────────
|
||||
|
||||
type PlayerPlane struct {
|
||||
ID uint32
|
||||
AirportID uint32
|
||||
PlaneID uint32
|
||||
AssignedHangarID uint32
|
||||
CurrentBuildingID sql.NullInt32
|
||||
Status string // "idle"|"maintenance"|"boarding"|"taxiing"|"flying"|"unloading"
|
||||
UpdatedAt time.Time
|
||||
PurchasedAt time.Time
|
||||
FlightStartTime sql.NullTime
|
||||
DestinationAirportID sql.NullInt32
|
||||
DestinationName sql.NullString
|
||||
|
||||
// Joined from planes
|
||||
PlaneName string
|
||||
AircraftSize string
|
||||
AircraftType string
|
||||
OperationType string
|
||||
TravelTimeSec uint32
|
||||
FuelCost uint32
|
||||
BuyingCost uint32
|
||||
MaxPassengers sql.NullInt32
|
||||
CashReward sql.NullInt32
|
||||
ExpReward uint32
|
||||
BoardingTimeSec uint32
|
||||
FuelingTimeSec uint32
|
||||
|
||||
// RemainingSec is the seconds left in the current timed phase, computed
|
||||
// server-side (MySQL NOW()) in GetFleet so the countdown shares the exact
|
||||
// clock used by the landing / service-advance checks. Avoids Go-vs-MySQL
|
||||
// clock skew that made planes land early.
|
||||
RemainingSec int
|
||||
}
|
||||
|
||||
// TimeRemainingSeconds returns seconds left in the current timed phase
|
||||
// (boarding, taxiing/fueling, or flying). The value is computed in SQL
|
||||
// (see GetFleet) so it agrees with the DB-side landing check.
|
||||
func (p *PlayerPlane) TimeRemainingSeconds() int {
|
||||
if p.RemainingSec < 0 {
|
||||
return 0
|
||||
}
|
||||
return p.RemainingSec
|
||||
}
|
||||
|
||||
// ─── Leveling ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// XP is cumulative. Advancing from level L to L+1 costs 500*(L+1) XP, so:
|
||||
// L0→L1 = 500, L1→L2 = 1000, L2→L3 = 1500 …
|
||||
// Total XP to reach the start of level L = 500 * L*(L+1)/2.
|
||||
|
||||
// XPToNextLevel is the XP span of the current level (level → level+1).
|
||||
func XPToNextLevel(level uint32) int {
|
||||
return 500 * (int(level) + 1)
|
||||
}
|
||||
|
||||
// cumulativeXP is the total XP required to be at the start of `level`.
|
||||
func cumulativeXP(level uint32) uint64 {
|
||||
l := uint64(level)
|
||||
return 500 * l * (l + 1) / 2
|
||||
}
|
||||
|
||||
// LevelForXP returns the level that the given total XP corresponds to.
|
||||
func LevelForXP(xp uint64) uint32 {
|
||||
level := uint32(0)
|
||||
for cumulativeXP(level+1) <= xp {
|
||||
level++
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// XPIntoLevel returns how much of the current level's span has been earned.
|
||||
func XPIntoLevel(level uint32, xp uint64) int {
|
||||
base := cumulativeXP(level)
|
||||
if xp < base {
|
||||
return 0
|
||||
}
|
||||
return int(xp - base)
|
||||
}
|
||||
|
||||
// ─── Building ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Building struct {
|
||||
ID uint32
|
||||
Name string
|
||||
MinLevel uint32
|
||||
ConstructionCostCash uint32
|
||||
UpgradeTier uint8
|
||||
BuildingType string
|
||||
// storage
|
||||
Capacity sql.NullInt32
|
||||
StorageType sql.NullString
|
||||
StorageSubtype sql.NullString
|
||||
// operation
|
||||
OperationSize sql.NullString
|
||||
OperationType sql.NullString
|
||||
// passive
|
||||
PassiveType sql.NullString
|
||||
PassiveRate sql.NullInt32
|
||||
}
|
||||
|
||||
// AirportBuilding is an instance owned by a player
|
||||
type AirportBuilding struct {
|
||||
ID uint32
|
||||
AirportID uint32
|
||||
BuildingID uint32
|
||||
Level uint32
|
||||
|
||||
// Joined from buildings
|
||||
Name string
|
||||
BuildingType string
|
||||
ConstructionCostCash uint32
|
||||
OperationSize sql.NullString
|
||||
OperationType sql.NullString
|
||||
Capacity sql.NullInt32
|
||||
StorageType sql.NullString
|
||||
}
|
||||
|
||||
// ─── Product ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Product struct {
|
||||
ID uint16
|
||||
Name string
|
||||
}
|
||||
|
||||
// ─── Contract ────────────────────────────────────────────────────────────────
|
||||
|
||||
type Contract struct {
|
||||
ID uint32
|
||||
Title string
|
||||
RequiredProductID uint16
|
||||
RequiredAmount uint32
|
||||
CurrentAmountDelivered uint32
|
||||
IsCompleted bool
|
||||
ExpiresAt time.Time
|
||||
// Joined
|
||||
ProductName string
|
||||
}
|
||||
|
||||
func (c *Contract) ProgressPct() int {
|
||||
if c.RequiredAmount == 0 {
|
||||
return 100
|
||||
}
|
||||
return int((c.CurrentAmountDelivered * 100) / c.RequiredAmount)
|
||||
}
|
||||
|
||||
// ─── App state helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// Tab indices
|
||||
const (
|
||||
TabAirport = 0
|
||||
TabFleet = 1
|
||||
TabBuildings = 2
|
||||
TabContracts = 3
|
||||
TabShop = 4
|
||||
)
|
||||
|
||||
var TabNames = []string{"✈ Airport", "🛩 Fleet", "🏗 Buildings", "📋 Contracts", "🛒 Shop"}
|
||||
317
internal/service/service.go
Normal file
317
internal/service/service.go
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
// Package service is the game's business-logic / orchestration layer. It sits
|
||||
// between the presentation layer (internal/ui) and the data-access layer
|
||||
// (internal/db): the UI depends only on GameService and never touches db
|
||||
// directly, while db owns all SQL and transactions. GameService composes db
|
||||
// operations, makes the business decisions the UI used to make, and runs the
|
||||
// RAM-first dispatch queue with a background SQL worker.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"skyrama-tui/internal/db"
|
||||
"skyrama-tui/internal/model"
|
||||
)
|
||||
|
||||
// pendingDispatch is one plane queued (in RAM) to be launched to a central port.
|
||||
// The background worker persists it to SQL as bays/runways free up.
|
||||
type pendingDispatch struct {
|
||||
airportID uint32
|
||||
plane model.PlayerPlane
|
||||
destID uint32
|
||||
destName string
|
||||
}
|
||||
|
||||
// GameService wraps *db.Client. The mutex guards the in-RAM dispatch state
|
||||
// (queue, notes, central-port name cache) shared with the background worker.
|
||||
type GameService struct {
|
||||
db *db.Client
|
||||
mu sync.Mutex
|
||||
queue []pendingDispatch
|
||||
notes map[uint32]string // airportID → last hard-failure note (cleared on read)
|
||||
central map[uint32]string // central airport id → display name (lazy cache)
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewGameService wraps the db client and starts the background dispatch worker.
|
||||
func NewGameService(client *db.Client) *GameService {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &GameService{
|
||||
db: client,
|
||||
notes: make(map[uint32]string),
|
||||
cancel: cancel,
|
||||
}
|
||||
go s.worker(ctx)
|
||||
return s
|
||||
}
|
||||
|
||||
// Close stops the background worker and closes the underlying db connection.
|
||||
func (s *GameService) Close() {
|
||||
s.cancel()
|
||||
s.db.Close()
|
||||
}
|
||||
|
||||
// ─── Pass-throughs (pure delegation; names match db.Client) ────────────────────
|
||||
|
||||
func (s *GameService) Login(username, password string) (int32, error) {
|
||||
return s.db.Login(username, password)
|
||||
}
|
||||
func (s *GameService) Register(username, password string) (int64, error) {
|
||||
return s.db.Register(username, password)
|
||||
}
|
||||
|
||||
func (s *GameService) GetAirportByUserID(userID int32) (*model.Airport, error) {
|
||||
return s.db.GetAirportByUserID(userID)
|
||||
}
|
||||
func (s *GameService) RefreshResources(airportID uint32) error {
|
||||
return s.db.RefreshResources(airportID)
|
||||
}
|
||||
func (s *GameService) GetAllCountries() ([]model.Country, error) { return s.db.GetAllCountries() }
|
||||
func (s *GameService) GetCentralAirports() ([]model.CentralAirport, error) {
|
||||
return s.db.GetCentralAirports()
|
||||
}
|
||||
func (s *GameService) GetAirportBuildings(airportID uint32) ([]model.AirportBuilding, error) {
|
||||
return s.db.GetAirportBuildings(airportID)
|
||||
}
|
||||
func (s *GameService) GetAllBuildings() ([]model.Building, error) { return s.db.GetAllBuildings() }
|
||||
func (s *GameService) BuildingCounts(airportID uint32) (int, int, error) {
|
||||
return s.db.BuildingCounts(airportID)
|
||||
}
|
||||
func (s *GameService) GetCaps(airportID uint32) (uint32, uint32, error) {
|
||||
return s.db.GetCaps(airportID)
|
||||
}
|
||||
func (s *GameService) GetActiveContracts(airportID uint32) ([]model.Contract, error) {
|
||||
return s.db.GetActiveContracts(airportID)
|
||||
}
|
||||
func (s *GameService) GetAllPlanes() ([]model.Plane, error) { return s.db.GetAllPlanes() }
|
||||
func (s *GameService) SellPlane(airportID, playerPlaneID uint32) (int64, error) {
|
||||
return s.db.SellPlane(airportID, playerPlaneID)
|
||||
}
|
||||
func (s *GameService) ConstructBuilding(airportID, buildingID uint32) error {
|
||||
return s.db.ConstructBuilding(airportID, buildingID)
|
||||
}
|
||||
func (s *GameService) DemolishBuilding(airportID, airportBuildingID uint32) (int64, error) {
|
||||
return s.db.DemolishBuilding(airportID, airportBuildingID)
|
||||
}
|
||||
func (s *GameService) BuyCapUpgrade(airportID uint32, buildingType string) error {
|
||||
return s.db.BuyCapUpgrade(airportID, buildingType)
|
||||
}
|
||||
func (s *GameService) AdvanceServiceStages(airportID uint32) error {
|
||||
return s.db.AdvanceServiceStages(airportID)
|
||||
}
|
||||
func (s *GameService) Facilities(airportID uint32) ([]model.FacilityStatus, error) {
|
||||
return s.db.GetFacilities(airportID)
|
||||
}
|
||||
|
||||
// ─── Orchestration (was in the UI) ─────────────────────────────────────────────
|
||||
|
||||
// BuyPlane finds a free hangar then buys the plane into it (one UI intent).
|
||||
func (s *GameService) BuyPlane(airportID, planeID uint32) error {
|
||||
hangarID, err := s.db.FindAvailableHangar(airportID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.BuyPlane(airportID, planeID, hangarID, hangarID)
|
||||
}
|
||||
|
||||
// LandReadyPlanes lands every plane whose flight has elapsed, returning how many
|
||||
// landed and the total cash reward (replaces the UI's land loop).
|
||||
func (s *GameService) LandReadyPlanes(airportID uint32) (int, int64, error) {
|
||||
ready, err := s.db.GetPlanesReadyToLand(airportID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var cash int64
|
||||
landed := 0
|
||||
for _, pp := range ready {
|
||||
if err := s.db.LandFlight(airportID, pp); err != nil {
|
||||
return landed, cash, err
|
||||
}
|
||||
landed++
|
||||
if pp.CashReward.Valid {
|
||||
cash += int64(pp.CashReward.Int32)
|
||||
}
|
||||
}
|
||||
return landed, cash, nil
|
||||
}
|
||||
|
||||
// ─── RAM-first dispatch ────────────────────────────────────────────────────────
|
||||
|
||||
// DispatchFlights accepts a dispatch instantly into the RAM queue (no SQL yet)
|
||||
// and returns how many idle planes were queued. The background worker launches
|
||||
// them as bays/runways free. UI feedback is immediate; SQL follows.
|
||||
func (s *GameService) DispatchFlights(airportID uint32, planes []model.PlayerPlane, destID uint32) int {
|
||||
name := s.centralName(destID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for i := range planes {
|
||||
if planes[i].Status != "idle" {
|
||||
continue
|
||||
}
|
||||
s.queue = append(s.queue, pendingDispatch{
|
||||
airportID: airportID,
|
||||
plane: planes[i],
|
||||
destID: destID,
|
||||
destName: name,
|
||||
})
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// GetFleet returns the DB fleet with the pending dispatch queue overlaid: planes
|
||||
// still idle in SQL but queued in RAM are surfaced with status "queued" + their
|
||||
// destination, so they leave the Waiting pool the instant a dispatch is accepted.
|
||||
func (s *GameService) GetFleet(airportID uint32) ([]model.PlayerPlane, error) {
|
||||
fleet, err := s.db.GetFleet(airportID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
queued := make(map[uint32]string)
|
||||
for _, p := range s.queue {
|
||||
if p.airportID == airportID {
|
||||
queued[p.plane.ID] = p.destName
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if len(queued) == 0 {
|
||||
return fleet, nil
|
||||
}
|
||||
for i := range fleet {
|
||||
if fleet[i].Status != "idle" {
|
||||
continue
|
||||
}
|
||||
if name, ok := queued[fleet[i].ID]; ok {
|
||||
fleet[i].Status = "queued"
|
||||
fleet[i].DestinationName = sql.NullString{String: name, Valid: true}
|
||||
}
|
||||
}
|
||||
return fleet, nil
|
||||
}
|
||||
|
||||
// CancelQueue drops all not-yet-launched dispatches for an airport from the RAM
|
||||
// queue and returns how many were removed. Planes the worker already launched
|
||||
// (now boarding/flying in SQL) are unaffected.
|
||||
func (s *GameService) CancelQueue(airportID uint32) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
kept := s.queue[:0]
|
||||
removed := 0
|
||||
for _, p := range s.queue {
|
||||
if p.airportID == airportID {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
s.queue = kept
|
||||
return removed
|
||||
}
|
||||
|
||||
// DispatchNote returns (and clears) the last hard-failure note for an airport,
|
||||
// e.g. a queued plane that couldn't launch because of insufficient fuel.
|
||||
func (s *GameService) DispatchNote(airportID uint32) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
note := s.notes[airportID]
|
||||
delete(s.notes, airportID)
|
||||
return note
|
||||
}
|
||||
|
||||
// centralName resolves a central airport id to its display name (lazy-cached).
|
||||
func (s *GameService) centralName(destID uint32) string {
|
||||
s.mu.Lock()
|
||||
cached := s.central
|
||||
s.mu.Unlock()
|
||||
if cached == nil {
|
||||
cached = make(map[uint32]string)
|
||||
if cs, err := s.db.GetCentralAirports(); err == nil {
|
||||
for _, c := range cs {
|
||||
cached[c.AirportID] = c.Name
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.central = cached
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return cached[destID]
|
||||
}
|
||||
|
||||
// ─── Background worker ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *GameService) worker(ctx context.Context) {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.drainQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainQueue tries to launch every queued plane once. Planes blocked only by
|
||||
// bay/runway contention are re-queued for the next tick; planes that fail a
|
||||
// validation check (fuel/passengers) are dropped with a note.
|
||||
func (s *GameService) drainQueue() {
|
||||
s.mu.Lock()
|
||||
pending := s.queue
|
||||
s.queue = nil
|
||||
s.mu.Unlock()
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var retry []pendingDispatch
|
||||
for _, p := range pending {
|
||||
launched, retryable, note := s.tryLaunch(p)
|
||||
switch {
|
||||
case launched:
|
||||
// persisted to SQL; drop from queue
|
||||
case retryable:
|
||||
retry = append(retry, p)
|
||||
default:
|
||||
s.mu.Lock()
|
||||
s.notes[p.airportID] = note
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
if len(retry) > 0 {
|
||||
s.mu.Lock()
|
||||
s.queue = append(retry, s.queue...) // keep retries ahead of newly-queued
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// tryLaunch attempts one queued plane. Returns (launched, retryable, note):
|
||||
// retryable=true means "no bay/runway free, try again later".
|
||||
func (s *GameService) tryLaunch(p pendingDispatch) (bool, bool, string) {
|
||||
pl := p.plane
|
||||
if pl.BoardingTimeSec > 0 || pl.FuelingTimeSec > 0 {
|
||||
bay, err := s.db.FindAvailableServiceBay(p.airportID, pl.AircraftSize)
|
||||
if err != nil {
|
||||
return false, true, "" // contention (or no bay yet) → retry
|
||||
}
|
||||
if err := s.db.StartBoarding(p.airportID, pl.ID, pl.FuelCost, bay, p.destID); err != nil {
|
||||
return false, false, fmt.Sprintf("couldn't dispatch %s: %v", pl.PlaneName, err)
|
||||
}
|
||||
return true, false, ""
|
||||
}
|
||||
runway, err := s.db.FindAvailableRunway(p.airportID, pl.AircraftSize)
|
||||
if err != nil {
|
||||
return false, true, ""
|
||||
}
|
||||
if err := s.db.LaunchFlight(p.airportID, pl.ID, pl.FuelCost, runway, p.destID); err != nil {
|
||||
return false, false, fmt.Sprintf("couldn't dispatch %s: %v", pl.PlaneName, err)
|
||||
}
|
||||
return true, false, ""
|
||||
}
|
||||
182
internal/style/style.go
Normal file
182
internal/style/style.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package style
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Palette
|
||||
const (
|
||||
ColorSky = lipgloss.Color("#00BFFF")
|
||||
ColorRunway = lipgloss.Color("#4A4A4A")
|
||||
ColorGold = lipgloss.Color("#FFD700")
|
||||
ColorGreen = lipgloss.Color("#39D353")
|
||||
ColorRed = lipgloss.Color("#FF4444")
|
||||
ColorMuted = lipgloss.Color("#626262")
|
||||
ColorWhite = lipgloss.Color("#F5F5F5")
|
||||
ColorDark = lipgloss.Color("#1A1A2E")
|
||||
ColorPanel = lipgloss.Color("#16213E")
|
||||
ColorHighlight = lipgloss.Color("#0F3460")
|
||||
ColorAccent = lipgloss.Color("#E94560")
|
||||
ColorFuel = lipgloss.Color("#FF8C00")
|
||||
ColorPassenger = lipgloss.Color("#7EC8E3")
|
||||
ColorCash = lipgloss.Color("#FFD700")
|
||||
)
|
||||
|
||||
// Base Styles
|
||||
var (
|
||||
Bold = lipgloss.NewStyle().Bold(true)
|
||||
|
||||
Title = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(ColorSky).
|
||||
BorderStyle(lipgloss.DoubleBorder()).
|
||||
BorderForeground(ColorSky).
|
||||
Padding(0, 2).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
Subtitle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
Italic(true)
|
||||
|
||||
// Panels
|
||||
Panel = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorHighlight).
|
||||
Padding(0, 1)
|
||||
|
||||
PanelFocused = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorSky).
|
||||
Padding(0, 1)
|
||||
|
||||
PanelHeader = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(ColorSky).
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderBottom(true).
|
||||
BorderForeground(ColorHighlight).
|
||||
MarginBottom(1)
|
||||
|
||||
// Status bar
|
||||
StatusBar = lipgloss.NewStyle().
|
||||
Background(ColorHighlight).
|
||||
Foreground(ColorWhite).
|
||||
Padding(0, 1)
|
||||
|
||||
StatusBarKey = lipgloss.NewStyle().
|
||||
Background(ColorAccent).
|
||||
Foreground(ColorWhite).
|
||||
Padding(0, 1).
|
||||
Bold(true)
|
||||
|
||||
// Resource chips
|
||||
FuelChip = lipgloss.NewStyle().
|
||||
Foreground(ColorFuel).
|
||||
Bold(true)
|
||||
|
||||
PassengerChip = lipgloss.NewStyle().
|
||||
Foreground(ColorPassenger).
|
||||
Bold(true)
|
||||
|
||||
CashChip = lipgloss.NewStyle().
|
||||
Foreground(ColorCash).
|
||||
Bold(true)
|
||||
|
||||
// Menu / list items
|
||||
MenuItem = lipgloss.NewStyle().
|
||||
Foreground(ColorWhite).
|
||||
PaddingLeft(2)
|
||||
|
||||
MenuItemSelected = lipgloss.NewStyle().
|
||||
Foreground(ColorSky).
|
||||
Bold(true).
|
||||
PaddingLeft(1).
|
||||
SetString("> ")
|
||||
|
||||
// Plane status colors
|
||||
StatusIdle = lipgloss.NewStyle().Foreground(ColorGreen)
|
||||
StatusFly = lipgloss.NewStyle().Foreground(ColorSky)
|
||||
StatusMaint = lipgloss.NewStyle().Foreground(ColorGold)
|
||||
StatusBoard = lipgloss.NewStyle().Foreground(ColorPassenger)
|
||||
StatusTaxi = lipgloss.NewStyle().Foreground(ColorFuel)
|
||||
|
||||
// Table
|
||||
TableHeader = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(ColorSky).
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderBottom(true).
|
||||
BorderForeground(ColorMuted)
|
||||
|
||||
TableRow = lipgloss.NewStyle().Foreground(ColorWhite)
|
||||
|
||||
TableRowAlt = lipgloss.NewStyle().
|
||||
Foreground(ColorWhite).
|
||||
Background(lipgloss.Color("#0D1B2A"))
|
||||
|
||||
// Alerts
|
||||
Success = lipgloss.NewStyle().
|
||||
Foreground(ColorGreen).
|
||||
Bold(true).
|
||||
SetString("✓ ")
|
||||
|
||||
Error = lipgloss.NewStyle().
|
||||
Foreground(ColorRed).
|
||||
Bold(true).
|
||||
SetString("✗ ")
|
||||
|
||||
Warning = lipgloss.NewStyle().
|
||||
Foreground(ColorGold).
|
||||
Bold(true).
|
||||
SetString("⚠ ")
|
||||
|
||||
Info = lipgloss.NewStyle().
|
||||
Foreground(ColorSky).
|
||||
Bold(true).
|
||||
SetString("ℹ ")
|
||||
|
||||
// Help text
|
||||
HelpKey = lipgloss.NewStyle().Foreground(ColorSky).Bold(true)
|
||||
HelpDesc = lipgloss.NewStyle().Foreground(ColorMuted)
|
||||
|
||||
// Muted / dimmed
|
||||
Muted = lipgloss.NewStyle().Foreground(ColorMuted)
|
||||
)
|
||||
|
||||
// Plane status badge
|
||||
func PlaneStatusStyle(status string) lipgloss.Style {
|
||||
switch status {
|
||||
case "idle":
|
||||
return StatusIdle
|
||||
case "flying":
|
||||
return StatusFly
|
||||
case "maintenance":
|
||||
return StatusMaint
|
||||
case "boarding":
|
||||
return StatusBoard
|
||||
case "taxiing":
|
||||
return StatusTaxi
|
||||
default:
|
||||
return Muted
|
||||
}
|
||||
}
|
||||
|
||||
// Resource bar (e.g. fuel/passenger capacity)
|
||||
func ResourceBar(current, max int, width int, color lipgloss.Color) string {
|
||||
if max == 0 {
|
||||
return ""
|
||||
}
|
||||
filled := (current * width) / max
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
bar := lipgloss.NewStyle().Foreground(color).Render(repeat("█", filled)) +
|
||||
lipgloss.NewStyle().Foreground(ColorMuted).Render(repeat("░", width-filled))
|
||||
return bar
|
||||
}
|
||||
|
||||
func repeat(s string, n int) string {
|
||||
result := ""
|
||||
for i := 0; i < n; i++ {
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
125
internal/ui/airport_view.go
Normal file
125
internal/ui/airport_view.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"skyrama-tui/internal/model"
|
||||
"skyrama-tui/internal/style"
|
||||
)
|
||||
|
||||
// AirportView renders the full airport overview panel.
|
||||
// Uses a single panel with two sections to avoid nested-panel line-count overhead.
|
||||
func AirportView(airport *model.Airport, country string, width int) string {
|
||||
if airport == nil {
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
style.PanelHeader.Render("🛫 Airport"),
|
||||
"",
|
||||
style.Muted.Render("No airport found for your account."),
|
||||
style.Muted.Render("Ask an admin to create one, or use the db_manager tool."),
|
||||
)
|
||||
}
|
||||
|
||||
barW := 22
|
||||
|
||||
fuelBar := style.ResourceBar(
|
||||
int(airport.CurrentFuel), atLeast1(int(airport.MaxFuelCapacity)), barW, style.ColorFuel)
|
||||
fuelLine := row(
|
||||
style.FuelChip.Width(14).Render("⛽ Fuel"),
|
||||
fuelBar,
|
||||
style.Muted.Render(fmt.Sprintf(" %d / %d", airport.CurrentFuel, airport.MaxFuelCapacity)),
|
||||
style.Muted.Render(fmt.Sprintf(" +%d/min", airport.FuelGenerationRate)),
|
||||
)
|
||||
|
||||
paxBar := style.ResourceBar(
|
||||
int(airport.CurrentPassengers), atLeast1(int(airport.MaxPassengerCapacity)), barW, style.ColorPassenger)
|
||||
paxLine := row(
|
||||
style.PassengerChip.Width(14).Render("👤 Passengers"),
|
||||
paxBar,
|
||||
style.Muted.Render(fmt.Sprintf(" %d / %d", airport.CurrentPassengers, airport.MaxPassengerCapacity)),
|
||||
style.Muted.Render(fmt.Sprintf(" +%d/min", airport.PassengerGenerationRate)),
|
||||
)
|
||||
|
||||
cashLine := style.CashChip.Render(fmt.Sprintf("💰 Cash $%s", formatCash(airport.Cash)))
|
||||
|
||||
xpNeeded := model.XPToNextLevel(airport.PlayerLevel)
|
||||
xpCurrent := model.XPIntoLevel(airport.PlayerLevel, airport.ExperiencePoints)
|
||||
xpBar := style.ResourceBar(xpCurrent, atLeast1(xpNeeded), barW, style.ColorGreen)
|
||||
xpLine := row(
|
||||
style.Bold.Width(14).Render("✨ XP"),
|
||||
xpBar,
|
||||
style.Muted.Render(fmt.Sprintf(" %d / %d", xpCurrent, xpNeeded)),
|
||||
)
|
||||
|
||||
avail := style.StatusIdle.Render("Open ✓")
|
||||
if !airport.IsAvailable {
|
||||
avail = style.StatusMaint.Render("Closed ✗")
|
||||
}
|
||||
|
||||
sinceSec := time.Since(airport.ResourcesLastUpdatedAt)
|
||||
syncStr := "just now"
|
||||
if sinceSec >= time.Second {
|
||||
syncStr = sinceSec.Round(time.Second).String() + " ago"
|
||||
}
|
||||
|
||||
// Single panel containing both sections — avoids double-border line overhead.
|
||||
panel := style.Panel.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left,
|
||||
style.PanelHeader.Render("Resources"),
|
||||
fuelLine,
|
||||
paxLine,
|
||||
cashLine,
|
||||
xpLine,
|
||||
"",
|
||||
style.PanelHeader.Render("Airport Info"),
|
||||
kv("Name", airport.Name),
|
||||
kv("Country", country),
|
||||
kv("Level", fmt.Sprintf("Lv.%d", airport.PlayerLevel)),
|
||||
kv("Status", avail),
|
||||
kv("Last sync", syncStr),
|
||||
))
|
||||
|
||||
// 1-line header to keep total view height minimal.
|
||||
header := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
style.Bold.Foreground(style.ColorSky).Width(width/2).Render("🛫 "+airport.Name),
|
||||
style.Muted.Width(width/2).Align(lipgloss.Right).
|
||||
Render(fmt.Sprintf("Lv.%d 📍 %s", airport.PlayerLevel, country)),
|
||||
)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, panel)
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func row(parts ...string) string {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
|
||||
}
|
||||
|
||||
func kv(key, val string) string {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
style.Muted.Width(14).Render(key+":"),
|
||||
val,
|
||||
)
|
||||
}
|
||||
|
||||
func formatCash(v uint64) string {
|
||||
s := fmt.Sprintf("%d", v)
|
||||
var b strings.Builder
|
||||
for i, ch := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
b.WriteRune(',')
|
||||
}
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// atLeast1 prevents division-by-zero in progress bar calculations.
|
||||
func atLeast1(n int) int {
|
||||
if n <= 0 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
375
internal/ui/app.go
Normal file
375
internal/ui/app.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
func tick() tea.Cmd {
|
||||
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||
}
|
||||
|
||||
type AirportFetchedMsg struct{ Airport *model.Airport }
|
||||
type AirportFetchErrMsg struct{ Err error }
|
||||
type CountriesFetchedMsg struct{ Countries []model.Country }
|
||||
|
||||
type AppModel struct {
|
||||
client *service.GameService
|
||||
userID int32
|
||||
username string
|
||||
|
||||
airport *model.Airport
|
||||
countries map[uint16]string
|
||||
|
||||
activeTab int
|
||||
fleet FleetModel
|
||||
buildings BuildingsModel
|
||||
contracts ContractsModel
|
||||
shop ShopModel
|
||||
|
||||
width int
|
||||
height int
|
||||
globalErr string
|
||||
loading bool
|
||||
}
|
||||
|
||||
func NewAppModel(client *service.GameService, userID int32, username string) AppModel {
|
||||
return AppModel{
|
||||
client: client,
|
||||
userID: userID,
|
||||
username: username,
|
||||
countries: make(map[uint16]string),
|
||||
loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (m AppModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.fetchAirport(), m.fetchCountries(), tick(), m.fleet.LoadDestinations())
|
||||
}
|
||||
|
||||
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
h := m.contentHeight()
|
||||
m.fleet.width = msg.Width
|
||||
m.fleet.height = h
|
||||
m.buildings.width = msg.Width
|
||||
m.buildings.height = h
|
||||
m.buildings.ownedList.SetSize(msg.Width-4, h-8)
|
||||
m.buildings.shopList.SetSize(msg.Width-4, h-8)
|
||||
m.shop.width = msg.Width
|
||||
m.shop.height = h
|
||||
m.shop.list.SetSize(msg.Width-4, h-7)
|
||||
// return immediately — no need to forward resize to sub-models twice
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
cmds = append(cmds, m.fetchAirport(), m.fleet.CheckLanding(), m.fleet.CheckServiceProgress(), tick())
|
||||
|
||||
case AirportFetchedMsg:
|
||||
m.loading = false
|
||||
m.airport = msg.Airport
|
||||
m.globalErr = ""
|
||||
if m.airport != nil {
|
||||
m.shop.playerLevel = m.airport.PlayerLevel
|
||||
m.shop.airportID = m.airport.ID
|
||||
m.fleet.airportID = m.airport.ID
|
||||
m.buildings.airportID = m.airport.ID
|
||||
m.contracts.airportID = m.airport.ID
|
||||
}
|
||||
|
||||
case AirportFetchErrMsg:
|
||||
m.loading = false
|
||||
m.globalErr = msg.Err.Error()
|
||||
|
||||
case CountriesFetchedMsg:
|
||||
for _, c := range msg.Countries {
|
||||
m.countries[c.ID] = c.Name
|
||||
}
|
||||
|
||||
// Fleet messages must always reach fleet regardless of the active tab,
|
||||
// so auto-landing and fleet reloads work while the user browses other tabs.
|
||||
case FlightLaunchedMsg, FlightLandedMsg, FleetLoadedMsg, FleetErrMsg, ServiceProgressMsg, PlaneSoldMsg, DispatchResultMsg, DestinationsLoadedMsg:
|
||||
updated, cmd := m.fleet.Update(msg)
|
||||
m.fleet = updated
|
||||
cmds = append(cmds, cmd)
|
||||
// Reload the airport header after resource-changing actions.
|
||||
switch msg.(type) {
|
||||
case FlightLandedMsg, PlaneSoldMsg, DispatchResultMsg:
|
||||
cmds = append(cmds, m.fetchAirport())
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
// Buildings/shop result messages always reach their sub-model so a reload
|
||||
// fires even if the user switched tabs, and refresh the airport header.
|
||||
case BuildingDemolishedMsg, CapUpgradedMsg, BuildingConstructedMsg:
|
||||
updated, cmd := m.buildings.Update(msg)
|
||||
m.buildings = updated
|
||||
cmds = append(cmds, cmd, m.fetchAirport())
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case PlaneBoughtMsg:
|
||||
updated, cmd := m.shop.Update(msg)
|
||||
m.shop = updated
|
||||
cmds = append(cmds, cmd, m.fetchAirport())
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "q":
|
||||
// Don't quit if a list sub-model is in filter mode, or the fleet
|
||||
// dispatch modal is open (it consumes q to cancel).
|
||||
if m.fleet.dispatchMode ||
|
||||
m.buildings.ownedList.FilterState() == list.Filtering ||
|
||||
m.buildings.shopList.FilterState() == list.Filtering ||
|
||||
m.shop.list.FilterState() == list.Filtering {
|
||||
break
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "1":
|
||||
return m.switchTab(model.TabAirport), nil
|
||||
case "2":
|
||||
m2 := m.switchTab(model.TabFleet)
|
||||
return m2, m2.fleet.LoadFleet()
|
||||
case "3":
|
||||
m2 := m.switchTab(model.TabBuildings)
|
||||
return m2, m2.buildings.Load()
|
||||
case "4":
|
||||
m2 := m.switchTab(model.TabContracts)
|
||||
return m2, m2.contracts.Load()
|
||||
case "5":
|
||||
m2 := m.switchTab(model.TabShop)
|
||||
return m2, m2.shop.Load()
|
||||
case "tab":
|
||||
next := (m.activeTab + 1) % len(model.TabNames)
|
||||
m2 := m.switchTab(next)
|
||||
return m2, m2.tabRefreshCmd(next)
|
||||
case "shift+tab":
|
||||
prev := (m.activeTab - 1 + len(model.TabNames)) % len(model.TabNames)
|
||||
m2 := m.switchTab(prev)
|
||||
return m2, m2.tabRefreshCmd(prev)
|
||||
}
|
||||
}
|
||||
|
||||
switch m.activeTab {
|
||||
case model.TabFleet:
|
||||
updated, cmd := m.fleet.Update(msg)
|
||||
m.fleet = updated
|
||||
cmds = append(cmds, cmd)
|
||||
case model.TabBuildings:
|
||||
updated, cmd := m.buildings.Update(msg)
|
||||
m.buildings = updated
|
||||
cmds = append(cmds, cmd)
|
||||
case model.TabContracts:
|
||||
updated, cmd := m.contracts.Update(msg)
|
||||
m.contracts = updated
|
||||
cmds = append(cmds, cmd)
|
||||
case model.TabShop:
|
||||
updated, cmd := m.shop.Update(msg)
|
||||
m.shop = updated
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m AppModel) View() string {
|
||||
//if m.width == 0 {
|
||||
// return "Initializing…"
|
||||
//}
|
||||
if m.loading {
|
||||
return lipgloss.Place(m.width, m.height,
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
style.PanelHeader.Render("Connecting to database…"))
|
||||
}
|
||||
|
||||
tabs := m.renderTabBar()
|
||||
content := style.Panel.
|
||||
Width(m.width - 2).
|
||||
Height(m.contentHeight()).
|
||||
MaxHeight(m.contentHeight() + 2).
|
||||
Render(m.renderContent())
|
||||
view := lipgloss.JoinVertical(lipgloss.Left, tabs, content, m.renderStatusBar())
|
||||
// Clamp to the terminal height so an over-tall tab can never push the frame
|
||||
// past the screen and leave stale lines from the previous tab visible.
|
||||
if m.height > 0 {
|
||||
view = lipgloss.NewStyle().MaxHeight(m.height).Render(view)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (m AppModel) contentHeight() int {
|
||||
h := m.height - 4
|
||||
if h < 4 {
|
||||
h = 4
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m AppModel) renderTabBar() string {
|
||||
var tabs []string
|
||||
for i, name := range model.TabNames {
|
||||
if i == m.activeTab {
|
||||
tabs = append(tabs, lipgloss.NewStyle().
|
||||
Foreground(style.ColorSky).Bold(true).
|
||||
Background(style.ColorHighlight).
|
||||
Padding(0, 1).Render(name))
|
||||
} else {
|
||||
tabs = append(tabs, lipgloss.NewStyle().
|
||||
Foreground(style.ColorMuted).Padding(0, 1).Render(name))
|
||||
}
|
||||
}
|
||||
bar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||
user := style.Muted.Render(" " + m.username + " ")
|
||||
errStr := ""
|
||||
if m.globalErr != "" {
|
||||
errStr = " " + style.Warning.String() + style.Muted.Render(m.globalErr) + " "
|
||||
}
|
||||
spacerW := m.width - lipgloss.Width(bar) - lipgloss.Width(user) - lipgloss.Width(errStr)
|
||||
if spacerW < 0 {
|
||||
spacerW = 0
|
||||
}
|
||||
return lipgloss.NewStyle().Background(style.ColorDark).Width(m.width).
|
||||
Render(lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
bar,
|
||||
lipgloss.NewStyle().Width(spacerW).Render(""),
|
||||
errStr,
|
||||
user,
|
||||
))
|
||||
}
|
||||
|
||||
func (m AppModel) renderContent() string {
|
||||
switch m.activeTab {
|
||||
case model.TabAirport:
|
||||
country := ""
|
||||
if m.airport != nil {
|
||||
country = m.countries[m.airport.OriginCountryID]
|
||||
}
|
||||
return AirportView(m.airport, country, m.width-4)
|
||||
case model.TabFleet:
|
||||
return m.fleet.View()
|
||||
case model.TabBuildings:
|
||||
return m.buildings.View()
|
||||
case model.TabContracts:
|
||||
return m.contracts.View()
|
||||
case model.TabShop:
|
||||
return m.shop.View()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m AppModel) renderStatusBar() string {
|
||||
cash, fuel, pax := "—", "—", "—"
|
||||
if m.airport != nil {
|
||||
cash = fmt.Sprintf("💰 $%s", formatCash(m.airport.Cash))
|
||||
fuel = fmt.Sprintf("⛽ %d/%d", m.airport.CurrentFuel, m.airport.MaxFuelCapacity)
|
||||
pax = fmt.Sprintf("👤 %d/%d", m.airport.CurrentPassengers, m.airport.MaxPassengerCapacity)
|
||||
}
|
||||
left := style.StatusBar.Render(fmt.Sprintf(" %s %s %s ", cash, fuel, pax))
|
||||
right := style.StatusBarKey.Render(" 1-5 ") + style.StatusBar.Render(" tabs ") +
|
||||
style.StatusBarKey.Render(" tab ") + style.StatusBar.Render(" cycle ") +
|
||||
style.StatusBarKey.Render(" q ") + style.StatusBar.Render(" quit ")
|
||||
spacerW := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if spacerW < 0 {
|
||||
spacerW = 0
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
left,
|
||||
lipgloss.NewStyle().Background(style.ColorHighlight).Width(spacerW).Render(""),
|
||||
right,
|
||||
)
|
||||
}
|
||||
|
||||
func (m AppModel) switchTab(tab int) AppModel {
|
||||
m.fleet.focused = false
|
||||
m.fleet.statusMsg = ""
|
||||
m.fleet.sellArmed = false
|
||||
m.buildings.focused = false
|
||||
m.buildings.statusMsg = ""
|
||||
m.buildings.demolishArmed = false
|
||||
m.contracts.focused = false
|
||||
m.contracts.statusMsg = ""
|
||||
m.shop.focused = false
|
||||
m.shop.statusMsg = ""
|
||||
m.activeTab = tab
|
||||
switch tab {
|
||||
case model.TabFleet:
|
||||
m.fleet.focused = true
|
||||
case model.TabBuildings:
|
||||
m.buildings.focused = true
|
||||
case model.TabContracts:
|
||||
m.contracts.focused = true
|
||||
case model.TabShop:
|
||||
m.shop.focused = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m AppModel) tabRefreshCmd(tab int) tea.Cmd {
|
||||
switch tab {
|
||||
case model.TabFleet:
|
||||
return m.fleet.LoadFleet()
|
||||
case model.TabBuildings:
|
||||
return m.buildings.Load()
|
||||
case model.TabContracts:
|
||||
return m.contracts.Load()
|
||||
case model.TabShop:
|
||||
return m.shop.Load()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *AppModel) Fleet(f *FleetModel) { m.fleet = *f }
|
||||
func (m *AppModel) SetBuildings(b *BuildingsModel) { m.buildings = *b }
|
||||
func (m *AppModel) SetContracts(c *ContractsModel) { m.contracts = *c }
|
||||
func (m *AppModel) SetShop(s *ShopModel) { m.shop = *s }
|
||||
|
||||
func (m AppModel) fetchAirport() tea.Cmd {
|
||||
client := m.client
|
||||
userID := m.userID
|
||||
airportID := uint32(0)
|
||||
if m.airport != nil {
|
||||
airportID = m.airport.ID
|
||||
}
|
||||
return func() tea.Msg {
|
||||
airport, err := client.GetAirportByUserID(userID)
|
||||
if err != nil {
|
||||
return AirportFetchErrMsg{Err: fmt.Errorf("no airport yet — create one via db_manager")}
|
||||
}
|
||||
if airportID != 0 {
|
||||
_ = client.RefreshResources(airportID)
|
||||
if a2, err2 := client.GetAirportByUserID(userID); err2 == nil {
|
||||
airport = a2
|
||||
}
|
||||
}
|
||||
return AirportFetchedMsg{Airport: airport}
|
||||
}
|
||||
}
|
||||
|
||||
func (m AppModel) fetchCountries() tea.Cmd {
|
||||
client := m.client
|
||||
return func() tea.Msg {
|
||||
countries, err := client.GetAllCountries()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return CountriesFetchedMsg{Countries: countries}
|
||||
}
|
||||
}
|
||||
559
internal/ui/buildings.go
Normal file
559
internal/ui/buildings.go
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
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 "🏢"
|
||||
}
|
||||
}
|
||||
176
internal/ui/contracts.go
Normal file
176
internal/ui/contracts.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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 ContractsLoadedMsg struct{ Contracts []model.Contract }
|
||||
type ContractsErrMsg struct{ Err error }
|
||||
|
||||
// ─── ContractsModel ───────────────────────────────────────────────────────────
|
||||
|
||||
type ContractsModel struct {
|
||||
client *service.GameService
|
||||
airportID uint32
|
||||
contracts []model.Contract
|
||||
cursor int
|
||||
statusMsg string
|
||||
focused bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewContractsModel(client *service.GameService, airportID uint32) ContractsModel {
|
||||
return ContractsModel{client: client, airportID: airportID}
|
||||
}
|
||||
|
||||
func (m ContractsModel) Init() tea.Cmd { return m.load() }
|
||||
|
||||
func (m ContractsModel) Update(msg tea.Msg) (ContractsModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case ContractsLoadedMsg:
|
||||
m.contracts = msg.Contracts
|
||||
if m.cursor >= len(m.contracts) {
|
||||
m.cursor = clampMin(len(m.contracts)-1, 0)
|
||||
}
|
||||
|
||||
case ContractsErrMsg:
|
||||
m.statusMsg = style.Error.String() + msg.Err.Error()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if !m.focused {
|
||||
break
|
||||
}
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.contracts)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "r":
|
||||
m.statusMsg = ""
|
||||
return m, m.load()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m ContractsModel) View() string {
|
||||
header := style.PanelHeader.Render("📋 Active Contracts")
|
||||
|
||||
statusLine := ""
|
||||
if m.statusMsg != "" {
|
||||
statusLine = "\n" + m.statusMsg + "\n"
|
||||
}
|
||||
|
||||
if len(m.contracts) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
statusLine,
|
||||
"",
|
||||
style.Muted.Render("No active contracts right now."),
|
||||
)
|
||||
}
|
||||
|
||||
rows := make([]string, len(m.contracts))
|
||||
for i, c := range m.contracts {
|
||||
rows[i] = m.renderContract(c, i == m.cursor)
|
||||
}
|
||||
|
||||
help := style.HelpKey.Render("↑↓ / jk") + style.HelpDesc.Render(" navigate ") +
|
||||
style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
statusLine,
|
||||
strings.Join(rows, "\n"),
|
||||
"",
|
||||
style.Muted.Render(help),
|
||||
)
|
||||
}
|
||||
|
||||
func (m ContractsModel) renderContract(c model.Contract, selected bool) string {
|
||||
pct := c.ProgressPct()
|
||||
barW := 24
|
||||
bar := style.ResourceBar(int(c.CurrentAmountDelivered), int(c.RequiredAmount), barW, style.ColorGreen)
|
||||
|
||||
expiresIn := time.Until(c.ExpiresAt).Round(time.Minute)
|
||||
urgency := style.Muted
|
||||
if expiresIn < 30*time.Minute {
|
||||
urgency = style.Error
|
||||
} else if expiresIn < 2*time.Hour {
|
||||
urgency = style.Warning
|
||||
}
|
||||
|
||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||
style.Bold.Render(c.Title),
|
||||
style.Muted.Render(fmt.Sprintf("Deliver %d × %s", c.RequiredAmount, c.ProductName)),
|
||||
lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
bar,
|
||||
style.Muted.Render(fmt.Sprintf(" %d/%d (%d%%)",
|
||||
c.CurrentAmountDelivered, c.RequiredAmount, pct)),
|
||||
),
|
||||
urgency.Render(fmt.Sprintf("⏰ Expires in %s", fmtContractDuration(expiresIn))),
|
||||
)
|
||||
|
||||
if selected {
|
||||
return style.PanelFocused.Render(inner)
|
||||
}
|
||||
return style.Panel.Render(inner)
|
||||
}
|
||||
|
||||
// ─── Exported for parent AppModel ─────────────────────────────────────────────
|
||||
|
||||
// Load is the exported version for use by parent AppModel.
|
||||
func (m ContractsModel) Load() tea.Cmd { return m.load() }
|
||||
|
||||
func (m ContractsModel) load() tea.Cmd {
|
||||
client := m.client
|
||||
airportID := m.airportID
|
||||
return func() tea.Msg {
|
||||
contracts, err := client.GetActiveContracts(airportID)
|
||||
if err != nil {
|
||||
return ContractsErrMsg{Err: err}
|
||||
}
|
||||
return ContractsLoadedMsg{Contracts: contracts}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func clampMin(v, min int) int {
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func fmtContractDuration(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "expired"
|
||||
}
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh %dm", h, m)
|
||||
}
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
810
internal/ui/fleet.go
Normal file
810
internal/ui/fleet.go
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
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)
|
||||
}
|
||||
178
internal/ui/login.go
Normal file
178
internal/ui/login.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"skyrama-tui/internal/service"
|
||||
"skyrama-tui/internal/style"
|
||||
)
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type LoginSuccessMsg struct {
|
||||
UserID int32
|
||||
Username string
|
||||
}
|
||||
type LoginErrMsg struct{ Err error }
|
||||
|
||||
// ─── Model ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type LoginModel struct {
|
||||
client *service.GameService
|
||||
inputs []textinput.Model // 0=username, 1=password
|
||||
focus int
|
||||
mode string // "login" | "register"
|
||||
err string
|
||||
width int
|
||||
}
|
||||
|
||||
func NewLoginModel(client *service.GameService) LoginModel {
|
||||
user := textinput.New()
|
||||
user.Placeholder = "username"
|
||||
user.Focus()
|
||||
user.CharLimit = 64
|
||||
user.Width = 30
|
||||
|
||||
pass := textinput.New()
|
||||
pass.Placeholder = "password"
|
||||
pass.EchoMode = textinput.EchoPassword
|
||||
pass.EchoCharacter = '•'
|
||||
pass.CharLimit = 128
|
||||
pass.Width = 30
|
||||
|
||||
return LoginModel{
|
||||
client: client,
|
||||
inputs: []textinput.Model{user, pass},
|
||||
mode: "login",
|
||||
}
|
||||
}
|
||||
|
||||
func (m LoginModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "tab", "down":
|
||||
m.focus = (m.focus + 1) % len(m.inputs)
|
||||
for i := range m.inputs {
|
||||
if i == m.focus {
|
||||
m.inputs[i].Focus()
|
||||
} else {
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
}
|
||||
case "shift+tab", "up":
|
||||
m.focus = (m.focus - 1 + len(m.inputs)) % len(m.inputs)
|
||||
for i := range m.inputs {
|
||||
if i == m.focus {
|
||||
m.inputs[i].Focus()
|
||||
} else {
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
}
|
||||
case "ctrl+r":
|
||||
m.mode = "register"
|
||||
m.err = ""
|
||||
case "ctrl+l":
|
||||
m.mode = "login"
|
||||
m.err = ""
|
||||
case "enter":
|
||||
username := strings.TrimSpace(m.inputs[0].Value())
|
||||
password := m.inputs[1].Value()
|
||||
if username == "" || password == "" {
|
||||
m.err = "username and password required"
|
||||
return m, nil
|
||||
}
|
||||
if m.mode == "login" {
|
||||
return m, m.doLogin(username, password)
|
||||
}
|
||||
return m, m.doRegister(username, password)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward input events to focused field
|
||||
var cmds []tea.Cmd
|
||||
for i := range m.inputs {
|
||||
var cmd tea.Cmd
|
||||
m.inputs[i], cmd = m.inputs[i].Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m LoginModel) View() string {
|
||||
banner := style.Title.Render("✈ SKYRAMA ✈")
|
||||
|
||||
modeLabel := style.PanelHeader.Render("Login")
|
||||
if m.mode == "register" {
|
||||
modeLabel = style.PanelHeader.Render("Register")
|
||||
}
|
||||
|
||||
form := lipgloss.JoinVertical(lipgloss.Left,
|
||||
style.Muted.Render("Username"),
|
||||
m.inputs[0].View(),
|
||||
"",
|
||||
style.Muted.Render("Password"),
|
||||
m.inputs[1].View(),
|
||||
)
|
||||
|
||||
errLine := ""
|
||||
if m.err != "" {
|
||||
errLine = "\n" + style.Error.String() + style.Muted.Render(m.err)
|
||||
}
|
||||
|
||||
hints := "\n" +
|
||||
style.HelpKey.Render("enter") + style.HelpDesc.Render(" confirm ") +
|
||||
style.HelpKey.Render("ctrl+r") + style.HelpDesc.Render(" register ") +
|
||||
style.HelpKey.Render("ctrl+l") + style.HelpDesc.Render(" login ") +
|
||||
style.HelpKey.Render("tab") + style.HelpDesc.Render(" next field")
|
||||
|
||||
box := style.Panel.Padding(1, 3).Render(
|
||||
lipgloss.JoinVertical(lipgloss.Left, modeLabel, "", form, errLine, hints),
|
||||
)
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Center, banner, "", box)
|
||||
|
||||
return lipgloss.Place(m.width, 24, lipgloss.Center, lipgloss.Center, content)
|
||||
}
|
||||
|
||||
// SetErr injects an error string from outside (e.g. from main after a LoginErrMsg).
|
||||
func (m *LoginModel) SetErr(e string) { m.err = e }
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m LoginModel) doLogin(username, password string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
id, err := m.client.Login(username, password)
|
||||
if err != nil {
|
||||
return LoginErrMsg{Err: err}
|
||||
}
|
||||
return LoginSuccessMsg{UserID: id, Username: username}
|
||||
}
|
||||
}
|
||||
|
||||
func (m LoginModel) doRegister(username, password string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_, err := m.client.Register(username, password)
|
||||
if err != nil {
|
||||
return LoginErrMsg{Err: fmt.Errorf("register: %w", err)}
|
||||
}
|
||||
// Auto-login after register
|
||||
id, err := m.client.Login(username, password)
|
||||
if err != nil {
|
||||
return LoginErrMsg{Err: err}
|
||||
}
|
||||
return LoginSuccessMsg{UserID: id, Username: username}
|
||||
}
|
||||
}
|
||||
203
internal/ui/shop.go
Normal file
203
internal/ui/shop.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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 ShopLoadedMsg struct{ Planes []model.Plane }
|
||||
type ShopErrMsg struct{ Err error }
|
||||
type PlaneBoughtMsg struct{ Name string }
|
||||
|
||||
// ─── List item ────────────────────────────────────────────────────────────────
|
||||
|
||||
type shopPlaneItem struct{ p model.Plane }
|
||||
|
||||
func (s shopPlaneItem) Title() string {
|
||||
return fmt.Sprintf("%s %s %s",
|
||||
planeIcon(s.p.AircraftType),
|
||||
s.p.Name,
|
||||
style.CashChip.Render(fmt.Sprintf("$%d", s.p.BuyingCostCash)),
|
||||
)
|
||||
}
|
||||
|
||||
func (s shopPlaneItem) Description() string {
|
||||
parts := []string{
|
||||
style.Muted.Render(fmt.Sprintf("lv.%d", s.p.MinLevel)),
|
||||
style.Muted.Render(s.p.AircraftSize + "/" + s.p.AircraftType),
|
||||
style.FuelChip.Render(fmt.Sprintf("⛽%d", s.p.OperationCostFuel)),
|
||||
style.Muted.Render(fmt.Sprintf("⏱%s", fmtDuration(int(s.p.TravelTimeInSeconds)))),
|
||||
style.Muted.Render(fmt.Sprintf("✨%d xp", s.p.CompletedFlightExperience)),
|
||||
}
|
||||
if s.p.MaxPassengers.Valid && s.p.MaxPassengers.Int32 > 0 {
|
||||
parts = append(parts,
|
||||
style.PassengerChip.Render(fmt.Sprintf("👤%d", s.p.MaxPassengers.Int32)))
|
||||
}
|
||||
if s.p.CompletedFlightCashReward.Valid {
|
||||
parts = append(parts,
|
||||
style.CashChip.Render(fmt.Sprintf("💰%d/flight", s.p.CompletedFlightCashReward.Int32)))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (s shopPlaneItem) FilterValue() string { return s.p.Name }
|
||||
|
||||
// ─── ShopModel ────────────────────────────────────────────────────────────────
|
||||
|
||||
type ShopModel struct {
|
||||
client *service.GameService
|
||||
airportID uint32
|
||||
playerLevel uint32
|
||||
list list.Model
|
||||
planes []model.Plane
|
||||
statusMsg string
|
||||
focused bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewShopModel(client *service.GameService, airportID uint32, playerLevel uint32) ShopModel {
|
||||
d := list.NewDefaultDelegate()
|
||||
d.Styles.SelectedTitle = d.Styles.SelectedTitle.
|
||||
Foreground(style.ColorGold).
|
||||
BorderForeground(style.ColorGold)
|
||||
d.Styles.SelectedDesc = d.Styles.SelectedDesc.
|
||||
Foreground(style.ColorPassenger).
|
||||
BorderForeground(style.ColorGold)
|
||||
|
||||
l := list.New(nil, d, 0, 0)
|
||||
l.Title = ""
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
|
||||
return ShopModel{
|
||||
client: client,
|
||||
airportID: airportID,
|
||||
playerLevel: playerLevel,
|
||||
list: l,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ShopModel) Init() tea.Cmd { return m.load() }
|
||||
|
||||
func (m ShopModel) Update(msg tea.Msg) (ShopModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.SetSize(m.width-4, m.height-12)
|
||||
|
||||
case ShopLoadedMsg:
|
||||
m.planes = msg.Planes
|
||||
m.list.SetItems(m.planesToItems())
|
||||
|
||||
case PlaneBoughtMsg:
|
||||
m.statusMsg = style.Success.String() + "Bought: " + msg.Name + "!"
|
||||
return m, m.load()
|
||||
|
||||
case ShopErrMsg:
|
||||
m.statusMsg = style.Error.String() + msg.Err.Error()
|
||||
|
||||
case tea.KeyMsg:
|
||||
if !m.focused {
|
||||
break
|
||||
}
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.statusMsg = ""
|
||||
return m, m.load()
|
||||
case "enter", " ", "b":
|
||||
item, ok := m.list.SelectedItem().(shopPlaneItem)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if item.p.MinLevel > m.playerLevel {
|
||||
m.statusMsg = style.Warning.String() +
|
||||
fmt.Sprintf("Requires level %d (you are level %d)",
|
||||
item.p.MinLevel, m.playerLevel)
|
||||
break
|
||||
}
|
||||
return m, m.buy(item.p)
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m ShopModel) View() string {
|
||||
header := style.PanelHeader.Render("🛒 Plane Shop")
|
||||
|
||||
levelNote := style.Info.String() + style.Muted.Render(
|
||||
fmt.Sprintf("Your level: %d", m.playerLevel),
|
||||
)
|
||||
|
||||
statusLine := ""
|
||||
if m.statusMsg != "" {
|
||||
statusLine = "\n" + m.statusMsg + "\n"
|
||||
}
|
||||
|
||||
help := style.HelpKey.Render("enter / b") + style.HelpDesc.Render(" buy ") +
|
||||
style.HelpKey.Render("r") + style.HelpDesc.Render(" refresh ") +
|
||||
style.HelpKey.Render("/") + style.HelpDesc.Render(" filter")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
levelNote,
|
||||
statusLine,
|
||||
m.list.View(),
|
||||
"",
|
||||
style.Muted.Render(help),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Exported for parent AppModel ─────────────────────────────────────────────
|
||||
|
||||
// Load is the exported version for use by parent AppModel.
|
||||
func (m ShopModel) Load() tea.Cmd { return m.load() }
|
||||
|
||||
func (m ShopModel) load() tea.Cmd {
|
||||
client := m.client
|
||||
return func() tea.Msg {
|
||||
planes, err := client.GetAllPlanes()
|
||||
if err != nil {
|
||||
return ShopErrMsg{Err: err}
|
||||
}
|
||||
return ShopLoadedMsg{Planes: planes}
|
||||
}
|
||||
}
|
||||
|
||||
func (m ShopModel) buy(p model.Plane) tea.Cmd {
|
||||
client := m.client
|
||||
airportID := m.airportID
|
||||
planeID := p.ID
|
||||
planeName := p.Name
|
||||
return func() tea.Msg {
|
||||
// The service finds a free hangar and parks the plane in one intent.
|
||||
if err := client.BuyPlane(airportID, planeID); err != nil {
|
||||
return ShopErrMsg{Err: err}
|
||||
}
|
||||
return PlaneBoughtMsg{Name: planeName}
|
||||
}
|
||||
}
|
||||
|
||||
func (m ShopModel) planesToItems() []list.Item {
|
||||
items := make([]list.Item, len(m.planes))
|
||||
for i, p := range m.planes {
|
||||
items[i] = shopPlaneItem{p: p}
|
||||
}
|
||||
return items
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue