From ef58d96008e2f5759dead2f7af45a30a1c89957c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 10:39:17 +0200 Subject: [PATCH] feat: extend builder --- src/builder.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/builder.lua b/src/builder.lua index b281514..81a61c2 100644 --- a/src/builder.lua +++ b/src/builder.lua @@ -336,6 +336,179 @@ function Builder.build(depth, rng, options) end end + -- A companion's bonding charm is the rarest of the depth-gated placements + -- and, unlike the others, drawn last so it can never perturb the rng + -- sequence any earlier system (traps, shrines, the shop, the amulet) relies + -- on for its own placement. + if depth >= 3 and rng:chance(1, 5) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local key = Companions.order[rng:range(1, #Companions.order)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.charm(key) } + mark(x, y) + end + end + + -- Rarer still, and drawn last of all for the same rng-ordering reason as + -- the charm above: a named artifact with a fixed, always-on mechanic. + if depth >= 5 and rng:chance(1, 6) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local key = Artifacts.order[rng:range(1, #Artifacts.order)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Artifacts.create(key) } + mark(x, y) + end + end + + -- A trap kit lets the player plant one of these same effects + -- themselves (see Game:useItem's KIT branch and Game:checkTrapFor, which + -- springs a planted trap on a monster exactly as a builder-placed one + -- springs on the player). Drawn last of all so it can never perturb any + -- earlier system's rng sequence, the charm and artifact included. + if depth >= 2 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + floorItems[#floorItems + 1] = { x = x, y = y, item = Items.kit(trapEffects[rng:range(1, #trapEffects)]) } + mark(x, y) + end + end + + -- A named boss rarer still guards the deepest floors, drawn dead last of + -- all for the same rng-ordering reason as the kit above: it can never + -- perturb any earlier system's placement, artifacts included. + if depth >= 6 and rng:chance(1, 5) then + local eligible = Bosses.forDepth(depth) + if #eligible > 0 then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local spec = eligible[rng:range(1, #eligible)] + entities[#entities + 1] = Bosses.create(spec, #entities + 1, x, y) + mark(x, y) + end + end + end + + -- A bounty board rarer than a trap kit but commoner than an artifact, + -- offering a single fixed pact the moment the player steps onto it (see + -- Game:tryAcceptQuest). Drawn dead last of all for the same rng-ordering + -- reason as the boss above: it can never perturb any earlier system's + -- placement, the boss included. + local questBoards = {} + if depth >= 2 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + questBoards[#questBoards + 1] = { + x = x, y = y, claimed = false, + quest = Quests.generate(rng, depth, Builder.maxDepth(options)), + } + end + end + + -- Herbs dot every floor from the first, foraging material rather than a + -- depth-gated reward. Drawn dead last of all, after the quest board, so + -- they can never perturb any earlier system's rng sequence. + local herbCount = rng:range(2, 4) + for _ = 1, herbCount do + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local key = Alchemy.order[rng:range(1, #Alchemy.order)] + floorItems[#floorItems + 1] = { x = x, y = y, item = Alchemy.herb(key) } + mark(x, y) + end + end + + -- A cauldron to brew those herbs at, rarer than the herbs themselves and + -- gated to depth 2+ so the entrance floor is foraging-only. Drawn last of + -- all for the same rng-ordering reason as the herbs above. + local cauldrons = {} + if depth >= 2 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + cauldrons[#cauldrons + 1] = { x = x, y = y } + mark(x, y) + end + end + + -- Volatile terrain rarer than a cauldron, gated to depth 4+ so the entry + -- floors are always safe: a lava pool of two to four adjoining cells that + -- burns anyone standing on it (see Game:checkTerrainFor), a single chasm + -- pit that drops the player to the floor below and swallows a monster + -- whole (capped to floors that still have one to fall onto), and a patch + -- of ice that sends a traveller sliding. Drawn dead last of all for the + -- same rng-ordering reason as the cauldron above. + if depth >= 4 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + map:set(x, y, Tiles.LAVA) + mark(x, y) + for _ = 1, rng:range(1, 3) do + local dir = NEIGHBOURS[rng:range(1, #NEIGHBOURS)] + local nx, ny = x + dir[1], y + dir[2] + if freeFloor(nx, ny) then + map:set(nx, ny, Tiles.LAVA) + mark(nx, ny) + end + end + end + end + + if depth >= 4 and depth < Builder.maxDepth(options) and rng:chance(1, 5) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + map:set(x, y, Tiles.CHASM) + mark(x, y) + end + end + + if depth >= 4 and rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + map:set(x, y, Tiles.ICE) + mark(x, y) + for _ = 1, rng:range(1, 4) do + local dir = NEIGHBOURS[rng:range(1, #NEIGHBOURS)] + local nx, ny = x + dir[1], y + dir[2] + if freeFloor(nx, ny) then + map:set(nx, ny, Tiles.ICE) + mark(nx, ny) + end + end + end + end + + -- Elite promotion rolls last of all, dead after the ice above: it only + -- edits stats already-placed entities carry, 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. + for _, entity in ipairs(entities) do + Elites.maybePromote(rng, entity, depth) + end + + -- A vault seals a treasure behind a lever puzzle instead of a key: a door + -- set on an unused doorway pinch point that only swings open once every + -- lever scattered on the side already reachable without it has been + -- pulled (see vaults.lua). Rarer than a boss and drawn dead last of all so + -- it can never perturb any earlier system's rng sequence, the boss and + -- quest board included. Falls back to an ordinary closed door whenever the + -- floor's geometry cannot host a genuine puzzle (no free doorway, no room + -- for its levers, or no cell the door actually gates), so a vault never + -- seals content behind an unsolvable or pointless door. + local vaultLevers, vaultDoors = {}, {} + if depth >= 5 and rng:chance(1, 6) then + local doorCandidates = doorwayCandidates(map) + for i = #doorCandidates, 1, -1 do + if taken(doorCandidates[i].x, doorCandidates[i].y) then + table.remove(doorCandidates, i) + end + end + if #doorCandidates > 0 then + local cell = doorCandidates[rng:range(1, #doorCandidates)] + map:set(cell.x, cell.y, Tiles.VAULT_DOOR) + mark(cell.x, cell.y) + local vaultId = "vault" .. depth + local openSide = reachSet(map, upX, upY, false, false) + local leverCount = rng:range(2, 3) return Level.new({ depth = depth, generator = generator,