asterism-games 0.17.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 +282 -0
- data/LICENSE +21 -0
- data/README.md +2009 -0
- data/VERSION +1 -0
- data/bin/asterism-games +116 -0
- data/bin/othello-tty +14 -0
- data/lib/asterism_games/ai.rb +457 -0
- data/lib/asterism_games/ai_hub.rb +171 -0
- data/lib/asterism_games/board/remote.rb +77 -0
- data/lib/asterism_games/board/table.rb +280 -0
- data/lib/asterism_games/board.rb +11 -0
- data/lib/asterism_games/config.rb +228 -0
- data/lib/asterism_games/game.rb +456 -0
- data/lib/asterism_games/host.rb +436 -0
- data/lib/asterism_games/othello/board.rb +161 -0
- data/lib/asterism_games/othello/remote.rb +85 -0
- data/lib/asterism_games/othello/table.rb +533 -0
- data/lib/asterism_games/othello.rb +12 -0
- data/lib/asterism_games/play/keys.rb +160 -0
- data/lib/asterism_games/play/mahjong.rb +508 -0
- data/lib/asterism_games/play/mahjong_screen.rb +607 -0
- data/lib/asterism_games/play/mahjong_text.rb +456 -0
- data/lib/asterism_games/play/othello.rb +456 -0
- data/lib/asterism_games/play.rb +254 -0
- data/lib/asterism_games/record_book.rb +468 -0
- data/lib/asterism_games/remote.rb +100 -0
- data/lib/asterism_games/rooms.rb +509 -0
- data/lib/asterism_games/store.rb +39 -0
- data/lib/asterism_games/version.rb +5 -0
- data/lib/asterism_games.rb +14 -0
- metadata +105 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ai"
|
|
4
|
+
|
|
5
|
+
module AsterismGames
|
|
6
|
+
module AI
|
|
7
|
+
# The host's side of the AI players: takes the decisions the tables ask
|
|
8
|
+
# for (game.ai_requests, under the host's lock), asks the deciders on
|
|
9
|
+
# threads of its own (never holding the lock while an API thinks),
|
|
10
|
+
# counts what they cost (Budget) and hands the answers back
|
|
11
|
+
# (game.ai_answer, under the lock again, on the host's next step).
|
|
12
|
+
#
|
|
13
|
+
# The tables learn which AI players they may offer (game.ai_kinds=):
|
|
14
|
+
# those with a decider whose money of today is not spent (Budget#over?,
|
|
15
|
+
# per kind: a kind with its own limit stops alone, the others when the
|
|
16
|
+
# day's total reaches the shared limit); then the AI seats of a kind
|
|
17
|
+
# that stopped are played by the CPU (game.ai_off!).
|
|
18
|
+
class Hub
|
|
19
|
+
attr_reader :stats, :budget, :deciders
|
|
20
|
+
|
|
21
|
+
def initialize(deciders, budget, calls_per_game: 400, threads: 4, log: nil)
|
|
22
|
+
@deciders = deciders
|
|
23
|
+
@budget = budget
|
|
24
|
+
@calls_per_game = calls_per_game
|
|
25
|
+
@log = log
|
|
26
|
+
@jobs = Queue.new
|
|
27
|
+
@results = Queue.new
|
|
28
|
+
@stats = Hash.new(0)
|
|
29
|
+
@times = [] # ms of each answered decision (the last 5000)
|
|
30
|
+
@over = [] # the kinds stopped for today
|
|
31
|
+
@threads = Array.new(@deciders.empty? ? 0 : threads) { Thread.new { work } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def any? = !@deciders.empty?
|
|
35
|
+
|
|
36
|
+
# The limits for the log: "at most $3.0 a day" when no kind has its
|
|
37
|
+
# own, else "at most $10.0 a day for claude, $2.0 for jev" (a kind
|
|
38
|
+
# without its own: "$3.0 for jev with every kind's together").
|
|
39
|
+
def self.limits_text(kinds, budget)
|
|
40
|
+
return "at most $#{budget.daily_usd} a day" if budget.kind_usd.empty?
|
|
41
|
+
parts = kinds.map do |k|
|
|
42
|
+
budget.own_limit?(k) ? "$#{budget.limit_usd(k)} for #{k}" : "$#{budget.daily_usd} for #{k} with every kind's together"
|
|
43
|
+
end
|
|
44
|
+
parts[0] = parts[0].sub(" for ", " a day for ")
|
|
45
|
+
"at most #{parts.join(', ')}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The kinds a table may offer now.
|
|
49
|
+
def kinds
|
|
50
|
+
return [] if @deciders.empty?
|
|
51
|
+
@deciders.keys - @over
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The kinds stopped for today.
|
|
55
|
+
def over = @over.dup
|
|
56
|
+
|
|
57
|
+
# One pass (on the host's loop, under its lock): the limits, the
|
|
58
|
+
# answers that came, the new requests.
|
|
59
|
+
def step(games, now)
|
|
60
|
+
return if @deciders.empty?
|
|
61
|
+
over = @deciders.keys.select { |kind| @budget.over?(kind) }
|
|
62
|
+
say_over(over - @over)
|
|
63
|
+
@over = over
|
|
64
|
+
all = over.size == @deciders.size
|
|
65
|
+
k = kinds
|
|
66
|
+
games.each_value do |g|
|
|
67
|
+
next unless g.respond_to?(:ai_requests)
|
|
68
|
+
g.ai_kinds = AI.kinds_for(g.class.respond_to?(:game_name) ? g.class.game_name : nil, k)
|
|
69
|
+
g.ai_calls_per_game = @calls_per_game
|
|
70
|
+
if @budget.kind_usd.empty?
|
|
71
|
+
# No kind has its own limit: all stop together, as before 0.15.0.
|
|
72
|
+
g.ai_off!("today's limit") if all
|
|
73
|
+
else
|
|
74
|
+
over.each { |kind| g.ai_off!("today's limit for #{kind}", kind: kind) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
until @results.empty?
|
|
78
|
+
table, seat, key, n = @results.pop
|
|
79
|
+
g = games[table]
|
|
80
|
+
g&.ai_answer(seat, key, n, now)
|
|
81
|
+
end
|
|
82
|
+
return if all
|
|
83
|
+
games.each do |name, g|
|
|
84
|
+
next unless g.respond_to?(:ai_requests)
|
|
85
|
+
g.ai_requests(now).each do |r|
|
|
86
|
+
next if over.include?(r[:kind]) # its seat is a CPU from this pass on
|
|
87
|
+
@stats["asked"] += 1
|
|
88
|
+
@jobs << [name, r[:seat], r[:key], r[:kind], r[:request], r[:until]]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Stops the threads (the host is stopping).
|
|
94
|
+
def stop
|
|
95
|
+
@threads.size.times { @jobs << nil }
|
|
96
|
+
@threads.each { |t| t.join(1) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The decision times (ms) answered so far: [count, average, max].
|
|
100
|
+
def times
|
|
101
|
+
return [0, 0, 0] if @times.empty?
|
|
102
|
+
[@times.size, (@times.sum / @times.size.to_f).round, @times.max]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# The log, once for each kind that has just stopped (again after a
|
|
108
|
+
# new day has let it play).
|
|
109
|
+
def say_over(kinds)
|
|
110
|
+
return if kinds.empty?
|
|
111
|
+
if @budget.kind_usd.empty?
|
|
112
|
+
@log&.call("games: AI players: today's limit of $#{@budget.daily_usd} is reached; " \
|
|
113
|
+
"no more AI players today, those seated are played by the CPU")
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
kinds.each do |kind|
|
|
117
|
+
shared = @budget.own_limit?(kind) ? "" : ", every kind's together"
|
|
118
|
+
@log&.call("games: AI players: today's limit for #{kind} ($#{@budget.limit_usd(kind)}#{shared}) is reached; " \
|
|
119
|
+
"no more #{kind} players today, those seated are played by the CPU")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def work
|
|
124
|
+
while (job = @jobs.pop)
|
|
125
|
+
table, seat, key, kind, request, until_ms = job
|
|
126
|
+
left = (until_ms - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)) / 1000.0
|
|
127
|
+
if left <= 0
|
|
128
|
+
@stats["late"] += 1
|
|
129
|
+
@results << [table, seat, key, nil]
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
n = decide(kind, request, left)
|
|
133
|
+
@results << [table, seat, key, n]
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
@log&.call("games: AI players: a worker stopped: #{e.class}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Asks one decider; the move number, or nil (failed).
|
|
140
|
+
def decide(kind, request, left)
|
|
141
|
+
d = @deciders[kind].decide(request, timeout: left)
|
|
142
|
+
@budget.record(kind, d.tokens || {})
|
|
143
|
+
@stats["answered"] += 1
|
|
144
|
+
@stats["answered_#{kind}"] += 1
|
|
145
|
+
if d.ms
|
|
146
|
+
@times << d.ms
|
|
147
|
+
@times.shift if @times.size > 5000
|
|
148
|
+
end
|
|
149
|
+
d.n
|
|
150
|
+
rescue TokenError => e
|
|
151
|
+
@budget.record(kind, e.tokens)
|
|
152
|
+
failed(kind, e.message)
|
|
153
|
+
rescue Error => e
|
|
154
|
+
@budget.record_failure(kind)
|
|
155
|
+
failed(kind, e.message)
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
# Nothing of a library's message (it could carry a request's
|
|
158
|
+
# details): the class only.
|
|
159
|
+
@budget.record_failure(kind)
|
|
160
|
+
failed(kind, e.class.name)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def failed(kind, why)
|
|
164
|
+
@stats["failed"] += 1
|
|
165
|
+
@stats["failed_#{kind}"] += 1
|
|
166
|
+
@log&.call("games: AI player #{kind}: #{why}; the CPU plays this move")
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../remote"
|
|
4
|
+
|
|
5
|
+
module AsterismGames
|
|
6
|
+
module Board
|
|
7
|
+
# The drawing board as an Asterism object (<node>/<app>/<board>), on
|
|
8
|
+
# the framework's Remote (join, mine, leave). Every method takes the
|
|
9
|
+
# caller's node ID as from: (Asterism fills it in).
|
|
10
|
+
#
|
|
11
|
+
# join(name) -> {"token", "owner"} or {"error" => why}
|
|
12
|
+
# line(token, c, w, pts) -> {"id" => stroke id} or why not
|
|
13
|
+
# more(token, id, pts) -> "ok" or why not
|
|
14
|
+
# undo(token) -> "ok" or why not ("nothing")
|
|
15
|
+
# clear(token) -> "ok" or why not ("not owner")
|
|
16
|
+
# picture(id, at) -> a page of the picture (Table#picture)
|
|
17
|
+
# view(token) -> the board's view, "own" and "mine", or {"error" => "token"}
|
|
18
|
+
# table -> the board's view (what the state key has)
|
|
19
|
+
class Remote < AsterismGames::Remote
|
|
20
|
+
METHODS = exposes(join: 1, mine: 0, line: 4, more: 3, undo: 1, clear: 1, picture: 2, view: 1, table: 0,
|
|
21
|
+
leave: 1)
|
|
22
|
+
object_doc "A shared drawing board (#{Table::WIDTH}x#{Table::HEIGHT}). join, then line / more to draw; " \
|
|
23
|
+
"the strokes go out on the board's strokes key."
|
|
24
|
+
docs join: [%w[name], "Joins the board as name (or gets this node's membership back). Returns " \
|
|
25
|
+
"{\"token\", \"owner\"}: every later call brings the token. The first to join " \
|
|
26
|
+
"is the owner, who may clear the board."],
|
|
27
|
+
line: [%w[token c w pts], "Starts a stroke: colour c 0-#{Table::COLORS - 1}, width step w " \
|
|
28
|
+
"0-#{Table::WIDTHS - 1}, pts [x, y, ...] (1-#{Table::PART_POINTS} points, " \
|
|
29
|
+
"x 0-#{Table::WIDTH - 1}, y 0-#{Table::HEIGHT - 1}). {\"id\" => id} or why not."],
|
|
30
|
+
more: [%w[token id pts], "More points of your stroke id (your newest one): up to " \
|
|
31
|
+
"#{Table::LINE_POINTS} points a stroke. \"ok\" or why not."],
|
|
32
|
+
undo: [%w[token], "Takes back your last stroke still on the board. \"ok\" or \"nothing\"."],
|
|
33
|
+
clear: [%w[token], "The owner clears the board. \"ok\" or \"not owner\"."],
|
|
34
|
+
picture: [%w[id at], "A page of the picture from stroke id (0: the first) and its point at: " \
|
|
35
|
+
"{\"v\", \"n\" strokes, \"p\" => [[id, c, w, [x, y, ...], k], ...], \"nx\" => " \
|
|
36
|
+
"[id, at] of the next page or nil}. k 1: the points start with the stroke's " \
|
|
37
|
+
"point before at."],
|
|
38
|
+
view: [%w[token], "The board: n strokes, np points, last the newest stroke's id, mb [name, flags " \
|
|
39
|
+
"(1 away, 2 owner)], lim [width, height, colours, widths, points a message, " \
|
|
40
|
+
"points a stroke, strokes, points], own (you are the owner), mine (your strokes)."],
|
|
41
|
+
table: [[], "The board's view without own and mine (what the state key has)."]
|
|
42
|
+
|
|
43
|
+
def line(token, c, w, pts, from: nil)
|
|
44
|
+
mark(token, from)
|
|
45
|
+
@game.line(token, c, w, pts)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def more(token, id, pts, from: nil)
|
|
49
|
+
mark(token, from)
|
|
50
|
+
@game.more(token, id, pts)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def undo(token, from: nil)
|
|
54
|
+
mark(token, from)
|
|
55
|
+
@game.undo(token)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clear(token, from: nil)
|
|
59
|
+
mark(token, from)
|
|
60
|
+
@game.clear(token)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def picture(id, at, from: nil)
|
|
64
|
+
@game.picture(id, at)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def view(token, from: nil)
|
|
68
|
+
mark(token, from)
|
|
69
|
+
@game.member_view(token, now) || { "error" => "token" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def table(from: nil)
|
|
73
|
+
@game.public_view(now)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../game"
|
|
4
|
+
|
|
5
|
+
module AsterismGames
|
|
6
|
+
module Board
|
|
7
|
+
# One drawing board (README "The drawing board"): a picture in board
|
|
8
|
+
# coordinates (WIDTH x HEIGHT) made of strokes, each a colour (one of
|
|
9
|
+
# COLORS), a width (one of WIDTHS steps) and a run of points. Nobody
|
|
10
|
+
# wins; every member draws. The first member to join is the owner (the
|
|
11
|
+
# next one in the order of joining when it goes), who alone may clear
|
|
12
|
+
# the board; each member may take back its own last stroke.
|
|
13
|
+
#
|
|
14
|
+
# Handing it out: every change goes out on the board's strokes key
|
|
15
|
+
# (asterism/<node>/<app>/<board>/strokes, Game#tell) as one message:
|
|
16
|
+
#
|
|
17
|
+
# {"o" => "a", "id", "c", "w", "p" => [x, y, ...], "k", "v"}
|
|
18
|
+
# points of stroke id: k 0 starts the stroke, k 1 goes on with it
|
|
19
|
+
# (p then starts with the stroke's last point so far, so every
|
|
20
|
+
# message draws as a line of its own); at most PART_POINTS points
|
|
21
|
+
# {"o" => "u", "id", "v"} stroke id taken back
|
|
22
|
+
# {"o" => "c", "v"} the board cleared
|
|
23
|
+
#
|
|
24
|
+
# v is the board's version when it was told. A screen draws these as
|
|
25
|
+
# they come; a recording of the key (the console's) played back puts
|
|
26
|
+
# the same messages again, and a screen draws them the same way (its
|
|
27
|
+
# picture may then differ from the saved one until it reads the
|
|
28
|
+
# picture again, picture). The same messages, so the same size limit:
|
|
29
|
+
# a message stays under the 4096 bytes a board receives (Zenoh's header
|
|
30
|
+
# included): PART_POINTS points of 3-byte coordinates are 1.5 KB.
|
|
31
|
+
#
|
|
32
|
+
# Limits: a stroke has at most LINE_POINTS points (a screen starts a
|
|
33
|
+
# new stroke past it); the board keeps at most MAX_STROKES strokes and
|
|
34
|
+
# MAX_POINTS points, and drops the oldest strokes past either (nothing
|
|
35
|
+
# is told: a screen that keeps the same limits drops them too, and
|
|
36
|
+
# they leave its picture when it is drawn again).
|
|
37
|
+
class Table < AsterismGames::Game
|
|
38
|
+
register "board"
|
|
39
|
+
tables "board1"
|
|
40
|
+
screens "/app/game/board/board.app.rb"
|
|
41
|
+
settings file: { "away_seconds" => ["away_ms", 1000] },
|
|
42
|
+
env: { "BOARD_AWAY_SECONDS" => ["away_ms", 1000] }
|
|
43
|
+
|
|
44
|
+
SAVE_FORMAT = 1
|
|
45
|
+
# Strokes come several times a second while somebody draws: the
|
|
46
|
+
# saved picture is written at most once a second (and when the host stops).
|
|
47
|
+
SAVE_HOLD_MS = 1000
|
|
48
|
+
WIDTH = 640
|
|
49
|
+
HEIGHT = 360
|
|
50
|
+
COLORS = 16
|
|
51
|
+
WIDTHS = 4
|
|
52
|
+
PART_POINTS = 256
|
|
53
|
+
LINE_POINTS = 1024
|
|
54
|
+
MAX_STROKES = 1000
|
|
55
|
+
MAX_POINTS = 40_000
|
|
56
|
+
# A page of picture: at most this many points.
|
|
57
|
+
PAGE_POINTS = 400
|
|
58
|
+
KEY = "strokes"
|
|
59
|
+
|
|
60
|
+
attr_reader :strokes, :points, :owner
|
|
61
|
+
|
|
62
|
+
def initialize(name, opts = {}, store: nil, log: nil)
|
|
63
|
+
super
|
|
64
|
+
@strokes = [] # [{"id", "node", "c", "w", "p" => [x, y, ...]}], oldest first
|
|
65
|
+
@points = 0
|
|
66
|
+
@next_id = 1
|
|
67
|
+
@owner = nil
|
|
68
|
+
@dropped = 0
|
|
69
|
+
load_saved(0)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------ members
|
|
73
|
+
|
|
74
|
+
def member_reply(node)
|
|
75
|
+
{ "token" => @members[node]["token"], "owner" => @owner == node }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def join(node, name, now)
|
|
79
|
+
r = super
|
|
80
|
+
if r["token"] && @owner.nil?
|
|
81
|
+
@owner = node.to_s
|
|
82
|
+
r["owner"] = true
|
|
83
|
+
end
|
|
84
|
+
r
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def forget(node)
|
|
88
|
+
super
|
|
89
|
+
pass_owner if @owner == node
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def pass_owner
|
|
93
|
+
@owner = @members.min_by { |_n, m| m["at"].to_i }&.first
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------ drawing
|
|
97
|
+
|
|
98
|
+
# A new stroke: colour c (0...COLORS), width w (0...WIDTHS), points
|
|
99
|
+
# [x, y, ...] (1..PART_POINTS points). Returns {"id" => id} or why not.
|
|
100
|
+
def line(token, c, w, pts)
|
|
101
|
+
node = node_of(token) or return "token"
|
|
102
|
+
c = int(c)
|
|
103
|
+
w = int(w)
|
|
104
|
+
return "colour" unless c && c >= 0 && c < COLORS
|
|
105
|
+
return "width" unless w && w >= 0 && w < WIDTHS
|
|
106
|
+
pts = clean_points(pts) or return "points"
|
|
107
|
+
s = { "id" => @next_id, "node" => node, "c" => c, "w" => w, "p" => pts }
|
|
108
|
+
@next_id += 1
|
|
109
|
+
@strokes << s
|
|
110
|
+
@points += pts.size / 2
|
|
111
|
+
changed
|
|
112
|
+
tell(KEY, { "o" => "a", "id" => s["id"], "c" => c, "w" => w, "p" => pts.dup, "k" => 0, "v" => @ver })
|
|
113
|
+
trim
|
|
114
|
+
{ "id" => s["id"] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# More points of the caller's stroke id (its newest stroke, still on
|
|
118
|
+
# the board, under LINE_POINTS points with these). "ok" or why not.
|
|
119
|
+
def more(token, id, pts)
|
|
120
|
+
node = node_of(token) or return "token"
|
|
121
|
+
s = @strokes.reverse_each.find { |x| x["node"] == node }
|
|
122
|
+
return "not yours" unless s && s["id"] == int(id)
|
|
123
|
+
pts = clean_points(pts) or return "points"
|
|
124
|
+
return "long" if s["p"].size / 2 + pts.size / 2 > LINE_POINTS
|
|
125
|
+
last = s["p"][-2, 2]
|
|
126
|
+
s["p"].concat(pts)
|
|
127
|
+
@points += pts.size / 2
|
|
128
|
+
changed
|
|
129
|
+
tell(KEY, { "o" => "a", "id" => s["id"], "c" => s["c"], "w" => s["w"], "p" => last + pts, "k" => 1, "v" => @ver })
|
|
130
|
+
trim
|
|
131
|
+
"ok"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Takes back the caller's last stroke still on the board. "ok" or why not.
|
|
135
|
+
def undo(token)
|
|
136
|
+
node = node_of(token) or return "token"
|
|
137
|
+
i = @strokes.rindex { |x| x["node"] == node } or return "nothing"
|
|
138
|
+
s = @strokes.delete_at(i)
|
|
139
|
+
@points -= s["p"].size / 2
|
|
140
|
+
changed
|
|
141
|
+
tell(KEY, { "o" => "u", "id" => s["id"], "v" => @ver })
|
|
142
|
+
"ok"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The owner clears the board. "ok" or why not.
|
|
146
|
+
def clear(token)
|
|
147
|
+
node = node_of(token) or return "token"
|
|
148
|
+
return "not owner" unless @owner == node
|
|
149
|
+
@strokes = []
|
|
150
|
+
@points = 0
|
|
151
|
+
changed
|
|
152
|
+
tell(KEY, { "o" => "c", "v" => @ver })
|
|
153
|
+
@log&.call("board: #{@name}: cleared")
|
|
154
|
+
"ok"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A page of the picture from stroke id (the first one at or after it)
|
|
158
|
+
# and its point at: {"v", "n", "p" => [[id, c, w, [x, y, ...], k], ...],
|
|
159
|
+
# "nx" => [id, at] for the next page, or nil}. k 1: the points start
|
|
160
|
+
# with the stroke's point before at (the page goes on with a stroke
|
|
161
|
+
# the last page began). At most PAGE_POINTS points a page.
|
|
162
|
+
def picture(id, at)
|
|
163
|
+
id = int(id) || 0
|
|
164
|
+
at = int(at) || 0
|
|
165
|
+
at = 0 if at.negative?
|
|
166
|
+
out = []
|
|
167
|
+
left = PAGE_POINTS
|
|
168
|
+
i = @strokes.index { |s| s["id"] >= id } || @strokes.size
|
|
169
|
+
at = 0 if i < @strokes.size && @strokes[i]["id"] != id
|
|
170
|
+
nx = nil
|
|
171
|
+
while i < @strokes.size
|
|
172
|
+
s = @strokes[i]
|
|
173
|
+
n = s["p"].size / 2
|
|
174
|
+
if left <= 0
|
|
175
|
+
nx = [s["id"], at]
|
|
176
|
+
break
|
|
177
|
+
end
|
|
178
|
+
take = [n - at, left].min
|
|
179
|
+
from = at.positive? ? at - 1 : 0
|
|
180
|
+
out << [s["id"], s["c"], s["w"], s["p"][from * 2, (at + take - from) * 2], at.positive? ? 1 : 0]
|
|
181
|
+
left -= take
|
|
182
|
+
if at + take < n
|
|
183
|
+
nx = [s["id"], at + take]
|
|
184
|
+
break
|
|
185
|
+
end
|
|
186
|
+
at = 0
|
|
187
|
+
i += 1
|
|
188
|
+
end
|
|
189
|
+
{ "v" => @ver, "n" => @strokes.size, "p" => out, "nx" => nx }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Past MAX_STROKES or MAX_POINTS: the oldest strokes go (the newest stays).
|
|
193
|
+
def trim
|
|
194
|
+
while @strokes.size > 1 && (@strokes.size > MAX_STROKES || @points > MAX_POINTS)
|
|
195
|
+
s = @strokes.shift
|
|
196
|
+
@points -= s["p"].size / 2
|
|
197
|
+
@dropped += 1
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def tick(now)
|
|
202
|
+
super
|
|
203
|
+
expire_members(now)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------ views
|
|
207
|
+
|
|
208
|
+
# Everybody's view (members, spectators, the state key):
|
|
209
|
+
# st "draw" n strokes np points
|
|
210
|
+
# last the newest stroke's id (0: none)
|
|
211
|
+
# mb [name, flags (1 away, 2 owner)] in the order they joined
|
|
212
|
+
# lim [WIDTH, HEIGHT, COLORS, WIDTHS, PART_POINTS, LINE_POINTS, MAX_STROKES, MAX_POINTS]
|
|
213
|
+
def public_view(now)
|
|
214
|
+
{ "v" => @ver, "t" => @name, "st" => "draw", "n" => @strokes.size, "np" => @points,
|
|
215
|
+
"last" => @strokes.empty? ? 0 : @strokes[-1]["id"], "mb" => member_list,
|
|
216
|
+
"lim" => [WIDTH, HEIGHT, COLORS, WIDTHS, PART_POINTS, LINE_POINTS, MAX_STROKES, MAX_POINTS] }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# A member's view: the public one, whether it is the owner and how
|
|
220
|
+
# many of the strokes are its own.
|
|
221
|
+
def member_view(token, now)
|
|
222
|
+
node = node_of(token) or return nil
|
|
223
|
+
public_view(now).merge("own" => @owner == node, "mine" => @strokes.count { |s| s["node"] == node })
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def member_list
|
|
227
|
+
@members.sort_by { |_n, m| m["at"].to_i }.map do |node, m|
|
|
228
|
+
[m["name"].to_s, (member_away?(node) ? 1 : 0) | (@owner == node ? 2 : 0)]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def summary(now)
|
|
233
|
+
away = @members.keys.count { |n| member_away?(n) }
|
|
234
|
+
{ "st" => "draw", "members" => @members.size, "humans" => @members.size - away, "cpus" => 0,
|
|
235
|
+
"away" => away, "strokes" => @strokes.size, "points" => @points }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------ saving
|
|
239
|
+
|
|
240
|
+
def to_saved(now)
|
|
241
|
+
super.merge("owner" => @owner, "next_id" => @next_id,
|
|
242
|
+
"strokes" => @strokes.map { |s| s.values_at("id", "node", "c", "w", "p") })
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def restore(data, now)
|
|
246
|
+
super
|
|
247
|
+
@owner = data["owner"]
|
|
248
|
+
@next_id = data["next_id"].to_i
|
|
249
|
+
@strokes = Array(data["strokes"]).filter_map do |id, node, c, w, p|
|
|
250
|
+
next unless id.is_a?(Integer) && p.is_a?(Array) && p.size >= 2 && p.size.even?
|
|
251
|
+
{ "id" => id, "node" => node.to_s, "c" => c.to_i, "w" => w.to_i, "p" => p }
|
|
252
|
+
end
|
|
253
|
+
@points = @strokes.sum { |s| s["p"].size / 2 }
|
|
254
|
+
@next_id = [@next_id, (@strokes.map { |s| s["id"] }.max || 0) + 1].max
|
|
255
|
+
@owner = nil unless @owner && @members.key?(@owner)
|
|
256
|
+
pass_owner unless @owner
|
|
257
|
+
@log&.call("board: #{@name}: resumed (#{@strokes.size} strokes, #{@points} points, #{@members.size} members)")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------------------ helpers
|
|
261
|
+
|
|
262
|
+
def int(x)
|
|
263
|
+
x.is_a?(Integer) ? x : nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# [x, y, ...] of integers on the board, 1..PART_POINTS points; nil when not.
|
|
267
|
+
def clean_points(pts)
|
|
268
|
+
return nil unless pts.is_a?(Array) && pts.size >= 2 && pts.size.even? && pts.size <= PART_POINTS * 2
|
|
269
|
+
i = 0
|
|
270
|
+
while i < pts.size
|
|
271
|
+
x = pts[i]
|
|
272
|
+
y = pts[i + 1]
|
|
273
|
+
return nil unless x.is_a?(Integer) && y.is_a?(Integer) && x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT
|
|
274
|
+
i += 2
|
|
275
|
+
end
|
|
276
|
+
pts.dup
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The shared drawing board (README "The drawing board") on the framework
|
|
4
|
+
# (asterism_games): one picture that every member draws on, handed out
|
|
5
|
+
# as strokes. Requiring this registers the game "board". It needs nothing
|
|
6
|
+
# but the framework.
|
|
7
|
+
require_relative "../asterism_games"
|
|
8
|
+
require_relative "board/table"
|
|
9
|
+
require_relative "board/remote"
|
|
10
|
+
|
|
11
|
+
AsterismGames::Board::Table.remote(AsterismGames::Board::Remote)
|