From 6d0666042ceb567d26af3f0ce6c9ab21c6e9b942 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 14:15:58 +0200 Subject: [PATCH] feat: expand builder --- src/builder.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/builder.lua b/src/builder.lua index 81a61c2..503d5d2 100644 --- a/src/builder.lua +++ b/src/builder.lua @@ -509,6 +509,179 @@ function Builder.build(depth, rng, options) local vaultId = "vault" .. depth local openSide = reachSet(map, upX, upY, false, false) local leverCount = rng:range(2, 3) + for _ = 1, leverCount do + local x, y = pickFree(reachable, rng, function(cx, cy) + return freeFloor(cx, cy) and openSide[map:index(cx, cy)] == true + end) + if x then + map:set(x, y, Tiles.LEVER) + vaultLevers[#vaultLevers + 1] = { x = x, y = y, id = vaultId, pulled = false } + mark(x, y) + end + end + local farSide = reachSet(map, upX, upY, false, true) + local tx, ty + if #vaultLevers > 0 then + tx, ty = pickFree(reachable, rng, function(cx, cy) + return freeFloor(cx, cy) and farSide[map:index(cx, cy)] == true + and openSide[map:index(cx, cy)] ~= true + end) + end + if tx then + vaultDoors[#vaultDoors + 1] = { x = cell.x, y = cell.y, id = vaultId, opened = false } + floorItems[#floorItems + 1] = { x = tx, y = ty, item = Vaults.treasure(rng, depth) } + mark(tx, ty) + else + -- No lever site or no cell the door actually gates: back out to a + -- plain closed door so the puzzle is never unsolvable or hollow, and + -- free the lever tiles this attempt already claimed. + map:set(cell.x, cell.y, Tiles.DOOR_CLOSED) + for _, lever in ipairs(vaultLevers) do + map:set(lever.x, lever.y, Tiles.FLOOR) + end + vaultLevers = {} + end + end + end + + -- Rune stones dot the dungeon from depth three on, rarer than a herb but + -- commoner than a trap kit: raw components for runewords.lua's + -- socket-and-match combo system, found rather than crafted so a build is + -- bounded by what the floor actually offers. Drawn dead last of all, + -- after the vault above, so it can never perturb any earlier system's + -- rng sequence. + if depth >= 3 and rng:chance(1, 2) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local key = Runewords.RUNES[rng:range(1, #Runewords.RUNES)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Runewords.rune(key) } + mark(x, y) + end + end + + -- A floor-wide weather condition, drawn dead last of all so it can never + -- perturb any earlier system's rng sequence: an infested reading retroactively + -- bumps every monster already placed on this floor (exactly like Elites. + -- maybePromote's own retroactive stat edit above), since only a stat and + -- never a position is at stake. + local weather + if depth >= 2 and rng:chance(1, 3) then + weather = Atmosphere.order[rng:range(1, #Atmosphere.order)] + local attackBonus = Atmosphere.monsterAttackBonus(weather) + if attackBonus > 0 then + for _, entity in ipairs(entities) do + entity.attack = entity.attack + attackBonus + entity.baseAttack = entity.baseAttack + attackBonus + end + end + end + + -- Ore veins and gem seams dot the dungeon from the second floor on, dead + -- last of all so they can never perturb any earlier system's rng sequence: + -- unlike the herbs above, a single visit does not spend a node outright - + -- it holds a few charges and slowly reopens (see nodes.lua) - so a floor + -- already cleared of monsters still rewards a return trip. + local nodes = {} + if depth >= 2 then + local veinCount = rng:range(1, 2) + for _ = 1, veinCount do + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local kind = rng:chance(1, 2) and Nodes.ORE or Nodes.GEM + local element = kind == Nodes.GEM and Crafting.elements[rng:range(1, #Crafting.elements)] or nil + local node = Nodes.new(kind, element) + node.x, node.y = x, y + nodes[#nodes + 1] = node + mark(x, y) + end + end + end + + -- A single item-set piece dots the dungeon from depth three on, rarer + -- than a rune but commoner than an artifact: unlike an artifact's fixed + -- always-on mechanic, a set piece is inert alone and only pays off once + -- its match turns up too (see sets.lua). Drawn dead last of all, after + -- the nodes above, so it can never perturb any earlier system's rng + -- sequence. + if depth >= 3 and rng:chance(1, 5) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local entry = Sets.order[rng:range(1, #Sets.order)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Sets.create(entry.set, entry.slot) } + mark(x, y) + end + end + + -- A single mimic disguises itself as loot from depth two on, rarer than + -- a trap kit: unlike every item placed above, it is inert to look at and + -- only bites once the player actually reaches for it (see mimics.lua). + -- Drawn dead last of all, after the set piece above, so it can never + -- perturb any earlier system's rng sequence. + if depth >= 2 and rng:chance(1, 6) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Mimics.floorItem(rng) } + mark(x, y) + end + end + + -- A scroll of remove curse turns up rarer than a mimic, the one dedicated + -- remedy for the curse pass below that a run can actually find rather than + -- having to gamble on a shrine of fortune. Drawn dead last of all, after + -- the mimic above, so it can never perturb any earlier system's rng + -- sequence. + if depth >= 2 and rng:chance(1, 8) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.scroll("removecurse") } + mark(x, y) + end + end + + -- Curses.lua's dark mirror of affixes.lua rolls dead last of all, after + -- every item above has already been placed: it only edits the stats of + -- weapons, armor, rings and bows already sitting in floorItems, never a + -- position, so it cannot desync reachability - but rng-ordering discipline + -- still applies, since it draws from the same stream every earlier + -- placement in this floor already relied on. Skips anything that already + -- landed a beneficial affix or names its own set/artifact, so a single + -- drop is never simultaneously a blessing and a curse. + for _, ground in ipairs(floorItems) do + local slot = slotForCursableItem[ground.item.kind] + if slot and not ground.price and not ground.item.affix and not ground.item.set and not ground.item.artifact then + local curse = Curses.roll(rng, slot) + if curse then Curses.afflict(ground.item, curse) end + end + end + + -- A miner's pick turns up rarer than the remove-curse scroll above, the + -- one way a run can find its own way into a rock burrower's tunnel or dig + -- a shortcut through a dead-end wall (see digging.lua). Drawn dead last + -- of all, after the curse pass above, so it can never perturb any + -- earlier system's rng sequence. + if depth >= 2 and rng:chance(1, 10) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.pick() } + mark(x, y) + end + end + + -- A lockpick turns up commoner than the miner's pick above, drawn dead + -- last of all so it can never perturb any earlier system's rng sequence: + -- the one way a run can gamble past a locked chest's lock without ever + -- finding its key (see chests.lua's Chests.pick). + if rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.lockpick() } + mark(x, y) + end + end + + -- A single lootable chest dots the dungeon from the first floor on, rarer + -- than a lockpick, drawn dead last of all so it can never perturb any + -- earlier system's rng sequence: a locked one may also be trapped (see return Level.new({ depth = depth, generator = generator,