Add fix plan for spec compliance issues
7 tasks: quick fixes, relic effects, target selection, turn timer, StartGame race fix, BSP tile map, lobby join-by-code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
652
docs/superpowers/plans/2026-03-24-catacombs-fixes.md
Normal file
652
docs/superpowers/plans/2026-03-24-catacombs-fixes.md
Normal file
@@ -0,0 +1,652 @@
|
||||
# Catacombs Bug Fixes & Missing Features Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix critical bugs and implement missing features identified in the spec-vs-implementation review.
|
||||
|
||||
**Architecture:** Incremental fixes to existing codebase. No major restructuring — patch what's broken, add what's missing. Each task is independently shippable.
|
||||
|
||||
**Tech Stack:** Go 1.22+, charmbracelet/wish, charmbracelet/bubbletea, charmbracelet/lipgloss, go.etcd.io/bbolt
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-23-catacombs-design.md`
|
||||
**Review:** See "Critical Issues Summary" in conversation history.
|
||||
|
||||
**Go binary path:** `/c/Users/98kim/sdk/go1.25.1/bin/go.exe` (not in PATH)
|
||||
|
||||
---
|
||||
|
||||
## File Map (files touched in this plan)
|
||||
|
||||
```
|
||||
ui/model.go — remove debug, add nickname input, target selection, dead player guard, save ranking
|
||||
ui/game_view.go — turn timer display, target selection indicator
|
||||
game/session.go — fix StartGame race, add TurnTimeLeft to GameState, relic effects
|
||||
game/turn.go — relic effect application on kill, individual flee
|
||||
game/event.go — overlapping monster floor ranges
|
||||
entity/player.go — relic-aware EffectiveATK/EffectiveDEF
|
||||
dungeon/generator.go — BSP dungeon generation with 2D tile map
|
||||
dungeon/room.go — tile-based room rendering data
|
||||
dungeon/render.go — NEW: ASCII tile map renderer
|
||||
store/db.go — no changes needed (SaveRun already exists)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Quick Fixes (debug removal, ranking save, nickname, dead player guard)
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/model.go`
|
||||
|
||||
- [ ] **Step 1: Remove debug lines from View()**
|
||||
|
||||
Remove the `debug` variable and all `debug +` prefixes from `View()`. Remove `lastKey` field from Model struct.
|
||||
|
||||
- [ ] **Step 2: Save ranking on game over**
|
||||
|
||||
In `updateGame`, when `m.gameState.GameOver` is detected and screen transitions to result, call:
|
||||
```go
|
||||
if m.store != nil {
|
||||
score := 0
|
||||
for _, p := range m.gameState.Players {
|
||||
score += p.Gold
|
||||
}
|
||||
m.store.SaveRun(m.playerName, m.gameState.FloorNum, score)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Block dead player actions**
|
||||
|
||||
In `updateGame` combat key handling, add guard:
|
||||
```go
|
||||
// Find this player and check if dead
|
||||
isPlayerDead := false
|
||||
for _, p := range m.gameState.Players {
|
||||
if p.Name == m.playerName && p.IsDead() {
|
||||
isPlayerDead = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isPlayerDead {
|
||||
return m, m.pollState() // spectator mode: just keep watching
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Save nickname on first connection**
|
||||
|
||||
In `updateTitle`, after setting `m.playerName = "Adventurer"` (when profile not found), add:
|
||||
```go
|
||||
if m.fingerprint != "" {
|
||||
m.store.SaveProfile(m.fingerprint, m.playerName)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and run tests**
|
||||
|
||||
```bash
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./ui/ -v -timeout 15s
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/model.go
|
||||
git commit -m "fix: remove debug, save rankings, block dead actions, save nickname"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Relic Effects
|
||||
|
||||
**Files:**
|
||||
- Modify: `entity/player.go`, `game/turn.go`
|
||||
- Test: `entity/player_test.go`
|
||||
|
||||
- [ ] **Step 1: Write relic effect test**
|
||||
|
||||
Add to `entity/player_test.go`:
|
||||
```go
|
||||
func TestRelicEffects(t *testing.T) {
|
||||
p := NewPlayer("test", ClassWarrior)
|
||||
p.Relics = append(p.Relics, Relic{Name: "Power Amulet", Effect: RelicATKBoost, Value: 3})
|
||||
if p.EffectiveATK() != 15 { // 12 base + 3 relic
|
||||
t.Errorf("ATK with relic: got %d, want 15", p.EffectiveATK())
|
||||
}
|
||||
|
||||
p.Relics = append(p.Relics, Relic{Name: "Iron Ward", Effect: RelicDEFBoost, Value: 2})
|
||||
if p.EffectiveDEF() != 10 { // 8 base + 2 relic
|
||||
t.Errorf("DEF with relic: got %d, want 10", p.EffectiveDEF())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test (should fail)**
|
||||
|
||||
```bash
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ -run TestRelicEffects -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add relic bonuses to EffectiveATK/EffectiveDEF**
|
||||
|
||||
In `entity/player.go`, update `EffectiveATK()`:
|
||||
```go
|
||||
func (p *Player) EffectiveATK() int {
|
||||
atk := p.ATK
|
||||
for _, item := range p.Inventory {
|
||||
if item.Type == ItemWeapon {
|
||||
atk += item.Bonus
|
||||
}
|
||||
}
|
||||
for _, r := range p.Relics {
|
||||
if r.Effect == RelicATKBoost {
|
||||
atk += r.Value
|
||||
}
|
||||
}
|
||||
return atk
|
||||
}
|
||||
```
|
||||
|
||||
Update `EffectiveDEF()` similarly for `RelicDEFBoost`.
|
||||
|
||||
- [ ] **Step 4: Add HealOnKill relic trigger in turn.go**
|
||||
|
||||
In `resolvePlayerActions()`, in the "Award gold" loop where `m.IsDead() && aliveBeforeTurn[i]`, add:
|
||||
```go
|
||||
// Apply RelicHealOnKill
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsDead() {
|
||||
for _, r := range p.Relics {
|
||||
if r.Effect == entity.RelicHealOnKill {
|
||||
p.Heal(r.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And in the gold award loop, apply `RelicGoldBoost`:
|
||||
```go
|
||||
for _, p := range s.state.Players {
|
||||
if !p.IsDead() {
|
||||
bonus := 0
|
||||
for _, r := range p.Relics {
|
||||
if r.Effect == entity.RelicGoldBoost {
|
||||
bonus += r.Value
|
||||
}
|
||||
}
|
||||
p.Gold += goldReward + bonus
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run all tests**
|
||||
|
||||
```bash
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./entity/ ./game/ -v -timeout 15s
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add entity/player.go entity/player_test.go game/turn.go
|
||||
git commit -m "feat: apply relic passive effects (ATK/DEF boost, heal on kill, gold boost)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Combat Target Selection & Individual Flee
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/model.go`, `ui/game_view.go`, `game/turn.go`
|
||||
|
||||
- [ ] **Step 1: Add target cursor to Model**
|
||||
|
||||
In `ui/model.go`, add field to Model:
|
||||
```go
|
||||
targetCursor int // selected enemy index during combat
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add target selection keys**
|
||||
|
||||
In `updateGame` combat phase, add left/right or tab for target cycling:
|
||||
```go
|
||||
case "tab":
|
||||
if len(m.gameState.Monsters) > 0 {
|
||||
m.targetCursor = (m.targetCursor + 1) % len(m.gameState.Monsters)
|
||||
}
|
||||
```
|
||||
|
||||
Update attack/skill to use `m.targetCursor`:
|
||||
```go
|
||||
case "1":
|
||||
m.session.SubmitAction(m.playerName, game.PlayerAction{Type: game.ActionAttack, TargetIdx: m.targetCursor})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Show target indicator in HUD**
|
||||
|
||||
In `ui/game_view.go` `renderHUD`, add arrow marker to selected target:
|
||||
```go
|
||||
marker := " "
|
||||
if i == targetCursor {
|
||||
marker = "> "
|
||||
}
|
||||
sb.WriteString(enemyStyle.Render(fmt.Sprintf("%s[%d] %s ...", marker, i, m.Name)))
|
||||
```
|
||||
|
||||
Pass `targetCursor` through `renderGame` and `renderHUD` (add parameter or embed in state).
|
||||
|
||||
- [ ] **Step 4: Change flee to individual**
|
||||
|
||||
In `game/turn.go` `resolvePlayerActions`, replace the unanimous flee block with individual flee:
|
||||
```go
|
||||
// Remove the fleeCount/aliveCount block entirely.
|
||||
// In the per-player action switch, change ActionFlee to:
|
||||
case ActionFlee:
|
||||
if combat.AttemptFlee() {
|
||||
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name))
|
||||
// In solo, end combat
|
||||
if s.state.SoloMode {
|
||||
s.state.Phase = PhaseExploring
|
||||
return
|
||||
}
|
||||
// In multiplayer, mark player as fled (treat as dead for this combat)
|
||||
// For simplicity: solo flee ends combat, multiplayer flee wastes turn
|
||||
} else {
|
||||
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add [Tab] hint to action bar**
|
||||
|
||||
In `ui/game_view.go`, update the action line:
|
||||
```go
|
||||
sb.WriteString(actionStyle.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target"))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build and test**
|
||||
|
||||
```bash
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./... -timeout 15s
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/model.go ui/game_view.go game/turn.go
|
||||
git commit -m "feat: target selection with Tab, individual flee in solo"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Turn Timer Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `game/session.go`, `game/turn.go`, `ui/game_view.go`
|
||||
|
||||
- [ ] **Step 1: Add TurnDeadline to GameState**
|
||||
|
||||
In `game/session.go` `GameState` struct:
|
||||
```go
|
||||
TurnDeadline time.Time // when current turn times out
|
||||
```
|
||||
|
||||
Import `"time"` in session.go.
|
||||
|
||||
- [ ] **Step 2: Set deadline in RunTurn**
|
||||
|
||||
In `game/turn.go` `RunTurn()`, after creating the timer:
|
||||
```go
|
||||
s.state.TurnDeadline = time.Now().Add(TurnTimeout)
|
||||
```
|
||||
|
||||
After turn resolves (in resolve section):
|
||||
```go
|
||||
s.state.TurnDeadline = time.Time{} // clear deadline
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Display countdown in HUD**
|
||||
|
||||
In `ui/game_view.go` `renderHUD`, when in combat phase:
|
||||
```go
|
||||
if !state.TurnDeadline.IsZero() {
|
||||
remaining := time.Until(state.TurnDeadline)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
timerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
|
||||
sb.WriteString(timerStyle.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds())))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
```
|
||||
|
||||
Import `"time"` in game_view.go.
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
```bash
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add game/session.go game/turn.go ui/game_view.go
|
||||
git commit -m "feat: display turn countdown timer in combat HUD"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Fix StartGame Race & Overlapping Monster Ranges
|
||||
|
||||
**Files:**
|
||||
- Modify: `game/session.go`, `game/event.go`
|
||||
|
||||
- [ ] **Step 1: Fix StartGame to only run once**
|
||||
|
||||
Add `started bool` field to `GameSession`:
|
||||
```go
|
||||
type GameSession struct {
|
||||
// ...
|
||||
started bool
|
||||
}
|
||||
```
|
||||
|
||||
Guard `StartGame()`:
|
||||
```go
|
||||
func (s *GameSession) StartGame() {
|
||||
s.mu.Lock()
|
||||
if s.started {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.started = true
|
||||
s.state.SoloMode = len(s.state.Players) == 1
|
||||
s.mu.Unlock()
|
||||
s.StartFloor()
|
||||
go s.combatLoop()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fix monster floor ranges to overlap**
|
||||
|
||||
In `game/event.go` `spawnMonsters`, replace the exclusive switch with weighted random selection from valid types:
|
||||
```go
|
||||
func (s *GameSession) spawnMonsters() {
|
||||
count := 1 + rand.Intn(5)
|
||||
floor := s.state.FloorNum
|
||||
s.state.Monsters = make([]*entity.Monster, count)
|
||||
|
||||
// Build list of valid monster types for this floor
|
||||
type floorRange struct {
|
||||
mt entity.MonsterType
|
||||
minFloor int
|
||||
maxFloor int
|
||||
}
|
||||
ranges := []floorRange{
|
||||
{entity.MonsterSlime, 1, 5},
|
||||
{entity.MonsterSkeleton, 3, 10},
|
||||
{entity.MonsterOrc, 6, 14},
|
||||
{entity.MonsterDarkKnight, 12, 20},
|
||||
}
|
||||
var valid []entity.MonsterType
|
||||
for _, r := range ranges {
|
||||
if floor >= r.minFloor && floor <= r.maxFloor {
|
||||
valid = append(valid, r.mt)
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
valid = []entity.MonsterType{entity.MonsterSlime}
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
mt := valid[rand.Intn(len(valid))]
|
||||
m := entity.NewMonster(mt, floor)
|
||||
if s.state.SoloMode {
|
||||
m.HP = m.HP / 2
|
||||
if m.HP < 1 {
|
||||
m.HP = 1
|
||||
}
|
||||
m.MaxHP = m.HP
|
||||
}
|
||||
s.state.Monsters[i] = m
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and test**
|
||||
|
||||
```bash
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./game/ -v -timeout 15s
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add game/session.go game/event.go
|
||||
git commit -m "fix: prevent double StartGame, use overlapping monster floor ranges"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: BSP Dungeon with 2D Tile Map
|
||||
|
||||
**Files:**
|
||||
- Modify: `dungeon/generator.go`, `dungeon/room.go`
|
||||
- Create: `dungeon/render.go`
|
||||
- Modify: `ui/game_view.go`
|
||||
- Test: `dungeon/generator_test.go`
|
||||
|
||||
This is the biggest task. The goal is to replace the list-based room display with a proper 2D ASCII map.
|
||||
|
||||
- [ ] **Step 1: Update Room struct for tile coordinates**
|
||||
|
||||
In `dungeon/room.go`, ensure Room has proper tile-space bounds:
|
||||
```go
|
||||
type Room struct {
|
||||
Type RoomType
|
||||
X, Y int // top-left corner in tile space
|
||||
W, H int // width, height in tiles
|
||||
CenterX int
|
||||
CenterY int
|
||||
Visited bool
|
||||
Cleared bool
|
||||
Neighbors []int
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite BSP generator**
|
||||
|
||||
Replace `dungeon/generator.go` with proper BSP:
|
||||
```go
|
||||
const (
|
||||
MapWidth = 60
|
||||
MapHeight = 20
|
||||
MinRoomW = 8
|
||||
MinRoomH = 5
|
||||
)
|
||||
|
||||
type bspNode struct {
|
||||
x, y, w, h int
|
||||
left, right *bspNode
|
||||
room *Room
|
||||
}
|
||||
|
||||
func GenerateFloor(floorNum int) *Floor {
|
||||
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
|
||||
splitBSP(root, 0)
|
||||
|
||||
var rooms []*Room
|
||||
collectRooms(root, &rooms)
|
||||
|
||||
// Ensure 5-8 rooms
|
||||
for len(rooms) < 5 {
|
||||
// Re-generate if too few
|
||||
root = &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
|
||||
splitBSP(root, 0)
|
||||
rooms = nil
|
||||
collectRooms(root, &rooms)
|
||||
}
|
||||
if len(rooms) > 8 {
|
||||
rooms = rooms[:8]
|
||||
}
|
||||
|
||||
// Assign room types
|
||||
for i := range rooms {
|
||||
rooms[i].Type = RandomRoomType()
|
||||
}
|
||||
rooms[len(rooms)-1].Type = RoomBoss
|
||||
|
||||
// Connect rooms (sibling nodes + extra)
|
||||
connectBSP(root, rooms)
|
||||
|
||||
// Build tile map
|
||||
tiles := buildTileMap(rooms)
|
||||
|
||||
return &Floor{
|
||||
Number: floorNum,
|
||||
Rooms: rooms,
|
||||
CurrentRoom: 0,
|
||||
Tiles: tiles,
|
||||
Width: MapWidth,
|
||||
Height: MapHeight,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `Tiles [][]Tile`, `Width`, `Height` fields to `Floor` struct.
|
||||
|
||||
Define `Tile` type:
|
||||
```go
|
||||
type Tile int
|
||||
const (
|
||||
TileWall Tile = iota
|
||||
TileFloor
|
||||
TileCorridor
|
||||
TileDoor
|
||||
)
|
||||
```
|
||||
|
||||
Implement `splitBSP`, `collectRooms`, `connectBSP`, `buildTileMap` functions.
|
||||
|
||||
- [ ] **Step 3: Create tile map renderer**
|
||||
|
||||
Create `dungeon/render.go`:
|
||||
```go
|
||||
func RenderFloor(floor *Floor, showFog bool) string {
|
||||
// Render tiles as ASCII: '#' wall, '.' floor, '+' door, '#' corridor
|
||||
// Mark current room's content
|
||||
// Apply fog of war: hidden rooms render as space, visited as dim
|
||||
}
|
||||
```
|
||||
|
||||
Characters:
|
||||
- `#` wall
|
||||
- `.` floor
|
||||
- `+` door / corridor
|
||||
- `@` player position (center of current room)
|
||||
- `D` monster (in combat rooms)
|
||||
- `$` treasure, `S` shop, `?` event, `B` boss
|
||||
|
||||
- [ ] **Step 4: Update game_view.go to use tile renderer**
|
||||
|
||||
Replace `renderMap()` in `ui/game_view.go`:
|
||||
```go
|
||||
func renderMap(floor *dungeon.Floor) string {
|
||||
return dungeon.RenderFloor(floor, true)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update generator_test.go**
|
||||
|
||||
Update existing tests to work with new generator. Verify:
|
||||
- 5-8 rooms
|
||||
- Exactly 1 boss room
|
||||
- All rooms connected
|
||||
- Tile map dimensions are MapWidth x MapHeight
|
||||
- Player start room (index 0) has floor tiles
|
||||
|
||||
- [ ] **Step 6: Build and test**
|
||||
|
||||
```bash
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe test ./dungeon/ -v
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add dungeon/ ui/game_view.go
|
||||
git commit -m "feat: BSP dungeon generation with 2D ASCII tile map rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Lobby Join-by-Code
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/model.go`, `ui/lobby_view.go`
|
||||
|
||||
- [ ] **Step 1: Add join-by-code input mode to lobby**
|
||||
|
||||
In `ui/model.go`, add to `lobbyState`:
|
||||
```go
|
||||
type lobbyState struct {
|
||||
rooms []roomInfo
|
||||
input string
|
||||
cursor int
|
||||
joining bool // true when typing a code
|
||||
codeInput string // accumulated code chars
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Handle 'j' key and code input**
|
||||
|
||||
In `updateLobby`:
|
||||
```go
|
||||
if m.lobbyState.joining {
|
||||
if isEnter(key) && len(m.lobbyState.codeInput) == 4 {
|
||||
if err := m.lobby.JoinRoom(m.lobbyState.codeInput, m.playerName); err == nil {
|
||||
m.roomCode = m.lobbyState.codeInput
|
||||
m.screen = screenClassSelect
|
||||
}
|
||||
m.lobbyState.joining = false
|
||||
m.lobbyState.codeInput = ""
|
||||
} else if isKey(key, "esc") {
|
||||
m.lobbyState.joining = false
|
||||
m.lobbyState.codeInput = ""
|
||||
} else if len(key.String()) == 1 && len(m.lobbyState.codeInput) < 4 {
|
||||
m.lobbyState.codeInput += strings.ToUpper(key.String())
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// existing key handling...
|
||||
if isKey(key, "j") {
|
||||
m.lobbyState.joining = true
|
||||
m.lobbyState.codeInput = ""
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Show code input in lobby view**
|
||||
|
||||
In `ui/lobby_view.go` `renderLobby`, add:
|
||||
```go
|
||||
if state.joining {
|
||||
inputStr := state.codeInput + strings.Repeat("_", 4-len(state.codeInput))
|
||||
sb.WriteString(fmt.Sprintf("\nEnter room code: [%s] (Esc to cancel)", inputStr))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
/c/Users/98kim/sdk/go1.25.1/bin/go.exe build ./...
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/model.go ui/lobby_view.go
|
||||
git commit -m "feat: lobby join-by-code with J key and 4-char input"
|
||||
```
|
||||
Reference in New Issue
Block a user