feat: extend builder

This commit is contained in:
dualfroz committed 2026-09-02 10:39:17 +02:00
1 parent 86b7017214
commit ef58d96008
1 file changed
+173
+173
View File
@@ -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,