asterism-games 0.18.0 → 0.20.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 +109 -0
- data/README.md +385 -14
- data/VERSION +1 -1
- data/bin/asterism-games +8 -0
- data/lib/asterism_games/ai.rb +64 -0
- data/lib/asterism_games/commentate/ai_comment.rb +633 -0
- data/lib/asterism_games/commentate/assets/TILES_LICENSE.txt +42 -0
- data/lib/asterism_games/commentate/assets/commentate.css +100 -0
- data/lib/asterism_games/commentate/assets/commentate.js +735 -0
- data/lib/asterism_games/commentate/assets/index.html +26 -0
- data/lib/asterism_games/commentate/assets/tiles_hand.bmp +0 -0
- data/lib/asterism_games/commentate/assets/tiles_river.bmp +0 -0
- data/lib/asterism_games/commentate/mahjong.rb +400 -0
- data/lib/asterism_games/commentate/mahjong_events.rb +320 -0
- data/lib/asterism_games/commentate/mahjong_phrases.rb +535 -0
- data/lib/asterism_games/commentate/mahjong_voice.yml +93 -0
- data/lib/asterism_games/commentate/server.rb +204 -0
- data/lib/asterism_games/commentate/voice.rb +394 -0
- data/lib/asterism_games/commentate/voicevox.rb +110 -0
- data/lib/asterism_games/commentate.rb +223 -0
- data/lib/asterism_games/host.rb +1 -1
- data/lib/asterism_games/othello/table.rb +13 -10
- data/lib/asterism_games/play/mahjong.rb +23 -10
- data/lib/asterism_games/play/mahjong_screen.rb +33 -12
- data/lib/asterism_games/play/mahjong_text.rb +47 -6
- metadata +18 -2
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require_relative "../ai"
|
|
6
|
+
require_relative "mahjong_events"
|
|
7
|
+
require_relative "mahjong_phrases"
|
|
8
|
+
|
|
9
|
+
module AsterismGames
|
|
10
|
+
module Commentate
|
|
11
|
+
# The AI's one-liners (README "Commentary", "The AI's one-liners"): at
|
|
12
|
+
# the big moments of a table (a riichi, a win, a draw, the lead
|
|
13
|
+
# changing hands, the last round, the end) and after a quiet stretch,
|
|
14
|
+
# Claude (Haiku by default) is asked for a short free line, said after
|
|
15
|
+
# the fixed commentary in the same voice.
|
|
16
|
+
#
|
|
17
|
+
# - Which scenes: Scenes.rank of the lines a view made; at most one call
|
|
18
|
+
# every MIN_INTERVAL_S, or OUTRANK_INTERVAL_S for a scene bigger than
|
|
19
|
+
# the last one asked about (a win after a riichi); a scene that comes
|
|
20
|
+
# sooner waits (the biggest one, let go after PENDING_S); one at a
|
|
21
|
+
# time, only for the
|
|
22
|
+
# tables a page watches (nobody watching: nothing is spent).
|
|
23
|
+
# - The ask: Prompt.system (the fixed part: the role, mahjong's rules in
|
|
24
|
+
# short, the tone, the length, whether the hands may be told; cached)
|
|
25
|
+
# and Prompt.user (the table now and what just happened). With the
|
|
26
|
+
# hands hidden (--public, or a page with H) the AI gets no hand.
|
|
27
|
+
# - The answer: dropped when the table has moved on (another round or
|
|
28
|
+
# phase, stale?) or it came later than MAX_AGE_S after the ask, or it
|
|
29
|
+
# failed or took longer than TIMEOUT_S (one line in the log, no
|
|
30
|
+
# retry, never the key or the words).
|
|
31
|
+
# - The cost: counted from the tokens (the cache's writes and reads
|
|
32
|
+
# too) in an AI::Budget of its own, under XDG_STATE_HOME; no call
|
|
33
|
+
# once today's money reaches the limit.
|
|
34
|
+
#
|
|
35
|
+
# The commenter is anything with say(system:, user:, timeout:) that
|
|
36
|
+
# returns an AI::Said: AI::Commentator (Claude) or Fake (the tests, and
|
|
37
|
+
# ASTERISM_GAMES_AI_FAKE=1: no API is called).
|
|
38
|
+
class AIComment
|
|
39
|
+
DEFAULT_MODEL = "claude-haiku-5-5"
|
|
40
|
+
DEFAULT_DAILY_USD = 3.0
|
|
41
|
+
KIND = "commentary"
|
|
42
|
+
MIN_INTERVAL_S = 20
|
|
43
|
+
OUTRANK_INTERVAL_S = 8
|
|
44
|
+
QUIET_S = 60
|
|
45
|
+
PENDING_S = 15
|
|
46
|
+
MAX_AGE_S = 15
|
|
47
|
+
TIMEOUT_S = 8
|
|
48
|
+
RECENT = 3
|
|
49
|
+
|
|
50
|
+
# USD per million tokens: input, output, a 5-minute cache write
|
|
51
|
+
# (1.25 x input), a cache read (0.1 x input). The published prices
|
|
52
|
+
# (checked 2026-10-20). A model not here is counted at the dearest
|
|
53
|
+
# (so the daily limit still holds).
|
|
54
|
+
PRICES = {
|
|
55
|
+
"claude-haiku-5-5" => { "in" => 0.10, "out" => 0.50, "cache_write" => 0.125, "cache_read" => 0.01 },
|
|
56
|
+
"claude-haiku-4-5" => { "in" => 1.0, "out" => 5.0, "cache_write" => 1.25, "cache_read" => 0.10 },
|
|
57
|
+
"claude-sonnet-5-5" => { "in" => 2.0, "out" => 10.0, "cache_write" => 2.5, "cache_read" => 0.20 },
|
|
58
|
+
"claude-opus-5-5" => { "in" => 4.0, "out" => 20.0, "cache_write" => 5.0, "cache_read" => 0.20 },
|
|
59
|
+
"claude-fable-5-1" => { "in" => 10.0, "out" => 50.0, "cache_write" => 12.5, "cache_read" => 0.25 }
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
def self.prices(model) = PRICES[model.to_s] || PRICES["claude-fable-5-1"]
|
|
63
|
+
|
|
64
|
+
# $XDG_STATE_HOME/asterism-games/ (~/.local/state/... by default).
|
|
65
|
+
def self.state_dir(env = ENV)
|
|
66
|
+
xdg = env["XDG_STATE_HOME"].to_s
|
|
67
|
+
base = xdg.start_with?("/") ? xdg : File.join(Dir.home, ".local", "state")
|
|
68
|
+
File.join(base, "asterism-games")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.fake_env?(env = ENV) = %w[1 true yes].include?(env["ASTERISM_GAMES_AI_FAKE"].to_s.downcase)
|
|
72
|
+
|
|
73
|
+
# From the options (--ai, --ai-model, --ai-daily-usd) and the
|
|
74
|
+
# environment: [AIComment, nil] or [nil, the status saying why not].
|
|
75
|
+
def self.from_options(opts, env: ENV, log: nil, commenter: nil, clock: nil)
|
|
76
|
+
return [nil, { "state" => "off", "note" => "off" }] if opts[:ai] == false
|
|
77
|
+
model = opts[:ai_model] || DEFAULT_MODEL
|
|
78
|
+
daily = opts[:ai_daily_usd] || DEFAULT_DAILY_USD
|
|
79
|
+
fake = commenter.nil? && fake_env?(env)
|
|
80
|
+
unless commenter
|
|
81
|
+
if fake
|
|
82
|
+
commenter = Fake.new(delay_ms: 600)
|
|
83
|
+
else
|
|
84
|
+
key = env["ANTHROPIC_API_KEY"].to_s
|
|
85
|
+
if key.empty?
|
|
86
|
+
log&.call("AI: off (no ANTHROPIC_API_KEY): set it for the AI's one-liners (README \"Commentary\")")
|
|
87
|
+
return [nil, { "state" => "off", "note" => "no_key" }]
|
|
88
|
+
end
|
|
89
|
+
begin
|
|
90
|
+
commenter = AI::Commentator.new(key, model: model)
|
|
91
|
+
rescue LoadError
|
|
92
|
+
log&.call("AI: off: ANTHROPIC_API_KEY is set but the anthropic gem is missing; " \
|
|
93
|
+
"`gem install anthropic` and the AI's one-liners come")
|
|
94
|
+
return [nil, { "state" => "off", "note" => "no_gem" }]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
dir = state_dir(env)
|
|
99
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
100
|
+
path = File.join(dir, fake ? "commentary_ai_usage.fake.json" : "commentary_ai_usage.json")
|
|
101
|
+
budget = AI::Budget.new(path, daily_usd: daily, prices: ->(_) { prices(model) })
|
|
102
|
+
kw = { model: model, budget: budget, log: log, fake: fake }
|
|
103
|
+
kw[:clock] = clock if clock
|
|
104
|
+
ai = new(commenter, **kw)
|
|
105
|
+
log&.call("AI: #{fake ? 'fakes (ASTERISM_GAMES_AI_FAKE): no API is called' : model}, " \
|
|
106
|
+
"today $#{format('%.4f', budget.usd_today)} of $#{format('%.2f', daily)} (counted in #{path})")
|
|
107
|
+
[ai, nil]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
attr_reader :budget, :model, :calls
|
|
111
|
+
|
|
112
|
+
# deliver: called with (table, text, job) for an answer that is still
|
|
113
|
+
# fresh; changed: called after a call (the money changed); targets:
|
|
114
|
+
# the tables to comment on (nil: all); hands: whether the AI may see
|
|
115
|
+
# the hands now. The hub sets these (attach). async: false runs each
|
|
116
|
+
# call in the caller's thread (the tests).
|
|
117
|
+
def initialize(commenter, model: DEFAULT_MODEL, budget:, log: nil, fake: false,
|
|
118
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, async: true, tone: "plain")
|
|
119
|
+
@commenter = commenter
|
|
120
|
+
@model = model
|
|
121
|
+
@budget = budget
|
|
122
|
+
@log = log
|
|
123
|
+
@fake = fake
|
|
124
|
+
@clock = clock
|
|
125
|
+
@async = async
|
|
126
|
+
@tone = tone
|
|
127
|
+
@lock = Monitor.new
|
|
128
|
+
@tables = {} # id => {"view", "line_at", "quiet_at", "pending", "recent"}
|
|
129
|
+
@busy = false
|
|
130
|
+
@last_call = nil
|
|
131
|
+
@last_rank = nil
|
|
132
|
+
@calls = 0
|
|
133
|
+
@deliver = nil
|
|
134
|
+
@changed = nil
|
|
135
|
+
@targets = nil
|
|
136
|
+
@hands = -> { true }
|
|
137
|
+
@queue = nil
|
|
138
|
+
@worker = nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
attr_accessor :tone
|
|
142
|
+
|
|
143
|
+
def attach(deliver:, changed: nil, targets: nil, hands: nil, tone: nil)
|
|
144
|
+
@deliver = deliver
|
|
145
|
+
@changed = changed
|
|
146
|
+
@targets = targets
|
|
147
|
+
@hands = hands if hands
|
|
148
|
+
@tone = tone if tone
|
|
149
|
+
self
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# What the page's bar shows.
|
|
153
|
+
def status
|
|
154
|
+
s = { "state" => @fake ? "fake" : "on", "model" => @model, "usd" => @budget.usd_today.round(4),
|
|
155
|
+
"limit_usd" => @budget.daily_usd }
|
|
156
|
+
s["state"] = "over" if @budget.over?
|
|
157
|
+
s
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A view of a table and the lines it made (the hub calls this for
|
|
161
|
+
# every view).
|
|
162
|
+
def observe(table, view, lines, now = @clock.call)
|
|
163
|
+
@lock.synchronize do
|
|
164
|
+
t = (@tables[table] ||= { "line_at" => now, "quiet_at" => now, "recent" => [] })
|
|
165
|
+
t["view"] = view
|
|
166
|
+
t["line_at"] = now unless lines.empty?
|
|
167
|
+
rank = lines.map { |l| Scenes.rank(l) }.compact.max
|
|
168
|
+
if rank
|
|
169
|
+
sc = { "rank" => rank, "kind" => Scenes.kind(lines), "lines" => lines, "at" => now }.merge(Scenes.where(view))
|
|
170
|
+
t["pending"] = sc if t["pending"].nil? || rank >= t["pending"]["rank"]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
try_fire(now)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Every few seconds (the runner): a quiet table gets a scene of its own.
|
|
177
|
+
def tick(now = @clock.call)
|
|
178
|
+
@lock.synchronize do
|
|
179
|
+
@tables.each_value do |t|
|
|
180
|
+
v = t["view"]
|
|
181
|
+
next unless t["pending"].nil? && v.is_a?(Hash) && v["status"] == "play"
|
|
182
|
+
next if now - t["line_at"] < QUIET_S || now - t["quiet_at"] < QUIET_S
|
|
183
|
+
t["pending"] = { "rank" => 0, "kind" => "quiet", "lines" => [], "at" => now,
|
|
184
|
+
"quiet_s" => (now - t["line_at"]).round }.merge(Scenes.where(v))
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
try_fire(now)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# An AI line was said at table: the quiet time counts from it.
|
|
191
|
+
def heard(table, text, now = @clock.call)
|
|
192
|
+
@lock.synchronize do
|
|
193
|
+
t = @tables[table] or return
|
|
194
|
+
t["line_at"] = now
|
|
195
|
+
t["recent"] << text
|
|
196
|
+
t["recent"].shift while t["recent"].size > RECENT
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def stop
|
|
201
|
+
@queue&.push(:stop)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Why an answer is not said any more (nil: it is): the table is in
|
|
205
|
+
# another round or phase, the host started again, or it is late.
|
|
206
|
+
def self.stale?(job, view, now)
|
|
207
|
+
return "late" if now - job["asked_at"] > MAX_AGE_S
|
|
208
|
+
return "gone" unless view.is_a?(Hash)
|
|
209
|
+
where = Scenes.where(view)
|
|
210
|
+
return "another round" if where["round"] != job["round"]
|
|
211
|
+
return "another phase" unless same_phase?(job["status"], where["status"])
|
|
212
|
+
return "the host started again" if where["sv"].to_i < job["sv"].to_i
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The phase a scene was in, and the table's now: the same, or the
|
|
217
|
+
# result of the last round turned into the end (a win's line still
|
|
218
|
+
# fits the end's screen).
|
|
219
|
+
def self.same_phase?(was, now) = was == now || (was == "result" && now == "over")
|
|
220
|
+
|
|
221
|
+
# A line as it may be said: one line of text, no quotes, no
|
|
222
|
+
# decorations or emoji; nil when nothing is left, or when it is far
|
|
223
|
+
# longer than asked and cannot be cut at a sentence's end.
|
|
224
|
+
def self.clean(text, max: 60)
|
|
225
|
+
s = text.to_s.gsub(/[\r\n]+/, " ").strip
|
|
226
|
+
s = s.sub(/\A(ずんだもん|実況|AI|ひとこと)\s*[::]\s*/, "")
|
|
227
|
+
s = s.gsub(/[「」『』"“”*#_`~|<>\[\]]/, "")
|
|
228
|
+
s = s.gsub(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{200D}\u{3030}\u{303D}]/, "")
|
|
229
|
+
s = s.gsub(/[ ]+/, " ").strip
|
|
230
|
+
return nil if s.empty?
|
|
231
|
+
return s if s.size <= max
|
|
232
|
+
cut = s[0, max]
|
|
233
|
+
i = cut.rindex(/[。!?!?]/)
|
|
234
|
+
i && i >= 8 ? cut[0..i] : nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
def log(m) = @log&.call("AI: #{m}")
|
|
240
|
+
|
|
241
|
+
def try_fire(now)
|
|
242
|
+
return if @deliver.nil?
|
|
243
|
+
targets = @targets&.call
|
|
244
|
+
return if targets && targets.empty?
|
|
245
|
+
return if @budget.over?
|
|
246
|
+
hands = @hands.call ? true : false
|
|
247
|
+
job = nil
|
|
248
|
+
@lock.synchronize do
|
|
249
|
+
return if @busy
|
|
250
|
+
return if @last_call && now - @last_call < OUTRANK_INTERVAL_S
|
|
251
|
+
best = nil
|
|
252
|
+
@tables.each do |id, t|
|
|
253
|
+
sc = t["pending"] or next
|
|
254
|
+
if now - sc["at"] > PENDING_S || stale_scene?(sc, t["view"])
|
|
255
|
+
t["pending"] = nil
|
|
256
|
+
next
|
|
257
|
+
end
|
|
258
|
+
next if targets && !targets.include?(id)
|
|
259
|
+
best = [id, t, sc] if best.nil? || sc["rank"] > best[2]["rank"]
|
|
260
|
+
end
|
|
261
|
+
return unless best
|
|
262
|
+
id, t, sc = best
|
|
263
|
+
if sc["kind"] == "quiet"
|
|
264
|
+
sc = sc.merge("topic" => Topics.pick(t["view"], hands: hands, last: t["topic"], n: t["quiets"].to_i))
|
|
265
|
+
t["topic"] = sc["topic"] && sc["topic"]["key"]
|
|
266
|
+
t["quiets"] = t["quiets"].to_i + 1
|
|
267
|
+
end
|
|
268
|
+
# A scene bigger than the last one asked about (a win after a
|
|
269
|
+
# riichi) waits OUTRANK_INTERVAL_S, any other MIN_INTERVAL_S.
|
|
270
|
+
gap = @last_rank && sc["rank"] > @last_rank ? OUTRANK_INTERVAL_S : MIN_INTERVAL_S
|
|
271
|
+
return if @last_call && now - @last_call < gap
|
|
272
|
+
t["pending"] = nil
|
|
273
|
+
t["quiet_at"] = now if sc["kind"] == "quiet"
|
|
274
|
+
@busy = true
|
|
275
|
+
@last_call = now
|
|
276
|
+
@last_rank = sc["rank"]
|
|
277
|
+
@calls += 1
|
|
278
|
+
job = { "table" => id, "kind" => sc["kind"], "rank" => sc["rank"], "asked_at" => now, "hands" => hands,
|
|
279
|
+
"topic" => sc["topic"] && sc["topic"]["key"],
|
|
280
|
+
"round" => sc["round"], "status" => sc["status"], "sv" => sc["sv"],
|
|
281
|
+
"system" => Prompt.system(tone: @tone, hands: hands),
|
|
282
|
+
"user" => Prompt.user(t["view"], sc, hands: hands, recent: t["recent"].dup, tone: @tone) }
|
|
283
|
+
end
|
|
284
|
+
submit(job)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def stale_scene?(sc, view)
|
|
288
|
+
w = Scenes.where(view)
|
|
289
|
+
w["round"] != sc["round"] || !AIComment.same_phase?(sc["status"], w["status"])
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def submit(job)
|
|
293
|
+
return run(job) unless @async
|
|
294
|
+
@lock.synchronize do
|
|
295
|
+
unless @worker&.alive?
|
|
296
|
+
@queue = Thread::Queue.new
|
|
297
|
+
@worker = Thread.new do
|
|
298
|
+
loop do
|
|
299
|
+
j = @queue.pop
|
|
300
|
+
break if j == :stop
|
|
301
|
+
run(j)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
@queue.push(job)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def run(job)
|
|
310
|
+
said = @commenter.say(system: job["system"], user: job["user"], timeout: TIMEOUT_S)
|
|
311
|
+
usd = @budget.record(KIND, said.tokens)
|
|
312
|
+
tk = said.tokens
|
|
313
|
+
log("#{job['kind']}#{job['topic'] ? " (#{job['topic']})" : ''}#{job['hands'] ? '' : ' (hands hidden)'}: " \
|
|
314
|
+
"#{format('%.1f', said.ms.to_i / 1000.0)} s, stop #{said.stop || '-'}, " \
|
|
315
|
+
"tokens in #{tk['in'].to_i} " \
|
|
316
|
+
"cache_write #{tk['cache_write'].to_i} cache_read #{tk['cache_read'].to_i} out #{tk['out'].to_i}, " \
|
|
317
|
+
"$#{format('%.5f', usd)} (today $#{format('%.4f', @budget.usd_today)})")
|
|
318
|
+
text = AIComment.clean(said.text)
|
|
319
|
+
return log("#{job['kind']}: an answer that is not a line, dropped") unless text
|
|
320
|
+
now = @clock.call
|
|
321
|
+
view = @lock.synchronize { @tables.dig(job["table"], "view") }
|
|
322
|
+
if (why = AIComment.stale?(job, view, now))
|
|
323
|
+
return log("#{job['kind']}: a stale answer dropped (#{why})")
|
|
324
|
+
end
|
|
325
|
+
heard(job["table"], text, now)
|
|
326
|
+
@deliver.call(job["table"], text, job)
|
|
327
|
+
rescue AI::TokenError => e
|
|
328
|
+
@budget.record(KIND, e.tokens)
|
|
329
|
+
log("#{job['kind']}: dropped (#{e.message})")
|
|
330
|
+
rescue AI::Error => e
|
|
331
|
+
@budget.record_failure(KIND)
|
|
332
|
+
log("#{job['kind']}: dropped (#{e.message})")
|
|
333
|
+
rescue StandardError => e
|
|
334
|
+
log("#{job['kind']}: dropped (#{e.class})")
|
|
335
|
+
ensure
|
|
336
|
+
@lock.synchronize { @busy = false }
|
|
337
|
+
@changed&.call
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Which lines make a scene, and how big it is.
|
|
341
|
+
module Scenes
|
|
342
|
+
RANKS = { "game_over" => 5, "lead_change" => 4, "draw" => 2, "abort" => 2, "riichi" => 1 }.freeze
|
|
343
|
+
WORDS = { "game_over" => "終局", "win_strong" => "大きな和了 (満貫以上)", "win" => "和了",
|
|
344
|
+
"lead_change" => "逆転", "oorasu" => "オーラス", "draw" => "流局", "abort" => "途中流局",
|
|
345
|
+
"riichi" => "リーチ", "quiet" => "静かな時間" }.freeze
|
|
346
|
+
|
|
347
|
+
module_function
|
|
348
|
+
|
|
349
|
+
# nil: not a scene. A win of a mangan or more (prio 3) and the
|
|
350
|
+
# lead changing hands rank above a plain win; the last round
|
|
351
|
+
# (a round start of prio 3) with a win.
|
|
352
|
+
def rank(line)
|
|
353
|
+
return nil if line["ai"]
|
|
354
|
+
case line["type"]
|
|
355
|
+
when "win" then line["prio"].to_i >= 3 ? 4 : 3
|
|
356
|
+
when "round_start" then line["prio"].to_i >= 3 ? 3 : nil
|
|
357
|
+
else RANKS[line["type"]]
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# The scene's name: its biggest line's.
|
|
362
|
+
def kind(lines)
|
|
363
|
+
l = lines.select { |x| rank(x) }.max_by { |x| rank(x) }
|
|
364
|
+
return "quiet" unless l
|
|
365
|
+
case l["type"]
|
|
366
|
+
when "win" then l["prio"].to_i >= 3 ? "win_strong" : "win"
|
|
367
|
+
when "round_start" then "oorasu"
|
|
368
|
+
else l["type"]
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Where the table is: {"round" => [wind, number, honba], "status", "sv"}.
|
|
373
|
+
def where(view)
|
|
374
|
+
return { "round" => nil, "status" => nil, "sv" => nil } unless view.is_a?(Hash)
|
|
375
|
+
r = view["round"].is_a?(Hash) ? view["round"] : {}
|
|
376
|
+
{ "round" => [r["wind"], r["number"], r["honba"]], "status" => view["status"], "sv" => view["sv"] }
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# What a quiet minute is about, chosen here so that the AI does not
|
|
381
|
+
# wander (with an open question Haiku spent seconds and hundreds of
|
|
382
|
+
# tokens): one of the leader's lead, a seat in tenpai without riichi
|
|
383
|
+
# (only with the hands shown), the dora, the wall left, the last
|
|
384
|
+
# discard; in turn, never the one of the last quiet minute.
|
|
385
|
+
module Topics
|
|
386
|
+
P = MahjongPhrases
|
|
387
|
+
ORDER = %w[lead dama dora wall last_discard].freeze
|
|
388
|
+
|
|
389
|
+
module_function
|
|
390
|
+
|
|
391
|
+
# {"key", "text"}, or nil when the view has nothing to talk about.
|
|
392
|
+
def pick(view, hands: false, last: nil, n: 0)
|
|
393
|
+
all = candidates(view, hands: hands)
|
|
394
|
+
return nil if all.empty?
|
|
395
|
+
keys = ORDER.select { |k| all.key?(k) }
|
|
396
|
+
keys -= [last] if keys.size > 1
|
|
397
|
+
k = keys[n % keys.size]
|
|
398
|
+
{ "key" => k, "text" => all[k] }
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def candidates(view, hands: false)
|
|
402
|
+
return {} unless view.is_a?(Hash)
|
|
403
|
+
seats = Array(view["seats"])
|
|
404
|
+
name = ->(i) { P.seat_name(seats, i) }
|
|
405
|
+
out = {}
|
|
406
|
+
sc = seats.map { |x| x["score"].to_i }
|
|
407
|
+
if sc.size >= 2
|
|
408
|
+
order = sc.each_index.sort_by { |i| [-sc[i], i] }
|
|
409
|
+
a, b = order
|
|
410
|
+
out["lead"] = "トップは#{name.call(a)} (#{sc[a]}点)、2位の#{name.call(b)}との差は#{sc[a] - sc[b]}点。"
|
|
411
|
+
end
|
|
412
|
+
if hands
|
|
413
|
+
seats.each_with_index do |x, i|
|
|
414
|
+
next if x["riichi"]
|
|
415
|
+
w = MahjongEvents.waits_of(x)
|
|
416
|
+
next unless w && !w.empty?
|
|
417
|
+
out["dama"] = "#{name.call(i)}はリーチをかけずにテンパイしている (待ち #{w.map { |t| P.tile_words(t)[0] }.join('・')})。"
|
|
418
|
+
break
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
dora = Array(view["dora_indicators"]).map { |t| MahjongEvents.dora_of(t) }.compact
|
|
422
|
+
unless dora.empty?
|
|
423
|
+
seen = seats.sum do |x|
|
|
424
|
+
(Array(x["river"]) + Array(x["melds"]).flat_map { |m| Array(m["tiles"]) })
|
|
425
|
+
.count { |t| dora.include?(MahjongEvents.plain(t)) }
|
|
426
|
+
end
|
|
427
|
+
out["dora"] = "ドラは#{dora.map { |t| P.tile_words(t)[0] }.join('・')}。河と鳴きに見えているのは#{seen}枚。"
|
|
428
|
+
end
|
|
429
|
+
left = view.dig("round", "wall_left")
|
|
430
|
+
out["wall"] = "山の残りは#{left}枚。" if left.is_a?(Integer)
|
|
431
|
+
ld = view["last_discard"]
|
|
432
|
+
if ld.is_a?(Hash) && ld["tile"]
|
|
433
|
+
out["last_discard"] = "直前に#{name.call(ld['seat'])}が#{P.tile_words(ld['tile'])[0]}を切った。"
|
|
434
|
+
end
|
|
435
|
+
out
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# The words of the ask. The system part is fixed for a tone and
|
|
440
|
+
# whether the hands may be told (four texts in all, each the same to
|
|
441
|
+
# the byte every time, so the cache holds); the user part is the
|
|
442
|
+
# table now.
|
|
443
|
+
module Prompt
|
|
444
|
+
P = MahjongPhrases
|
|
445
|
+
|
|
446
|
+
HANDS = <<~TEXT
|
|
447
|
+
観戦用の様子なので、各席の手牌と待ちが見えている。手牌と待ちに触れてよい (例: 誰の待ちが山に残っていそうか、誰が危ない牌を抱えているか、誰の手が高そうか)。ただし打ち手には見えていないことを忘れず、観戦者への語りかけとして言う。
|
|
448
|
+
TEXT
|
|
449
|
+
NO_HANDS = <<~TEXT
|
|
450
|
+
手牌は見えない (配信用で、公開の情報だけで実況する)。手牌・待ち・聴牌しているかどうかを推測で言わない。河・鳴き・リーチ・点数・残り枚数など、誰にでも見える情報だけで話す。
|
|
451
|
+
TEXT
|
|
452
|
+
|
|
453
|
+
SYSTEM = <<~TEXT
|
|
454
|
+
あなたは麻雀の観戦実況の「ひとこと」係です。Asterism のゲームホストで打たれている 4 人麻雀の卓を外から見て、決まりの実況 (リーチ・鳴き・和了・点数・順位などを決まった文で伝える係) の後に、短い自由なひとことを添えます。事実は決まりの実況がもう伝えているので、同じ事実を繰り返さず、その場面の意味、緊張感、見どころ、次の展開への期待を、観戦者に語りかけるように言ってください。
|
|
455
|
+
|
|
456
|
+
## 答え方
|
|
457
|
+
- 日本語で 40 字以内、1〜2 文。
|
|
458
|
+
- 答えは実況の文だけ。前置き、説明、かぎかっこ、記号の飾り、絵文字、顔文字、改行、話し手の名前は付けない。
|
|
459
|
+
- 牌は「3萬」「5筒」「7索」「赤5筒」「東」「白」「發」「中」のように書く (漢数字や英字の記号は使わない)。
|
|
460
|
+
- 与えられた様子に無いことは言わない。点数・役・順位を推測で言わない。打ち手の名前は様子にある名前をそのまま使う。
|
|
461
|
+
- 打ち手を馬鹿にしない。特定の打ち手だけを応援しすぎない。
|
|
462
|
+
- 口調: %<tone>s
|
|
463
|
+
|
|
464
|
+
## 手牌
|
|
465
|
+
%<hands>s
|
|
466
|
+
## 麻雀の要点
|
|
467
|
+
- 4 人で打つ。東風戦は東場の 4 局、半荘は南場までの 8 局。親 (東家) が和了るか聴牌で流局すると連荘になり、本場が 1 つ増える。
|
|
468
|
+
- 持ち点は 25000 点から。最後の局 (オーラス) が終わった時の点数で順位が決まる。トップ争い、2 着争い、ラス回避、親の連荘、オーラスの逆転が見どころ。
|
|
469
|
+
- 手牌は 13 枚。ツモで 14 枚にして 4 面子 1 雀頭 (または七対子・国士無双) を作ると和了。他家の捨て牌で和了るのがロン、自分で引いて和了るのがツモ。
|
|
470
|
+
- 聴牌はあと 1 枚で和了れる形。待ちの牌が河や他家の手にたくさん見えていると、残りが少なく和了りにくい。
|
|
471
|
+
- リーチ: 門前で聴牌したら 1000 点を供託して宣言する。以後は手を変えられない。一発・裏ドラで高くなることがある。他家は安全な牌 (リーチ者の河にある牌) を切って守る (オリ) か、勝負するかを選ぶ。
|
|
472
|
+
- 鳴き: チー (上家の捨て牌で順子)、ポン (誰の捨て牌でも刻子)、カン (4 枚)。鳴くと速くなるが、門前でなくなりリーチできず、役も限られる。
|
|
473
|
+
- ドラ: ドラ表示牌の次の牌がドラで、1 枚 1 翻。赤5 もドラ。カンをすると新しいドラがめくれる。
|
|
474
|
+
- 点数は翻と符で決まる。5 翻で満貫 (子 8000 点、親 12000 点)、6〜7 翻で跳満、8〜10 翻で倍満、11〜12 翻で三倍満、13 翻以上か特別な役で役満。
|
|
475
|
+
- 流局: 山が尽きると流局し、聴牌の人がノーテンの人から合わせて 3000 点を受け取る。九種九牌・四風連打などの途中流局もある。
|
|
476
|
+
- 河は各席が捨てた牌の並び。河から、何が安全か、手が速いか、何を集めていそうかが読める。残り枚数が少ないと、和了るか流局かの瀬戸際になる。
|
|
477
|
+
- 1 局の流れ: 局の始まり、打牌の繰り返し (途中にリーチや鳴き)、和了か流局、次の局。
|
|
478
|
+
|
|
479
|
+
## 様子の読み方
|
|
480
|
+
ユーザの文は、その時点の卓の様子の要約 (局、本場、供託、残り枚数、ドラ、各席の名前・家・点数・リーチ・鳴き・河の最後の数枚%<hand_note>s、和了や終局の結果) と、場面 (リーチ、和了、流局、逆転、オーラス、終局、静かな時間)、直前の出来事 (決まりの実況が言った文)、最近のひとことです。その場面ならではのひとことを言ってください。場面が静かな時間のときは、ユーザの文の「話題」の 1 つだけについて言ってください (ほかの話を広げない)。最近のひとことと同じ言い回しは避けてください。
|
|
481
|
+
TEXT
|
|
482
|
+
|
|
483
|
+
@cache = {}
|
|
484
|
+
@cache_lock = Mutex.new
|
|
485
|
+
|
|
486
|
+
module_function
|
|
487
|
+
|
|
488
|
+
def system(tone: "plain", hands: true)
|
|
489
|
+
key = [tone, hands ? true : false]
|
|
490
|
+
@cache_lock.synchronize do
|
|
491
|
+
@cache[key] ||= format(SYSTEM, tone: (MahjongPhrases::TONES[tone] || MahjongPhrases::TONES["plain"])["prompt"],
|
|
492
|
+
hands: hands ? HANDS : NO_HANDS,
|
|
493
|
+
hand_note: hands ? "、手牌と待ち" : "").freeze
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
WIND = { "east" => "東家", "south" => "南家", "west" => "西家", "north" => "北家" }.freeze
|
|
498
|
+
STATUS = { "play" => "対局中", "result" => "局の結果", "over" => "終局", "wait" => "対局前" }.freeze
|
|
499
|
+
|
|
500
|
+
# The table now and what just happened (sc: the scene).
|
|
501
|
+
def user(view, sc, hands: true, recent: [], tone: "plain")
|
|
502
|
+
v = view.is_a?(Hash) ? view : {}
|
|
503
|
+
seats = Array(v["seats"])
|
|
504
|
+
out = ["卓の様子 (sv #{v['sv']}):"]
|
|
505
|
+
r = v["round"].is_a?(Hash) ? v["round"] : nil
|
|
506
|
+
if r
|
|
507
|
+
rt, = P.round_words(r)
|
|
508
|
+
dora = Array(v["dora_indicators"]).map { |t| MahjongEvents.dora_of(t) }.compact
|
|
509
|
+
line = +"#{rt}、供託#{r['riichi_sticks'].to_i}本"
|
|
510
|
+
line << "、残り#{r['wall_left']}枚" if r["wall_left"]
|
|
511
|
+
line << "、ドラ #{tiles(dora)} (表示牌 #{tiles(Array(v['dora_indicators']))})" unless dora.empty?
|
|
512
|
+
line << "。状態: #{STATUS[v['status']] || v['status']}"
|
|
513
|
+
line << "、手番 #{P.seat_name(seats, r['turn'])}" if v["status"] == "play" && r["turn"].is_a?(Integer)
|
|
514
|
+
out << "#{line}。"
|
|
515
|
+
else
|
|
516
|
+
out << "状態: #{STATUS[v['status']] || v['status'] || '不明'}。"
|
|
517
|
+
end
|
|
518
|
+
seats.each_with_index { |s, i| out << seat_line(s, i, r, hands) }
|
|
519
|
+
(res = result_line(v, seats)) && out << res
|
|
520
|
+
(fin = final_line(v, seats)) && out << fin
|
|
521
|
+
out << ""
|
|
522
|
+
out << "場面: #{Scenes::WORDS[sc['kind']] || sc['kind']}"
|
|
523
|
+
out << "直前の出来事:"
|
|
524
|
+
said = Array(sc["lines"]).map { |l| hands ? l["text"] : (l.key?("ptext") ? l["ptext"] : l["text"]) }.compact
|
|
525
|
+
if said.empty?
|
|
526
|
+
out << "- しばらく動きがない (#{sc['quiet_s'] || QUIET_S} 秒)。"
|
|
527
|
+
out << "話題: #{sc['topic']['text']}" if sc["topic"]
|
|
528
|
+
else
|
|
529
|
+
said.each { |t| out << "- #{t}" }
|
|
530
|
+
end
|
|
531
|
+
unless recent.empty?
|
|
532
|
+
out << ""
|
|
533
|
+
out << "最近のひとこと:"
|
|
534
|
+
recent.each { |t| out << "- #{t}" }
|
|
535
|
+
end
|
|
536
|
+
out.join("\n")
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def seat_line(s, i, r, hands)
|
|
540
|
+
name = s["name"].to_s.strip.empty? ? "#{i + 1}番の席" : s["name"].to_s.strip
|
|
541
|
+
w = WIND[s["wind"]] || ""
|
|
542
|
+
w += "(親)" if r && r["dealer"] == i
|
|
543
|
+
parts = ["- #{name} #{w} #{s['score']}点"]
|
|
544
|
+
parts << "リーチ中" if s["riichi"]
|
|
545
|
+
melds = Array(s["melds"])
|
|
546
|
+
parts << "鳴き #{melds.map { |m| meld_words(m) }.join('、')}" unless melds.empty?
|
|
547
|
+
river = Array(s["river"])
|
|
548
|
+
parts << "河 #{river.size}枚#{river.empty? ? '' : " (最後: #{tiles(river.last(6), sep: ' ')})"}"
|
|
549
|
+
if hands && s["hand"].is_a?(Array)
|
|
550
|
+
parts << "手牌 #{tiles(s['hand'], sep: '')}#{s['drawn'] ? " ツモ #{tiles([s['drawn']])}" : ''}"
|
|
551
|
+
w8 = MahjongEvents.waits_of(s)
|
|
552
|
+
parts << (w8 && !w8.empty? ? "待ち #{tiles(w8)}" : "ノーテン") if s["drawn"].nil?
|
|
553
|
+
end
|
|
554
|
+
parts.join("、")
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def meld_words(m)
|
|
558
|
+
kind = P::CALL_KIND[m["type"]] || m["type"].to_s
|
|
559
|
+
"#{kind} #{tiles(Array(m['tiles']), sep: '')}"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def result_line(v, seats)
|
|
563
|
+
res = v["result"]
|
|
564
|
+
return nil unless res.is_a?(Hash) && %w[result over].include?(v["status"])
|
|
565
|
+
case res["kind"].to_s
|
|
566
|
+
when "tsumo_agari", "ron_agari"
|
|
567
|
+
Array(res["wins"]).map do |w|
|
|
568
|
+
by = res["kind"] == "ron_agari" ? "ロン (#{P.seat_name(seats, res['from'])}から)" : "ツモ"
|
|
569
|
+
"結果: #{P.seat_name(seats, w['seat'])}の#{by}、#{Array(w['yaku']).join('・')}、#{w['points']}。"
|
|
570
|
+
end.join("\n")
|
|
571
|
+
when "ryuukyoku"
|
|
572
|
+
t = Array(res["tenpai"]).map { |i| P.seat_name(seats, i) }
|
|
573
|
+
"結果: 流局。テンパイ #{t.empty? ? 'なし' : t.join('、')}。"
|
|
574
|
+
else "結果: 途中流局 (#{P::ABORT_TEXT[res['kind']] || res['kind']})。"
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def final_line(v, seats)
|
|
579
|
+
fin = v["final"]
|
|
580
|
+
return nil unless v["status"] == "over" && fin.is_a?(Array) && !fin.empty?
|
|
581
|
+
"終局の順位: #{fin.sort_by { |f| f[1].to_i }.map { |f| "#{f[1]}位 #{P.seat_name(seats, f[0])} #{f[2]}点" }.join('、')}。"
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def tiles(names, sep: "・")
|
|
585
|
+
names.map { |t| P.tile_words(t)[0] }.join(sep)
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# A commenter for the tests and for trying the page without a key
|
|
590
|
+
# (ASTERISM_GAMES_AI_FAKE=1): says a line of its own after delay_ms
|
|
591
|
+
# (naming the scene it was asked about), every fail_every-th fails,
|
|
592
|
+
# every slow_every-th takes longer than its time limit. The first
|
|
593
|
+
# call counts a cache write, the others a cache read, as the API
|
|
594
|
+
# would. requests: what it was asked.
|
|
595
|
+
class Fake
|
|
596
|
+
attr_reader :requests
|
|
597
|
+
|
|
598
|
+
# stop: a stop reason to fail with as a cut answer would
|
|
599
|
+
# ("max_tokens"), counting its tokens.
|
|
600
|
+
def initialize(delay_ms: 0, fail_every: 0, slow_every: 0, text: nil, stop: nil)
|
|
601
|
+
@stop = stop
|
|
602
|
+
@delay_ms = delay_ms
|
|
603
|
+
@fail_every = fail_every
|
|
604
|
+
@slow_every = slow_every
|
|
605
|
+
@text = text
|
|
606
|
+
@requests = []
|
|
607
|
+
@lock = Mutex.new
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def count = @lock.synchronize { @requests.size }
|
|
611
|
+
|
|
612
|
+
def say(system:, user:, timeout: TIMEOUT_S)
|
|
613
|
+
i = @lock.synchronize { (@requests << { "system" => system, "user" => user }).size }
|
|
614
|
+
if @slow_every.positive? && (i % @slow_every).zero?
|
|
615
|
+
sleep(timeout + 0.2)
|
|
616
|
+
raise AI::Error, "fake: Net::ReadTimeout"
|
|
617
|
+
end
|
|
618
|
+
sleep(@delay_ms / 1000.0) if @delay_ms.positive?
|
|
619
|
+
raise AI::Error, "fake: failing on purpose (#{i})" if @fail_every.positive? && (i % @fail_every).zero?
|
|
620
|
+
if @stop
|
|
621
|
+
raise AI::TokenError.new("fake: cut (stop_reason #{@stop}, out 200)",
|
|
622
|
+
{ "in" => 300, "out" => 200, "cache_write" => 0, "cache_read" => 1500 })
|
|
623
|
+
end
|
|
624
|
+
scene = user[/^場面: (.+)$/, 1] || "この"
|
|
625
|
+
text = @text ? @text.call(system, user, i) : "偽のひとこと#{i}、#{scene}の場面なのだ!"
|
|
626
|
+
AI::Said.new(text: text, ms: @delay_ms, stop: "end_turn",
|
|
627
|
+
tokens: { "in" => 300, "out" => 25, "cache_write" => i == 1 ? 1500 : 0,
|
|
628
|
+
"cache_read" => i == 1 ? 0 : 1500 })
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
The tile sheets tiles_hand.bmp and tiles_river.bmp
|
|
2
|
+
|
|
3
|
+
These two pictures are the hand-drawn mahjong tiles of Family mruby's
|
|
4
|
+
mahjong app (fmruby-core, flash/app/game/mahjong/), copied here byte for
|
|
5
|
+
byte. They are drawn by hand by Katsuhiko Kageyama, the author of Family
|
|
6
|
+
mruby and of this gem.
|
|
7
|
+
|
|
8
|
+
In this gem they are under the MIT License, the license of the gem
|
|
9
|
+
(LICENSE), by the author's decision. In fmruby-core they stay under that
|
|
10
|
+
repository's license (GPL-3.0).
|
|
11
|
+
|
|
12
|
+
tiles_hand.bmp 420x224, 8-bit, cells of 42x56
|
|
13
|
+
tiles_river.bmp 240x128, 8-bit, cells of 24x32
|
|
14
|
+
|
|
15
|
+
Both are 10 x 4 cells without gaps. Tile n (the table's tile numbers:
|
|
16
|
+
0..8 1m..9m, 9 the red 5m, 10..19 the circles likewise, 20..29 the
|
|
17
|
+
bamboos, 30..36 east south west north white green red, 37 the back) is at
|
|
18
|
+
x = (n % 10) * cell width, y = (n / 10) * cell height. The pixel values
|
|
19
|
+
are RGB332 colours and the palette says the same colours; RGB332 0xE3
|
|
20
|
+
(magenta) is the transparent colour.
|
|
21
|
+
|
|
22
|
+
MIT License
|
|
23
|
+
|
|
24
|
+
Copyright (c) 2026 Katsuhiko Kageyama
|
|
25
|
+
|
|
26
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
27
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
28
|
+
in the Software without restriction, including without limitation the rights
|
|
29
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
30
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
31
|
+
furnished to do so, subject to the following conditions:
|
|
32
|
+
|
|
33
|
+
The above copyright notice and this permission notice shall be included in all
|
|
34
|
+
copies or substantial portions of the Software.
|
|
35
|
+
|
|
36
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
37
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
38
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
39
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
40
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
41
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
42
|
+
SOFTWARE.
|