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:
2
PLAN.md
2
PLAN.md
@@ -162,7 +162,7 @@ Goal: Add the entertaining shell implementations.
|
|||||||
- "WARNING: milk expires in 2 days"
|
- "WARNING: milk expires in 2 days"
|
||||||
- Per-credential shell routing via `shell` field in static credentials
|
- Per-credential shell routing via `shell` field in static credentials
|
||||||
|
|
||||||
### 3.4 Text Adventure
|
### 3.4 Text Adventure ✅
|
||||||
- Zork-style dungeon crawler
|
- Zork-style dungeon crawler
|
||||||
- "You are in a dimly lit server room."
|
- "You are in a dimly lit server room."
|
||||||
- Navigation, items, puzzles
|
- Navigation, items, puzzles
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Key settings:
|
|||||||
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
||||||
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
|
- `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)
|
- `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.db_path` — SQLite database path (default `oubliette.db`)
|
||||||
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
||||||
- `storage.retention_interval` — how often to run retention (default `1h`)
|
- `storage.retention_interval` — how often to run retention (default `1h`)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/detection"
|
"git.t-juice.club/torjus/oubliette/internal/detection"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/notify"
|
"git.t-juice.club/torjus/oubliette/internal/notify"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
"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/banking"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell/bash"
|
"git.t-juice.club/torjus/oubliette/internal/shell/bash"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
|
"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 {
|
if err := registry.Register(banking.NewBankingShell(), 1); err != nil {
|
||||||
return nil, fmt.Errorf("registering banking shell: %w", err)
|
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{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
|||||||
114
internal/shell/adventure/adventure.go
Normal file
114
internal/shell/adventure/adventure.go
Normal 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
|
||||||
|
}
|
||||||
383
internal/shell/adventure/adventure_test.go
Normal file
383
internal/shell/adventure/adventure_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/shell/adventure/parser.go
Normal file
106
internal/shell/adventure/parser.go
Normal 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:], " ")}
|
||||||
|
}
|
||||||
211
internal/shell/adventure/world.go
Normal file
211
internal/shell/adventure/world.go
Normal 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
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ hostname = "ubuntu-server"
|
|||||||
# terminal_id = "SB-0001" # random if not set
|
# terminal_id = "SB-0001" # random if not set
|
||||||
# region = "NORTHEAST"
|
# region = "NORTHEAST"
|
||||||
|
|
||||||
|
# [shell.adventure]
|
||||||
|
# dungeon_name = "THE OUBLIETTE"
|
||||||
|
|
||||||
# [detection]
|
# [detection]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications
|
# threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications
|
||||||
|
|||||||
Reference in New Issue
Block a user