This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/shell/adventure/game.go
Torjus Håkestad aa569aac16 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>
2026-02-15 05:13:03 +01:00

359 lines
9.4 KiB
Go

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
}
}
}