TheSkyPaster

create coasters RCT2 score

Aug 3rd, 2026 (edited)
41
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 14.89 KB | None | 0 0
  1. --[[
  2.     Records track information using CC: Sable,
  3.     for Create: Coasters Simulated.
  4. ]]
  5.  
  6.  
  7. MOVING_VELOCITY = .01 -- min velocity to start moving (b/s)
  8. STANDARD_GRAVITY = 9.80665 -- used for calculating Gs
  9. GRAVITY = 9.80665 -- used for adding Gs caused by gravity (only this value changes on the moon)
  10. INVERSION_DOT = -.7 -- 153 degrees away from upwards
  11. MIN_AIRTIME_G = .001 -- min g force pulling up (including gravity) for it to count as airtime
  12. MIN_AIRTIME_DURATION = .01 -- min duration of airtime to count
  13. MIN_DROP_SPEED = .1 -- min speed downwards to count as a drop
  14. MIN_DROP_HEIGHT = .5 -- min height of drop to count (1 slab)
  15.  
  16.  
  17. local tracker = {}
  18. tracker.__index = tracker
  19.  
  20. function tracker.new()
  21.     return setmetatable({
  22.         sum = 0,
  23.         abs_sum = 0,
  24.         values = {}
  25.     }, tracker)
  26. end
  27.  
  28. function tracker:update(value)
  29.     if self.min == nil or value < self.min then
  30.         self.min = value
  31.     end
  32.     if self.max == nil or value > self.max then
  33.         self.max = value
  34.     end
  35.     local abs = math.abs(value)
  36.     if self.abs_min == nil or abs < self.abs_min then
  37.         self.abs_min = abs
  38.     end
  39.     if self.abs_max == nil or abs > self.abs_max then
  40.         self.abs_max = abs
  41.     end
  42.  
  43.     self.sum = self.sum + value
  44.     self.abs_sum = self.sum + abs
  45.     table.insert(self.values, value)
  46. end
  47.  
  48. function tracker:getAverage()
  49.     return self.sum / #self.values
  50. end
  51.  
  52. function tracker:getAbsAverage()
  53.     return self.abs_sum / #self.values
  54. end
  55.  
  56. function tracker:__tostring()
  57.     return ("min %.3g max %.3g (abs min %.3g abs max %.3g) avg ~%.3g (%.3g total over %d values)"):format(self.min, self.max, self.abs_min, self.abs_max, self:getAverage(), self.sum, #self.values)
  58. end
  59.  
  60. local function getScoreText(intensity)
  61.     if intensity <= 2.55 then
  62.         return "Low"
  63.     elseif intensity <= 5.11 then
  64.         return "Medium"
  65.     elseif intensity <= 7.67 then
  66.         return "High"
  67.     elseif intensity <= 10.23 then
  68.         return "Very high"
  69.     elseif intensity <= 12.79 then
  70.         return "Extreme"
  71.     elseif intensity <= 50.01 then
  72.         return "Ultra-Extreme"
  73.     else
  74.         return "Uber-Extreme"
  75.     end
  76. end
  77.  
  78. local function split(text)
  79.     local words = {}
  80.     for word in (text or ""):gmatch("%S+") do
  81.         table.insert(words, word:lower())
  82.     end
  83.     return words
  84. end
  85.  
  86.  
  87. -- Setup
  88.  
  89. assert(sublevel, "Sublevel API is missing, is CC: Sable installed?")
  90. assert(quaternion, "Quaternion API is missing, is CC: Sable installed?")
  91. assert(sublevel.isInPlotGrid(), "Computer is not on a sublevel.")
  92.  
  93.  
  94. print("\nReady to dispatch...")
  95.  
  96. local forward = vector.new(0, 0, -1)
  97. local up = vector.new(0, 1, 0) -- seats also always face up in their sublevel
  98. do
  99.     local last_time = os.epoch("utc") / 1000
  100.     local last_p = sublevel.getLogicalPose().position
  101.     while true do
  102.         local time = os.epoch("utc") / 1000
  103.         local delta = time - last_time
  104.  
  105.         local p = sublevel.getLogicalPose().position
  106.         local v = p:sub(last_p):mul(delta)
  107.         if v:length() > MOVING_VELOCITY then
  108.             local q = sublevel.getLogicalPose().orientation
  109.             forward = q:inverse():mul(v):round(MOVING_VELOCITY):normalize()
  110.             break
  111.         end
  112.  
  113.         last_time = time
  114.         last_p = p
  115.         os.sleep(0)
  116.     end
  117. end
  118. local left = up:cross(forward):normalize()
  119.  
  120.  
  121. -- Recording
  122.  
  123. print(("\nCart started moving in %s, recording started..."):format(forward:round(.01)))
  124. print("Hold t to end the recording.")
  125. local start = os.epoch("utc") / 1000
  126.  
  127. local tracker_times = tracker.new()
  128. local total_g = tracker.new()
  129. local lateral_g = tracker.new()
  130. local vertical_g = tracker.new()
  131. local accel_g = tracker.new()
  132. local speed = tracker.new()
  133. local airtime = tracker.new()
  134. local drops = tracker.new()
  135. local inversions = 0
  136. local length = 0
  137.  
  138. local end_time = start
  139. do
  140.     local airtime_start = nil
  141.     local drop_total = nil
  142.     local was_inverted = false
  143.  
  144.     local last_time = start
  145.     local last_p = sublevel.getLogicalPose().position
  146.     local last_v = vector.new(0, 0, 0)
  147.  
  148.     local timer = os.startTimer(0)
  149.     while true do
  150.         local event, p1 = os.pullEvent()
  151.  
  152.         if event == "key" and p1 == keys.t then
  153.             break
  154.         elseif event == "timer" and p1 == timer then
  155.  
  156.             local now = os.epoch("utc") / 1000
  157.             tracker_times:update(now)
  158.             local delta = now - last_time
  159.  
  160.             local p = sublevel.getLogicalPose().position
  161.             local p_delta = p:sub(last_p)
  162.             length = length + p_delta:length()
  163.  
  164.             local v = p_delta:div(delta)
  165.             speed:update(v:length())
  166.  
  167.             local q = sublevel.getLogicalPose().orientation
  168.  
  169.             local global_g = v:sub(last_v):div(delta):add(vector.new(0, GRAVITY, 0)):div(STANDARD_GRAVITY)
  170.             local global_g_length = global_g:length()
  171.             total_g:update(global_g_length)
  172.             local g = q:inverse():mul(global_g):mul(global_g_length) -- quaternion:mul() normalizes the vector for some reason
  173.  
  174.             local lat = g:dot(left)
  175.             lateral_g:update(lat)
  176.  
  177.             local vert = g:dot(up)
  178.             vertical_g:update(vert)
  179.  
  180.             local accel = g:dot(forward)
  181.             accel_g:update(accel)
  182.  
  183.             local global_up = q:inverse():mul(up)
  184.             if global_up:dot(up) < INVERSION_DOT then
  185.                 if not was_inverted then
  186.                     print("> inversion")
  187.                     was_inverted = true
  188.                     inversions = inversions + 1
  189.                 end
  190.             else
  191.                 was_inverted = false
  192.             end
  193.  
  194.             if g.y < -MIN_AIRTIME_G then
  195.                 if airtime_start == nil then
  196.                     airtime_start = now
  197.                 end
  198.             elseif airtime_start ~= nil then
  199.                 local time = last_time - airtime_start
  200.                 if time > MIN_AIRTIME_DURATION then
  201.                     airtime:update(time)
  202.                     print(("> airtime ended of %.3g seconds"):format(last_time - airtime_start))
  203.                 end
  204.                 airtime_start = nil
  205.             end
  206.  
  207.             if v.y < -MIN_DROP_SPEED then
  208.                 -- TODO don't count loops as drops somehow
  209.                 if drop_total == nil then
  210.                     drop_total = 0
  211.                 end
  212.                 drop_total = drop_total - v.y * delta
  213.             elseif drop_total ~= nil then
  214.                 if drop_total > MIN_DROP_HEIGHT then
  215.                     drops:update(drop_total)
  216.                     print(("> drop ended of %.3g blocks"):format(drop_total))
  217.                 end
  218.                 drop_total = nil
  219.             end
  220.  
  221.             last_time = now
  222.             -- don't let user being slow to stop script inflate duration
  223.             if v:length() > MOVING_VELOCITY then
  224.                 end_time = now
  225.             end
  226.             last_v = v
  227.             last_p = p
  228.             timer = os.startTimer(0)
  229.         end
  230.     end
  231. end
  232.  
  233.  
  234. -- Data compilation
  235.  
  236. local duration = end_time - start
  237.  
  238. local trackers = {
  239.     time = tracker_times,
  240.     total_g = total_g,
  241.     lateral_g = lateral_g,
  242.     vertical_g = vertical_g,
  243.     accel_g = accel_g,
  244.     speed = speed
  245. }
  246. local events = {
  247.     airtime = airtime,
  248.     drops = drops
  249. }
  250. local misc = {
  251.     inversions = inversions,
  252.     length = length,
  253.     duration = duration
  254. }
  255.  
  256. -- https://github-wiki-see.page/m/OpenRCT2/OpenRCT2/wiki/Ride-rating-calculation
  257. -- https://github.com/OpenRCT2/OpenRCT2/blob/a3f7b5d3b09f470dee3e1b28ba157e40e3ff95c4/src/openrct2/ride/RideRatings.cpp
  258. local excitement =
  259.     (.8 * vertical_g.max) -- g-force
  260.     + (.24 * math.max(-2.5, vertical_g.min))
  261.     + (.4 * math.min(1.5, lateral_g.abs_max))
  262.     + (.11 * math.min(9, #drops.values)) -- drops
  263.     + (.0049 * drops.max)
  264.     + (.27 * math.min(6, inversions)) -- inversions
  265.     + (.125 * airtime.sum) -- airtime
  266.  
  267. local intensity =
  268.     (.8 * vertical_g.max) -- g-force
  269.     + (.8 * (1 + vertical_g.min))
  270.     + lateral_g.abs_max
  271.     + (.14 * #drops.values) -- drops
  272.     + (.0098 * drops.max)
  273.     + (.5 * inversions) -- inversions
  274.  
  275. local nausea =
  276.     (.26 * vertical_g.max) -- g-force
  277.     + (.22 * (1 + vertical_g.min))
  278.     + (.33 * lateral_g.abs_max)
  279.     + (.1 * #drops.values) -- drops
  280.     + (.0016 * drops.max)
  281.     + (.22 * inversions) -- inversions
  282.     + (.0625 * airtime.sum) -- airtime
  283.  
  284. -- https://github.com/OpenRCT2/OpenRCT2/blob/5aa3f85c56c4bd68500380190d9deac357686423/src/openrct2/ride/rtd/coaster/TwisterRollerCoaster.h
  285. excitement = excitement
  286.     + (.0116577 * math.min(6000, length)) -- track length
  287.     + (.4 * math.min(150, duration)) -- duration -- https://github-wiki-see.page/m/Sadret/openrct2-plugin-wiki/wiki/Date-and-Time
  288.     + (.01736 * speed:getAverage()) -- speed avg -- https://gall.dcinside.com/mgallery/board/view/?id=rct&no=8507
  289.     + (.00264 * speed.max) -- speed max
  290.  
  291. intensity = intensity
  292.     + (.026 * speed:getAverage()) -- speed avg
  293.     + (.00528 * speed.max) -- speed max
  294.  
  295. nausea = nausea
  296.     + (.0021 * speed.max) -- speed max
  297.  
  298. if lateral_g.abs_max > 2.8 then
  299.     intensity = intensity + 3.75
  300.     nausea = nausea + 2
  301. end
  302. if lateral_g.abs_max > 3.1 then
  303.     excitement = excitement * .5
  304.     intensity = intensity + 8.5
  305.     nausea = nausea + 4
  306. end
  307.  
  308. for _,threshold in pairs({10, 11, 12, 13.2, 14.5}) do
  309.     if intensity > threshold then
  310.         excitement = excitement * .75
  311.     end
  312. end
  313.  
  314. local rct2_scores = {Excitement = excitement, Intensity = intensity, Nausea = nausea}
  315. local function printGeneralInfo()
  316.     local lines = {
  317.         "",
  318.         ("Speed: max %.2fkb/h avg ~%.2fkb/h"):format(speed.max * 3.6, speed:getAverage() * 3.6),
  319.         ("Duration: %.2fs"):format(duration),
  320.         ("%.1f blocks long"):format(length),
  321.         "",
  322.         ("G-force: max %.2fg avg ~%.2fg"):format(total_g.max, total_g:getAverage()),
  323.         ("Lateral g-force: max %.2fg avg ~%.2fg"):format(lateral_g.abs_max, lateral_g:getAbsAverage()),
  324.         ("Vertical g-force: down max %.2fg up max %.2fg avg ~%.2fg"):format(vertical_g.min, vertical_g.max, vertical_g:getAverage()),
  325.         ("Acceleration g-force: brake max %.2fg speed max %.2fg avg ~%.2fg"):format(accel_g.min, accel_g.max, accel_g:getAbsAverage()),
  326.         "",
  327.         ("%d drops max %.1fb total %.1fb avg ~%.1fb"):format(#drops.values, drops.max, drops.sum, drops:getAverage()),
  328.         ("%d airtimes max %.2fs total %.2fs avg ~%.2fs"):format(#airtime.values, airtime.max, airtime.sum, airtime:getAverage()),
  329.         ("%d inversions"):format(inversions),
  330.         ""
  331.     }
  332.  
  333.     for name, val in pairs(rct2_scores) do
  334.         table.insert(lines, ("%s: %.2f (%s)"):format(name, val, getScoreText(val)))
  335.     end
  336.  
  337.     local _, height = term.getCursorPos()
  338.     textutils.pagedPrint(table.concat(lines, "\n"), height - 2)
  339. end
  340. printGeneralInfo()
  341.  
  342. local function writeMisc(path)
  343.     local full_path = ("%s/misc.lua"):format(path)
  344.     local file = fs.open(full_path, "w")
  345.  
  346.     file.writeLine(textutils.serialize(misc, {
  347.         compact = true,
  348.         allow_repetitions = true
  349.     }))
  350.     file.flush()
  351.     file.close()
  352.  
  353.     return full_path
  354. end
  355.  
  356. local function writeTrackerSummaries(path)
  357.     local full_path = ("%s/summaries.csv"):format(path)
  358.     local file = fs.open(full_path, "w")
  359.  
  360.     file.writeLine("name,min,max,abs_min,abs_max,avg,sum,count")
  361.     for name, tracker in pairs(trackers) do
  362.         file.writeLine(("%s,%s,%s,%s,%s,%s,%s,%d"):format(
  363.             name,
  364.             tracker.min, tracker.max,
  365.             tracker.abs_min, tracker.abs_max,
  366.             tracker:getAverage(),
  367.             tracker.sum,
  368.             #tracker.values
  369.         ))
  370.     end
  371.     file.flush()
  372.     file.close()
  373.  
  374.     return full_path
  375. end
  376.  
  377. local function writeTrackers(path)
  378.     local full_path = ("%s/trackers.csv"):format(path)
  379.     local file = fs.open(full_path, "w")
  380.  
  381.     local columns = {}
  382.     for name, tracker in pairs(trackers) do
  383.         table.insert(columns, name)
  384.     end
  385.     file.writeLine(table.concat(columns, ","))
  386.  
  387.     local i = 1
  388.     while true do
  389.         local time = trackers.time.values[i]
  390.         if time == nil or time > end_time then
  391.             break
  392.         end
  393.  
  394.         local values = {}
  395.         for _, name in ipairs(columns) do
  396.             local tracker = trackers[name]
  397.             if i > #tracker.values then
  398.                 table.insert(values, "")
  399.             else
  400.                 table.insert(values, tracker.values[i])
  401.             end
  402.         end
  403.         file.writeLine(table.concat(values, ","))
  404.  
  405.         i = i + 1
  406.     end
  407.  
  408.     file.flush()
  409.     file.close()
  410.  
  411.     return full_path
  412. end
  413.  
  414.  
  415. -- CLI
  416.  
  417. print("\nWelcome to the interactive CLI,\ntype 'help' for usage information.")
  418.  
  419. local completion = require("cc.completion")
  420. local function complete(text)
  421.     local words = split(text)
  422.  
  423.     if #words <= 1 then
  424.         return completion.choice(text, {
  425.             "help",
  426.             "exit",
  427.             "general",
  428.             "graph",
  429.             "export"
  430.         })
  431.     end
  432.     if #words == 2 then
  433.         if words[1] == "graph" then
  434.             local keys = {}
  435.             for name, _ in pairs(trackers) do
  436.                 table.insert(keys, name)
  437.             end
  438.             return completion.choice(words[2], keys)
  439.  
  440.         elseif words[1] == "export" then
  441.             return fs.complete(words[2], shell.dir(), false, true)
  442.         end
  443.     end
  444. end
  445.  
  446. local cmd_history = {}
  447. while true do
  448.     write("\n? ")
  449.     local cmd = read(nil, cmd_history, complete)
  450.     table.insert(cmd_history, cmd)
  451.     local words = split(cmd)
  452.  
  453.     if words[1] == "help" then
  454.         local _, height = term.getCursorPos()
  455.         textutils.pagedPrint([[Usage:
  456. * help - this information list
  457. * exit - exit this program
  458. * general - reprints the previously printed general data, like RCT2 scores
  459. * graph <metric> - prints graph for the specified metric
  460. * export <path> - exports all recorded data to multiple files in specified path]],
  461.         height - 2)
  462.  
  463.     elseif words[1] == "exit" then
  464.         break
  465.     elseif words[1] == "general" or words[1] == "rct2" then
  466.         printGeneralInfo()
  467.     elseif words[1] == "export" then
  468.         if #words ~= 2 or words[2] == nil then
  469.             print("Missing path to export to.")
  470.         else
  471.             local success, result = pcall(function()
  472.                 local path = words[2]
  473.                 fs.makeDir(path)
  474.  
  475.                 local funcs = {
  476.                     writeMisc,
  477.                     writeTrackerSummaries,
  478.                     writeTrackers
  479.                 }
  480.                 for i, func in pairs(funcs) do
  481.                     print(("Exported to '%s'... (%d/%d)"):format(func(path), i, #funcs))
  482.                 end
  483.             end)
  484.  
  485.             if not success then
  486.                 print(("Export failed: %s"):format(result))
  487.             end
  488.         end
  489.     else
  490.         print("Unknown command, use 'help' for usage information.")
  491.     end
  492. end
  493.  
Advertisement
Add Comment
Please, Sign In to add comment