asterism-games 0.18.0 → 0.19.1

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.
@@ -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.