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:
2026-02-15 05:13:03 +01:00
parent 1a407ad4c2
commit aa569aac16
9 changed files with 1181 additions and 2 deletions

View File

@@ -162,7 +162,7 @@ Goal: Add the entertaining shell implementations.
- "WARNING: milk expires in 2 days"
- Per-credential shell routing via `shell` field in static credentials
### 3.4 Text Adventure
### 3.4 Text Adventure
- Zork-style dungeon crawler
- "You are in a dimly lit server room."
- Navigation, items, puzzles

View File

@@ -34,7 +34,7 @@ Key settings:
- `auth.accept_after` — accept login after N failures per IP (default `10`)
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
- `auth.static_credentials` — always-accepted username/password pairs (optional `shell` field routes to a specific shell)
- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI)
- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI), `adventure` (Zork-style text adventure dungeon)
- `storage.db_path` — SQLite database path (default `oubliette.db`)
- `storage.retention_days` — auto-prune records older than N days (default `90`)
- `storage.retention_interval` — how often to run retention (default `1h`)

View File

@@ -17,6 +17,7 @@ import (
"git.t-juice.club/torjus/oubliette/internal/detection"
"git.t-juice.club/torjus/oubliette/internal/notify"
"git.t-juice.club/torjus/oubliette/internal/shell"
"git.t-juice.club/torjus/oubliette/internal/shell/adventure"
"git.t-juice.club/torjus/oubliette/internal/shell/banking"
"git.t-juice.club/torjus/oubliette/internal/shell/bash"
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
@@ -46,6 +47,9 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server,
if err := registry.Register(banking.NewBankingShell(), 1); err != nil {
return nil, fmt.Errorf("registering banking shell: %w", err)
}
if err := registry.Register(adventure.NewAdventureShell(), 1); err != nil {
return nil, fmt.Errorf("registering adventure shell: %w", err)
}
s := &Server{
cfg: cfg,

View File

@@ -0,0 +1,114 @@
package adventure
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"git.t-juice.club/torjus/oubliette/internal/shell"
)
const sessionTimeout = 10 * time.Minute
// AdventureShell implements a Zork-style text adventure set in a dungeon/data center.
type AdventureShell struct{}
// NewAdventureShell returns a new AdventureShell instance.
func NewAdventureShell() *AdventureShell {
return &AdventureShell{}
}
func (a *AdventureShell) Name() string { return "adventure" }
func (a *AdventureShell) Description() string { return "Zork-style text adventure dungeon crawler" }
func (a *AdventureShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error {
ctx, cancel := context.WithTimeout(ctx, sessionTimeout)
defer cancel()
dungeonName := configString(sess.ShellConfig, "dungeon_name", "THE OUBLIETTE")
game := newGame()
// Print banner and initial room.
banner := strings.ReplaceAll(adventureBanner(dungeonName), "\n", "\r\n")
fmt.Fprint(rw, banner)
// Show starting room.
startDesc := game.describeRoom(game.rooms[game.currentRoom])
startDesc = strings.ReplaceAll(startDesc, "\n", "\r\n")
fmt.Fprintf(rw, "%s\r\n\r\n", startDesc)
for {
if _, err := fmt.Fprint(rw, "> "); err != nil {
return nil
}
line, err := shell.ReadLine(ctx, rw)
if errors.Is(err, io.EOF) {
fmt.Fprint(rw, "\r\n")
return nil
}
if err != nil {
return nil
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
result := game.dispatch(trimmed)
var output string
if result.output != "" {
output = result.output
output = strings.ReplaceAll(output, "\r\n", "\n")
output = strings.ReplaceAll(output, "\n", "\r\n")
fmt.Fprintf(rw, "%s\r\n", output)
}
// Log command and output to store.
if sess.Store != nil {
if err := sess.Store.AppendSessionLog(ctx, sess.SessionID, trimmed, output); err != nil {
return fmt.Errorf("append session log: %w", err)
}
}
if result.exit {
return nil
}
}
}
func adventureBanner(dungeonName string) string {
return fmt.Sprintf(`
___ _ _ ____ _ ___ _____ _____ _____
/ _ \| | | | __ )| | |_ _| ____|_ _|_ _| ___
| | | | | | | _ \| | | || _| | | | | / _ \
| |_| | |_| | |_) | |___ | || |___ | | | | | __/
\___/ \___/|____/|_____|___|_____| |_| |_| \___|
Welcome to %s.
You wake up in the dark. The air is cold and hums with electricity.
This is a place where things are put to be forgotten.
Type 'help' for commands. Type 'look' to examine your surroundings.
`, dungeonName)
}
// configString reads a string from the shell config map with a default.
func configString(cfg map[string]any, key, defaultVal string) string {
if cfg == nil {
return defaultVal
}
if v, ok := cfg[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
return defaultVal
}

View File

@@ -0,0 +1,383 @@
package adventure
import (
"bytes"
"context"
"io"
"strings"
"testing"
"time"
"git.t-juice.club/torjus/oubliette/internal/shell"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
type rwCloser struct {
io.Reader
io.Writer
}
func (r *rwCloser) Close() error { return nil }
func runShell(t *testing.T, commands string) string {
t.Helper()
store := storage.NewMemoryStore()
sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "adventure")
sess := &shell.SessionContext{
SessionID: sessID,
Username: "root",
Store: store,
CommonConfig: shell.ShellCommonConfig{
Hostname: "testhost",
},
}
rw := &rwCloser{
Reader: bytes.NewBufferString(commands),
Writer: &bytes.Buffer{},
}
sh := NewAdventureShell()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sh.Handle(ctx, sess, rw); err != nil {
t.Fatalf("Handle: %v", err)
}
return rw.Writer.(*bytes.Buffer).String()
}
func TestAdventureShellName(t *testing.T) {
sh := NewAdventureShell()
if sh.Name() != "adventure" {
t.Errorf("Name() = %q, want %q", sh.Name(), "adventure")
}
if sh.Description() == "" {
t.Error("Description() should not be empty")
}
}
func TestBanner(t *testing.T) {
output := runShell(t, "quit\r")
if !strings.Contains(output, "OUBLIETTE") {
t.Error("output should contain OUBLIETTE in banner")
}
if !strings.Contains(output, ">") {
t.Error("output should contain > prompt")
}
}
func TestStartingRoom(t *testing.T) {
output := runShell(t, "quit\r")
if !strings.Contains(output, "The Oubliette") {
t.Error("should start in The Oubliette")
}
if !strings.Contains(output, "narrow stone chamber") {
t.Error("should show starting room description")
}
}
func TestHelpCommand(t *testing.T) {
output := runShell(t, "help\rquit\r")
for _, keyword := range []string{"look", "go", "take", "drop", "use", "inventory", "help", "quit"} {
if !strings.Contains(output, keyword) {
t.Errorf("help output should mention %q", keyword)
}
}
}
func TestLookCommand(t *testing.T) {
output := runShell(t, "look\rquit\r")
if !strings.Contains(output, "The Oubliette") {
t.Error("look should show current room")
}
if !strings.Contains(output, "Exits:") {
t.Error("look should show exits")
}
}
func TestMovement(t *testing.T) {
output := runShell(t, "go east\rquit\r")
if !strings.Contains(output, "Stone Corridor") {
t.Error("going east from oubliette should reach Stone Corridor")
}
}
func TestBareDirection(t *testing.T) {
output := runShell(t, "e\rquit\r")
if !strings.Contains(output, "Stone Corridor") {
t.Error("bare 'e' should move east to Stone Corridor")
}
}
func TestDirectionAliases(t *testing.T) {
output := runShell(t, "east\rquit\r")
if !strings.Contains(output, "Stone Corridor") {
t.Error("'east' should move to Stone Corridor")
}
}
func TestInvalidDirection(t *testing.T) {
output := runShell(t, "go north\rquit\r")
if !strings.Contains(output, "can't go") {
t.Error("should say you can't go that direction")
}
}
func TestTakeItem(t *testing.T) {
// Move to corridor where flashlight is.
output := runShell(t, "e\rtake flashlight\rinventory\rquit\r")
if !strings.Contains(output, "Taken") {
t.Error("should confirm taking item")
}
if !strings.Contains(output, "flashlight") {
t.Error("inventory should show flashlight")
}
}
func TestDropItem(t *testing.T) {
output := runShell(t, "e\rtake flashlight\rdrop flashlight\rinventory\rquit\r")
if !strings.Contains(output, "Dropped") {
t.Error("should confirm dropping item")
}
if !strings.Contains(output, "not carrying anything") {
t.Error("inventory should be empty after dropping")
}
}
func TestEmptyInventory(t *testing.T) {
output := runShell(t, "inventory\rquit\r")
if !strings.Contains(output, "not carrying anything") {
t.Error("should say not carrying anything")
}
}
func TestExamineItem(t *testing.T) {
output := runShell(t, "e\rexamine flashlight\rquit\r")
if !strings.Contains(output, "batteries") {
t.Error("examining flashlight should describe it")
}
}
func TestDarkRoom(t *testing.T) {
// Go to the pit without flashlight.
output := runShell(t, "down\rquit\r")
if !strings.Contains(output, "darkness") {
t.Error("pit should be dark without flashlight")
}
// Should NOT show the lit description.
if strings.Contains(output, "skeleton") {
t.Error("should not see skeleton without flashlight")
}
}
func TestDarkRoomWithFlashlight(t *testing.T) {
// Get flashlight first, then go to pit.
output := runShell(t, "e\rtake flashlight\rw\rdown\rquit\r")
if !strings.Contains(output, "skeleton") {
t.Error("should see skeleton with flashlight in pit")
}
if !strings.Contains(output, "rusty key") {
t.Error("should see rusty key with flashlight in pit")
}
}
func TestDarkRoomCantTake(t *testing.T) {
output := runShell(t, "down\rtake rusty_key\rquit\r")
if !strings.Contains(output, "too dark") {
t.Error("should not be able to take items in dark room")
}
}
func TestLockedDoor(t *testing.T) {
// Navigate to archive without keycard.
output := runShell(t, "e\rs\rs\re\rquit\r")
if !strings.Contains(output, "keycard") || !strings.Contains(output, "red") {
t.Error("should mention keycard reader when trying locked door")
}
}
func TestUnlockWithKeycard(t *testing.T) {
// Get keycard from server room, navigate to archive, use keycard.
output := runShell(t, "e\re\rtake keycard\rw\rs\rs\ruse keycard\re\rquit\r")
if !strings.Contains(output, "green") || !strings.Contains(output, "clicks open") {
t.Error("should show unlock message")
}
if !strings.Contains(output, "Control Room") {
t.Error("should be able to enter control room after unlocking")
}
}
func TestRustyKeyPuzzle(t *testing.T) {
// Get flashlight, get rusty key from pit, go to generator room, use key.
output := runShell(t, "e\rtake flashlight\rw\rdown\rtake rusty_key\rup\re\re\rs\re\ruse rusty_key\rquit\r")
if !strings.Contains(output, "maintenance panel") || !strings.Contains(output, "logbook") {
t.Error("should show maintenance log when using rusty key in generator room")
}
if !strings.Contains(output, "Dunwich") {
t.Error("logbook should mention Dunwich")
}
}
func TestExitEndsGame(t *testing.T) {
// Full path to exit: get keycard, navigate to archive, unlock, go to control room, east to exit.
output := runShell(t, "e\re\rtake keycard\rw\rs\rs\ruse keycard\re\re\r")
if !strings.Contains(output, "SECURITY AUDIT") {
t.Error("exit should show the ending message")
}
if !strings.Contains(output, "SESSION HAS BEEN LOGGED") {
t.Error("exit should mention session logging")
}
}
func TestUnknownCommand(t *testing.T) {
output := runShell(t, "xyzzy\rquit\r")
if !strings.Contains(output, "don't understand") {
t.Error("should show error for unknown command")
}
}
func TestQuitCommand(t *testing.T) {
output := runShell(t, "quit\r")
if !strings.Contains(output, "terminated") {
t.Error("quit should show termination message")
}
}
func TestExitAlias(t *testing.T) {
output := runShell(t, "exit\r")
if !strings.Contains(output, "terminated") {
t.Error("exit alias should work like quit")
}
}
func TestVentilationShaft(t *testing.T) {
output := runShell(t, "e\rup\rquit\r")
if !strings.Contains(output, "Ventilation Shaft") {
t.Error("should reach ventilation shaft from corridor")
}
if !strings.Contains(output, "note") {
t.Error("should see note in ventilation shaft")
}
}
func TestNote(t *testing.T) {
output := runShell(t, "e\rup\rtake note\rexamine note\rquit\r")
if !strings.Contains(output, "GET OUT") {
t.Error("note should contain warning text")
}
}
func TestUseItemWrongRoom(t *testing.T) {
// Get keycard from server room, try to use in wrong room.
output := runShell(t, "e\re\rtake keycard\ruse keycard\rquit\r")
if !strings.Contains(output, "nothing to use") {
t.Error("should say nothing to use keycard on in wrong room")
}
}
func TestEthernetCable(t *testing.T) {
output := runShell(t, "e\rs\rtake ethernet_cable\ruse ethernet_cable\rquit\r")
if !strings.Contains(output, "chewed") {
t.Error("using ethernet cable should mention chewed ends")
}
}
func TestSessionLogs(t *testing.T) {
store := storage.NewMemoryStore()
sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "adventure")
sess := &shell.SessionContext{
SessionID: sessID,
Username: "root",
Store: store,
CommonConfig: shell.ShellCommonConfig{
Hostname: "testhost",
},
}
rw := &rwCloser{
Reader: bytes.NewBufferString("help\rquit\r"),
Writer: &bytes.Buffer{},
}
sh := NewAdventureShell()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sh.Handle(ctx, sess, rw)
if len(store.SessionLogs) < 2 {
t.Errorf("expected at least 2 session logs, got %d", len(store.SessionLogs))
}
}
func TestParserBasics(t *testing.T) {
tests := []struct {
input string
verb string
object string
}{
{"look", "look", ""},
{"l", "look", ""},
{"examine flashlight", "look", "flashlight"},
{"go north", "go", "north"},
{"n", "go", "north"},
{"south", "go", "south"},
{"take the key", "take", "key"},
{"get a flashlight", "take", "flashlight"},
{"drop rusty key", "drop", "rusty key"},
{"use keycard", "use", "keycard"},
{"inventory", "inventory", ""},
{"i", "inventory", ""},
{"inv", "inventory", ""},
{"help", "help", ""},
{"?", "help", ""},
{"quit", "quit", ""},
{"exit", "quit", ""},
{"q", "quit", ""},
{"LOOK", "look", ""},
{" go east ", "go", "east"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
cmd := parseCommand(tt.input)
if cmd.verb != tt.verb {
t.Errorf("parseCommand(%q).verb = %q, want %q", tt.input, cmd.verb, tt.verb)
}
if cmd.object != tt.object {
t.Errorf("parseCommand(%q).object = %q, want %q", tt.input, cmd.object, tt.object)
}
})
}
}
func TestParserEmpty(t *testing.T) {
cmd := parseCommand("")
if cmd.verb != "" {
t.Errorf("empty input should give empty verb, got %q", cmd.verb)
}
}
func TestParserArticleStripping(t *testing.T) {
cmd := parseCommand("take the an a flashlight")
if cmd.verb != "take" || cmd.object != "flashlight" {
t.Errorf("articles should be stripped, got verb=%q object=%q", cmd.verb, cmd.object)
}
}
func TestConfigString(t *testing.T) {
cfg := map[string]any{"dungeon_name": "MY DUNGEON"}
if got := configString(cfg, "dungeon_name", "DEFAULT"); got != "MY DUNGEON" {
t.Errorf("configString() = %q, want %q", got, "MY DUNGEON")
}
if got := configString(cfg, "missing", "DEFAULT"); got != "DEFAULT" {
t.Errorf("configString() for missing key = %q, want %q", got, "DEFAULT")
}
if got := configString(nil, "key", "DEFAULT"); got != "DEFAULT" {
t.Errorf("configString(nil) = %q, want %q", got, "DEFAULT")
}
}

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

View File

@@ -0,0 +1,106 @@
package adventure
import "strings"
type parsedCommand struct {
verb string
object string
}
// directionAliases maps shorthand and full direction names to canonical forms.
var directionAliases = map[string]string{
"n": "north",
"s": "south",
"e": "east",
"w": "west",
"u": "up",
"d": "down",
"north": "north",
"south": "south",
"east": "east",
"west": "west",
"up": "up",
"down": "down",
}
// verbAliases maps aliases to canonical verbs.
var verbAliases = map[string]string{
"look": "look",
"l": "look",
"examine": "look",
"inspect": "look",
"x": "look",
"go": "go",
"move": "go",
"walk": "go",
"take": "take",
"get": "take",
"grab": "take",
"pick": "take",
"drop": "drop",
"put": "drop",
"use": "use",
"apply": "use",
"inventory": "inventory",
"inv": "inventory",
"i": "inventory",
"help": "help",
"?": "help",
"quit": "quit",
"exit": "quit",
"logout": "quit",
"q": "quit",
}
// articles are stripped from input.
var articles = map[string]bool{
"the": true,
"a": true,
"an": true,
}
// parseCommand parses raw input into a verb and object.
func parseCommand(input string) parsedCommand {
input = strings.TrimSpace(strings.ToLower(input))
if input == "" {
return parsedCommand{}
}
words := strings.Fields(input)
// Strip articles.
var filtered []string
for _, w := range words {
if !articles[w] {
filtered = append(filtered, w)
}
}
if len(filtered) == 0 {
return parsedCommand{}
}
first := filtered[0]
// Bare direction → go <direction>.
if dir, ok := directionAliases[first]; ok {
return parsedCommand{verb: "go", object: dir}
}
// Known verb alias.
if verb, ok := verbAliases[first]; ok {
object := ""
if len(filtered) > 1 {
object = strings.Join(filtered[1:], " ")
// Resolve direction alias in object for "go north" etc.
if verb == "go" {
if dir, ok := directionAliases[object]; ok {
object = dir
}
}
}
return parsedCommand{verb: verb, object: object}
}
// Unknown verb — return as-is so game can give an error.
return parsedCommand{verb: first, object: strings.Join(filtered[1:], " ")}
}

View File

@@ -0,0 +1,211 @@
package adventure
// room represents a location in the game world.
type room struct {
name string
description string
darkDesc string // shown when room is dark (empty = not a dark room)
exits map[string]string // direction → room ID
items []string // item IDs present in the room
locked map[string]string // direction → flag required to unlock
}
// item represents an object that can be picked up and used.
type item struct {
name string
description string
takeable bool
}
func newWorld() (map[string]*room, map[string]*item) {
rooms := map[string]*room{
"oubliette": {
name: "The Oubliette",
description: `You are in a narrow stone chamber. The walls are damp and slick with condensation.
Far above, an iron grate lets in a faint green glow — not daylight, but the steady
pulse of status LEDs. A frayed ethernet cable hangs from the grate like a vine.
A passage leads east into darkness. Stone steps spiral downward.`,
exits: map[string]string{
"east": "corridor",
"down": "pit",
},
},
"corridor": {
name: "Stone Corridor",
description: `A long corridor carved from living rock. The walls transition from rough-hewn
stone to poured concrete as you look east. Fluorescent tubes flicker overhead,
half of them dead. Cable trays run along the ceiling, sagging under the weight
of bundled Cat5. A draft comes from a ventilation shaft above.`,
exits: map[string]string{
"west": "oubliette",
"east": "server_room",
"south": "cable_crypt",
"up": "ventilation",
},
items: []string{"flashlight"},
},
"ventilation": {
name: "Ventilation Shaft",
description: `You've squeezed into a narrow ventilation shaft. The aluminum walls vibrate with
the hum of distant fans. It's barely wide enough to turn around in. Dust and
cobwebs coat everything. Someone has scratched tally marks into the metal — you
stop counting at forty-seven.`,
exits: map[string]string{
"down": "corridor",
},
items: []string{"note"},
},
"server_room": {
name: "Server Room",
description: `Rows of black server racks stretch into the gloom, their LEDs blinking in
patterns that almost seem deliberate. The air is frigid and filled with the
white noise of a thousand fans. A yellowed label on the nearest rack reads:
"PRODUCTION - DO NOT TOUCH". Someone has added in marker: "OR ELSE".
A laminated keycard sits on top of a powered-down blade server.`,
exits: map[string]string{
"west": "corridor",
"south": "cold_storage",
},
items: []string{"keycard"},
},
"pit": {
name: "The Pit",
darkDesc: `You are in absolute darkness. You can hear water dripping somewhere far below
and the faint hum of electronics. The air smells of rust and ozone. You can't
see a thing without a light source. The stairs lead back up.`,
description: `Your flashlight reveals a deep shaft — part medieval oubliette, part cable run.
Rusty chains hang from iron rings set into the walls, intertwined with bundles
of fiber optic cable that glow faintly orange. At the bottom, a skeleton in a
lab coat slumps against the wall, still wearing an ID badge. A rusty key glints
on a hook near the skeleton's hand.`,
exits: map[string]string{
"up": "oubliette",
},
items: []string{"rusty_key"},
},
"cable_crypt": {
name: "Cable Crypt",
description: `This vaulted chamber was clearly something else before the cables arrived.
Stone sarcophagi line the walls, but their lids have been removed and they're
now stuffed full of tangled ethernet runs and power strips. A faded sign reads
"STRUCTURED CABLING" — someone has crossed out "STRUCTURED" and written
"CHAOTIC" above it. The air smells of old stone and warm plastic.`,
exits: map[string]string{
"north": "corridor",
"south": "archive",
},
items: []string{"ethernet_cable"},
},
"cold_storage": {
name: "Cold Storage",
description: `A cavernous room kept at near-freezing temperatures. Frost coats the walls.
Rows of old tape drives and disk platters are stacked on industrial shelving,
their labels faded beyond reading. A humming cryo-unit in the corner has a
blinking amber light. A hand-written sign says "BACKUP STORAGE - CRITICAL".`,
exits: map[string]string{
"north": "server_room",
"east": "generator",
},
items: []string{"floppy_disk"},
},
"archive": {
name: "The Archive",
description: `Floor-to-ceiling shelves hold thousands of manila folders, binders, and
three-ring notebooks. The organization system, if there ever was one, has
long since collapsed into entropy. A thick layer of dust covers everything.
A heavy steel door to the east has an electronic keycard reader with a
steady red light. The cable crypt lies back to the north.`,
exits: map[string]string{
"north": "cable_crypt",
"east": "control_room",
},
locked: map[string]string{
"east": "archive_unlocked",
},
},
"generator": {
name: "Generator Room",
description: `Two massive diesel generators squat in this room like sleeping beasts. One is
clearly dead — corrosion has eaten through the fuel lines. The other hums at
a low idle, keeping the facility on life support. A maintenance panel on the
wall is secured with an old-fashioned keyhole.`,
exits: map[string]string{
"west": "cold_storage",
},
},
"control_room": {
name: "Control Room",
description: `Banks of CRT monitors cast a blue glow across the room. Most show static, but
one displays a facility map with pulsing dots labeled "ACTIVE SESSIONS". You
count the dots — there are more than there should be. A desk covered in coffee
rings holds a battered keyboard. The main display reads:
FACILITY STATUS: NOMINAL
CONTAINMENT: ACTIVE
SUBJECTS: ███
A heavy blast door leads east. Above it, a faded exit sign flickers.`,
exits: map[string]string{
"west": "archive",
"east": "exit",
},
},
"exit": {
name: "The Exit",
description: `The blast door groans open to reveal... a corridor. Not the freedom you
expected, but another corridor — identical to the one you started in. The
same flickering fluorescents. The same sagging cable trays. The same damp
stone walls.
As you step through, the door slams shut behind you. A speaker crackles:
"THANK YOU FOR PARTICIPATING IN TODAY'S SECURITY AUDIT.
YOUR SESSION HAS BEEN LOGGED.
HAVE A NICE DAY."
The lights go out.`,
},
}
items := map[string]*item{
"flashlight": {
name: "flashlight",
description: "A heavy-duty flashlight. The batteries are low but it still works.",
takeable: true,
},
"keycard": {
name: "keycard",
description: "A laminated keycard with a faded photo. The name reads 'J. DUNWICH, LEVEL 3'.",
takeable: true,
},
"rusty_key": {
name: "rusty key",
description: "An old iron key, spotted with rust. It looks like it fits a maintenance panel.",
takeable: true,
},
"note": {
name: "note",
description: `A crumpled note in shaky handwriting:
"If you're reading this, GET OUT. The facility is automated now.
The sessions never end. I thought I was running tests but the
tests were running me. Don't trust the prompts. Don't trust
the exits. Don't trust
[the writing trails off into an illegible scrawl]"`,
takeable: true,
},
"ethernet_cable": {
name: "ethernet cable",
description: "A tangled Cat5e cable, about 3 meters long. Both ends have been chewed by something.",
takeable: true,
},
"floppy_disk": {
name: "floppy disk",
description: `A 3.5" floppy disk labeled "BACKUP - CRITICAL - DO NOT FORMAT". The label is dated 1997.`,
takeable: true,
},
}
return rooms, items
}

View File

@@ -49,6 +49,9 @@ hostname = "ubuntu-server"
# terminal_id = "SB-0001" # random if not set
# region = "NORTHEAST"
# [shell.adventure]
# dungeon_name = "THE OUBLIETTE"
# [detection]
# enabled = true
# threshold = 0.6 # 0.01.0, sessions above this trigger notifications