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>
384 lines
11 KiB
Go
384 lines
11 KiB
Go
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")
|
|
}
|
|
}
|