-- --------------------------------------------------------------------------- -- Small shared helpers. Kept deliberately thin. -- --------------------------------------------------------------------------- Util = {} function Util.trim(s) if type(s) ~= 'string' then return '' end return (s:gsub('^%s+', ''):gsub('%s+$', '')) end function Util.clamp(v, lo, hi) if v < lo then return lo end if v > hi then return hi end return v end function Util.round(v, places) local m = 10 ^ (places or 0) return math.floor(v * m + 0.5) / m end --- Deep copy; cycles are not expected in config/state tables. function Util.copy(t) if type(t) ~= 'table' then return t end local out = {} for k, v in pairs(t) do out[k] = Util.copy(v) end return out end function Util.count(t) local n = 0 for _ in pairs(t) do n = n + 1 end return n end --- Group separated money, e.g. 1234567 -> "1,234,567" function Util.money(n) local s = tostring(math.floor(math.abs(n or 0))) local out = s:reverse():gsub('(%d%d%d)', '%1,'):reverse() out = out:gsub('^,', '') return (n or 0) < 0 and ('-' .. out) or out end --- Names are stored capitalised regardless of how they were typed. function Util.properName(s) s = Util.trim(s):lower() return (s:gsub("([^%s'-]+)", function(word) return word:sub(1, 1):upper() .. word:sub(2) end)) end --- Validation shared by client (live feedback) and server (enforcement), so --- the two can never disagree about what is acceptable. Rules = {} Rules.username = { min = 3, max = 20, pattern = '^[%w_]+$', hint = '3-20 characters, letters, numbers and underscore only', } Rules.password = { min = 8, max = 72, hint = 'at least 8 characters, with a letter and a number', } Rules.name = { min = 2, max = 20, pattern = "^[%a][%a'%-]*$", hint = "letters, apostrophes and hyphens", } function Rules.checkUsername(v) v = Util.trim(v or '') if #v < Rules.username.min then return false, 'Too short - ' .. Rules.username.hint end if #v > Rules.username.max then return false, 'Too long - ' .. Rules.username.hint end if not v:match(Rules.username.pattern) then return false, 'Only letters, numbers and underscore' end return true end function Rules.checkPassword(v) v = v or '' if #v < Rules.password.min then return false, 'At least 8 characters' end if #v > Rules.password.max then return false, 'Too long' end if not v:match('%a') then return false, 'Needs at least one letter' end if not v:match('%d') then return false, 'Needs at least one number' end return true end function Rules.checkName(v) v = Util.trim(v or '') if #v < Rules.name.min then return false, 'Too short' end if #v > Rules.name.max then return false, 'Too long' end if not v:match(Rules.name.pattern) then return false, "Letters, ' and - only" end return true end --- Date of birth: accepts YYYY-MM-DD, must be a real date and an adult age. function Rules.checkDob(v) v = Util.trim(v or '') local y, m, d = v:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$') if not y then return false, 'Use YYYY-MM-DD' end y, m, d = tonumber(y), tonumber(m), tonumber(d) if m < 1 or m > 12 then return false, 'Month must be 01-12' end local mdays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } if (y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0 then mdays[2] = 29 end if d < 1 or d > mdays[m] then return false, 'That day does not exist' end if y < 1930 or y > 2010 then return false, 'Year must be between 1930 and 2010' end return true end