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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user