├── .gitignore ├── .gitattributes ├── .github └── FUNDING.yml ├── Turns.ttslua ├── GlobalPatches.ttslua ├── Grid.ttslua ├── LICENSE ├── lunajson ├── LICENSE ├── encoder.lua ├── decoder.lua └── sax.lua ├── ChatCommands.ttslua ├── License.ttslua ├── Debug.ttslua ├── PlayerDropZone.ttslua ├── Class.ttslua ├── EventManager.ttslua ├── RemoteLogger.ttslua ├── Ui.ttslua ├── Logger.ttslua ├── Graph.ttslua ├── Base64.ttslua ├── DieInstance.ttslua ├── Object.ttslua ├── HandZone.ttslua ├── Http.ttslua ├── Vector2.ttslua ├── InfiniteContainerInstance.ttslua ├── SaveManager.ttslua ├── Coroutine.ttslua ├── InstanceManager.ttslua ├── Vector3.ttslua ├── ObjectUtils.ttslua ├── DropZone.ttslua ├── README.asciidoc └── Json.ttslua /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ttslua linguist-language=Lua 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Benjamin-Dobell] 2 | -------------------------------------------------------------------------------- /Turns.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local GlobalTurns = Turns 4 | 5 | ---@class ge_tts__Turns : tts__Turns 6 | local Turns = {} 7 | 8 | Turns.Type = { 9 | Auto = 1, 10 | Custom = 2, 11 | } 12 | 13 | setmetatable(Turns, { 14 | __index = function(_, key) 15 | return (--[[---@type table]] GlobalTurns)[key] 16 | end, 17 | ---@param key any 18 | ---@param value any 19 | __newindex = function(_, key, value) 20 | (--[[---@type table]] GlobalTurns)[key] = value 21 | end 22 | }) 23 | 24 | return Turns 25 | -------------------------------------------------------------------------------- /GlobalPatches.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | -- From time to time there are bugs in TTS' APIs that we're able to fix/patch in a non-intrusive fashion. 4 | 5 | -- Lua Color indexing fix, see: https://github.com/Berserk-Games/Tabletop-Simulator-Lua-Classes/pull/1 6 | 7 | ---@type {__index: fun(c: any, k: any): any} 8 | local colorMetatable = getmetatable(Color) 9 | local originalColorIndex = colorMetatable.__index 10 | 11 | colorMetatable.__index = function(c, k) 12 | if type(k) ~= 'string' then 13 | return nil 14 | end 15 | 16 | return originalColorIndex(c, k) 17 | end 18 | -------------------------------------------------------------------------------- /Grid.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local GlobalGrid = Grid 4 | 5 | ---@class ge_tts__Grid : tts__Grid 6 | local Grid = {} 7 | 8 | Grid.Type = { 9 | Box = 1, 10 | HexHorizontal = 2, 11 | HexVertical = 3, 12 | } 13 | 14 | Grid.Snapping = { 15 | None = 1, 16 | Lines = 2, 17 | Center = 3, 18 | LinesAndCenter = 4, 19 | } 20 | 21 | setmetatable(Grid, { 22 | __index = function(_, key) 23 | return (--[[---@type table]] GlobalGrid)[key] 24 | end, 25 | ---@param key any 26 | ---@param value any 27 | __newindex = function(_, key, value) 28 | (--[[---@type table]] GlobalGrid)[key] = value 29 | end 30 | }) 31 | 32 | return Grid 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Benjamin Dobell, Glass Echidna 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /lunajson/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2017 Shunsuke Shimizu (grafi) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /ChatCommands.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local EventManager = require('ge_tts.EventManager') 4 | 5 | ---@alias ge_tts__ChatCommand_Callback fun(value: string, player: tts__Player): void 6 | 7 | ---@type table 8 | local commandCallbacks = {} 9 | 10 | local chatCommandPattern = '^~(.+)~%s*(.*)' 11 | 12 | ---@class tts__ChatCommands 13 | local ChatCommand = {} 14 | 15 | --- Replaces the current command pattern (default '^~(.+)~%s*(.*)'). 16 | --- 17 | --- The pattern must have two (and only two) capture groups, the first being the command name, and 18 | --- the second being the value, which once captured will be passed to command callbacks. 19 | ---@param pattern string 20 | function ChatCommand.setPattern(pattern) 21 | chatCommandPattern = pattern 22 | end 23 | 24 | ---@param command string 25 | ---@param callback ge_tts__ChatCommand_Callback 26 | function ChatCommand.addCommand(command, callback) 27 | commandCallbacks[command] = callback 28 | end 29 | 30 | ---@param command string 31 | function ChatCommand.removeCommand(command) 32 | commandCallbacks[command] = nil 33 | end 34 | 35 | ---@param message string 36 | ---@param player tts__Player 37 | local function onChat(message, player) 38 | local _, _, command, value = message:find(chatCommandPattern) 39 | 40 | if not command then 41 | return true 42 | end 43 | 44 | local callback = commandCallbacks[--[[---@type string]] command] 45 | 46 | if callback then 47 | callback(--[[---@type string]] value, player) 48 | else 49 | broadcastToColor('Unknown command: ' .. command, player.color, 'Red') 50 | end 51 | 52 | return false 53 | end 54 | 55 | EventManager.addHandler('onChat', onChat) 56 | 57 | return ChatCommand 58 | -------------------------------------------------------------------------------- /License.ttslua: -------------------------------------------------------------------------------- 1 | local TableUtils = require('ge_tts.TableUtils') 2 | 3 | -- This license applies to ge_tts. Do *not* assume it extends to the mod! 4 | ---@type table 5 | local licenses = { 6 | ge_tts = [[Copyright (c) 2024 Benjamin Dobell 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in 16 | all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | ]], 26 | } 27 | 28 | local License = {} 29 | 30 | ---@param library string 31 | ---@param license string 32 | ---@return boolean 33 | function License.add(library, license) 34 | if licenses[library] then 35 | return false 36 | end 37 | 38 | licenses[library] = license 39 | return true 40 | end 41 | 42 | ---@param library string 43 | ---@return nil | string 44 | function License.get(library) 45 | return licenses[library] 46 | end 47 | 48 | ---@return string[] 49 | function License.getLibraries() 50 | return TableUtils.keys(licenses) 51 | end 52 | 53 | return License 54 | -------------------------------------------------------------------------------- /Debug.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local ChatCommands = require('ge_tts.ChatCommands') 4 | 5 | ---@class tts__Debug 6 | local Debug = {} 7 | 8 | local globalTable = (--[[---@type table]] _G) 9 | 10 | --- Iterates through loaded (require'd) modules and exposes all modules matching modulePattern as global variables. The 11 | --- global variable for a module will be the module's name with variablePrefix substituted for modulePattern and periods 12 | --- replaced by double underscores. 13 | ---@overload fun(): void 14 | ---@param modulePattern? nil | string @Default '^ge_tts%.' 15 | ---@param variablePrefix? nil | string @Default '' 16 | function Debug.createGlobals(modulePattern, variablePrefix) 17 | Wait.frames(function() 18 | modulePattern = modulePattern or '^ge_tts%.' 19 | variablePrefix = variablePrefix or '' 20 | 21 | -- First directory is the "scripts", which we don't want. 22 | for packageName, package in pairs(_LOADED) do 23 | if packageName:find(--[[---@not nil]] modulePattern) then 24 | local identifier = packageName:gsub(--[[---@not nil]] modulePattern, --[[---@not nil]] variablePrefix):gsub('[/%.]', '__') 25 | globalTable[identifier] = package 26 | end 27 | end 28 | 29 | print('Initialized Debug globals with "' .. variablePrefix .. '" prefix.') 30 | end, 1) 31 | end 32 | 33 | ---@param value string 34 | ---@param player tts__Player 35 | local onChatCommand = function(value, player) 36 | if player.host then 37 | ---@type string[] 38 | local components = {} 39 | 40 | for component in --[[---@type fun(): string]] string.gmatch(value, "([^ ]+)") do 41 | table.insert(components, component) 42 | end 43 | 44 | if #components == 0 then 45 | return 46 | end 47 | 48 | local subcommand = components[1] 49 | 50 | if subcommand == 'globals' then 51 | Debug.createGlobals(components[2], components[3]) 52 | end 53 | end 54 | end 55 | 56 | ChatCommands.addCommand('debug', onChatCommand) 57 | 58 | return Debug 59 | -------------------------------------------------------------------------------- /PlayerDropZone.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Class = require('ge_tts.Class') 4 | local DropZone = require('ge_tts.DropZone') 5 | local TableUtils = require('ge_tts.TableUtils') 6 | 7 | ---@class ge_tts__PlayerDropZone : ge_tts__DropZone 8 | 9 | ---@shape ge_tts__PlayerDropZone_SavedState : ge_tts__DropZone_SavedState 10 | ---@field ownerColor tts__PlayerColor 11 | 12 | ---@class ge_tts__static_PlayerDropZone : ge_tts__static_DropZone 13 | ---@overload fun(position: tts__VectorShape, rotation: tts__VectorShape, scale: tts__VectorShape, occupantScale: nil | number, owner: tts__Player): ge_tts__PlayerDropZone 14 | ---@overload fun(savedState: ge_tts__PlayerDropZone_SavedState): ge_tts__PlayerDropZone 15 | local PlayerDropZone = {} 16 | 17 | setmetatable(PlayerDropZone, TableUtils.merge(getmetatable(DropZone), { 18 | ---@param class self 19 | ---@param zonePositionOrSavedState tts__VectorShape | ge_tts__PlayerDropZone_SavedState 20 | ---@param zoneRotation tts__VectorShape 21 | ---@param zoneScale tts__VectorShape 22 | ---@param occupantScale nil | number @Optional - occupant's desired X-axis scale. When scaling is applied it is applied to all dimensions i.e. aspect ratio is preserved. `nil` means dropped objects will not have their scale altered. 23 | ---@param owner tts__Player @TTS player that owns this PlayerDropZone 24 | __call = function(class, zonePositionOrSavedState, zoneRotation, zoneScale, occupantScale, owner) 25 | local self = --[[---@type ge_tts__PlayerDropZone]] Class.parentConstructor(class, DropZone)( 26 | zonePositionOrSavedState, 27 | zoneRotation, 28 | zoneScale, 29 | occupantScale 30 | ) 31 | 32 | function self.getOwner() 33 | return owner 34 | end 35 | 36 | local superSave = self.save 37 | 38 | ---@return ge_tts__PlayerDropZone_SavedState 39 | function self.save() 40 | return --[[---@type ge_tts__PlayerDropZone_SavedState]] TableUtils.merge(superSave(), { 41 | ownerColor = owner.color 42 | }) 43 | end 44 | 45 | if PlayerDropZone.isSavedState(zonePositionOrSavedState) then 46 | local data = --[[---@type ge_tts__PlayerDropZone_SavedState]] zonePositionOrSavedState 47 | 48 | owner = Player[data.ownerColor] 49 | end 50 | 51 | return self 52 | end, 53 | __index = DropZone, 54 | })) 55 | 56 | return PlayerDropZone 57 | -------------------------------------------------------------------------------- /Class.ttslua: -------------------------------------------------------------------------------- 1 | ---@class ge_tts__Class 2 | local Class = {} 3 | 4 | ---@param object table 5 | ---@return nil | table 6 | function Class.getClass(object) 7 | return (--[[---@type { __class: nil | table } ]] object).__class 8 | end 9 | 10 | ---@param sourceClass table 11 | ---@param targetClass table 12 | ---@return boolean 13 | local function isDescendentClass(sourceClass, targetClass) 14 | if sourceClass == targetClass then 15 | return true 16 | end 17 | 18 | local parentClass = (--[[---@type { __index: nil | table } ]] getmetatable(sourceClass)).__index 19 | return parentClass ~= nil and isDescendentClass(--[[---@not nil]] parentClass, targetClass) 20 | end 21 | 22 | ---@type table> 23 | local descendentCache = {} 24 | 25 | ---@param sourceClass table 26 | ---@param targetClass table 27 | ---@return boolean 28 | function Class.isDescendentClass(sourceClass, targetClass) 29 | if sourceClass == targetClass then 30 | return true 31 | end 32 | 33 | local sourceCache = descendentCache[sourceClass] 34 | 35 | if sourceCache then 36 | local isDescendent = (--[[---@not nil]] sourceCache)[targetClass] 37 | 38 | if isDescendent ~= nil then 39 | return --[[---@not nil]] isDescendent 40 | end 41 | else 42 | sourceCache = {} 43 | descendentCache[sourceClass] = sourceCache 44 | end 45 | 46 | local parentClass = (--[[---@type { __index: nil | table } ]] getmetatable(sourceClass)).__index 47 | local isDescendent = parentClass ~= nil and isDescendentClass(--[[---@not nil]] parentClass, targetClass) 48 | 49 | ;(--[[---@not nil]] sourceCache)[targetClass] = isDescendent 50 | 51 | return isDescendent 52 | end 53 | 54 | ---@param object table 55 | ---@return boolean 56 | function Class.isInstance(object, class) 57 | local objectClass = Class.getClass(object) 58 | return objectClass ~= nil and Class.isDescendentClass(--[[---@not nil]] Class.getClass(object), class) 59 | end 60 | 61 | ---@generic AncestorClass 62 | ---@generic Class : AncestorClass 63 | ---@param descendent Class 64 | ---@param parent nil | AncestorClass 65 | ---@return AncestorClass 66 | function Class.parentConstructor(descendent, parent) 67 | local metatable = --[[---@type { __call: function }]] getmetatable(parent) 68 | local call = metatable.__call 69 | local wrappedStaticParent = function(...) 70 | return call(descendent, ...) 71 | end 72 | return --[[---@type AncestorClass]] wrappedStaticParent 73 | end 74 | 75 | ---@generic Class 76 | ---@param class Class 77 | ---@return { __class: Class } 78 | function Class.rootConstructor(class) 79 | return { __class = class } 80 | end 81 | 82 | return Class 83 | -------------------------------------------------------------------------------- /EventManager.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local TableUtils = require('ge_tts.TableUtils') 4 | local Logger = require('ge_tts.Logger') 5 | 6 | ---@type table 7 | local EVENT_DEFAULT_RETURN_VALUES = { 8 | filterObjectEnterContainer = true, 9 | } 10 | 11 | ---@type table 12 | local eventHandlers = {} 13 | 14 | local globalHandlers = --[[---@type {[string]: nil | function}]] _G 15 | 16 | ---@param event string 17 | local function listen(event) 18 | local previousGlobalHandler = globalHandlers[event] 19 | 20 | ;(--[[---@type table]] _G)[event] = function(...) 21 | local handlers = TableUtils.copy(eventHandlers[event]) -- Copied in case we add/remove handlers during a handler callback 22 | 23 | ---@type std__Packed 24 | local finalResult = --[[---@type std__Packed]] {n = 0} 25 | 26 | for _, handler in ipairs(handlers) do 27 | local result = table.pack(handler(...)) 28 | 29 | if result.n > 0 then 30 | finalResult = result 31 | end 32 | end 33 | 34 | if finalResult.n > 0 then 35 | return table.unpack(finalResult, 1, finalResult.n) 36 | else 37 | local defaultValue = EVENT_DEFAULT_RETURN_VALUES[event] 38 | 39 | if defaultValue ~= nil then 40 | return defaultValue 41 | end 42 | end 43 | end 44 | 45 | ---@type function[] 46 | local handlers = {} 47 | 48 | eventHandlers[event] = handlers 49 | 50 | Logger.log('EventManager now listening for ' .. event, Logger.VERBOSE) 51 | 52 | if previousGlobalHandler then 53 | table.insert(handlers, --[[---@not nil]] previousGlobalHandler) 54 | Logger.log('Pre-existing global ' .. event .. ' handler preserved as the first handler', Logger.VERBOSE) 55 | end 56 | 57 | return handlers 58 | end 59 | 60 | local SAVE_MANAGER_EVENTS = {'onSave', 'onLoad'} 61 | 62 | ---@class ge_tts__EventManager 63 | local EventManager = {} 64 | 65 | ---@param event string @Event name 66 | ---@param handler function @Function that will be called when the event fires. Parameters vary depending on the event. 67 | function EventManager.addHandler(event, handler) 68 | assert(not TableUtils.find(SAVE_MANAGER_EVENTS, event), 'EventManager cannot handle ' .. event .. '. Please use SaveManager instead.') 69 | 70 | local handlers = eventHandlers[event] or listen(event) 71 | 72 | if not TableUtils.find(handlers, handler) then 73 | table.insert(handlers, handler) 74 | end 75 | end 76 | 77 | ---@param event string @Event name 78 | ---@param handler function @A previously registered handler that you wish to remove. 79 | function EventManager.removeHandler(event, handler) 80 | assert(not TableUtils.find(SAVE_MANAGER_EVENTS, event), 'EventManager cannot handle ' .. event .. '. Please use SaveManager instead.') 81 | 82 | local handlers = eventHandlers[event] 83 | local handlerIndex = handlers and TableUtils.find(handlers, handler) 84 | 85 | if handlerIndex then 86 | table.remove(handlers, --[[---@not nil]] handlerIndex) 87 | end 88 | end 89 | 90 | ---@param event string @Event name 91 | ---@vararg any 92 | function EventManager.triggerEvent(event, ...) 93 | local handler = globalHandlers[event] 94 | 95 | if handler then 96 | (--[[---@not nil]] handler)(...) 97 | end 98 | end 99 | 100 | return EventManager 101 | -------------------------------------------------------------------------------- /RemoteLogger.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local TableUtils = require('ge_tts.TableUtils') 4 | local Logger = require('ge_tts.Logger') 5 | local Json = require('ge_tts.Json') 6 | 7 | local MAX_RETRIES = 5 8 | 9 | ---@class ge_tts__RemoteLogger : ge_tts__Logger 10 | 11 | ---@class ge_tts__static_RemoteLogger : ge_tts__static_Logger 12 | ---@overload fun(url: string): ge_tts__RemoteLogger 13 | local RemoteLogger = {} 14 | 15 | ---@type table 16 | local levelPrefixes = { 17 | [Logger.ERROR] = 'ERROR: ', 18 | [Logger.WARNING] = 'WARNING: ', 19 | [Logger.INFO] = 'INFO: ', 20 | [Logger.DEBUG] = 'DEBUG: ', 21 | [Logger.VERBOSE] = 'VERBOSE: ', 22 | } 23 | 24 | -- RemoteLogger guarantees message order and batches messages rather than instantly sending an individual request per message, which may arrive out of 25 | -- order. Failed request will be retried. 26 | -- 27 | -- Note: The retry mechanism may cause duplicate messages to be logged depending on server implementation. Specifically there's an edge-case if the server 28 | -- receives a request, logs the messages, but the connection drops before we receive a 200 status code. Generally this won't occur, however if it's a major 29 | -- concern then the server could first respond 200, wait for TCP confirmation, *then* log the messages received in the request. 30 | setmetatable(RemoteLogger, TableUtils.merge(getmetatable(Logger), { 31 | ---@param url string 32 | __call = function(_, url) 33 | local self = Logger() 34 | 35 | ---@type string[] 36 | local queuedMessages = {} 37 | 38 | local postingCount = 0 39 | local retry = 0 40 | 41 | local function pumpQueue() 42 | if postingCount == 0 and #queuedMessages > 0 then 43 | retry = retry + 1 44 | 45 | if retry <= MAX_RETRIES then 46 | local content = Json.encode({ 47 | messages=queuedMessages 48 | }) 49 | 50 | postingCount = #queuedMessages 51 | 52 | -- NOTE: We're completely abusing the semantics of the HTTP PUT verb. We absolutely should be POSTing, but TTS's WebRequest APIs are 53 | -- without a doubt the worst attempt at writing a HTTP client that I've ever encountered. For some reason we can only POST URL encoded 54 | -- forms (which quickly run into URI length constraints, hence is unusable) where as we can PUT an arbitrary string, so we make do. 55 | WebRequest.put(url, content, function(request) 56 | if request.is_error then 57 | postingCount = 0 58 | Wait.time(pumpQueue, retry * retry * 0.1) 59 | elseif request.is_done then 60 | local unpostedMessages = {} 61 | 62 | for i=postingCount + 1, #queuedMessages do 63 | table.insert(unpostedMessages, queuedMessages[i]) 64 | end 65 | 66 | queuedMessages = unpostedMessages 67 | postingCount = 0 68 | retry = 0 69 | 70 | pumpQueue() 71 | end 72 | end) 73 | else 74 | error("Failed to send remote log messages.") 75 | postingCount = 0 76 | retry = 0 77 | end 78 | end 79 | end 80 | 81 | ---@param message string 82 | ---@param level number @One of Logger.ERROR, Logger.WARNING, Logger.INFO, Logger.DEBUG or Logger.VERBOSE 83 | function self.log(message, level) 84 | if level <= self.getFilterLevel() then 85 | table.insert(queuedMessages, levelPrefixes[level] .. message) 86 | pumpQueue() 87 | end 88 | end 89 | 90 | return self 91 | end, 92 | __index = Logger, 93 | })) 94 | 95 | return RemoteLogger 96 | -------------------------------------------------------------------------------- /Ui.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | ---@class ge_tts__static_Ui 4 | local Ui = {} 5 | 6 | -- Common 7 | 8 | Ui.Alignment = { 9 | UpperLeft = "UpperLeft", 10 | UpperCenter = "UpperCenter", 11 | UpperRight = "UpperRight", 12 | MiddleLeft = "MiddleLeft", 13 | MiddleCenter = "MiddleCenter", 14 | MiddleRight = "MiddleRight", 15 | LowerLeft = "LowerLeft", 16 | LowerCenter = "LowerCenter", 17 | LowerRight = "LowerRight", 18 | } 19 | 20 | Ui.Animation = { 21 | Hide = { 22 | None = "None", 23 | Shrink = "Shrink", 24 | FadeOut = "FadeOut", 25 | SlideOutLeft = "SlideOut_Left", 26 | SlideOutRight = "SlideOut_Right", 27 | SlideOutTop = "SlideOut_Top", 28 | SlideOutBottom = "SlideOut_Bottom", 29 | }, 30 | Show = { 31 | None = "None", 32 | Grow = "Grow", 33 | FadeIn = "FadeIn", 34 | SlideInLeft = "SlideIn_Left", 35 | SlideInRight = "SlideIn_Right", 36 | SlideInTop = "SlideIn_Top", 37 | SlideInBottom = "SlideIn_Bottom", 38 | }, 39 | } 40 | 41 | Ui.ContentSizeFit = { 42 | Vertical = "vertical", 43 | Horizontal = "horizontal", 44 | Both = "both", 45 | None = "none", 46 | } 47 | 48 | Ui.FontStyle = { 49 | Normal = "Normal", 50 | Bold = "Bold", 51 | Italic = "Italic", 52 | BoldItalic = "BoldItalic", 53 | } 54 | 55 | Ui.IconAlignment = { 56 | Left = "Left", 57 | Right = "Right", 58 | } 59 | 60 | Ui.MouseButton = { 61 | Left = "-1", 62 | Right = "-2", 63 | Middle = "-3", 64 | } 65 | 66 | Ui.Navigation = { 67 | None = "None", 68 | Horizontal = "Horizontal", 69 | Vertical = "Vertical", 70 | Automatic = "Automatic", 71 | Explicit = "Explicit", 72 | } 73 | 74 | Ui.Tag = { 75 | Button = "Button", 76 | Defaults = "Defaults", 77 | HorizontalLayout = "HorizontalLayout", 78 | Image = "Image", 79 | Option = "Option", 80 | Panel = "Panel", 81 | Text = "Text", 82 | VerticalLayout = "VerticalLayout", 83 | } 84 | 85 | Ui.TooltipPosition = { 86 | Above = "Above", 87 | Below = "Below", 88 | Left = "Left", 89 | Right = "Right", 90 | } 91 | 92 | -- Elements 93 | 94 | Ui.Button = { 95 | Transition = { 96 | None = "None", 97 | ColorTint = "ColorTint", 98 | SpriteSwap = "SpriteSwap", 99 | Animation = "Animation", 100 | } 101 | } 102 | 103 | Ui.Text = { 104 | HorizontalOverflow = { 105 | Wrap = "Wrap", 106 | Overflow = "Overflow", 107 | }, 108 | VerticalOverflow = { 109 | Truncate = "Truncate", 110 | Overflow = "Overflow", 111 | } 112 | } 113 | 114 | ---@generic Attributes : tts__UIElementBase_Attributes 115 | ---@generic Child : tts__UIElement 116 | ---@generic Element : tts__UIElementBase 117 | ---@param element Element 118 | ---@param attributes Attributes 119 | function Ui.setAttributes(element, attributes) 120 | if not element.attributes then 121 | element.attributes = --[[---@type Attributes]] {} 122 | end 123 | 124 | for k, v in pairs(attributes) do 125 | (--[[---@type any]] element.attributes)[k] = v 126 | end 127 | end 128 | 129 | -- UI.setAttribute is buggy and in certain cases unrelated attributes to the one being updated are visually discarded. 130 | -- This is particularly prominent with buttons, setting 'text' or 'interactable' result in 'color' and 'textColor' being 131 | -- discarded. We provide convenience functions that (re)set all attributes rather than just the one/few being targetted. 132 | Ui.Runtime = {} 133 | 134 | ---@param id tts__UIElement_Id 135 | ---@param name string 136 | ---@param value string | number | boolean 137 | ---@return boolean 138 | function Ui.Runtime.setAttribute(id, name, value) 139 | local attributes = UI.getAttributes(id) 140 | attributes[name] = tostring(value) 141 | return UI.setAttributes(id, attributes) 142 | end 143 | 144 | ---@generic V : (string | number | boolean) 145 | ---@param id tts__UIElement_Id 146 | ---@param attributes table 147 | ---@return boolean 148 | function Ui.Runtime.setAttributes(id, attributes) 149 | local allAttributes = UI.getAttributes(id) 150 | 151 | for k, v in pairs(attributes) do 152 | allAttributes[k] = tostring(v) 153 | end 154 | 155 | return UI.setAttributes(id, allAttributes) 156 | end 157 | 158 | return Ui 159 | -------------------------------------------------------------------------------- /Logger.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | ---@shape ge_tts__Logger 4 | ---@field getFilterLevel fun(): ge_tts__Logger_LogLevel 5 | ---@field setFilterLevel fun(level: ge_tts__Logger_LogLevel | `Logger.ERROR` | `Logger.WARNING` | `Logger.INFO` | `Logger.DEBUG` | `Logger.VERBOSE`): void 6 | ---@field log fun(message: string, level?: ge_tts__Logger_LogLevel | `Logger.ERROR` | `Logger.WARNING` | `Logger.INFO` | `Logger.DEBUG` | `Logger.VERBOSE`): void 7 | ---@field assert fun(value: any, message: string): void 8 | 9 | ---@class ge_tts__DefaultLogger : ge_tts__Logger 10 | 11 | ---@class ge_tts__static_Logger 12 | ---@overload fun(): ge_tts__DefaultLogger 13 | local Logger = {} 14 | 15 | Logger.ERROR = 1 16 | Logger.WARNING = 2 17 | Logger.INFO = 3 18 | Logger.DEBUG = 4 19 | Logger.VERBOSE = 5 20 | 21 | ---@alias ge_tts__Logger_LogLevel 1 | 2 | 3 | 4 | 5 22 | 23 | ---@type table 24 | local levelPrefixes = { 25 | [Logger.ERROR] = 'ERROR: ', 26 | [Logger.WARNING] = 'WARNING: ', 27 | [Logger.INFO] = '', 28 | [Logger.DEBUG] = '', 29 | [Logger.VERBOSE] = '', 30 | } 31 | 32 | ---@type ge_tts__Logger_LogLevel 33 | local defaultLogLevel = Logger.DEBUG 34 | 35 | setmetatable(Logger, { 36 | ---@return ge_tts__DefaultLogger 37 | __call = function() 38 | local self = --[[---@type ge_tts__DefaultLogger]] {} 39 | 40 | ---@type ge_tts__Logger_LogLevel 41 | local filterLevel = Logger.INFO 42 | 43 | ---@return ge_tts__Logger_LogLevel 44 | function self.getFilterLevel() 45 | return filterLevel 46 | end 47 | 48 | ---@param level ge_tts__Logger_LogLevel | `Logger.ERROR` | `Logger.WARNING` | `Logger.INFO` | `Logger.DEBUG` | `Logger.VERBOSE` 49 | function self.setFilterLevel(level) 50 | filterLevel = level 51 | end 52 | 53 | ---@param message string 54 | ---@param level? ge_tts__Logger_LogLevel | `Logger.ERROR` | `Logger.WARNING` | `Logger.INFO` | `Logger.DEBUG` | `Logger.VERBOSE` 55 | function self.log(message, level) 56 | level = level or defaultLogLevel 57 | 58 | if level <= filterLevel then 59 | print(levelPrefixes[--[[---@not nil]] level] .. message) 60 | end 61 | end 62 | 63 | --- 64 | ---If value is false, logs message at level Logger.ERROR and then calls Lua's in-built error(message). 65 | --- 66 | ---@param value any 67 | ---@param message string 68 | function self.assert(value, message) 69 | if not value then 70 | self.log(message, Logger.ERROR) 71 | error(message, 2) 72 | end 73 | end 74 | 75 | return self 76 | end 77 | }) 78 | 79 | ---@type ge_tts__Logger 80 | local defaultLogger = Logger() 81 | 82 | ---@param logger ge_tts__Logger 83 | function Logger.setDefaultLogger(logger) 84 | defaultLogger = logger 85 | end 86 | 87 | function Logger.getDefaultLogger() 88 | return defaultLogger 89 | end 90 | 91 | --- 92 | ---When calling log() without specifying a log level, messages will log at the provided log level. 93 | --- 94 | ---@param level ge_tts__Logger_LogLevel | `Logger.ERROR` | `Logger.WARNING` | `Logger.INFO` | `Logger.DEBUG` | `Logger.VERBOSE` 95 | function Logger.setDefaultLogLevel(level) 96 | defaultLogLevel = level 97 | end 98 | 99 | --- 100 | ---Returns the default log level. 101 | --- 102 | ---@return ge_tts__Logger_LogLevel 103 | function Logger.getDefaultLogLevel() 104 | return defaultLogLevel 105 | end 106 | 107 | --- 108 | ---Logs a message at the specified log level. If level is omitted, the default log level will be used. 109 | --- 110 | ---@overload fun(message: string): void 111 | ---@param message string 112 | ---@param level? ge_tts__Logger_LogLevel | `Logger.ERROR` | `Logger.WARNING` | `Logger.INFO` | `Logger.DEBUG` | `Logger.VERBOSE` 113 | function Logger.log(message, level) 114 | level = level or defaultLogLevel 115 | defaultLogger.log(message, --[[---@not nil]] level) 116 | end 117 | 118 | --- 119 | ---If value is false, logs message at level Logger.ERROR using the default logger, and then calls Lua's error(message). 120 | --- 121 | ---@param value any 122 | ---@param message string 123 | function Logger.assert(value, message) 124 | if not value then 125 | defaultLogger.log(message, Logger.ERROR) 126 | error(message, 2) 127 | end 128 | end 129 | 130 | return Logger 131 | -------------------------------------------------------------------------------- /Graph.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local TableUtils = require('ge_tts.TableUtils') 4 | local Graph = {} 5 | 6 | ---@generic T 7 | ---@param node T 8 | ---@param getChildren fun(node: T): nil | T[] @A callback that when passed a node, must return a table of the node's children to be traversed, or nil. 9 | ---@param visitCallback fun(node: T) @If callback returns *any* value (including nil), then traversal is halted and the value is returned. 10 | ---@return thread 11 | local function breadthVisitCoroutine(node, getChildren, visitCallback) 12 | return coroutine.create(function() 13 | ---@type std__Packed 14 | local result = table.pack(visitCallback(node)) 15 | 16 | if result.n > 0 then 17 | return table.unpack(result, 1, result.n) 18 | end 19 | 20 | local children = getChildren(node) 21 | 22 | if children then 23 | local visitDescendantCoroutines = TableUtils.map(--[[---@type any[] ]] children, function(child) 24 | return breadthVisitCoroutine(child, getChildren, visitCallback) 25 | end) 26 | 27 | local stopped = true 28 | 29 | repeat 30 | coroutine.yield() 31 | 32 | stopped = true 33 | 34 | for _, visitDescendant in ipairs(visitDescendantCoroutines) do 35 | if coroutine.status(visitDescendant) == 'suspended' then 36 | result = table.pack(coroutine.resume(visitDescendant)) 37 | 38 | if #result > 1 then 39 | return table.unpack(result, 2) 40 | end 41 | 42 | stopped = false 43 | end 44 | end 45 | until stopped 46 | end 47 | end) 48 | end 49 | 50 | --- Performs preorder traversal over a node hierarchy starting at `node`. If `visitCallback` returns a value, traversal stops and the value is returned. 51 | ---@generic T 52 | ---@param node T 53 | ---@param getChildren fun(node: T): nil | T[] @A callback that when passed a node, must return a table of the node's children to be traversed, or nil. 54 | ---@param visitCallback fun(node: T) @If callback returns *any* value (including nil), then traversal is halted and the value is returned. 55 | ---@return any... @The return value of callback, or no return value if the entire tree traverses without callback returning a value. 56 | function Graph.traverse(node, getChildren, visitCallback) 57 | local result = table.pack(visitCallback(node)) 58 | 59 | if #result > 1 then 60 | return table.unpack(result, 2) 61 | end 62 | 63 | local children = getChildren(node) 64 | 65 | if children then 66 | for _, child in ipairs(--[[---@not nil]] children) do 67 | result = table.pack(Graph.traverse(child, getChildren, visitCallback)) 68 | 69 | if #result > 1 then 70 | return table.unpack(result, 2) 71 | end 72 | end 73 | end 74 | end 75 | 76 | --- Performs breadth first traversal over a node hierarchy starting at `node`. If `visitCallback` returns a value, traversal stops and the value is returned. 77 | ---@generic T 78 | ---@param root T 79 | ---@param getChildren fun(node: T): nil | T[] @A callback that when passed a node, must return a table of the node's children to be traversed, or nil. 80 | ---@param visitCallback fun(node: T) @If callback returns *any* value (including nil), then traversal is halted and the value is returned. 81 | ---@return any @The return value of callback, or no return value if the entire tree traverses without callback returning a value. 82 | function Graph.breadthTraverse(root, getChildren, visitCallback) 83 | local breadthVisit = breadthVisitCoroutine(root, getChildren, visitCallback) 84 | 85 | repeat 86 | local result = table.pack(coroutine.resume(breadthVisit)) 87 | 88 | if #result > 1 then 89 | return --[[---@not nil]] table.unpack(--[[---@type std__Packed]] result, 2) 90 | end 91 | until coroutine.status(breadthVisit) ~= 'suspended' 92 | end 93 | 94 | --- Perform breadth first search over a node hierarchy starting at `node`, and returning the first node for which `visitCallback` returns true. 95 | ---@generic T 96 | ---@param root T 97 | ---@param getChildren fun(node: T): nil | T[] @A callback that when passed a node, must return a table of the node's children to be traversed, may be length zero. 98 | ---@param visitCallback fun(node: T) @Condition callback 99 | ---@return nil | T 100 | function Graph.find(root, getChildren, visitCallback) 101 | return Graph.breadthTraverse(root, getChildren, function(node) 102 | if visitCallback(node) then 103 | return node 104 | end 105 | end) 106 | end 107 | 108 | return Graph 109 | -------------------------------------------------------------------------------- /Base64.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | -- Base64 implementation originally based on https://github.com/iskolbin/lbase64 (public domain), 4 | -- but modified for simplicity, TTS and to work with number[] buffers, rather than strings. 5 | 6 | local TableUtils = require('ge_tts.TableUtils') 7 | 8 | ---@class ge_tts__Base64 9 | local Base64 = {} 10 | 11 | local extract = bit32.extract 12 | 13 | local PAD_KEY = 64 14 | 15 | ---@param char62? nil | string 16 | ---@param char63? nil | string 17 | ---@param charPad? nil | string 18 | ---@return table 19 | function Base64.encodingMap(char62, char63, charPad) 20 | ---@type table 21 | local encodingTable = {} 22 | 23 | for b64code, char in pairs({ 24 | [0] = 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 25 | 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 26 | 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 27 | 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', 28 | '3', '4', '5', '6', '7', '8', '9', char62 or '+', char63 or '/', charPad or '=' 29 | }) do 30 | encodingTable[b64code] = char:byte() 31 | end 32 | 33 | return encodingTable 34 | end 35 | 36 | ---@overload fun(char62: string, char63: string): table 37 | ---@overload fun(char62: string): table 38 | ---@overload fun(): table 39 | ---@param char62 string 40 | ---@param char63 string 41 | ---@param charPad? string 42 | ---@return table 43 | function Base64.decodingMap(char62, char63, charPad) 44 | return TableUtils.invert(Base64.encodingMap(char62, char63, charPad)) 45 | end 46 | 47 | local DEFAULT_ENCODING_MAP = Base64.encodingMap() 48 | local DEFAULT_DECODING_MAP = Base64.decodingMap() 49 | 50 | ---@overload fun(buffer: number[], pad: boolean): string 51 | ---@overload fun(buffer: number[]): string 52 | ---@param buffer number[] 53 | ---@param pad boolean 54 | ---@param map table 55 | ---@return string 56 | function Base64.encode(buffer, pad, map) 57 | pad = pad == nil or pad 58 | map = map or DEFAULT_ENCODING_MAP 59 | 60 | ---@type string[] 61 | local components = {} 62 | local index = 1 63 | local length = #buffer 64 | local lastComponentSize = length % 3 65 | 66 | for offset = 1, length - lastComponentSize, 3 do 67 | local a, b, c = --[[---@not nil, nil, nil]] table.unpack(buffer, offset, offset + 2) 68 | local v = a * 0x10000 + b * 0x100 + c 69 | 70 | components[index] = string.char(map[extract(v, 18, 6)], map[extract(v, 12, 6)], map[extract(v, 6, 6)], map[extract(v, 0, 6)]) 71 | index = index + 1 72 | end 73 | 74 | if lastComponentSize == 2 then 75 | local a, b = --[[---@not nil, nil]] table.unpack(buffer, length - 1, length) 76 | local v = a * 0x10000 + b * 0x100 77 | 78 | components[index] = string.char(map[extract(v, 18, 6)], map[extract(v, 12, 6)], map[extract(v, 6, 6)]) .. (pad and string.char(map[PAD_KEY]) or '') 79 | elseif lastComponentSize == 1 then 80 | local v = buffer[length] * 0x10000 81 | 82 | components[index] = string.char(map[extract(v, 18, 6)], map[extract(v, 12, 6)]) .. (pad and string.char(map[PAD_KEY], map[PAD_KEY]) or '') 83 | end 84 | 85 | return table.concat(components) 86 | end 87 | 88 | ---@overload fun(b64: string): number[] 89 | ---@param b64 string 90 | ---@param map table 91 | ---@return number[] 92 | function Base64.decode(b64, map) 93 | map = map or DEFAULT_DECODING_MAP 94 | 95 | ---@type number[] 96 | local buffer = {} 97 | local offset = 1 98 | 99 | local length = #b64 100 | 101 | if map[--[[---@not nil]] b64:sub(-2, -2):byte()] == PAD_KEY then 102 | length = length - 2 103 | elseif map[--[[---@not nil]] b64:sub(-1, -1):byte()] == PAD_KEY then 104 | length = length - 1 105 | end 106 | 107 | local lastBlockSize = length % 4 108 | local fullBlockEnd = length - lastBlockSize 109 | 110 | for i = 1, fullBlockEnd, 4 do 111 | local a, b, c, d = --[[---@not nil, nil, nil, nil]] b64:byte(i, i + 3) 112 | 113 | local v = map[a] * 0x40000 + map[b] * 0x1000 + map[c] * 0x40 + map[d] 114 | 115 | buffer[offset] = extract(v, 16, 8) 116 | buffer[offset + 1] = extract(v, 8, 8) 117 | buffer[offset + 2] = extract(v, 0, 8) 118 | 119 | offset = offset + 3 120 | end 121 | 122 | 123 | if lastBlockSize == 3 then 124 | local a, b, c = --[[---@not nil, nil, nil]] b64:byte(fullBlockEnd + 1, fullBlockEnd + 3) 125 | local v = map[a] * 0x40000 + map[b] * 0x1000 + map[c] * 0x40 126 | 127 | buffer[offset] = extract(v, 16, 8) 128 | buffer[offset + 1] = extract(v, 8, 8) 129 | elseif lastBlockSize == 2 then 130 | local a, b = --[[---@not nil, nil]] b64:byte(fullBlockEnd + 1, fullBlockEnd + 2) 131 | local v = map[a] * 0x40000 + map[b] * 0x1000 132 | 133 | buffer[offset] = extract(v, 16, 8) 134 | end 135 | 136 | return buffer 137 | end 138 | 139 | return Base64 140 | -------------------------------------------------------------------------------- /DieInstance.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Class = require('ge_tts.Class') 4 | local EventManager = require('ge_tts.EventManager') 5 | local Instance = require('ge_tts.Instance') 6 | local Object = require('ge_tts.Object') 7 | local Vector3 = require('ge_tts.Vector3') 8 | 9 | ---@class ge_tts__DieInstance : ge_tts__Instance 10 | 11 | ---@class ge_tts__static_DieInstance : ge_tts__static_Instance 12 | ---@overload fun(savedState: ge_tts__Instance_SavedState): ge_tts__DieInstance 13 | ---@overload fun(object: tts__Object): ge_tts__DieInstance 14 | ---@overload fun(guid: string, container: tts__Container): ge_tts__DieInstance 15 | ---@overload fun(objectOrSavedState: tts__Object | ge_tts__Instance_SavedState): ge_tts__DieInstance 16 | ---@overload fun(objectOrGuidOrSavedState: tts__Object | string | ge_tts__Instance_SavedState, nilOrContainer: nil | tts__Container): ge_tts__DieInstance 17 | local DieInstance = {} 18 | 19 | DieInstance.TYPE = 'Die' 20 | 21 | setmetatable(DieInstance, { 22 | ---@param class self 23 | ---@param objectOrGuidOrSavedState tts__Object | string | ge_tts__Instance_SavedState 24 | ---@param nilOrContainer nil | tts__Container 25 | __call = function(class, objectOrGuidOrSavedState, nilOrContainer) 26 | local self = --[[---@type ge_tts__DieInstance]] Class.parentConstructor(self, Instance)( 27 | objectOrGuidOrSavedState, 28 | nilOrContainer 29 | ) 30 | 31 | self.getObject().registerCollisions() 32 | 33 | ---@param value number | string 34 | ---@param playerColor nil | tts__PlayerColor 35 | function self.onRolled(value, playerColor) 36 | end 37 | 38 | ---@param value number | string 39 | ---@param playerColor nil | tts__PlayerColor 40 | function self.onRotationValueUpdated(value, playerColor) 41 | end 42 | 43 | return self 44 | end, 45 | __index = Instance, 46 | }) 47 | 48 | local MIN_ROLL_ANGULAR_VELOCITY_SQUARED = 2 * math.pi * math.pi 49 | 50 | ---@type table 51 | local monitoredDice = {} 52 | 53 | ---@param object tts__Object 54 | ---@param playerColor nil | tts__PlayerColor 55 | ---@param isRandomizing boolean 56 | local onObjectUpdating = function(object, playerColor, isRandomizing) 57 | if monitoredDice[object] or object.type ~= Object.Type.Die then 58 | return 59 | end 60 | 61 | local instance = Instance.getOneInstance(object) 62 | 63 | if instance and (--[[---@type ge_tts__DieInstance]] instance).onRolled ~= nil then 64 | local dieInstance = (--[[---@type ge_tts__DieInstance]] instance) 65 | local initialRotationValue = object.getRotationValue() 66 | local isRolling = isRandomizing 67 | 68 | monitoredDice[object] = true 69 | 70 | local onRollDetected = function() 71 | Wait.condition(function() 72 | monitoredDice[object] = nil 73 | 74 | if object ~= nil then 75 | local value = object.getRotationValue() 76 | dieInstance.onRolled(value, playerColor) 77 | dieInstance.onRotationValueUpdated(value, playerColor) 78 | end 79 | end, function() 80 | return object == nil or object.resting 81 | end) 82 | end 83 | 84 | if isRolling then 85 | Wait.frames(function() 86 | onRollDetected() 87 | end) 88 | else 89 | local minRandomizeYVelocity = 1.5 * (math.abs(Physics.getGravity().y) ^ 0.5) 90 | 91 | Wait.condition(function() 92 | if isRolling then 93 | onRollDetected() 94 | else 95 | if object ~= nil then 96 | local value = object.getRotationValue() 97 | 98 | if value ~= initialRotationValue then 99 | dieInstance.onRotationValueUpdated(value, playerColor) 100 | end 101 | end 102 | monitoredDice[object] = nil 103 | end 104 | end, function() 105 | if object == nil or object.resting then 106 | return true 107 | end 108 | 109 | isRolling = not object.isSmoothMoving() and object.held_by_color == nil and ( 110 | (object.getRotationValue() ~= initialRotationValue and Vector3.lengthSquared(object.getAngularVelocity()) > MIN_ROLL_ANGULAR_VELOCITY_SQUARED) 111 | or object.getVelocity().y > minRandomizeYVelocity 112 | ) 113 | 114 | return isRolling 115 | end, 20) 116 | end 117 | end 118 | end 119 | 120 | ---@param playerColor nil | tts__PlayerColor 121 | ---@param object tts__Object 122 | local function onObjectDrop(playerColor, object) 123 | onObjectUpdating(object, playerColor, false) 124 | end 125 | 126 | ---@param registeredObject tts__Object 127 | local function onObjectCollisionExit(registeredObject) 128 | onObjectUpdating(registeredObject, nil, false) 129 | end 130 | 131 | ---@param object tts__Object 132 | ---@param playerColor tts__PlayerColor 133 | local function onObjectRandomize(object, playerColor) 134 | onObjectUpdating(object, playerColor, true) 135 | end 136 | 137 | EventManager.addHandler('onObjectRandomize', onObjectRandomize) 138 | EventManager.addHandler('onObjectDrop', onObjectDrop) 139 | EventManager.addHandler('onObjectCollisionExit', onObjectCollisionExit) 140 | 141 | return DieInstance 142 | -------------------------------------------------------------------------------- /lunajson/encoder.lua: -------------------------------------------------------------------------------- 1 | local error = error 2 | local byte, find, format, gsub, match = string.byte, string.find, string.format, string.gsub, string.match 3 | local concat = table.concat 4 | local tostring = tostring 5 | local rawget, pairs, type, next = rawget, pairs, type, next 6 | local setmetatable = setmetatable 7 | local huge, tiny = 1/0, -1/0 8 | 9 | local f_string_esc_pat = '[^ -!#-[%]^-\255]' 10 | local _ENV = nil 11 | 12 | ---@shape lunajson__EncodeDispatcher 13 | ---@field boolean fun(v: boolean): void 14 | ---@field number fun(v: number): void 15 | ---@field string fun(v: string): void 16 | ---@field table fun(v: table): void 17 | 18 | ---@alias lunajson__GenerateValueEncode fun(nullv: any, dispatcher: lunajson__EncodeDispatcher, push: (fun(component: string): void), replace: (fun(replacer: (fun(builder: string[], next: number): string[], number)): void)): (fun(v: any): void) 19 | 20 | local function newencoder() 21 | ---@type any, any 22 | local v, nullv 23 | 24 | ---@type number, string[], table 25 | local i, builder, visited 26 | 27 | ---@param v any 28 | local function f_tostring(v) 29 | builder[i] = tostring(v) 30 | i = i+1 31 | end 32 | 33 | local radixmark = --[[---@type nil | string]] match(tostring(0.5), '[^0-9]') 34 | local delimmark = --[[---@type string]] match(tostring(12345.12345), '[^0-9' .. radixmark .. ']') 35 | if radixmark == '.' then 36 | radixmark = nil 37 | end 38 | 39 | ---@type nil | true 40 | local radixordelim 41 | if radixmark or delimmark then 42 | radixordelim = true 43 | if radixmark and find(--[[---@not nil]] radixmark, '%W') then 44 | radixmark = '%' .. radixmark 45 | end 46 | if delimmark and find(delimmark, '%W') then 47 | delimmark = '%' .. delimmark 48 | end 49 | end 50 | 51 | ---@param n number 52 | local f_number = function(n) 53 | if tiny < n and n < huge then 54 | local s = format("%.17g", n) 55 | if radixordelim then 56 | if delimmark then 57 | s = gsub(s, delimmark, '') 58 | end 59 | if radixmark then 60 | s = gsub(s, --[[---@not nil]] radixmark, '.') 61 | end 62 | end 63 | builder[i] = s 64 | i = i+1 65 | return 66 | end 67 | error('invalid number') 68 | end 69 | 70 | ---@type fun(v: any): void 71 | local doencode 72 | 73 | local f_string_subst = { 74 | ['"'] = '\\"', 75 | ['\\'] = '\\\\', 76 | ['\b'] = '\\b', 77 | ['\f'] = '\\f', 78 | ['\n'] = '\\n', 79 | ['\r'] = '\\r', 80 | ['\t'] = '\\t', 81 | __index = function(_, c) 82 | return format('\\u00%02X', byte(c)) 83 | end 84 | } 85 | setmetatable(f_string_subst, f_string_subst) 86 | 87 | ---@param s string 88 | local function f_string(s) 89 | builder[i] = '"' 90 | if find(s, f_string_esc_pat) then 91 | s = gsub(s, f_string_esc_pat, f_string_subst) 92 | end 93 | builder[i+1] = s 94 | builder[i+2] = '"' 95 | i = i+3 96 | end 97 | 98 | ---@param o table 99 | local function f_table(o) 100 | if visited[o] then 101 | error("loop detected") 102 | end 103 | visited[o] = true 104 | 105 | local tmp = o.n 106 | if type(tmp) == 'number' then -- arraylen available 107 | builder[i] = '[' 108 | i = i+1 109 | for j = 1, tmp do 110 | doencode(o[j]) 111 | builder[i] = ',' 112 | i = i+1 113 | end 114 | if tmp > 0 then 115 | i = i-1 116 | end 117 | builder[i] = ']' 118 | 119 | else 120 | tmp = rawget(o, 1) 121 | if tmp ~= nil then -- detected as array 122 | builder[i] = '[' 123 | i = i+1 124 | local j = 2 125 | repeat 126 | doencode(tmp) 127 | tmp = o[j] 128 | if tmp == nil then 129 | break 130 | end 131 | j = j+1 132 | builder[i] = ',' 133 | i = i+1 134 | until false 135 | builder[i] = ']' 136 | 137 | else -- detected as object 138 | builder[i] = '{' 139 | i = i+1 140 | for k, v in pairs(o) do 141 | if type(k) ~= 'string' then 142 | error('non-string key: ' .. tostring(k) .. ' (' .. type(k) .. ')') 143 | end 144 | f_string(k) 145 | builder[i] = ':' 146 | i = i+1 147 | doencode(v) 148 | builder[i] = ',' 149 | i = i+1 150 | end 151 | if next(o) then 152 | i = i-1 153 | end 154 | builder[i] = '}' 155 | end 156 | end 157 | 158 | i = i+1 159 | visited[o] = nil 160 | end 161 | 162 | ---@type lunajson__EncodeDispatcher 163 | local dispatcher = { 164 | boolean = f_tostring, 165 | number = f_number, 166 | string = f_string, 167 | table = f_table, 168 | __index = function(_, key) 169 | error("invalid type value: " .. key) 170 | end 171 | } 172 | 173 | setmetatable(dispatcher, dispatcher) 174 | 175 | ---@param v any 176 | local function defaultencode(v) 177 | if v == nullv then 178 | builder[i] = 'null' 179 | i = i+1 180 | return 181 | end 182 | return dispatcher[--[[---@not 'nil' | 'function' | 'thread' | 'userdata']] type(v)](v) 183 | end 184 | 185 | ---@param component string 186 | local function push(component) 187 | builder[i] = component 188 | i = i+1 189 | end 190 | 191 | ---@param replacer fun(builder: string[], next: number): string[], number 192 | local function replace(replacer) 193 | builder, i = replacer(builder, i) 194 | end 195 | 196 | ---@param v_ any 197 | ---@param nullv_? any 198 | ---@param generate_value_encode? nil | lunajson__GenerateValueEncode 199 | local function encode(v_, nullv_, generate_value_encode) 200 | v, nullv = v_, nullv_ 201 | i, builder, visited = 1, {}, {} 202 | doencode = generate_value_encode and generate_value_encode(nullv, dispatcher, push, replace) or defaultencode 203 | 204 | doencode(v) 205 | return concat(builder) 206 | end 207 | 208 | return encode 209 | end 210 | 211 | return newencoder 212 | -------------------------------------------------------------------------------- /Object.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Object = {} 4 | 5 | Object.Name = { 6 | AssetBundle = "Custom_Assetbundle", 7 | BackgammonBoard = "backgammon_board", 8 | BackgammonPieceBrown = "backgammon_piece_brown", 9 | BackgammonPieceWhite = "backgammon_piece_white", 10 | Bag = "Bag", 11 | BlockRectangle = "BlockRectangle", 12 | BlockSquare = "BlockSquare", 13 | BlockTriangle = "BlockTriangle", 14 | Board = "Custom_Board", 15 | Card = "Card", 16 | CardCustom = "CardCustom", 17 | CheckerBlack = "Checker_black", 18 | CheckerBoard = "Checker_Board", 19 | CheckerRed = "Checker_red", 20 | CheckerWhite = "Checker_white", 21 | ChessBishop = "Chess_Bishop", 22 | ChessBoard = "Chess_Board", 23 | ChessKing = "Chess_King", 24 | ChessKnight = "Chess_Knight", 25 | ChessPawn = "Chess_Pawn", 26 | ChessQueen = "Chess_Queen", 27 | ChessRook = "Chess_Rook", 28 | ChineseCheckersBoard = "Chinese_Checkers_Board", 29 | ChineseCheckersPiece = "Chinese_Checkers_Piece", 30 | Chip10 = "Chip_10", 31 | Chip50 = "Chip_50", 32 | Chip100 = "Chip_100", 33 | Chip500 = "Chip_500", 34 | ChiP1000="Chip_1000", 35 | Deck = "Deck", 36 | DeckCardBotHead = "Deck_CardBot_Head", 37 | DeckCardBotMain = "Deck_CardBot_Main", 38 | DeckCustom = "DeckCustom", 39 | Die4 = "Die_4", 40 | Die6 = "Die_6", 41 | Die6Rounded = "Die_6_Rounded", 42 | Die8 = "Die_8", 43 | Die10 = "Die_10", 44 | Die12 = "Die_12", 45 | Die20 = "Die_20", 46 | DieCustom = "Custom_Dice", 47 | DiePiecepack = "Die_Piecepack", 48 | DigitalClock = "Digital_Clock", 49 | Domino = "Domino", 50 | FigurineCardBot = "Figurine_Card_Bot", 51 | FigurineCustom = "Figurine_Custom", 52 | FigurineKimiKat = "Figurine_Kimi_Kat", 53 | FigurineKnil = "Figurine_Knil", 54 | FigurineMara = "Figurine_Mara", 55 | FigurineSirLoin = "Figurine_Sir_Loin", 56 | FigurineZeke = "Figurine_Zeke", 57 | FigurineZomblor = "Figurine_Zomblor", 58 | FogOfWarTrigger = "FogOfWarTrigger", 59 | GoBoard = "Go_Board", 60 | GoGameBowlBlack = "go_game_bowl_black", 61 | GoGameBowlWhite = "go_game_bowl_white", 62 | GoGamePieceBlack = "go_game_piece_black", 63 | GoGamePieceWhite = "go_game_piece_white", 64 | InfiniteBag = "Infinite_Bag", 65 | MahjongTile = "Mahjong_Tile", 66 | MetalBall = "Ball", 67 | Model = "Custom_Model", 68 | Pachisiboard = "Pachisi_board", 69 | PlayerPawn = "PlayerPawn", 70 | Quarter = "Quarter", 71 | ReversiBoard = "reversi_board", 72 | ReversiChip = "reversi_chip", 73 | RPGBear = "rpg_BEAR", 74 | RPGChimera = "rpg_CHIMERA", 75 | RPGCyclop = "rpg_CYCLOP", 76 | RPGDragonide = "rpg_DRAGONIDE", 77 | RPGEvilWatcher = "rpg_EVIL_WATCHER", 78 | RPGGhoul = "rpg_GHOUL", 79 | RPGGiantViper = "rpg_GIANT_VIPER", 80 | RPGGoblin = "rpg_GOBLIN", 81 | RPGGolem = "rpg_GOLEM", 82 | RPGGriffon = "rpg_GRIFFON", 83 | RPGHydra = "rpg_HYDRA", 84 | RPGKobold = "rpg_KOBOLD", 85 | RPGLizardWarrior = "rpg_LIZARD_WARRIOR", 86 | RPGManticora = "rpg_MANTICORA", 87 | RPGMummy = "rpg_MUMMY", 88 | RPGOgre = "rpg_OGRE", 89 | RPGOrc = "rpg_ORC", 90 | RPGRat = "rpg_RAT", 91 | RPGSkeletonKnight = "rpg_SKELETON_KNIGHT", 92 | RPGTreeEnt = "rpg_TREE_ENT", 93 | RPGTroll = "rpg_TROLL", 94 | RPGVampire = "rpg_VAMPIRE", 95 | RPGWerewolf = "rpg_WEREWOLF", 96 | RPGWolf = "rpg_WOLF", 97 | RPGWyvern = "rpg_WYVERN", 98 | ScriptingTrigger = "ScriptingTrigger", 99 | Tablet = "Tablet", 100 | Tile = "Custom_Tile", 101 | TilesetBarrel = "Tileset_Barrel", 102 | TilesetChair = "Tileset_Chair", 103 | TilesetChest = "Tileset_Chest", 104 | TilesetCorner = "Tileset_Corner", 105 | TilesetFloor = "Tileset_Floor", 106 | TilesetRock = "Tileset_Rock", 107 | TilesetTable = "Tileset_Table", 108 | TilesetTree = "Tileset_Tree", 109 | TilesetWall = "Tileset_Wall", 110 | Token = "Custom_Token", 111 | } 112 | 113 | Object.Type = { 114 | BackgammonPiece = "Backgammon Piece", 115 | Bag = "Bag", 116 | Block = "Block", 117 | Board = "Board", 118 | Calculator = "Calculator", 119 | Card = "Card", 120 | Checker = "Checker", 121 | Chess = "Chess", 122 | Chip = "Chip", 123 | Clock = "Clock", 124 | Coin = "Coin", 125 | Counter = "Counter", 126 | Deck = "Deck", 127 | Die = "Dice", 128 | Domino = "Domino", 129 | Figurine = "Figurine", 130 | Fog = "Fog", 131 | FogOfWar = "FogOfWar", 132 | Generic = "Generic", 133 | GoPiece = "GoPiece", 134 | Hand = "Hand", 135 | Infinite = "Infinite", 136 | InventoryBackground = "InventoryBackground", 137 | InventoryBotBackground = "InventoryBotBG", 138 | InventoryItemBlank = "InventoryItemBlank", 139 | InventoryTopBackground = "InventoryTopBG", 140 | Jigsaw = "Jigsaw", 141 | JigsawBox = "Jigsaw Box", 142 | MP3 = "Mp3", 143 | Notecard = "Notecard", 144 | Pointer = "Pointer", 145 | Randomize = "Randomize", 146 | RPGFigurine = "rpgFigurine", 147 | Scripting = "Scripting", 148 | Stack = "Stack", 149 | Superfight = "Superfight", 150 | Surface = "Surface", 151 | Tablet = "Tablet", 152 | Text = "3D Text", 153 | Tile = "Tile", 154 | Tileset = "Tileset", 155 | VRUI = "VR UI", 156 | } 157 | 158 | Object.Tag = Object.Type 159 | 160 | Object.AssetBundleType = { 161 | Generic = 0, 162 | Coin = 1, 163 | Bag = 2, 164 | Figurine = 3, 165 | Board = 4, 166 | Infinite = 5, 167 | Dice = 6, 168 | } 169 | 170 | Object.CardType = { 171 | RectangleRounded = 0, 172 | Rectangle = 1, 173 | HexRounded = 2, 174 | Hex = 3, 175 | Circle = 4, 176 | } 177 | 178 | Object.DieType = { 179 | D4 = 0, 180 | D6 = 1, 181 | D8 = 2, 182 | D10 = 3, 183 | D12 = 4, 184 | D20 = 5, 185 | } 186 | 187 | Object.ModelType = { 188 | Generic = 0, 189 | Figurine = 1, 190 | Dice = 2, 191 | Coin = 3, 192 | Board = 4, 193 | Chip = 5, 194 | Bag = 6, 195 | Infinite = 7, 196 | } 197 | 198 | Object.TileType = { 199 | Box = 0, 200 | Hex = 1, 201 | Circle = 2, 202 | Rounded = 3, 203 | } 204 | 205 | Object.MaterialType = { 206 | Plastic = 0, 207 | Wood = 1, 208 | Metal = 2, 209 | Cardboard = 3, 210 | Glass = 4, 211 | } 212 | 213 | return Object 214 | -------------------------------------------------------------------------------- /HandZone.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Class = require('ge_tts.Class') 4 | local ObjectUtils = require('ge_tts.ObjectUtils') 5 | local TableUtils = require('ge_tts.TableUtils') 6 | local Zone = require('ge_tts.Zone') 7 | 8 | ---@class ge_tts__HandZone : ge_tts__Zone 9 | 10 | ---@shape ge_tts__HandZone_SavedState : ge_tts__Zone_SavedState 11 | ---@field ownerColor tts__PlayerHandColor 12 | ---@field handIndex number 13 | 14 | ---@param owner tts__Player 15 | ---@param handIndex number 16 | ---@return tts__Vector, tts__Vector, tts__Vector 17 | local function zoneParameters(owner, handIndex) 18 | local handTransform = owner.getHandTransform(handIndex) 19 | return handTransform.position, handTransform.rotation, handTransform.scale 20 | end 21 | 22 | ---@class ge_tts__static_HandZone : ge_tts__static_Zone 23 | ---@overload fun(owner: tts__Player): ge_tts__HandZone 24 | ---@overload fun(owner: tts__Player, handIndex: nil | number): ge_tts__HandZone 25 | ---@overload fun(savedState: ge_tts__HandZone_SavedState): ge_tts__HandZone 26 | ---@overload fun(ownerOrSavedState: tts__Player | ge_tts__HandZone_SavedState, nilOrHandIndex: nil | number): ge_tts__HandZone 27 | local HandZone = {} 28 | 29 | HandZone.TYPE = 'HandZone' 30 | 31 | setmetatable(HandZone, TableUtils.merge(getmetatable(Zone), { 32 | ---@param class self 33 | ---@param ownerOrSavedState tts__Player | ge_tts__HandZone_SavedState 34 | ---@param nilOrHandIndex nil | number @TTS player hand index, defaults to 1. 35 | __call = function(class, ownerOrSavedState, nilOrHandIndex) 36 | local isSavedState = HandZone.isSavedState(ownerOrSavedState) 37 | local self = --[[---@type ge_tts__HandZone]] ( 38 | isSavedState 39 | and Class.parentConstructor(class, Zone)(--[[---@type ge_tts__HandZone_SavedState]] ownerOrSavedState) 40 | or Class.parentConstructor(class, Zone)( 41 | zoneParameters(--[[---@type tts__Player]] ownerOrSavedState, --[[---@not nil]] nilOrHandIndex) 42 | ) 43 | ) 44 | 45 | ---@type tts__Player 46 | local owner 47 | 48 | ---@type number 49 | local handIndex 50 | 51 | ---@return tts__Player 52 | function self.getOwner() 53 | return owner 54 | end 55 | 56 | ---@return number 57 | function self.getHandIndex() 58 | return handIndex 59 | end 60 | 61 | local superOnEnter = self.onEnter 62 | 63 | --- Called when a TTS object enters this HandZone. 64 | ---@param object tts__Object 65 | function self.onEnter(object) 66 | superOnEnter(object) 67 | 68 | if not object.held_by_color and not self.isObjectOccupying(object) and TableUtils.find(owner.getHandObjects(handIndex), object) then 69 | self.onDrop(owner.color, object) 70 | end 71 | end 72 | 73 | --- Called when a player attempts to drop an object within this zone. The return value 74 | --- indicates whether the zone wishes to accept, reject or ignore the object being dropped. 75 | ---@param colorName tts__PlayerColor @Color of the TTS player that dropped the TTS object. 76 | ---@param object tts__Object 77 | ---@return ge_tts__Zone_FilterResult 78 | function self.filterObject(colorName, object) 79 | return object.use_hands and HandZone.FilterResult.ACCEPT or HandZone.FilterResult.IGNORE 80 | end 81 | 82 | local superDrop = self.drop 83 | 84 | --- Can be called to dynamically drop (deal) a TTS object into this HandZone. Works for containers or objects with `use_hands` enabled. 85 | ---@param colorName nil | tts__PlayerColor @Color of the TTS player that should be deemed responsible for having dropped the TTS object. 86 | ---@param object tts__Object @The object that will be dropped. 87 | function self.drop(colorName, object) 88 | local isContainer = ObjectUtils.isContainerTag(object.type) 89 | self.deal(colorName, object, isContainer and object.getQuantity() or 1) 90 | end 91 | 92 | --- Same as onDrop except that we provide a count which is the maximum number of objects dealt from a container. 93 | ---@param colorName nil | tts__PlayerColor @Color of the TTS player that should be deemed responsible for having dropped the TTS object. 94 | ---@param object tts__Object @The object that will be dropped. 95 | ---@param count number @Number of cards to deal 96 | function self.deal(colorName, object, count) 97 | local isContainer = ObjectUtils.isContainerTag(object.type) 98 | 99 | if isContainer then 100 | -- The deal API doesn't do what we want, so we need to do our best to mimic it with takeObject 101 | local quantity = math.min(count, object.getQuantity()) 102 | 103 | for _ = 1, quantity do 104 | local takenObject = object.takeObject({}) 105 | 106 | if takenObject then 107 | (--[[---@not nil]] takenObject).use_hands = true 108 | ;(--[[---@not nil]] takenObject).deal(1, --[[---@type tts__PlayerHandColor]] owner.color) 109 | 110 | superDrop(colorName, --[[---@not nil]] takenObject) 111 | end 112 | end 113 | else 114 | if object.spawning then 115 | -- Unlike setPositionSmooth, deal does not seem to work reliably on objects that are in the process 116 | -- of spawning. Although, it does seem to work well enough with takeObject() above. 117 | Wait.condition(function() 118 | -- Check that the object is still occupying this hand zone before we try move it here 119 | if self.isObjectOccupying(object) then 120 | object.use_hands = true 121 | object.deal(1, --[[---@type tts__PlayerHandColor]] owner.color, handIndex) 122 | end 123 | end, function() return object ~= nil and not object.spawning end) 124 | else 125 | object.deal(1, --[[---@type tts__PlayerHandColor]] owner.color, handIndex) 126 | end 127 | 128 | superDrop(colorName, object) 129 | end 130 | end 131 | 132 | local superSave = self.save 133 | 134 | ---@return ge_tts__HandZone_SavedState 135 | function self.save() 136 | return --[[---@type ge_tts__HandZone_SavedState]] TableUtils.merge(superSave(), { 137 | ownerColor = owner.color, 138 | handIndex = handIndex, 139 | }) 140 | end 141 | 142 | if isSavedState then 143 | local data = --[[---@type ge_tts__HandZone_SavedState]] ownerOrSavedState 144 | 145 | owner = Player[data.ownerColor] 146 | handIndex = data.handIndex 147 | else 148 | owner = --[[---@type tts__Player]] ownerOrSavedState 149 | handIndex = nilOrHandIndex or 1 150 | end 151 | 152 | return self 153 | end, 154 | __index = Zone, 155 | })) 156 | 157 | return HandZone 158 | -------------------------------------------------------------------------------- /Http.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Json = require('ge_tts.Json') 4 | local TableUtils = require('ge_tts.TableUtils') 5 | 6 | ---@shape ge_tts__Http_Response 7 | ---@field statusCode number 8 | ---@field headers table 9 | ---@field body B 10 | 11 | ---@alias ge_tts__Http_Callback fun(response: nil | ge_tts__Http_Response, error: nil | string): void 12 | 13 | ---@type nil | string 14 | local decodeJsonContentType = 'application/json' 15 | 16 | ---@class ge_tts__Http 17 | local Http = {} 18 | 19 | ---@param type nil | string 20 | function Http.setDecodeJsonContentType(type) 21 | decodeJsonContentType = type 22 | end 23 | 24 | ---@return nil | string 25 | function Http.getDecodeJsonContentType() 26 | return decodeJsonContentType 27 | end 28 | 29 | ---@param headers table 30 | ---@param name string 31 | ---@return nil | string, nil | string @value, headerName - `headerName` being the case-sensitive variant of `name` found within headers 32 | function Http.getHeader(headers, name) 33 | name = name:lower() 34 | 35 | return TableUtils.detect(headers, function(_, key) 36 | return key:lower() == name 37 | end) 38 | end 39 | 40 | ---@generic B 41 | ---@param method string 42 | ---@param url string 43 | ---@param body nil | table | string @If provided as a table, it will be JSON encoded. Otherwise, the body must be provided as a string. Tabletop Simulator can only handle UTF-8 text. If a HTTP endpoint you wish to call requires application/octet-stream, then you can Base64 encode the data and send it via your own HTTP proxy. 44 | ---@param headersOrNil nil | table 45 | ---@param callback ge_tts__Http_Callback 46 | ---@return fun(): void 47 | function Http.submit(method, url, body, headersOrNil, callback) 48 | ---@type table 49 | local headers = headersOrNil and TableUtils.copy(headersOrNil) or {} 50 | local canceled = false 51 | 52 | ---@type nil | fun(): void 53 | local cancelEncoding 54 | 55 | ---@type nil | fun(): void 56 | local cancelDecoding 57 | 58 | ---@param body string 59 | local function performRequest(body) 60 | WebRequest.custom(url, method, true, --[[---@type nil | string]] body, headers, function(request) 61 | if canceled then 62 | return 63 | end 64 | 65 | if request.is_error then 66 | callback(nil, request.error) 67 | elseif request.is_done then 68 | local statusCode = request.response_code 69 | local responseHeaders = request.getResponseHeaders() 70 | local contentType = Http.getHeader(responseHeaders, 'Content-Type') 71 | 72 | if contentType and (--[[---@not nil]] contentType):lower() == decodeJsonContentType then 73 | cancelDecoding = Json.decodeAsync(request.text, { 74 | ---@param value B 75 | onCompletion = function(value) 76 | callback({ 77 | body = value, 78 | headers = responseHeaders, 79 | statusCode = statusCode, 80 | }, nil) 81 | end, 82 | onError = function(message) 83 | callback(nil, "Failed to parse JSON response body: " .. message) 84 | end, 85 | }) 86 | else 87 | callback({ 88 | body = --[[---@type B]] request.text, 89 | headers = responseHeaders, 90 | statusCode = statusCode, 91 | outbound = body, 92 | }, nil) 93 | end 94 | end 95 | end) 96 | end 97 | 98 | if type(body) == 'table' then 99 | local contentType, contentTypeHeader = Http.getHeader(headers, 'Content-Type') 100 | contentTypeHeader = contentTypeHeader or 'Content-Type' 101 | 102 | if not (contentType and (--[[---@not nil]] contentType):sub(-4) == 'json') then 103 | headers = TableUtils.copy(headers) 104 | headers[--[[---@not nil]] contentTypeHeader] = 'application/json' 105 | end 106 | 107 | cancelEncoding = Json.encodeAsync(body, { 108 | onCompletion = function(json) 109 | cancelEncoding = nil 110 | performRequest(json) 111 | end, 112 | onError = function(error) 113 | callback(nil, error) 114 | end, 115 | }) 116 | else 117 | performRequest(--[[---@type string]] body) 118 | end 119 | 120 | return function() 121 | if not canceled then 122 | canceled = true 123 | 124 | if cancelEncoding then 125 | (--[[---@not nil]] cancelEncoding)() 126 | end 127 | 128 | if cancelDecoding then 129 | (--[[---@not nil]] cancelDecoding)() 130 | end 131 | end 132 | end 133 | end 134 | 135 | ---@generic B 136 | ---@param url string 137 | ---@param headers nil | table 138 | ---@param callback ge_tts__Http_Callback 139 | ---@return fun(): void 140 | function Http.delete(url, headers, callback) 141 | return Http.submit('DELETE', url, nil, headers, callback) 142 | end 143 | 144 | ---@generic B 145 | ---@param url string 146 | ---@param headers nil | table 147 | ---@param callback ge_tts__Http_Callback 148 | ---@return fun(): void 149 | function Http.get(url, headers, callback) 150 | return Http.submit('GET', url, nil, headers, callback) 151 | end 152 | 153 | ---@generic B 154 | ---@param url string 155 | ---@param body nil | table | string @If provided as a table, it will be JSON encoded. If provided as a number array, numbers are assumed to be [0, 255] and Base64 encoded. Otherwise, the body is a string. 156 | ---@param headers nil | table 157 | ---@param callback ge_tts__Http_Callback 158 | ---@return fun(): void 159 | function Http.patch(url, body, headers, callback) 160 | return Http.submit('PATCH', url, body, headers, callback) 161 | end 162 | 163 | ---@generic B 164 | ---@param url string 165 | ---@param body nil | table | string @If provided as a table, it will be JSON encoded. If provided as a number array, numbers are assumed to be [0, 255] and Base64 encoded. Otherwise, the body is a string. 166 | ---@param headers nil | table 167 | ---@param callback ge_tts__Http_Callback 168 | ---@return fun(): void 169 | function Http.post(url, body, headers, callback) 170 | return Http.submit('POST', url, body, headers, callback) 171 | end 172 | 173 | ---@generic B 174 | ---@param url string 175 | ---@param body nil | table | string @If provided as a table, it will be JSON encoded. If provided as a number array, numbers are assumed to be [0, 255] and Base64 encoded. Otherwise, the body is a string. 176 | ---@param headers nil | table 177 | ---@param callback ge_tts__Http_Callback 178 | ---@return fun(): void 179 | function Http.put(url, body, headers, callback) 180 | return Http.submit('PUT', url, body, headers, callback) 181 | end 182 | 183 | return Http 184 | -------------------------------------------------------------------------------- /Vector2.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | --- 4 | --- A 2D vector implementation. 5 | --- 6 | --- Components can be accessed as x and y properties, or indexed by numbers [1] and [2]. 7 | --- 8 | ---@class ge_tts__Vector2 : __ge_tts__NumCharVec2Shape 9 | 10 | ---@shape ge_tts__CharVec2Shape 11 | ---@field x number 12 | ---@field y number 13 | 14 | ---@shape ge_tts__NumVec2Shape 15 | ---@field [1] number 16 | ---@field [2] number 17 | 18 | ---@alias ge_tts__Vec2Shape ge_tts__CharVec2Shape | ge_tts__NumVec2Shape 19 | 20 | ---@shape __ge_tts__NumCharVec2Shape 21 | ---@field x T 22 | ---@field y T 23 | ---@field [1] T 24 | ---@field [2] T 25 | 26 | ---@shape ge_tts__NumCharVec2Shape : __ge_tts__NumCharVec2Shape 27 | 28 | ---@param vector ge_tts__Vector2 29 | ---@param index any 30 | local function numberedIndex(vector, index) 31 | if type(index) == 'number' then 32 | if index == 1 then 33 | return vector.x 34 | elseif index == 2 then 35 | return vector.y 36 | end 37 | 38 | return nil 39 | end 40 | end 41 | 42 | local DEGREES_RATIO = 180 / math.pi 43 | local RADIANS_RATIO = math.pi / 180 44 | 45 | ---@class ge_tts__static_Vector2 46 | ---@overload fun(): ge_tts__Vector2 47 | ---@overload fun(x: number, y: number): ge_tts__Vector2 48 | ---@overload fun(source: ge_tts__Vec2Shape): ge_tts__Vector2 49 | local Vector2 = {} 50 | 51 | setmetatable(Vector2, { 52 | ---@param sourceXOrVector nil | __ge_tts__NumCharVec2Shape 53 | ---@param sourceY nil | number 54 | ---@return ge_tts__Vector2 55 | __call = function(_, sourceXOrVector, sourceY) 56 | local self = --[[---@type self]] {x = 0, y = 0} 57 | 58 | setmetatable(self, { 59 | __index = numberedIndex, 60 | __tostring = function(_) 61 | return self.toString() 62 | end, 63 | }) 64 | 65 | if sourceXOrVector then 66 | if type(sourceXOrVector) == 'table' then 67 | local source = --[[---@type __ge_tts__NumCharVec2Shape]] sourceXOrVector 68 | 69 | self.x = source.x or source[1] or self.x 70 | self.y = source.y or source[2] or self.y 71 | else 72 | self.x = --[[---@type number]] sourceXOrVector 73 | self.y = --[[---@type number]] sourceY 74 | end 75 | end 76 | 77 | ---@return string 78 | function self.toString() 79 | return '{x = ' .. self.x .. ', y = ' .. self.y .. '}' 80 | end 81 | 82 | ---@return ge_tts__CharVec2Shape 83 | function self.toData() 84 | return {x = self.x, y = self.y} 85 | end 86 | 87 | ---@return number 88 | function self.lengthSquared() 89 | return Vector2.lengthSquared(self) 90 | end 91 | 92 | ---@return number 93 | function self.length() 94 | return Vector2.length(self) 95 | end 96 | 97 | ---Add a vector to self. 98 | ---@overload fun(v: ge_tts__Vec2Shape): self 99 | ---@param v ge_tts__NumCharVec2Shape 100 | ---@return self 101 | function self.add(v) 102 | self.x = self.x + (v.x or v[1]) 103 | self.y = self.y + (v.y or v[2]) 104 | return self 105 | end 106 | 107 | ---Subtract a vector from self. 108 | ---@overload fun(v: ge_tts__Vec2Shape): self 109 | ---@param v ge_tts__NumCharVec2Shape 110 | ---@return self 111 | function self.sub(v) 112 | self.x = self.x - (v.x or v[1]) 113 | self.y = self.y - (v.y or v[2]) 114 | return self 115 | end 116 | 117 | ---@param factor number | ge_tts__Vec2Shape 118 | ---@return self 119 | function self.scale(factor) 120 | if (type(factor) == 'number') then 121 | self.x = self.x * factor 122 | self.y = self.y * factor 123 | else 124 | self.x = self.x * ((--[[---@type ge_tts__CharVec2Shape]] factor).x or (--[[---@type ge_tts__NumVec2Shape]] factor)[1]) 125 | self.y = self.y * ((--[[---@type ge_tts__CharVec2Shape]] factor).y or (--[[---@type ge_tts__NumVec2Shape]] factor)[2]) 126 | end 127 | 128 | return self 129 | end 130 | 131 | ---@return self 132 | function self.normalize() 133 | return self.scale(1 / self.length()) 134 | end 135 | 136 | ---@param angle number @angle in degrees 137 | ---@return self 138 | function self.rotate(angle) 139 | angle = angle * RADIANS_RATIO 140 | local x = self.x 141 | self.x = x * math.cos(angle) - self.y * math.sin(angle) 142 | self.y = x * math.sin(angle) + self.y * math.cos(angle) 143 | return self 144 | end 145 | 146 | return self 147 | end, 148 | }) 149 | 150 | ---@overload fun(v: ge_tts__Vec2Shape): number 151 | ---@param v ge_tts__NumCharVec2Shape 152 | ---@return number 153 | function Vector2.x(v) 154 | return v.x or v[1] 155 | end 156 | 157 | ---@overload fun(v: ge_tts__Vec2Shape): number 158 | ---@param v ge_tts__NumCharVec2Shape 159 | ---@return number 160 | function Vector2.y(v) 161 | return v.y or v[2] 162 | end 163 | 164 | ---@overload fun(v: ge_tts__Vec2Shape): number 165 | ---@param v ge_tts__NumCharVec2Shape 166 | ---@return number 167 | function Vector2.lengthSquared(v) 168 | local x = v.x or v[1] 169 | local y = v.y or v[2] 170 | return x * x + y * y 171 | end 172 | 173 | ---@param v ge_tts__Vec2Shape 174 | ---@return number 175 | function Vector2.length(v) 176 | return math.sqrt(Vector2.lengthSquared(v)) 177 | end 178 | 179 | ---@param v1 ge_tts__Vec2Shape 180 | ---@param v2 ge_tts__Vec2Shape 181 | ---@return ge_tts__Vector2 182 | function Vector2.add(v1, v2) 183 | return Vector2(v1).add(v2) 184 | end 185 | 186 | ---@param v1 ge_tts__Vec2Shape 187 | ---@param v2 ge_tts__Vec2Shape 188 | ---@return ge_tts__Vector2 189 | function Vector2.sub(v1, v2) 190 | return Vector2(v1).sub(v2) 191 | end 192 | 193 | ---@param v ge_tts__Vec2Shape 194 | ---@param factor number | ge_tts__Vec2Shape 195 | ---@return ge_tts__Vector2 196 | function Vector2.scale(v, factor) 197 | return Vector2(v).scale(factor) 198 | end 199 | 200 | ---@param v ge_tts__Vec2Shape 201 | ---@return ge_tts__Vector2 202 | function Vector2.normalize(v) 203 | return Vector2(v).normalize() 204 | end 205 | 206 | ---@overload fun(v1: ge_tts__Vec2Shape, v2: ge_tts__Vec2Shape): number 207 | ---@param v1 ge_tts__NumCharVec2Shape 208 | ---@param v2 ge_tts__NumCharVec2Shape 209 | ---@return number 210 | function Vector2.cross(v1, v2) 211 | local x1 = v1.x or v1[1] 212 | local y1 = v1.y or v1[2] 213 | 214 | local x2 = v2.x or v2[1] 215 | local y2 = v2.y or v2[2] 216 | 217 | return x1 * y2 - y1 * x2 218 | end 219 | 220 | --- Returns the angle between v1 and v2 in degrees. 221 | ---@overload fun(v1: ge_tts__Vec2Shape, v2: ge_tts__Vec2Shape): number 222 | ---@param v1 ge_tts__NumCharVec2Shape 223 | ---@param v2 ge_tts__NumCharVec2Shape 224 | ---@return number 225 | function Vector2.angle(v1, v2) 226 | return DEGREES_RATIO * math.acos(Vector2.dot(v1, v2) / (Vector2.length(v1) * Vector2.length(v2))) 227 | end 228 | 229 | ---@overload fun(v1: ge_tts__Vec2Shape, v2: ge_tts__Vec2Shape): number 230 | ---@param v1 ge_tts__NumCharVec2Shape 231 | ---@param v2 ge_tts__NumCharVec2Shape 232 | ---@return number 233 | function Vector2.dot(v1, v2) 234 | local x1 = v1.x or v1[1] 235 | local y1 = v1.y or v1[2] 236 | 237 | local x2 = v2.x or v2[1] 238 | local y2 = v2.y or v2[2] 239 | 240 | return x1 * x2 + y1 * y2 241 | end 242 | 243 | ---@overload fun(v1: ge_tts__Vec2Shape, v2: ge_tts__Vec2Shape): number 244 | ---@param v1 ge_tts__NumCharVec2Shape 245 | ---@param v2 ge_tts__NumCharVec2Shape 246 | ---@return number 247 | function Vector2.distanceSquared(v1, v2) 248 | local x1 = v1.x or v1[1] 249 | local y1 = v1.y or v1[2] 250 | 251 | local x2 = v2.x or v2[1] 252 | local y2 = v2.y or v2[2] 253 | 254 | return Vector2.lengthSquared({x = x2 - x1, y = y2 - y1}) 255 | end 256 | 257 | ---@param v1 ge_tts__Vec2Shape 258 | ---@param v2 ge_tts__Vec2Shape 259 | ---@return number 260 | function Vector2.distance(v1, v2) 261 | return math.sqrt(Vector2.distanceSquared(v1, v2)) 262 | end 263 | 264 | ---@overload fun(v: ge_tts__Vec2Shape, angle: number): ge_tts__Vector2 265 | ---@param v ge_tts__NumCharVec2Shape 266 | ---@param angle number @angle in degrees 267 | ---@return ge_tts__Vector2 268 | function Vector2.rotate(v, angle) 269 | angle = angle * RADIANS_RATIO 270 | 271 | local x = v.x or v[1] 272 | local y = v.y or v[2] 273 | 274 | return Vector2( 275 | x * math.cos(angle) - y * math.sin(angle), 276 | x * math.sin(angle) + y * math.cos(angle) 277 | ) 278 | end 279 | 280 | ---@overload fun(v3: tts__VectorShape): ge_tts__Vector2 281 | ---@param v3 ge_tts__NumCharVec3 282 | ---@return ge_tts__Vector2 283 | function Vector2.fromXZ(v3) 284 | return Vector2(v3.x or v3[1], v3.z or v3[3]) 285 | end 286 | 287 | return Vector2 288 | -------------------------------------------------------------------------------- /InfiniteContainerInstance.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Class = require('ge_tts.Class') 4 | local EventManager = require('ge_tts.EventManager') 5 | local Instance = require('ge_tts.Instance') 6 | local ObjectUtils = require('ge_tts.ObjectUtils') 7 | local TableUtils = require('ge_tts.TableUtils') 8 | 9 | ---@shape ge_tts__InfiniteContainerInstance_SavedState : ge_tts__Instance_SavedState 10 | ---@field spawnedGuids string[] 11 | 12 | -- NOTE: We need to explicitly define fields/functions on lambda-style generic classes. 13 | ---@class ge_tts__InfiniteContainerInstance : ge_tts__Instance 14 | ---@field getObject fun(): tts__Container 15 | ---@field setFilterInstanceEnter fun(callback: (nil | fun(instance: T): boolean)): void 16 | ---@field filterObjectEnter fun(object: tts__Object): boolean 17 | ---@field onObjectLeave fun(object: tts__Object): void 18 | ---@field save fun(): ge_tts__InfiniteContainerInstance_SavedState 19 | 20 | --- The implementer owns the built instance and is responsible for saving its state. The only interaction the infinite container will ever take with these 21 | --- instances is that it will destroy() them if they re-enter the infinite container. To be notified of this your instances must either override any created 22 | --- instance's destroy() method, or you can provide a callback on the infinite container with setFilterInstanceEnter(). 23 | ---@alias ge_tts__InfiniteContainerInstance_BuildInstance fun(index: number, object: tts__ObjectState, containerInstance: ge_tts__InfiniteContainerInstance): T 24 | 25 | ---@class ge_tts__static_InfiniteContainerInstance : ge_tts__static_Instance 26 | ---@overload fun(savedState: ge_tts__InfiniteContainerInstance_SavedState, instanceBuilder: ge_tts__InfiniteContainerInstance_BuildInstance): ge_tts__InfiniteContainerInstance 27 | ---@overload fun(container: tts__Container, instanceBuilder: ge_tts__InfiniteContainerInstance_BuildInstance): ge_tts__InfiniteContainerInstance 28 | local InfiniteContainerInstance = {} 29 | 30 | InfiniteContainerInstance.TYPE = 'Infinite Container' 31 | 32 | setmetatable(InfiniteContainerInstance, TableUtils.merge(getmetatable(Instance), { 33 | ---@generic T : ge_tts__Instance 34 | ---@param class self 35 | ---@param containerOrSavedState tts__Container | ge_tts__InfiniteContainerInstance_SavedState 36 | ---@param buildInstance ge_tts__InfiniteContainerInstance_BuildInstance @When an instance is removed, this function is called to create a new instance that will take its place in the infinite container. 37 | __call = function(class, containerOrSavedState, buildInstance) 38 | local isSavedState = InfiniteContainerInstance.isSavedState(containerOrSavedState) 39 | 40 | local self = --[[---@type ge_tts__InfiniteContainerInstance]] ( 41 | isSavedState 42 | and Class.parentConstructor(class, Instance)(--[[---@type ge_tts__InfiniteContainerInstance_SavedState]] containerOrSavedState) 43 | or Class.parentConstructor(class, Instance)(--[[---@type tts__Container]] containerOrSavedState) 44 | ) 45 | 46 | local containedObjectStates = (--[[---@type tts__ContainerState]] self.getObject().getData() ).ContainedObjects 47 | 48 | ---@type nil | fun(instance: T): boolean 49 | local filterInstanceEnter = nil 50 | 51 | ---@type table 52 | local spawnedGuidMap = {} 53 | 54 | --- The provided callback will be called if an instance previously taken from this container attempts to 55 | --- re-enter the container. If the callback returns true, instance.destroy() will be called, otherwise 56 | --- instance.reject() will be called. A nil callback (default) is the same always returning true. 57 | ---@param callback nil | fun(instance: T): boolean 58 | function self.setFilterInstanceEnter(callback) 59 | filterInstanceEnter = callback 60 | end 61 | 62 | ---@param object tts__Object 63 | ---@return boolean 64 | function self.filterObjectEnter(object) 65 | if spawnedGuidMap[object.guid] then 66 | local instance = --[[---@type T]] Instance.getInstance(object.guid) 67 | 68 | if not filterInstanceEnter or filterInstanceEnter(instance) then 69 | spawnedGuidMap[object.guid] = nil 70 | self.invalidateSavedState() 71 | 72 | instance.destroy() 73 | elseif not object.isSmoothMoving() then 74 | instance.reject() 75 | end 76 | else 77 | local instance = Instance.getInstance(object.guid) 78 | 79 | if not object.isSmoothMoving() then 80 | if instance then 81 | (--[[---@not nil]] instance).reject() 82 | else 83 | local pickupPosition = object.pick_up_position 84 | 85 | if pickupPosition:sqrMagnitude() ~= 0 then 86 | ObjectUtils.setPositionSmooth(object, pickupPosition, false, true) 87 | ObjectUtils.setRotationSmooth(object, object.pick_up_rotation, false, true) 88 | end 89 | end 90 | end 91 | end 92 | 93 | return false 94 | end 95 | 96 | ---@param object tts__Object 97 | function self.onObjectLeave(object) 98 | local guid = object.guid 99 | 100 | spawnedGuidMap[guid] = true 101 | self.invalidateSavedState() 102 | 103 | local objectState, index = --[[---@not nil, nil]] TableUtils.detect(containedObjectStates, function(objectState) 104 | return objectState.GUID == guid 105 | end) 106 | 107 | objectState.GUID = ObjectUtils.nextGuid() 108 | buildInstance(index, objectState, self) 109 | 110 | local previousContainer = self.getObject() 111 | local containerGuid = previousContainer.guid 112 | 113 | local containerState = --[[---@type tts__ContainerState]] previousContainer.getData() 114 | containerState.ContainedObjects = containedObjectStates 115 | 116 | local instances = Instance.getInstances(previousContainer) 117 | 118 | self.setObject(nil) 119 | 120 | for _, instance in ipairs(instances) do 121 | instance.setObject(nil) 122 | end 123 | 124 | previousContainer.destruct() 125 | 126 | local newContainer = ObjectUtils.safeRespawnObject(containerState, containerGuid) 127 | 128 | self.setObject(newContainer) 129 | 130 | for _, instance in ipairs(instances) do 131 | instance.setObject(newContainer) 132 | end 133 | end 134 | 135 | local superSave = self.save 136 | 137 | ---@return ge_tts__InfiniteContainerInstance_SavedState 138 | function self.save() 139 | return --[[---@type ge_tts__InfiniteContainerInstance_SavedState]] TableUtils.merge(superSave(), { 140 | spawnedGuids = TableUtils.keys(spawnedGuidMap), 141 | }) 142 | end 143 | 144 | if isSavedState then 145 | local savedState = --[[---@type ge_tts__InfiniteContainerInstance_SavedState]] containerOrSavedState 146 | 147 | for _, guid in ipairs(savedState.spawnedGuids) do 148 | spawnedGuidMap[guid] = true 149 | end 150 | else 151 | for index, containedObjectState in ipairs(containedObjectStates) do 152 | local instance = Instance.getInstance(--[[---@not nil]] containedObjectState.GUID) 153 | 154 | if not instance then 155 | buildInstance(index, containedObjectState, self) 156 | end 157 | end 158 | end 159 | 160 | return self 161 | end, 162 | __index = Instance, 163 | })) 164 | 165 | ---@param container tts__Container 166 | ---@param object tts__Object 167 | local filterObjectEnterContainer = function(container, object) 168 | local instance = Instance.getOneInstance(container) 169 | 170 | if instance and (--[[---@not nil]] instance).getType() == InfiniteContainerInstance.TYPE then 171 | return (--[[---@type ge_tts__InfiniteContainerInstance]] instance).filterObjectEnter(object) 172 | end 173 | 174 | -- NOTE: We're intentionally *not* returning *any* value if the event isn't for a InfiniteContainerInstance. If 175 | -- multiple handlers are registered with ge_tts' EventManager, it knows not to overwrite previous handlers' 176 | -- return values i.e. if another handler already returned false, we don't want to overwrite that return value. 177 | end 178 | 179 | ---@param container tts__Container 180 | ---@param object tts__Object 181 | local onObjectLeaveContainer = function(container, object) 182 | local instance = Instance.getOneInstance(container) 183 | 184 | if instance and (--[[---@not nil]] instance).getType() == InfiniteContainerInstance.TYPE then 185 | (--[[---@type ge_tts__InfiniteContainerInstance]] instance).onObjectLeave(object) 186 | end 187 | end 188 | 189 | EventManager.addHandler('filterObjectEnterContainer', filterObjectEnterContainer) 190 | EventManager.addHandler('onObjectLeaveContainer', onObjectLeaveContainer) 191 | 192 | return InfiniteContainerInstance 193 | -------------------------------------------------------------------------------- /SaveManager.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Logger = require('ge_tts.Logger') 4 | local TableUtils = require('ge_tts.TableUtils') 5 | 6 | local SAVE_STATE_IDENTIFIER = "__ge_tts_save__" 7 | 8 | ---@class ge_tts__SaveManager 9 | local SaveManager = {} 10 | 11 | ---@shape __ge_tts__SaveManager_Callbacks 12 | ---@field onLoads (fun(savedState: string): void)[] 13 | ---@field onSave nil | (fun(): nil | string) 14 | 15 | local ORIGINAL_PSEUDO_MODULE_NAME = '__originalSavedState' 16 | 17 | ---@type table 18 | local callbacks = {} 19 | 20 | local originalOnSave = --[[---@type nil | fun(): string]] _G.onSave 21 | local originalOnLoad = --[[---@type nil | fun(savedState: string): void]] _G.onLoad 22 | 23 | ---@param moduleName string 24 | ---@return __ge_tts__SaveManager_Callbacks 25 | local function getModuleCallbacks(moduleName) 26 | local moduleCallbacks = callbacks[moduleName] 27 | 28 | if not moduleCallbacks then 29 | moduleCallbacks = { onLoads = {} } 30 | callbacks[moduleName] = moduleCallbacks 31 | end 32 | 33 | return moduleCallbacks 34 | end 35 | 36 | ---@param moduleName string 37 | ---@param savedState string 38 | local function executeOnLoads(moduleName, savedState) 39 | if moduleName == ORIGINAL_PSEUDO_MODULE_NAME and originalOnLoad then 40 | (--[[---@not nil]] originalOnLoad)(savedState) 41 | else 42 | local onLoads = TableUtils.copy(getModuleCallbacks(moduleName).onLoads) -- Copying because callbacks may modify onLoads whilst we iterate. 43 | 44 | for _, onLoad in ipairs(onLoads) do 45 | onLoad(savedState) 46 | end 47 | end 48 | end 49 | 50 | --- 51 | ---Registers onSave for the specified moduleName. moduleName must be unique. 52 | --- 53 | ---Any onLoad registered for the same moduleName will be called with the savedState returned from onSave. This allows 54 | ---several Lua modules/files to independently maintain their own savedState. 55 | --- 56 | ---@param moduleName string 57 | ---@param onSave fun(): nil | string 58 | function SaveManager.registerOnSave(moduleName, onSave) 59 | Logger.assert(type(moduleName) == 'string' and moduleName ~= '', 'moduleName must be specified') 60 | 61 | local moduleCallbacks = getModuleCallbacks(moduleName) 62 | 63 | Logger.assert(moduleCallbacks.onSave == nil, 'onSave is already registered for module: ' .. moduleName) 64 | 65 | moduleCallbacks.onSave = onSave 66 | end 67 | 68 | 69 | --- 70 | ---Registers onLoad for the specified moduleName. You may have multiple onLoad registered for the same moduleName. 71 | --- 72 | ---The provided onLoad function will only be called with data pertaining to the provided moduleName. This allows Lua 73 | ---modules to independently maintain their own savedState. 74 | --- 75 | ---If the moduleName argument is omitted, the provided onLoad will be called with an empty string. This is useful if you 76 | ---simply want your onLoad callback called when Tabletop Simulator finished loading, but you don't need any saved state. 77 | --- 78 | ---@overload fun(onLoad: (fun(savedState: string): void)): boolean 79 | ---@overload fun(moduleName: string, onLoad: (fun(savedState: string): void)): boolean 80 | ---@param moduleNameOrOnLoad string | fun(savedState: string): void 81 | ---@param nilOrOnLoad nil | fun(savedState: string): void 82 | function SaveManager.registerOnLoad(moduleNameOrOnLoad, nilOrOnLoad) 83 | if type(moduleNameOrOnLoad) == 'function' then 84 | SaveManager.registerOnLoad('', --[[---@type fun(savedState: string): void]] moduleNameOrOnLoad) 85 | return 86 | end 87 | 88 | Logger.assert(type(moduleNameOrOnLoad) == 'string', 'moduleName must be a string') 89 | 90 | local moduleName = --[[---@type string]] moduleNameOrOnLoad 91 | local moduleCallbacks = getModuleCallbacks(moduleName) 92 | local onLoad = --[[---@type fun(savedState: string): void]] nilOrOnLoad 93 | 94 | table.insert(moduleCallbacks.onLoads, onLoad) 95 | end 96 | 97 | --- 98 | ---Remove the existing onSave callback for moduleName. 99 | --- 100 | ---Returns true if there was an existing onSave callback and it was removed, or false if there was already no onSave for moduleName. 101 | --- 102 | ---@param moduleName string 103 | ---@return boolean 104 | function SaveManager.removeOnSave(moduleName) 105 | local moduleCallbacks = callbacks[moduleName] 106 | 107 | if moduleCallbacks and moduleCallbacks.onSave then 108 | moduleCallbacks.onSave = nil 109 | return true 110 | end 111 | 112 | return false 113 | end 114 | 115 | ---@overload fun(onLoad: (fun(savedState: string): void)): boolean 116 | ---@overload fun(moduleName: string, onLoad: (fun(savedState: string): void)): boolean 117 | ---@param moduleNameOrOnLoad string | fun(savedState: string): void 118 | ---@param nilOrOnLoad nil | fun(savedState: string): void 119 | ---@return boolean 120 | function SaveManager.removeOnLoad(moduleNameOrOnLoad, nilOrOnLoad) 121 | if type(moduleNameOrOnLoad) == 'function' then 122 | return SaveManager.removeOnLoad('', --[[---@type fun(savedState: string): void]] moduleNameOrOnLoad) 123 | end 124 | 125 | Logger.assert(type(moduleNameOrOnLoad) == 'string', 'SaveManager moduleName must be a string') 126 | 127 | local moduleName = --[[---@type string]] moduleNameOrOnLoad 128 | local moduleCallbacks = callbacks[moduleName] 129 | local onLoad = nilOrOnLoad 130 | 131 | if moduleCallbacks then 132 | for i, existingOnLoad in ipairs(moduleCallbacks.onLoads) do 133 | if existingOnLoad == onLoad then 134 | table.remove(moduleCallbacks.onLoads, i) 135 | return true 136 | end 137 | end 138 | end 139 | 140 | return false 141 | end 142 | 143 | ---@return string 144 | function onSave() 145 | local savedState = SAVE_STATE_IDENTIFIER 146 | 147 | for moduleName, moduleCallbacks in pairs(callbacks) do 148 | if moduleCallbacks.onSave then 149 | local moduleSavedState = (--[[---@not nil]] moduleCallbacks.onSave)() 150 | 151 | if moduleSavedState ~= nil then 152 | Logger.assert(type(moduleSavedState) == 'string', moduleName .. "'s onSave returned a " .. type(moduleSavedState) .. ', a string is required.') 153 | 154 | savedState = savedState .. moduleName:len() .. ' ' .. moduleName .. ' ' .. (--[[---@not nil]] moduleSavedState):len() .. ' ' .. moduleSavedState 155 | end 156 | end 157 | end 158 | 159 | if originalOnSave then 160 | local originalSavedStated = (--[[---@not nil]] originalOnSave)() 161 | savedState = savedState .. ORIGINAL_PSEUDO_MODULE_NAME:len() .. ' ' .. ORIGINAL_PSEUDO_MODULE_NAME .. ' ' .. originalSavedStated:len() .. ' ' .. originalSavedStated 162 | end 163 | 164 | return savedState 165 | end 166 | 167 | local GE_MODULE_PREFIX = 'ge_tts.' 168 | 169 | ---@param savedState string 170 | function onLoad(savedState) 171 | savedState = savedState or '' 172 | 173 | Logger.assert(savedState == '' or savedState:sub(1, SAVE_STATE_IDENTIFIER:len()) == SAVE_STATE_IDENTIFIER, "When working with ge_tts, you must use ge_tts.SaveManager instead of writing directly to script_state.") 174 | 175 | local savedStateLength = savedState:len() 176 | local moduleNameOffset = SAVE_STATE_IDENTIFIER:len() + 1 177 | local i = moduleNameOffset 178 | 179 | ---@type table 180 | local moduleStateRanges = {} 181 | 182 | repeat 183 | if savedState:sub(i, i) == ' ' then 184 | local moduleNameLength = tonumber(savedState:sub(moduleNameOffset, i - 1)) 185 | local moduleName = savedState:sub(i + 1, i + moduleNameLength) 186 | local moduleSizeOffset = i + moduleNameLength + 2 187 | 188 | for j = moduleSizeOffset, savedStateLength do 189 | if savedState:sub(j, j) == ' ' then 190 | local moduleStateLength = tonumber(savedState:sub(moduleSizeOffset, j - 1)) 191 | local moduleSavedStateEnd = j + moduleStateLength 192 | 193 | moduleStateRanges[moduleName] = { 194 | rangeStart = j + 1, 195 | rangeEnd = moduleSavedStateEnd 196 | } 197 | 198 | moduleNameOffset = moduleSavedStateEnd + 1 199 | i = moduleSavedStateEnd + 1 200 | break 201 | end 202 | end 203 | else 204 | i = i + 1 205 | end 206 | until i > savedStateLength 207 | 208 | -- ge_tts listeners execute first 209 | for moduleName, _ in pairs(callbacks) do 210 | if moduleName:sub(1, GE_MODULE_PREFIX:len()) == GE_MODULE_PREFIX then 211 | local stateRange = moduleStateRanges[moduleName] 212 | 213 | if stateRange then 214 | local moduleSavedState = savedState:sub(stateRange.rangeStart, stateRange.rangeEnd) 215 | executeOnLoads(moduleName, moduleSavedState) 216 | else 217 | executeOnLoads(moduleName, '') 218 | end 219 | end 220 | end 221 | 222 | for moduleName, _ in pairs(callbacks) do 223 | if moduleName:sub(1, GE_MODULE_PREFIX:len()) ~= GE_MODULE_PREFIX then 224 | local stateRange = moduleStateRanges[moduleName] 225 | 226 | if stateRange then 227 | local moduleSavedState = savedState:sub(stateRange.rangeStart, stateRange.rangeEnd) 228 | executeOnLoads(moduleName, moduleSavedState) 229 | else 230 | executeOnLoads(moduleName, '') 231 | end 232 | end 233 | end 234 | end 235 | 236 | return SaveManager 237 | -------------------------------------------------------------------------------- /Coroutine.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | ---@class ge_tts__Coroutine 4 | local Coroutine = {} 5 | 6 | ---@param co thread 7 | ---@param onError nil | fun(message: string): void 8 | local function resumeWithErrorHandling(co, onError) 9 | local result, message = coroutine.resume(co) 10 | 11 | if not result then 12 | if onError then 13 | (--[[---@not nil]] onError)(message) 14 | else 15 | error(message) 16 | end 17 | end 18 | end 19 | 20 | ---@overload fun(fn: (fun(resume: (fun(result: R): void)): void), timeout?: nil | number, onError?: nil | (fun(message: string): void), incorrectResumptionErrorMessage?: nil | string): boolean, R 21 | ---@overload fun(fn: (fun(resume: (fun(): void)): void), timeout?: nil | number, onError?: nil | (fun(message: string): void), incorrectResumptionErrorMessage?: nil | string): boolean 22 | ---@generic R 23 | ---@param fn fun(resume: (fun(result: R): void)): void 24 | ---@param timeout? nil | number @Timeout in seconds (optional). 25 | ---@param onError? nil | fun(message: string): void @A handler for any errors raised by the current coroutine after it has been resumed. 26 | ---@param incorrectResumptionErrorMessage? nil | string 27 | ---@return false | (true, R) @true followed by fn result if resume was called before the timeout, or false if the (optional) timeout was reached. 28 | function Coroutine.yield(fn, timeout, onError, incorrectResumptionErrorMessage) 29 | local co = coroutine.running() 30 | 31 | local yielded = false 32 | 33 | ---@type nil | boolean 34 | local resumed 35 | 36 | ---@type R 37 | local result 38 | 39 | ---@type fun(userResult: R): void 40 | local resume = function(userResult) 41 | if resumed then 42 | return -- Already resumed 43 | end 44 | 45 | result = userResult 46 | resumed = true 47 | 48 | if yielded then 49 | -- If resume is called synchronously in fn, then we haven't (and won't) yield. Thus, there's no suspended 50 | -- coroutine to resume. 51 | resumeWithErrorHandling(co, onError) 52 | end 53 | end 54 | 55 | ---@type nil | number 56 | local waitId 57 | 58 | if timeout then 59 | waitId = Wait.time(function() 60 | if resumed then 61 | return 62 | end 63 | 64 | waitId = nil 65 | resumed = false 66 | resumeWithErrorHandling(co, onError) 67 | end, --[[---@not nil]] timeout) 68 | end 69 | 70 | fn(resume) 71 | 72 | if not resumed then 73 | yielded = true 74 | coroutine.yield() 75 | end 76 | 77 | if waitId then 78 | Wait.stop(--[[---@not nil]] waitId) 79 | end 80 | 81 | if not resumed then 82 | if resumed == nil then 83 | error(incorrectResumptionErrorMessage) 84 | end 85 | 86 | return false 87 | end 88 | 89 | return true, result 90 | end 91 | 92 | ---@overload fun(arr: T[], fn: (fun(resume: (fun(result: R): void), element: T, index: number): void), timeout: number, onError?: nil | (fun(message: string): void), incorrectResumptionErrorMessage?: nil | string): (true, R[]) | (false, table) 93 | ---@overload fun(arr: T[], fn: (fun(resume: (fun(result: R): void), element: T, index: number): void), timeout?: nil, onError?: nil | (fun(message: string): void), incorrectResumptionErrorMessage?: nil | string): true, R[] 94 | ---@overload fun(arr: T[], fn: (fun(resume: (fun(): void), element: T, index: number): void), timeout?: nil | number, onError?: nil | (fun(message: string): void), incorrectResumptionErrorMessage?: nil | string): boolean 95 | ---@generic T 96 | ---@generic R 97 | ---@param arr T[] 98 | ---@param fn fun(resume: (fun(result: R): void), element: T, index: number): void 99 | ---@param timeout? nil | number @Timeout in seconds (optional). 100 | ---@param onError? nil | fun(message: string): void @A handler for any errors raised by the current coroutine after it has been resumed. 101 | ---@param incorrectResumptionErrorMessage? nil | string 102 | ---@return (true, R[]) | (false, table) @true followed by fn result if resume was called before the timeout, or false if the (optional) timeout was reached. 103 | function Coroutine.yieldAll(arr, fn, timeout, onError, incorrectResumptionErrorMessage) 104 | local co = coroutine.running() 105 | 106 | local count = #arr 107 | 108 | if count == 0 then 109 | return true, {} 110 | end 111 | 112 | local resultCount = 0 113 | 114 | local yielded = false 115 | 116 | ---@type nil | boolean 117 | local resumed 118 | 119 | ---@type nil | number 120 | local waitId 121 | 122 | if timeout then 123 | Wait.time(function() 124 | if resumed then 125 | return 126 | end 127 | 128 | waitId = nil 129 | resumed = false 130 | resumeWithErrorHandling(co, onError) 131 | end, --[[---@not nil]] timeout) 132 | end 133 | 134 | ---@type table 135 | local elementsResumed = {} 136 | 137 | ---@type table 138 | local results = {} 139 | 140 | for i, element in ipairs(arr) do 141 | ---@type fun(userResult: R): void 142 | local resume = function(userResult) 143 | if elementsResumed[i] then 144 | return 145 | end 146 | 147 | elementsResumed[i] = true 148 | results[i] = userResult 149 | resultCount = resultCount + 1 150 | 151 | if resultCount == count then 152 | -- If resume is called synchronously in fn, then we haven't (and won't) yield. Thus, there's no suspended 153 | -- coroutine to resume. 154 | resumed = true 155 | 156 | if yielded then 157 | resumeWithErrorHandling(co, onError) 158 | end 159 | end 160 | end 161 | 162 | fn(resume, element, i) 163 | end 164 | 165 | if not resumed then 166 | yielded = true 167 | coroutine.yield() 168 | end 169 | 170 | if waitId then 171 | Wait.stop(--[[---@not nil]] waitId) 172 | end 173 | 174 | if resumed == nil then 175 | error(incorrectResumptionErrorMessage) 176 | end 177 | 178 | if resumed then 179 | return true, --[[---@type R[] ]] results 180 | end 181 | 182 | return false, results 183 | end 184 | 185 | --- Yields from the current coroutine. Resumes once a condition is met or an optional timeout is reached. 186 | ---@overload fun(condition: fun(): boolean): true 187 | ---@overload fun(condition: (fun(): boolean), timeout: number): boolean 188 | ---@param condition fun(): boolean @Return true when the current coroutine should be resumed. 189 | ---@param timeout nil | number @Timeout in seconds (optional). 190 | ---@param onError nil | fun(message: string): void @A handler for any errors raised by the current coroutine after it has been resumed. 191 | ---@return boolean @True if the condition was met, or false if the (optional) timeout was reached. 192 | function Coroutine.yieldCondition(condition, timeout, onError) 193 | local co = coroutine.running() 194 | 195 | ---@type nil | boolean 196 | local conditionMet 197 | 198 | local resume = function() 199 | conditionMet = true 200 | resumeWithErrorHandling(co, onError) 201 | end 202 | 203 | if timeout then 204 | Wait.condition(resume, condition, --[[---@not nil]] timeout, function() 205 | conditionMet = false 206 | resumeWithErrorHandling(co, onError) 207 | end) 208 | else 209 | Wait.condition(resume, condition) 210 | end 211 | 212 | coroutine.yield() 213 | 214 | if conditionMet == nil then 215 | error("Coroutine.yieldCondition(): attempt to resume before Wait was completed!") 216 | end 217 | 218 | return --[[---@not nil]] conditionMet 219 | end 220 | 221 | --- Yields from the current coroutine, which will later be resumed after the specified number of frames have passed. 222 | ---@overload fun(frames: number): void 223 | ---@param frames number 224 | ---@param onError nil | fun(message: string): void @A handler for any errors raised by the current coroutine after it has been resumed. 225 | function Coroutine.yieldFrames(frames, onError) 226 | Coroutine.yield( 227 | ---@param resume fun(): void 228 | function(resume) 229 | Wait.frames(resume, frames) 230 | end, 231 | nil, 232 | onError, 233 | "Coroutine.yieldFrames(): attempt to resume before Wait was completed!" 234 | ) 235 | end 236 | 237 | --- Yields from the current coroutine, which will later be resumed after the specified number of seconds have passed. 238 | ---@overload fun(seconds: number): void 239 | ---@param seconds number 240 | ---@param onError nil | fun(message: string): void @A handler for any errors raised by the current coroutine after it has been resumed. 241 | function Coroutine.yieldSeconds(seconds, onError) 242 | Coroutine.yield( 243 | ---@param resume fun(): void 244 | function(resume) 245 | Wait.time(resume, seconds) 246 | end, 247 | nil, 248 | onError, 249 | "Coroutine.yieldSeconds(): attempt to resume before Wait was completed!" 250 | ) 251 | end 252 | 253 | --- Creates a coroutine from the specified function and immediately starts it, passing any provided arguments. 254 | ---@param func fun 255 | ---@vararg any 256 | ---@return boolean, any... 257 | function Coroutine.start(func, ...) 258 | return coroutine.resume(coroutine.create(func), ...) 259 | end 260 | 261 | return Coroutine 262 | -------------------------------------------------------------------------------- /InstanceManager.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Json = require('ge_tts.Json') 4 | local TableUtils = require('ge_tts.TableUtils') 5 | 6 | local SAVE_STATE_IDENTIFIER = "__ge_tts_save__" 7 | 8 | ---@class ge_tts__static_InstanceManager 9 | ---@overload fun(): ge_tts__InstanceManager 10 | local InstanceManager = {} 11 | 12 | ---@class ge_tts__InstanceManager 13 | 14 | ---@shape __ge_tts__ObjectState 15 | ---@field json nil | string 16 | ---@field instanceStates table 17 | 18 | ---@shape __ge_tts__InstanceState 19 | ---@field json nil | string 20 | ---@field object nil | tts__Object 21 | 22 | ---@type nil | ge_tts__InstanceManager 23 | local currentInstanceManager = nil 24 | 25 | setmetatable(InstanceManager, { 26 | __call = function() 27 | local self = --[[---@type ge_tts__InstanceManager]] {} 28 | 29 | local Instance = require('ge_tts.Instance') -- Required here to prevent top-level cyclical requires. 30 | 31 | ---@type table 32 | local objectStateMap = {} 33 | 34 | ---@type table 35 | local instanceStateMap = {} 36 | 37 | ---@param object tts__Object 38 | ---@return __ge_tts__ObjectState 39 | local function getObjectState(object) 40 | local objectState = objectStateMap[(--[[---@not nil]] object)] 41 | 42 | if not objectState then 43 | objectState = { 44 | instanceStates = {} 45 | } 46 | objectStateMap[(--[[---@not nil]] object)] = objectState 47 | end 48 | 49 | return objectState 50 | end 51 | 52 | ---@param instanceGuid string 53 | ---@return __ge_tts__InstanceState 54 | local function getInstanceState(instanceGuid) 55 | local instanceState = instanceStateMap[instanceGuid] 56 | 57 | if not instanceState then 58 | instanceState = {} 59 | instanceStateMap[instanceGuid] = instanceState 60 | end 61 | 62 | return instanceState 63 | end 64 | 65 | ---@param instance ge_tts__Instance 66 | function self.invalidateSavedState(instance) 67 | local instanceGuid = instance.getInstanceGuid() 68 | local instanceState = getInstanceState(instanceGuid) 69 | instanceState.json = nil 70 | 71 | local previousObject = instanceState.object 72 | local currentObject = instance.safeGetObject() 73 | 74 | if previousObject ~= currentObject then 75 | if previousObject then 76 | local previousObjectState = getObjectState(--[[---@not nil]] previousObject) 77 | 78 | previousObjectState.json = nil 79 | previousObjectState.instanceStates[instanceGuid] = nil 80 | 81 | if not next(previousObjectState.instanceStates) then 82 | objectStateMap[--[[---@not nil]] previousObject] = nil 83 | end 84 | end 85 | 86 | if currentObject then 87 | local currentObjectState = getObjectState(--[[---@not nil]] currentObject) 88 | currentObjectState.json = nil 89 | currentObjectState.instanceStates[instanceGuid] = instanceState 90 | end 91 | 92 | instanceState.object = currentObject 93 | elseif currentObject then 94 | local objectState = getObjectState(--[[---@not nil]] currentObject) 95 | objectState.json = nil 96 | end 97 | end 98 | 99 | ---@param instance ge_tts__Instance 100 | function self.onInstanceDestroyed(instance) 101 | local instanceGuid = instance.getInstanceGuid() 102 | local instanceState = instanceStateMap[instanceGuid] 103 | 104 | if not instanceState then 105 | return 106 | end 107 | 108 | local previousObject = instanceState.object 109 | 110 | if previousObject then 111 | local previousObjectState = getObjectState(--[[---@not nil]] previousObject) 112 | 113 | previousObjectState.json = nil 114 | previousObjectState.instanceStates[instanceGuid] = nil 115 | 116 | if not next(previousObjectState.instanceStates) then 117 | objectStateMap[--[[---@not nil]] previousObject] = nil 118 | end 119 | end 120 | 121 | instanceStateMap[instanceGuid] = nil 122 | end 123 | 124 | ---@param instance ge_tts__Instance 125 | ---@return string 126 | function self.saveInstanceState(instance) 127 | local state = getInstanceState(instance.getInstanceGuid()) 128 | 129 | if not state.json then 130 | self.invalidateSavedState(instance) 131 | state.json = Json.encode(instance.save()) 132 | end 133 | 134 | return instance.getInstanceGuid() 135 | end 136 | 137 | ---@param instanceGuid string 138 | ---@return any 139 | function self.loadInstanceState(instanceGuid) 140 | local instanceState = instanceStateMap[instanceGuid] 141 | 142 | if not instanceState or not instanceState.json then 143 | error("No instance state available for instance " .. instanceGuid) 144 | end 145 | 146 | return Json.decode(--[[---@not nil]] instanceState.json) 147 | end 148 | 149 | --- Persists instance states to associated objects. 150 | function self.save() 151 | for object, objectState in pairs(objectStateMap) do 152 | if not objectState.json then 153 | local json = Json.encode(TableUtils.map(objectState.instanceStates, function(instanceState, instanceGuid) 154 | if not instanceState.json then 155 | local instance = Instance.getInstance(instanceGuid) 156 | 157 | if instance then 158 | -- Typically mods will be calling InstanceManager.saveInstanceState() themselves. However, if an instance owns another instance 159 | -- it only needs to encode the instance GUID, which never changes. We don't want to require children instances invalidate their 160 | -- parent instance's state just to trigger a resave. As such, we do *also* auto-save instances, but we *don't* handle instance 161 | -- object/container changes at this point. The auto-save functionality is purely to handle parent-child instance relationships. 162 | instanceState.json = Json.encode((--[[---@not nil]] instance).save()) 163 | end 164 | end 165 | 166 | return instanceState.json 167 | end)) 168 | 169 | objectState.json = json 170 | 171 | if object ~= nil then 172 | object.script_state = SAVE_STATE_IDENTIFIER .. json 173 | end 174 | end 175 | end 176 | end 177 | 178 | function self.load() 179 | for _, object in ipairs(getAllObjects()) do 180 | local savedState = object.script_state 181 | 182 | if savedState:sub(1, SAVE_STATE_IDENTIFIER:len()) == SAVE_STATE_IDENTIFIER then 183 | local objectJson = savedState:sub(SAVE_STATE_IDENTIFIER:len() + 1) 184 | 185 | local objectState = getObjectState(object) 186 | objectState.json = objectJson 187 | 188 | local instanceStates = --[[---@type table]] Json.decode(objectJson) 189 | 190 | for instanceGuid, instanceJson in pairs(instanceStates) do 191 | local instanceState = getInstanceState(instanceGuid) 192 | instanceState.object = object 193 | instanceState.json = instanceJson 194 | 195 | objectState.instanceStates[instanceGuid] = instanceState 196 | end 197 | end 198 | end 199 | end 200 | 201 | function self.destroy() 202 | objectStateMap = {} 203 | instanceStateMap = {} 204 | 205 | if currentInstanceManager then 206 | currentInstanceManager = nil 207 | end 208 | end 209 | 210 | return self 211 | end, 212 | }) 213 | 214 | ---@return nil | ge_tts__InstanceManager 215 | function InstanceManager.get() 216 | return currentInstanceManager 217 | end 218 | 219 | ---@param manager ge_tts__InstanceManager 220 | function InstanceManager.set(manager) 221 | currentInstanceManager = manager 222 | end 223 | 224 | ---@param instance ge_tts__Instance 225 | function InstanceManager.invalidateSavedState(instance) 226 | if currentInstanceManager then 227 | return (--[[---@not nil]] currentInstanceManager).invalidateSavedState(instance) 228 | end 229 | end 230 | 231 | ---@param instance ge_tts__Instance 232 | function InstanceManager.onInstanceDestroyed(instance) 233 | if currentInstanceManager then 234 | return (--[[---@not nil]] currentInstanceManager).onInstanceDestroyed(instance) 235 | end 236 | end 237 | 238 | ---@param instance ge_tts__Instance 239 | ---@return string 240 | function InstanceManager.saveInstanceState(instance) 241 | if not currentInstanceManager then 242 | error("InstanceManager not set") 243 | end 244 | 245 | return (--[[---@not nil]] currentInstanceManager).saveInstanceState(instance) 246 | end 247 | 248 | ---@param instanceGuid string 249 | ---@return any 250 | function InstanceManager.loadInstanceState(instanceGuid) 251 | if not currentInstanceManager then 252 | error("InstanceManager not set") 253 | end 254 | 255 | return (--[[---@not nil]] currentInstanceManager).loadInstanceState(instanceGuid) 256 | end 257 | 258 | function InstanceManager.save() 259 | if not currentInstanceManager then 260 | error("InstanceManager not set") 261 | end 262 | 263 | (--[[---@not nil]] currentInstanceManager).save() 264 | end 265 | 266 | function InstanceManager.load() 267 | if not currentInstanceManager then 268 | error("InstanceManager not set") 269 | end 270 | 271 | (--[[---@not nil]] currentInstanceManager).load() 272 | end 273 | 274 | return InstanceManager 275 | -------------------------------------------------------------------------------- /Vector3.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | --- 4 | --- A 3D vector implementation. 5 | --- 6 | --- Components can be accessed as x, y and z properties, or indexed by numbers [1], [2] and [3]. 7 | --- 8 | ---@class ge_tts__Vector3 : __ge_tts__NumCharVec3 9 | 10 | ---@shape __ge_tts__NumCharVec3 11 | ---@field x T 12 | ---@field y T 13 | ---@field z T 14 | ---@field [1] T 15 | ---@field [2] T 16 | ---@field [3] T 17 | 18 | ---@shape ge_tts__NumCharVec3 : __ge_tts__NumCharVec3 19 | 20 | ---@param vector ge_tts__Vector3 21 | ---@param index any 22 | local function numberedIndex(vector, index) 23 | if type(index) == 'number' then 24 | if index == 1 then 25 | return vector.x 26 | elseif index == 2 then 27 | return vector.y 28 | elseif index == 3 then 29 | return vector.z 30 | end 31 | 32 | return nil 33 | end 34 | end 35 | 36 | local DEGREES_RATIO = 180 / math.pi 37 | local RADIANS_RATIO = math.pi / 180 38 | 39 | ---@class ge_tts__static_Vector3 40 | ---@overload fun(): ge_tts__Vector3 41 | ---@overload fun(x: number, y: number, z: number): ge_tts__Vector3 42 | ---@overload fun(source: tts__VectorShape): ge_tts__Vector3 43 | local Vector3 = {} 44 | 45 | setmetatable(Vector3, { 46 | ---@param sourceXOrVector nil | __ge_tts__NumCharVec3 47 | ---@param sourceY nil | number 48 | ---@param sourceZ nil | number 49 | ---@return ge_tts__Vector3 50 | __call = function(_, sourceXOrVector, sourceY, sourceZ) 51 | local self = --[[---@type self]] {x = 0, y = 0, z = 0} 52 | 53 | setmetatable(self, { 54 | __index = numberedIndex, 55 | __tostring = function(_) 56 | return self.toString() 57 | end, 58 | }) 59 | 60 | if sourceXOrVector then 61 | if type(sourceXOrVector) == 'table' then 62 | local source = --[[---@type __ge_tts__NumCharVec3]] sourceXOrVector 63 | 64 | self.x = source.x or source[1] or self.x 65 | self.y = source.y or source[2] or self.y 66 | self.z = source.z or source[3] or self.z 67 | else 68 | self.x = --[[---@type number]] sourceXOrVector 69 | self.y = --[[---@type number]] sourceY 70 | self.z = --[[---@type number]] sourceZ 71 | end 72 | end 73 | 74 | ---@return string 75 | function self.toString() 76 | return '{x = ' .. self.x .. ', y = ' .. self.y .. ', z = ' .. self.z .. '}' 77 | end 78 | 79 | ---@return tts__CharVectorShape 80 | function self.toData() 81 | return {x = self.x, y = self.y, z = self.z} 82 | end 83 | 84 | ---@return number 85 | function self.lengthSquared() 86 | return Vector3.lengthSquared(self) 87 | end 88 | 89 | ---@return number 90 | function self.length() 91 | return Vector3.length(self) 92 | end 93 | 94 | ---Add a vector to self. 95 | ---@overload fun(v: tts__VectorShape): self 96 | ---@param v ge_tts__NumCharVec3 97 | ---@return self 98 | function self.add(v) 99 | self.x = self.x + (v.x or v[1]) 100 | self.y = self.y + (v.y or v[2]) 101 | self.z = self.z + (v.z or v[3]) 102 | return self 103 | end 104 | 105 | ---Subtract a vector from self. 106 | ---@overload fun(v: tts__VectorShape): self 107 | ---@param v ge_tts__NumCharVec3 108 | ---@return self 109 | function self.sub(v) 110 | self.x = self.x - (v.x or v[1]) 111 | self.y = self.y - (v.y or v[2]) 112 | self.z = self.z - (v.z or v[3]) 113 | return self 114 | end 115 | 116 | ---@param factor number | tts__VectorShape 117 | ---@return self 118 | function self.scale(factor) 119 | if (type(factor) == 'number') then 120 | self.x = self.x * factor 121 | self.y = self.y * factor 122 | self.z = self.z * factor 123 | else 124 | self.x = self.x * ((--[[---@type tts__CharVectorShape]] factor).x or (--[[---@type tts__NumVectorShape]] factor)[1]) 125 | self.y = self.y * ((--[[---@type tts__CharVectorShape]] factor).y or (--[[---@type tts__NumVectorShape]] factor)[2]) 126 | self.z = self.z * ((--[[---@type tts__CharVectorShape]] factor).z or (--[[---@type tts__NumVectorShape]] factor)[3]) 127 | end 128 | 129 | return self 130 | end 131 | 132 | ---@return self 133 | function self.normalize() 134 | return self.scale(1 / self.length()) 135 | end 136 | 137 | ---@param angle number @angle in degrees 138 | ---@return self 139 | function self.rotateX(angle) 140 | angle = angle * RADIANS_RATIO 141 | local y = self.y 142 | self.y = y * math.cos(angle) - self.z * math.sin(angle) 143 | self.z = y * math.sin(angle) + self.z * math.cos(angle) 144 | return self 145 | end 146 | 147 | ---@param angle number @angle in degrees 148 | ---@return self 149 | function self.rotateY(angle) 150 | angle = angle * RADIANS_RATIO 151 | local x = self.x 152 | self.x = self.z * math.sin(angle) + x * math.cos(angle) 153 | self.z = self.z * math.cos(angle) - x * math.sin(angle) 154 | return self 155 | end 156 | 157 | ---@param angle number @angle in degrees 158 | ---@return self 159 | function self.rotateZ(angle) 160 | angle = angle * RADIANS_RATIO 161 | local x = self.x 162 | self.x = x * math.cos(angle) - self.y * math.sin(angle) 163 | self.y = x * math.sin(angle) + self.y * math.cos(angle) 164 | return self 165 | end 166 | 167 | return self 168 | end, 169 | }) 170 | 171 | ---@overload fun(v: tts__VectorShape): number 172 | ---@param v ge_tts__NumCharVec3 173 | ---@return number 174 | function Vector3.x(v) 175 | return v.x or v[1] 176 | end 177 | 178 | ---@overload fun(v: tts__VectorShape): number 179 | ---@param v ge_tts__NumCharVec3 180 | ---@return number 181 | function Vector3.y(v) 182 | return v.y or v[2] 183 | end 184 | 185 | ---@overload fun(v: tts__VectorShape): number 186 | ---@param v ge_tts__NumCharVec3 187 | ---@return number 188 | function Vector3.z(v) 189 | return v.z or v[3] 190 | end 191 | 192 | ---@overload fun(v: tts__VectorShape): number 193 | ---@param v ge_tts__NumCharVec3 194 | ---@return number 195 | function Vector3.lengthSquared(v) 196 | local x = v.x or v[1] 197 | local y = v.y or v[2] 198 | local z = v.z or v[3] 199 | return x * x + y * y + z * z 200 | end 201 | 202 | ---@param v tts__VectorShape 203 | ---@return number 204 | function Vector3.length(v) 205 | return math.sqrt(Vector3.lengthSquared(v)) 206 | end 207 | 208 | ---@param v1 tts__VectorShape 209 | ---@param v2 tts__VectorShape 210 | ---@return ge_tts__Vector3 211 | function Vector3.add(v1, v2) 212 | return Vector3(v1).add(v2) 213 | end 214 | 215 | ---@param v1 tts__VectorShape 216 | ---@param v2 tts__VectorShape 217 | ---@return ge_tts__Vector3 218 | function Vector3.sub(v1, v2) 219 | return Vector3(v1).sub(v2) 220 | end 221 | 222 | ---@param v tts__VectorShape 223 | ---@param factor number | tts__VectorShape 224 | ---@return ge_tts__Vector3 225 | function Vector3.scale(v, factor) 226 | return Vector3(v).scale(factor) 227 | end 228 | 229 | ---@param v tts__VectorShape 230 | ---@return ge_tts__Vector3 231 | function Vector3.normalize(v) 232 | return Vector3(v).normalize() 233 | end 234 | 235 | ---@overload fun(v1: tts__VectorShape, v2: tts__VectorShape): ge_tts__Vector3 236 | ---@param v1 ge_tts__NumCharVec3 237 | ---@param v2 ge_tts__NumCharVec3 238 | ---@return ge_tts__Vector3 239 | function Vector3.cross(v1, v2) 240 | local x1 = v1.x or v1[1] 241 | local y1 = v1.y or v1[2] 242 | local z1 = v1.z or v1[3] 243 | 244 | local x2 = v2.x or v2[1] 245 | local y2 = v2.y or v2[2] 246 | local z2 = v2.z or v2[3] 247 | 248 | return Vector3(y1 * z2 - z1 * y2, z1 * x2 - x1 * z2, x1 * y2 - y1 * x2) 249 | end 250 | 251 | --- Returns the angle between v1 and v2 in degrees. 252 | ---@overload fun(v1: tts__VectorShape, v2: tts__VectorShape): number 253 | ---@param v1 ge_tts__NumCharVec3 254 | ---@param v2 ge_tts__NumCharVec3 255 | ---@return number 256 | function Vector3.angle(v1, v2) 257 | return DEGREES_RATIO * math.acos(Vector3.dot(v1, v2) / (Vector3.length(v1) * Vector3.length(v2))) 258 | end 259 | 260 | ---@overload fun(v1: tts__VectorShape, v2: tts__VectorShape): number 261 | ---@param v1 ge_tts__NumCharVec3 262 | ---@param v2 ge_tts__NumCharVec3 263 | ---@return number 264 | function Vector3.dot(v1, v2) 265 | local x1 = v1.x or v1[1] 266 | local y1 = v1.y or v1[2] 267 | local z1 = v1.z or v1[3] 268 | 269 | local x2 = v2.x or v2[1] 270 | local y2 = v2.y or v2[2] 271 | local z2 = v2.z or v2[3] 272 | 273 | return x1 * x2 + y1 * y2 + z1 * z2 274 | end 275 | 276 | ---@overload fun(v1: tts__VectorShape, v2: tts__VectorShape): number 277 | ---@param v1 ge_tts__NumCharVec3 278 | ---@param v2 ge_tts__NumCharVec3 279 | ---@return number 280 | function Vector3.distanceSquared(v1, v2) 281 | local x1 = v1.x or v1[1] 282 | local y1 = v1.y or v1[2] 283 | local z1 = v1.z or v1[3] 284 | 285 | local x2 = v2.x or v2[1] 286 | local y2 = v2.y or v2[2] 287 | local z2 = v2.z or v2[3] 288 | 289 | return Vector3.lengthSquared({x = x2 - x1, y = y2 - y1, z = z2 - z1}) 290 | end 291 | 292 | ---@overload fun(v1: tts__VectorShape, v2: tts__VectorShape): number 293 | ---@param v1 ge_tts__NumCharVec3 294 | ---@param v2 ge_tts__NumCharVec3 295 | ---@return number 296 | function Vector3.distance(v1, v2) 297 | return math.sqrt(Vector3.distanceSquared(v1, v2)) 298 | end 299 | 300 | ---@overload fun(v: tts__VectorShape, angle: number): ge_tts__Vector3 301 | ---@param v ge_tts__NumCharVec3 302 | ---@param angle number @angle in degrees 303 | ---@return ge_tts__Vector3 304 | function Vector3.rotateX(v, angle) 305 | angle = angle * RADIANS_RATIO 306 | 307 | local x = v.x or v[1] 308 | local y = v.y or v[2] 309 | local z = v.z or v[3] 310 | 311 | return Vector3( 312 | x, 313 | y * math.cos(angle) - z * math.sin(angle), 314 | y * math.sin(angle) + z * math.cos(angle) 315 | ) 316 | end 317 | 318 | ---@overload fun(v: tts__VectorShape, angle: number): ge_tts__Vector3 319 | ---@param v ge_tts__NumCharVec3 320 | ---@param angle number @angle in degrees 321 | ---@return ge_tts__Vector3 322 | function Vector3.rotateY(v, angle) 323 | angle = angle * RADIANS_RATIO 324 | 325 | local x = v.x or v[1] 326 | local y = v.y or v[2] 327 | local z = v.z or v[3] 328 | 329 | return Vector3( 330 | z * math.sin(angle) + x * math.cos(angle), 331 | y, 332 | z * math.cos(angle) - x * math.sin(angle) 333 | ) 334 | end 335 | 336 | ---@overload fun(v: tts__VectorShape, angle: number): ge_tts__Vector3 337 | ---@param v ge_tts__NumCharVec3 338 | ---@param angle number @angle in degrees 339 | ---@return ge_tts__Vector3 340 | function Vector3.rotateZ(v, angle) 341 | angle = angle * RADIANS_RATIO 342 | 343 | local x = v.x or v[1] 344 | local y = v.y or v[2] 345 | local z = v.z or v[3] 346 | 347 | return Vector3( 348 | x * math.cos(angle) - y * math.sin(angle), 349 | x * math.sin(angle) + y * math.cos(angle), 350 | z 351 | ) 352 | end 353 | 354 | return Vector3 355 | -------------------------------------------------------------------------------- /ObjectUtils.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Base64 = require('ge_tts.Base64') 4 | local Json = require('ge_tts.Json') 5 | local Object = require('ge_tts.Object') 6 | local SaveManager = require('ge_tts.SaveManager') 7 | local Vector3 = require('ge_tts.Vector3') 8 | 9 | ---There's a random component to our GUIDs designed to mitigate collisions is a user wants to copy objects between mods. 10 | local GUID_PREFIX_RANDOM_BYTE_LENGTH = 3 11 | 12 | ---@type string 13 | local guidPrefix 14 | 15 | ---@type number 16 | local guidIndex = 0 17 | 18 | ---@class ge_tts__ObjectUtils 19 | local ObjectUtils = {} 20 | 21 | ---@param obj tts__Object 22 | ---@return ge_tts__Vector3 23 | function ObjectUtils.getTransformScale(obj) 24 | local rotation = obj.getRotation() 25 | local onesVector = Vector3(1, 1, 1).rotateZ(rotation.z).rotateX(rotation.x).rotateY(rotation.y) 26 | local scale = Vector3(obj.positionToLocal(onesVector.add(obj.getPosition()))) 27 | return scale 28 | end 29 | 30 | ---@param tag string 31 | ---@return boolean 32 | function ObjectUtils.isContainerTag(tag) 33 | return tag == Object.Tag.Deck or tag == Object.Tag.Bag 34 | end 35 | 36 | ---@return number 37 | function ObjectUtils.previousGuidIndex() 38 | return guidIndex 39 | end 40 | 41 | ---@return string 42 | function ObjectUtils.guidPrefix() 43 | return guidPrefix 44 | end 45 | 46 | ---@return string 47 | function ObjectUtils.nextGuid() 48 | guidIndex = guidIndex + 1 49 | return guidPrefix .. tostring(guidIndex) 50 | end 51 | 52 | ---@param objectState tts__ObjectState 53 | ---@param guid string 54 | ---@param callback_function nil | tts__ObjectCallbackFunction 55 | local function safeSpawnObject(objectState, guid, callback_function) 56 | objectState.GUID = guid 57 | 58 | local spawningObject = spawnObjectData({ 59 | data = objectState, 60 | callback_function = callback_function, 61 | }) 62 | 63 | return spawningObject 64 | end 65 | 66 | --- 67 | ---A wrapper around TTS' spawnObjectData() which assigns GUIDs in a fashion that mitigates collisions with objects 68 | ---in containers. 69 | --- 70 | ---@param objectState tts__ObjectState @Will be JSON encoded after we generate and assign a GUID. 71 | ---@param callback_function? nil | tts__ObjectCallbackFunction @Callback that will be called when the object has finished spawning. 72 | ---@return tts__Object 73 | function ObjectUtils.safeSpawnObject(objectState, callback_function) 74 | return safeSpawnObject(objectState, ObjectUtils.nextGuid(), callback_function) 75 | end 76 | 77 | --- 78 | ---Same as ObjectUtils.safeSpawnObject(...), except that instead of generating a unique GUID, it is your responsibility 79 | ---to provide one. If you fail to provide a unique GUID, all safety guarantees are lost. 80 | --- 81 | ---In practice, you should only call this method if you're respawning an object that was destroyed. 82 | --- 83 | ---@overload fun(objectState: tts__ObjectState, guid: string): tts__Object 84 | ---@param objectState tts__ObjectState @Will be JSON encoded after we generate and assign a GUID. 85 | ---@param guid string 86 | ---@param callback_function nil | tts__ObjectCallbackFunction @Callback that will be called when the object has finished spawning. 87 | ---@return tts__Object 88 | function ObjectUtils.safeRespawnObject(objectState, guid, callback_function) 89 | return safeSpawnObject(objectState, guid, callback_function) 90 | end 91 | 92 | --- This is only useful in very specific circumstances. Generally ObjectUtils is automatically setup appropriately when 93 | --- Tabletop Simulator calls onLoad. 94 | ---@param guidPrefix string 95 | ---@param guidIndex number 96 | function ObjectUtils.setup(prefix, index) 97 | guidPrefix = prefix 98 | guidIndex = index 99 | end 100 | 101 | ---@overload fun(position: nil | tts__VectorShape): tts__ObjectState_Transform 102 | ---@overload fun(position: nil | tts__VectorShape, rotation: nil | tts__VectorShape): tts__ObjectState_Transform 103 | ---@overload fun(position: nil | tts__VectorShape, rotation: nil | tts__VectorShape, scale: nil | tts__VectorShape): tts__ObjectState_Transform 104 | ---@overload fun(transform: {position: nil | tts__VectorShape, rotation: nil | tts__VectorShape, scale: nil | tts__VectorShape}): tts__ObjectState_Transform 105 | ---@vararg ge_tts__Vector3 106 | ---@return tts__ObjectState_Transform 107 | function ObjectUtils.transformState(...) 108 | ---@type tts__ObjectState_Transform 109 | local state = {} 110 | 111 | ---@type nil | tts__VectorShape 112 | local position 113 | 114 | ---@type nil | tts__VectorShape 115 | local rotation = nil 116 | 117 | ---@type nil | tts__VectorShape 118 | local scale = nil 119 | 120 | if select('#', ...) == 1 then 121 | local args = --[[---@type table]] ... 122 | 123 | if args[1] then 124 | position = --[[---@type tts__VectorShape]] args 125 | else 126 | local transform = --[[---@type {position: nil | tts__VectorShape, rotation: nil | tts__VectorShape, scale: nil | tts__VectorShape}]] args 127 | position = transform.position 128 | rotation = transform.rotation 129 | scale = transform.scale 130 | end 131 | else 132 | position, rotation, scale = ... 133 | end 134 | 135 | if position then 136 | state.posX = (--[[---@type tts__CharVectorShape]] position).x or (--[[---@type tts__NumVectorShape]] position)[1] 137 | state.posY = (--[[---@type tts__CharVectorShape]] position).y or (--[[---@type tts__NumVectorShape]] position)[2] 138 | state.posZ = (--[[---@type tts__CharVectorShape]] position).z or (--[[---@type tts__NumVectorShape]] position)[3] 139 | end 140 | 141 | if rotation then 142 | state.rotX = (--[[---@type tts__CharVectorShape]] rotation).x or (--[[---@type tts__NumVectorShape]] rotation)[1] 143 | state.rotY = (--[[---@type tts__CharVectorShape]] rotation).y or (--[[---@type tts__NumVectorShape]] rotation)[2] 144 | state.rotZ = (--[[---@type tts__CharVectorShape]] rotation).z or (--[[---@type tts__NumVectorShape]] rotation)[3] 145 | end 146 | 147 | if scale then 148 | state.scaleX = (--[[---@type tts__CharVectorShape]] scale).x or (--[[---@type tts__NumVectorShape]] scale)[1] 149 | state.scaleY = (--[[---@type tts__CharVectorShape]] scale).y or (--[[---@type tts__NumVectorShape]] scale)[2] 150 | state.scaleZ = (--[[---@type tts__CharVectorShape]] scale).z or (--[[---@type tts__NumVectorShape]] scale)[3] 151 | end 152 | 153 | return state 154 | end 155 | 156 | ---@param transformState tts__ObjectState_Transform 157 | ---@return ge_tts__Vector3 158 | function ObjectUtils.getTransformStatePosition(transformState) 159 | return Vector3( 160 | transformState.posX or 0, 161 | transformState.posY or 0, 162 | transformState.posZ or 0 163 | ) 164 | end 165 | 166 | ---@param transformState tts__ObjectState_Transform 167 | ---@return ge_tts__Vector3 168 | function ObjectUtils.getTransformStateRotation(transformState) 169 | return Vector3( 170 | transformState.rotX or 0, 171 | transformState.rotY or 0, 172 | transformState.rotZ or 0 173 | ) 174 | end 175 | 176 | ---@param transformState tts__ObjectState_Transform 177 | ---@return ge_tts__Vector3 178 | function ObjectUtils.getTransformStateScale(transformState) 179 | return Vector3( 180 | transformState.scaleX or 1, 181 | transformState.scaleY or 1, 182 | transformState.scaleZ or 1 183 | ) 184 | end 185 | 186 | --- 187 | ---Same as ObjectUtils.safeSpawnObject except that each entry in containerState.ContainedObjects will also be assigned a 188 | ---unique GUID. 189 | --- 190 | ---@overload fun(containerState: tts__ContainerState): tts__Container 191 | ---@param containerState tts__ContainerState @Will be JSON encoded after we generate and assign a GUID. 192 | ---@param callback_function nil | tts__Callback @Callback that will be called when the object has finished spawning. 193 | ---@return tts__Container 194 | function ObjectUtils.safeSpawnContainer(containerState, callback_function) 195 | for _, objectState in ipairs(containerState.ContainedObjects) do 196 | objectState.GUID = ObjectUtils.nextGuid() 197 | end 198 | 199 | return --[[---@type tts__Container]] ObjectUtils.safeSpawnObject(containerState, --[[---@type nil | tts__ObjectCallbackFunction]] callback_function) 200 | end 201 | 202 | local POSITION_NEAR_THRESHOLD = 0.025001 203 | local POSITION_NEAR_THRESHOLD_SQUARED = POSITION_NEAR_THRESHOLD * POSITION_NEAR_THRESHOLD 204 | 205 | --- Works around a bug in TTS' setPositionSmooth that occurs if you setPositionSmooth to a position then immediately 206 | --- back to the current position with setPositionSmooth, then the second call is incorrectly ignored. 207 | ---@param object tts__Object 208 | ---@param position tts__VectorShape 209 | ---@param collide? nil | boolean 210 | ---@param fast? nil | boolean 211 | function ObjectUtils.setPositionSmooth(object, position, collide, fast) 212 | if object.getPositionSmooth() then 213 | local objectPosition = object.getPosition() 214 | 215 | if Vector3.distanceSquared(position, objectPosition) <= POSITION_NEAR_THRESHOLD_SQUARED then 216 | object.setPosition(position) 217 | return 218 | end 219 | end 220 | 221 | object.setPositionSmooth(position, collide, fast) 222 | end 223 | 224 | local ROTATION_NEAR_THRESHOLD = 1.000001 225 | 226 | --- Works around a bug in TTS' setRotationSmooth that occurs if you setRotationSmooth to a rotation then immediately 227 | --- back to the current rotation with setRotationSmooth, then the second call is incorrectly ignored. 228 | ---@param object tts__Object 229 | ---@param rotation tts__VectorShape 230 | ---@param collide? nil | boolean 231 | ---@param fast? nil | boolean 232 | function ObjectUtils.setRotationSmooth(object, rotation, collide, fast) 233 | if object.getRotationSmooth() then 234 | local objectRotation = object.getRotation() 235 | local objectRotationDirection = Vector3(1, 0, 0) 236 | .rotateZ(objectRotation.z) 237 | .rotateX(objectRotation.x) 238 | .rotateY(objectRotation.y) 239 | 240 | local rotationDirection = Vector3(1, 0, 0) 241 | .rotateZ(Vector3.z(rotation)) 242 | .rotateX(Vector3.x(rotation)) 243 | .rotateY(Vector3.y(rotation)) 244 | 245 | if Vector3.angle(rotationDirection, objectRotationDirection) <= ROTATION_NEAR_THRESHOLD then 246 | object.setRotation(rotation) 247 | return 248 | end 249 | end 250 | 251 | object.setRotationSmooth(rotation, collide, fast) 252 | end 253 | 254 | ---@shape __ge_tts__ObjectUtils_SavedStateData 255 | ---@field guidIndex number 256 | ---@field guidPrefix string 257 | 258 | ---@return string 259 | local function onSave() 260 | ---@type __ge_tts__ObjectUtils_SavedStateData 261 | local data = { 262 | guidIndex = guidIndex, 263 | guidPrefix = guidPrefix, 264 | } 265 | 266 | return Json.encode(data) 267 | end 268 | 269 | local function onFirstLoad() 270 | local guidRandomBytes = {} 271 | 272 | for _ = 1, GUID_PREFIX_RANDOM_BYTE_LENGTH do 273 | table.insert(guidRandomBytes, math.random(1, 255)) 274 | end 275 | 276 | guidPrefix = Base64.encode(guidRandomBytes, false) .. ':' 277 | end 278 | 279 | ---@param savedState string 280 | local function onLoad(savedState) 281 | if savedState == '' then 282 | onFirstLoad() 283 | return 284 | end 285 | 286 | local data = --[[---@type __ge_tts__ObjectUtils_SavedStateData]] Json.decode(savedState) 287 | 288 | guidPrefix = data.guidPrefix 289 | guidIndex = data.guidIndex 290 | end 291 | 292 | local MODULE_NAME = 'ge_tts.ObjectUtils' 293 | 294 | SaveManager.registerOnSave(MODULE_NAME, onSave) 295 | SaveManager.registerOnLoad(MODULE_NAME, onLoad) 296 | 297 | return ObjectUtils 298 | -------------------------------------------------------------------------------- /DropZone.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local Class = require('ge_tts.Class') 4 | local ObjectUtils = require('ge_tts.ObjectUtils') 5 | local TableUtils = require('ge_tts.TableUtils') 6 | local Vector3 = require('ge_tts.Vector3') 7 | local Zone = require('ge_tts.Zone') 8 | 9 | local MAX_DROP_VELOCITY_SQUARED = 5 * 5 -- 5 in/sec 10 | 11 | ---@class ge_tts__DropZone : ge_tts__Zone 12 | 13 | ---@shape ge_tts__DropZone_SavedState : ge_tts__Zone_SavedState 14 | ---@field occupantScale number 15 | ---@field rotationEnabled boolean 16 | ---@field rotationAxis number 17 | ---@field facing ge_tts__DropZone_Facing 18 | ---@field dropOffset tts__CharVectorShape 19 | ---@field fastMovementEnabled boolean 20 | ---@field fastRotationEnabled boolean 21 | 22 | ---@class ge_tts__static_DropZone : ge_tts__static_Zone 23 | ---@overload fun(position: tts__VectorShape, rotation: tts__VectorShape, scale: tts__VectorShape): ge_tts__DropZone 24 | ---@overload fun(position: tts__VectorShape, rotation: tts__VectorShape, scale: tts__VectorShape, occupantScale: nil | number): ge_tts__DropZone 25 | ---@overload fun(trigger: tts__ScriptingTrigger): ge_tts__DropZone 26 | ---@overload fun(trigger: tts__ScriptingTrigger, occupantScale: nil | number): ge_tts__DropZone 27 | ---@overload fun(savedState: ge_tts__DropZone_SavedState): ge_tts__DropZone 28 | ---@overload fun(positionTriggerOrSavedState: tts__VectorShape | tts__ScriptingTrigger | ge_tts__DropZone_SavedState, nilOrRotationOrOccupantScale: nil | number | tts__VectorShape, nilOrScale: nil | tts__VectorShape, nilOrOccupantScale: nil | number): ge_tts__DropZone 29 | local DropZone = {} 30 | 31 | DropZone.TYPE = 'DropZone' 32 | 33 | DropZone.Facing = { 34 | UP = 1, 35 | DOWN = 2, 36 | DROPPED = 3, 37 | } 38 | 39 | DropZone.RotationAxis = { 40 | X = 1, 41 | Y = 2, 42 | Z = 4, 43 | All = 7 44 | } 45 | 46 | ---@param colorName tts__PlayerColor 47 | ---@param object tts__Object 48 | local function isInHand(object) 49 | for _, colorName in ipairs(Player.getAvailableColors()) do 50 | local player = Player[colorName] 51 | 52 | for i = 1, player.getHandCount() do 53 | for _, handObject in ipairs(player.getHandObjects(i)) do 54 | if object == handObject then 55 | return true 56 | end 57 | end 58 | end 59 | end 60 | 61 | return false 62 | end 63 | 64 | ---@alias ge_tts__DropZone_Facing 1 | 2 | 3 65 | 66 | setmetatable(DropZone, TableUtils.merge(getmetatable(Zone), { 67 | ---@param class self 68 | ---@param positionTriggerOrSavedState tts__VectorShape | tts__ScriptingTrigger | ge_tts__DropZone_SavedState 69 | ---@param nilOrRotationOrOccupantScale nil | number | tts__VectorShape 70 | ---@param nilOrScale nil | tts__VectorShape 71 | ---@param nilOrOccupantScale nil | number @Optional - occupant's desired X-axis scale. When scaling is applied it is applied to all dimensions i.e. aspect ratio is preserved. `nil` means dropped objects will not have their scale altered. 72 | __call = function(class, positionTriggerOrSavedState, nilOrRotationOrOccupantScale, nilOrScale, nilOrOccupantScale) 73 | local triggerProvided = type(positionTriggerOrSavedState) == 'userdata' 74 | local self = --[[---@type ge_tts__DropZone]] Class.parentConstructor(class, Zone)( 75 | positionTriggerOrSavedState, 76 | not triggerProvided and --[[---@type nil | tts__VectorShape]] nilOrRotationOrOccupantScale or nil, 77 | nilOrScale 78 | ) 79 | 80 | ---@type nil | number 81 | local occupantScale 82 | 83 | ---@type boolean 84 | local rotationEnabled = true 85 | 86 | ---@type number 87 | local rotationAxis = DropZone.RotationAxis.All 88 | 89 | ---@type ge_tts__DropZone_Facing 90 | local facing = DropZone.Facing.UP 91 | 92 | ---@type ge_tts__Vector3 93 | local dropOffset = Vector3() 94 | 95 | local fastMovementEnabled = false 96 | local fastRotationEnabled = false 97 | 98 | ---@return nil | number @occupant's desired X-axis scale 99 | function self.getOccupantScale() 100 | return occupantScale 101 | end 102 | 103 | ---@return number 104 | function self.getRotationAxis() 105 | return rotationAxis 106 | end 107 | 108 | ---@param axis number 109 | function self.setRotationAxis(axis) 110 | rotationAxis = axis 111 | self.invalidateSavedState() 112 | end 113 | 114 | ---@return boolean 115 | function self.getRotationEnabled() 116 | return rotationEnabled 117 | end 118 | 119 | ---@param enabled boolean 120 | function self.setRotationEnabled(enabled) 121 | rotationEnabled = enabled 122 | self.invalidateSavedState() 123 | end 124 | 125 | ---@return ge_tts__DropZone_Facing 126 | function self.getFacing() 127 | return facing 128 | end 129 | 130 | ---@param face ge_tts__DropZone_Facing 131 | function self.setFacing(face) 132 | facing = face 133 | self.invalidateSavedState() 134 | end 135 | 136 | ---@return ge_tts__Vector3 137 | function self.getDropOffset() 138 | return Vector3(dropOffset) 139 | end 140 | 141 | ---@param offset tts__VectorShape 142 | function self.setDropOffset(offset) 143 | dropOffset = Vector3(offset) 144 | self.invalidateSavedState() 145 | end 146 | 147 | ---@return boolean 148 | function self.isFastMovementEnabled() 149 | return fastMovementEnabled 150 | end 151 | 152 | ---@param fast boolean 153 | function self.setFastMovementEnabled(fast) 154 | fastMovementEnabled = fast 155 | end 156 | 157 | ---@return boolean 158 | function self.isFastRotationEnabled() 159 | return fastRotationEnabled 160 | end 161 | 162 | ---@param fast boolean 163 | function self.setFastRotationEnabled(fast) 164 | fastRotationEnabled = fast 165 | end 166 | 167 | --- Called when a player attempts to drop an object within this zone. A drop zone will 168 | --- ignore objects that are dropped whilst moving at a high velocity, as it's assumed the 169 | --- player is trying to throw the object, not drop it in this zone. 170 | ---@param colorName tts__PlayerColor @Color of the TTS player that dropped the TTS object. 171 | ---@param object tts__Object 172 | ---@return ge_tts__Zone_FilterResult 173 | function self.filterObject(colorName, object) 174 | -- TODO: Once released, use object.getSmoothMoveTargetPosition() API. For now, we assume if the object is 175 | -- smooth moving that it's explicitly doing so into the zone i.e. we shouldn't ignore it. 176 | if object.isSmoothMoving() or Vector3.lengthSquared(object.getVelocity()) < MAX_DROP_VELOCITY_SQUARED then 177 | return Zone.FilterResult.ACCEPT 178 | else 179 | return Zone.FilterResult.IGNORE 180 | end 181 | end 182 | 183 | local superOnDrop = self.onDrop 184 | 185 | --- Called when a TTS object is dropped within this DropZone. 186 | ---@param colorName nil | tts__PlayerColor @Color of the TTS player that dropped the TTS object. 187 | ---@param object tts__Object @The object that was dropped. 188 | function self.onDrop(colorName, object) 189 | superOnDrop(colorName, object) 190 | 191 | local objectRotation = object.getRotationSmooth() or object.getRotation() 192 | local position = self.getObject().positionToWorld(dropOffset) 193 | 194 | ObjectUtils.setPositionSmooth(object, { position.x, math.max(position.y, object.getPosition().y), position.z }, false, fastMovementEnabled) 195 | 196 | if isInHand(object) then 197 | local useHands = object.use_hands 198 | 199 | object.use_hands = false 200 | 201 | -- If an object was just dropped in a hand zone, it seems we're unable to smooth move it out this frame. 202 | Wait.frames(function() 203 | if not object.isDestroyed() then 204 | if Zone.getObjectOccupyingZone(object) == self then 205 | ObjectUtils.setPositionSmooth(object, { position.x, math.max(position.y, object.getPosition().y), position.z }, false, fastMovementEnabled) 206 | end 207 | 208 | object.use_hands = useHands 209 | end 210 | end) 211 | end 212 | 213 | if rotationEnabled then 214 | local rotation = self.getRotation() 215 | 216 | if bit32.band(rotationAxis, DropZone.RotationAxis.X) ~= 0 then 217 | objectRotation.x = rotation.x 218 | end 219 | 220 | if bit32.band(rotationAxis, DropZone.RotationAxis.Y) ~= 0 then 221 | objectRotation.y = rotation.y 222 | end 223 | 224 | if bit32.band(rotationAxis, DropZone.RotationAxis.Z) ~= 0 then 225 | if facing == DropZone.Facing.DROPPED then 226 | objectRotation.z = (objectRotation.z + 360) % 360 227 | 228 | if objectRotation.z >= 270 then 229 | objectRotation.z = 360 230 | elseif objectRotation.z <= 90 then 231 | objectRotation.z = 0 232 | else 233 | objectRotation.z = 180 234 | end 235 | else 236 | objectRotation.z = facing == DropZone.Facing.UP and 0 or 180 237 | end 238 | end 239 | 240 | ObjectUtils.setRotationSmooth(object, objectRotation, false, fastRotationEnabled) 241 | end 242 | 243 | if occupantScale then 244 | object.scale((--[[---@not nil]] occupantScale) / object.getScale()[1]) 245 | end 246 | end 247 | 248 | local superInsertOccupyingObject = self.insertOccupyingObject 249 | 250 | --- Used programmatically when `object` should be made a direct occupant, but not dropped by a player. 251 | ---@param object tts__Object @The object that was dropped. 252 | function self.insertOccupyingObject(object) 253 | if occupantScale then 254 | object.scale((--[[---@not nil]] occupantScale) / object.getScale()[1]) 255 | end 256 | 257 | superInsertOccupyingObject(object) 258 | end 259 | 260 | ---@type fun(): ge_tts__Zone_SavedState 261 | local superSave = self.save 262 | 263 | ---@return ge_tts__DropZone_SavedState 264 | function self.save() 265 | return --[[---@type ge_tts__DropZone_SavedState]] TableUtils.merge(superSave(), { 266 | occupantScale = occupantScale, 267 | rotationEnabled = rotationEnabled, 268 | rotationAxis = rotationAxis, 269 | facing = facing, 270 | dropOffset = dropOffset.toData(), 271 | fastMovementEnabled = fastMovementEnabled, 272 | fastRotationEnabled = fastRotationEnabled, 273 | }) 274 | end 275 | 276 | if DropZone.isSavedState(positionTriggerOrSavedState) then 277 | local data = --[[---@type ge_tts__DropZone_SavedState]] positionTriggerOrSavedState 278 | 279 | occupantScale = data.occupantScale 280 | rotationEnabled = data.rotationEnabled 281 | rotationAxis = data.rotationAxis 282 | facing = data.facing 283 | dropOffset = Vector3(data.dropOffset) 284 | fastMovementEnabled = data.fastMovementEnabled 285 | fastRotationEnabled = data.fastRotationEnabled 286 | elseif triggerProvided then 287 | occupantScale = --[[---@type nil | number]] nilOrRotationOrOccupantScale 288 | else 289 | occupantScale = nilOrOccupantScale 290 | end 291 | 292 | return self 293 | end, 294 | __index = Zone, 295 | })) 296 | 297 | return DropZone 298 | -------------------------------------------------------------------------------- /README.asciidoc: -------------------------------------------------------------------------------- 1 | = Glass Echidna Tabletop Simulator Libraries 2 | :toc: 3 | ifndef::env-github[:icons: font] 4 | ifdef::env-github[] 5 | :tip-caption: :bulb: 6 | :note-caption: :information_source: 7 | :important-caption: :heavy_exclamation_mark: 8 | :caution-caption: :fire: 9 | :warning-caption: :warning: 10 | endif::[] 11 | 12 | toc::[] 13 | 14 | == License 15 | 16 | Everything in this repository is permissively licensed under the MIT 17 | license, please refer to the `LICENSE` file. 18 | 19 | == Discord 20 | 21 | If you’d like to discuss ge_tts, you can do so on the 22 | https://discord.gg/YwD22SM[TTS Community Discord]. 23 | 24 | == Example project 25 | 26 | For a quick overview of how to build a mod with ge_tts, please take a 27 | look at the https://github.com/Benjamin-Dobell/ge_tts_demo[demo 28 | project]. 29 | 30 | == Luanalysis and Jetbrains IntelliJ IDEA 31 | 32 | For development of your TTS mod we highly recommend using Jetbrains 33 | IntelliJ IDEA with a 34 | https://github.com/Benjamin-Dobell/IntelliJ-Luanalysis[Luanalysis] to 35 | write your code, instead of (or rather in conjunction with) Atom and the 36 | Atom TTS plugin. 37 | 38 | https://www.jetbrains.com/idea/download/[IntelliJ Community Edition] and 39 | Luanalysis are both free, and offer significantly more advanced Lua 40 | editing capabilities than Atom. 41 | 42 | Most importantly, we’ve included Luanalysis type definitions for our 43 | APIs. This means that when you use our types, function and variable 44 | names will auto-complete. Additionally, when using Luanalysis you’ll 45 | want https://github.com/Benjamin-Dobell/tts-types[Tabletop Simulator 46 | Luanalysis Types] which enable auto-completion _and_ type checking. 47 | 48 | _However_, at present there is not yet a Tabletop Simulator plugin 49 | available for Jetbrains IntelliJ IDEA. So you must still use Atom for 50 | loading code out of, and saving code into Tabletop Simulator. 51 | 52 | == Requiring modules 53 | 54 | ge_tts is split up into standard Lua modules. In order to use these 55 | modules you must https://www.lua.org/pil/8.1.html[require] them. 56 | 57 | [NOTE] 58 | ==== 59 | e.g. 60 | 61 | [source,lua] 62 | ---- 63 | local TableUtils = require('ge_tts.TableUtils') 64 | 65 | log(TableUtils.merge({ 66 | a = 1 67 | }, { 68 | b = 2 69 | })) 70 | ---- 71 | ==== 72 | 73 | The official Atom plugin has built-in support for `require`. Otherwise, 74 | if your IDE doesn’t support it, you can use 75 | https://github.com/Benjamin-Dobell/luabundler[luabundler] from command 76 | line. However, this is an advanced solution, Atom is recommended for 77 | pushing code to TTS. 78 | 79 | == API 80 | 81 | To browse the precise APIs available, you should open up a module in 82 | your IDE and refer to the inline documentation (comments). What follows 83 | is a simply a quick overview of these modules. 84 | 85 | === Base64 86 | 87 | A package for encoding and decoding Base64 binary data. 88 | 89 | === Coroutine 90 | 91 | Convenience functions for working with co-routines that are, for 92 | example, to be executed every X frames, or every X seconds. 93 | 94 | e.g. 95 | [source,lua] 96 | ---- 97 | Coroutine.start(function() 98 | print("Immediately") 99 | 100 | Coroutine.yieldSeconds(1) 101 | 102 | print("One second later") 103 | 104 | Coroutine.yieldFrames(30) 105 | 106 | print("30 frames later") 107 | local object = CaspawnObject({type = "BlockRectangle"}) 108 | 109 | Coroutine.yieldCondition(function() return not object.spawning end) 110 | 111 | print("After the object finished spawning") 112 | end) 113 | ---- 114 | 115 | === Debug 116 | 117 | Simply utility to facilitate debugging within TTS. 118 | 119 | ==== `Debug.createGlobals(prefix = "")` 120 | 121 | Registers all `require()`d ge_tts types as global variables so they can 122 | easily used from TTS’ console. 123 | 124 | e.g. If you include `Debug.createGlobals()` at the bottom of your script 125 | 126 | .... 127 | /execute 128 | .... 129 | 130 | === DropZone 131 | 132 | A `Zone` that acts as flexible, extensible and scriptable replacement 133 | for TTS snap points. When an object is dropped in `DropZone` the object 134 | will be smoothly animated into the center of the zone (and rotated 135 | accordingly). 136 | 137 | `DropZone` also have an optional `occupantScale` which specifies how 138 | dropped objects should be scaled (along the X-axis) when they’re dropped 139 | in the DropZone, aspect ratio is always preserved. Automatic scaling can 140 | be used to provide visual queues about important objects, or rather 141 | objects placed in important locations/zones. 142 | 143 | To extend `DropZone`’s functionality you can ``sub-class'' `DropZone` 144 | and override the `filterObject`, `onEnter`, `onLeave`, `onDrop` and 145 | `onPickup` functions as desired. 146 | 147 | `DropZone` is itself a sub-class of `Zone`, so for an example of how you 148 | can extend a ``class'' please refer to `DropZone.ttslua` (or 149 | `HandZone.ttslua`). 150 | 151 | === EventManager 152 | 153 | TTS has several events which are called as global functions on a script. 154 | It’s fairly common to have several objects or unrelated pieces of code 155 | that are interested in these events. 156 | 157 | `EventManager` allows several pieces of code to subscribe to the one 158 | event. If you have already written global event handler functions you 159 | must move their definition _above_ any `require()` of ge_tts modules in 160 | the same script, otherwise your exising handlers will interfere with 161 | `EventManager`. 162 | 163 | === Graph 164 | 165 | A package with functions useful for working with node hierarchies 166 | e.g. TTS UI (``XML'') tables. 167 | 168 | === HandZone 169 | 170 | A `Zone` that belongs to a player (owner) and corresponds with one of 171 | their hands (most games just have the one hand). When instantiated 172 | `HandZone` will automatically size itself to encompass the associated 173 | TTS hand zone so that you can programatically track cards that are in 174 | the players hand. 175 | 176 | Typically, to make use of this package you’d create your own 177 | package/``class'' where you extend `HandZone` and override the 178 | `onEnter`, `onLeave`, `onDrop` and `onPickup` functions as desired. 179 | 180 | `HandZone` is itself a sub-class of `Zone`, so for an example of how you 181 | can extend a ``class'' please refer to `HandZone.ttslua` (or 182 | `DropZone.ttslua`). 183 | 184 | === Http 185 | 186 | Http convenience module which wraps Tabletop Simulators WebRequest API. 187 | The Http module will automatically encode/decode JSON, otherwise you can 188 | provide a string and specify headers yourself. 189 | 190 | === Instance 191 | 192 | IMPORTANT: ge_tts does not presently support `Instance` being stored 193 | in _nested_ containers i.e. Cards placed in a deck are fine. However, 194 | ge_tts is _presently_ unable to track `Instance` referring to a card in 195 | a deck _in a bag_. 196 | 197 | _Please refer to 198 | https://github.com/Benjamin-Dobell/ge_tts_demo[ge_tts_demo] for a 199 | demonstration._ 200 | 201 | Unlike TTS objects, which are destroyed when entering a container, 202 | instances more closely resemble the concept of a real world game piece, 203 | and are only destroyed if you delete the object in TTS. 204 | 205 | `Instance` also provides some convenience methods that help you interact 206 | with TTS objects. For example, `reject()` knows how to return a TTS 207 | object to wherever it previously came from; either its previous zone, or 208 | if it has never been in a zone before, wherever it was picked up from. 209 | 210 | === InstanceManager 211 | 212 | WARNING: This is an _advanced_ feature, and makes implementing saving 213 | and loading more difficult. 214 | 215 | `InstanceManager` exists for the sole purpose of improving save 216 | performance. 217 | 218 | `InstanceManager` is beneficial if your mod has a lot of `Instance` 219 | (typically 500+) or some of your `Instance` sub-classes are storing a 220 | lot of data that changes infrequently. `InstanceManager` essentially 221 | introduces a caching layer, that results in each instance’s `save()` 222 | being called only when absolutely necessary, and most importantly, 223 | smaller less frequent JSON encodes. 224 | 225 | [arabic] 226 | . You _enable_ use of an `InstanceManager` with 227 | `InstanceManager.set(yourInstanceManager)`. 228 | + 229 | [TIP] 230 | ==== 231 | You _don’t_ need to sub-class `InstanceManager`. 232 | [source,lua] 233 | ---- 234 | InstanceManager.set(InstanceManager()) 235 | ---- 236 | is perfectly acceptable. 237 | ==== 238 | 239 | . Your main module’s `onSave` (`SaveManager.registerOnSave`) must call 240 | `InstanceManager.save()` and `onLoad` (`SaveManager.registerOnLoad`) 241 | must call `InstanceManager.load()`. 242 | 243 | . You must call `self.invalidateSavedState()` on an `Instance`, if you 244 | know its saved state is dirty. 245 | 246 | . When saving an instance, call 247 | `InstanceManager.saveInstanceState(instance)` and store the returned 248 | instance GUID only. As opposed to calling `instanced.save()` and storing 249 | the generated saved stated (which is what you’d do without the 250 | `InstanceManager`). 251 | 252 | . When loading/recreating an instance, call 253 | `InstanceManager.loadInstanceState(instanceGuid)` to obtain the saved 254 | state of the `Instance`, which you’ll then provide to the `Instance`’s 255 | constructor. 256 | 257 | When enabled `InstanceManager` will persist `Instance` saved state 258 | (i.e. return value of `save()`) to the corresponding TTS object’s 259 | `script_state`. 260 | 261 | === Logger 262 | 263 | A robust logging system with support for log levels and filtering. 264 | 265 | === PlayerDropZone 266 | 267 | A `DropZone` that is associated with a particular TTS player, 268 | specifically instances have an additional `getOwner()`. 269 | 270 | === RemoteLogger 271 | 272 | A `Logger` that rather than printing to TTS’ console, will HTTP `PUT` a 273 | JSON object with `messages` (array of strings) to a URL that you provide 274 | when instantiating the `RemoteLogger`. 275 | 276 | Using HTTP `PUT` instead of `POST` is pretty severe abuse of HTTP 277 | semantics, however we don’t have a choice as TTS’ HTTP functionality is 278 | severely lacking and cannot `POST` JSON. 279 | 280 | WARNING: The `Content-Type` of the request is `octet-stream` instead 281 | of the correct type `application/json`. As mentioned, TTS’ HTTP client 282 | is currently very limited and does not allow us to set headers. 283 | 284 | We don’t presently provide a corresponding server, but it’s pretty 285 | trivial to create your own in Python, Ruby, Node.js etc. 286 | 287 | Remote logs could be useful for diagnosing issues your players are 288 | running into, however personally I just use it in development as my logs 289 | are kept even if TTS crashes, and it’s easy to copy and paste data from 290 | my logs etc. 291 | 292 | === SaveManager 293 | 294 | SaveManager allows modules/files to independently maintain their own 295 | saved state, without conflicting with other saved state from other 296 | modules/files. 297 | 298 | === TableUtils 299 | 300 | Several convenience methods to be used in conjunction with tables. 301 | 302 | WARNING: For both performance and semantic reasons, this module will 303 | only operate on tables that are either _arrays_ or _hashes/maps_, but 304 | not tables that are _both_ simultaneously. Behavior is undefined for 305 | tables that contain a key for [1] _as well as_ non-consecutive integer, 306 | or non-integer, keys. 307 | 308 | === Vector2 309 | 310 | 2D vector implementation. 311 | 312 | === Vector3 313 | 314 | 3D vector implementation. 315 | 316 | This was written before TTS had its own `Vector` class and is used 317 | through-out this library. You may pass `Vector3` to any TTS method that 318 | accepts a vector. However, it’s worth keeping in mind that our methods 319 | return a `Vector3`, whilst TTS’s own methods return `Vector`. 320 | 321 | In general TTS’ `Vector` and our `Vector3` offer a similar set of 322 | functionality, however you can call `Vector3` methods the same way you’d 323 | call methods on any complex type in TTS API i.e. `vector1.add(vector2)`, 324 | where as TTS’ `Vector` requres you to do `vector1:add(vector2)`. 325 | 326 | Additionally, all `Vector3` methods will happily accept a `Vector3`, a 327 | `Vector`, a table with entries `x`, `y` and `z`, or a table with entries 328 | `[1]`, `[2]` and `[3]` as arguments. Where as the TTS-provided `Vector` 329 | is a bit more restrictive and will only accept arguments that are also 330 | `Vector` e.g.  331 | 332 | [source,lua] 333 | ---- 334 | local v = Vector() 335 | v:scale({1, 3, 1}) -- This will throw an error 336 | 337 | local v3 = Vector3() 338 | v3.scale({1, 3, 1}) -- This works fine, as does... 339 | v3.scale(v) 340 | v3.scale({x = 1, y = 3, z = 1}) 341 | ---- 342 | 343 | === Zone 344 | 345 | A wrapper around a TTS scripting trigger (`ScriptingTrigger`) that 346 | tracks dropped and picked up objects. Objects that have been dropped in 347 | the `Zone` are deemed to be occupying and can be retrieved with 348 | `getOccupyingObjects()`. 349 | 350 | Typically, you’ll want to use a `DropZone`, `PlayerDropZone` or 351 | `HandZone` rather than `Zone`. However, you may sub-class `Zone` if you 352 | wish. 353 | -------------------------------------------------------------------------------- /Json.ttslua: -------------------------------------------------------------------------------- 1 | require('ge_tts.License') 2 | 3 | local License = require('ge_tts.License') 4 | 5 | if Color then 6 | -- JSON encoding of Color presently fails due to a bug in Color. Fortunately, we can patch Color to fix it. 7 | require('ge_tts.GlobalPatches') 8 | end 9 | 10 | local Coroutine = require('ge_tts.Coroutine') 11 | local TableUtils = require('ge_tts.TableUtils') 12 | 13 | local LunaJsonDecoder = require('ge_tts.lunajson.decoder') 14 | local LunaJsonEncoder = require('ge_tts.lunajson.encoder') 15 | local LunaJsonSax = require('ge_tts.lunajson.sax') 16 | 17 | -- This license applies to lunajson. Do *not* assume it extends to the mod! 18 | local lunajsonLicense = [[The MIT License (MIT) 19 | 20 | Copyright (c) 2015-2017 Shunsuke Shimizu (grafi) 21 | 22 | Permission is hereby granted, free of charge, to any person obtaining a copy 23 | of this software and associated documentation files (the "Software"), to deal 24 | in the Software without restriction, including without limitation the rights 25 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 26 | copies of the Software, and to permit persons to whom the Software is 27 | furnished to do so, subject to the following conditions: 28 | 29 | The above copyright notice and this permission notice shall be included in 30 | all copies or substantial portions of the Software. 31 | 32 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 33 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 34 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 35 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 36 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 37 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 38 | THE SOFTWARE.]] 39 | 40 | License.add("lunajson", lunajsonLicense) 41 | 42 | ---@class ge_tts__JsonNull 43 | local NULL = setmetatable({}, { 44 | __index = {}, 45 | __newindex = function() error("Attempt to modify JSON.null()") end, 46 | __metatable = false 47 | }) 48 | 49 | ---@class ge_tts__Json 50 | local Json = {} 51 | 52 | ---@return ge_tts__JsonNull 53 | function Json.null() 54 | return NULL 55 | end 56 | 57 | ---@alias ge_tts__JsonObject table 58 | ---@alias ge_tts__JsonArray ge_tts__JsonValue[] 59 | ---@alias ge_tts__JsonContainer ge_tts__JsonObject | ge_tts__JsonArray 60 | ---@alias ge_tts__JsonValue ge_tts__JsonContainer | number | string | boolean | nil | ge_tts__JsonNull 61 | 62 | ---@alias __ge_tts__JsonNodeTypeObject 0 63 | ---@alias __ge_tts__JsonNodeTypeArray 1 64 | ---@alias __ge_tts__JsonNodeTypeKey 2 65 | 66 | ---@type __ge_tts__JsonNodeTypeObject 67 | local NODE_OBJECT = 0 68 | 69 | ---@type __ge_tts__JsonNodeTypeArray 70 | local NODE_ARRAY = 1 71 | 72 | ---@type __ge_tts__JsonNodeTypeKey 73 | local NODE_KEY = 2 74 | 75 | ---@alias __ge_tts__JsonNodeType __ge_tts__JsonNodeTypeObject | __ge_tts__JsonNodeTypeArray | __ge_tts__JsonNodeTypeKey 76 | 77 | ---@alias __ge_tts__JsonObjectNode {[1]: __ge_tts__JsonNodeTypeObject, [2]: ge_tts__JsonObject} 78 | ---@alias __ge_tts__JsonArrayNode {[1]: __ge_tts__JsonNodeTypeArray, [2]: ge_tts__JsonArray, [3]: number} 79 | ---@alias __ge_tts__JsonKeyNode {[1]: __ge_tts__JsonNodeTypeKey, [2]: string } 80 | 81 | 82 | ---@alias __ge_tts__JsonNode __ge_tts__JsonObjectNode | __ge_tts__JsonArrayNode | __ge_tts__JsonKeyNode 83 | 84 | ---@shape ge_tts__Json_DecodeOptions 85 | ---@field encodeArrayLength nil | boolean @Default false. When true, array lengths are written to an `n` field on the array (i.e. std__Packed). Thus an empty array can be discerned from an empty table. 86 | ---@field nullIdentification nil | boolean @Default true. When true, null values in an array/object are represented by JSON.null() rather than being omitted. 87 | 88 | ---@shape ge_tts__Json_DecodeAsyncOptions : ge_tts__Json_DecodeOptions 89 | ---@field onCompletion fun(data: any): void 90 | ---@field onError fun(message: string): void 91 | ---@field charactersPerChunk nil | number @Default 2048 (2 KiB) 92 | ---@field framesBetweenChunks nil | number @Default 1 93 | 94 | ---@shape ge_tts__Json_EncodeAsyncOptions 95 | ---@field onCompletion fun(json: string): void 96 | ---@field onError fun(message: string): void 97 | ---@field componentsPerChunk nil | number @Default 128 98 | ---@field framesBetweenChunks nil | number @Default 1 99 | 100 | ---@type ge_tts__Json_DecodeOptions 101 | local defaultDecodeOptions = { 102 | encodeArrayLength = false, 103 | nullIdentification = true, 104 | } 105 | 106 | --- Sets the default decoding options used by decode and decodeAsync when called with options omitted. 107 | ---@param decodeOptions ge_tts__Json_DecodeOptions 108 | function Json.setDefaultDecodeOptions(decodeOptions) 109 | defaultDecodeOptions = decodeOptions 110 | end 111 | 112 | --- Parses JSON in a pseudo-async fashion using co-operative multi-tasking (i.e. coroutines). 113 | --- 114 | --- The parser will only do a limited amount of work each frame before handing off processing back to TTS, thus we 115 | --- don't freeze the game when parsing large JSON. 116 | --- 117 | --- Return value is a function that can be called to cancel decoding if it is yet to complete. 118 | ---@param json string 119 | ---@param options ge_tts__Json_DecodeAsyncOptions 120 | ---@return fun(): void 121 | function Json.decodeAsync(json, options) 122 | local canceled = false 123 | 124 | options = TableUtils.merge(--[[---@type ge_tts__Json_DecodeAsyncOptions]] defaultDecodeOptions, options) 125 | 126 | Coroutine.start(function() 127 | ---@type __ge_tts__JsonNode[] 128 | local stack = {} 129 | 130 | ---@type nil | __ge_tts__JsonNode 131 | local currentNode 132 | 133 | ---@type ge_tts__JsonValue 134 | local result 135 | 136 | ---@param value ge_tts__JsonValue 137 | local function addValue(value) 138 | if currentNode then 139 | local nodeType = (--[[---@not nil]] currentNode)[1] 140 | 141 | if value == nil and options.nullIdentification then 142 | value = Json.null() 143 | end 144 | 145 | if nodeType == NODE_KEY then 146 | local key = (--[[---@type __ge_tts__JsonKeyNode]] currentNode)[2] 147 | 148 | local parentNode = --[[---@type __ge_tts__JsonObjectNode]] table.remove(stack) 149 | local parentObject = parentNode[2] 150 | parentObject[key] = value 151 | 152 | currentNode = parentNode 153 | elseif nodeType == NODE_ARRAY then 154 | local arrayNode = --[[---@type __ge_tts__JsonArrayNode]] currentNode 155 | 156 | local array = arrayNode[2] 157 | arrayNode[3] = arrayNode[3] + 1 -- Update length 158 | array[arrayNode[3]] = value 159 | end 160 | else 161 | result = value 162 | end 163 | end 164 | 165 | ---@type lunajson__SaxHandler 166 | local handler = { 167 | startobject = function() 168 | if currentNode then 169 | table.insert(stack, --[[---@not nil]] currentNode) 170 | end 171 | 172 | currentNode = {NODE_OBJECT , {}} 173 | end, 174 | ---@param key string 175 | key = function(key) 176 | table.insert(stack, --[[---@not nil]] currentNode) 177 | currentNode = {NODE_KEY, key} 178 | end, 179 | endobject = function() 180 | local objectNode = (--[[---@type __ge_tts__JsonObjectNode]] currentNode) 181 | currentNode = table.remove(stack) 182 | addValue(objectNode[2]) 183 | end, 184 | startarray = function() 185 | if currentNode then 186 | table.insert(stack, --[[---@not nil]] currentNode) 187 | end 188 | 189 | currentNode = {NODE_ARRAY , {}, 0} 190 | end, 191 | endarray = function() 192 | local objectNode = (--[[---@type __ge_tts__JsonArrayNode]] currentNode) 193 | local array = objectNode[2] 194 | currentNode = table.remove(stack) 195 | 196 | if options.encodeArrayLength then 197 | (--[[---@type std__Packed]] array).n = objectNode[3] 198 | end 199 | 200 | addValue(array) 201 | end, 202 | string = addValue, 203 | number = addValue, 204 | boolean = addValue, 205 | null = function() 206 | addValue(nil) 207 | end, 208 | } 209 | 210 | ---@type number 211 | local charactersPerChunk = 0 212 | 213 | if options.charactersPerChunk then 214 | charactersPerChunk = --[[---@not nil]] options.charactersPerChunk 215 | end 216 | 217 | if charactersPerChunk <= 0 then 218 | charactersPerChunk = 2048 219 | end 220 | 221 | ---@type number 222 | local framesBetweenChunks 223 | 224 | if options.framesBetweenChunks and framesBetweenChunks > 0 then 225 | framesBetweenChunks = --[[---@not nil]] options.framesBetweenChunks 226 | else 227 | framesBetweenChunks = 1 228 | end 229 | 230 | local offset = 1 231 | local length = #json 232 | 233 | local function feed() 234 | local characterCount = math.min(length - offset + 1, charactersPerChunk) 235 | 236 | if characterCount == 0 or canceled then 237 | return nil 238 | end 239 | 240 | Coroutine.yieldFrames(framesBetweenChunks, function(message) 241 | if not canceled then 242 | options.onError(message) 243 | end 244 | end) 245 | 246 | local nextOffset = offset + characterCount 247 | local substring = json:sub(offset, nextOffset - 1) 248 | offset = nextOffset 249 | return substring 250 | end 251 | 252 | local parser = --[[---@type {run: fun(): void}]] LunaJsonSax.newparser(feed, handler) 253 | parser.run() 254 | 255 | if not canceled then 256 | options.onCompletion(result) 257 | end 258 | end) 259 | 260 | return function() 261 | canceled = true 262 | end 263 | end 264 | 265 | local decode = LunaJsonDecoder() 266 | 267 | ---@overload fun(json: string): any 268 | ---@param json string 269 | ---@param options nil | ge_tts__Json_DecodeOptions 270 | ---@return any 271 | function Json.decode(json, options) 272 | local decodeOptions = TableUtils.merge(defaultDecodeOptions, --[[---@not nil]] options or {}) 273 | local nullValue = decodeOptions.nullIdentification and Json.null() or nil 274 | return (decode(json, 0, nullValue, decodeOptions.encodeArrayLength or false)) 275 | end 276 | 277 | local encode = LunaJsonEncoder() 278 | 279 | ---@param value any 280 | ---@return string 281 | function Json.encode(value) 282 | return encode(value, Json.null()) 283 | end 284 | 285 | ---@param value any 286 | ---@param options ge_tts__Json_EncodeAsyncOptions 287 | ---@return fun(): void 288 | function Json.encodeAsync(value, options) 289 | local canceled = false 290 | 291 | Coroutine.start(function() 292 | local asyncEncode = LunaJsonEncoder() 293 | local concat = table.concat 294 | 295 | ---@param builder string[] 296 | ---@param nextIndex number 297 | ---@return string[], number 298 | local function replacer(builder, nextIndex) 299 | return { concat(builder) }, 2 300 | end 301 | 302 | ---@type lunajson__GenerateValueEncode 303 | local generateEncode = function(nullv, dispatcher, push, replaceBuilder) 304 | local componentCount = 0 305 | local chunkSize = options.componentsPerChunk or 128 306 | local waitFrames = options.framesBetweenChunks or 1 307 | 308 | ---@param v 309 | return function(v) 310 | if canceled then 311 | return 312 | end 313 | 314 | if v == nullv then 315 | push('null') 316 | else 317 | dispatcher[--[[---@not 'nil' | 'function' | 'thread' | 'userdata']] type(v)](v) 318 | end 319 | 320 | componentCount = componentCount + 1 321 | 322 | if componentCount >= chunkSize then 323 | componentCount = 0 324 | replaceBuilder(replacer) 325 | 326 | Coroutine.yieldFrames(waitFrames, function(message) 327 | if not canceled then 328 | options.onError(message) 329 | end 330 | end) 331 | end 332 | end 333 | end 334 | 335 | local result = asyncEncode(value, Json.null(), generateEncode) 336 | 337 | if not canceled then 338 | options.onCompletion(result) 339 | end 340 | end) 341 | 342 | return function() 343 | canceled = true 344 | end 345 | end 346 | 347 | --- Fills gaps (up to the specified length) in sparseArray with Json.null(), then returns it. 348 | ---@generic T 349 | ---@generic N : number 350 | ---@param sparseArray table 351 | ---@param length number 352 | ---@return (T | ge_tts__JsonNull)[] 353 | function Json.nullFillSparseArray(sparseArray, length) 354 | for i = 1, length do 355 | if type((--[[---@type T[] ]] sparseArray)[i]) == 'nil' then 356 | (--[[---@type (T | ge_tts__JsonNull)[] ]] sparseArray)[i] = Json.null() 357 | end 358 | end 359 | 360 | return --[[---@type (T | ge_tts__JsonNull)[] ]] sparseArray 361 | end 362 | 363 | return Json 364 | -------------------------------------------------------------------------------- /lunajson/decoder.lua: -------------------------------------------------------------------------------- 1 | local setmetatable, tonumber, tostring = 2 | setmetatable, tonumber, tostring 3 | local floor, inf = 4 | math.floor, math.huge 5 | local byte, char, find, gsub, match, sub = 6 | string.byte, string.char, string.find, string.gsub, string.match, string.sub 7 | 8 | local function _decode_error(pos, errmsg) 9 | error("parse error at " .. pos .. ": " .. errmsg, 2) 10 | end 11 | 12 | local f_str_ctrl_pat = '[^\32-\255]' 13 | local _ENV = nil 14 | 15 | 16 | local function newdecoder() 17 | ---@type string, number, any, boolean, number 18 | local json, pos, nullv, arraylen, rec_depth 19 | 20 | -- `f` is the temporary for dispatcher[c] and 21 | -- the dummy for the first return value of `find` 22 | ---@type {[number]: fun(): void}, fun(): any 23 | local dispatcher, f 24 | 25 | --[[ 26 | Helper 27 | --]] 28 | local function decode_error(errmsg) 29 | return _decode_error(pos, errmsg) 30 | end 31 | 32 | --[[ 33 | Invalid 34 | --]] 35 | local function f_err() 36 | decode_error('invalid value') 37 | end 38 | 39 | --[[ 40 | Constants 41 | --]] 42 | -- null 43 | local function f_nul() 44 | if sub(json, pos, pos+2) == 'ull' then 45 | pos = pos+3 46 | return nullv 47 | end 48 | decode_error('invalid value') 49 | end 50 | 51 | -- false 52 | local function f_fls() 53 | if sub(json, pos, pos+3) == 'alse' then 54 | pos = pos+4 55 | return false 56 | end 57 | decode_error('invalid value') 58 | end 59 | 60 | -- true 61 | local function f_tru() 62 | if sub(json, pos, pos+2) == 'rue' then 63 | pos = pos+3 64 | return true 65 | end 66 | decode_error('invalid value') 67 | end 68 | 69 | --[[ 70 | Numbers 71 | Conceptually, the longest prefix that matches to `[-+.0-9A-Za-z]+` (in regexp) 72 | is captured as a number and its conformance to the JSON spec is checked. 73 | --]] 74 | -- deal with non-standard locales 75 | local radixmark = --[[---@type string]] match(tostring(0.5), '[^0-9]') 76 | local fixedtonumber = tonumber 77 | if radixmark ~= '.' then 78 | if find(radixmark, '%W') then 79 | radixmark = '%' .. radixmark 80 | end 81 | fixedtonumber = function(s) 82 | return tonumber((gsub(s, '.', radixmark))) 83 | end 84 | end 85 | 86 | local function number_error() 87 | return decode_error('invalid number') 88 | end 89 | 90 | -- `0(\.[0-9]*)?([eE][+-]?[0-9]*)?` 91 | local function f_zro(mns) 92 | ---@type string, string | number 93 | local num, c = --[[---@type string, string]] match(json, '^(%.?[0-9]*)([-+.A-Za-z]?)', pos) -- skipping 0 94 | 95 | if num == '' then 96 | if c == '' then 97 | if mns then 98 | return -0.0 99 | end 100 | return 0 101 | end 102 | 103 | if c == 'e' or c == 'E' then 104 | num, c = --[[---@type string, string]] match(json, '^([^eE]*[eE][-+]?[0-9]+)([-+.A-Za-z]?)', pos) 105 | if c == '' then 106 | pos = pos + #num 107 | if mns then 108 | return -0.0 109 | end 110 | return 0.0 111 | end 112 | end 113 | number_error() 114 | end 115 | 116 | if byte(num) ~= 0x2E or byte(num, -1) == 0x2E then 117 | number_error() 118 | end 119 | 120 | if c ~= '' then 121 | if c == 'e' or c == 'E' then 122 | num, c = --[[---@type string, string]] match(json, '^([^eE]*[eE][-+]?[0-9]+)([-+.A-Za-z]?)', pos) 123 | end 124 | if c ~= '' then 125 | number_error() 126 | end 127 | end 128 | 129 | pos = pos + #num 130 | c = --[[---@not nil]] fixedtonumber(num) 131 | 132 | if mns then 133 | c = -c 134 | end 135 | return c 136 | end 137 | 138 | -- `[1-9][0-9]*(\.[0-9]*)?([eE][+-]?[0-9]*)?` 139 | local function f_num(mns) 140 | pos = pos-1 141 | 142 | ---@type string, string | number 143 | local num, c = --[[---@type string, string]] match(json, '^([0-9]+%.?[0-9]*)([-+.A-Za-z]?)', pos) 144 | if byte(num, -1) == 0x2E then -- error if ended with period 145 | number_error() 146 | end 147 | 148 | if c ~= '' then 149 | if c ~= 'e' and c ~= 'E' then 150 | number_error() 151 | end 152 | num, c = --[[---@type string, string]] match(json, '^([^eE]*[eE][-+]?[0-9]+)([-+.A-Za-z]?)', pos) 153 | if not num or c ~= '' then 154 | number_error() 155 | end 156 | end 157 | 158 | pos = pos + #num 159 | c = --[[---@not nil]] fixedtonumber(num) 160 | 161 | if mns then 162 | c = -c 163 | end 164 | return c 165 | end 166 | 167 | -- skip minus sign 168 | local function f_mns() 169 | local c = byte(json, pos) 170 | if c then 171 | pos = pos+1 172 | if c > 0x30 then 173 | if c < 0x3A then 174 | return f_num(true) 175 | end 176 | else 177 | if c > 0x2F then 178 | return f_zro(true) 179 | end 180 | end 181 | end 182 | decode_error('invalid number') 183 | end 184 | 185 | --[[ 186 | Strings 187 | --]] 188 | local f_str_hextbl = --[[---@type {[number]: number, __index: fun(): number}]] { 189 | 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 190 | 0x8, 0x9, inf, inf, inf, inf, inf, inf, 191 | inf, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, inf, 192 | inf, inf, inf, inf, inf, inf, inf, inf, 193 | inf, inf, inf, inf, inf, inf, inf, inf, 194 | inf, inf, inf, inf, inf, inf, inf, inf, 195 | inf, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, 196 | __index = function() 197 | return inf 198 | end 199 | } 200 | setmetatable(f_str_hextbl, f_str_hextbl) 201 | 202 | local f_str_escapetbl = { 203 | ['"'] = '"', 204 | ['\\'] = '\\', 205 | ['/'] = '/', 206 | ['b'] = '\b', 207 | ['f'] = '\f', 208 | ['n'] = '\n', 209 | ['r'] = '\r', 210 | ['t'] = '\t', 211 | __index = function() 212 | decode_error("invalid escape sequence") 213 | end 214 | } 215 | setmetatable(f_str_escapetbl, f_str_escapetbl) 216 | 217 | local function surrogate_first_error() 218 | return decode_error("1st surrogate pair byte not continued by 2nd") 219 | end 220 | 221 | local f_str_surrogate_prev = 0 222 | 223 | ---@overload fun(ch: string, ucode: string): string 224 | ---@param ch '"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 'u' 225 | ---@param ucode number 226 | ---@return string 227 | local function f_str_subst(ch, ucode) 228 | if ch == 'u' then 229 | local c1, c2, c3, c4, rest = --[[---@not nil, nil, nil, nil]] byte(--[[---@type string]] ucode, 1, 5) 230 | ucode = f_str_hextbl[c1-47] * 0x1000 + 231 | f_str_hextbl[c2-47] * 0x100 + 232 | f_str_hextbl[c3-47] * 0x10 + 233 | f_str_hextbl[c4-47] 234 | if ucode ~= inf then 235 | if ucode < 0x80 then -- 1byte 236 | if rest then 237 | return char(ucode, rest) 238 | end 239 | return char(ucode) 240 | elseif ucode < 0x800 then -- 2bytes 241 | c1 = floor(ucode / 0x40) 242 | c2 = ucode - c1 * 0x40 243 | c1 = c1 + 0xC0 244 | c2 = c2 + 0x80 245 | if rest then 246 | return char(c1, c2, rest) 247 | end 248 | return char(c1, c2) 249 | elseif ucode < 0xD800 or 0xE000 <= ucode then -- 3bytes 250 | c1 = floor(ucode / 0x1000) 251 | ucode = ucode - c1 * 0x1000 252 | c2 = floor(ucode / 0x40) 253 | c3 = ucode - c2 * 0x40 254 | c1 = c1 + 0xE0 255 | c2 = c2 + 0x80 256 | c3 = c3 + 0x80 257 | if rest then 258 | return char(c1, c2, c3, rest) 259 | end 260 | return char(c1, c2, c3) 261 | elseif 0xD800 <= ucode and ucode < 0xDC00 then -- surrogate pair 1st 262 | if f_str_surrogate_prev == 0 then 263 | f_str_surrogate_prev = ucode 264 | if not rest then 265 | return '' 266 | end 267 | surrogate_first_error() 268 | end 269 | f_str_surrogate_prev = 0 270 | surrogate_first_error() 271 | else -- surrogate pair 2nd 272 | if f_str_surrogate_prev ~= 0 then 273 | ucode = 0x10000 + 274 | (f_str_surrogate_prev - 0xD800) * 0x400 + 275 | (ucode - 0xDC00) 276 | f_str_surrogate_prev = 0 277 | c1 = floor(ucode / 0x40000) 278 | ucode = ucode - c1 * 0x40000 279 | c2 = floor(ucode / 0x1000) 280 | ucode = ucode - c2 * 0x1000 281 | c3 = floor(ucode / 0x40) 282 | c4 = ucode - c3 * 0x40 283 | c1 = c1 + 0xF0 284 | c2 = c2 + 0x80 285 | c3 = c3 + 0x80 286 | c4 = c4 + 0x80 287 | if rest then 288 | return char(c1, c2, c3, c4, rest) 289 | end 290 | return char(c1, c2, c3, c4) 291 | end 292 | decode_error("2nd surrogate pair byte appeared without 1st") 293 | end 294 | end 295 | decode_error("invalid unicode codepoint literal") 296 | end 297 | if f_str_surrogate_prev ~= 0 then 298 | f_str_surrogate_prev = 0 299 | surrogate_first_error() 300 | end 301 | return f_str_escapetbl[--[[---@not 'u']] ch] .. ucode 302 | end 303 | 304 | -- caching interpreted keys for speed 305 | local f_str_keycache = --[[---@type {[string]: string}]] setmetatable({}, {__mode="v"}) 306 | 307 | local function f_str(iskey) 308 | local newpos = pos 309 | 310 | ---@type number, number, number 311 | local tmppos, c1, c2 312 | repeat 313 | newpos = --[[---@type number]] find(json, '"', newpos, true) -- search '"' 314 | if not newpos then 315 | decode_error("unterminated string") 316 | end 317 | tmppos = newpos-1 318 | newpos = newpos+1 319 | c1, c2 = --[[---@not nil, nil]] byte(json, tmppos-1, tmppos) 320 | if c2 == 0x5C and c1 == 0x5C then -- skip preceding '\\'s 321 | repeat 322 | tmppos = tmppos-2 323 | c1, c2 = --[[---@not nil, nil]] byte(json, tmppos-1, tmppos) 324 | until c2 ~= 0x5C or c1 ~= 0x5C 325 | tmppos = newpos-2 326 | end 327 | until c2 ~= 0x5C -- leave if '"' is not preceded by '\' 328 | 329 | local str = sub(json, pos, tmppos) 330 | pos = newpos 331 | 332 | if iskey then -- check key cache 333 | tmppos = --[[---@type any]] f_str_keycache[str] -- reuse tmppos for cache key/val 334 | if tmppos then 335 | return --[[---@type string]] tmppos 336 | end 337 | tmppos = --[[---@type any]] str 338 | end 339 | 340 | if find(str, f_str_ctrl_pat) then 341 | decode_error("unescaped control string") 342 | end 343 | if find(str, '\\', 1, true) then -- check whether a backslash exists 344 | -- We need to grab 4 characters after the escape char, 345 | -- for encoding unicode codepoint to UTF-8. 346 | -- As we need to ensure that every first surrogate pair byte is 347 | -- immediately followed by second one, we grab upto 5 characters and 348 | -- check the last for this purpose. 349 | str = gsub(str, '\\(.)([^\\]?[^\\]?[^\\]?[^\\]?[^\\]?)', f_str_subst) 350 | if f_str_surrogate_prev ~= 0 then 351 | f_str_surrogate_prev = 0 352 | decode_error("1st surrogate pair byte not continued by 2nd") 353 | end 354 | end 355 | if iskey then -- commit key cache 356 | f_str_keycache[--[[---@type string]] tmppos] = str 357 | end 358 | return str 359 | end 360 | 361 | --[[ 362 | Arrays, Objects 363 | --]] 364 | -- array 365 | local function f_ary() 366 | rec_depth = rec_depth + 1 367 | if rec_depth > 1000 then 368 | decode_error('too deeply nested json (> 1000)') 369 | end 370 | local ary = {} 371 | 372 | pos = --[[---@type number]] match(json, '^[ \n\r\t]*()', pos) 373 | 374 | local i = 0 375 | if byte(json, pos) == 0x5D then -- check closing bracket ']' which means the array empty 376 | pos = pos+1 377 | else 378 | local newpos = pos 379 | repeat 380 | i = i+1 381 | f = dispatcher[--[[---@not nil]] byte(json,newpos)] -- parse value 382 | pos = newpos+1 383 | ary[i] = f() 384 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*,[ \n\r\t]*()', pos) -- check comma 385 | until not newpos 386 | 387 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*%]()', pos) -- check closing bracket 388 | if not newpos then 389 | decode_error("no closing bracket of an array") 390 | end 391 | pos = newpos 392 | end 393 | 394 | if arraylen then -- commit the length of the array if `arraylen` is set 395 | ary.n = i 396 | end 397 | rec_depth = rec_depth - 1 398 | return ary 399 | end 400 | 401 | -- objects 402 | local function f_obj() 403 | rec_depth = rec_depth + 1 404 | if rec_depth > 1000 then 405 | decode_error('too deeply nested json (> 1000)') 406 | end 407 | 408 | local obj = --[[---@type {[string]: any}]] {} 409 | 410 | pos = --[[---@type number]] match(json, '^[ \n\r\t]*()', pos) 411 | if byte(json, pos) == 0x7D then -- check closing bracket '}' which means the object empty 412 | pos = pos+1 413 | else 414 | local newpos = pos 415 | 416 | repeat 417 | if byte(json, newpos) ~= 0x22 then -- check '"' 418 | decode_error("not key") 419 | end 420 | pos = newpos+1 421 | local key = f_str(true) -- parse key 422 | 423 | -- optimized for compact json 424 | -- c1, c2 == ':', or 425 | -- c1, c2, c3 == ':', ' ', 426 | f = f_err 427 | local c1, c2, c3 = --[[---@not nil, nil, nil]] byte(json, pos, pos+3) 428 | if c1 == 0x3A then 429 | if c2 ~= 0x20 then 430 | f = dispatcher[c2] 431 | newpos = pos+2 432 | else 433 | f = dispatcher[c3] 434 | newpos = pos+3 435 | end 436 | end 437 | if f == f_err then -- read a colon and arbitrary number of spaces 438 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*:[ \n\r\t]*()', pos) 439 | if not newpos then 440 | decode_error("no colon after a key") 441 | end 442 | f = dispatcher[--[[---@not nil]] byte(json, newpos)] 443 | newpos = newpos+1 444 | end 445 | pos = newpos 446 | obj[key] = f() -- parse value 447 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*,[ \n\r\t]*()', pos) 448 | until not newpos 449 | 450 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*}()', pos) 451 | if not newpos then 452 | decode_error("no closing bracket of an object") 453 | end 454 | pos = newpos 455 | end 456 | 457 | rec_depth = rec_depth - 1 458 | return obj 459 | end 460 | 461 | --[[ 462 | The jump table to dispatch a parser for a value, 463 | indexed by the code of the value's first char. 464 | Nil key means the end of json. 465 | --]] 466 | dispatcher = --[[---@type {[number]: fun(): void}]] { [0] = 467 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 468 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 469 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 470 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 471 | f_err, f_err, f_str, f_err, f_err, f_err, f_err, f_err, 472 | f_err, f_err, f_err, f_err, f_err, f_mns, f_err, f_err, 473 | f_zro, f_num, f_num, f_num, f_num, f_num, f_num, f_num, 474 | f_num, f_num, f_err, f_err, f_err, f_err, f_err, f_err, 475 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 476 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 477 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 478 | f_err, f_err, f_err, f_ary, f_err, f_err, f_err, f_err, 479 | f_err, f_err, f_err, f_err, f_err, f_err, f_fls, f_err, 480 | f_err, f_err, f_err, f_err, f_err, f_err, f_nul, f_err, 481 | f_err, f_err, f_err, f_err, f_tru, f_err, f_err, f_err, 482 | f_err, f_err, f_err, f_obj, f_err, f_err, f_err, f_err, 483 | __index = function() 484 | decode_error("unexpected termination") 485 | end 486 | } 487 | setmetatable(dispatcher, dispatcher) 488 | 489 | --[[ 490 | run decoder 491 | --]] 492 | ---@param json_ string 493 | ---@param pos_ number 494 | ---@param nullv_ any 495 | ---@param arraylen_ boolean 496 | ---@return any | (any, number) 497 | local function decode(json_, pos_, nullv_, arraylen_) 498 | json, pos, nullv, arraylen = json_, pos_, nullv_, arraylen_ 499 | rec_depth = 0 500 | 501 | pos = --[[---@type number]] match(json, '^[ \n\r\t]*()', pos) 502 | 503 | f = dispatcher[--[[---@not nil]] byte(json, pos)] 504 | pos = pos+1 505 | local v = f() 506 | 507 | if pos_ then 508 | return v, pos 509 | else 510 | f, pos = --[[---@type any, number]] find(json, '^[ \n\r\t]*', pos) 511 | if pos ~= #json then 512 | decode_error('json ended') 513 | end 514 | return v 515 | end 516 | end 517 | 518 | return decode 519 | end 520 | 521 | return newdecoder 522 | -------------------------------------------------------------------------------- /lunajson/sax.lua: -------------------------------------------------------------------------------- 1 | local setmetatable, tonumber, tostring = 2 | setmetatable, tonumber, tostring 3 | local floor, inf = 4 | math.floor, math.huge 5 | local byte, char, find, gsub, match, sub = 6 | string.byte, string.char, string.find, string.gsub, string.match, string.sub 7 | 8 | local function _parse_error(pos, errmsg) 9 | error("parse error at " .. pos .. ": " .. errmsg, 2) 10 | end 11 | 12 | local f_str_ctrl_pat = '[^\32-\255]' 13 | local type, unpack = type, table.unpack 14 | 15 | local _ENV = nil 16 | 17 | ---@class lunajson__SaxParser 18 | ---@field run fun(): void 19 | ---@field tryc fun(): nil | number 20 | ---@field read fun(n: number): string 21 | ---@field tellpos fun(): number 22 | 23 | ---@shape lunajson__SaxHandler 24 | ---@field startobject nil | fun(): void 25 | ---@field key nil | fun(key: string): void 26 | ---@field endobject nil | fun(): void 27 | ---@field startarray nil | fun(): void 28 | ---@field endarray nil | fun(): void 29 | ---@field string nil | fun(value: string): void 30 | ---@field number nil | fun(value: number): void 31 | ---@field boolean nil | fun(value: boolean): void 32 | ---@field null nil | fun(value: nil): void 33 | 34 | local function nop() end 35 | 36 | ---@overload fun(src: string, saxtbl: lunajson__SaxHandler): lunajson__SaxParser 37 | ---@param src fun(): nil | string 38 | ---@param saxtbl lunajson__SaxHandler 39 | ---@return lunajson__SaxParser 40 | local function newparser(src, saxtbl) 41 | ---@type string, (fun(): void), number 42 | local json, jsonnxt, rec_depth 43 | local jsonlen, pos, acc = 0, 1, 0 44 | 45 | -- `f` is the temporary for dispatcher[c] and 46 | -- the dummy for the first return value of `find` 47 | ---@type {[number]: fun(): void}, fun(): void 48 | local dispatcher, f 49 | 50 | -- initialize 51 | if type(src) == 'string' then 52 | json = --[[---@type string]] src 53 | jsonlen = #json 54 | jsonnxt = function() 55 | json = '' 56 | jsonlen = 0 57 | jsonnxt = nop 58 | end 59 | else 60 | jsonnxt = function() 61 | acc = acc + jsonlen 62 | pos = 1 63 | 64 | repeat 65 | -- Don't like this cast, it's wrong. Ideally we'd have a local 66 | -- var that's nillable, but lunajson is heavily optimized, so we 67 | -- make do. 68 | json = --[[---@not nil]] src() 69 | 70 | if not json then 71 | json = '' 72 | jsonlen = 0 73 | jsonnxt = nop 74 | return 75 | end 76 | 77 | jsonlen = #json 78 | until jsonlen > 0 79 | end 80 | jsonnxt() 81 | end 82 | 83 | local sax_startobject = saxtbl.startobject or nop 84 | local sax_key = saxtbl.key or nop 85 | local sax_endobject = saxtbl.endobject or nop 86 | local sax_startarray = saxtbl.startarray or nop 87 | local sax_endarray = saxtbl.endarray or nop 88 | local sax_string = saxtbl.string or nop 89 | local sax_number = saxtbl.number or nop 90 | local sax_boolean = saxtbl.boolean or nop 91 | local sax_null = saxtbl.null or nop 92 | 93 | --[[ 94 | Helper 95 | --]] 96 | local function tryc() 97 | local c = byte(json, pos) 98 | if not c then 99 | jsonnxt() 100 | c = byte(json, pos) 101 | end 102 | return c 103 | end 104 | 105 | local function parse_error(errmsg) 106 | return _parse_error(acc + pos, errmsg) 107 | end 108 | 109 | local function tellc() 110 | return tryc() or parse_error("unexpected termination") 111 | end 112 | 113 | local function spaces() -- skip spaces and prepare the next char 114 | while true do 115 | pos = --[[---@type number]] match(json, '^[ \n\r\t]*()', pos) 116 | if pos <= jsonlen then 117 | return 118 | end 119 | if jsonlen == 0 then 120 | parse_error("unexpected termination") 121 | end 122 | jsonnxt() 123 | end 124 | end 125 | 126 | --[[ 127 | Invalid 128 | --]] 129 | local function f_err() 130 | parse_error('invalid value') 131 | end 132 | 133 | --[[ 134 | Constants 135 | --]] 136 | -- fallback slow constants parser 137 | ---@overload fun(target: string, targetlen: number, ret: nil, sax_f: fun(value: nil): void): void 138 | ---@overload fun(target: string, targetlen: number, ret: true, sax_f: fun(value: true): void): void 139 | ---@overload fun(target: string, targetlen: number, ret: false, sax_f: fun(value: false): void): void 140 | ---@param target string 141 | ---@param targetlen number 142 | ---@param ret nil | boolean 143 | ---@param sax_f fun(value: nil | boolean): void 144 | local function generic_constant(target, targetlen, ret, sax_f) 145 | for i = 1, targetlen do 146 | local c = tellc() 147 | if byte(target, i) ~= c then 148 | parse_error("invalid char") 149 | end 150 | pos = pos+1 151 | end 152 | sax_f(ret) 153 | end 154 | 155 | -- null 156 | local function f_nul() 157 | if sub(json, pos, pos+2) == 'ull' then 158 | pos = pos+3 159 | sax_null(nil) 160 | return 161 | end 162 | generic_constant('ull', 3, nil, sax_null) 163 | end 164 | 165 | -- false 166 | local function f_fls() 167 | if sub(json, pos, pos+3) == 'alse' then 168 | pos = pos+4 169 | sax_boolean(false) 170 | return 171 | end 172 | generic_constant('alse', 4, false, sax_boolean) 173 | end 174 | 175 | -- true 176 | local function f_tru() 177 | if sub(json, pos, pos+2) == 'rue' then 178 | pos = pos+3 179 | sax_boolean(true) 180 | return 181 | end 182 | generic_constant('rue', 3, true, sax_boolean) 183 | end 184 | 185 | --[[ 186 | Numbers 187 | Conceptually, the longest prefix that matches to `[-+.0-9A-Za-z]+` (in regexp) 188 | is captured as a number and its conformance to the JSON spec is checked. 189 | --]] 190 | -- deal with non-standard locales 191 | local radixmark = --[[---@type string]] match(tostring(0.5), '[^0-9]') 192 | local fixedtonumber = tonumber 193 | if radixmark ~= '.' then 194 | if find(radixmark, '%W') then 195 | radixmark = '%' .. radixmark 196 | end 197 | fixedtonumber = function(s) 198 | return tonumber((gsub(s, '.', radixmark))) 199 | end 200 | end 201 | 202 | local function number_error() 203 | return parse_error('invalid number') 204 | end 205 | 206 | -- fallback slow parser 207 | local function generic_number(mns) 208 | ---@type (nil | number)[] 209 | local buf = {} 210 | local i = 1 211 | local is_int = true 212 | 213 | local c = byte(json, pos) 214 | pos = pos+1 215 | 216 | local function nxt() 217 | buf[i] = c 218 | i = i+1 219 | c = tryc() 220 | pos = pos+1 221 | end 222 | 223 | if c == 0x30 then 224 | nxt() 225 | if c and 0x30 <= c and c < 0x3A then 226 | number_error() 227 | end 228 | else 229 | repeat nxt() until not (c and 0x30 <= c and c < 0x3A) 230 | end 231 | if c == 0x2E then 232 | is_int = false 233 | nxt() 234 | if not (c and 0x30 <= c and c < 0x3A) then 235 | number_error() 236 | end 237 | repeat nxt() until not (c and 0x30 <= c and c < 0x3A) 238 | end 239 | if c == 0x45 or c == 0x65 then 240 | is_int = false 241 | nxt() 242 | if c == 0x2B or c == 0x2D then 243 | nxt() 244 | end 245 | if not (c and 0x30 <= c and c < 0x3A) then 246 | number_error() 247 | end 248 | repeat nxt() until not (c and 0x30 <= c and c < 0x3A) 249 | end 250 | if c and (0x41 <= c and c <= 0x5B or 251 | 0x61 <= c and c <= 0x7B or 252 | c == 0x2B or c == 0x2D or c == 0x2E) then 253 | number_error() 254 | end 255 | pos = pos-1 256 | 257 | local num = fixedtonumber(char(unpack(buf))) 258 | if mns then 259 | num = -num 260 | end 261 | sax_number(--[[---@not nil]] num) 262 | end 263 | 264 | -- `0(\.[0-9]*)?([eE][+-]?[0-9]*)?` 265 | local function f_zro(mns) 266 | ---@type string, number | string 267 | local num, c = --[[---@type string, string]] match(json, '^(%.?[0-9]*)([-+.A-Za-z]?)', pos) -- skipping 0 268 | 269 | if num == '' then 270 | if pos > jsonlen then 271 | pos = pos - 1 272 | generic_number(mns) 273 | return 274 | end 275 | if c == '' then 276 | if mns then 277 | sax_number(-0.0) 278 | return 279 | end 280 | sax_number(0) 281 | return 282 | end 283 | 284 | if c == 'e' or c == 'E' then 285 | num, c = --[[---@type string, string]] match(json, '^([^eE]*[eE][-+]?[0-9]+)([-+.A-Za-z]?)', pos) 286 | if c == '' then 287 | pos = pos + #num 288 | if pos > jsonlen then 289 | pos = pos - #num - 1 290 | generic_number(mns) 291 | return 292 | end 293 | if mns then 294 | sax_number(-0.0) 295 | return 296 | end 297 | sax_number(0.0) 298 | return 299 | end 300 | end 301 | pos = pos-1 302 | generic_number(mns) 303 | return 304 | end 305 | 306 | if byte(num) ~= 0x2E or byte(num, -1) == 0x2E then 307 | pos = pos-1 308 | generic_number(mns) 309 | return 310 | end 311 | 312 | if c ~= '' then 313 | if c == 'e' or c == 'E' then 314 | num, c = --[[---@type string, string]] match(json, '^([^eE]*[eE][-+]?[0-9]+)([-+.A-Za-z]?)', pos) 315 | end 316 | if c ~= '' then 317 | pos = pos-1 318 | generic_number(mns) 319 | return 320 | end 321 | end 322 | 323 | pos = pos + #num 324 | if pos > jsonlen then 325 | pos = pos - #num - 1 326 | generic_number(mns) 327 | return 328 | end 329 | c = --[[---@not nil]] fixedtonumber(num) 330 | 331 | if mns then 332 | c = -c 333 | end 334 | sax_number(--[[---@type number]] c) 335 | return 336 | end 337 | 338 | -- `[1-9][0-9]*(\.[0-9]*)?([eE][+-]?[0-9]*)?` 339 | local function f_num(mns) 340 | pos = pos-1 341 | 342 | ---@type string, string | number 343 | local num, c = --[[---@type string, string]] match(json, '^([0-9]+%.?[0-9]*)([-+.A-Za-z]?)', pos) 344 | if byte(num, -1) == 0x2E then -- error if ended with period 345 | generic_number(mns) 346 | return 347 | end 348 | 349 | if c ~= '' then 350 | if c ~= 'e' and c ~= 'E' then 351 | generic_number(mns) 352 | return 353 | end 354 | num, c = --[[---@type string, string]] match(json, '^([^eE]*[eE][-+]?[0-9]+)([-+.A-Za-z]?)', pos) 355 | if not num or c ~= '' then 356 | generic_number(mns) 357 | return 358 | end 359 | end 360 | 361 | pos = pos + #num 362 | if pos > jsonlen then 363 | pos = pos - #num 364 | generic_number(mns) 365 | return 366 | end 367 | c = --[[---@not nil]] fixedtonumber(num) 368 | 369 | if mns then 370 | c = -c 371 | end 372 | sax_number(--[[---@type number]] c) 373 | end 374 | 375 | -- skip minus sign 376 | local function f_mns() 377 | local c = (byte(json, pos)) or tellc() 378 | if c then 379 | pos = pos+1 380 | if c > 0x30 then 381 | if c < 0x3A then 382 | f_num(true) 383 | return 384 | end 385 | else 386 | if c > 0x2F then 387 | f_zro(true) 388 | return 389 | end 390 | end 391 | end 392 | parse_error("invalid number") 393 | end 394 | 395 | --[[ 396 | Strings 397 | --]] 398 | local f_str_hextbl = --[[---@type {[number]: number, __index: fun(): number}]] { 399 | 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 400 | 0x8, 0x9, inf, inf, inf, inf, inf, inf, 401 | inf, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, inf, 402 | inf, inf, inf, inf, inf, inf, inf, inf, 403 | inf, inf, inf, inf, inf, inf, inf, inf, 404 | inf, inf, inf, inf, inf, inf, inf, inf, 405 | inf, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, 406 | __index = function() 407 | return inf 408 | end 409 | } 410 | setmetatable(f_str_hextbl, f_str_hextbl) 411 | 412 | local f_str_escapetbl = { 413 | ['"'] = '"', 414 | ['\\'] = '\\', 415 | ['/'] = '/', 416 | ['b'] = '\b', 417 | ['f'] = '\f', 418 | ['n'] = '\n', 419 | ['r'] = '\r', 420 | ['t'] = '\t', 421 | __index = function() 422 | parse_error("invalid escape sequence") 423 | end 424 | } 425 | setmetatable(f_str_escapetbl, f_str_escapetbl) 426 | 427 | local function surrogate_first_error() 428 | return parse_error("1st surrogate pair byte not continued by 2nd") 429 | end 430 | 431 | local f_str_surrogate_prev = 0 432 | 433 | ---@overload fun(ch: string, ucode: string): string 434 | ---@param ch '"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 'u' 435 | ---@param ucode number 436 | ---@return string 437 | local function f_str_subst(ch, ucode) 438 | if ch == 'u' then 439 | local c1, c2, c3, c4, rest = --[[---@not nil, nil, nil, nil]] byte(--[[---@type string]] ucode, 1, 5) 440 | ucode = f_str_hextbl[c1-47] * 0x1000 + 441 | f_str_hextbl[c2-47] * 0x100 + 442 | f_str_hextbl[c3-47] * 0x10 + 443 | f_str_hextbl[c4-47] 444 | if ucode ~= inf then 445 | if ucode < 0x80 then -- 1byte 446 | if rest then 447 | return char(ucode, rest) 448 | end 449 | return char(ucode) 450 | elseif ucode < 0x800 then -- 2bytes 451 | c1 = floor(ucode / 0x40) 452 | c2 = ucode - c1 * 0x40 453 | c1 = c1 + 0xC0 454 | c2 = c2 + 0x80 455 | if rest then 456 | return char(c1, c2, rest) 457 | end 458 | return char(c1, c2) 459 | elseif ucode < 0xD800 or 0xE000 <= ucode then -- 3bytes 460 | c1 = floor(ucode / 0x1000) 461 | ucode = ucode - c1 * 0x1000 462 | c2 = floor(ucode / 0x40) 463 | c3 = ucode - c2 * 0x40 464 | c1 = c1 + 0xE0 465 | c2 = c2 + 0x80 466 | c3 = c3 + 0x80 467 | if rest then 468 | return char(c1, c2, c3, rest) 469 | end 470 | return char(c1, c2, c3) 471 | elseif 0xD800 <= ucode and ucode < 0xDC00 then -- surrogate pair 1st 472 | if f_str_surrogate_prev == 0 then 473 | f_str_surrogate_prev = ucode 474 | if not rest then 475 | return '' 476 | end 477 | surrogate_first_error() 478 | end 479 | f_str_surrogate_prev = 0 480 | surrogate_first_error() 481 | else -- surrogate pair 2nd 482 | if f_str_surrogate_prev ~= 0 then 483 | ucode = 0x10000 + 484 | (f_str_surrogate_prev - 0xD800) * 0x400 + 485 | (ucode - 0xDC00) 486 | f_str_surrogate_prev = 0 487 | c1 = floor(ucode / 0x40000) 488 | ucode = ucode - c1 * 0x40000 489 | c2 = floor(ucode / 0x1000) 490 | ucode = ucode - c2 * 0x1000 491 | c3 = floor(ucode / 0x40) 492 | c4 = ucode - c3 * 0x40 493 | c1 = c1 + 0xF0 494 | c2 = c2 + 0x80 495 | c3 = c3 + 0x80 496 | c4 = c4 + 0x80 497 | if rest then 498 | return char(c1, c2, c3, c4, rest) 499 | end 500 | return char(c1, c2, c3, c4) 501 | end 502 | parse_error("2nd surrogate pair byte appeared without 1st") 503 | end 504 | end 505 | parse_error("invalid unicode codepoint literal") 506 | end 507 | if f_str_surrogate_prev ~= 0 then 508 | f_str_surrogate_prev = 0 509 | surrogate_first_error() 510 | end 511 | return f_str_escapetbl[--[[---@not 'u']] ch] .. ucode 512 | end 513 | 514 | local function f_str(iskey) 515 | local pos2 = pos 516 | ---@type number 517 | local newpos 518 | local str = '' 519 | ---@type nil | true 520 | local bs 521 | while true do 522 | while true do -- search '\' or '"' 523 | newpos = --[[---@not nil]] find(json, '[\\"]', pos2) 524 | if newpos then 525 | break 526 | end 527 | str = str .. sub(json, pos, jsonlen) 528 | if pos2 == jsonlen+2 then 529 | pos2 = 2 530 | else 531 | pos2 = 1 532 | end 533 | jsonnxt() 534 | if jsonlen == 0 then 535 | parse_error("unterminated string") 536 | end 537 | end 538 | if byte(json, newpos) == 0x22 then -- break if '"' 539 | break 540 | end 541 | pos2 = newpos+2 -- skip '\' 542 | bs = true -- mark the existence of a backslash 543 | end 544 | str = str .. sub(json, pos, newpos-1) 545 | pos = newpos+1 546 | 547 | if find(str, f_str_ctrl_pat) then 548 | parse_error("unescaped control string") 549 | end 550 | if bs then -- a backslash exists 551 | -- We need to grab 4 characters after the escape char, 552 | -- for encoding unicode codepoint to UTF-8. 553 | -- As we need to ensure that every first surrogate pair byte is 554 | -- immediately followed by second one, we grab upto 5 characters and 555 | -- check the last for this purpose. 556 | str = gsub(str, '\\(.)([^\\]?[^\\]?[^\\]?[^\\]?[^\\]?)', f_str_subst) 557 | if f_str_surrogate_prev ~= 0 then 558 | f_str_surrogate_prev = 0 559 | parse_error("1st surrogate pair byte not continued by 2nd") 560 | end 561 | end 562 | 563 | if iskey then 564 | sax_key(str) 565 | else 566 | sax_string(str) 567 | end 568 | end 569 | 570 | --[[ 571 | Arrays, Objects 572 | --]] 573 | -- arrays 574 | local function f_ary() 575 | rec_depth = rec_depth + 1 576 | if rec_depth > 1000 then 577 | parse_error('too deeply nested json (> 1000)') 578 | end 579 | sax_startarray() 580 | 581 | spaces() 582 | if byte(json, pos) == 0x5D then -- check closing bracket ']' which means the array empty 583 | pos = pos+1 584 | else 585 | ---@type number 586 | local newpos 587 | while true do 588 | f = dispatcher[--[[---@not nil]] byte(json, pos)] -- parse value 589 | pos = pos+1 590 | f() 591 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*,[ \n\r\t]*()', pos) -- check comma 592 | if newpos then 593 | pos = newpos 594 | else 595 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*%]()', pos) -- check closing bracket 596 | if newpos then 597 | pos = newpos 598 | break 599 | end 600 | spaces() -- since the current chunk can be ended, skip spaces toward following chunks 601 | local c = byte(json, pos) 602 | pos = pos+1 603 | if c == 0x2C then -- check comma again 604 | spaces() 605 | elseif c == 0x5D then -- check closing bracket again 606 | break 607 | else 608 | parse_error("no closing bracket of an array") 609 | end 610 | end 611 | if pos > jsonlen then 612 | spaces() 613 | end 614 | end 615 | end 616 | 617 | rec_depth = rec_depth - 1 618 | sax_endarray() 619 | end 620 | 621 | -- objects 622 | local function f_obj() 623 | rec_depth = rec_depth + 1 624 | if rec_depth > 1000 then 625 | parse_error('too deeply nested json (> 1000)') 626 | end 627 | sax_startobject() 628 | 629 | spaces() 630 | if byte(json, pos) == 0x7D then -- check closing bracket '}' which means the object empty 631 | pos = pos+1 632 | else 633 | ---@type number 634 | local newpos 635 | while true do 636 | if byte(json, pos) ~= 0x22 then 637 | parse_error("not key") 638 | end 639 | pos = pos+1 640 | f_str(true) -- parse key 641 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*:[ \n\r\t]*()', pos) -- check colon 642 | if newpos then 643 | pos = newpos 644 | else 645 | spaces() -- read spaces through chunks 646 | if byte(json, pos) ~= 0x3A then -- check colon again 647 | parse_error("no colon after a key") 648 | end 649 | pos = pos+1 650 | spaces() 651 | end 652 | if pos > jsonlen then 653 | spaces() 654 | end 655 | f = dispatcher[--[[---@not nil]] byte(json, pos)] 656 | pos = pos+1 657 | f() -- parse value 658 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*,[ \n\r\t]*()', pos) -- check comma 659 | if newpos then 660 | pos = newpos 661 | else 662 | newpos = --[[---@type number]] match(json, '^[ \n\r\t]*}()', pos) -- check closing bracket 663 | if newpos then 664 | pos = newpos 665 | break 666 | end 667 | spaces() -- read spaces through chunks 668 | local c = byte(json, pos) 669 | pos = pos+1 670 | if c == 0x2C then -- check comma again 671 | spaces() 672 | elseif c == 0x7D then -- check closing bracket again 673 | break 674 | else 675 | parse_error("no closing bracket of an object") 676 | end 677 | end 678 | if pos > jsonlen then 679 | spaces() 680 | end 681 | end 682 | end 683 | 684 | rec_depth = rec_depth - 1 685 | sax_endobject() 686 | end 687 | 688 | --[[ 689 | The jump table to dispatch a parser for a value, 690 | indexed by the code of the value's first char. 691 | Key should be non-nil. 692 | --]] 693 | dispatcher = --[[---@type {[number]: fun(): void}]] { [0] = 694 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 695 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 696 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 697 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 698 | f_err, f_err, f_str, f_err, f_err, f_err, f_err, f_err, 699 | f_err, f_err, f_err, f_err, f_err, f_mns, f_err, f_err, 700 | f_zro, f_num, f_num, f_num, f_num, f_num, f_num, f_num, 701 | f_num, f_num, f_err, f_err, f_err, f_err, f_err, f_err, 702 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 703 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 704 | f_err, f_err, f_err, f_err, f_err, f_err, f_err, f_err, 705 | f_err, f_err, f_err, f_ary, f_err, f_err, f_err, f_err, 706 | f_err, f_err, f_err, f_err, f_err, f_err, f_fls, f_err, 707 | f_err, f_err, f_err, f_err, f_err, f_err, f_nul, f_err, 708 | f_err, f_err, f_err, f_err, f_tru, f_err, f_err, f_err, 709 | f_err, f_err, f_err, f_obj, f_err, f_err, f_err, f_err, 710 | } 711 | 712 | --[[ 713 | public funcitons 714 | --]] 715 | local function run() 716 | rec_depth = 0 717 | spaces() 718 | f = dispatcher[--[[---@not nil]] byte(json, pos)] 719 | pos = pos+1 720 | f() 721 | end 722 | 723 | ---@param n number 724 | ---@return string 725 | local function read(n) 726 | if n < 0 then 727 | error("the argument must be non-negative") 728 | end 729 | local pos2 = (pos-1) + n 730 | local str = sub(json, pos, pos2) 731 | while pos2 > jsonlen and jsonlen ~= 0 do 732 | jsonnxt() 733 | pos2 = pos2 - (jsonlen - (pos-1)) 734 | str = str .. sub(json, pos, pos2) 735 | end 736 | if jsonlen ~= 0 then 737 | pos = pos2+1 738 | end 739 | return str 740 | end 741 | 742 | local function tellpos() 743 | return acc + pos 744 | end 745 | 746 | return --[[---@type lunajson__SaxParser]] { 747 | run = run, 748 | tryc = tryc, 749 | read = read, 750 | tellpos = tellpos, 751 | } 752 | end 753 | 754 | return { 755 | newparser = newparser 756 | } 757 | --------------------------------------------------------------------------------