From e1883553866a4c4dc0876e8860a55ebd35507e54 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 23:39:28 +0200 Subject: [PATCH] feat: add csvField, Export.leaderboardCsv and Factions.areRivals --- src/export.lua | 48 +++++++++++++++++ src/factions.lua | 130 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/export.lua create mode 100644 src/factions.lua diff --git a/src/export.lua b/src/export.lua new file mode 100644 index 0000000..d1d0c83 --- /dev/null +++ b/src/export.lua @@ -0,0 +1,48 @@ +-- Renders meta.lua's own leaderboard as CSV text, one row per entry, for a +-- player who wants their run history in a spreadsheet rather than +-- warren.lua's own --report listing. A leaderboard entry's exact field set +-- (see leaderboard.lua's Leaderboard.record) becomes the exact column set +-- here, so the header row and every data row are always built from the +-- same list and can never drift apart from each other. + +local Compendium = require("compendium") + +local Export = {} + +Export.COLUMNS = { "rank", "score", "className", "depth", "turn", "won", "seed" } +Export.COMPENDIUM_COLUMNS = { "key", "name", "seen", "kills" } + +local function csvField(value) + if value == nil then return "" end + if value == true then return "true" end + if value == false then return "false" end + return tostring(value) +end + +-- One CSV document: a header row naming Export.COLUMNS, then one row per +-- `board` entry in its own existing order (best score first, see +-- Leaderboard.record) with a 1-based rank column prepended. +function Export.leaderboardCsv(board) + local lines = { table.concat(Export.COLUMNS, ",") } + for rank, entry in ipairs(board) do + local row = { rank, entry.score, entry.className, entry.depth, entry.turn, entry.won, entry.seed } + for i, value in ipairs(row) do row[i] = csvField(value) end + lines[#lines + 1] = table.concat(row, ",") + end + return table.concat(lines, "\n") .. "\n" +end + +-- One CSV document over every species Compendium.entries knows about (see +-- compendium.lua), sighted or not - so an export always lists the whole +-- bestiary's shape, not just whatever a given save has stumbled across. +function Export.compendiumCsv(compendium) + local lines = { table.concat(Export.COMPENDIUM_COLUMNS, ",") } + for _, entry in ipairs(Compendium.entries(compendium)) do + local row = { entry.key, entry.name, entry.seen, entry.kills } + for i, value in ipairs(row) do row[i] = csvField(value) end + lines[#lines + 1] = table.concat(row, ",") + end + return table.concat(lines, "\n") .. "\n" +end + +return Export diff --git a/src/factions.lua b/src/factions.lua new file mode 100644 index 0000000..77cf6a7 --- /dev/null +++ b/src/factions.lua @@ -0,0 +1,130 @@ +-- Faction standing. Every hostile species in the bestiary carries a `faction` +-- tag; the player carries a `reputation` table scoring how each faction feels +-- about them, starting neutral (an absent entry reads as 0). Killing a +-- faction's members sours that score; paying tribute or turning in a bounty +-- sweetens it. Reputation then feeds back into the world two ways: a +-- tribal faction tagged "neutral" (see neutralFactions) leaves its members +-- passively unprovoked until the player either strikes first or has enraged +-- the whole tribe by other means, and the merchant guild's prices rise or +-- fall with how the player stands with it. + +local Factions = {} + +Factions.MERCHANT = "merchant" +Factions.GOBLINOID = "goblinoid" +Factions.VERMIN = "vermin" +Factions.ORCISH = "orcish" +Factions.UNDEAD = "undead" +Factions.BEAST = "beast" +Factions.CULT = "cult" +Factions.DRAKE = "drake" +Factions.BANDIT = "bandit" + +-- Factions whose rank-and-file stay passive - see Factions.isWary - unless +-- provoked in melee/at range or already hated into open hostility. +Factions.neutralFactions = { [Factions.GOBLINOID] = true } + +-- Faction pairs that loathe each other regardless of the player: two +-- monsters from an unrelated pair here fight on sight if they ever share a +-- floor (see infighting.lua), no discord spell required. Listed once per +-- pair; Factions.areRivals checks both directions. Deliberately excludes any +-- faction pairing with a summon relationship (CULT raises UNDEAD minions) +-- so a caster is never made to loathe its own summoned brood. +Factions.RIVALRIES = { + { Factions.GOBLINOID, Factions.ORCISH }, + { Factions.BANDIT, Factions.MERCHANT }, + { Factions.VERMIN, Factions.BEAST }, +} + +function Factions.areRivals(factionA, factionB) + if not factionA or not factionB or factionA == factionB then return false end + for _, pair in ipairs(Factions.RIVALRIES) do + if (pair[1] == factionA and pair[2] == factionB) or (pair[1] == factionB and pair[2] == factionA) then + return true + end + end + return false +end + +local HOSTILE_MAX = -30 +local UNFRIENDLY_MAX = -10 +local FRIENDLY_MIN = 25 +local HONORED_MIN = 60 + +local PRICE_MULTIPLIER = { + hostile = 1.5, unfriendly = 1.15, neutral = 1.0, friendly = 0.85, honored = 0.65, +} + +local KILL_PENALTY = 5 +local BOSS_KILL_PENALTY = 15 +local BASE_APPEASE_COST = 20 +local APPEASE_GAIN = 15 + +function Factions.reputationFor(player, faction) + return (player.reputation and player.reputation[faction]) or 0 +end + +-- The named tier a raw reputation score falls into, from most soured to most +-- warm. Ties resolve toward the harsher tier so a score sitting exactly on a +-- boundary reads as the tier it just reached rather than the one it left. +function Factions.tier(rep) + if rep <= HOSTILE_MAX then return "hostile" end + if rep <= UNFRIENDLY_MAX then return "unfriendly" end + if rep >= HONORED_MIN then return "honored" end + if rep >= FRIENDLY_MIN then return "friendly" end + return "neutral" +end + +-- Nudges `faction`'s standing by `delta`, clamped to +-100, creating the +-- player's reputation table on first use. +function Factions.adjust(player, faction, delta) + player.reputation = player.reputation or {} + local updated = Factions.reputationFor(player, faction) + delta + if updated > 100 then updated = 100 end + if updated < -100 then updated = -100 end + player.reputation[faction] = updated + return updated +end + +-- Called once per monster a player action fells: unfactioned kills (traps, +-- hazards with no owning species) are simply ignored. +function Factions.registerKill(player, target) + if not target.faction then return end + Factions.adjust(player, target.faction, target.isBoss and -BOSS_KILL_PENALTY or -KILL_PENALTY) +end + +-- Whether `faction`'s members currently hold back from attacking on sight. +-- Non-tribal factions are never wary; a tribal one stops being wary the +-- instant the whole faction's standing curdles into open hostility. +function Factions.isWary(player, monster) + local faction = monster.faction + if not (faction and Factions.neutralFactions[faction]) then return false end + if monster.provoked then return false end + return Factions.tier(Factions.reputationFor(player, faction)) ~= "hostile" +end + +function Factions.priceMultiplier(player, faction) + return PRICE_MULTIPLIER[Factions.tier(Factions.reputationFor(player, faction))] +end + +-- Gold cost of the next tribute to `faction`: a flat base that climbs the +-- deeper the grudge already runs, so digging out of "hostile" costs more +-- than smoothing over a merely "unfriendly" one. +function Factions.appeaseCost(player, faction) + return BASE_APPEASE_COST + math.max(0, -Factions.reputationFor(player, faction)) * 2 +end + +-- Spends gold to buy `faction`'s standing up by a fixed amount. Returns +-- (true, costPaid) on success; (false, cost) when the player cannot afford +-- it; (false, 0) when the faction is already maxed out and has nothing left +-- to sell. +function Factions.appease(player, faction) + if Factions.reputationFor(player, faction) >= 100 then return false, 0 end + local cost = Factions.appeaseCost(player, faction) + if player.gold < cost then return false, cost end + player.gold = player.gold - cost + Factions.adjust(player, faction, APPEASE_GAIN) + return true, cost +end + +return Factions