diff --git a/PLAN.md b/PLAN.md index 261f03d..33a171e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/README.md b/README.md index 75535fe..0c974be 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/internal/server/server.go b/internal/server/server.go index d53f359..eb4ae3f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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, diff --git a/internal/shell/adventure/adventure.go b/internal/shell/adventure/adventure.go new file mode 100644 index 0000000..8f43927 --- /dev/null +++ b/internal/shell/adventure/adventure.go @@ -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 +} diff --git a/internal/shell/adventure/adventure_test.go b/internal/shell/adventure/adventure_test.go new file mode 100644 index 0000000..a9eaf29 --- /dev/null +++ b/internal/shell/adventure/adventure_test.go @@ -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") + } +} diff --git a/internal/shell/adventure/game.go b/internal/shell/adventure/game.go new file mode 100644 index 0000000..1b5a824 --- /dev/null +++ b/internal/shell/adventure/game.go @@ -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 - Move (north, south, east, west, up, down) + take - Pick up an item + drop - Drop an item + use - 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 + } + } +} diff --git a/internal/shell/adventure/parser.go b/internal/shell/adventure/parser.go new file mode 100644 index 0000000..c34b7e4 --- /dev/null +++ b/internal/shell/adventure/parser.go @@ -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 . + 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:], " ")} +} diff --git a/internal/shell/adventure/world.go b/internal/shell/adventure/world.go new file mode 100644 index 0000000..4b2e1e7 --- /dev/null +++ b/internal/shell/adventure/world.go @@ -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 +} diff --git a/oubliette.toml.example b/oubliette.toml.example index 2112f8c..7e1473f 100644 --- a/oubliette.toml.example +++ b/oubliette.toml.example @@ -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.0–1.0, sessions above this trigger notifications