/ spin.lua
spin.lua
  1  --[[
  2      ToasterGen Spin
  3  
  4      Copyright (C) 2025 Clifton Toaster Reid <cliftontreid@duck.com>
  5  
  6      This library is free software: you can redistribute it and/or modify
  7      it under the terms of the GNU Lesser General Public License as published by
  8      the Free Software Foundation, either version 3 of the License, or
  9      (at your option) any later version.
 10  
 11      This library is distributed in the hope that it will be useful,
 12      but WITHOUT ANY WARRANTY; without even the implied warranty of
 13      MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 14      GNU Lesser General Public License for more details.
 15  
 16      You should have received a copy of the GNU Lesser General Public License
 17      along with this library. If not, see <https://www.gnu.org/licenses/>.
 18  ]]
 19  
 20  ---@class Reward
 21  ---@field numeric number The number with which to multiply the bet to get the payout
 22  ---@field dozen number The number with which to multiply the bet for winning the 1st 12, 2nd 12, and 3rd 12 options
 23  ---@field binary number The number with which to multiply the bet for winning the 1-18, 19-36, EVEN, and ODD options
 24  ---@field colour number The number with which to multiply the bet for winning the RED and BLACK options
 25  
 26  ---@class DeviceConfig
 27  ---@field carpet string The device that is used as carpet
 28  ---@field ring string The device that is used as ring
 29  ---@field modem string The device that is used as modem
 30  ---@field redstone string The device that is used as redstone
 31  ---@field ivmanagers table<number, string> The devices that are used as inventory managers
 32  ---@field ivmanBigger number The streigth of the signal for the lowest inventory manager
 33  
 34  ---@class DebugConfig
 35  ---@field loki string | nil The URL of the Loki server to send logs to
 36  ---@field tempo string | nil The URL of the Tempo server to send traces to
 37  
 38  ---@type number | nil
 39  local LokiTimer = nil
 40  local LokiSleepyTime = 5
 41  ---@type number | nil
 42  local TempoTimer = nil
 43  local TempoSleepyTime = 2
 44  
 45  ---@class ClientConfig
 46  ---@field version string The version of the config file schema using Semantic Versioning
 47  ---@field rewards Reward The rewards that can be given to the player
 48  ---@field devices DeviceConfig The devices that are used in the game
 49  ---@field debug DebugConfig The debug configuration
 50  
 51  local toml = require("src.toml")
 52  
 53  ---@type ClientConfig
 54  local config
 55  local currentBetter = -1
 56  
 57  local ucolors = {
 58      -- Cannot be red, black or green
 59      colors.orange,
 60      colors.yellow,
 61      colors.pink,
 62      colors.cyan,
 63      colors.blue,
 64      colors.purple,
 65      colors.white,
 66      colors.lime,
 67  }
 68  
 69  ---@return ClientConfig
 70  local function loadConfig()
 71      local file = fs.open("/config.toml", "r")
 72      local cfg = toml.parse(file.readAll())
 73  
 74      file.close()
 75      return cfg
 76  end
 77  
 78  local function defaultConfig()
 79      ---@type ClientConfig
 80      local c = {
 81          version = "0.1.0",
 82          rewards = {
 83              numeric = 2,
 84              dozen = 1.75,
 85              binary = 1.35,
 86              colour = 1.35,
 87          },
 88          devices = {
 89              carpet = "monitor_0",
 90              ring = "monitor_1",
 91              redstone = "back",
 92              ivmanagers = { "playerDetector_1" },
 93              modem = "top",
 94              ivmanBigger = 1,
 95          },
 96          debug = {
 97              loki = nil,
 98              tempo = nil,
 99          },
100      }
101  
102      return c
103  end
104  
105  local function saveConfig(config)
106      local file = fs.open("/config.toml", "w")
107      file.write(toml.encode(config))
108      file.close()
109  end
110  
111  local function isConfig()
112      return fs.exists("config.toml")
113  end
114  
115  --- Write the bets and the winning number to a file
116  ---@param nbr number The winning number
117  ---@param bets Bet[] The bets that have been placed
118  local function emergencyWrite(nbr, bets)
119      -- write all bets to a json file '/bets.json' and the winning number to '/win'
120  
121      local file = fs.open("/bets.json", "w")
122      file.write(textutils.serialize(bets))
123      file.close()
124      file = fs.open("/win", "w")
125      file.write(textutils.serialize(nbr))
126      file.close()
127  end
128  
129  ---@param bet Bet The bet to check
130  ---@param nbr number The winning number
131  local function getPayout(bet, nbr)
132      -- handle numeric bets
133      if bet.number <= 36 and bet.number >= 0 then
134          if bet.number == nbr then
135              return bet.amount * config.rewards.numeric
136          end
137      end
138  
139      -- handle dozen bets
140      if bet.number == 51 then
141          if nbr >= 1 and nbr <= 12 then
142              return bet.amount * config.rewards.dozen
143          end
144      end
145      if bet.number == 52 then
146          if nbr >= 13 and nbr <= 24 then
147              return bet.amount * config.rewards.dozen
148          end
149      end
150      if bet.number == 53 then
151          if nbr >= 25 and nbr <= 36 then
152              return bet.amount * config.rewards.dozen
153          end
154      end
155  
156      -- handle binary bets, 54 55 58 59
157      if bet.number == 54 then -- 1 to 18
158          if nbr >= 1 and nbr <= 18 then
159              return bet.amount * config.rewards.binary
160          end
161      end
162      if bet.number == 55 or bet.number == 56 then -- even / red
163          if nbr % 2 == 0 then
164              return bet.amount * config.rewards.binary
165          end
166      end
167      if bet.number == 58 or bet.number == 57 then -- odd / black
168          if nbr % 2 == 1 then
169              return bet.amount * config.rewards.binary
170          end
171      end
172      if bet.number == 59 then -- 19 to 36
173          if nbr >= 19 and nbr <= 36 then
174              return bet.amount * config.rewards.binary
175          end
176      end
177  
178      return nil
179  end
180  
181  local function mainLoop()
182      -- Load modules once outside the loop
183      local carpet = require("src.carpet")
184      local ring = require("src.ring")
185      local Logger = require("src.log")
186      local Tracer = require("src.trace")
187      local iv = require("src.inventory")
188  
189      if config.debug then
190          if config.debug.loki then
191              Logger.setLoki(config.debug.loki)
192          end
193          if config.debug.tempo then
194              Tracer.setTempo(config.debug.tempo)
195          end
196      end
197  
198      -- Initialize devices
199      iv.init(config.devices.ivmanagers)
200      ring.init(config.devices.ring)
201      carpet.init(config.devices.carpet)
202  
203      -- Handle redstone signal events
204      local function handleRedstoneEvent()
205          local tr = Tracer.new()
206          tr:setName("event.redstone")
207          tr:addTag("device", config.devices.redstone)
208  
209          local redStreingth = redstone.getAnalogInput(config.devices.redstone)
210          local id = redStreingth - config.devices.ivmanBigger
211          if id < 0 then
212              Logger.error("Redstone signal too low")
213              tr:addAnnotation("low signal")
214              Tracer.addSpan(tr:endSpan())
215              return
216          end
217          -- Store the player id in currentBetter
218          currentBetter = id
219          Logger.info("Player " .. id .. " clicked on the button")
220  
221          Tracer.addSpan(tr:endSpan())
222      end
223  
224      -- Handle carpet monitor touch events
225      local function handleCarpetTouch(x, y)
226          local tr = Tracer.new()
227          tr:setName("event.monitor_touch")
228          tr:addTag("device", config.devices.carpet)
229          tr:addTag("action", "bet")
230          tr:addTag("x", x)
231          tr:addTag("y", y)
232          tr:addTag("player", tostring(currentBetter))
233  
234          if currentBetter == -1 then
235              Logger.error("No player detected")
236              return
237          end
238  
239          local ballance = iv.getMoneyInPlayer(currentBetter)
240          if ballance == nil or ballance <= 0 then
241              Logger.error("Player " .. tostring(currentBetter) .. " has no money")
242              Logger.debug("Player is " .. (iv.getPlayer(currentBetter) or ""))
243              tr:addAnnotation("no money")
244              Tracer.addSpan(tr:endSpan())
245              return
246          end
247  
248          local nbr = carpet.findClickedNumber(x, y)
249          if nbr == nil then
250              Logger.error("No number clicked")
251              tr:addAnnotation("no number clicked")
252              Tracer.addSpan(tr:endSpan())
253              return
254          end
255  
256          local res = iv.takeMoneyFromPlayer(currentBetter, 1)
257          if res == nil then
258              Logger.error("Error taking money from player " .. tostring(currentBetter))
259              tr:addAnnotation("error taking money")
260              Tracer.addSpan(tr:endSpan())
261              return
262          end
263  
264          local player = iv.getPlayer(currentBetter)
265          carpet.addBet(1, ucolors[currentBetter + 1], player or "", nbr)
266          Logger.info("Bet added successfully")
267  
268          tr:addAnnotation("bet added")
269          os.sleep(0.2)
270          Tracer.addSpan(tr:endSpan())
271      end
272  
273      -- Handle ring monitor touch events
274      local function handleRingTouch()
275          local tr = Tracer.new()
276          tr:setName("event.monitor_touch")
277          tr:addTag("device", config.devices.ring)
278          tr:addTag("player", tostring(currentBetter))
279          tr:addTag("action", "spin")
280  
281          local min, max = 150, 200
282          local nbr = ring.launchBall(math.random(min, max))
283          local bets = carpet.getBets()
284  
285          Logger.info("Running through " .. #bets .. " bets")
286          for _, b in ipairs(bets) do
287              local ptr = Tracer.new()
288              ptr:setName("bet_check")
289              ptr:addTag("bet", tostring(b.number))
290              ptr:addTag("player", b.player)
291              ptr:addTag("color", tostring(b.color))
292              ptr:addTag("amount", tostring(b.amount))
293              ptr:addTag("winning_number", tostring(nbr))
294              ptr:setParentId(tr.traceId)
295  
296              Logger.debug("Checking bet for player " ..
297                  b.player .. " on number " .. b.number .. " with color " .. b.color .. " and amount " .. b.amount)
298              local payout = getPayout(b, nbr)
299  
300              if payout then
301                  Logger.info("Payout for bet: " .. payout)
302                  tr:addAnnotation("payout" .. tostring(payout))
303                  local idx = iv.findPlayer(b.player)
304                  if idx == nil then
305                      Logger.error("Player " .. b.player .. " not found for payout")
306                      emergencyWrite(nbr, bets)
307                      ptr:addAnnotation("player not found")
308                      Tracer.addSpan(ptr:endSpan())
309                  else
310                      local res = iv.addMoneyToPlayer(idx - 1, payout)
311                      if res == nil then
312                          Logger.error("Failed to give money to player " .. b.player)
313                          emergencyWrite(nbr, bets)
314                          ptr:addAnnotation("error giving money")
315                          Tracer.addSpan(ptr:endSpan())
316                      else
317                          Logger.info("Money added to player " .. b.player)
318                          ptr:addAnnotation("money added")
319                          Tracer.addSpan(ptr:endSpan())
320                      end
321                  end
322                  Logger.info("Payout processed for player " .. b.player)
323              else
324                  Logger.info("No payout for bet")
325                  ptr:addAnnotation("no payout")
326                  Tracer.addSpan(ptr:endSpan())
327              end
328          end
329          carpet.resetBets()
330          carpet.update()
331          Logger.info("Bets reset successfully")
332  
333          tr:addAnnotation("spin finished")
334          Tracer.addSpan(tr:endSpan())
335      end
336  
337      -- Handle monitor touch events
338      local function handleMonitorTouch(monitorName, x, y)
339          if monitorName == config.devices.carpet then
340              handleCarpetTouch(x, y)
341          elseif monitorName == config.devices.ring then
342              handleRingTouch()
343          end
344      end
345  
346      LokiTimer = os.startTimer(LokiSleepyTime)
347      TempoTimer = os.startTimer(TempoSleepyTime)
348  
349      -- Main event loop
350      while true do
351          local rEvent = { os.pullEventRaw() }
352  
353          if rEvent[1] == "redstone" then
354              handleRedstoneEvent()
355          elseif rEvent[1] == "monitor_touch" then
356              handleMonitorTouch(rEvent[2], rEvent[3], rEvent[4])
357          elseif rEvent[1] == "terminate" then
358              Logger.info("Terminating program")
359              error("Program terminated by user", 0)
360          elseif rEvent[1] == "timer" then
361              if rEvent[2] == LokiTimer then
362                  Logger.sendLoki()
363                  LokiTimer = os.startTimer(LokiSleepyTime)
364              elseif rEvent[2] == TempoTimer then
365                  Tracer.sendTempo()
366                  TempoTimer = os.startTimer(TempoSleepyTime)
367              end
368          end
369      end
370  end
371  
372  local function main()
373      if not isConfig() then
374          saveConfig(defaultConfig())
375          error("No config file found, a default one has been created", 0)
376          error("Please edit the config file to your liking", 0)
377          return
378      end
379  
380      config = loadConfig()
381      mainLoop()
382  end
383  
384  main()