This commit is contained in:
Kim ravn Hansen
2025-08-31 10:08:06 +02:00
commit 81e1196669
12 changed files with 4014 additions and 0 deletions

249
tooling/mathD100.lua Executable file
View File

@@ -0,0 +1,249 @@
--
-- {{{ Digit Functions
--- Get sorted base-10 digits of a number
---@param n integer
---@return integer[]
local function get_sorted_digits(n)
local digits = {}
for d in tostring(math.abs(n)):gmatch("%d") do
table.insert(digits, tonumber(d))
end
table.sort(digits)
return digits
end
-- }}}
--{{{ print_r
local function print_r(t)
local print_r_cache = {}
local function sub_print_r(t, indent)
if (print_r_cache[tostring(t)]) then
print(indent .. "*" .. tostring(t))
else
print_r_cache[tostring(t)] = true
if (type(t) == "table") then
local tLen = #t
for i = 1, tLen do
local val = t[i]
if (type(val) == "table") then
print(indent .. "#[" .. i .. "] => " .. tostring(t) .. " {")
sub_print_r(val, indent .. string.rep(" ", string.len(i) + 8))
print(indent .. string.rep(" ", string.len(i) + 6) .. "}")
elseif (type(val) == "string") then
print(indent .. "#[" .. i .. '] => "' .. val .. '"')
else
print(indent .. "#[" .. i .. "] => " .. tostring(val))
end
end
for pos, val in pairs(t) do
if type(pos) ~= "number" or math.floor(pos) ~= pos or (pos < 1 or pos > tLen) then
if (type(val) == "table") then
print(indent .. "[" .. pos .. "] => " .. tostring(t) .. " {")
sub_print_r(val, indent .. string.rep(" ", string.len(pos) + 8))
print(indent .. string.rep(" ", string.len(pos) + 6) .. "}")
elseif (type(val) == "string") then
print(indent .. "[" .. pos .. '] => "' .. val .. '"')
else
print(indent .. "[" .. pos .. "] => " .. tostring(val))
end
end
end
else
print(indent .. tostring(t))
end
end
end
if (type(t) == "table") then
print(tostring(t) .. " {")
sub_print_r(t, " ")
print("}")
else
sub_print_r(t, " ")
end
print()
end
--}}}
--{{{ table_count
function table_count(t)
local count = 0
for _ in pairs(t) do count = count + 1 end
return count
end
--}}}
--{{{ range(low, high, n)
---Create a table with integers in a given range
---@param low integer Lowest number
---@param high integer Highest number
---@param n integer Increment - the array will contain every nth number between [low..high]
---@return table
local function range(low, high, n)
local t = {}
for i = low, high, n do
table.insert(t, i)
end
return t
end
---}}}
--{{{ sortMapByValueDesc(m)
---Sort the table m by value.
---@param m table
---@return table
local function sortMapByValueDesc(m)
local totalCount = 0
local tmp = {}
-- create a temporary table that is easier to sort
for damage, occurrences in pairs(m) do
local entry = {
damage = damage,
count = occurrences,
}
totalCount = totalCount + occurrences
table.insert(tmp, entry)
end
table.sort(tmp, function(a, b)
return a.count > b.count
end)
return tmp
--
-- local result = {}
-- for _, v in pairs(tmp) do
-- result[v.damage] = v.count
-- end
--
--
-- if table_count(result) ~= table_count(m) then
-- error("I couldn't sort!!")
-- end
--
-- return result
end
--}}}
--{{{table_sum(t)
---numeric sum of table values
---@param t integer[]
---@return integer
local function table_sum(t)
local result = 0
for _, v in pairs(t) do
result = result + v
end
return result
end
--}}}
--{{{mostCommonDamageValues(instances, ratio)
--- Calculate the most likely values
---@param instances table A map of [damage score] => [occurrences]
---@param ratio number Include most common damage until the sum of the probabilities of the damage scores are at least this number.
---@return table
local function mostCommonDamageValues(instances, ratio)
if ratio == nil then
ratio = 0.5
end
local sortedInstances = sortMapByValueDesc(instances)
local totalOccurrences = table_sum(instances)
local result = {}
local occurrenceAccumulator = 0
for _, instance in pairs(sortedInstances) do
occurrenceAccumulator = occurrenceAccumulator + instance.count
table.insert(result, instance.damage)
if occurrenceAccumulator / totalOccurrences >= ratio then
return result
end
end
return result
end
--}}}
--{{{calculateDamageD100(skill, base_damage)
---Calculate the average of the d100 attack method
---@param skill integer The skill level of the attacker (range of 1 to 100, but will be capped to 20)
---@param base_damage integer The base damage of the attacker's weapon.
---@return number
---@return table mostRolled the damage values that are most likely to be rolled
local function calculateDamageD100(skill, base_damage)
local sum = 0
local n = 0
local tn = math.min(skill, 80)
local damages = {}
for rolled = 1, 100, 1 do
local digits = get_sorted_digits(rolled)
local damage = 0
if rolled > tn then
-- glancing blow: damage equal to weapon's base damage.
damage = base_damage
end
if rolled < tn then
-- normal hit: damage equal digit sum of what is rolled plus base damage.
damage = table_sum(digits) + base_damage
end
if rolled == tn then
-- Critical Hit: damage equal to what you rolled.
damage = rolled
end
local key = tostring(damage)
n = n + 1
sum = sum + damage
if not damages[key] then
damages[key] = 1
else
damages[key] = damages[key] + 1
end
end
return sum / n, mostCommonDamageValues(damages, 0.5)
end
--}}}
local skill_min = 30
local skill_max = 80
local skill_inc = 5
local skills_table = range(skill_min, skill_max, skill_inc)
local damage_min = 0
local damage_max = 10
local damage_inc = 3
local damages_table = range(damage_min, damage_max, damage_inc)
io.write("skill\\damage", ";")
for i, dmg in pairs(damages_table) do
io.write(string.format("%d (avg);%d (typical)", dmg, dmg))
if i < #damages_table then
io.write(";")
end
end
print("")
for _, skill in pairs(skills_table) do
io.write(skill, ";")
local tmp = {}
for key, damage in ipairs(damages_table) do
local avg, dmg = calculateDamageD100(skill, damage)
tmp[key] = string.format("%.2f;%s", avg, table.concat(dmg, ", "))
end
print(table.concat(tmp, ";"))
end

217
tooling/mathD20.lua Executable file
View File

@@ -0,0 +1,217 @@
--
--{{{ print_r(t)
local function print_r(t)
local print_r_cache = {}
local function sub_print_r(t, indent)
if (print_r_cache[tostring(t)]) then
print(indent .. "*" .. tostring(t))
else
print_r_cache[tostring(t)] = true
if (type(t) == "table") then
local tLen = #t
for i = 1, tLen do
local val = t[i]
if (type(val) == "table") then
print(indent .. "#[" .. i .. "] => " .. tostring(t) .. " {")
sub_print_r(val, indent .. string.rep(" ", string.len(i) + 8))
print(indent .. string.rep(" ", string.len(i) + 6) .. "}")
elseif (type(val) == "string") then
print(indent .. "#[" .. i .. '] => "' .. val .. '"')
else
print(indent .. "#[" .. i .. "] => " .. tostring(val))
end
end
for pos, val in pairs(t) do
if type(pos) ~= "number" or math.floor(pos) ~= pos or (pos < 1 or pos > tLen) then
if (type(val) == "table") then
print(indent .. "[" .. pos .. "] => " .. tostring(t) .. " {")
sub_print_r(val, indent .. string.rep(" ", string.len(pos) + 8))
print(indent .. string.rep(" ", string.len(pos) + 6) .. "}")
elseif (type(val) == "string") then
print(indent .. "[" .. pos .. '] => "' .. val .. '"')
else
print(indent .. "[" .. pos .. "] => " .. tostring(val))
end
end
end
else
print(indent .. tostring(t))
end
end
end
if (type(t) == "table") then
print(tostring(t) .. " {")
sub_print_r(t, " ")
print("}")
else
sub_print_r(t, " ")
end
print()
end
--}}}
--{{{ table_count(t)
function table_count(t)
local count = 0
for _ in pairs(t) do count = count + 1 end
return count
end
--}}}
--{{{ range(low, high, n)
---Create a table with integers in a given range
---@param low integer Lowest number
---@param high integer Highest number
---@param n integer Increment - the array will contain every nth number between [low..high]
---@return table
local function range(low, high, n)
local t = {}
for i = low, high, n do
table.insert(t, i)
end
return t
end
---}}}
-- {{{ sortMapByValueDesc(m)
---Sort the table m by value.
---@param m table
---@return table
local function sortMapByValueDesc(m)
local tmp = {}
-- create a temporary table that is easier to sort
for damage, occurrences in pairs(m) do
local entry = {
damage = damage,
count = occurrences,
}
table.insert(tmp, entry)
end
table.sort(tmp, function(a, b)
return a.count > b.count
end)
return tmp
end
--}}}
-- {{{ arraySum(t)
local function table_sum(t)
local accumulator = 0
for _, v in pairs(t) do
accumulator = accumulator + v
end
return accumulator
end
---}}}
--{{{ mostCommonDamageValues(instances, ratio)
--- Calculate the most likely values
---@param instances table A map of [damage score] => [occurrences]
---@param ratio number Include most common damage until the sum of the probabilities of the damage scores are at least this number.
---@return table
local function mostCommonDamageValues(instances, ratio)
if ratio == nil then
ratio = 0.25
end
local sortedInstances = sortMapByValueDesc(instances)
local totalOccurrences = table_sum(instances)
local result = {}
local occurrenceAccumulator = 0
for _, instance in pairs(sortedInstances) do
occurrenceAccumulator = occurrenceAccumulator + instance.count
table.insert(result, instance.damage)
if occurrenceAccumulator / totalOccurrences > ratio then
return result
end
end
return result
end
--}}}
-- {{{ calculateDamageD20(skill, base_damage)
---@param skill integer
---@param base_damage integer
---@return number average The average damage
---@return table typical The damage numbers that, together, are likely to occur more than 50% of the time.
local function calculateDamageD20(skill, base_damage)
local sum = 0
local n = 0
local tn = math.min(skill, 15)
local outcomes = {}
for rolled = 1, 20, 1 do
local damage = 0
if rolled > tn then
-- glancing blow
damage = base_damage
end
if rolled == tn then
-- crit
damage = 2 * (skill + base_damage)
end
if rolled < tn then
-- hit
damage = rolled + base_damage
end
local key = tostring(damage)
if outcomes[key] == nil then
outcomes[key] = 1
else
outcomes[key] = outcomes[key] + 1
end
sum = sum + damage
n = n + 1
end
return sum / n, mostCommonDamageValues(outcomes, 0.5)
end
-- }}}
local skill_min = 5
local skill_max = 15
local skill_inc = 1
local skills_table = range(skill_min, skill_max, skill_inc)
local damage_min = 0
local damage_max = 10
local damage_inc = 3
local damages_table = range(damage_min, damage_max, damage_inc)
io.write("skill\\damage", ";")
for i, dmg in pairs(damages_table) do
io.write(string.format("%d (avg);%d (typical)", dmg, dmg))
if i < #damages_table then
io.write(";")
end
end
print("")
for _, skill in pairs(skills_table) do
io.write(skill, ";")
local tmp = {}
for key, damage in ipairs(damages_table) do
local avg, dmg = calculateDamageD20(skill, damage)
tmp[key] = string.format("%.2f;%s", avg, table.concat(dmg, ", "))
end
print(table.concat(tmp, ";"))
end

68
tooling/process.lua Executable file
View File

@@ -0,0 +1,68 @@
-- Sort selected areas of a text file
--
-- Each area has a number of sections,
-- and each section has a headline that
-- is used as a key.
--
-- The program simply treats each section as a string, and treats each area as a
-- collection of strings. The strings are sorted such that the sections in
-- the area will appear in order.
--
-- For now, only asciidoc (and markdown) headlines are supported, but
-- description lists are on the way
--
-- TODO:
-- Variables and substitutions with evaluation via dostring
-- Syntax for settings vars could be
-- {{? mufassa = "tjams" }}
-- {{? mufassa tjams }}
--
-- Syntax for inserting vars could be
-- {{:: string.upper(mufassa) }}
-- my little pony is {{: mufassa .. " næsehorn "}}
--
-- Can assist in streamlining data across the three guides.
-- Can ease links.
--
-- Maybe a quick var insert:
-- @@@mufassa (inserts the contents of mufassa variable)
--
local filename = arg[1]
-- Check that a filename was given
if arg[1] == nil then
print(string.format("usage: %s [filename]", arg[0]))
os.exit(0)
end
-- Check that file exists
local f = io.open(filename, "r+")
if not f then
print(string.format("file '%s' does not exist", filename))
os.exit(1)
end
local sorter = require("sorter")
local sorted = sorter(f:lines())
if arg[2] == "--overwrite" then
--
-- Check if there were any changes to the file.
f:seek("set")
if sorted == f:read("a*") then
print("No sorting needed. Closing file")
f:close()
os.exit(0)
end
f:seek("set")
f:write(sorted)
f:flush()
f:close()
os.exit(0)
end
f:close()
print(sorted)

190
tooling/sorter.lua Executable file
View File

@@ -0,0 +1,190 @@
-- This program scans a text for REGIONS to sort.
--
-- Each REGION contains the following:
-- A "START_SORT:" string command that also tells the sorter which types of HEADLINES the REGION contains.
-- (optionally) a number of PREFACE lines.
-- A number of SECTIONS to sort
-- An "END_SORT" command
--
-- Each SECTION contains the following
-- A HEADLINE
-- A number of lines of body text.
--
-- A HEADLINE can (currently) be one of:
-- - An asciidoc headline: a line that always begins with a number of '=' characters, followed by at least one normal text character.
-- These headlines are convenient because it allows us to easily alphabetize headlines in certain chapters
-- (for instance in chapters where spells, items, or abilities each have their own headline).
-- - A hidden headline. A text they always begins "//HEADLINE:", followed by a number of normal text characters.
-- These hidden headlines are useful when we want to alphabetize tables, lists, and other sections that do not contain
-- explicit headlines.
--
--
-- Each REGION has a number of SECTIONS, and each SECTION
-- has a HEADLINE. HEADLINES can be actual asciidoc headlines (strings beginning with a number of "=" characters)
-- or they can be pseudo-headlines that exist as comments inside the asciidoc file.
--
--
-- {{{ Some variables to help us
local output = "" -- Output buffer
local STATE_SCANNING = 0 -- State machine: scanning for START_SORT commands
local MODE_SORTING = 1 -- State machine: sorting, and looking for END_SORT commands.
local state = STATE_SCANNING -- State machine: current mode
local headlinePattern = "" -- If a line matches this pattern, is a section headline (a sort key)
local sections = {} -- The sections we're currently sorting.
local preface = "" -- contains the text in a chunk that comes before the first section headline
--}}}
--{{{ _start_sort(line)
---Check if a line starts with START_SORT and then a key to look for.
---@param line string The line we're checking
---
---@return boolean success Was the line correctly formatted?
---@return boolean start_sort Did this line contain a correct START_SORT command?
---@return string pattern The regex pattern to scan for to find the headline/key that each section starts with.
---
local function _is_start_sort_command(line)
local match = string.match(line, "^//%s*START_SORT%s*(%S*)%s*$")
if not match then
return true, false, ""
end
--
-- ASCIIDOC HEADLINE
--
-- These types of headlines start with a number of "=" characters.
-- They sort REGIONS of text where each SECTION begins with an asciidoc headline
if string.sub(match, 1, 1) == "=" then
-- We are sorting by section names
-- The type of section we're sorting (i.e. its depth) is denoted by the given number '=' characters
-- Every time we encounter a section with the corresponding number of equal signs, we consider that a sorting key.
return true, true, "^" .. match .. "%s+"
end
--
-- HIDDEN HEADLINE
--
-- These headlines begin with "//HEADLINE:"
-- They are comments inside the asciidoc source code so they do not show up in the rendered text.
-- Thus they sort REGIONS of text where each SECTION begins with a hidden headline
if match == "//HEADLINE:" then
-- We are sorting by custom keys.
-- This means that every time we encounter the string "HEADLINE:" we consider the next line a sorting key.
return true, true, "^%s*//HEADLINE:"
end
return false, false, match
end
--}}}
-- {{{ _state_machine_scan(line)
local function _state_machine_scan(line)
local success, is_start_sort_command, _headline_pattern = _is_start_sort_command(line)
if not success then
local errMsg = string.format("Failed to parse sort key: '%s'", _headline_pattern)
print(errMsg)
error(errMsg)
end
if is_start_sort_command then
state = MODE_SORTING
headlinePattern = _headline_pattern
sections = {}
preface = ""
end
output = output .. line
end
--}}}
--{{{ _state_machine_sort(line)
local function _state_machine_sort(line)
--
-- NEW SECTION STARTED
--
if string.match(line, headlinePattern) then -- We found a new section headline/sortkey
-- Start a new section
sections[#sections + 1] = line
return
end
--
-- SORTED REGION ENDED
--
if string.match(line, "^//%s*END_SORT") then
-- We have encountered a command to stop sorting. So we should stop sorting.
-- Each section within the current sorting area is a single string
-- Sort those strings to sort the entire area,
-- and compile/combine/join the strings into the output.
table.sort(sections)
local sorted = table.concat(sections, "")
-- Compile the resulting sorted text.
output = output .. preface .. sorted .. line
-- Reset the state machine to start looking for new areas to sort.
state = STATE_SCANNING
sections = {}
preface = ""
return
end
-- SECTION PREFACE
--
-- This happens if we have preface text before the first heading/sortkey
-- Preface text is paragraph text that comes after START_SORT, but before the
-- first sorting key/section is encountered.
if nil == sections[#sections] then
preface = preface .. line
return
end
-- This line is a normal line within the section.
-- Add it to the current section.
sections[#sections] = sections[#sections] .. line
end
-- }}}
--{{{ _state_machine_process_single_line(line)
local function _state_machine_process_single_line(line)
line = line .. "\n"
-- SCANNING
if state == STATE_SCANNING then
_state_machine_scan(line)
return
end
--
-- SORTING
--
if state == MODE_SORTING then
_state_machine_sort(line)
return
end
end
--}}}
--{{{ sorter(lines)
---Scan a file and sort the headings of the chunks marked for sorting
---@param lines Iterator
---@return string output sorted string
local function sorter(lines)
for line in lines do
_state_machine_process_single_line(line)
end
if not (state == STATE_SCANNING and #sections == 0) then
error("you must be missing an END_SORT or similar")
end
return output
end
--}}}
return sorter