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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +171 -0
- data/exe/text_player +6 -0
- data/lib/text_player/cli.rb +29 -0
- data/lib/text_player/command_result.rb +38 -0
- data/lib/text_player/commands/action.rb +26 -0
- data/lib/text_player/commands/quit.rb +33 -0
- data/lib/text_player/commands/restore.rb +52 -0
- data/lib/text_player/commands/save.rb +46 -0
- data/lib/text_player/commands/score.rb +36 -0
- data/lib/text_player/commands/start.rb +44 -0
- data/lib/text_player/commands.rb +31 -0
- data/lib/text_player/dfrotz.rb +113 -0
- data/lib/text_player/formatters/base.rb +35 -0
- data/lib/text_player/formatters/data.rb +160 -0
- data/lib/text_player/formatters/json.rb +15 -0
- data/lib/text_player/formatters/shell.rb +73 -0
- data/lib/text_player/formatters/text.rb +22 -0
- data/lib/text_player/formatters.rb +25 -0
- data/lib/text_player/gamefile.rb +33 -0
- data/lib/text_player/savefile.rb +25 -0
- data/lib/text_player/session.rb +101 -0
- data/lib/text_player/version.rb +5 -0
- data/lib/text_player.rb +35 -0
- data/sig/text_player.rbs +4 -0
- metadata +88 -0
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
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,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
|
data/lib/text_player.rb
ADDED
@@ -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
|
data/sig/text_player.rbs
ADDED
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: []
|