feat: add text adventure shell (PLAN.md 3.4)
Zork-style dungeon crawler set in an abandoned data center / medieval dungeon. 11 rooms, 6 items, 3 puzzles (dark room, locked door, maintenance panel), standard text adventure parser with verb aliases and direction shortcuts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
358
internal/shell/adventure/game.go
Normal file
358
internal/shell/adventure/game.go
Normal file
@@ -0,0 +1,358 @@
|
||||
package adventure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type gameState struct {
|
||||
currentRoom string
|
||||
inventory []string
|
||||
rooms map[string]*room
|
||||
items map[string]*item
|
||||
flags map[string]bool
|
||||
turns int
|
||||
}
|
||||
|
||||
type commandResult struct {
|
||||
output string
|
||||
exit bool
|
||||
}
|
||||
|
||||
func newGame() *gameState {
|
||||
rooms, items := newWorld()
|
||||
return &gameState{
|
||||
currentRoom: "oubliette",
|
||||
rooms: rooms,
|
||||
items: items,
|
||||
flags: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gameState) dispatch(input string) commandResult {
|
||||
cmd := parseCommand(input)
|
||||
if cmd.verb == "" {
|
||||
return commandResult{}
|
||||
}
|
||||
|
||||
g.turns++
|
||||
|
||||
switch cmd.verb {
|
||||
case "look":
|
||||
return g.cmdLook(cmd.object)
|
||||
case "go":
|
||||
return g.cmdGo(cmd.object)
|
||||
case "take":
|
||||
return g.cmdTake(cmd.object)
|
||||
case "drop":
|
||||
return g.cmdDrop(cmd.object)
|
||||
case "use":
|
||||
return g.cmdUse(cmd.object)
|
||||
case "inventory":
|
||||
return g.cmdInventory()
|
||||
case "help":
|
||||
return g.cmdHelp()
|
||||
case "quit":
|
||||
return commandResult{output: "The darkness closes in. Session terminated.", exit: true}
|
||||
default:
|
||||
return commandResult{output: fmt.Sprintf("I don't understand '%s'. Type 'help' for available commands.", input)}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gameState) cmdHelp() commandResult {
|
||||
help := `Available commands:
|
||||
look / examine [item] - Look around or examine something
|
||||
go <direction> - Move (north, south, east, west, up, down)
|
||||
take <item> - Pick up an item
|
||||
drop <item> - Drop an item
|
||||
use <item> - Use an item
|
||||
inventory - Check what you're carrying
|
||||
help - Show this help
|
||||
quit / exit - End session
|
||||
|
||||
You can also just type a direction (n, s, e, w, u, d) to move.`
|
||||
return commandResult{output: help}
|
||||
}
|
||||
|
||||
func (g *gameState) cmdLook(object string) commandResult {
|
||||
r := g.rooms[g.currentRoom]
|
||||
|
||||
// Look at a specific item.
|
||||
if object != "" {
|
||||
return g.examineItem(object)
|
||||
}
|
||||
|
||||
// Look at the room.
|
||||
return commandResult{output: g.describeRoom(r)}
|
||||
}
|
||||
|
||||
func (g *gameState) describeRoom(r *room) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "== %s ==\n", r.name)
|
||||
|
||||
// Dark room check.
|
||||
if r.darkDesc != "" && !g.hasItem("flashlight") {
|
||||
b.WriteString(r.darkDesc)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
b.WriteString(r.description)
|
||||
|
||||
// List visible items.
|
||||
visibleItems := g.roomItems(r)
|
||||
if len(visibleItems) > 0 {
|
||||
b.WriteString("\n\nYou can see: ")
|
||||
names := make([]string, len(visibleItems))
|
||||
for i, id := range visibleItems {
|
||||
names[i] = g.items[id].name
|
||||
}
|
||||
b.WriteString(strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// List exits.
|
||||
if len(r.exits) > 0 {
|
||||
b.WriteString("\n\nExits: ")
|
||||
dirs := make([]string, 0, len(r.exits))
|
||||
for dir := range r.exits {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
b.WriteString(strings.Join(dirs, ", "))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (g *gameState) roomItems(r *room) []string {
|
||||
// In dark rooms without flashlight, can't see items.
|
||||
if r.darkDesc != "" && !g.hasItem("flashlight") {
|
||||
return nil
|
||||
}
|
||||
return r.items
|
||||
}
|
||||
|
||||
func (g *gameState) examineItem(name string) commandResult {
|
||||
id := g.resolveItem(name)
|
||||
if id == "" {
|
||||
return commandResult{output: fmt.Sprintf("You don't see '%s' here.", name)}
|
||||
}
|
||||
return commandResult{output: g.items[id].description}
|
||||
}
|
||||
|
||||
func (g *gameState) cmdGo(direction string) commandResult {
|
||||
if direction == "" {
|
||||
return commandResult{output: "Go where? Try a direction: north, south, east, west, up, down."}
|
||||
}
|
||||
|
||||
r := g.rooms[g.currentRoom]
|
||||
destID, ok := r.exits[direction]
|
||||
if !ok {
|
||||
return commandResult{output: fmt.Sprintf("You can't go %s from here.", direction)}
|
||||
}
|
||||
|
||||
// Check locked doors.
|
||||
if r.locked != nil {
|
||||
if flag, locked := r.locked[direction]; locked && !g.flags[flag] {
|
||||
return g.lockedMessage(direction)
|
||||
}
|
||||
}
|
||||
|
||||
g.currentRoom = destID
|
||||
dest := g.rooms[destID]
|
||||
|
||||
// Entering the exit room ends the game.
|
||||
if destID == "exit" {
|
||||
return commandResult{output: g.describeRoom(dest), exit: true}
|
||||
}
|
||||
|
||||
return commandResult{output: g.describeRoom(dest)}
|
||||
}
|
||||
|
||||
func (g *gameState) lockedMessage(direction string) commandResult {
|
||||
if direction == "east" && g.currentRoom == "archive" {
|
||||
return commandResult{output: "The steel door won't budge. The keycard reader blinks red, waiting."}
|
||||
}
|
||||
return commandResult{output: "The way is locked."}
|
||||
}
|
||||
|
||||
func (g *gameState) cmdTake(name string) commandResult {
|
||||
if name == "" {
|
||||
return commandResult{output: "Take what?"}
|
||||
}
|
||||
|
||||
r := g.rooms[g.currentRoom]
|
||||
|
||||
// Can't take items in dark rooms.
|
||||
if r.darkDesc != "" && !g.hasItem("flashlight") {
|
||||
return commandResult{output: "It's too dark to find anything."}
|
||||
}
|
||||
|
||||
id := g.resolveRoomItem(name)
|
||||
if id == "" {
|
||||
return commandResult{output: fmt.Sprintf("You don't see '%s' here.", name)}
|
||||
}
|
||||
|
||||
it := g.items[id]
|
||||
if !it.takeable {
|
||||
return commandResult{output: fmt.Sprintf("You can't take the %s.", it.name)}
|
||||
}
|
||||
|
||||
// Remove from room, add to inventory.
|
||||
g.removeRoomItem(r, id)
|
||||
g.inventory = append(g.inventory, id)
|
||||
return commandResult{output: fmt.Sprintf("Taken: %s", it.name)}
|
||||
}
|
||||
|
||||
func (g *gameState) cmdDrop(name string) commandResult {
|
||||
if name == "" {
|
||||
return commandResult{output: "Drop what?"}
|
||||
}
|
||||
|
||||
id := g.resolveInventoryItem(name)
|
||||
if id == "" {
|
||||
return commandResult{output: fmt.Sprintf("You're not carrying '%s'.", name)}
|
||||
}
|
||||
|
||||
// Remove from inventory, add to room.
|
||||
g.removeInventoryItem(id)
|
||||
r := g.rooms[g.currentRoom]
|
||||
r.items = append(r.items, id)
|
||||
return commandResult{output: fmt.Sprintf("Dropped: %s", g.items[id].name)}
|
||||
}
|
||||
|
||||
func (g *gameState) cmdUse(name string) commandResult {
|
||||
if name == "" {
|
||||
return commandResult{output: "Use what?"}
|
||||
}
|
||||
|
||||
id := g.resolveInventoryItem(name)
|
||||
if id == "" {
|
||||
return commandResult{output: fmt.Sprintf("You're not carrying '%s'.", name)}
|
||||
}
|
||||
|
||||
switch id {
|
||||
case "keycard":
|
||||
return g.useKeycard()
|
||||
case "rusty_key":
|
||||
return g.useRustyKey()
|
||||
case "flashlight":
|
||||
return commandResult{output: "The flashlight is already on. Its beam cuts through the darkness."}
|
||||
case "ethernet_cable":
|
||||
return commandResult{output: "You wave the cable around hopefully. Nothing happens. Both ends are chewed through anyway."}
|
||||
case "floppy_disk":
|
||||
return commandResult{output: "You don't have anything to put it in. Floppy drives went extinct decades ago."}
|
||||
case "note":
|
||||
return commandResult{output: g.items[id].description}
|
||||
default:
|
||||
return commandResult{output: fmt.Sprintf("You can't figure out how to use the %s here.", g.items[id].name)}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gameState) useKeycard() commandResult {
|
||||
if g.currentRoom != "archive" {
|
||||
return commandResult{output: "There's nothing to use the keycard on here."}
|
||||
}
|
||||
if g.flags["archive_unlocked"] {
|
||||
return commandResult{output: "You already unlocked that door."}
|
||||
}
|
||||
g.flags["archive_unlocked"] = true
|
||||
return commandResult{output: "You swipe the keycard. The reader flashes green and the steel door clicks open with a heavy thunk."}
|
||||
}
|
||||
|
||||
func (g *gameState) useRustyKey() commandResult {
|
||||
if g.currentRoom != "generator" {
|
||||
return commandResult{output: "There's nothing to use the rusty key on here."}
|
||||
}
|
||||
if g.flags["panel_opened"] {
|
||||
return commandResult{output: "The maintenance panel is already open."}
|
||||
}
|
||||
g.flags["panel_opened"] = true
|
||||
return commandResult{output: `The key fits. The maintenance panel swings open with a screech, revealing a logbook:
|
||||
|
||||
MAINTENANCE LOG - GENERATOR B
|
||||
Last service: 2003-11-15
|
||||
Technician: J. Dunwich
|
||||
|
||||
Notes: "Generator A offline permanently. B running on fumes.
|
||||
Fuel delivery canceled - 'facility decommissioned' per management.
|
||||
But the servers are still running. WHO IS USING THEM?
|
||||
Filed ticket #4,271. No response. As usual."
|
||||
|
||||
The entries stop after that date.`}
|
||||
}
|
||||
|
||||
func (g *gameState) cmdInventory() commandResult {
|
||||
if len(g.inventory) == 0 {
|
||||
return commandResult{output: "You're not carrying anything."}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("You are carrying:\n")
|
||||
for _, id := range g.inventory {
|
||||
fmt.Fprintf(&b, " - %s\n", g.items[id].name)
|
||||
}
|
||||
return commandResult{output: strings.TrimRight(b.String(), "\n")}
|
||||
}
|
||||
|
||||
// hasItem checks if the player has an item in inventory.
|
||||
func (g *gameState) hasItem(id string) bool {
|
||||
return slices.Contains(g.inventory, id)
|
||||
}
|
||||
|
||||
// resolveItem finds an item by name in both room and inventory.
|
||||
func (g *gameState) resolveItem(name string) string {
|
||||
if id := g.resolveInventoryItem(name); id != "" {
|
||||
return id
|
||||
}
|
||||
return g.resolveRoomItem(name)
|
||||
}
|
||||
|
||||
// resolveRoomItem finds an item in the current room by partial name match.
|
||||
func (g *gameState) resolveRoomItem(name string) string {
|
||||
r := g.rooms[g.currentRoom]
|
||||
name = strings.ToLower(name)
|
||||
for _, id := range r.items {
|
||||
if matchesItem(id, g.items[id].name, name) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveInventoryItem finds an item in inventory by partial name match.
|
||||
func (g *gameState) resolveInventoryItem(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
for _, id := range g.inventory {
|
||||
if matchesItem(id, g.items[id].name, name) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchesItem checks if a search term matches an item's ID or display name.
|
||||
func matchesItem(id, displayName, search string) bool {
|
||||
return id == search ||
|
||||
strings.ToLower(displayName) == search ||
|
||||
strings.Contains(id, search) ||
|
||||
strings.Contains(strings.ToLower(displayName), search)
|
||||
}
|
||||
|
||||
func (g *gameState) removeRoomItem(r *room, id string) {
|
||||
for i, itemID := range r.items {
|
||||
if itemID == id {
|
||||
r.items = append(r.items[:i], r.items[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gameState) removeInventoryItem(id string) {
|
||||
for i, invID := range g.inventory {
|
||||
if invID == id {
|
||||
g.inventory = append(g.inventory[:i], g.inventory[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user