dopairb 0.1.0 → 0.4.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.
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Bonus art: famous public-domain paintings, painted procedurally at any
5
+ # size (no bundled images). Each painter maps (u, v) in 0..1 to an RGB.
6
+ module Gallery
7
+ Piece = Struct.new(:id, :title, :artist, :year, :aspect, :painter, keyword_init: true)
8
+
9
+ module Paint
10
+ module_function
11
+
12
+ def mix(a, b, t) = Color.mix(a, b, t)
13
+ def smooth(e0, e1, x) = (t = Fx.clamp01((x - e0) / (e1 - e0))) * t * (3 - 2 * t)
14
+ def dist(u, v, x, y, sx = 1.0) = Math.hypot((u - x) * sx, v - y)
15
+
16
+ # Cheap deterministic value noise in 0..1.
17
+ def noise(x, y)
18
+ n = Math.sin(x * 12.9898 + y * 78.233) * 43_758.5453
19
+ n - n.floor
20
+ end
21
+
22
+ def fbm(x, y)
23
+ x0 = x.floor
24
+ y0 = y.floor
25
+ fx = x - x0
26
+ fy = y - y0
27
+ a = noise(x0, y0)
28
+ b = noise(x0 + 1, y0)
29
+ c = noise(x0, y0 + 1)
30
+ d = noise(x0 + 1, y0 + 1)
31
+ sx = fx * fx * (3 - 2 * fx)
32
+ sy = fy * fy * (3 - 2 * fy)
33
+ (a + (b - a) * sx) + ((c + (d - c) * sx) - (a + (b - a) * sx)) * sy
34
+ end
35
+
36
+ # Smooth value noise at any scale (0..1): fbm(u * 8, v * 8) for blotches.
37
+ def vnoise(u, v, scale = 8.0) = fbm(u * scale, v * scale)
38
+
39
+ def hsv(h, s = 1.0, v = 1.0) = Color.hsv(h, s, v)
40
+
41
+ # 0xRRGGBB -> [r, g, b]
42
+ def rgb(hex) = [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]
43
+
44
+ # Gradient through evenly spaced colors, t in 0..1.
45
+ def ramp(t, *colors)
46
+ t = Fx.clamp01(t)
47
+ return colors.last if t >= 1
48
+ f = t * (colors.size - 1)
49
+ i = f.floor
50
+ mix(colors[i], colors[i + 1], f - i)
51
+ end
52
+
53
+ # < 1 inside the ellipse (0 at the center).
54
+ def ellipse(u, v, cx, cy, rx, ry) = ((u - cx) / rx)**2 + ((v - cy) / ry)**2
55
+
56
+ # Point in polygon; pts = [[u, v], ...].
57
+ def inside?(u, v, pts)
58
+ hit = false
59
+ j = pts.size - 1
60
+ pts.each_with_index do |(xi, yi), i|
61
+ xj, yj = pts[j]
62
+ hit = !hit if (yi > v) != (yj > v) && u < (xj - xi) * (v - yi) / (yj - yi + 1e-12) + xi
63
+ j = i
64
+ end
65
+ hit
66
+ end
67
+
68
+ # Brush-stroke texture in -1..1 along angle (radians), freq strokes per unit.
69
+ def brush(u, v, angle: 0.0, freq: 40.0, wobble: 0.3)
70
+ x = u * Math.cos(angle) + v * Math.sin(angle)
71
+ y = -u * Math.sin(angle) + v * Math.cos(angle)
72
+ Math.sin((y + wobble * Math.sin(x * 9 + y * 3) / freq * 6) * freq * Math::PI)
73
+ end
74
+
75
+ # A color with a little painterly variation.
76
+ def jitter(color, u, v, amount = 18, scale = 30.0)
77
+ k = (vnoise(u, v, scale) - 0.5) * 2 * amount
78
+ color.map { |c| (c + k).round.clamp(0, 255) }
79
+ end
80
+
81
+ # Hokusai, Fine Wind, Clear Morning ("Red Fuji")
82
+ def red_fuji(u, v)
83
+ peak_u = 0.58
84
+ peak_v = 0.17
85
+ # concave slopes
86
+ left = peak_v + (peak_u - u) * 1.05 + (peak_u - u)**2 * -0.5
87
+ right = peak_v + (u - peak_u) * 0.95 + (u - peak_u)**2 * -0.3
88
+ slope = u < peak_u ? left : right
89
+ if v > 0.86
90
+ return mix([20, 60, 50], [10, 30, 30], (v - 0.86) / 0.14) if noise((u * 60).floor, (v * 40).floor) < 0.8
91
+ return [40, 90, 60]
92
+ end
93
+ if v >= slope
94
+ depth = (v - peak_v) / 0.7
95
+ if v > 0.74 + 0.03 * Math.sin(u * 30)
96
+ tree = noise((u * 70).floor, (v * 50).floor) > 0.55
97
+ return tree ? [35, 95, 60] : [25, 70, 50]
98
+ end
99
+ snow = v < peak_v + 0.13 + 0.05 * Math.sin(u * 55) * (v - peak_v) * 8
100
+ return mix([250, 250, 245], [210, 215, 230], depth) if snow && Math.sin(u * 90) > -0.6
101
+ base = mix([150, 40, 20], [205, 75, 35], depth * 1.3)
102
+ streak = Math.sin(u * 140 + v * 10) > 0.75 ? 0.8 : 1.0
103
+ return Color.scale(base, streak)
104
+ end
105
+ sky = mix([40, 80, 160], [175, 205, 230], smooth(0.0, 0.7, v))
106
+ # mackerel clouds: rows of puffs low in the sky
107
+ if v > 0.42
108
+ row = ((v - 0.42) * 22)
109
+ puff = Math.sin(u * 48 + row.floor * 1.7) * 0.5 + 0.5
110
+ frac = row - row.floor
111
+ return mix([255, 255, 250], sky, 0.25) if puff > 0.45 && frac.between?(0.15, 0.7)
112
+ end
113
+ sky
114
+ end
115
+
116
+ # Hokusai, The Great Wave off Kanagawa
117
+ def great_wave(u, v)
118
+ sky = mix([205, 195, 160], [235, 225, 195], v)
119
+ # Mount Fuji, small in the trough
120
+ fu = 0.64
121
+ fv = 0.58
122
+ if v > fv && v < 0.70 && (u - fu).abs < (v - fv) * 1.6
123
+ return v < fv + 0.035 ? [245, 245, 240] : [70, 80, 110]
124
+ end
125
+ # the big wave: a crescent between two circles, curling to the right
126
+ ox = (u - 0.36) * 1.2
127
+ oy = v - 0.62
128
+ outer = Math.hypot(ox, oy)
129
+ inner = Math.hypot((u - 0.56) * 1.2, v - 0.66)
130
+ if outer < 0.50 && inner > 0.30 && v < 0.80 && u < 0.78
131
+ edge = 0.50 - outer
132
+ ang = Math.atan2(oy, ox)
133
+ claws = Math.sin(ang * 50) > 0.1
134
+ return [250, 250, 245] if edge < 0.05 && (claws || edge < 0.02)
135
+ lip = inner - 0.30
136
+ return [250, 250, 245] if lip < 0.025 && v < 0.5
137
+ stripe = Math.sin(outer * 110 + inner * 40) > 0.6
138
+ base = mix([90, 135, 190], [15, 40, 105], Fx.clamp01(edge * 4))
139
+ return stripe ? mix(base, [235, 240, 245], 0.7) : base
140
+ end
141
+ # foam spray dots falling from the crest
142
+ if dist(u, v, 0.80, 0.22, 1.25) < 0.09 && noise((u * 80).floor, (v * 60).floor) > 0.72
143
+ return [250, 250, 245]
144
+ end
145
+ sea_v = 0.70 + 0.04 * Math.sin(u * 14)
146
+ if v > sea_v
147
+ stripe = Math.sin(v * 160 + Math.sin(u * 25) * 4) > 0.7
148
+ base = mix([40, 80, 140], [20, 45, 100], v)
149
+ # boats
150
+ [[0.30, 0.78], [0.62, 0.86]].each do |bx, by|
151
+ return [215, 195, 140] if ((u - bx) / 0.13)**2 + ((v - by) / 0.025)**2 < 1
152
+ end
153
+ # small wave at right
154
+ return [250, 250, 245] if dist(u, v, 0.86, 0.74, 1.4) < 0.10 && dist(u, v, 0.86, 0.74, 1.4) > 0.08
155
+ return stripe ? [235, 240, 245] : base
156
+ end
157
+ sky
158
+ end
159
+
160
+ # van Gogh, The Starry Night
161
+ def starry_night(u, v)
162
+ # cypress, a dark flame on the left
163
+ cyp = 0.17 + 0.09 * (v - 0.05) * 1.1 * Math.sin(v * 3) + 0.03 * Math.sin(v * 25)
164
+ width = (v - 0.03) * 0.13
165
+ if v > 0.03 && (u - cyp).abs < width
166
+ return Math.sin(v * 70 + u * 30) > 0.3 ? [45, 70, 40] : [20, 30, 25]
167
+ end
168
+ hill = 0.70 + 0.06 * Math.sin(u * 5 + 1) + 0.03 * Math.sin(u * 13)
169
+ if v > hill
170
+ # village: houses with lit windows and a church spire
171
+ return [30, 40, 70] if (u - 0.55).abs < 0.008 && v > hill - 0.18
172
+ if v > hill + 0.07 && v < 0.93
173
+ cell_x = (u * 40).floor
174
+ cell_y = (v * 30).floor
175
+ return [240, 210, 90] if noise(cell_x, cell_y) > 0.82
176
+ return [55, 70, 105] if noise(cell_x + 3, cell_y) > 0.45
177
+ end
178
+ return Math.sin(u * 40 + v * 20) > 0.5 ? [35, 60, 100] : [25, 45, 80]
179
+ end
180
+ # moon
181
+ dm = dist(u, v, 0.87, 0.13, 1.3)
182
+ return [255, 235, 120] if dm < 0.05
183
+ return [240, 200, 60] if dm < 0.075 && Math.sin(dm * 300) > 0
184
+ # stars with halos
185
+ [[0.10, 0.08], [0.30, 0.15], [0.48, 0.07], [0.66, 0.22], [0.78, 0.40], [0.40, 0.45], [0.95, 0.52], [0.22, 0.38]].each do |sx, sy|
186
+ d = dist(u, v, sx, sy, 1.3)
187
+ return [255, 250, 200] if d < 0.015
188
+ return [235, 215, 90] if d < 0.035
189
+ return [170, 190, 210] if d < 0.05 && Math.sin(d * 400) > 0.2
190
+ end
191
+ # the great swirl
192
+ sx = u - 0.48
193
+ sy = (v - 0.30) * 1.6
194
+ r = Math.hypot(sx, sy)
195
+ a = Math.atan2(sy, sx)
196
+ swirl = Math.sin(r * 55 - a * 2 + Math.sin(u * 7) * 2)
197
+ if r < 0.22 && swirl > 0.4
198
+ return mix([230, 225, 170], [120, 160, 210], r / 0.22)
199
+ end
200
+ stroke = Math.sin(v * 60 + Math.sin(u * 9 + v * 4) * 5)
201
+ base = mix([30, 60, 140], [70, 110, 180], smooth(0.0, 0.7, v))
202
+ stroke > 0.6 ? mix(base, [150, 180, 220], 0.45) : base
203
+ end
204
+
205
+ # Leonardo da Vinci, Mona Lisa
206
+ def mona_lisa(u, v)
207
+ # dark frame
208
+ land = mix([150, 160, 120], [90, 100, 60], v)
209
+ land = mix(land, [70, 90, 80], 0.4) if Math.sin(u * 20 + v * 9) > 0.6
210
+ fx = 0.50
211
+ fy = 0.30
212
+ face = ((u - fx) / 0.13)**2 + ((v - fy) / 0.15)**2
213
+ hair = ((u - fx) / 0.20)**2 + ((v - fy - 0.04) / 0.22)**2
214
+ if face < 1
215
+ skin = mix([235, 190, 120], [180, 130, 70], Fx.clamp01((u - fx + 0.13) / 0.35) * 0.6 + face * 0.3)
216
+ return [60, 40, 25] if (v - (fy - 0.02)).abs < 0.012 && ((u - fx).abs - 0.055).abs < 0.025 # eyes
217
+ return [150, 80, 50] if (v - (fy + 0.085 + 0.25 * (u - fx)**2)).abs < 0.006 && (u - fx).abs < 0.045 # the smile
218
+ return mix(skin, [150, 100, 55], 0.4) if (u - fx).abs < 0.008 && v > fy && v < fy + 0.05 # nose
219
+ return skin
220
+ end
221
+ return [50, 35, 20] if hair < 1 && v < 0.55
222
+ # neck and chest
223
+ if v > 0.43 && v < 0.62 && (u - fx).abs < 0.05 + (v - 0.43) * 0.8
224
+ return mix([225, 180, 115], [190, 140, 80], (v - 0.43) * 4)
225
+ end
226
+ # dress
227
+ if v > 0.45 && (u - fx).abs < 0.12 + (v - 0.45) * 1.1
228
+ # crossed hands
229
+ return mix([235, 190, 125], [195, 145, 85], ((u - 0.5).abs) * 4) if ((u - 0.5) / 0.20)**2 + ((v - 0.88) / 0.06)**2 < 1
230
+ return Math.sin(v * 90 + u * 10) > 0.8 ? [95, 70, 30] : [55, 40, 20]
231
+ end
232
+ land
233
+ end
234
+
235
+ # Munch, The Scream
236
+ def the_scream(u, v)
237
+ # the figure
238
+ hx = 0.48
239
+ hy = 0.62
240
+ head = ((u - hx) / 0.08)**2 + ((v - hy) / 0.10)**2
241
+ if head < 1
242
+ return [40, 30, 30] if ((u - hx).abs - 0.03).abs < 0.015 && (v - hy + 0.02).abs < 0.02 # eyes
243
+ return [40, 30, 30] if ((u - hx) / 0.015)**2 + ((v - hy - 0.05) / 0.03)**2 < 1 # mouth
244
+ return [215, 205, 150]
245
+ end
246
+ # hands on cheeks
247
+ return [200, 190, 140] if [[hx - 0.085, hy + 0.01], [hx + 0.085, hy + 0.01]].any? { |x, y| ((u - x) / 0.02)**2 + ((v - y) / 0.06)**2 < 1 }
248
+ body_w = 0.07 + (v - hy) * 0.35 + 0.02 * Math.sin(v * 30)
249
+ return Math.sin(v * 40 + u * 15) > 0.3 ? [45, 50, 70] : [25, 25, 40] if v > hy + 0.08 && (u - hx - (v - hy) * 0.1).abs < body_w
250
+ # the bridge railing, from the left down to the bottom right
251
+ rail = 0.48 + (u * 0.55)
252
+ if v > rail - 0.015 && v < rail + 0.015
253
+ return [130, 70, 40]
254
+ end
255
+ if v > rail
256
+ return Math.sin((u + v) * 70) > 0.4 ? [190, 120, 70] : [160, 95, 55] # deck
257
+ end
258
+ # fjord and shore
259
+ shore = 0.42 + 0.06 * Math.sin(u * 7)
260
+ if v > shore
261
+ return u < 0.5 + 0.1 * Math.sin(v * 10) ? mix([40, 60, 120], [70, 90, 130], Math.sin(v * 60 + u * 12) * 0.5 + 0.5) : mix([80, 100, 60], [140, 110, 60], Math.sin(u * 30 + v * 20) * 0.5 + 0.5)
262
+ end
263
+ # the blood-red sky
264
+ wave = Math.sin(v * 38 + Math.sin(u * 6) * 2.5)
265
+ if wave > 0.4 then [230, 90, 40]
266
+ elsif wave > -0.2 then [245, 160, 60]
267
+ elsif wave > -0.7 then [220, 190, 80]
268
+ else [190, 60, 40]
269
+ end
270
+ end
271
+ end
272
+
273
+ # Filled by Gallery.piece as the files below are loaded.
274
+ PIECES = []
275
+
276
+ module_function
277
+
278
+ # Register a painting. The block gets (u, v) and runs with Paint's helpers.
279
+ # piece :the_kiss, "The Kiss", "Gustav Klimt", "1908", aspect: 1.0 do |u, v| ... end
280
+ def piece(id, title, artist, year, aspect:, &painter)
281
+ raise ArgumentError, "duplicate piece #{id}" if PIECES.any? { |p| p.id == id }
282
+ PIECES << Piece.new(id: id, title: title, artist: artist, year: year, aspect: aspect, painter: painter)
283
+ end
284
+
285
+ piece(:great_wave, "The Great Wave off Kanagawa", "Katsushika Hokusai", "c. 1831", aspect: 1.5) { |u, v| great_wave(u, v) }
286
+ piece(:red_fuji, "Fine Wind, Clear Morning", "Katsushika Hokusai", "c. 1831", aspect: 1.45) { |u, v| red_fuji(u, v) }
287
+ piece(:starry_night, "The Starry Night", "Vincent van Gogh", "1889", aspect: 1.26) { |u, v| starry_night(u, v) }
288
+ piece(:mona_lisa, "Mona Lisa", "Leonardo da Vinci", "c. 1503", aspect: 0.67) { |u, v| mona_lisa(u, v) }
289
+ piece(:the_scream, "The Scream", "Edvard Munch", "1893", aspect: 0.8) { |u, v| the_scream(u, v) }
290
+
291
+ def find(id) = PIECES.find { |p| p.id == id.to_sym }
292
+ def ids = PIECES.map(&:id)
293
+
294
+ # Pixel grid (rows of RGB) of w x h pixels.
295
+ def pixels(piece, w, h)
296
+ @cache ||= {}
297
+ @cache[[piece.id, w, h]] ||= Array.new(h) do |y|
298
+ Array.new(w) { |x| Paint.instance_exec((x + 0.5) / w, (y + 0.5) / h, &piece.painter) }
299
+ end
300
+ end
301
+
302
+ # Largest size that fits in cols x rows of cells (two pixels per cell, vertically).
303
+ # => [pixel width, pixel height]
304
+ def fit(piece, cols, rows)
305
+ h = rows * 2
306
+ w = (h * piece.aspect).round
307
+ if w > cols
308
+ w = cols
309
+ h = (w / piece.aspect).round
310
+ end
311
+ [w, h - h % 2]
312
+ end
313
+
314
+ # Pick a piece for a reward: one not yet collected if possible.
315
+ def pick(rng, owned = [])
316
+ fresh = PIECES.reject { |p| owned.include?(p.id) }
317
+ (fresh.empty? ? PIECES : fresh).sample(random: rng)
318
+ end
319
+
320
+ LUMA = " .:-=+*#%@"
321
+
322
+ # Draw the piece into a canvas at (x, y). reveal: 0..1 rows shown top-down
323
+ # (a curtain); depth :none falls back to luminance ASCII.
324
+ def draw(canvas, piece, x, y, w, h, reveal: 1.0, depth: :truecolor, &tweak)
325
+ px = pixels(piece, w, h)
326
+ rows = h / 2
327
+ shown = (rows * reveal).ceil
328
+ half = Term.glyph("▀", nil)
329
+ rows.times do |r|
330
+ break if r >= shown
331
+ w.times do |c|
332
+ top = px[r * 2][c]
333
+ bot = px[r * 2 + 1][c]
334
+ top, bot = tweak.(top, bot, c, r) if tweak
335
+ if depth == :none
336
+ l = Color.luminance(Color.mix(top, bot, 0.5))
337
+ canvas.put(x + c, y + r, LUMA[(l / 256.0 * LUMA.size).floor.clamp(0, LUMA.size - 1)])
338
+ elsif half
339
+ canvas.put(x + c, y + r, half, top, bg: bot)
340
+ else
341
+ canvas.put(x + c, y + r, " ", nil, bg: Color.mix(top, bot, 0.5))
342
+ end
343
+ end
344
+ end
345
+ end
346
+ end
347
+ end
348
+
349
+ Dir[File.join(__dir__, "gallery", "*.rb")].sort.each { |f| require f }
data/lib/dopairb/game.rb CHANGED
@@ -9,17 +9,38 @@ module Dopairb
9
9
  COMBO_MILESTONES = [3, 5, 10, 15, 20, 30, 50, 75, 100].freeze
10
10
  COMBO_MEGA = [10, 25, 50, 100].freeze
11
11
  EVAL_MILESTONES = [10, 50, 100, 500, 1000, 5000, 10_000].freeze
12
+ # Typing streaks pay out at powers of two: 8, 16, 32, ... 1024.
13
+ TYPING_MILESTONES = (3..10).map { |n| 2**n }.freeze
14
+ FEVER_COMBO = 10
15
+ # Variable rewards: any success may crit (more likely with a full CHARGE).
16
+ CRIT_CHANCE = 0.10
17
+ CRIT_CHARGE_BONUS = 0.12
18
+ CRIT_MULTS = [2, 2, 2, 4, 4, 8].freeze
19
+ JACKPOT_CHANCE = 0.015
20
+ JACKPOT_MULT = 16
21
+ # Bonus art can also just drop (careers only: the gallery lives in the profile).
22
+ ART_DROP_CHANCE = 0.02
12
23
 
13
- Event = Struct.new(:kind, :tier, :info, :flags, :gain, :combo, :streak, :score, keyword_init: true) do
24
+ Event = Struct.new(:kind, :tier, :info, :flags, :gain, :combo, :streak, :score, :mult, :level, keyword_init: true) do
14
25
  def flag?(f) = flags.include?(f)
15
26
  end
16
27
 
17
28
  attr_reader :score, :combo, :max_combo, :evals, :successes, :failures, :interrupts,
18
29
  :charge, :error_streak, :record, :typing_combo, :started_at, :last_key_at,
19
- :keystrokes, :best_typing
30
+ :keystrokes, :best_typing, :comebacks, :crits, :jackpots, :level, :xp_base, :best_combo_ever
20
31
 
21
- def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
32
+ # XP needed for a level doubles each time: LV2 at 512, LV3 at 1024, ...
33
+ def self.xp_for(level) = level <= 1 ? 0 : 2**(level + 7)
34
+
35
+ def self.level_for(xp)
36
+ lv = 1
37
+ lv += 1 while xp >= xp_for(lv + 1)
38
+ lv
39
+ end
40
+
41
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, rng: Random.new)
22
42
  @clock = clock
43
+ @rng = rng
23
44
  @started_at = now
24
45
  @score = 0
25
46
  @combo = 0
@@ -38,8 +59,28 @@ module Dopairb
38
59
  @same_char = 0
39
60
  @typing_points = 0
40
61
  @keystrokes = 0
62
+ @comebacks = 0
63
+ @crits = 0
64
+ @jackpots = 0
65
+ @xp_base = 0
66
+ @level = 1
67
+ @best_combo_ever = nil
68
+ @leveling = false
69
+ @streaks_paid = []
41
70
  end
42
71
 
72
+ # Career so far (from Profile): lifetime XP before this session and the best combo ever.
73
+ def career(xp:, best_combo:)
74
+ @leveling = true
75
+ @xp_base = xp.to_i
76
+ @best_combo_ever = best_combo
77
+ @level = Game.level_for(@xp_base + @score)
78
+ end
79
+
80
+ def xp = @xp_base + @score
81
+ def fever? = @combo >= FEVER_COMBO
82
+ def leveling? = @leveling
83
+
43
84
  def now = @clock.call
44
85
 
45
86
  def multiplier(t = nil)
@@ -55,7 +96,8 @@ module Dopairb
55
96
  end
56
97
 
57
98
  # kind: :insert, :delete, :move, :bracket, :string, :block, :complete, :history, :newline, :paste
58
- # Returns :resume when typing restarts after a pause, else nil.
99
+ # Returns :resume when typing restarts after a pause, [:streak, n] at a
100
+ # power-of-two typing streak, else nil.
59
101
  def key(kind, char = nil)
60
102
  t = now
61
103
  resumed = @last_key_at && t - @last_key_at > 2.0 && kind == :insert
@@ -65,6 +107,7 @@ module Dopairb
65
107
  @typing_combo = kind == :move ? 0 : 1
66
108
  end
67
109
  @best_typing = @typing_combo if @typing_combo > @best_typing
110
+ milestone = kind == :insert && TYPING_MILESTONES.include?(@typing_combo)
68
111
  @last_key_at = t
69
112
  @keystrokes += 1 unless kind == :paste
70
113
 
@@ -98,7 +141,14 @@ module Dopairb
98
141
  @typing_points += p
99
142
  @score += p
100
143
  end
101
- resumed ? :resume : nil
144
+ return :resume if resumed
145
+ return nil unless milestone
146
+ # outside the per-eval typing cap, but each streak pays once per eval
147
+ unless @streaks_paid.include?(@typing_combo)
148
+ @streaks_paid << @typing_combo
149
+ @score += @typing_combo * 4
150
+ end
151
+ [:streak, @typing_combo]
102
152
  end
103
153
 
104
154
  def success(info, code: "", out_lines: 0)
@@ -106,7 +156,10 @@ module Dopairb
106
156
  @successes += 1
107
157
  flags = []
108
158
  flags << :first_hit if @successes == 1
109
- flags << :comeback if @error_streak > 0
159
+ if @error_streak > 0
160
+ flags << :comeback
161
+ @comebacks += 1
162
+ end
110
163
  streak = @error_streak
111
164
  @error_streak = 0
112
165
  @combo += 1
@@ -115,6 +168,11 @@ module Dopairb
115
168
  flags << :combo_mega if COMBO_MEGA.include?(@combo)
116
169
  flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
117
170
  flags << :output_rain if out_lines >= 20
171
+ flags << :fever if fever?
172
+ flags << :fever_start if @combo == FEVER_COMBO
173
+ if @best_combo_ever && @best_combo_ever >= 5 && @combo == @best_combo_ever + 1
174
+ flags << :combo_best
175
+ end
118
176
 
119
177
  if (num = numeric(info))
120
178
  if @record && num > @record && num >= 100
@@ -130,12 +188,32 @@ module Dopairb
130
188
  gain += 300 if flags.include?(:new_record)
131
189
  gain += 1000 if flags.include?(:eval_milestone)
132
190
  gain += @combo * 20 if flags.include?(:combo_milestone)
191
+ mult = 1
192
+ roll = @rng.rand
193
+ if roll < JACKPOT_CHANCE
194
+ flags << :jackpot
195
+ mult = JACKPOT_MULT
196
+ @jackpots += 1
197
+ elsif roll < CRIT_CHANCE + CRIT_CHARGE_BONUS * @charge / CHARGE_MAX
198
+ flags << :critical
199
+ mult = CRIT_MULTS.sample(random: @rng)
200
+ @crits += 1
201
+ end
202
+ flags << :art_drop if @leveling && @rng.rand < ART_DROP_CHANCE
203
+ mult *= 2 if fever?
204
+ gain *= mult
133
205
  @score += gain
134
206
  @charge = 0.0
135
207
  @typing_points = 0
208
+ @streaks_paid.clear
209
+ lv = @leveling ? Game.level_for(xp) : @level
210
+ if lv > @level
211
+ flags << :level_up
212
+ @level = lv
213
+ end
136
214
 
137
215
  Event.new(kind: info[:type] == :definition ? :definition : :success, tier: nil, info: info, flags: flags,
138
- gain: gain, combo: @combo, streak: streak, score: @score)
216
+ gain: gain, combo: @combo, streak: streak, score: @score, mult: mult, level: @level)
139
217
  end
140
218
 
141
219
  def failure(info)
@@ -146,8 +224,10 @@ module Dopairb
146
224
  @combo = 0
147
225
  @charge = 0.0
148
226
  @typing_points = 0
227
+ @streaks_paid.clear
149
228
  flags = []
150
229
  flags << :combo_break if broken >= 3
230
+ flags << :fever_end if broken >= FEVER_COMBO
151
231
  flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
152
232
  Event.new(kind: :failure, tier: nil, info: info.merge(broken_combo: broken), flags: flags, gain: 0,
153
233
  combo: 0, streak: @error_streak, score: @score)
@@ -158,6 +238,7 @@ module Dopairb
158
238
  lost = @charge
159
239
  @charge = 0.0
160
240
  @typing_points = 0
241
+ @streaks_paid.clear
161
242
  Event.new(kind: :interrupt, tier: nil, info: { charge: lost }, flags: [], gain: 0, combo: @combo,
162
243
  streak: @error_streak, score: @score)
163
244
  end
@@ -166,8 +247,8 @@ module Dopairb
166
247
 
167
248
  def stats
168
249
  { evals: @evals, successes: @successes, failures: @failures, interrupts: @interrupts,
169
- max_combo: @max_combo, score: @score, keystrokes: @keystrokes, best_typing: @best_typing,
170
- time: elapsed }
250
+ max_combo: @max_combo, score: @score, comebacks: @comebacks, keystrokes: @keystrokes, best_typing: @best_typing,
251
+ crits: @crits, jackpots: @jackpots, time: elapsed }
171
252
  end
172
253
 
173
254
  private