samagotchi 0.154.0 → 0.155.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 +4 -4
- data/CHANGELOG.md +30 -1
- data/docs/architecture.md +3 -2
- data/docs/bundles.md +21 -0
- data/docs/cli.md +10 -8
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +1 -1
- data/docs/internals/anthropic.md +4 -4
- data/docs/internals/attached-context.md +8 -6
- data/docs/internals/block-renderers.md +8 -1
- data/docs/internals/bridge.md +18 -5
- data/docs/internals/engine.md +221 -100
- data/docs/internals/inline-widgets.md +136 -0
- data/docs/internals/objectives.md +8 -6
- data/docs/internals/participants.md +5 -5
- data/docs/internals/prompt-caching.md +12 -6
- data/docs/internals/web-frontend.md +2 -2
- data/docs/internals/working-set.md +2 -1
- data/docs/plugins.md +2 -0
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +16 -1
- data/lib/samagotchi/answer_text.rb +1 -2
- data/lib/samagotchi/block_renderers.rb +5 -0
- data/lib/samagotchi/bridge/card_places.rb +56 -7
- data/lib/samagotchi/bridge/card_store.rb +24 -9
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command/check.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
- data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
- data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
- data/lib/samagotchi/engine/context_judge.rb +19 -125
- data/lib/samagotchi/engine/guardrails.rb +4 -2
- data/lib/samagotchi/engine/messages.rb +9 -6
- data/lib/samagotchi/engine/objectives.rb +22 -90
- data/lib/samagotchi/engine/participants.rb +15 -10
- data/lib/samagotchi/engine/plugins.rb +3 -1
- data/lib/samagotchi/engine/questions.rb +1 -1
- data/lib/samagotchi/engine/side_requests.rb +5 -2
- data/lib/samagotchi/engine/toolkit.rb +33 -190
- data/lib/samagotchi/engine/turn_end.rb +1 -1
- data/lib/samagotchi/engine/turn_helpers.rb +16 -39
- data/lib/samagotchi/engine/verify.rb +7 -143
- data/lib/samagotchi/engine.rb +162 -99
- data/lib/samagotchi/engine_peers.rb +1 -1
- data/lib/samagotchi/engine_snapshots.rb +9 -8
- data/lib/samagotchi/event_fanout.rb +18 -12
- data/lib/samagotchi/guardrail_wiring.rb +83 -82
- data/lib/samagotchi/guardrails/gate.rb +41 -40
- data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
- data/lib/samagotchi/guardrails/targets.rb +6 -4
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/idle_recap.rb +3 -1
- data/lib/samagotchi/inline_widgets.rb +325 -0
- data/lib/samagotchi/kernel_loop.rb +194 -216
- data/lib/samagotchi/llm/backend.rb +3 -1
- data/lib/samagotchi/llm/chat_loop.rb +66 -42
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/llm_context_forget.rb +5 -3
- data/lib/samagotchi/model_binding.rb +3 -3
- data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
- data/lib/samagotchi/question_desk.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +1 -1
- data/lib/samagotchi/session_manager.rb +8 -13
- data/lib/samagotchi/session_retention.rb +24 -4
- data/lib/samagotchi/steer.rb +12 -1
- data/lib/samagotchi/stream_event.rb +22 -0
- data/lib/samagotchi/system_prompt.rb +84 -61
- data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
- data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
- data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
- data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
- data/lib/samagotchi/testing/boundary.rb +7 -0
- data/lib/samagotchi/testing/bundle.rb +7 -5
- data/lib/samagotchi/testing/install.rb +3 -2
- data/lib/samagotchi/tool_activity.rb +8 -6
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tool_response.rb +4 -3
- data/lib/samagotchi/tool_runner.rb +93 -101
- data/lib/samagotchi/toolkit_binding.rb +40 -14
- data/lib/samagotchi/tools/deferred_tools.rb +17 -21
- data/lib/samagotchi/tools/registry.rb +43 -40
- data/lib/samagotchi/tools/tool_index.rb +135 -88
- data/lib/samagotchi/tools/tool_surface.rb +173 -0
- data/lib/samagotchi/turn_context.rb +67 -0
- data/lib/samagotchi/turn_flow.rb +7 -2
- data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
- data/lib/samagotchi/turn_stages/objectives.rb +146 -0
- data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
- data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
- data/lib/samagotchi/turn_stages/warmup.rb +83 -0
- data/lib/samagotchi/turn_state.rb +46 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -1
- data/lib/samagotchi/web/markdown_renderer.rb +39 -14
- data/lib/samagotchi/web/message_display.rb +8 -1
- data/lib/samagotchi/web/public/app.js +5 -1
- data/lib/samagotchi/web/public/block_host.js +74 -199
- data/lib/samagotchi/web/public/block_renderers.css +2 -0
- data/lib/samagotchi/web/public/block_source.js +7 -4
- data/lib/samagotchi/web/public/block_watch.js +256 -0
- data/lib/samagotchi/web/public/card_stamps.js +27 -0
- data/lib/samagotchi/web/public/cards_place.js +5 -3
- data/lib/samagotchi/web/public/composer_fold.js +3 -0
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +3 -2
- data/lib/samagotchi/web/public/index.html +28 -9
- data/lib/samagotchi/web/public/inline_widgets.js +146 -0
- data/lib/samagotchi/web/public/owner_verify.js +1 -1
- data/lib/samagotchi/web/public/participant.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +1 -1
- data/lib/samagotchi/web/public/stage_watch.js +6 -2
- data/lib/samagotchi/web/public/text_roots.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/verify_target.js +1 -1
- data/lib/samagotchi/web/public/widget_picks.js +130 -0
- data/lib/samagotchi/web/public/widget_walk.js +230 -0
- data/lib/samagotchi/web/public/widgets.css +70 -0
- data/lib/samagotchi/web/session_access_routes.rb +8 -0
- data/lib/samagotchi/web/session_show.rb +4 -0
- data/lib/samagotchi/worker.rb +8 -5
- data/lib/samagotchi.rb +0 -1
- metadata +19 -2
- data/lib/samagotchi/pending_input_queue.rb +0 -55
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "psych"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A ```chi-ui fence's YAML, read once on the server (Web::MarkdownRenderer):
|
|
8
|
+
# what the page draws (a picks or a walk) as plain, JSON-ready data, or the
|
|
9
|
+
# one-line reason it stays source. Pure: no web, no Engine.
|
|
10
|
+
#
|
|
11
|
+
# The YAML is read from Psych's node tree, never loaded, so a value stays the
|
|
12
|
+
# text the model wrote (YAML 1.1 would make `- no` false, `1.10` 1.1 and a
|
|
13
|
+
# date a Date), and nothing beyond plain data gets through: an anchor, an
|
|
14
|
+
# alias or a tag is refused. A block that isn't YAML gets one more try after
|
|
15
|
+
# the lenient pass (Lenient), which quotes the free-text values models leave
|
|
16
|
+
# bare; one that is, but has an option or a free-text value read as a
|
|
17
|
+
# one-pair mapping (`- fix now: drop it`), gets that text back (Tree#folds).
|
|
18
|
+
# See docs/internals/inline-widgets.md.
|
|
19
|
+
module InlineWidgets
|
|
20
|
+
# error is nil when the block draws; type is set whenever the root names a
|
|
21
|
+
# known one, so a refused block still says what it tried to be.
|
|
22
|
+
Result = Data.define(:type, :data, :lenient, :error) do
|
|
23
|
+
def ok? = error.nil?
|
|
24
|
+
def lenient? = lenient
|
|
25
|
+
|
|
26
|
+
# The page's input (data-widget): the data with its type.
|
|
27
|
+
def widget_json = JSON.generate({ "type" => type }.merge(data))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The one-line reason a block doesn't draw.
|
|
31
|
+
class Refused < StandardError; end
|
|
32
|
+
|
|
33
|
+
# The fence's language. Core draws it, so no bundle can claim it
|
|
34
|
+
# (BlockRenderers::RESERVED).
|
|
35
|
+
LANG = "chi-ui"
|
|
36
|
+
TYPES = %w[picks walk].freeze
|
|
37
|
+
MAX_SOURCE_BYTES = 16 * 1024
|
|
38
|
+
MAX_ROWS = 12
|
|
39
|
+
MAX_STEPS = 30
|
|
40
|
+
ACTORS = (2..5)
|
|
41
|
+
OPTIONS = (2..4)
|
|
42
|
+
MAX_STRING = 300
|
|
43
|
+
# A step's `next: end`: the walk ends there (a branch that ends early).
|
|
44
|
+
# No step may take it as its id.
|
|
45
|
+
END_NEXT = "end"
|
|
46
|
+
# The deepest legal block nests 6 (root, steps, a step, cases, a case, a
|
|
47
|
+
# folded option); past this it is refused before the stack can overflow.
|
|
48
|
+
MAX_DEPTH = 8
|
|
49
|
+
# How much of a bad type value an error repeats.
|
|
50
|
+
MAX_ECHO = 40
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
# @return [Result]
|
|
55
|
+
def parse(source)
|
|
56
|
+
source = source.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
57
|
+
lenient = false
|
|
58
|
+
raise Refused, "the block is over #{MAX_SOURCE_BYTES / 1024} KB" if source.bytesize > MAX_SOURCE_BYTES
|
|
59
|
+
raise Refused, "the block is not valid text (UTF-8)" unless source.valid_encoding?
|
|
60
|
+
|
|
61
|
+
tree, lenient = load(source)
|
|
62
|
+
raise Refused, "the block isn't a mapping (type: picks or walk first)" unless tree.root.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
type = tree.root["type"]
|
|
65
|
+
raise Refused, "the block needs type: picks or walk" if type.nil?
|
|
66
|
+
raise Refused, "unknown type #{echo(type)} (picks or walk)" unless TYPES.include?(type)
|
|
67
|
+
|
|
68
|
+
schema = Schema.new(tree.folds)
|
|
69
|
+
data = type == "picks" ? schema.picks(tree.root) : schema.walk(tree.root)
|
|
70
|
+
Result.new(type: type, data: data, lenient: lenient || schema.folded?, error: nil)
|
|
71
|
+
rescue Refused => e
|
|
72
|
+
Result.new(type: TYPES.include?(type) ? type : nil, data: nil, lenient: lenient, error: e.message)
|
|
73
|
+
rescue SystemStackError
|
|
74
|
+
# MAX_DEPTH stops a deep block first; this is the backstop, since a stack
|
|
75
|
+
# overflow is no StandardError and would break every render of the text.
|
|
76
|
+
Result.new(type: nil, data: nil, lenient: lenient, error: "the block nests too deep")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def echo(type)
|
|
80
|
+
return "(not text)" unless type.is_a?(String)
|
|
81
|
+
|
|
82
|
+
type.size > MAX_ECHO ? "#{type[0, MAX_ECHO].inspect}..." : type.inspect
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# [Tree, lenient?]: strict first; only a block that isn't YAML gets the
|
|
86
|
+
# lenient pass. A refused feature (an alias) is final either way.
|
|
87
|
+
def load(source)
|
|
88
|
+
[Tree.new(source, Psych.parse_stream(source)), false]
|
|
89
|
+
rescue Psych::Exception
|
|
90
|
+
begin
|
|
91
|
+
quoted = Lenient.quote(source)
|
|
92
|
+
[Tree.new(quoted, Psych.parse_stream(quoted)), true]
|
|
93
|
+
rescue Psych::SyntaxError => e
|
|
94
|
+
raise Refused, "not YAML: #{e.problem} at line #{e.line}"
|
|
95
|
+
rescue Psych::Exception => e
|
|
96
|
+
raise Refused, "not YAML: #{e.message.lines.first.to_s.strip}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Psych's node tree to Hashes, Arrays and Strings (nil for a plain empty
|
|
101
|
+
# or null value), the scalars' own text. folds: each one-pair mapping of
|
|
102
|
+
# scalars (`fix now: drop it`), by identity, to the text it was written as,
|
|
103
|
+
# which Schema takes where it wants text.
|
|
104
|
+
class Tree
|
|
105
|
+
NULLS = ["", "~", "null", "Null", "NULL"].freeze
|
|
106
|
+
|
|
107
|
+
attr_reader :root, :folds
|
|
108
|
+
|
|
109
|
+
def initialize(source, stream)
|
|
110
|
+
@lines = source.lines
|
|
111
|
+
@folds = {}.compare_by_identity
|
|
112
|
+
documents = stream.children
|
|
113
|
+
raise Refused, "the block is empty" if documents.empty?
|
|
114
|
+
raise Refused, "one YAML document only" if documents.size > 1
|
|
115
|
+
|
|
116
|
+
@root = node(documents.first.root, 1)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def node(node, depth)
|
|
122
|
+
raise Refused, "the block nests too deep" if depth > MAX_DEPTH
|
|
123
|
+
raise Refused, "YAML aliases and anchors aren't allowed" if node.is_a?(Psych::Nodes::Alias) || node.anchor
|
|
124
|
+
raise Refused, "YAML tags aren't allowed" if node.tag
|
|
125
|
+
|
|
126
|
+
case node
|
|
127
|
+
when Psych::Nodes::Mapping then mapping(node, depth)
|
|
128
|
+
when Psych::Nodes::Sequence then node.children.map { |child| node(child, depth + 1) }
|
|
129
|
+
else scalar(node)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def mapping(node, depth)
|
|
134
|
+
hash = node.children.each_slice(2).to_h do |key, value|
|
|
135
|
+
key = node(key, depth + 1)
|
|
136
|
+
raise Refused, "a key must be a plain word" unless key.is_a?(String)
|
|
137
|
+
|
|
138
|
+
[key, node(value, depth + 1)]
|
|
139
|
+
end
|
|
140
|
+
fold(hash, *node.children) if node.children.size == 2
|
|
141
|
+
hash
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def scalar(node)
|
|
145
|
+
return nil if node.plain && NULLS.include?(node.value)
|
|
146
|
+
|
|
147
|
+
node.value
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The pair's text as written when it sits on one line, else key: value.
|
|
151
|
+
def fold(hash, key, value)
|
|
152
|
+
return unless key.is_a?(Psych::Nodes::Scalar) && key.plain && value.is_a?(Psych::Nodes::Scalar)
|
|
153
|
+
return unless hash.values.first.is_a?(String)
|
|
154
|
+
|
|
155
|
+
line = @lines[key.start_line] if key.start_line == value.end_line
|
|
156
|
+
written = line&.[](key.start_column...value.end_column)
|
|
157
|
+
@folds[hash] = written&.start_with?(key.value) ? written : "#{key.value}: #{value.value}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Eval 0013's lenient pass: a free-text value, or a list item that isn't a
|
|
162
|
+
# mapping, goes in quotes unless it is one clean scalar already, so
|
|
163
|
+
# `does: Asked: ...`, `- B: keep 1 h` and `text: "x" is called` all parse.
|
|
164
|
+
# Line by line, so a YAML error's line still points at the source.
|
|
165
|
+
module Lenient
|
|
166
|
+
TEXT_KEYS = %w[title text why does sees behind when recommended].freeze
|
|
167
|
+
ITEM_KEYS = %w[id when goto actor does sees].freeze
|
|
168
|
+
VALUE = /^([ \t]*(?:- )?(?:#{TEXT_KEYS.join("|")}):[ \t]+)(.+?)[ \t]*$/
|
|
169
|
+
ITEM = /^([ \t]*- )(?!(?:#{ITEM_KEYS.join("|")}):)(.+?)[ \t]*$/
|
|
170
|
+
|
|
171
|
+
module_function
|
|
172
|
+
|
|
173
|
+
def quote(source)
|
|
174
|
+
source.gsub(VALUE) { "#{::Regexp.last_match(1)}#{scalar(::Regexp.last_match(2))}" }
|
|
175
|
+
.gsub(ITEM) { "#{::Regexp.last_match(1)}#{scalar(::Regexp.last_match(2))}" }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A value as one double-quoted scalar. Kept as written: one quoted
|
|
179
|
+
# string, or a flow list, mapping or block scalar with no ": " in it.
|
|
180
|
+
def scalar(value)
|
|
181
|
+
return value if value.match?(/\A"[^"]*"\z/) || value.match?(/\A'[^']*'\z/)
|
|
182
|
+
return value if value.match?(/\A[\[{|>]/) && !value.include?(": ")
|
|
183
|
+
|
|
184
|
+
wrapped = value.size > 1 && value.start_with?('"') && value.end_with?('"')
|
|
185
|
+
JSON.generate(wrapped ? value[1..-2] : value)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The two schemas, as the page reads them: string keys, ids as strings,
|
|
190
|
+
# every other key dropped, and an optional value absent rather than nil.
|
|
191
|
+
# Where free text is wanted (an option, title, text, why, does, sees,
|
|
192
|
+
# behind, when, recommended), a folded one-pair mapping (Tree) is taken as
|
|
193
|
+
# its text, and folded? says one was.
|
|
194
|
+
class Schema
|
|
195
|
+
def initialize(folds)
|
|
196
|
+
@folds = folds
|
|
197
|
+
@folded = false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def folded? = @folded
|
|
201
|
+
|
|
202
|
+
def picks(tree)
|
|
203
|
+
rows = list(tree["rows"], "rows")
|
|
204
|
+
raise Refused, "picks has #{rows.size} rows (at most #{MAX_ROWS})" if rows.size > MAX_ROWS
|
|
205
|
+
|
|
206
|
+
rows = rows.each_with_index.map { |row, index| row(row, index) }
|
|
207
|
+
unique(rows, "row")
|
|
208
|
+
compact("title" => text(tree["title"], "the title", free: true), "rows" => rows)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def row(row, index)
|
|
212
|
+
where = "row #{index + 1}"
|
|
213
|
+
raise Refused, "#{where} isn't a mapping" unless row.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
id = text(row["id"], "#{where}'s id", required: true)
|
|
216
|
+
where = "row #{id}"
|
|
217
|
+
options = list(row["options"], "#{where}'s options")
|
|
218
|
+
raise Refused, "#{where} has #{options.size} options (#{OPTIONS.min}-#{OPTIONS.max})" unless OPTIONS.cover?(options.size)
|
|
219
|
+
|
|
220
|
+
options = options.map { |option| text(option, "#{where}'s option", required: true, free: true) }
|
|
221
|
+
twice = options.tally.find { |_option, count| count > 1 }
|
|
222
|
+
raise Refused, "#{where}: option #{twice.first.inspect} is listed twice" if twice
|
|
223
|
+
|
|
224
|
+
recommended = text(row["recommended"], "#{where}'s recommended", free: true)
|
|
225
|
+
if recommended && !options.include?(recommended)
|
|
226
|
+
raise Refused, "#{where}: recommended #{recommended.inspect} isn't one of its options"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
compact("id" => id, "text" => text(row["text"], "#{where}'s text", required: true, free: true), "options" => options,
|
|
230
|
+
"recommended" => recommended, "why" => text(row["why"], "#{where}'s why", free: true))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def walk(tree)
|
|
234
|
+
title = text(tree["title"], "the title", required: true, free: true)
|
|
235
|
+
actors = list(tree["actors"], "actors").map { |actor| text(actor, "an actor", required: true) }
|
|
236
|
+
raise Refused, "walk has #{actors.size} actors (#{ACTORS.min}-#{ACTORS.max})" unless ACTORS.cover?(actors.size)
|
|
237
|
+
|
|
238
|
+
twice = actors.tally.find { |_actor, count| count > 1 }
|
|
239
|
+
raise Refused, "actor #{twice.first.inspect} is listed twice" if twice
|
|
240
|
+
|
|
241
|
+
steps = list(tree["steps"], "steps")
|
|
242
|
+
raise Refused, "walk has #{steps.size} steps (at most #{MAX_STEPS})" if steps.size > MAX_STEPS
|
|
243
|
+
|
|
244
|
+
steps = steps.each_with_index.map { |step, index| step(step, index, actors) }
|
|
245
|
+
ids = unique(steps, "step")
|
|
246
|
+
raise Refused, "step id #{END_NEXT.inspect} is taken by next: #{END_NEXT}" if ids.include?(END_NEXT)
|
|
247
|
+
|
|
248
|
+
steps.each { |step| targets(step, ids) }
|
|
249
|
+
{ "title" => title, "actors" => actors, "steps" => steps }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def step(step, index, actors)
|
|
253
|
+
where = "step #{index + 1}"
|
|
254
|
+
raise Refused, "#{where} isn't a mapping" unless step.is_a?(Hash)
|
|
255
|
+
|
|
256
|
+
id = text(step["id"], "#{where}'s id", required: true)
|
|
257
|
+
where = "step #{id}"
|
|
258
|
+
actor = text(step["actor"], "#{where}'s actor", required: true)
|
|
259
|
+
raise Refused, "#{where}: actor #{actor.inspect} isn't one of the actors" unless actors.include?(actor)
|
|
260
|
+
|
|
261
|
+
does = text(step["does"], "#{where}'s does", free: true)
|
|
262
|
+
sees = text(step["sees"], "#{where}'s sees", free: true)
|
|
263
|
+
raise Refused, "#{where} needs does or sees" unless does || sees
|
|
264
|
+
|
|
265
|
+
nxt = text(step["next"], "#{where}'s next")
|
|
266
|
+
raise Refused, "#{where}: next is the step itself" if nxt == id
|
|
267
|
+
|
|
268
|
+
compact("id" => id, "actor" => actor, "does" => does, "sees" => sees,
|
|
269
|
+
"behind" => text(step["behind"], "#{where}'s behind", free: true), "next" => nxt,
|
|
270
|
+
"cases" => cases(step, where))
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# cases, and eval round 1's name for them (branches), as one list.
|
|
274
|
+
def cases(step, where)
|
|
275
|
+
given = %w[cases branches].filter_map { |key| list(step[key], "#{where}'s #{key}") unless step[key].nil? }
|
|
276
|
+
return nil if given.empty?
|
|
277
|
+
|
|
278
|
+
given.flatten(1).map do |item|
|
|
279
|
+
raise Refused, "#{where}: each case needs when and goto" unless item.is_a?(Hash)
|
|
280
|
+
|
|
281
|
+
{ "when" => text(item["when"], "#{where}'s when", required: true, free: true),
|
|
282
|
+
"goto" => text(item["goto"], "#{where}'s goto", required: true) }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def targets(step, ids)
|
|
287
|
+
nxt = step["next"] unless step["next"] == END_NEXT
|
|
288
|
+
names = [nxt, *Array(step["cases"]).map { |item| item["goto"] }].compact
|
|
289
|
+
missing = names.find { |name| !ids.include?(name) }
|
|
290
|
+
raise Refused, "step #{step["id"]}: #{missing.inspect} isn't a step" if missing
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def unique(items, noun)
|
|
294
|
+
ids = items.map { |item| item["id"] }
|
|
295
|
+
twice = ids.tally.find { |_id, count| count > 1 }
|
|
296
|
+
raise Refused, "#{noun} id #{twice.first.inspect} is used twice" if twice
|
|
297
|
+
|
|
298
|
+
ids
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def list(value, where)
|
|
302
|
+
raise Refused, "#{where} must be a non-empty list" unless value.is_a?(Array) && !value.empty?
|
|
303
|
+
|
|
304
|
+
value
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def text(value, where, required: false, free: false)
|
|
308
|
+
if free && value.is_a?(Hash) && @folds.key?(value)
|
|
309
|
+
value = @folds[value]
|
|
310
|
+
@folded = true
|
|
311
|
+
end
|
|
312
|
+
raise Refused, "#{where} must be text, not a #{value.is_a?(Hash) ? "mapping" : "list"}" if value.is_a?(Hash) || value.is_a?(Array)
|
|
313
|
+
|
|
314
|
+
value = value&.strip
|
|
315
|
+
value = nil if value && value.empty?
|
|
316
|
+
raise Refused, "#{where} is missing" if required && value.nil?
|
|
317
|
+
raise Refused, "#{where} is over #{MAX_STRING} characters" if value && value.size > MAX_STRING
|
|
318
|
+
|
|
319
|
+
value
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def compact(hash) = hash.compact
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|