diff --git a/src/ascension.lua b/src/ascension.lua new file mode 100644 index 0000000..9c4d2b4 --- /dev/null +++ b/src/ascension.lua @@ -0,0 +1,136 @@ +-- Optional run-wide difficulty mutators a player opts into before a run +-- starts, in exchange for extra renown once it ends: risk taken on purpose, +-- unlike events.lua's mutators which fire and fade on their own mid-run +-- regardless of anyone's choice. A mutator unlocks once meta.lua's own +-- cross-run tallies (best depth, wins) cross its fixed requirement - +-- computed live off those tallies rather than persisted, since both only +-- ever climb, so nothing here needs its own entry in meta.lua's save shape. +-- +-- Active mutators for the run in progress live on game.ascension, a plain +-- array of keys in catalog order (see Ascension.resolve), exactly the shape +-- warren.lua's --mutators flag builds. Plain data, so it serializes with no +-- special save-format handling, and every combat/essence/sight hook that +-- reads it (Game:monsterDamageMultiplier, Game:essenceMultiplier, +-- Game:effectiveRadius) stays a pure function of that array alongside +-- whatever level-scoped event Events.lua itself is contributing. + +local Ascension = {} + +Ascension.MUTATORS = { + { key = "bloodbound", name = "Bloodbound", desc = "monsters hit 50% harder", + renownBonus = 15, requiresDepth = 3, requiresWins = 0, damageMultiplier = 1.5 }, + { key = "famine", name = "Famine", desc = "essence income halved", + renownBonus = 10, requiresDepth = 2, requiresWins = 0, essenceMultiplier = 0.5 }, + { key = "nightblind", name = "Nightblind", desc = "sight radius reduced by 2", + renownBonus = 10, requiresDepth = 2, requiresWins = 0, sightPenalty = 2 }, + { key = "frail", name = "Frail", desc = "defense halved", + renownBonus = 12, requiresDepth = 4, requiresWins = 0, + applyToPlayer = function(player) + player.baseDefense = player.baseDefense // 2 + player.defense = player.defense // 2 + end }, + { key = "glasscannon", name = "Glass Cannon", desc = "max hp halved, attack doubled", + renownBonus = 20, requiresDepth = 0, requiresWins = 1, + applyToPlayer = function(player) + player.maxHp = math.max(1, player.maxHp // 2) + player.hp = math.min(player.hp, player.maxHp) + player.baseAttack = player.baseAttack * 2 + player.attack = player.attack * 2 + end }, +} + +Ascension.byKey = {} +Ascension.order = {} +for _, def in ipairs(Ascension.MUTATORS) do + Ascension.byKey[def.key] = def + Ascension.order[#Ascension.order + 1] = def.key +end + +-- A meta-less caller (no --meta flag at all) can still activate whatever +-- costs nothing to unlock; every other mutator needs a persistent meta file +-- to have already banked the depth or win it requires. +function Ascension.isUnlocked(meta, key) + local def = Ascension.byKey[key] + if not def then return false end + if not meta then return def.requiresDepth == 0 and def.requiresWins == 0 end + return meta.bestDepth >= def.requiresDepth and meta.wins >= def.requiresWins +end + +-- Validates a requested list of mutator keys against both the catalog and +-- `meta`'s own unlock state, returned de-duplicated in catalog order rather +-- than whatever order the caller supplied - so two runs activating the same +-- set always see it identically regardless of how --mutators listed them. +-- An unknown or still-locked key is silently dropped rather than failing +-- the whole run over one bad flag. +function Ascension.resolve(meta, requested) + if not requested or #requested == 0 then return {} end + local wanted = {} + for _, key in ipairs(requested) do wanted[key] = true end + local active = {} + for _, key in ipairs(Ascension.order) do + if wanted[key] and Ascension.isUnlocked(meta, key) then + active[#active + 1] = key + end + end + return active +end + +function Ascension.isActive(active, key) + for _, k in ipairs(active or {}) do + if k == key then return true end + end + return false +end + +function Ascension.damageMultiplier(active) + local mult = 1 + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def and def.damageMultiplier then mult = mult * def.damageMultiplier end + end + return mult +end + +function Ascension.essenceMultiplier(active) + local mult = 1 + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def and def.essenceMultiplier then mult = mult * def.essenceMultiplier end + end + return mult +end + +function Ascension.sightPenalty(active) + local penalty = 0 + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def and def.sightPenalty then penalty = penalty + def.sightPenalty end + end + return penalty +end + +-- Renown bonus percentage this active set adds on top of Meta.renownFor's +-- own base score, summed rather than compounded - three 15% mutators add +-- 45%, not a runaway 1.15^3 - so stacking more of them is always a +-- straightforward trade of more risk for more reward. +function Ascension.renownBonusPercent(active) + local percent = 0 + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def then percent = percent + def.renownBonus end + end + return percent +end + +-- Applies every active mutator's one-time player-stat delta, in catalog +-- order. Meant to run once in Game.new, after Meta.applyAll's perks and any +-- class delta, since a mutator is the final, voluntary layer of difficulty +-- stacked on top of whatever build the player already chose. +function Ascension.applyToPlayer(active, player) + for _, key in ipairs(active or {}) do + local def = Ascension.byKey[key] + if def and def.applyToPlayer then def.applyToPlayer(player) end + end +end + +return Ascension diff --git a/src/atmosphere.lua b/src/atmosphere.lua new file mode 100644 index 0000000..462c238 --- /dev/null +++ b/src/atmosphere.lua @@ -0,0 +1,49 @@ +-- Floor-wide weather: a single condition drawn once at generation (like +-- `dark`) that colors the whole level rather than one cell of it. Unlike +-- terrain.lua's per-tile hazards (lava, chasm, ice), a floor's weather has no +-- position of its own - every numeric hook below is a flat, floor-lifetime +-- modifier the caller applies once per turn or once at generation, never +-- something a player can step in or out of. + +local Atmosphere = {} + +Atmosphere.FOGGY = "foggy" +Atmosphere.CONSECRATED = "consecrated" +Atmosphere.INFESTED = "infested" + +-- Fixed order so a given rng draw always names the same condition. +Atmosphere.order = { Atmosphere.FOGGY, Atmosphere.CONSECRATED, Atmosphere.INFESTED } + +-- A thick, clinging fog: everyone's sight radius shrinks, monsters and traps +-- included, before the darkness/torch radius in Game:effectiveRadius is +-- even considered. +function Atmosphere.sightPenalty(weather) + if weather == Atmosphere.FOGGY then return 2 end + return 0 +end + +-- Consecrated ground mends the living each turn they linger on it, exactly +-- like an artifact's fixed regen (see artifacts.ringRegen) but floor-wide +-- and free of any equipment. +function Atmosphere.regenBonus(weather) + if weather == Atmosphere.CONSECRATED then return 1 end + return 0 +end + +-- An infested floor's monsters hit harder from the moment they are placed - +-- a flat, permanent bump baked into their stats at generation time (see +-- builder.lua), the same way an elite affix or a boss's own catalog entry +-- already sets a monster's power once and for all. +function Atmosphere.monsterAttackBonus(weather) + if weather == Atmosphere.INFESTED then return 2 end + return 0 +end + +function Atmosphere.describe(weather) + if weather == Atmosphere.FOGGY then return "a thick fog blankets this floor" end + if weather == Atmosphere.CONSECRATED then return "this floor is consecrated ground" end + if weather == Atmosphere.INFESTED then return "this floor teems with infestation" end + return nil +end + +return Atmosphere