text_player 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 560e01113ea75b493fc246f2fdc7141fcd20c0d294a877189e823c91503e2c46
4
+ data.tar.gz: 966be56852cb9ef8561cbca87f34b03e9e51f34d0b89034207cbca187f8accc3
5
+ SHA512:
6
+ metadata.gz: bffe4f70ce951b792b712185e749b4e90d116f85c03f90a91279eb042159f399a46ed50678922fdca8596d89fbb9ce99fdd438013c85277bde876299151cafdc
7
+ data.tar.gz: d35806c05c92dbf1516c055a58b439c34f32c20ee243c69f4b352b32c3471df1f66d2c0d1078c963002b612d3f4bdddc7120c73f472e63edd32a69f7dd200959
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-06-08
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Martin Emde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # TextPlayer
2
+
3
+ A Ruby interface for running text-based interactive fiction games using the Frotz Z-Machine interpreter. This gem provides structured access to classic text adventure games with multiple output formatters for different use cases.
4
+
5
+ Inspired by [@danielricks/textplayer](https://davidgriffith.gitlab.io/frotz/) - the original Python implementation.
6
+
7
+ I have chosen not to distribute the games in the ruby gem. You'll need to clone this repository to use the games directly without the full pathname. This is out of an abundance of caution and respect to the owners. Offering them for download, as is done regularly, may be interpreted differently than distributing them in a package.
8
+
9
+ I am grateful for the ability to use these games for learning and building. Zork is the game that got me started on MUDs as a kid, which is the reason I'm a programmer now.
10
+
11
+ ## Requirements
12
+
13
+ TextPlayer requires Frotz, a Z-Machine interpreter written by Stefan Jokisch in 1995-1997. More information [here](http://frotz.sourceforge.net/).
14
+
15
+ Use Homebrew to install the `frotz` package:
16
+
17
+ ```bash
18
+ $ brew instal frotz
19
+ ```
20
+
21
+ If you don't have homebrew, download the source code, build and install.
22
+
23
+ ```bash
24
+ $ git clone https://github.com/DavidGriffith/frotz.git
25
+ $ cd frotz
26
+ $ make dumb
27
+ $ make dumb_install # optional, but recommended
28
+ ```
29
+
30
+ The `dfrotz` (dumb frotz) binary must be available in your PATH or you will need to pass the path to the dfrotz executable as an argument to TextPlayer.
31
+
32
+ ## Installation
33
+
34
+ Add to an application:
35
+
36
+ ```bash
37
+ $ bundle add text_player
38
+ $ bundle install
39
+ ```
40
+
41
+ Or install it:
42
+
43
+ ```bash
44
+ $ gem install text_player
45
+ ```
46
+
47
+ If you'd like to use the games included in the repository, clone it directly from github.com:
48
+
49
+ ```bash
50
+ $ git clone git@github.com:martinemde/text_player.git
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ You can use the command line to check if it's working:
56
+
57
+ ```bash
58
+ $ text_player help
59
+ $ text_player play zork1
60
+ ```
61
+
62
+ ### Basic Example
63
+
64
+ The point of this library is to allow you to run text based adventure games programmatically.
65
+
66
+ ```ruby
67
+ require 'text_player'
68
+
69
+ # Create a new game session
70
+ game = TextPlayer::Session.new('games/zork1.z5')
71
+
72
+ # Or specify a custom dfrotz path
73
+ # This must be dfrotz, the DUMB version of frotz, which installs with frotz.
74
+ game = TextPlayer::Session.new('games/zork1.z5', dfrotz: '~/bin/dfrotz')
75
+
76
+ # Start the game
77
+ start_output = game.start
78
+ puts start_output
79
+
80
+ # Execute commands
81
+ response = game.call('go north')
82
+ puts response
83
+
84
+ # Get current score
85
+ if score = game.score
86
+ current_score, max_score = score.score, score.out_of
87
+ puts "Score: #{current_score}/#{max_score}"
88
+ end
89
+
90
+ # Save and restore
91
+ game.save('my_save')
92
+ game.restore('my_save')
93
+
94
+ # Quit the game
95
+ game.quit
96
+ ```
97
+
98
+ ### Save and Restore Operations
99
+
100
+ ```ruby
101
+ # Save to default slot (autosave)
102
+ save_result = game.save
103
+ puts save_result # Formatted feedback about save operation
104
+
105
+ # Save to named slot
106
+ game.save('before_dragon')
107
+
108
+ # Restore from default slot
109
+ game.restore
110
+
111
+ # Restore from named slot
112
+ game.restore('before_dragon')
113
+ ```
114
+
115
+ ### Interactive Shell Example
116
+
117
+ ```ruby
118
+ require 'text_player'
119
+
120
+ game = TextPlayer::Session.new('zork1.z5')
121
+ formatter = TextPlayer::Formatters::Shell
122
+ game.run do |result|
123
+ formatter.new(result).write($stdout)
124
+ command = $stdin.gets
125
+ break if command.nil?
126
+ command
127
+ end
128
+ ```
129
+
130
+ ### Configuring dfrotz Path
131
+
132
+ By default, TextPlayer looks for the `dfrotz` executable in the system PATH `dfrotz`. You can specify a custom path:
133
+
134
+ ```ruby
135
+ # Use local path to compiled dfrotz
136
+ game = TextPlayer::Session.new('zork1.z5', dfrotz: './frotz/dfrotz')
137
+
138
+ # Use absolute path
139
+ game = TextPlayer::Session.new('zork1.z5', dfrotz: '/usr/local/bin/dfrotz')
140
+ ```
141
+
142
+ ## Development
143
+
144
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
145
+
146
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
147
+
148
+ ## Contributing
149
+
150
+ Bug reports and pull requests are welcome on GitHub at https://github.com/martinemde/text_player.
151
+
152
+ ## Game Files
153
+
154
+ You'll need Z-Machine game files (`.z3`, `.z5`, `.z8` extensions) to play. Many classic interactive fiction games are available from:
155
+
156
+ - [The Interactive Fiction Archive](https://www.ifarchive.org/)
157
+ - [Infocom games](http://www.infocom-if.org/downloads/downloads.html)
158
+
159
+ ## License
160
+
161
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
162
+
163
+ I have included the same games from [@danielricks/textplayer](https://github.com/danielricks/textplayer), assuming that in the last ~10 years that it has not been a problem.
164
+
165
+ The games are copyright and licensed by their respective owners.
166
+
167
+ **Please open an issue on the repository or contact me directly if there are any concerns.**
168
+
169
+ ## Credits
170
+
171
+ This Ruby implementation was inspired and influenced by [@danielricks/textplayer](https://github.com/danielricks/textplayer), who wrote a Python interface for Frotz to facilitate training models to automatically play the game.
data/exe/text_player ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/text_player/cli"
5
+
6
+ TextPlayer::CLI.start(ARGV)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "../text_player"
5
+
6
+ module TextPlayer
7
+ class CLI < Thor
8
+ default_command :play
9
+
10
+ desc "play GAME", "Play a text adventure game"
11
+ option :formatter, type: :string, default: "shell", desc: "Specify the formatter to use (text, data, json, shell)"
12
+ def play(game)
13
+ gamefile = TextPlayer::Gamefile.from_input(game)
14
+ session = TextPlayer::Session.new(gamefile)
15
+
16
+ formatter_type = options[:formatter].downcase.to_sym
17
+ formatter = TextPlayer::Formatters.by_name(formatter_type)
18
+
19
+ session.run do |result|
20
+ formatter.write(result, $stdout)
21
+ $stdin.gets
22
+ end
23
+ end
24
+
25
+ def self.exit_on_failure?
26
+ true
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TextPlayer
4
+ # Encapsulates the result of executing a command
5
+ CommandResult = Data.define(:input, :raw_output, :operation, :success, :message, :details) do
6
+ # Common failure patterns in text adventure games
7
+
8
+ def initialize(input:, raw_output: "", operation: :action, success: true, message: nil, **details)
9
+ super(input:, raw_output:, operation:, success:, message:, details:)
10
+ end
11
+
12
+ def action_command? = operation == :action
13
+
14
+ def system_command? = !action_command?
15
+
16
+ def success? = success
17
+
18
+ def failure? = !success
19
+
20
+ def to_h
21
+ super.merge(details)
22
+ end
23
+
24
+ private
25
+
26
+ def respond_to_missing?(method, include_private = false)
27
+ details.key?(method) || super
28
+ end
29
+
30
+ def method_missing(method, *args, &block)
31
+ if details.key?(method)
32
+ details[method]
33
+ else
34
+ super
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command_result"
4
+
5
+ module TextPlayer
6
+ module Commands
7
+ # Command for generic game actions (look, go north, take sword, etc.)
8
+ Action = Data.define(:input) do
9
+ def execute(game)
10
+ game.write(input)
11
+ raw_output = game.read_until(TextPlayer::PROMPT_REGEX)
12
+
13
+ CommandResult.new(
14
+ input: input,
15
+ raw_output: raw_output,
16
+ operation: :action,
17
+ success: !failure_detected?(raw_output)
18
+ )
19
+ end
20
+
21
+ def failure_detected?(output)
22
+ TextPlayer::FAILURE_PATTERNS.any? { |pattern| output.match?(pattern) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command_result"
4
+
5
+ module TextPlayer
6
+ module Commands
7
+ # Command for quitting the game
8
+ Quit = Data.define do
9
+ def input
10
+ "quit"
11
+ end
12
+
13
+ def execute(game)
14
+ begin
15
+ game.write(input)
16
+ sleep(0.2)
17
+ game.write("y")
18
+ rescue Errno::EPIPE
19
+ # Expected when process exits - ignore
20
+ ensure
21
+ game.terminate
22
+ end
23
+
24
+ CommandResult.new(
25
+ input: input,
26
+ operation: :quit,
27
+ success: true,
28
+ message: "Game quit successfully"
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command_result"
4
+
5
+ module TextPlayer
6
+ module Commands
7
+ # Command for restoring game state
8
+ Restore = Data.define(:savefile) do
9
+ def input
10
+ "restore"
11
+ end
12
+
13
+ def execute(game)
14
+ unless savefile.exist?
15
+ return CommandResult.new(
16
+ input: input,
17
+ operation: :restore,
18
+ success: false,
19
+ message: "Restore failed - file not found",
20
+ slot: savefile.slot,
21
+ filename: savefile.filename
22
+ )
23
+ end
24
+
25
+ game.write(input)
26
+ game.read_until(TextPlayer::FILENAME_PROMPT_REGEX)
27
+ game.write(savefile.filename)
28
+
29
+ result = game.read_until(/Ok\.|Failed\.|not found|>/i)
30
+
31
+ success = result.include?("Ok.")
32
+ message = if success
33
+ "Game restored successfully"
34
+ elsif result.include?("Failed") || result.include?("not found")
35
+ "Restore failed - file not found by dfrotz process even though it existed before running this command"
36
+ else
37
+ "Restore operation completed"
38
+ end
39
+
40
+ CommandResult.new(
41
+ input: input,
42
+ raw_output: result,
43
+ operation: :restore,
44
+ success: success,
45
+ message: message,
46
+ slot: savefile.slot,
47
+ filename: savefile.filename
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command_result"
4
+
5
+ module TextPlayer
6
+ module Commands
7
+ # Command for saving game state
8
+ Save = Data.define(:savefile) do
9
+ def input
10
+ "save"
11
+ end
12
+
13
+ def execute(game)
14
+ # Note: we could check if the file exists and delete it here, but
15
+ # instead we will let dfrotz handle it in case the save fails.
16
+ game.write(input)
17
+ game.read_until(TextPlayer::FILENAME_PROMPT_REGEX)
18
+ game.write(savefile.filename)
19
+
20
+ result = game.read_until(/Overwrite existing file\? |Ok\.|Failed\.|>/i)
21
+
22
+ if result.include?("Overwrite existing file?")
23
+ game.write("y")
24
+ result += game.read_until(/Ok\.|Failed\.|>/i)
25
+ end
26
+
27
+ success = result.include?("Ok.")
28
+ message = if success
29
+ "[#{savefile.slot}] Game saved successfully"
30
+ else
31
+ "Save operation failed"
32
+ end
33
+
34
+ CommandResult.new(
35
+ input: input,
36
+ raw_output: result,
37
+ operation: :save,
38
+ success: success,
39
+ message: message,
40
+ slot: savefile.slot,
41
+ filename: savefile.filename
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command_result"
4
+
5
+ module TextPlayer
6
+ module Commands
7
+ # Command for getting game score
8
+ Score = Data.define do
9
+ def input
10
+ "score"
11
+ end
12
+
13
+ def execute(game)
14
+ game.write(input)
15
+ raw_output = game.read_until(TextPlayer::PROMPT_REGEX)
16
+
17
+ score, out_of = nil
18
+ if TextPlayer::SCORE_REGEX =~ raw_output
19
+ score, out_of = $1, $2
20
+ end
21
+
22
+ # Some games give dialog instead of score
23
+ # We will return what the game says as a success
24
+ # whether or not we find a score.
25
+ CommandResult.new(
26
+ input:,
27
+ raw_output:,
28
+ operation: :score,
29
+ success: true,
30
+ score:,
31
+ out_of:
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command_result"
4
+
5
+ module TextPlayer
6
+ module Commands
7
+ # Command for starting the game
8
+ # This is used to start the game and is not accessible by the user.
9
+ Start = Data.define do
10
+ def input
11
+ nil
12
+ end
13
+
14
+ def execute(game)
15
+ raw_output = game.read_until(TextPlayer::PROMPT_REGEX)
16
+
17
+ # Handle "Press any key" prompts - be more specific
18
+ max_iterations = 5
19
+ lines = raw_output.lines
20
+ while /\A\W*(Press|Hit|More)\s+.*\z/i.match?(lines.last) # if last line is a continuation prompt
21
+ lines.pop
22
+ game.write(" ")
23
+ lines.concat game.read_until(TextPlayer::PROMPT_REGEX).lines
24
+ max_iterations -= 1
25
+ break if max_iterations.zero?
26
+ end
27
+ raw_output = lines.join
28
+
29
+ # Skip introduction if offered
30
+ if raw_output.include?("introduction")
31
+ game.write("no")
32
+ raw_output += game.read_until(TextPlayer::PROMPT_REGEX)
33
+ end
34
+
35
+ CommandResult.new(
36
+ input: input,
37
+ raw_output: raw_output,
38
+ operation: :start,
39
+ success: true
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commands/action"
4
+ require_relative "commands/quit"
5
+ require_relative "commands/restore"
6
+ require_relative "commands/save"
7
+ require_relative "commands/score"
8
+ require_relative "commands/start"
9
+
10
+ module TextPlayer
11
+ module Commands
12
+ def self.create(input, game_name: nil)
13
+ case input.strip.downcase
14
+ when "score"
15
+ Commands::Score.new
16
+ when /^save\s*(\S+)/ # no end anchor to catch all save commands that have args
17
+ Commands::Save.new(savefile: Savefile.new(game_name:, slot: Regexp.last_match(1)))
18
+ when "save"
19
+ Commands::Save.new(savefile: Savefile.new(game_name:))
20
+ when /^restore\s*(\S+)/ # no end anchor to catch all restore commands that have args
21
+ Commands::Restore.new(savefile: Savefile.new(game_name:, slot: Regexp.last_match(1)))
22
+ when "restore"
23
+ Commands::Restore.new(savefile: Savefile.new(game_name:))
24
+ when "quit"
25
+ Commands::Quit.new
26
+ else
27
+ Commands::Action.new(input:)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+ require "pathname"
6
+
7
+ module TextPlayer
8
+ # Dfrotz - Direct interface to dfrotz interpreter
9
+ class Dfrotz
10
+ TIMEOUT = 1
11
+ IO_SELECT_TIMEOUT = 0.1
12
+ CHUNK_SIZE = 1024
13
+ COMMAND_DELAY = 0.1
14
+ SYSTEM_PATH = "dfrotz"
15
+
16
+ def self.path
17
+ ENV.fetch("DFROTZ_PATH", SYSTEM_PATH)
18
+ end
19
+
20
+ def self.executable?(path = self.path)
21
+ File.executable?(path) || system("which #{path} > /dev/null 2>&1")
22
+ end
23
+
24
+ def initialize(game_path, dfrotz: nil, timeout: TIMEOUT, command_delay: COMMAND_DELAY)
25
+ Signal.trap("PIPE", "DEFAULT")
26
+ @game_path = game_path
27
+ @dfrotz = dfrotz || self.class.path
28
+ raise "dfrotz not found: #{@dfrotz.inspect}" unless self.class.executable?(@dfrotz)
29
+
30
+ @timeout = timeout
31
+ @command_delay = command_delay
32
+ @stdin = @stdout = @wait_thr = nil
33
+ end
34
+
35
+ def start
36
+ return true if running?
37
+
38
+ @stdin, @stdout, @wait_thr = Open3.popen2(@dfrotz, @game_path)
39
+ true
40
+ end
41
+
42
+ # Send a command to the game.
43
+ #
44
+ # Automatically sleeps for COMMAND_DELAY seconds, keeping callers simple.
45
+ # It takes time for every command to return output. If you don't wait,
46
+ # you'll get nothing in response, and then follow up commands will
47
+ # return the last command's output instead of the current command's.
48
+ def write(cmd)
49
+ return false unless running?
50
+
51
+ @stdin.puts(cmd)
52
+ @stdin.flush
53
+ sleep(@command_delay)
54
+ true
55
+ rescue Errno::EPIPE
56
+ # Process has exited - this is expected during quit
57
+ false
58
+ end
59
+
60
+ def read_all
61
+ read_until(nil)
62
+ end
63
+
64
+ def read_until(pattern)
65
+ return "" unless running?
66
+
67
+ output = +""
68
+ begin
69
+ Timeout.timeout(@timeout) do
70
+ loop do
71
+ break unless read_chunk_into(output)
72
+ break if pattern && output =~ pattern
73
+ end
74
+ end
75
+ rescue Timeout::Error
76
+ # Return whatever we got
77
+ end
78
+ output
79
+ end
80
+
81
+ def running?
82
+ @stdin && !@stdin.closed? && @wait_thr&.alive?
83
+ end
84
+
85
+ def terminate
86
+ return true unless running?
87
+
88
+ close
89
+ @wait_thr.kill
90
+ rescue
91
+ true
92
+ end
93
+
94
+ private
95
+
96
+ def read_chunk_into(output)
97
+ return false unless IO.select([@stdout], nil, nil, IO_SELECT_TIMEOUT)
98
+
99
+ chunk = @stdout.read_nonblock(CHUNK_SIZE)
100
+ output << chunk
101
+ true
102
+ rescue IO::WaitReadable, EOFError
103
+ false
104
+ end
105
+
106
+ def close
107
+ @stdin&.close
108
+ @stdout&.close
109
+ rescue
110
+ true
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command_result"
4
+
5
+ module TextPlayer
6
+ module Formatters
7
+ # Base formatter with stream writing and common interface
8
+ class Base
9
+ def self.write(command_result, stream)
10
+ new(command_result).write(stream)
11
+ end
12
+
13
+ attr_reader :command_result
14
+
15
+ def initialize(command_result)
16
+ @command_result = command_result
17
+ end
18
+
19
+ # Write formatted output to stream
20
+ def write(stream)
21
+ stream.write(to_s)
22
+ end
23
+
24
+ # String representation for stream output
25
+ def to_s
26
+ command_result.to_h.inspect
27
+ end
28
+
29
+ # Hash representation for programmatic access
30
+ def to_h
31
+ command_result.to_h
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module TextPlayer
6
+ module Formatters
7
+ # Data formatter - parses game-specific data and returns structured output
8
+ class Data < Base
9
+ SCORE_PATTERN = /Score:\s*(\d+)/i
10
+ MOVES_PATTERN = /Moves:\s*(\d+)/i
11
+ TIME_PATTERN = /(\d{1,2}:\d{2}\s*(?:AM|PM))/i
12
+
13
+ def to_h
14
+ super.merge(parsed_data)
15
+ end
16
+
17
+ private
18
+
19
+ def parsed_data
20
+ @parsed_data ||= begin
21
+ raw_output = command_result.raw_output
22
+ cleaned = raw_output.dup
23
+
24
+ # Extract from original, remove from cleaned copy
25
+ location = extract_location(raw_output)
26
+ score = extract_score(raw_output)
27
+ moves = extract_moves(raw_output)
28
+ time = extract_time(raw_output)
29
+ prompt = extract_prompt(raw_output)
30
+
31
+ # Remove what we found from the cleaned copy
32
+ remove_extracted_data!(cleaned, location, score, moves, time, prompt)
33
+
34
+ # Final cleanup of remaining text
35
+ output = final_cleanup(cleaned)
36
+
37
+ {
38
+ location: location,
39
+ score: score,
40
+ moves: moves,
41
+ time: time,
42
+ prompt: prompt,
43
+ output: output,
44
+ has_prompt: !prompt.nil?
45
+ }
46
+ end
47
+ end
48
+
49
+ def extract_location(text)
50
+ lines = text.split("\n")
51
+ first_line = lines.first&.strip
52
+
53
+ return nil if first_line.nil? || first_line.empty?
54
+
55
+ # Try different location extraction strategies
56
+ location = extract_location_with_stats(first_line) ||
57
+ extract_standalone_location(first_line)
58
+
59
+ return nil unless location && valid_location?(location)
60
+ location
61
+ end
62
+
63
+ def extract_location_with_stats(line)
64
+ # Handle formats like: " Canyon Bottom Score: 0 Moves: 26"
65
+ # or " In the enchanted forest 5:00 AM Score: 0"
66
+ parts = line.split(/\s{3,}/)
67
+ return nil if parts.length < 2
68
+
69
+ candidate = parts.first.strip
70
+ candidate.empty? ? nil : candidate
71
+ end
72
+
73
+ def extract_standalone_location(line)
74
+ # Handle formats like: " Brig" (location on its own line)
75
+ candidate = line.strip
76
+ # Only consider it a location if it doesn't contain stats and isn't an error message
77
+ if !candidate.match?(/(?:Score|Moves|AM|PM):/i) &&
78
+ !candidate.match?(/\d+:\d+/) &&
79
+ !candidate.match?(/[.!?]$/) && # Not a sentence ending with punctuation
80
+ candidate.length < 50 && # Reasonable location length
81
+ !candidate.downcase.include?("response") # Not a generic response
82
+ candidate
83
+ end
84
+ end
85
+
86
+ def extract_score(text)
87
+ match = text.match(SCORE_PATTERN)
88
+ match ? match[1].to_i : nil
89
+ end
90
+
91
+ def extract_moves(text)
92
+ match = text.match(MOVES_PATTERN)
93
+ match ? match[1].to_i : nil
94
+ end
95
+
96
+ def extract_time(text)
97
+ match = text.match(TIME_PATTERN)
98
+ match ? match[1] : nil
99
+ end
100
+
101
+ def extract_prompt(text)
102
+ # Extract prompt from end of text (usually ">")
103
+ lines = text.split("\n")
104
+ last_line = lines.last&.strip
105
+ last_line if last_line&.match?(TextPlayer::PROMPT_REGEX)
106
+ end
107
+
108
+ def remove_extracted_data!(text, location, score, moves, time, prompt)
109
+ # Handle location removal
110
+ if location
111
+ lines = text.split("\n")
112
+ first_line = lines.first&.strip
113
+
114
+ if first_line && extract_location_with_stats(first_line)
115
+ # Replace status line with just the location name
116
+ lines[0] = location
117
+ text.replace(lines.join("\n"))
118
+ end
119
+ end
120
+
121
+ # Remove patterns for extracted data
122
+ text.gsub!(SCORE_PATTERN, "") if score
123
+ text.gsub!(MOVES_PATTERN, "") if moves
124
+ text.gsub!(TIME_PATTERN, "") if time
125
+
126
+ # Remove prompt if we extracted it
127
+ if prompt
128
+ lines = text.split("\n")
129
+ if lines.last&.strip == prompt
130
+ lines.pop
131
+ text.replace(lines.join("\n"))
132
+ end
133
+ end
134
+ end
135
+
136
+ def final_cleanup(text)
137
+ # Clean up excessive whitespace but preserve paragraph structure
138
+ # Remove more than 2 consecutive newlines (preserve paragraph breaks)
139
+ text.gsub!(/\n{3,}/, "\n\n")
140
+ # Remove lines that are only whitespace
141
+ text.gsub!(/^\s+$/m, "")
142
+ # Clean up any trailing/leading whitespace on lines
143
+ text.gsub!(/[ \t]+$/, "")
144
+
145
+ text.strip
146
+ end
147
+
148
+ def valid_location?(location)
149
+ location.length.positive? &&
150
+ !location.start_with?("I don't ") &&
151
+ !location.start_with?("I can't ") &&
152
+ !location.start_with?("What do you ") &&
153
+ !location.start_with?("You're ") &&
154
+ !location.start_with?("You ") &&
155
+ !location.start_with?("That's not ") &&
156
+ !location.start_with?("I beg your pardon")
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "data"
5
+
6
+ module TextPlayer
7
+ module Formatters
8
+ # JSON formatter - returns JSON string of structured data
9
+ class Json < Data
10
+ def to_s
11
+ JSON.generate(to_h)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module TextPlayer
6
+ module Formatters
7
+ # Shell formatter - interactive presentation with prompts and colors
8
+ class Shell < Base
9
+ def to_s
10
+ if command_result.action_command?
11
+ format_game_output
12
+ else
13
+ format_system_feedback
14
+ end
15
+ end
16
+
17
+ def to_h
18
+ super.merge(
19
+ formatted_output: to_s
20
+ )
21
+ end
22
+
23
+ def write(stream)
24
+ if command_result.action_command?
25
+ content, prompt = extract_prompt(display_content)
26
+ stream.write(content)
27
+ if prompt
28
+ color = command_result.success? ? "\e[32m" : "\e[31m"
29
+ stream.write("#{color}#{prompt}\e[0m")
30
+ end
31
+ else
32
+ stream.write(to_s)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def format_system_feedback
39
+ return command_result.raw_output if %i[start score].include?(command_result.operation)
40
+
41
+ prefix = command_result.success? ? "\e[32m✓\e[0m" : "\e[31m✗\e[0m"
42
+ feedback = "#{prefix} #{command_result.operation.upcase}: #{command_result.message}"
43
+
44
+ # Add details if present
45
+ if command_result.details.any?
46
+ detail_lines = command_result.details.map { |k, v| " #{k}: #{v}" }
47
+ feedback += "\n#{detail_lines.join("\n")}"
48
+ end
49
+
50
+ feedback
51
+ end
52
+
53
+ def format_game_output
54
+ display_content
55
+ end
56
+
57
+ def display_content
58
+ command_result.message || command_result.raw_output
59
+ end
60
+
61
+ def extract_prompt(content)
62
+ # Look for prompt at the end (> or similar)
63
+ # Match: content + optional newlines + > + optional spaces
64
+ if TextPlayer::PROMPT_REGEX.match?(content)
65
+ content = content.gsub(TextPlayer::PROMPT_REGEX, "").rstrip
66
+ [content + "\n\n", "> "]
67
+ else
68
+ [content, nil]
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module TextPlayer
6
+ module Formatters
7
+ # Plain text formatter - returns raw output
8
+ class Text < Base
9
+ def to_s
10
+ content = command_result.message || command_result.raw_output
11
+ content = remove_prompt(content)
12
+ "#{content}\n\n"
13
+ end
14
+
15
+ private
16
+
17
+ def remove_prompt(content)
18
+ content.gsub(TextPlayer::PROMPT_REGEX, "").rstrip
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatters/base"
4
+ require_relative "formatters/text"
5
+ require_relative "formatters/data"
6
+ require_relative "formatters/json"
7
+ require_relative "formatters/shell"
8
+
9
+ module TextPlayer
10
+ # UI Formatters - Stream-based output handling for different interfaces
11
+ module Formatters
12
+ def self.by_name(name)
13
+ case name
14
+ when :data then Data
15
+ when :json then Json
16
+ when :shell then Shell
17
+ else Text
18
+ end
19
+ end
20
+
21
+ def self.create(name, command_result)
22
+ by_name(name).new(command_result)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module TextPlayer
6
+ # Gamefile - A game file and its name
7
+ Gamefile = Data.define(:name, :path) do
8
+ def self.from_input(input)
9
+ if input.include?("/")
10
+ path = Pathname.new(input)
11
+ new(name: path.basename.to_s, path:)
12
+ else # must be a simple game name
13
+ matches = TextPlayer::GAME_DIR.glob("#{input}.*")
14
+
15
+ if matches.size == 1
16
+ path = matches.first
17
+ new(name: path.basename.to_s, path:)
18
+ else
19
+ names = matches.map { |m| m.basename }
20
+ raise ArgumentError, "Multiple games found for '#{input}':\n#{names.join("\n")}"
21
+ end
22
+ end
23
+ end
24
+
25
+ def initialize(name:, path:)
26
+ super(name: name.to_s, path: Pathname.new(path))
27
+ end
28
+
29
+ def exist? = path.exist?
30
+
31
+ def full_path = path.expand_path.to_s
32
+ end
33
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TextPlayer
4
+ # Utilities for saving and restoring game state
5
+ Savefile = Data.define(:game_name, :slot) do
6
+ def initialize(game_name: nil, slot: nil)
7
+ slot = slot.to_s.strip
8
+ slot = TextPlayer::AUTO_SAVE_SLOT if slot.empty?
9
+ super
10
+ end
11
+
12
+ def filename
13
+ basename = [game_name, slot].compact.join("_")
14
+ "saves/#{basename}.qzl"
15
+ end
16
+
17
+ def exist?
18
+ File.exist?(filename)
19
+ end
20
+
21
+ def delete
22
+ File.delete(filename)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TextPlayer
4
+ # Mid-level: Manages game session lifecycle and output formatting
5
+ class Session
6
+ def initialize(gamefile, dfrotz: nil)
7
+ @gamefile = gamefile
8
+ @game = Dfrotz.new(gamefile.full_path, dfrotz:)
9
+ @started = false
10
+ @interrupt_count = 0
11
+ end
12
+
13
+ def run(&)
14
+ result = start
15
+ while running?
16
+ command = yield result
17
+ break if command.nil?
18
+
19
+ result = call(command)
20
+ end
21
+ end
22
+
23
+ def start
24
+ return @start_result if @started
25
+
26
+ setup_interrupt_handling
27
+ @game.start
28
+ @started = true
29
+
30
+ start_command = Commands::Start.new
31
+ @start_result = execute_command(start_command)
32
+ end
33
+
34
+ def running?
35
+ @started && @game.running?
36
+ end
37
+
38
+ # We intentionally intercept certain commands.
39
+ # Because the intention of this library is automated play, allowing an agent
40
+ # to save to any file path on the system is a security risk at worst, and
41
+ # a nuisance at best.
42
+ #
43
+ # We automatically save to "autosave" when the game is quit.
44
+ #
45
+ # Quit is also intercepted to make sure we shut down the game cleanly.
46
+ def call(cmd)
47
+ command = Commands.create(cmd, game_name: @gamefile.name)
48
+ execute_command(command)
49
+ end
50
+
51
+ def score
52
+ command = Commands::Score.new
53
+ execute_command(command)
54
+ end
55
+
56
+ def save(slot = nil)
57
+ command = Commands::Save.new(save: Save.new(game_name: @gamefile.name, slot:))
58
+ execute_command(command)
59
+ end
60
+
61
+ def restore(slot = nil)
62
+ command = Commands::Restore.new(save: Save.new(game_name: @gamefile.name, slot:))
63
+ execute_command(command)
64
+ end
65
+
66
+ def quit
67
+ command = Commands::Quit.new
68
+ execute_command(command)
69
+ end
70
+
71
+ private
72
+
73
+ def execute_command(command)
74
+ if running?
75
+ command.execute(@game)
76
+ else
77
+ CommandResult.new(
78
+ input: command.input,
79
+ operation: :error,
80
+ success: false,
81
+ message: "Game not running"
82
+ )
83
+ end
84
+ end
85
+
86
+ def setup_interrupt_handling
87
+ Signal.trap("INT") do
88
+ @interrupt_count += 1
89
+ if @interrupt_count == 1
90
+ warn "\n\nInterrupt received - quitting game gracefully..."
91
+ quit if running?
92
+
93
+ exit(0)
94
+ else
95
+ @game.terminate
96
+ exit(1)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TextPlayer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require_relative "text_player/version"
6
+ require_relative "text_player/gamefile"
7
+ require_relative "text_player/dfrotz"
8
+ require_relative "text_player/formatters"
9
+ require_relative "text_player/commands"
10
+ require_relative "text_player/savefile"
11
+ require_relative "text_player/session"
12
+
13
+ module TextPlayer
14
+ class Error < StandardError; end
15
+
16
+ AUTO_SAVE_SLOT = "autosave"
17
+ FILENAME_PROMPT_REGEX = /Please enter a filename \[.*\]: /
18
+ PROMPT_REGEX = /^>\s*$/
19
+ SCORE_REGEX = /([0-9]+) ?(?:\(total [points ]*[out ]*of [a mxiuof]*[a posible]*([0-9]+)\))?/i
20
+ GAME_DIR = Pathname.new(__dir__).join("../games")
21
+ FAILURE_PATTERNS = [
22
+ /I don't understand/i,
23
+ /I don't know/i,
24
+ /You can't/i,
25
+ /You're not/i,
26
+ /I can't see/i,
27
+ /That doesn't make sense/i,
28
+ /That's not a verb I recognize/i,
29
+ /What do you want to/i,
30
+ /You don't see/i,
31
+ /There is no/i,
32
+ /I don't see/i,
33
+ /I beg your pardon/i
34
+ ].freeze
35
+ end
@@ -0,0 +1,4 @@
1
+ module TextPlayer
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: text_player
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Cardiff Emde
8
+ - Martin Emde
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: Ruby gem for playing Zork and other text-based adventure games that provides
28
+ a programmatic interface for interacting with the game.
29
+ email:
30
+ - cardiff.emde@gmail.com
31
+ - me@martinemde.com
32
+ executables:
33
+ - text_player
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - exe/text_player
41
+ - lib/text_player.rb
42
+ - lib/text_player/cli.rb
43
+ - lib/text_player/command_result.rb
44
+ - lib/text_player/commands.rb
45
+ - lib/text_player/commands/action.rb
46
+ - lib/text_player/commands/quit.rb
47
+ - lib/text_player/commands/restore.rb
48
+ - lib/text_player/commands/save.rb
49
+ - lib/text_player/commands/score.rb
50
+ - lib/text_player/commands/start.rb
51
+ - lib/text_player/dfrotz.rb
52
+ - lib/text_player/formatters.rb
53
+ - lib/text_player/formatters/base.rb
54
+ - lib/text_player/formatters/data.rb
55
+ - lib/text_player/formatters/json.rb
56
+ - lib/text_player/formatters/shell.rb
57
+ - lib/text_player/formatters/text.rb
58
+ - lib/text_player/gamefile.rb
59
+ - lib/text_player/savefile.rb
60
+ - lib/text_player/session.rb
61
+ - lib/text_player/version.rb
62
+ - sig/text_player.rbs
63
+ homepage: https://github.com/martinemde/text_player
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ allowed_push_host: https://rubygems.org
68
+ homepage_uri: https://github.com/martinemde/text_player
69
+ source_code_uri: https://github.com/martinemde/text_player
70
+ changelog_uri: https://github.com/martinemde/text_player/blob/main/CHANGELOG.md
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.2'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.6.7
86
+ specification_version: 4
87
+ summary: Ruby gem for playing Zork and other text-based adventure games
88
+ test_files: []