From 907f57db41508110c2707f38cd249022b902e73c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 21:41:04 +0200 Subject: [PATCH] feat: add Branches.seedFor and Branches.build --- src/branches.lua | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/branches.lua diff --git a/src/branches.lua b/src/branches.lua new file mode 100644 index 0000000..fb6a998 --- /dev/null +++ b/src/branches.lua @@ -0,0 +1,83 @@ +-- Rift crossings: a rare, shimmering tear in a floor from depth six on (see +-- builder.lua's placement, gated behind a carried rift shard) that a player +-- can step through into a short, self-contained pocket floor - generated +-- once from the same world seed as everything else (see Branches.build) at +-- a deliberately harsher effective depth than the floor the rift itself sits +-- on, so what waits behind it is always a genuine step up in danger and in +-- reward. Unlike horde.lua's arena (a one-way trial that replaces the run's +-- level outright), a rift crossing is round-trip: Game:enterRift remembers +-- exactly where the player stood before stepping through, and Game:exitRift +-- restores it to the byte, so nothing about the main dungeon left behind is +-- ever perturbed by the detour. + +local Rng = require("rng") +local Builder = require("builder") +local Level = require("level") + +local Branches = {} + +-- How much harder a rift's own pocket floor is generated than the main-track +-- depth its entrance sits on - fixed rather than randomised, so a rift found +-- at a given depth always promises the same order of danger and reward. +Branches.DEPTH_BONUS = 4 + +-- Distinguishes a rift's own private rng stream from both the main dungeon's +-- per-depth stream (Dungeon:levelSeed's own salt) and every other salted +-- stream in the codebase, so discovering a rift can never perturb - or be +-- perturbed by - any of them. +local SALT = 0xC2B2AE3D27D4EB4F + +-- The deterministic seed a rift found on `depth` always builds its pocket +-- floor from, a pure function of the world seed and the origin depth alone - +-- never the main dungeon's own per-depth rng stream - so the exact same rift +-- always opens onto the exact same pocket floor no matter how many times (or +-- in what order) the run's other floors happen to be visited first. +function Branches.seedFor(worldSeed, depth) + return math.tointeger(worldSeed ~ (depth * SALT)) or 0 +end + +-- Builds the pocket floor a rift found at `depth` opens onto: an ordinary +-- Builder.build floor, just generated Branches.DEPTH_BONUS deeper than the +-- rift's own origin so its monsters and loot are drawn from a rung the +-- player has not reached yet on the main track. `options` is the run's own +-- dungeon options (mutators, class, graves), copied rather than mutated, with +-- maxDepth pinned one short of the pocket's own depth - short, not equal or +-- past it, so neither of Builder.build's two maxDepth-gated passes ever fire +-- in a pocket floor: no chasm (there is deliberately no lower pocket floor to +-- fall into) and, far more importantly, no Amulet of Warren - a rift is a +-- richer fight, never a shortcut past the real dungeon's own win condition. +function Branches.build(worldSeed, depth, options) + local pocketDepth = depth + Branches.DEPTH_BONUS + local rng = Rng.new(Branches.seedFor(worldSeed, depth)) + local pocketOptions = {} + for k, v in pairs(options or {}) do pocketOptions[k] = v end + pocketOptions.maxDepth = pocketDepth - 1 + return Builder.build(pocketDepth, rng, pocketOptions) +end + +-- Every rift's own pocket floor, serialized exactly like Dungeon:toData +-- serializes the main track: a depth-sorted array of {depth, level} pairs, so +-- a save taken mid-crossing (or with a rift merely discovered and never +-- entered) restores every pocket floor already generated byte-for-byte, dead +-- monsters and all - and stores nothing extra for a rift a run never stepped +-- through at all. +function Branches.levelsToData(riftLevels) + local depths = {} + for depth in pairs(riftLevels or {}) do depths[#depths + 1] = depth end + table.sort(depths) + local stored = {} + for _, depth in ipairs(depths) do + stored[#stored + 1] = { depth = depth, level = riftLevels[depth]:toData() } + end + return stored +end + +function Branches.levelsFromData(stored) + local riftLevels = {} + for _, entry in ipairs(stored or {}) do + riftLevels[entry.depth] = Level.fromData(entry.level) + end + return riftLevels +end + +return Branches