<roblox version="4">
  <Item class="Lighting" referent="0">
    <Properties>
      <string name="Name">Lighting</string>
      <Color3 name="Ambient">
        <R>0.102</R>
        <G>0.11</G>
        <B>0.141</B>
      </Color3>
      <float name="Brightness">1.35</float>
      <float name="ClockTime">17.3</float>
      <Color3 name="ColorShift_Bottom">
        <R>0</R>
        <G>0</G>
        <B>0</B>
      </Color3>
      <Color3 name="ColorShift_Top">
        <R>0.063</R>
        <G>0.071</G>
        <B>0.09</B>
      </Color3>
      <float name="EnvironmentDiffuseScale">0.8</float>
      <float name="EnvironmentSpecularScale">0.5</float>
      <float name="ExposureCompensation">0.05</float>
      <float name="GeographicLatitude">20</float>
      <bool name="GlobalShadows">true</bool>
      <Color3 name="OutdoorAmbient">
        <R>0.22</R>
        <G>0.247</G>
        <B>0.318</B>
      </Color3>
      <token name="Technology">4</token>
    </Properties>
    <Item class="Atmosphere" referent="1">
      <Properties>
        <string name="Name">Atmosphere</string>
        <Color3 name="Color">
          <R>0.71</R>
          <G>0.741</G>
          <B>0.804</B>
        </Color3>
        <Color3 name="Decay">
          <R>0.416</R>
          <G>0.447</G>
          <B>0.545</B>
        </Color3>
        <float name="Density">0.32</float>
        <float name="Glare">0.15</float>
        <float name="Haze">1.4</float>
        <float name="Offset">0.15</float>
      </Properties>
    </Item>
    <Item class="DepthOfFieldEffect" referent="2">
      <Properties>
        <string name="Name">Depth</string>
        <float name="FarIntensity">0.1</float>
        <float name="FocusDistance">22</float>
        <float name="InFocusRadius">90</float>
        <float name="NearIntensity">0</float>
      </Properties>
    </Item>
    <Item class="BloomEffect" referent="3">
      <Properties>
        <string name="Name">Glow</string>
        <float name="Intensity">0.5</float>
        <float name="Size">24</float>
        <float name="Threshold">1.6</float>
      </Properties>
    </Item>
    <Item class="ColorCorrectionEffect" referent="4">
      <Properties>
        <string name="Name">Grade</string>
        <float name="Brightness">-0.02</float>
        <float name="Contrast">0.14</float>
        <float name="Saturation">-0.1</float>
        <Color3 name="TintColor">
          <R>0.957</R>
          <G>0.973</G>
          <B>1</B>
        </Color3>
      </Properties>
    </Item>
    <Item class="SunRaysEffect" referent="5">
      <Properties>
        <string name="Name">Rays</string>
        <float name="Intensity">0.12</float>
        <float name="Spread">0.85</float>
      </Properties>
    </Item>
  </Item>
  <Item class="ReplicatedFirst" referent="6">
    <Properties>
      <string name="Name">ReplicatedFirst</string>
    </Properties>
    <Item class="Folder" referent="7">
      <Properties>
        <string name="Name">Boot</string>
      </Properties>
      <Item class="LocalScript" referent="8">
        <Properties>
          <string name="Name">Loading</string>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Loading — the very first thing on screen, before any other script exists.

	WHAT THIS COVERS. There is a gap between a player clicking Play and
	`Main.client` getting its first line executed: the place has to download,
	instances have to replicate, and the terrain has to arrive. Roblox fills that
	gap with its own loading screen — the grey one with the spinning logo — and
	that screen is the single loudest "this is somebody's first project" signal a
	Roblox game can emit, because every unfinished game on the platform has it
	and no finished one does.

	`ReplicatedFirst` is the only container whose scripts run during that window,
	which is why this lives outside `src/client` and is the one script in the
	game that cannot require anything from `Shared` — none of it has replicated
	yet. Everything here is therefore hand-rolled and duplicated on purpose:
	three colours and a font. Keep it that way. A require here would either
	yield forever or fail on a slow connection, and the failure mode is a black
	screen with no way out.

	WHAT IT HANDS OVER TO. `UI/Flow` runs the dive sequence — the in-fiction
	"logging in" cinematic — and that is a *different thing* that happens once
	the game is real. This screen's only job is to hold the space until then and
	get out of the way without a seam, so it fades into whatever is underneath
	rather than cutting.
]]

local ContentProvider = game:GetService("ContentProvider")
local Players = game:GetService("Players")
local ReplicatedFirst = game:GetService("ReplicatedFirst")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

ReplicatedFirst:RemoveDefaultLoadingScreen()

local VOID = Color3.fromRGB(6, 8, 12)
local TEXT = Color3.fromRGB(233, 239, 247)
local DIM = Color3.fromRGB(88, 100, 118)
local ACCENT = Color3.fromRGB(64, 196, 220)

local player = Players.LocalPlayer
local gui = Instance.new("ScreenGui")
gui.Name = "Loading"
gui.IgnoreGuiInset = true
gui.ResetOnSpawn = false
gui.DisplayOrder = 10000
gui.Parent = player:WaitForChild("PlayerGui")

local backdrop = Instance.new("Frame")
backdrop.Size = UDim2.fromScale(1, 1)
backdrop.BackgroundColor3 = VOID
backdrop.BorderSizePixel = 0
backdrop.Parent = gui

local title = Instance.new("TextLabel")
title.AnchorPoint = Vector2.new(0.5, 0.5)
title.Position = UDim2.fromScale(0.5, 0.46)
title.Size = UDim2.new(1, 0, 0, 34)
title.BackgroundTransparency = 1
title.Font = Enum.Font.GothamBold
title.Text = "SHANGRI-LA FRONTIER"
title.TextSize = 26
title.TextColor3 = TEXT
title.TextTransparency = 1
title.Parent = backdrop

local subtitle = Instance.new("TextLabel")
subtitle.AnchorPoint = Vector2.new(0.5, 0.5)
subtitle.Position = UDim2.new(0.5, 0, 0.46, 26)
subtitle.Size = UDim2.new(1, 0, 0, 16)
subtitle.BackgroundTransparency = 1
subtitle.Font = Enum.Font.Gotham
subtitle.Text = "CONNECTING"
subtitle.TextSize = 11
subtitle.TextColor3 = DIM
subtitle.TextTransparency = 1
subtitle.Parent = backdrop

-- A hairline that fills as loading progresses. Deliberately not a percentage:
-- the numbers available here are estimates, and a bar that jumps from 12% to
-- 90% is worse than a bar with no number on it at all.
local track = Instance.new("Frame")
track.AnchorPoint = Vector2.new(0.5, 0.5)
track.Position = UDim2.new(0.5, 0, 0.46, 58)
track.Size = UDim2.fromOffset(220, 2)
track.BackgroundColor3 = DIM
track.BackgroundTransparency = 0.7
track.BorderSizePixel = 0
track.Parent = backdrop

local fill = Instance.new("Frame")
fill.Size = UDim2.fromScale(0, 1)
fill.BackgroundColor3 = ACCENT
fill.BorderSizePixel = 0
fill.Parent = track

TweenService:Create(title, TweenInfo.new(0.8), { TextTransparency = 0 }):Play()
TweenService:Create(subtitle, TweenInfo.new(0.8, Enum.EasingStyle.Quad, Enum.EasingDirection.Out, 0, false, 0.2), {
	TextTransparency = 0,
}):Play()

--[[
	Progress is a blend of two things neither of which is reliable alone:
	`game:IsLoaded()` (binary, and true long before the world looks right) and
	the preload count. So the bar eases toward whatever the best current
	estimate is and never goes backwards, which is the honest presentation of a
	number that genuinely is a guess.
]]
local progress = 0
local target = 0

local function setTarget(value: number)
	target = math.max(target, math.clamp(value, 0, 1))
end

task.spawn(function()
	if not game:IsLoaded() then
		game.Loaded:Wait()
	end
	setTarget(0.55)
	subtitle.Text = "LOADING WORLD"

	-- Preload only what is already here. Walking the whole DataModel on a slow
	-- machine costs more than it saves, so this is scoped to the interface
	-- assets that would otherwise pop in visibly on the first frame.
	local queue = {}
	for _, descendant in player.PlayerGui:GetDescendants() do
		if descendant:IsA("ImageLabel") or descendant:IsA("ImageButton") then
			table.insert(queue, descendant)
		end
	end
	if #queue > 0 then
		pcall(function()
			ContentProvider:PreloadAsync(queue)
		end)
	end
	setTarget(0.8)

	-- The real finish line: the character exists and the interface has booted.
	subtitle.Text = "ENTERING"
	local timeout = os.clock() + 20
	while os.clock() < timeout do
		-- `Layer_Boot` is what `UI/Core/Layer` names the dive sequence's
		-- ScreenGui. Its existence is the signal that the interface is up and
		-- there is something real to fade into.
		if player.Character and player.PlayerGui:FindFirstChild("Layer_Boot") then
			break
		end
		task.wait(0.1)
	end
	setTarget(1)
end)

local connection
connection = RunService.RenderStepped:Connect(function(dt)
	-- Creeps forward even with no news, so a stalled connection still looks
	-- alive. It can never reach the target on its own.
	setTarget(math.min(progress + dt * 0.02, 0.5))

	-- Chases hard once the finish line is real. The dive sequence starts as
	-- soon as the interface boots and runs about eight seconds; easing gently
	-- into the last few percent kept this opaque backdrop over the top of the
	-- first two of them, so the game's signature opening played to nobody.
	local speed = if target >= 1 then 9 else 3
	progress += (target - progress) * math.min(dt * speed, 1)
	fill.Size = UDim2.fromScale(progress, 1)

	if progress > 0.995 then
		connection:Disconnect()

		-- Fades rather than cuts. Whatever is underneath — the dive sequence,
		-- the title screen — is already running by now, so a cut would show a
		-- frame of it mid-animation.
		for _, object in { title, subtitle } do
			TweenService:Create(object, TweenInfo.new(0.4), { TextTransparency = 1 }):Play()
		end
		TweenService:Create(track, TweenInfo.new(0.4), { BackgroundTransparency = 1 }):Play()
		TweenService:Create(fill, TweenInfo.new(0.4), { BackgroundTransparency = 1 }):Play()

		local out = TweenService:Create(backdrop, TweenInfo.new(0.7, Enum.EasingStyle.Quad), {
			BackgroundTransparency = 1,
		})
		out:Play()
		out.Completed:Wait()
		gui:Destroy()
	end
end)
]]></string>
        </Properties>
      </Item>
    </Item>
  </Item>
  <Item class="ReplicatedStorage" referent="9">
    <Properties>
      <string name="Name">ReplicatedStorage</string>
    </Properties>
    <Item class="Folder" referent="10">
      <Properties>
        <string name="Name">Shared</string>
      </Properties>
      <Item class="Folder" referent="11">
        <Properties>
          <string name="Name">Combat</string>
        </Properties>
        <Item class="ModuleScript" referent="12">
          <Properties>
            <string name="Name">Audio</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Audio — every sound the game makes.

	THE GAME IS NO LONGER SILENT.

	I can't author or source original audio, and for a long time every cue in
	here fired into an empty asset id — a boss with no music, a sword with no
	sound. What I *can* use is the sound set that ships inside the engine under
	`rbxasset://`, exactly as the particle textures do. Those files are always
	present, can never be moderated away, and need no upload.

	They are not bespoke, so the work is in the *shaping*: one impact sample
	pitched down to 0.5 and played loud is a boss slam; the same sample at 1.6
	and quiet is a footstep on stone. Every cue below declares its own volume,
	pitch range and roll-off, and a randomised pitch per fire is what stops a
	repeated sample reading as a loop.

	SWAPPING IN REAL AUDIO IS ONE LINE PER CUE. Change `id` and nothing else
	moves — the call sites, mixing and spatialisation all stay put. Treat this
	as a complete, working scratch track that a real sound pass replaces
	incrementally rather than all at once.

	ROUTING. Every cue goes through a bus (`Config/Mix`) rather than carrying an
	absolute volume to the speakers. Cues declare a bus only when it isn't the
	default — in practice that means the interface block, because everything
	else is a thing in the world. Balance, volume sliders and ducking all happen
	on the bus, so none of them are the call site's problem.
]]

local Debris = game:GetService("Debris")
local RunService = game:GetService("RunService")
local SoundService = game:GetService("SoundService")
local TweenService = game:GetService("TweenService")

local Mix = require(script.Parent.Parent.Config.Mix)

local Audio = {}

--[[
	Engine-shipped sources. These are the files Roblox's own default character
	and UI scripts reference, so they are as reliable as anything can be
	without an upload.
]]
local SRC = {
	step = "rbxasset://sounds/action_footsteps_plastic.mp3",
	jump = "rbxasset://sounds/action_jump.mp3",
	land = "rbxasset://sounds/action_jump_land.mp3",
	fall = "rbxasset://sounds/action_falling.mp3",
	getUp = "rbxasset://sounds/action_get_up.mp3",
	splash = "rbxasset://sounds/impact_water.mp3",
	impact = "rbxasset://sounds/impact_generic.mp3",
	die = "rbxasset://sounds/uuhhh.mp3",
	metal = "rbxasset://sounds/metal.ogg",
	swoosh = "rbxasset://sounds/swoosh.mp3",
	ping = "rbxasset://sounds/electronicpingshort.wav",
	bass = "rbxasset://sounds/bass.wav",
	click = "rbxasset://sounds/clickfast.wav",
	switch = "rbxasset://sounds/switch3.wav",
	snap = "rbxasset://sounds/snap.mp3",
	pop = "rbxasset://sounds/pop_mid_up.wav",
}

--[[
	name = { id, volume, pitch = {low, high}, distance }

	`pitch` is the range a fire is randomised within. It is doing most of the
	character work: the same impact sample carries a footstep, a sword hit and
	a boss slam purely on pitch and volume.
]]
Audio.Cues = {
	----------------------------------------------------------------------
	-- Footsteps. Same sample, five surfaces, separated by pitch alone.
	----------------------------------------------------------------------
	stepGrass = { id = SRC.step, volume = 0.22, pitch = { 1.25, 1.5 }, distance = 60 },
	stepDirt = { id = SRC.step, volume = 0.26, pitch = { 1.0, 1.2 }, distance = 60 },
	stepStone = { id = SRC.step, volume = 0.34, pitch = { 0.8, 0.95 }, distance = 75 },
	stepSand = { id = SRC.step, volume = 0.2, pitch = { 1.4, 1.7 }, distance = 50 },
	stepWood = { id = SRC.step, volume = 0.3, pitch = { 0.65, 0.8 }, distance = 65 },

	----------------------------------------------------------------------
	-- Locomotion
	----------------------------------------------------------------------
	hop = { id = SRC.jump, volume = 0.3, pitch = { 1.2, 1.45 }, distance = 90 },
	land = { id = SRC.land, volume = 0.45, pitch = { 0.95, 1.15 }, distance = 110 },
	--- Pitched right down and loud: the same file as a normal landing, but it
	--- has to read as something enormous arriving.
	heavyLand = { id = SRC.impact, volume = 1.0, pitch = { 0.35, 0.45 }, distance = 260 },
	windRush = { id = SRC.swoosh, volume = 0.55, pitch = { 0.6, 0.8 }, distance = 150 },
	splash = { id = SRC.splash, volume = 0.6, pitch = { 0.9, 1.15 }, distance = 100 },

	----------------------------------------------------------------------
	-- Attacks
	----------------------------------------------------------------------
	dash = { id = SRC.swoosh, volume = 0.7, pitch = { 1.1, 1.35 }, distance = 140 },
	slash = { id = SRC.swoosh, volume = 0.65, pitch = { 1.3, 1.7 }, distance = 120 },
	spin = { id = SRC.swoosh, volume = 0.7, pitch = { 1.5, 1.9 }, distance = 120 },

	----------------------------------------------------------------------
	-- Reactions
	----------------------------------------------------------------------
	--- The payoff cue. Metal, pitched up, loud, and carrying furthest of
	--- anything in the game — a parry should be audible across the arena.
	parry = { id = SRC.metal, volume = 1.0, pitch = { 1.35, 1.5 }, distance = 300 },
	blocked = { id = SRC.metal, volume = 0.55, pitch = { 0.75, 0.9 }, distance = 140 },
	--- The same metal as a block, dragged as low as it will go and made louder
	--- than the block it failed to be. It has to be unmistakably the *wrong*
	--- version of a sound the player already knows, or a guard break just reads
	--- as having taken a hit.
	guardBreak = { id = SRC.metal, volume = 0.9, pitch = { 0.3, 0.38 }, distance = 220 },
	hurt = { id = SRC.impact, volume = 0.8, pitch = { 0.85, 1.0 }, distance = 160 },
	hitLight = { id = SRC.impact, volume = 0.42, pitch = { 1.3, 1.55 }, distance = 110 },
	hitHeavy = { id = SRC.impact, volume = 0.75, pitch = { 0.7, 0.85 }, distance = 180 },

	----------------------------------------------------------------------
	-- Creatures
	----------------------------------------------------------------------
	growl = { id = SRC.bass, volume = 0.5, pitch = { 0.45, 0.6 }, distance = 180 },
	screech = { id = SRC.metal, volume = 0.6, pitch = { 1.8, 2.2 }, distance = 220 },
	breath = { id = SRC.fall, volume = 0.18, pitch = { 0.5, 0.65 }, distance = 60 },

	----------------------------------------------------------------------
	-- Encounter
	----------------------------------------------------------------------
	introStare = { id = SRC.bass, volume = 0.85, pitch = { 0.3, 0.34 }, distance = 400 },
	defeat = { id = SRC.die, volume = 0.7, pitch = { 0.7, 0.9 }, distance = 200 },
	--- The focus assist's pulse. Slow, low, felt more than heard.
	heartbeat = { id = SRC.bass, volume = 0.45, pitch = { 0.32, 0.36 } },

	----------------------------------------------------------------------
	-- Interface
	----------------------------------------------------------------------
	--- The only block that names a bus. Everything above it is a thing in the
	--- world and belongs on the default effects bus; everything here is the
	--- interface talking about the world, and a player who wants the menus
	--- quieter without going deaf in a fight needs them apart.
	uiHover = { id = SRC.click, volume = 0.14, pitch = { 1.5, 1.6 }, bus = "Ui" },
	uiSelect = { id = SRC.switch, volume = 0.3, pitch = { 1.0, 1.1 }, bus = "Ui" },
	uiOpen = { id = SRC.pop, volume = 0.3, pitch = { 0.9, 1.0 }, bus = "Ui" },
	uiClose = { id = SRC.pop, volume = 0.24, pitch = { 0.7, 0.8 }, bus = "Ui" },
	levelUp = { id = SRC.ping, volume = 0.6, pitch = { 1.0, 1.05 }, bus = "Ui" },
	loot = { id = SRC.ping, volume = 0.4, pitch = { 1.4, 1.5 }, bus = "Ui" },
	unique = { id = SRC.ping, volume = 0.75, pitch = { 0.7, 0.75 }, bus = "Ui" },
}

--------------------------------------------------------------------------------
-- The bus tree
--------------------------------------------------------------------------------

--[[
	The server builds the groups and the client waits for them.

	It has to be that way round. Most cues are fired server-side, and a `Sound`
	that replicates has to point at a `SoundGroup` that exists on every machine
	— a client-built group would be invisible to it. Once the tree has
	replicated, each client writes its own volumes onto it, because a local
	property write on a replicated instance stays local. One player turning the
	music down is one player turning the music down.

	Resolution runs in a spawned thread so that requiring this module never
	yields. Every play path is nil-safe on the group, so a cue fired in the
	first frames before the tree has arrived is unrouted rather than lost.
]]
local groups: { [string]: SoundGroup } = {}

local function buildGroups()
	for _, bus in Mix.Buses do
		local group = Instance.new("SoundGroup")
		group.Name = bus.id
		group.Volume = bus.volume
		group.Parent = SoundService
		groups[bus.id] = group
	end

	-- Parented in a second pass so the declaration order in Mix.Buses doesn't
	-- have to be topological. Guarded: if a future engine version stops letting
	-- groups nest, the tree degrades to flat buses instead of erroring out and
	-- taking every sound in the game with it.
	for _, bus in Mix.Buses do
		local group, parent = groups[bus.id], bus.parent and groups[bus.parent]
		if group and parent then
			local ok = pcall(function()
				group.SoundGroup = parent
			end)
			if not ok then
				warn(`[Audio] {bus.id} could not nest under {bus.parent}; running flat`)
			end
		end
	end
end

local function awaitGroups()
	for _, bus in Mix.Buses do
		local group = SoundService:WaitForChild(bus.id, 10)
		if group and group:IsA("SoundGroup") then
			groups[bus.id] = group
		else
			warn(`[Audio] bus {bus.id} never arrived; its cues will play unrouted`)
		end
	end
end

task.spawn(function()
	if RunService:IsServer() then
		buildGroups()
	else
		awaitGroups()
	end
end)

--- The live `SoundGroup` for a bus, or nil if the tree hasn't resolved yet.
--- Exposed so the client can hang effects and volume changes off it.
function Audio.group(id: string): SoundGroup?
	return groups[id]
end

--------------------------------------------------------------------------------

local function build(cue): Sound?
	if not cue or not cue.id or cue.id == "" then
		return nil
	end

	local sound = Instance.new("Sound")
	sound.SoundId = cue.id
	sound.Volume = cue.volume or 0.6
	sound.SoundGroup = groups[cue.bus or Mix.DefaultBus]
	if cue.pitch then
		sound.PlaybackSpeed = cue.pitch[1] + math.random() * (cue.pitch[2] - cue.pitch[1])
	end
	if cue.distance then
		sound.RollOffMaxDistance = cue.distance
		sound.RollOffMode = Enum.RollOffMode.InverseTapered
	end
	return sound
end

--[[
	Positional anchors, pooled.

	Every spatial cue used to create a Part and hand it to Debris for six
	seconds. Footsteps alone fire a couple of times a second per moving thing,
	and with a player, fourteen roamers and a town's worth of residents that is
	dozens of instances created and destroyed every second — which on Roblox
	shows up as periodic garbage-collection hitches, not as a steady cost.

	A fixed ring of anchors removes the churn entirely. Sixty-four is far more
	than can ever be audible at once, and cycling through them means the oldest
	sound is the one interrupted if the ring ever wraps.
]]
local POOL_SIZE = 64
local pool: { BasePart } = {}
local poolIndex = 0

local function anchorAt(position: Vector3): BasePart
	poolIndex = poolIndex % POOL_SIZE + 1

	local anchor = pool[poolIndex]
	if not (anchor and anchor.Parent) then
		anchor = Instance.new("Part")
		anchor.Name = "SoundAnchor"
		anchor.Size = Vector3.new(0.2, 0.2, 0.2)
		anchor.Transparency = 1
		anchor.Anchored = true
		anchor.CanCollide = false
		anchor.CanQuery = false
		anchor.CanTouch = false
		anchor.Parent = workspace
		pool[poolIndex] = anchor
	end

	anchor.CFrame = CFrame.new(position)
	return anchor
end

--- Fires a cue at a world position, so it attenuates with distance.
function Audio.playAt(name: string, position: Vector3)
	local sound = build(Audio.Cues[name])
	if not sound then
		return
	end

	sound.Parent = anchorAt(position)
	sound:Play()

	-- The Sound still retires; only the anchor is reused.
	Debris:AddItem(sound, 6)
end

--- Fires a cue with no position — UI stings, screen-space feedback.
function Audio.play(name: string)
	local sound = build(Audio.Cues[name])
	if not sound then
		return
	end
	sound.Parent = SoundService
	sound:Play()
	Debris:AddItem(sound, 6)
end

--------------------------------------------------------------------------------
-- Music
--------------------------------------------------------------------------------

--[[
	Boss music, synthesised rather than sampled.

	There is no engine-shipped music track, so the "theme" is a slow pulse of
	low tones layered under the fight — a heartbeat that rises with the boss's
	phase. It is not a composition and does not pretend to be one; it is
	tension where there was silence, and it means the fights have *something*
	driving them until a real track is dropped in.
]]
local musicThread: thread? = nil
local musicIntensity = 1

function Audio.startMusic()
	if musicThread then
		return
	end
	musicIntensity = 1

	musicThread = task.spawn(function()
		local beat = 0
		while true do
			beat += 1
			-- Two-note pulse: a low root, and an off-beat fifth. Faster and
			-- higher as the fight escalates.
			local root = build({
				id = SRC.bass,
				volume = 0.3 + musicIntensity * 0.04,
				pitch = { 0.28 + musicIntensity * 0.012, 0.30 + musicIntensity * 0.012 },
				bus = "Music",
			})
			if root then
				root.Parent = SoundService
				root:Play()
				Debris:AddItem(root, 6)
			end

			if beat % 2 == 0 then
				local fifth = build({
					id = SRC.bass,
					volume = 0.18,
					pitch = { 0.44, 0.46 },
					bus = "Music",
				})
				if fifth then
					fifth.Parent = SoundService
					fifth:Play()
					Debris:AddItem(fifth, 6)
				end
			end

			task.wait(math.max(1.15 - musicIntensity * 0.06, 0.55))
		end
	end)
end

--- Raised by the boss runtime on each phase, so the pulse quickens as the
--- fight goes on.
function Audio.setIntensity(level: number)
	musicIntensity = math.clamp(level, 1, 8)
end

function Audio.stopMusic()
	if musicThread then
		task.cancel(musicThread)
		musicThread = nil
	end
	musicIntensity = 1
end

--------------------------------------------------------------------------------
-- Ducking and levels
--------------------------------------------------------------------------------

--[[
	Push a bus down so something else can be heard, then let it back up.

	CLIENT ONLY, and deliberately so. A duck is one player's experience of one
	moment; running it on the server would dip the music for everyone in the
	game because a stranger parried something on the other side of the map.
	Called on the server it is a no-op rather than an error, so shared code can
	ask for a duck without caring which half it is running on.

	Fast down, hold, slow up. The slow return is the whole trick — coming back
	quickly reads as a dropout, coming back slowly reads as the music having
	been pushed aside.
]]
local duckUntil = 0
local duckThread: thread? = nil

function Audio.duck(shape: string?)
	if RunService:IsServer() then
		return
	end

	local rule = Mix.Ducks[shape or "impact"] or Mix.Ducks.impact
	local group = groups[Mix.Duck.bus]
	local bus = Mix.bus(Mix.Duck.bus)
	if not (group and bus) then
		return
	end

	local depth = rule.depth or Mix.Duck.depth
	local releaseAt = os.clock() + Mix.Duck.attack + rule.hold

	-- Overlapping ducks extend rather than restart, so a flurry of impacts
	-- holds the music down once instead of stuttering it.
	if releaseAt > duckUntil then
		duckUntil = releaseAt
	end

	TweenService:Create(group, TweenInfo.new(Mix.Duck.attack), { Volume = bus.volume * depth }):Play()

	if duckThread then
		return
	end
	duckThread = task.spawn(function()
		while os.clock() < duckUntil do
			task.wait(0.05)
		end
		duckThread = nil
		-- Re-read the bus volume rather than caching it: the player may have
		-- moved the slider while the music was down, and coming back up to the
		-- old level would silently undo their change.
		TweenService:Create(group, TweenInfo.new(Mix.Duck.release), { Volume = bus.volume }):Play()
	end)
end

--[[
	Set a bus level, on this machine only.

	Writes to `Mix.Buses` as well as to the group, because that table is what
	the duck releases back to — setting only the group would work until the next
	impact snapped it back to the old value.
]]
function Audio.setLevel(id: string, volume: number)
	local bus = Mix.bus(id)
	if not bus then
		return
	end

	bus.volume = math.clamp(volume, 0, 1)
	local group = groups[id]
	if group and not (id == Mix.Duck.bus and duckThread) then
		group.Volume = bus.volume
	end
end

function Audio.level(id: string): number
	local bus = Mix.bus(id)
	return if bus then bus.volume else 1
end

return Audio
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="13">
          <Properties>
            <string name="Name">Vfx</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Vfx — combat effects, built entirely from engine primitives.

	No marketplace assets and no imported meshes: expanding parts, Trails,
	Beams and ParticleEmitters using the textures that ship with the engine.
	That keeps the whole fight version-controlled text, and means none of it can
	break because an asset got moderated.

	Everything self-destructs. A combat effect that leaks is a frame-rate bug
	waiting for a long fight.

	Effects run on the server so every player sees them. They are cosmetic — if
	one fails to spawn, the fight still works.
]]

local Debris = game:GetService("Debris")
local TweenService = game:GetService("TweenService")

local SMOKE = "rbxasset://textures/particles/smoke_main.dds"
local SPARKLE = "rbxasset://textures/particles/sparkles_main.dds"

local Vfx = {}

local container: Folder? = nil

local function bin(): Instance
	if container and container.Parent then
		return container
	end
	container = Instance.new("Folder")
	container.Name = "VfxBin"
	container.Parent = workspace
	return container
end

local function decorPart(size: Vector3, cframe: CFrame, color: Color3, transparency: number?): BasePart
	local part = Instance.new("Part")
	part.Size = size
	part.CFrame = cframe
	part.Color = color
	part.Material = Enum.Material.Neon
	part.Transparency = transparency or 0
	part.Anchored = true
	part.CanCollide = false
	part.CanQuery = false
	part.CanTouch = false
	part.CastShadow = false
	part.Parent = bin()
	return part
end

--------------------------------------------------------------------------------
-- Ground
--------------------------------------------------------------------------------

--- Flat expanding ring. The workhorse: landings, dashes, shockwaves.
function Vfx.ring(position: Vector3, maxRadius: number, color: Color3, duration: number?, thickness: number?)
	local life = duration or 0.42
	local ring = decorPart(Vector3.new(2, thickness or 0.18, 2), CFrame.new(position), color, 0.25)

	-- A cylinder that flattens as it expands reads as a ring without needing
	-- a torus mesh asset.
	local mesh = Instance.new("SpecialMesh")
	mesh.MeshType = Enum.MeshType.Cylinder
	mesh.Parent = ring
	ring.Orientation = Vector3.new(0, 0, 90)

	TweenService:Create(ring, TweenInfo.new(life, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
		Size = Vector3.new(maxRadius * 2, 0.05, maxRadius * 2),
		Transparency = 1,
	}):Play()

	Debris:AddItem(ring, life + 0.1)
	return ring
end

--[[
	A ring that CLOSES over `duration`, landing on nothing at the exact moment
	an attack connects.

	The expanding version says "something happened here". This one says "now",
	which is the only thing a parry cue can usefully say. Used for melee
	telegraphs so the unassisted fight has an honest read, not a guess.
]]
function Vfx.closingRing(position: Vector3, startRadius: number, color: Color3, duration: number)
	local ring = decorPart(Vector3.new(startRadius * 2, 0.16, startRadius * 2), CFrame.new(position), color, 0.4)

	local mesh = Instance.new("SpecialMesh")
	mesh.MeshType = Enum.MeshType.Cylinder
	mesh.Parent = ring
	ring.Orientation = Vector3.new(0, 0, 90)

	TweenService:Create(ring, TweenInfo.new(duration, Enum.EasingStyle.Linear), {
		Size = Vector3.new(1.2, 0.16, 1.2),
		Transparency = 0.1,
	}):Play()

	Debris:AddItem(ring, duration + 0.12)
	return ring
end

--- Dust kicked up by a landing or a hard direction change.
function Vfx.dust(position: Vector3, power: number)
	local anchor = Instance.new("Part")
	anchor.Size = Vector3.new(0.2, 0.2, 0.2)
	anchor.CFrame = CFrame.new(position)
	anchor.Transparency = 1
	anchor.Anchored = true
	anchor.CanCollide = false
	anchor.CanQuery = false
	anchor.Parent = bin()

	local emitter = Instance.new("ParticleEmitter")
	emitter.Texture = SMOKE
	emitter.Color = ColorSequence.new(Color3.fromRGB(150, 140, 122))
	emitter.Size = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 0.6 * power),
		NumberSequenceKeypoint.new(1, 3.2 * power),
	})
	emitter.Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 0.45),
		NumberSequenceKeypoint.new(1, 1),
	})
	emitter.Lifetime = NumberRange.new(0.35, 0.7)
	emitter.Speed = NumberRange.new(5 * power, 11 * power)
	emitter.SpreadAngle = Vector2.new(70, 70)
	emitter.Drag = 6
	emitter.Rotation = NumberRange.new(0, 360)
	emitter.RotSpeed = NumberRange.new(-60, 60)
	emitter.Parent = anchor

	emitter:Emit(math.floor(10 + 14 * power))
	Debris:AddItem(anchor, 1.2)
end

--- Landing impact: ring plus dust plus a little debris.
function Vfx.landing(position: Vector3, power: number)
	Vfx.ring(position + Vector3.new(0, 0.15, 0), 3 + power * 3.5, Color3.fromRGB(168, 156, 136), 0.35)
	Vfx.dust(position, power)

	for _ = 1, math.floor(2 + power * 3) do
		local chip = Instance.new("Part")
		chip.Size = Vector3.new(0.28, 0.22, 0.3) * (0.6 + math.random() * 0.9)
		chip.CFrame = CFrame.new(position + Vector3.new(0, 0.4, 0))
		chip.Color = Color3.fromRGB(96, 84, 66)
		chip.Material = Enum.Material.Slate
		chip.CanCollide = false
		chip.CanQuery = false
		chip.CastShadow = false
		chip.Parent = bin()
		chip.AssemblyLinearVelocity = Vector3.new(
			(math.random() - 0.5) * 26 * power,
			8 + math.random() * 12 * power,
			(math.random() - 0.5) * 26 * power
		)
		Debris:AddItem(chip, 1.4)
	end
end

--------------------------------------------------------------------------------
-- Motion
--------------------------------------------------------------------------------

--- Attaches a persistent motion trail to a part. Returns it so callers can
--- toggle .Enabled during dashes rather than rebuilding it.
function Vfx.attachTrail(part: BasePart, color: Color3): Trail
	local a0 = Instance.new("Attachment")
	a0.Position = Vector3.new(0, 1.2, 0)
	a0.Parent = part

	local a1 = Instance.new("Attachment")
	a1.Position = Vector3.new(0, -1.2, 0)
	a1.Parent = part

	local trail = Instance.new("Trail")
	trail.Attachment0 = a0
	trail.Attachment1 = a1
	trail.Color = ColorSequence.new(color)
	trail.Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 0.25),
		NumberSequenceKeypoint.new(1, 1),
	})
	trail.Lifetime = 0.22
	trail.MinLength = 0
	trail.LightEmission = 0.7
	trail.FaceCamera = true
	trail.Enabled = false
	trail.Parent = part

	return trail
end

--- Streaks along a dash path. Cheap stand-in for motion blur.
function Vfx.speedLines(from: Vector3, to: Vector3, color: Color3)
	local direction = to - from
	local distance = direction.Magnitude
	if distance < 1 then
		return
	end

	for _ = 1, 7 do
		local spread =
			Vector3.new((math.random() - 0.5) * 5, (math.random() - 0.5) * 3.5 + 1.5, (math.random() - 0.5) * 5)
		local start = from + spread
		local length = distance * (0.4 + math.random() * 0.45)

		local line = decorPart(
			Vector3.new(0.09, 0.09, length),
			CFrame.lookAt(start + direction.Unit * (length / 2), start + direction.Unit * length),
			color,
			0.35
		)
		TweenService:Create(line, TweenInfo.new(0.22, Enum.EasingStyle.Quad), { Transparency = 1 }):Play()
		Debris:AddItem(line, 0.3)
	end
end

--------------------------------------------------------------------------------
-- Impact
--------------------------------------------------------------------------------

--- The arc a slash carves through the air.
function Vfx.slash(position: Vector3, direction: Vector3, size: number, color: Color3)
	local flat = Vector3.new(direction.X, 0, direction.Z)
	if flat.Magnitude < 0.01 then
		flat = Vector3.new(0, 0, -1)
	end

	local arc = decorPart(
		Vector3.new(size * 0.14, size * 1.7, size * 0.5),
		CFrame.lookAt(position, position + flat.Unit) * CFrame.Angles(0, 0, math.rad(28)),
		color,
		0.1
	)

	TweenService:Create(arc, TweenInfo.new(0.2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
		Size = Vector3.new(size * 0.02, size * 2.4, size * 0.7),
		Transparency = 1,
	}):Play()
	Debris:AddItem(arc, 0.3)
end

function Vfx.sparks(position: Vector3, count: number, color: Color3)
	local anchor = Instance.new("Part")
	anchor.Size = Vector3.new(0.2, 0.2, 0.2)
	anchor.CFrame = CFrame.new(position)
	anchor.Transparency = 1
	anchor.Anchored = true
	anchor.CanCollide = false
	anchor.CanQuery = false
	anchor.Parent = bin()

	local emitter = Instance.new("ParticleEmitter")
	emitter.Texture = SPARKLE
	emitter.Color = ColorSequence.new(color)
	emitter.Size = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 0.85),
		NumberSequenceKeypoint.new(1, 0),
	})
	emitter.Transparency = NumberSequence.new(0.05)
	emitter.Lifetime = NumberRange.new(0.2, 0.5)
	emitter.Speed = NumberRange.new(22, 48)
	emitter.SpreadAngle = Vector2.new(180, 180)
	emitter.Drag = 8
	emitter.LightEmission = 1
	emitter.Acceleration = Vector3.new(0, -55, 0)
	emitter.Parent = anchor

	emitter:Emit(count)
	Debris:AddItem(anchor, 1)
end

--- The big one. Parries only.
function Vfx.parryBurst(position: Vector3)
	local white = Color3.fromRGB(255, 255, 255)

	-- Flash core.
	local core = decorPart(Vector3.new(1, 1, 1), CFrame.new(position), white, 0)
	local coreMesh = Instance.new("SpecialMesh")
	coreMesh.MeshType = Enum.MeshType.Sphere
	coreMesh.Parent = core
	TweenService:Create(core, TweenInfo.new(0.26, Enum.EasingStyle.Quint, Enum.EasingDirection.Out), {
		Size = Vector3.new(13, 13, 13),
		Transparency = 1,
	}):Play()
	Debris:AddItem(core, 0.35)

	-- Expanding shockwave ring, then a slower second one.
	Vfx.ring(position, 15, white, 0.34, 0.4)
	task.delay(0.07, function()
		Vfx.ring(position, 23, Color3.fromRGB(220, 240, 255), 0.5, 0.22)
	end)

	Vfx.sparks(position, 46, Color3.fromRGB(255, 246, 214))

	-- Radiating shards.
	for index = 1, 10 do
		local angle = (index / 10) * math.pi * 2 + math.random() * 0.3
		local direction = Vector3.new(math.cos(angle), 0.15 + math.random() * 0.5, math.sin(angle))
		local shard = decorPart(Vector3.new(0.16, 0.16, 3.4), CFrame.lookAt(position, position + direction), white, 0.1)
		TweenService:Create(shard, TweenInfo.new(0.3, Enum.EasingStyle.Quint, Enum.EasingDirection.Out), {
			CFrame = CFrame.lookAt(position + direction * 13, position + direction * 26),
			Size = Vector3.new(0.02, 0.02, 0.6),
			Transparency = 1,
		}):Play()
		Debris:AddItem(shard, 0.4)
	end
end

--- Failed parry / clean hit on the player.
function Vfx.bloodSlash(position: Vector3, direction: Vector3)
	Vfx.slash(position, direction, 7, Color3.fromRGB(196, 26, 32))
	Vfx.sparks(position, 22, Color3.fromRGB(168, 22, 28))
end

--------------------------------------------------------------------------------
-- Arena
--------------------------------------------------------------------------------

--- Leaves shaken loose. Called when something heavy lands nearby.
function Vfx.leaves(position: Vector3, count: number)
	for _ = 1, count do
		local leaf = Instance.new("Part")
		leaf.Size = Vector3.new(0.5, 0.06, 0.34)
		leaf.CFrame = CFrame.new(
			position + Vector3.new((math.random() - 0.5) * 30, 10 + math.random() * 18, (math.random() - 0.5) * 30)
		) * CFrame.Angles(math.random() * 6, math.random() * 6, math.random() * 6)
		leaf.Color = Color3.fromRGB(74 + math.random(0, 40), 108 + math.random(0, 40), 48)
		leaf.Material = Enum.Material.Grass
		leaf.Anchored = true
		leaf.CanCollide = false
		leaf.CanQuery = false
		leaf.CastShadow = false
		leaf.Parent = bin()

		local drift = Vector3.new((math.random() - 0.5) * 8, -(6 + math.random() * 5), (math.random() - 0.5) * 8)
		TweenService:Create(leaf, TweenInfo.new(2.6, Enum.EasingStyle.Linear), {
			CFrame = leaf.CFrame * CFrame.new(drift.X, drift.Y * 2.6, drift.Z) * CFrame.Angles(3, 5, 2),
			Transparency = 1,
		}):Play()
		Debris:AddItem(leaf, 2.8)
	end
end

return Vfx
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="Folder" referent="14">
        <Properties>
          <string name="Name">Config</string>
        </Properties>
        <Item class="ModuleScript" referent="15">
          <Properties>
            <string name="Name">Appearance</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Appearance — what your character actually looks like.

	Creation asked for a name, a race and a fighting style, and then handed you
	the default Roblox avatar. That is the "something missing" — you make three
	meaningful choices and none of them are visible on the body you then spend
	the whole game looking at.

	FOUR AXES, chosen because between them they cover the silhouette:

	    skin     the face and hands
	    outfit   the largest block of colour on the body
	    hair     the top of the silhouette, and the only part with a shape
	    build    slim / average / broad, applied as body scale

	Deliberately not a hundred sliders. A short palette that all looks good
	together beats a colour wheel that mostly produces mud, and every option
	here is picked to read clearly against the game's dim, blue-grey world.

	Race tints the *default* skin selection rather than restricting it — a
	Draconid starts on the pale green, but nothing stops you moving it.
]]

export type Swatch = {
	id: string,
	name: string,
	color: Color3,
}

export type Build = {
	id: string,
	name: string,
	note: string,
	--- Multiplies Humanoid body scale.
	height: number,
	width: number,
	head: number,
}

local Appearance = {}

Appearance.Skin = {
	{ id = "porcelain", name = "Porcelain", color = Color3.fromRGB(244, 218, 198) },
	{ id = "sand", name = "Sand", color = Color3.fromRGB(226, 188, 152) },
	{ id = "amber", name = "Amber", color = Color3.fromRGB(198, 148, 108) },
	{ id = "clay", name = "Clay", color = Color3.fromRGB(160, 110, 78) },
	{ id = "umber", name = "Umber", color = Color3.fromRGB(112, 74, 52) },
	{ id = "ash", name = "Ash", color = Color3.fromRGB(72, 50, 40) },
	--- Non-human options, for the races that want them.
	{ id = "jade", name = "Jade", color = Color3.fromRGB(150, 190, 156) },
	{ id = "slate", name = "Slate", color = Color3.fromRGB(146, 152, 170) },
} :: { Swatch }

Appearance.Hair = {
	{ id = "black", name = "Black", color = Color3.fromRGB(28, 26, 32) },
	{ id = "brown", name = "Brown", color = Color3.fromRGB(84, 56, 38) },
	{ id = "chestnut", name = "Chestnut", color = Color3.fromRGB(130, 78, 44) },
	{ id = "wheat", name = "Wheat", color = Color3.fromRGB(212, 178, 112) },
	{ id = "ivory", name = "Ivory", color = Color3.fromRGB(232, 226, 210) },
	{ id = "ember", name = "Ember", color = Color3.fromRGB(186, 74, 48) },
	{ id = "azure", name = "Azure", color = Color3.fromRGB(74, 128, 186) },
	{ id = "violet", name = "Violet", color = Color3.fromRGB(140, 96, 176) },
} :: { Swatch }

Appearance.Outfit = {
	{ id = "traveller", name = "Traveller's Green", color = Color3.fromRGB(76, 96, 74) },
	{ id = "wall", name = "Gatewatch Blue", color = Color3.fromRGB(62, 80, 112) },
	{ id = "forge", name = "Forge Brown", color = Color3.fromRGB(92, 64, 48) },
	{ id = "ash", name = "Ashen Grey", color = Color3.fromRGB(78, 78, 84) },
	{ id = "crimson", name = "Crimson", color = Color3.fromRGB(128, 50, 52) },
	{ id = "bone", name = "Bone", color = Color3.fromRGB(196, 186, 166) },
	{ id = "midnight", name = "Midnight", color = Color3.fromRGB(40, 42, 58) },
	{ id = "gold", name = "Old Gold", color = Color3.fromRGB(150, 118, 58) },
} :: { Swatch }

--[[
	Build.

	Scales are kept inside 0.9-1.1 on purpose. Roblox's own animations and
	every hitbox in this game assume a roughly standard humanoid, and a
	character scaled far outside that starts clipping through doorways it
	should fit and missing attacks it should land. The difference is meant to
	be readable, not dramatic.
]]
Appearance.Build = {
	{ id = "slim", name = "Slim", note = "Lighter on the feet.", height = 1.02, width = 0.9, head = 1.0 },
	{ id = "average", name = "Average", note = "Nothing to explain.", height = 1.0, width = 1.0, head = 1.0 },
	{ id = "broad", name = "Broad", note = "Takes up more doorway.", height = 1.05, width = 1.12, head = 0.97 },
} :: { Build }

--- Which skin a race starts on. A default, never a restriction.
Appearance.RaceSkin = {
	human = "sand",
	beastfolk = "clay",
	draconid = "jade",
	fae = "porcelain",
}

local function find(list, id: string?)
	for _, entry in list do
		if entry.id == id then
			return entry
		end
	end
	return list[1]
end

function Appearance.skin(id: string?): Swatch
	return find(Appearance.Skin, id)
end
function Appearance.hair(id: string?): Swatch
	return find(Appearance.Hair, id)
end
function Appearance.outfit(id: string?): Swatch
	return find(Appearance.Outfit, id)
end
function Appearance.build(id: string?): Build
	return find(Appearance.Build, id)
end

--- Fills in anything the client left out, so the server never has to trust a
--- partial payload and a returning player always gets a valid body.
function Appearance.sanitise(raw: any, raceId: string?)
	raw = if typeof(raw) == "table" then raw else {}
	return {
		skin = Appearance.skin(raw.skin or Appearance.RaceSkin[raceId or "human"]).id,
		hair = Appearance.hair(raw.hair).id,
		outfit = Appearance.outfit(raw.outfit).id,
		build = Appearance.build(raw.build).id,
	}
end

return Appearance
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="16">
          <Properties>
            <string name="Name">Assist</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Assist — the adaptive parry helper.

	HOW THE SLOW-MOTION ACTUALLY WORKS, because this is the load-bearing idea:

	Roblox has no global time scale. What makes a parry easier is not the world
	running slower — it is having longer, in real seconds, between "I can see
	this is about to land" and "it lands". So the assist stretches the *attack's
	approach* rather than the world: the enemy holds its strike, the prompt
	appears, and the impact arrives `promptLead / slowFactor` real seconds later.
	At 0.35 slow, a 0.22s tell becomes 0.63 real seconds of reaction time.

	The parry window itself is never widened. You still open a 0.15-0.24s window
	with your own press, and the hit still has to land inside it. Press before
	the prompt and your window closes before the attack arrives; press after and
	it has already resolved. **The assist buys you time to react, never a free
	success** — which is the whole difference between teaching timing and
	replacing it.

	ADAPTATION IN THREE STAGES. Help escalates only as far as it has to, and
	each stage is a different *kind* of help, not a louder version of the last:

	  1. Time only.   The approach stretches. No ring, no prompt. You still have
	                  to read the attack itself — you just get longer to do it.
	                  Most players find the timing here, and it is entirely
	                  theirs when they do.
	  2. + the ring.  A shape that closes on the parry window. Still no
	                  instruction: it abstracts the cue you were already reading
	                  rather than replacing it.
	  3. + the key.   Only now does "F" appear, and only inside the window.

	The ordering matters more than the numbers. Showing the key first teaches
	obedience — press when told — and a player who learns that way has learned
	nothing transferable, because the prompt is not there in the real fight.
	Showing time first, then a shape, then a letter means most players never
	reach stage 3, and the ones who do have already been given two gentler
	chances to own it themselves.

	Withdrawal is fast: successes pull the stage back down, and three clean
	parries in a row clear it entirely.
]]

local Assist = {}

export type Mode = "off" | "adaptive" | "always"

Assist.Modes = {
	{
		id = "off",
		name = "Off",
		summary = "No slowdown, no prompts, no indicators. Pure reaction.",
	},
	{
		id = "adaptive",
		name = "Adaptive",
		summary = "Escalates only as far as it has to, and collapses the moment you improve.",
	},
	{
		id = "always",
		name = "Always On",
		summary = "Always slows the approach and shows the ring. Never the key prompt.",
	},
}

Assist.DefaultMode = "adaptive"

--[[
	Each stage names the number of accumulated failures that unlocks it, and
	exactly what it adds. `ring` and `prompt` are what the client is permitted
	to draw — the client never decides this for itself.
]]
Assist.Stages = {
	{
		tier = 1,
		failures = 3,
		ring = false,
		prompt = false,
		name = "Focus",
		summary = "Time stretches. The timing is still yours to find.",
	},
	{
		tier = 2,
		failures = 5,
		ring = true,
		prompt = false,
		name = "Focus + timing ring",
		summary = "A ring closes on the parry window.",
	},
	{
		tier = 3,
		failures = 7,
		ring = true,
		prompt = true,
		name = "Focus + ring + prompt",
		summary = "The key appears, only inside the window.",
	},
}

--- Consecutive successes that clear all accumulated help at once.
Assist.SuccessesBeforeOff = 3
--- Failures a single success cancels. Above 1 so improvement is felt fast.
Assist.FailuresPerSuccess = 2
--- Ceiling, so a long bad run can't bank help forever.
Assist.MaxFailures = 9

--- Failure count at which the slow reaches full strength.
Assist.FailuresForFullStrength = 7

--- The stage "Always On" sits at: slowed and ringed, but never prompted.
--- Choosing it should not hand out the instruction a struggling player earns.
Assist.AlwaysTier = 2

--[[
	`strength` 0-1 interpolates between these. At 0 the assist is inert; at 1 a
	struggling player gets the full effect.

	Even at full strength this is not generous — 0.63s of reaction time is
	comfortable but still demands a deliberate press, and most players take
	several attempts. That is intended: the goal is rhythm, not a handout.
]]
Assist.Slow = {
	--- Fraction of normal speed the approach plays at. Lower is slower.
	strongest = 0.35,
	weakest = 0.7,
}

--- Seconds of *slowed* time between the prompt appearing and impact.
Assist.PromptLead = {
	strongest = 0.26,
	weakest = 0.16,
}

--- Opacity of the ring and prompt, so they fade out as skill rises.
Assist.Visibility = {
	strongest = 1.0,
	weakest = 0.35,
}

--[[
	Latency compensation, and the single most important pair of numbers here.

	The server holds its strike for `ring + ping + ImpactBias`. Without the ping
	term a press made exactly on the ring's close arrives after the hit has
	already resolved, and the parry cannot be landed by anyone at any skill.

	ImpactBias then lands the hit slightly *after* the ring closes rather than
	exactly on it. Pressing early is forgiving — your window is still open —
	while pressing late is fatal, so the bias should always err late.
]]
Assist.MaxCompensation = 0.35
Assist.ImpactBias = 0.06

--- Player movement multiplier during the focus window. Mild — enough to sell
--- the concentration, not enough to make dodging feel broken.
Assist.MoveScale = 0.55

--- "Always On" runs at a fixed mild strength rather than the full effect, so
--- choosing it isn't strictly better than getting good.
Assist.AlwaysStrength = 0.45

--- Highest stage unlocked by a given failure count, or nil for none.
function Assist.stageFor(failures: number)
	local found = nil
	for _, stage in Assist.Stages do
		if failures >= stage.failures then
			found = stage
		end
	end
	return found
end

--- Linear blend from weakest to strongest by strength.
local function blend(range, strength: number): number
	return range.weakest + (range.strongest - range.weakest) * math.clamp(strength, 0, 1)
end

Assist.blend = blend

--- Everything the client and server need for one assisted attack. `stage`
--- decides what the client is allowed to draw.
function Assist.profile(strength: number, stage)
	return {
		strength = strength,
		slow = blend(Assist.Slow, strength),
		lead = blend(Assist.PromptLead, strength),
		visibility = blend(Assist.Visibility, strength),
		tier = if stage then stage.tier else 1,
		ring = if stage then stage.ring else false,
		prompt = if stage then stage.prompt else false,
	}
end

return Assist
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="17">
          <Properties>
            <string name="Name">Bindings</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Bindings — every control, on both devices, in one table.

	THE PROBLEM THIS SOLVES. Input was scattered: `Enum.KeyCode.F` in
	Combat, `Enum.KeyCode.G` in Interact, `Enum.KeyCode.B` in Main, the slot
	keys in Skills, and a separate hand-written list in `Screens/Controls` for
	the on-screen panel. Six places, no shared truth, and the on-screen panel
	had already been wrong once when block moved off the right mouse button.

	Adding a gamepad to that would have meant a second `KeyCode` comparison at
	every one of those sites and a second hand-written glyph list. So the table
	came first.

	WHAT THE ENGINE ALREADY DOES, and what is therefore absent here: movement,
	the camera, and jump. Roblox's own control scripts bind the left stick, the
	right stick and ButtonA before any of our code runs. Re-binding them would
	mean reimplementing them worse. `jump` appears below with no handler purely
	so the on-screen panel can name its button.

	THE PAD LAYOUT is the one an action game player already has in their hands:
	triggers for the things you hold and spam, bumpers for the things you time,
	face buttons for the things you choose.

	    RT  attack          LT  block (hold)
	    RB  skill 3         LB  parry
	    X   skill 1         Y   skill 2
	    A   jump            B   interact, or close an open menu
	    L3  sprint          Start  menu       Select  this panel

	B is contextual on purpose. It is the cancel button on every console ever
	made, so it closes a menu when one is open; the rest of the time the thumb
	is already resting on it and interaction is the most frequent thing you do
	out of combat.
]]

local Bindings = {}

export type Action = {
	id: string,
	--- Primary keyboard key. `keys` covers the handful with two (both Shifts).
	key: EnumItem?,
	keys: { EnumItem }?,
	--- Mouse button, for actions bound to one.
	mouse: EnumItem?,
	--- Touch counts as a press for this action.
	touch: boolean?,
	pad: EnumItem?,
	--- What the on-screen panel prints for each device.
	label: string,
	padLabel: string,
	--- Panel copy. Actions with no text are internal and stay off the panel.
	text: string?,
	--- Drawn in the accent colour: the one control the game is really about.
	accent: boolean?,
	order: number,
}

Bindings.Actions = {
	move = {
		id = "move",
		label = "W A S D",
		padLabel = "L Stick",
		text = "Move",
		key = Enum.KeyCode.W,
		order = 10,
	},
	jump = {
		id = "jump",
		label = "SPACE",
		padLabel = "A",
		text = "Jump",
		key = Enum.KeyCode.Space,
		pad = Enum.KeyCode.ButtonA,
		order = 20,
	},
	sprint = {
		id = "sprint",
		label = "SHIFT",
		padLabel = "L3",
		text = "Sprint (hold)",
		keys = { Enum.KeyCode.LeftShift, Enum.KeyCode.RightShift },
		pad = Enum.KeyCode.ButtonL3,
		order = 30,
	},
	attack = {
		id = "attack",
		label = "CLICK",
		padLabel = "RT",
		text = "Attack",
		mouse = Enum.UserInputType.MouseButton1,
		touch = true,
		pad = Enum.KeyCode.ButtonR2,
		order = 40,
	},
	block = {
		id = "block",
		label = "C",
		padLabel = "LT",
		text = "Block (hold)",
		key = Enum.KeyCode.C,
		pad = Enum.KeyCode.ButtonL2,
		order = 50,
	},
	parry = {
		id = "parry",
		label = "F",
		padLabel = "LB",
		text = "Parry",
		key = Enum.KeyCode.F,
		pad = Enum.KeyCode.ButtonL1,
		accent = true,
		order = 60,
	},
	interact = {
		id = "interact",
		label = "G",
		padLabel = "B",
		text = "Talk / Interact",
		key = Enum.KeyCode.G,
		pad = Enum.KeyCode.ButtonB,
		order = 70,
	},
	skill1 = {
		id = "skill1",
		label = "Q",
		padLabel = "X",
		key = Enum.KeyCode.Q,
		pad = Enum.KeyCode.ButtonX,
		order = 80,
	},
	skill2 = {
		id = "skill2",
		label = "E",
		padLabel = "Y",
		key = Enum.KeyCode.E,
		pad = Enum.KeyCode.ButtonY,
		order = 81,
	},
	skill3 = {
		id = "skill3",
		label = "R",
		padLabel = "RB",
		key = Enum.KeyCode.R,
		pad = Enum.KeyCode.ButtonR1,
		order = 82,
	},
	--- The panel prints all three slots as one row.
	skills = {
		id = "skills",
		label = "Q E R",
		padLabel = "X Y RB",
		text = "Skills",
		key = Enum.KeyCode.Q,
		pad = Enum.KeyCode.ButtonX,
		order = 83,
	},
	menu = {
		id = "menu",
		label = "B",
		padLabel = "Start",
		text = "Menu",
		key = Enum.KeyCode.B,
		pad = Enum.KeyCode.ButtonStart,
		order = 90,
	},
	controls = {
		id = "controls",
		label = "H",
		padLabel = "Select",
		text = "Hide this",
		key = Enum.KeyCode.H,
		pad = Enum.KeyCode.ButtonSelect,
		order = 100,
	},
	cancel = {
		id = "cancel",
		label = "ESC",
		padLabel = "B",
		key = Enum.KeyCode.Escape,
		pad = Enum.KeyCode.ButtonB,
		order = 110,
	},
}

--- Slot index for each ability action, so Abilities can stay data-driven.
Bindings.SkillSlots = { skill1 = 1, skill2 = 2, skill3 = 3 }

--[[
	Does this InputObject fire this action?

	One function, called at every input site, so keyboard and gamepad can never
	drift apart — adding a pad button to an action here makes it work
	everywhere that action is handled, with no further edits.
]]
function Bindings.matches(id: string, input: InputObject): boolean
	local action = Bindings.Actions[id]
	if not (action and input) then
		return false
	end

	if action.key and input.KeyCode == action.key then
		return true
	end
	if action.pad and input.KeyCode == action.pad then
		return true
	end
	if action.mouse and input.UserInputType == action.mouse then
		return true
	end
	if action.touch and input.UserInputType == Enum.UserInputType.Touch then
		return true
	end

	if action.keys then
		for _, key in action.keys do
			if input.KeyCode == key then
				return true
			end
		end
	end

	return false
end

--- First action in `ids` that this input fires, or nil. For the sites that
--- switch over several actions at once.
function Bindings.resolve(input: InputObject, ids: { string }): string?
	for _, id in ids do
		if Bindings.matches(id, input) then
			return id
		end
	end
	return nil
end

--- Everything the on-screen panel should list, in order.
function Bindings.panel(): { Action }
	local out = {}
	for _, action in Bindings.Actions do
		if action.text then
			table.insert(out, action)
		end
	end
	table.sort(out, function(a, b)
		return a.order < b.order
	end)
	return out
end

--- The glyph to print for an action on the device currently in use.
function Bindings.glyph(id: string, gamepad: boolean): string
	local action = Bindings.Actions[id]
	if not action then
		return "?"
	end
	return if gamepad then action.padLabel else action.label
end

return Bindings
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="18">
          <Properties>
            <string name="Name">Bunny</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Bunny — every number in the Vorpal Bunny fight.

	Split out of Encounters.luau because this one creature now has more tuning
	surface than the rest of the game combined, and burying it in a shared file
	makes it impossible to iterate on.

	DESIGN
	------
	The old fight had one attack and a creature that stood still between uses.
	That reads as a target dummy with a timer. This one is built on three rules:

	  1. It is NEVER stationary. There is no idle state — only "circling",
	     "repositioning", "feinting" and "attacking". Even Observe is a slow
	     sidestep with head tracking.
	  2. It NEVER travels in a straight line. All locomotion is hops, and every
	     hop has a lateral component. A straight approach is readable and a
	     predator that reads as readable is not frightening.
	  3. It chooses, it doesn't cycle. The brain scores every action against
	     live signals — your distance, your stamina, how aggressive you've been,
	     how many times you've parried it — so it adapts instead of looping.

	The threat budget is deliberately brutal: a clean Dash Slash takes 92 of
	your 100 starting health. You are meant to feel that any single mistake is
	nearly fatal, because that is what makes the parry worth learning.
]]

local Bunny = {}

Bunny.Identity = {
	id = "vorpal_bunny",
	name = "Vorpal Bunny",
	title = "Unique Scenario",
	unique = true,
	maxHealth = 820,
	maxPhase = 2,
	rewardXp = 320,
}

Bunny.Perception = {
	aggroRange = 40,
	leash = 155,
	--- Preferred standoff distance while circling.
	circleRadius = 19,
	--- Closer than this and it wants to disengage or spin-slash.
	crowdedRange = 9,
	--- Further than this and it wants to close with a leap.
	farRange = 34,
}

--[[
	LOCOMOTION — everything is a hop.

	A hop is four beats: compress (anticipation), launch, airborne arc, land
	(squash). Nothing translates without going through them, which is what
	makes the movement read as an animal rather than a sliding brick.

	`lateralBias` forces sideways travel into every hop, including approaches.
	At 0 the creature would beeline; at 1 it would only strafe. 0.55 gives a
	zig-zag that closes ground while never being predictable.
]]
Bunny.Hop = {
	compress = 0.07,
	airTime = 0.25,
	land = 0.09,
	height = 3.4,

	shortDistance = 8,
	mediumDistance = 15,
	longDistance = 24,
	longAirTime = 0.42,
	longHeight = 8.5,

	lateralBias = 0.55,
	--- Chance per hop of reversing the circling direction.
	reverseChance = 0.22,
	--- Chance per hop of an obstacle vault (hops onto a rock or log).
	vaultChance = 0.14,
}

--[[
	ATTACKS

	Each carries its own telegraph length and its own commit point. `commitAt`
	is the fraction of the windup after which the direction is locked and the
	telegraph goes solid; before it, the creature is still tracking you.

	TWO DIFFERENT WINDOWS, and conflating them is what made this table wrong for
	a long time.

	The *parry* window is the whole windup, because a parry does not care which
	way the attack is going — it only cares that one is coming, and the growl
	plus the crouch announce that at windup start. That is 0.40-0.50s of
	warning, comfortably above visual reaction time.

	The *repositioning* window is (1 - commitAt) * windup * scale, which is the
	time between the direction locking and the strike. This is what you get to
	sidestep in, and it is deliberately far below reaction time — you cannot
	watch for the commit and then move, you have to have already decided.

	    attack               normal   frenzy (x0.68)
	    Dash Slash            0.225      0.153
	    Triple Dash #1        0.151      0.103
	    Triple Dash #2-3      0.129      0.087   (a further x0.85 per repeat)
	    Spin Slash            0.176      0.120
	    Leap                  0.072      0.049   (unparryable; see trackUntil)

	`overshoot` bounds how far past you a dash ends. It must stay at or under
	`Encounters.PlayerAttack.range` (11) or the punish is out of reach and the
	fight stops being winnable — the same failure the old one-attack version hit
	when it overshot by 27. Without a bound the travel is a flat `distance` from
	wherever the creature happened to be standing, so the overshoot ranged from
	-1 to +24 studs depending on engagement distance and the punish window was a
	lottery.
]]
Bunny.Attacks = {
	dashSlash = {
		name = "dashSlash",
		--- Crouch, eye flash, held. The signature tell.
		windup = 0.5,
		commitAt = 0.55,
		dash = 0.17,
		--- Ceiling on the travel. It skids to a halt here if you are further
		--- away than it can reach, which is a whiff and a free punish.
		distance = 33,
		--- Studs past you it comes to rest. Inside player attack range, so the
		--- punish is always there without having to chase it.
		overshoot = 9,
		--- Floor, so a dash thrown from on top of you still goes somewhere and
		--- reads as a dash rather than a twitch.
		minDistance = 14,
		recover = 0.85,
		damage = 92,
		radius = 6,
		weight = 1.0,
	},

	tripleDash = {
		name = "tripleDash",
		windup = 0.42,
		commitAt = 0.64,
		dash = 0.15,
		distance = 25,
		--- Tighter than the Dash Slash's: each pass ends close enough that the
		--- 0.38s gap is worth one combo opener, which is what makes the chain a
		--- trade rather than a wall.
		overshoot = 7,
		minDistance = 11,
		--- Gap between dashes. Each is telegraphed and parried separately.
		between = 0.38,
		repeats = 3,
		recover = 1.0,
		damage = 58,
		radius = 5.5,
		weight = 0.75,
	},

	fakeAttack = {
		name = "fakeAttack",
		--- Reads exactly like a Dash Slash until it doesn't.
		windup = 0.5,
		--- Aborts here. Deliberately just past a Dash Slash's commit point, so
		--- a player who has learned to parry on commit gets punished for it.
		cancelAt = 0.62,
		--- Then immediately relocates behind you.
		reposition = 0.3,
		behindDistance = 11,
		recover = 0.34,
		weight = 0.55,
	},

	--[[
		Leap. The one attack that must be *moved out of* rather than answered,
		which is what stops the fight collapsing into standing still and
		pressing F on rhythm.

		THE ESCAPE HAS TO ACTUALLY EXIST. It used to re-aim at your feet for the
		whole hang, so the landing spot was fixed 0.2s before impact with an
		11-stud radius — a full sprint covers 5.2 studs in that time, so it was
		unavoidable by movement, and at 104 damage against 100 starting health
		it was a guaranteed kill on a fresh character rather than the near-miss
		the rest of the fight is tuned for.

		Now it stops re-aiming at `trackUntil` through the hang and the locked
		spot is drawn on the ground. That leaves
		    hang * (1 - trackUntil) + fall = 0.3 * 0.65 + 0.2 = 0.395s
		to clear an 8.5-stud radius:
		    sprint 26 studs/s -> 10.3  clears it
		    walk   16 studs/s ->  6.3  does not
		So it is escapable, but only by spending sprint stamina — the same pool
		you block and dodge with. That is the fight's central trade, not an
		exception to it.

		If you change `hang`, `fall`, `trackUntil` or `radius`, re-check
		`SPRINT_SPEED * (hang * (1 - trackUntil) + fall) > radius`.
	]]
	leapAttack = {
		name = "leapAttack",
		--- A parry answers a blade coming at you; it does not answer four
		--- hundred pounds of rabbit falling on your head. Guard still treats a
		--- correctly-timed window as a block, so reading it is never wasted.
		unparryable = true,
		windup = 0.4,
		commitAt = 0.82,
		rise = 0.38,
		--- Hangs above and slightly behind the camera. You lose sight of it.
		hang = 0.3,
		--- Fraction of the hang it is still re-aiming for. After this the
		--- landing is locked and marked, and the hang becomes your head start.
		trackUntil = 0.35,
		fall = 0.2,
		height = 46,
		recover = 1.05,
		--- The heaviest hit in the fight, and deliberately just under a fresh
		--- character's 100 health. Surviving on 4 is the point; dying outright
		--- is a different, worse lesson.
		damage = 96,
		radius = 8.5,
		weight = 0.7,
	},

	spinSlash = {
		name = "spinSlash",
		windup = 0.44,
		commitAt = 0.6,
		hits = 3,
		between = 0.24,
		recover = 0.95,
		damage = 31,
		radius = 8.5,
		weight = 0.65,
	},

	retreat = {
		name = "retreat",
		--- Not an attack: a disengage that buys it space and buys you nothing.
		hops = 2,
		recover = 0.2,
		weight = 0.4,
	},
}

--- Below `below` health it gets faster, jumps higher, waits less and feints
--- more. Phase 2 exists to make the last third of the fight the hard part.
Bunny.Frenzy = {
	below = 0.35,
	speed = 1.35,
	height = 1.4,
	--- Multiplier on rest and windup timings. Lower is faster.
	timing = 0.68,
	fakeBonus = 0.5,
}

--- Pause between actions, before frenzy scaling.
Bunny.Rest = {
	min = 0.5,
	max = 1.3,
	--- Hops taken while waiting out the rest timer. It is always moving.
	circleHops = 3,
}

--[[
	BRAIN — utility scoring.

	Each action's score is its base weight times a set of situational
	multipliers. The brain picks from the top of the list with a little noise,
	so it is legible without being deterministic. Values above 1 encourage,
	below 1 discourage.
]]
Bunny.Utility = {
	--- Applied when you are inside crowdedRange.
	closeUp = { spinSlash = 2.6, retreat = 1.8, dashSlash = 0.35, leapAttack = 0.2, tripleDash = 0.3 },
	--- Applied when you are beyond farRange.
	farOff = { leapAttack = 2.4, tripleDash = 1.9, dashSlash = 1.2, spinSlash = 0.1, retreat = 0.2 },

	--- You are low on stamina, so you cannot block or dodge much. Press.
	staminaBelow = 0.35,
	pressAdvantage = { dashSlash = 1.7, tripleDash = 1.6, spinSlash = 1.5, fakeAttack = 0.4, retreat = 0.2 },

	--- You have parried it recently. Feint more; it has learned you will bite.
	parryMemory = 3,
	parryPunish = { fakeAttack = 3.0, dashSlash = 0.6, tripleDash = 0.8 },

	--- You have been swinging a lot. Back off and make you chase.
	aggressionWindow = 4,
	aggressionThreshold = 5,
	spacing = { retreat = 2.2, leapAttack = 1.6, fakeAttack = 1.4, spinSlash = 0.5 },

	--- Never repeat the same action twice running unless forced.
	repeatPenalty = 0.3,
	--- Random jitter so identical situations don't always give identical picks.
	noise = 0.25,
}

--- Shove distances are NOT here — they are `Parry.Shove` and `Parry.FailShove`,
--- because every enemy uses them and two copies drift.
Bunny.Guard = {
	--- Damage multiplier while it is recovering from an attack.
	punish = 1.5,
	--- Damage multiplier while staggered by a parry.
	counter = 2.5,
	staggerTime = 1.8,
}

--- Boss intro cinematic beats, in seconds from the trigger.
Bunny.Intro = {
	--- It keeps eating. You have to get close before it acknowledges you.
	graze = 1.6,
	--- Ambience drops out. Birds stop, wind dies.
	silence = 0.8,
	--- Head turns toward you.
	turn = 1.1,
	--- Eyes come up.
	glare = 0.9,
	--- Bar and title land, music starts.
	reveal = 1.0,
	total = 5.4,
}

return Bunny
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="19">
          <Properties>
            <string name="Name">Characters</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Characters — the race and combat-style tables offered at creation.

	Data only. Character creation renders straight from these lists, so adding
	a style is a table entry, not a UI change.

	Styles are the meaningful pick: they set how the game *plays*. Races are
	deliberately small modifiers -- in a game about reading attack patterns, a
	race choice should flavour your run, not decide it.

	Difficulty is stated plainly rather than hidden. Someone picking Unarmed
	should know up front what they're signing up for.
]]

export type Race = {
	id: string,
	name: string,
	tagline: string,
	description: string,
	traits: { string },
}

export type Style = {
	id: string,
	name: string,
	glyph: string,
	tagline: string,
	description: string,
	difficulty: string,
	--- 0-1, drives the little rating meters in the preview pane.
	ratings: { { label: string, value: number } },
}

local Characters = {}

--[[
	ORIGIN — where and how you enter the world.

	This is the one creation choice that changes the first hour rather than the
	numbers. Traveler drops you in the Rampant Forest with no tutorial, no
	guidance and a Unique Scenario boss somewhere up the road; everything else
	starts you inside Firstia with an instructor who teaches you the game.

	Taken from the source: Sunraku's Wanderer origin is why he starts in the
	woods instead of a tutorial town, and it is why his first hour is a series
	of near-death experiences rather than a guided walk.

	Both paths converge — same progression, same skills, same world. The
	difference is what your first hour feels like, and what you can say about
	it afterwards.
]]
export type Origin = {
	id: string,
	name: string,
	tagline: string,
	description: string,
	--- "forest" or "firstia"
	start: string,
	tutorial: boolean,
	notes: { string },
}

Characters.Origins = {
	{
		id = "traveler",
		name = "Traveler",
		tagline = "No map, no teacher",
		description = "You wake in the Rampant Forest with no instruction and no safety. "
			.. "Something is killing people on the northern road, and nobody is going to warn you about it.",
		start = "forest",
		tutorial = false,
		notes = {
			"Starts in the Rampant Forest",
			"No tutorial — learn by surviving",
			"Early access to a Unique Scenario boss",
		},
	},
	{
		id = "citizen",
		name = "Citizen",
		tagline = "Firstia born",
		description = "You start inside the walls, where the training yard and an instructor "
			.. "with too much time will walk you through everything before you set foot outside.",
		start = "firstia",
		tutorial = true,
		notes = { "Starts in Firstia", "Full guided tutorial", "Starting equipment issued" },
	},
	{
		id = "apprentice",
		name = "Apprentice",
		tagline = "Taught properly",
		description = "Indentured to a guild that insists on doing things by the book. "
			.. "The book is dull, and it will keep you alive.",
		start = "firstia",
		tutorial = true,
		notes = { "Starts in Firstia", "Full guided tutorial", "Extra skill point on completion" },
	},
	{
		id = "conscript",
		name = "Conscript",
		tagline = "Drilled, not taught",
		description = "Signed up for the wall watch. You will be shouted at, then handed a weapon, "
			.. "then shouted at again.",
		start = "firstia",
		tutorial = true,
		notes = { "Starts in Firstia", "Full guided tutorial", "Extra starting stamina" },
	},
} :: { Origin }

function Characters.findOrigin(id: string?): Origin?
	for _, origin in Characters.Origins do
		if origin.id == id then
			return origin
		end
	end
	return nil
end

Characters.Races = {
	{
		id = "human",
		name = "Human",
		tagline = "Nothing given",
		description = "No bonuses. No penalties. Every advantage you get, you earned.",
		traits = { "Balanced stat growth", "No racial restrictions" },
	},
	{
		id = "beastfolk",
		name = "Beastfolk",
		tagline = "Move first",
		description = "Built to close distance and leave it again. Fragile if you stop moving.",
		traits = { "+12% move speed", "Heightened detection range", "-8% max health" },
	},
	{
		id = "draconid",
		name = "Draconid",
		tagline = "Immovable",
		description = "Slow, heavy, and extremely hard to stagger. Dodging is not your plan.",
		traits = { "+20% poise", "+10% defence", "-10% move speed" },
	},
	{
		id = "fae",
		name = "Fae",
		tagline = "Thin margins",
		description = "The largest resource pool in the game, wrapped in the thinnest body.",
		traits = { "+30% resource pool", "Faster resource regen", "-15% max health" },
	},
} :: { Race }

Characters.Styles = {
	{
		id = "unarmed",
		name = "Unarmed",
		glyph = "✊",
		tagline = "The kusoge hunter's pick",
		description = "No weapon, no reach, no safety net. Fastest recovery frames in the game — "
			.. "you win by reading the boss, not by out-statting it.",
		difficulty = "Punishing",
		ratings = {
			{ label = "Damage", value = 0.35 },
			{ label = "Speed", value = 1.0 },
			{ label = "Reach", value = 0.15 },
			{ label = "Forgiveness", value = 0.1 },
		},
	},
	{
		id = "longsword",
		name = "Longsword",
		glyph = "⚔",
		tagline = "Learn the game with this",
		description = "Even damage, even reach, even recovery. Nothing about it is exceptional, "
			.. "which is exactly why it teaches the fundamentals.",
		difficulty = "Standard",
		ratings = {
			{ label = "Damage", value = 0.6 },
			{ label = "Speed", value = 0.6 },
			{ label = "Reach", value = 0.55 },
			{ label = "Forgiveness", value = 0.7 },
		},
	},
	{
		id = "greatsword",
		name = "Greatsword",
		glyph = "🗡",
		tagline = "Commit or die",
		description = "Enormous damage and poise, telegraphed swings you cannot cancel. "
			.. "Every attack is a decision you're stuck with.",
		difficulty = "Demanding",
		ratings = {
			{ label = "Damage", value = 1.0 },
			{ label = "Speed", value = 0.2 },
			{ label = "Reach", value = 0.8 },
			{ label = "Forgiveness", value = 0.45 },
		},
	},
	{
		id = "dualblades",
		name = "Dual Blades",
		glyph = "🔪",
		tagline = "Death by a thousand",
		description = "Shortest reach, highest hit rate. Trades all defence for uptime — "
			.. "if you're not attacking, you're losing.",
		difficulty = "Demanding",
		ratings = {
			{ label = "Damage", value = 0.5 },
			{ label = "Speed", value = 0.95 },
			{ label = "Reach", value = 0.3 },
			{ label = "Forgiveness", value = 0.25 },
		},
	},
	{
		id = "bow",
		name = "Bow",
		glyph = "🏹",
		tagline = "Distance is a resource",
		description = "Kill it before it reaches you. When it does reach you, you have "
			.. "almost nothing left to answer with.",
		difficulty = "Standard",
		ratings = {
			{ label = "Damage", value = 0.7 },
			{ label = "Speed", value = 0.5 },
			{ label = "Reach", value = 1.0 },
			{ label = "Forgiveness", value = 0.4 },
		},
	},
	{
		id = "caster",
		name = "Caster",
		glyph = "✦",
		tagline = "Windows, not reflexes",
		description = "Long cast times that root you in place. You aren't reacting to the boss, "
			.. "you're predicting where it will be a second from now.",
		difficulty = "Demanding",
		ratings = {
			{ label = "Damage", value = 0.85 },
			{ label = "Speed", value = 0.25 },
			{ label = "Reach", value = 0.9 },
			{ label = "Forgiveness", value = 0.3 },
		},
	},
} :: { Style }

function Characters.findRace(id: string?): Race?
	for _, race in Characters.Races do
		if race.id == id then
			return race
		end
	end
	return nil
end

function Characters.findStyle(id: string?): Style?
	for _, style in Characters.Styles do
		if style.id == id then
			return style
		end
	end
	return nil
end

return Characters
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="20">
          <Properties>
            <string name="Name">Crafting</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Crafting — turning what fell off a corpse into something worth carrying.

	Selling materials is an exit; crafting is a reason to keep them. The two
	have to be in tension or the shop's buy-back price silently deletes the
	whole loot system: if every drop is worth exactly its gold value, players
	vendor everything and craft nothing.

	So every recipe here is worth **more than the sum of its parts sold**, and
	the good ones are worth a lot more. A Vorpal Fang sells for 420. Two of
	them plus a boss's worth of hide becomes a weapon you cannot buy at any
	price in any shop, which is the only reward structure that makes a player
	hesitate at the sell button.

	Recipes gate on quest completion rather than level. Level is a number you
	pass through; "you have actually killed the thing this is made of" is a
	story, and it means the crafting list unfolds as the player does.

	Pencilgon runs the bench — in the source she is the party's builder and
	the one who reads the numbers, so the recipe list is hers.
]]

export type Ingredient = {
	id: string,
	count: number,
}

export type Recipe = {
	id: string,
	name: string,
	rarity: string,
	slot: string,
	description: string,
	stats: { { label: string, value: string } }?,
	--- Gold charged on top of the materials, for the bench's time.
	fee: number,
	needs: { Ingredient },
	--- Quest that must be finished before this appears on the board.
	requires: string?,
	--- One-line justification shown under a locked recipe.
	lockedBy: string?,
}

local Crafting = {}

Crafting.Recipes = {
	----------------------------------------------------------------------
	-- Open from the start. Made from what the forest drops.
	----------------------------------------------------------------------
	{
		id = "hide_jerkin",
		name = "Hide Jerkin",
		rarity = "Common",
		slot = "Armor",
		description = "Six hides, cured badly and stitched worse. It will stop a claw once.",
		stats = { { label = "Defence", value = "+18" } },
		fee = 40,
		needs = { { id = "beast_hide", count = 6 } },
	},
	{
		id = "scrap_cleaver",
		name = "Scrap Cleaver",
		rarity = "Common",
		slot = "Weapon",
		description = "Goblin iron folded over itself until it stopped arguing. Heavy, ugly, effective.",
		stats = { { label = "Attack", value = "+26" }, { label = "Recovery", value = "Slow" } },
		fee = 60,
		needs = { { id = "goblin_iron", count = 5 }, { id = "beast_hide", count = 2 } },
	},
	{
		id = "earring_charm",
		name = "Charm of Small Mercies",
		rarity = "Uncommon",
		slot = "Trinket",
		description = "Twelve goblin ears on a wire. Deeply unpleasant, and every guard in Firstia has one.",
		stats = { { label = "Stamina Regen", value = "+12%" } },
		fee = 30,
		needs = { { id = "goblin_ear", count = 12 } },
	},

	{
		id = "river_stew",
		name = "River Stew",
		rarity = "Uncommon",
		slot = "Consumable",
		description = "Carp, herbs and whatever else was in the pot. Restores 140 health "
			.. "and, for ten minutes afterwards, you simply feel better about things.",
		stats = { { label = "Heal", value = "140" }, { label = "Stamina Regen", value = "+20% / 10 min" } },
		fee = 25,
		needs = { { id = "river_carp", count = 3 } },
	},
	{
		id = "lantern_lamp",
		name = "Lanternfish Lamp",
		rarity = "Rare",
		slot = "Trinket",
		description = "It keeps glowing. Nobody has been able to explain it and the guild has "
			.. "stopped asking. Lights your own patch of the dark, which past the treeline "
			.. "at night is worth more than a sword.",
		stats = { { label = "Light", value = "24 studs" }, { label = "vs Night", value = "+10%" } },
		fee = 180,
		needs = { { id = "lantern_fish", count = 2 }, { id = "goblin_iron", count = 4 } },
	},

	----------------------------------------------------------------------
	-- Unlocked by the Vorpal Bunny.
	----------------------------------------------------------------------
	{
		id = "vorpal_edge",
		name = "Vorpal Edge",
		rarity = "Rare",
		slot = "Weapon",
		description = "The fang, mounted. It keeps the thing's speed and none of its judgement — "
			.. "swings faster than you can reliably stop, which is the trade.",
		stats = {
			{ label = "Attack", value = "+52" },
			{ label = "Recovery", value = "Fastest" },
			{ label = "Guard", value = "-15%" },
		},
		fee = 320,
		needs = { { id = "bunny_fang", count = 2 }, { id = "goblin_iron", count = 6 } },
		requires = "bunny_season",
		lockedBy = "Kill whatever is on the road first.",
	},
	{
		id = "vorpal_cloak",
		name = "Rampant Cloak",
		rarity = "Rare",
		slot = "Back",
		description = "Pelt-lined, and it moves like the animal did. You will notice on the first dodge.",
		stats = { { label = "Defence", value = "+24" }, { label = "Move", value = "+8%" } },
		fee = 260,
		needs = { { id = "bunny_pelt", count = 2 }, { id = "beast_hide", count = 8 } },
		requires = "bunny_season",
		lockedBy = "Kill whatever is on the road first.",
	},

	----------------------------------------------------------------------
	-- Unlocked by the Lycagon. The end of the line for now.
	----------------------------------------------------------------------
	{
		id = "nightslayer_fang",
		name = "Nightslayer's Answer",
		rarity = "Unique",
		slot = "Weapon",
		description = "Ground from the tooth of the thing that hunted this treeline for a year. "
			.. "It is not a better sword. It is the specific sword that killed it, and every "
			.. "beast in the Rampant knows the difference.",
		stats = {
			{ label = "Attack", value = "+78" },
			{ label = "vs Beast", value = "+30%" },
			{ label = "Parry Window", value = "+0.04s" },
		},
		fee = 900,
		needs = {
			{ id = "wolf_fang", count = 1 },
			{ id = "bunny_fang", count = 2 },
			{ id = "goblin_iron", count = 12 },
		},
		requires = "night_howl",
		lockedBy = "Kill what howls at night first.",
	},
} :: { Recipe }

function Crafting.find(id: string?): Recipe?
	for _, recipe in Crafting.Recipes do
		if recipe.id == id then
			return recipe
		end
	end
	return nil
end

--- What a recipe's materials would fetch if simply sold. Used to keep the
--- crafting side of the economy honest against the shop side.
function Crafting.materialValue(recipe: Recipe, sellValues: { [string]: number }): number
	local total = 0
	for _, need in recipe.needs do
		total += (sellValues[need.id] or 0) * need.count
	end
	return total
end

return Crafting
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="21">
          <Properties>
            <string name="Name">Encounters</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Encounters — the player's half of combat, shared by every fight.

	Everything in this file describes what *you* can do: the combo chain, reach
	and arc, block, hitstop, dodge i-frames. Enemies read it so a swing means
	the same thing whoever is on the receiving end.

	WHERE THE ENEMY NUMBERS LIVE. Per-creature tuning is per-creature — the
	Vorpal Bunny is `Config/Bunny`, the three bosses are specs driven by
	`server/Boss/Runtime`. This file used to also carry an `Encounters.
	VorpalBunny` block from when that fight was a single lunge on a timer. The
	fight was rebuilt around a six-action utility brain and the block was left
	behind, read by nothing, long enough that its health (780 vs 820), phase
	count (1 vs 2), damage (95 vs 92), aggro range, leash and reward had all
	drifted from the live values — while its header went on describing a
	creature with one attack. Removed. Bunny numbers are in `Config/Bunny`, and
	the winnability invariant this header used to state now lives next to the
	`overshoot` field that actually enforces it.

	Windup length remains the single most important class of value in the game:
	too short and a fight is unfair, too long and it's trivial.
]]

local Encounters = {}

--------------------------------------------------------------------------------
-- Player combat
--------------------------------------------------------------------------------

--[[
	A combo chain, not a swing on a cooldown.

	One attack on a timer gives you nothing to do between telegraphs and no
	reason for any hit to feel different from the last. A chain gives the
	punish window an internal shape: the first two hits are cheap and fast, the
	third commits, and the finisher is slow, expensive and worth it — so you
	are constantly deciding whether you have time to finish or should bail out.

	Inputs during a swing are buffered rather than dropped. Dropping them is
	the single most common reason Roblox melee feels unresponsive: the player
	pressed the button, saw nothing happen, and concluded the game is broken.
]]
Encounters.Combo = {
	{ damage = 24, windup = 0.09, active = 0.10, recovery = 0.20, stamina = 7, knockback = 6 },
	{ damage = 26, windup = 0.09, active = 0.10, recovery = 0.22, stamina = 7, knockback = 6 },
	{ damage = 32, windup = 0.12, active = 0.11, recovery = 0.28, stamina = 9, knockback = 12 },
	{ damage = 58, windup = 0.18, active = 0.13, recovery = 0.46, stamina = 15, knockback = 34 },
}

Encounters.PlayerAttack = {
	range = 11,
	--- Half-angle of the swing arc, in degrees.
	arc = 60,
	critChance = 0.12,
	critMultiplier = 2,
	--- Chain lapses back to the first hit after this long without swinging.
	comboReset = 0.85,
	--- A press this long before the current swing ends still chains.
	bufferWindow = 0.3,
}

--[[
	Block — the safe, expensive answer.

	Sunraku does not dodge the Vorpal Bunny. He reads that it keeps going for
	his neck, guards, and counters; the fight ends with a parry, and clearing it
	teaches him Flash Counter. Building the fight around dodging was importing a
	different game's answer.

	Holding block stops most of the damage but drains stamina hard, and when the
	bar runs out the guard breaks and you eat the hit whole. That is the cost
	that makes the parry worth learning.

	PARRY IS NOT HERE. It is a discrete press on F with its own upgradeable
	ranks, and it lives in `Config/Parry`. This table used to also carry
	`parryWindow`, `parryStagger` and `counterMultiplier` from when a parry was
	a well-timed tap of the block button. Once parry moved to its own input and
	its own ranks, `Guard` started reading `Parry.Ranks[rank]` and these three
	became decoration — three plausible-looking numbers that changed nothing
	when edited. Removed.
]]
Encounters.Block = {
	--- Fraction of incoming damage absorbed by an untimed block.
	damageReduction = 0.78,
	--- Stamina spent absorbing one hit. Blocking is a resource, not a wall.
	staminaPerHit = 30,
	--- Guard up with less stamina than this and it breaks: the block fails
	--- open, you take the hit in full and your guard drops.
	---
	--- A 0-1 fraction, because that is what the vitals report puts on the wire.
	--- It was written as an absolute 12 against a 100-point bar and read by
	--- nothing at all, so the break never happened and block was an unlimited
	--- 78% wall that cost nothing once the bar was empty.
	guardBreakBelow = 0.12,
	--- Movement speed while holding block.
	moveSpeed = 8,
}

--[[
	Hitstop: both parties freeze for a few frames on impact.

	The cheapest, largest game-feel win available. Without it, damage is a
	number appearing next to an enemy that never reacted; with it, a hit has
	weight. Values are small on purpose — past about 0.12s it stops reading as
	impact and starts reading as lag.
]]
Encounters.Hitstop = {
	light = 0.05,
	heavy = 0.10,
	parry = 0.16,
}

--- Invulnerability granted by Quickstep, matching its skill description.
--- Dodging still works; it is now the option, not the only option.
Encounters.DodgeInvulnerability = 0.22

function Encounters.comboStep(index: number)
	return Encounters.Combo[math.clamp(index, 1, #Encounters.Combo)]
end

return Encounters
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="22">
          <Properties>
            <string name="Name">Feel</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Feel — the numbers that decide what moving through the world is like.

	THE PROBLEM THIS EXISTS TO FIX.

	The combat in this game is good. Everything around it was a screensaver: a
	default Roblox character walking silently over ground that never responded,
	jumping and landing with no consequence, sprinting with no sensation beyond
	a larger number. Combat is maybe a tenth of the time anyone spends here. The
	other nine tenths said "tech demo".

	None of what follows is a mechanic. Nothing here changes what you can do —
	it changes what doing it *feels* like, which is the difference between a
	game people finish and a game people remember.

	THE RULES BEHIND THE NUMBERS

	  1. Every action gets a reaction. Landing kicks dust. Turning leans you.
	     Sprinting pulls the camera. Nothing the player does should be met with
	     silence.
	  2. Reactions scale with magnitude. A step off a kerb and a drop off a
	     cliff cannot produce the same puff of dust, or neither means anything.
	  3. Effects decay fast. Game feel lives in the 100-300ms after an action.
	     Anything still moving a second later reads as lag, not weight.
	  4. Nothing is ever perfectly still. A frozen character is a dead one, and
	     the eye catches stillness faster than motion.
]]

local Feel = {}

--[[
	LANDING.

	Fall distance is measured peak-to-ground, not velocity, because that is
	what the player was watching. Below `soft` a landing is just a footstep —
	if a two-stud hop kicks up dust, dust stops meaning anything.
]]
Feel.Landing = {
	soft = 5,
	--- At and above this, the full treatment: dust ring, debris, hard dip.
	hard = 22,
	--- Camera drop in studs at a hard landing, eased back out.
	dip = 2.4,
	dipRecover = 7,
	shake = 2.6,
	--- Extra time the camera hangs low on a really bad landing.
	stagger = 0.18,
}

--[[
	FOOTSTEPS.

	Driven by distance travelled, never by a timer. A timer desynchronises the
	moment speed changes and the result is the uncanny shuffle every untuned
	Roblox game has.
]]
Feel.Steps = {
	--- Studs between footfalls at walking pace.
	stride = 6.2,
	--- Sprinting lengthens the stride but raises the cadence overall.
	sprintStride = 8.4,
	--- Puff size, scaled by speed.
	dustScale = 0.22,
	sprintDustScale = 0.5,
}

--- Surface responses. Material drives both the sound cue and the dust colour,
--- so running from a road onto grass is audible and visible.
Feel.Surfaces = {
	Grass = { cue = "stepGrass", dust = Color3.fromRGB(120, 148, 92), dustiness = 0.5 },
	LeafyGrass = { cue = "stepGrass", dust = Color3.fromRGB(120, 148, 92), dustiness = 0.5 },
	Ground = { cue = "stepDirt", dust = Color3.fromRGB(148, 128, 100), dustiness = 1.0 },
	Sand = { cue = "stepSand", dust = Color3.fromRGB(196, 176, 138), dustiness = 1.3 },
	Slate = { cue = "stepStone", dust = Color3.fromRGB(130, 130, 128), dustiness = 0.2 },
	Cobblestone = { cue = "stepStone", dust = Color3.fromRGB(140, 136, 126), dustiness = 0.25 },
	Brick = { cue = "stepStone", dust = Color3.fromRGB(140, 136, 126), dustiness = 0.2 },
	Rock = { cue = "stepStone", dust = Color3.fromRGB(130, 130, 128), dustiness = 0.25 },
	Wood = { cue = "stepWood", dust = Color3.fromRGB(120, 96, 68), dustiness = 0.15 },
	WoodPlanks = { cue = "stepWood", dust = Color3.fromRGB(120, 96, 68), dustiness = 0.15 },
	Default = { cue = "stepDirt", dust = Color3.fromRGB(140, 130, 116), dustiness = 0.4 },
}

--[[
	LEAN.

	The single cheapest thing that stops a Roblox character reading as a
	sliding statue. Turning rolls the upper body into the corner and sprinting
	pitches it forward — both scaled by how hard you are actually turning, so
	standing still leans nothing.
]]
Feel.Lean = {
	--- Degrees of roll at maximum turn rate.
	turn = 13,
	--- Degrees of forward pitch at full sprint.
	sprint = 9,
	--- Degrees of backward pitch while blocking. You brace.
	guard = -6,
	--- How fast the lean chases its target. Low is floaty, high is twitchy.
	responsiveness = 7,
	--- Turn rate, in radians/sec, that counts as "maximum".
	maxTurnRate = 3.4,
}

--[[
	CAMERA.

	Deliberately restrained. Camera work is the effect players notice least
	when it's right and most when it's overdone — every value here is at the
	edge of subliminal on purpose.
]]
Feel.Camera = {
	--- FOV added at full sprint. Speed you can feel rather than measure.
	sprintFov = 7,
	fovResponsiveness = 4,
	--- Breathing sway while idle, in studs. Tiny. It only has to not be zero.
	idleSway = 0.055,
	idleRate = 1.6,
	--- Sideways drift with movement, so strafing isn't perfectly rigid.
	moveSway = 0.09,
}

--- Speed streaks at the screen edge while sprinting.
Feel.SpeedLines = {
	--- Fraction of the sprint before they appear, so a short burst is clean.
	threshold = 0.55,
	opacity = 0.16,
}

return Feel
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="23">
          <Properties>
            <string name="Name">Fishing</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Fishing — the river, and what is in it.

	Every other system in this game is about winning a fight. This one is the
	opposite on purpose: it is the thing you do when you do not want to be hit.
	A world that only rewards combat has one texture, and a player who has just
	lost to the Lycagon three times needs somewhere to go that is not the
	Lycagon.

	THE CATCH IS A TIMING TEST, not a random roll. A bite opens a window; how
	close to its centre you strike decides the *quality* of what you land, and
	quality is what upgrades a common fish into the rare one from the same
	water. That makes fishing a skill with a ceiling rather than a slot
	machine, and it reuses the reflex the whole rest of the game is built on.

	Night and day have different stock. It costs nothing, it makes the river
	worth revisiting, and it gives the day/night cycle a second job.
]]

export type Fish = {
	id: string,
	name: string,
	rarity: string,
	--- Sold to the fishmonger for this.
	value: number,
	--- Relative chance of being the fish on the hook.
	weight: number,
	--- "day" | "night" | "any"
	when: string,
	--- Minimum catch quality (0-1) required. The best fish only come to a
	--- near-perfect strike, so the ceiling is skill and not patience.
	quality: number,
	description: string,
}

local Fishing = {}

--- Seconds before something bites. Wide, so the wait is never predictable.
Fishing.BiteDelay = { 2.5, 7.5 }
--- How long the strike window stays open once a bite lands.
Fishing.HookWindow = 1.1
--- Strikes inside this fraction of the window's centre count as perfect.
Fishing.PerfectBand = 0.22
--- Casting again is gated so the river can't be spammed.
Fishing.Cooldown = 1.2

Fishing.Stock = {
	{
		id = "river_carp",
		name = "River Carp",
		rarity = "Common",
		value = 22,
		weight = 30,
		when = "any",
		quality = 0,
		description = "Bony, sullen, and the backbone of every meal in Firstia.",
	},
	{
		id = "silt_eel",
		name = "Silt Eel",
		rarity = "Common",
		value = 34,
		weight = 20,
		when = "any",
		quality = 0,
		description = "Comes up already angry. Maribel wants the skin, don't ask why.",
	},
	{
		id = "bright_dace",
		name = "Bright Dace",
		rarity = "Uncommon",
		value = 70,
		weight = 16,
		when = "day",
		quality = 0.35,
		description = "Runs in the shallows at noon and turns the whole river silver.",
	},
	{
		id = "glass_perch",
		name = "Glass Perch",
		rarity = "Uncommon",
		value = 88,
		weight = 12,
		when = "day",
		quality = 0.5,
		description = "You can read a signpost through one. Nobody knows why they are like that.",
	},
	{
		id = "moon_trout",
		name = "Moonlit Trout",
		rarity = "Rare",
		value = 190,
		weight = 9,
		when = "night",
		quality = 0.55,
		description = "Only rises under the moon, and only for someone who strikes clean.",
	},
	{
		id = "lantern_fish",
		name = "Lanternfish",
		rarity = "Rare",
		value = 240,
		weight = 6,
		when = "night",
		quality = 0.7,
		description = "Carries its own light down there. Landing one lights up the bank.",
	},
	{
		id = "rampant_gar",
		name = "Rampant Gar",
		rarity = "Epic",
		value = 520,
		weight = 3,
		when = "any",
		quality = 0.85,
		description = "Long as your arm, all teeth, and it fights back the whole way in. "
			.. "The river's own boss, and it only comes up for a perfect strike.",
	},
	{
		id = "old_boot",
		name = "Someone's Boot",
		rarity = "Common",
		value = 4,
		weight = 10,
		when = "any",
		quality = 0,
		description = "It is a boot. It has been down there a while. Gus will still buy it.",
	},
} :: { Fish }

function Fishing.find(id: string?): Fish?
	for _, fish in Fishing.Stock do
		if fish.id == id then
			return fish
		end
	end
	return nil
end

--[[
	Picks what is on the hook.

	Filtered by time of day and by how clean the strike was, then weighted.
	Filtering by quality *before* the roll is what makes a good strike feel
	like it did something: a sloppy strike cannot land a Rampant Gar at all,
	rather than landing one slightly less often.
]]
function Fishing.roll(quality: number, night: boolean, random: Random): Fish
	local band = if night then "night" else "day"
	local pool, total = {}, 0

	for _, fish in Fishing.Stock do
		if (fish.when == "any" or fish.when == band) and quality >= fish.quality then
			table.insert(pool, fish)
			total += fish.weight
		end
	end

	-- A carp is always in the pool, so this can never fail to return.
	if total <= 0 then
		return Fishing.Stock[1]
	end

	local pick = random:NextNumber() * total
	for _, fish in pool do
		pick -= fish.weight
		if pick <= 0 then
			return fish
		end
	end
	return pool[#pool]
end

--- Grades a strike. `offset` is how far from the window's centre the press
--- landed, as a fraction of half the window.
function Fishing.grade(offset: number): (number, string)
	local quality = math.clamp(1 - math.abs(offset), 0, 1)
	local label = if quality >= 1 - Fishing.PerfectBand
		then "PERFECT"
		elseif quality >= 0.6 then "GOOD"
		elseif quality >= 0.3 then "FAIR"
		else "SCRAPPY"
	return quality, label
end

return Fishing
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="24">
          <Properties>
            <string name="Name">Items</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Items — item definitions, and the starter kit each combat style is issued.

	Everyone starts armed. A style you picked at creation that leaves you
	punching air for your first fight isn't a choice, it's a punishment, and
	the Vorpal Bunny is unforgiving enough already.

	Starter gear is deliberately Common and deliberately boring: small flat
	numbers, no effects, no drawbacks. It exists so the first fight works and
	so there is a baseline for the first real drop to beat.

	The twinblades keep their name from the source — Sunraku opens the story
	with Mercenary Twinblades and kills a goblin in one hit with them.
]]

export type Item = {
	id: string,
	name: string,
	rarity: string,
	slot: string?,
	description: string?,
	quantity: number?,
	stats: { { label: string, value: string } }?,
	drawbacks: { string }?,
}

local Items = {}

Items.Starters = {
	unarmed = {
		id = "training_wraps",
		name = "Training Wraps",
		rarity = "Common",
		slot = "Weapon",
		description = "Cloth, and the willingness to hit things with your hands. "
			.. "No reach, no safety, fastest recovery in the game.",
		stats = {
			{ label = "Attack", value = "+4" },
			{ label = "Recovery", value = "Fastest" },
		},
	},
	longsword = {
		id = "mercenary_longsword",
		name = "Mercenary Longsword",
		rarity = "Common",
		slot = "Weapon",
		description = "Standard issue. Balanced, unremarkable, and exactly what you need "
			.. "to learn the fundamentals on.",
		stats = {
			{ label = "Attack", value = "+12" },
			{ label = "Reach", value = "Average" },
		},
	},
	greatsword = {
		id = "quarry_cleaver",
		name = "Quarry Cleaver",
		rarity = "Common",
		slot = "Weapon",
		description = "Closer to a slab of iron than a sword. Every swing is a decision " .. "you are stuck with.",
		stats = {
			{ label = "Attack", value = "+26" },
			{ label = "Swing Speed", value = "Slow" },
		},
	},
	dualblades = {
		id = "mercenary_twinblades",
		name = "Mercenary Twinblades",
		rarity = "Common",
		slot = "Weapon",
		description = "A matched pair, short and quick. If you are not attacking, you are losing.",
		stats = {
			{ label = "Attack", value = "+7 x2" },
			{ label = "Hit Rate", value = "High" },
		},
	},
	bow = {
		id = "hunters_shortbow",
		name = "Hunter's Shortbow",
		rarity = "Common",
		slot = "Weapon",
		description = "Kill it before it reaches you. It has no advice for after that.",
		stats = {
			{ label = "Attack", value = "+10" },
			{ label = "Range", value = "Longest" },
		},
	},
	caster = {
		id = "apprentice_staff",
		name = "Apprentice Staff",
		rarity = "Common",
		slot = "Weapon",
		description = "Long casts that root you in place. You aren't reacting to the boss, " .. "you're predicting it.",
		stats = {
			{ label = "Attack", value = "+18" },
			{ label = "Cast Time", value = "Slow" },
		},
	},
} :: { [string]: Item }

--- Given to everyone regardless of style.
Items.CommonKit = {
	{
		id = "green_herb",
		name = "Green Herb",
		rarity = "Common",
		quantity = 3,
		description = "Restores a little health. You will want more of these.",
	},
} :: { Item }

function Items.starterFor(styleId: string?): Item
	return Items.Starters[styleId or "unarmed"] or Items.Starters.unarmed
end

--- The full opening inventory for a style: weapon first, then the common kit.
function Items.startingInventory(styleId: string?): { Item }
	local inventory = { Items.starterFor(styleId) }
	for _, item in Items.CommonKit do
		table.insert(inventory, item)
	end
	return inventory
end

return Items
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="25">
          <Properties>
            <string name="Name">Mix</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Mix — the audio mixer: buses, ducking and acoustic spaces.

	WHY THIS EXISTS.

	Every cue in the game used to be a loose `Instance.new("Sound")` with its
	own volume baked in at the call site. That works right up until you want any
	of the four things a shipped game is expected to have:

	  1. A volume slider. There was nothing to attach one to — turning the music
	     down meant editing a constant and rebuilding.
	  2. Ducking. A boss roar, a parry and a level-up all played at full volume
	     straight over the music, so the loudest moments in the game were the
	     muddiest.
	  3. A sense of place. A sword hit in Wethermon's tomb sounded exactly like
	     the same hit in open forest, because nothing was ever routed through a
	     space.
	  4. Balance you can change in one place. Thirty-one cues each carrying an
	     absolute volume means "the interface is too loud" is a thirty-one file
	     edit and a guess.

	A bus tree fixes all four at once, and costs nothing at runtime: a
	`SoundGroup` is a multiplier, and the engine applies it for free.

	WHO OWNS WHAT. The server builds the group tree, because most cues are fired
	server-side and a replicated `Sound` has to point at a `SoundGroup` that
	exists for everyone. Each client then writes its *own* volumes onto those
	replicated groups — local property writes don't replicate, so one player
	muting the music is one player muting the music.
]]

local Mix = {}

--[[
	The tree. Order matters: a bus is created after its parent.

	Defaults are the mix, not placeholders. Ambience and music sit well under
	the effects that carry information, because the fights are the game and
	anything competing with a telegraph cue is actively harmful.
]]
export type Bus = {
	id: string,
	parent: string?,
	volume: number,
	--- Shown in Settings. Buses without one are structural and stay hidden.
	label: string?,
	summary: string?,
}

Mix.Buses = {
	{ id = "Master", parent = nil, volume = 1.0, label = "Master", summary = "Everything." },
	{
		id = "Music",
		parent = "Master",
		volume = 0.8,
		label = "Music",
		summary = "The boss pulse. Ducks under impacts on its own.",
	},
	{
		id = "Sfx",
		parent = "Master",
		volume = 1.0,
		label = "Effects",
		summary = "Hits, parries, footsteps, creatures. Carries the timing you fight on.",
	},
	{ id = "Ui", parent = "Master", volume = 0.85, label = "Interface", summary = "Menus, toasts, notifications." },
	{
		id = "Ambience",
		parent = "Master",
		volume = 0.65,
		label = "Ambience",
		summary = "Wind, birds, water, crowds.",
	},
} :: { Bus }

Mix.Root = "Master"

--- Cues that don't name a bus land here. Effects rather than interface: a cue
--- nobody has classified is far more likely to be something in the world.
Mix.DefaultBus = "Sfx"

--[[
	DUCKING.

	Not a compressor — a scripted dip on one bus when something more important
	than it happens. Roblox has no sidechain, and faking one by watching
	playback amplitude is both expensive and laggy, so the moments that duck are
	named explicitly instead. That turns out to be better: a duck is a
	*dramatic* choice, and a real compressor would also dip for a footstep.

	The shape is the standard one — fast down, hold, slow back up. Coming back
	slowly is what makes it feel like the music was pushed aside rather than
	glitched.
]]
Mix.Duck = {
	bus = "Music",
	--- Multiplier at full duck. Not zero: the music vanishing entirely reads as
	--- a bug, and the point is to make room, not to stop.
	depth = 0.32,
	attack = 0.05,
	release = 0.75,
}

--- Named duck shapes, so call sites say what happened rather than tuning it.
--- `hold` is how long it stays down before the release begins.
Mix.Ducks = {
	--- A parry. The largest hole in the mix the game ever opens, because it is
	--- the moment the whole fight is built around.
	parry = { hold = 0.5, depth = 0.18 },
	--- Guard break, heavy landing, boss slam.
	impact = { hold = 0.22, depth = 0.4 },
	--- A boss revealing itself. Long, because the roar has to own the room.
	reveal = { hold = 1.1, depth = 0.12 },
	--- Someone is talking.
	dialogue = { hold = 0.35, depth = 0.45 },
	--- Level up, quest complete, unique scenario.
	fanfare = { hold = 0.6, depth = 0.3 },
}

--[[
	ACOUSTIC SPACES.

	A `ReverbSoundEffect` on the effects bus, retuned as you move. This is the
	cheapest possible "the world is a place" signal — the same footstep sample
	in a tomb and in open forest is two different sounds, and players read it
	without ever noticing they are reading it.

	`wet` and `dry` are in decibels and both negative: reverb is added *under*
	the source, never over it. Anything approaching 0 wet turns a hit cue into
	mush and costs you the timing information the cue exists to carry.

	Zones are resolved by the client every frame and cross-faded, so walking
	through the tomb doorway is a transition rather than a switch.
]]
export type Space = {
	id: string,
	decay: number,
	density: number,
	diffusion: number,
	wet: number,
	dry: number,
}

Mix.Spaces = {
	--- Open road and fields. Barely anything — just enough that indoors has
	--- something to contrast against.
	open = { id = "open", decay = 0.9, density = 30, diffusion = 55, wet = -22, dry = 0 },
	--- The Rampant Forest. Trees scatter rather than reflect, so this is short
	--- and diffuse rather than long and ringing.
	forest = { id = "forest", decay = 1.3, density = 45, diffusion = 90, wet = -17, dry = 0 },
	--- Firstia and Rabituza. Stone walls close by, so early reflections.
	town = { id = "town", decay = 1.6, density = 70, diffusion = 70, wet = -14, dry = -1 },
	--- The colosseum. A bowl: long, loud and a little hostile.
	arena = { id = "arena", decay = 2.6, density = 85, diffusion = 60, wet = -10, dry = -1 },
	--- Wethermon's tomb, the only interior. The one place that should feel
	--- genuinely enclosed, so it goes furthest.
	tomb = { id = "tomb", decay = 4.0, density = 100, diffusion = 45, wet = -7, dry = -2 },
} :: { [string]: Space }

Mix.DefaultSpace = "open"

--- Seconds to cross-fade between spaces. Slow enough that a doorway reads as a
--- threshold, fast enough that it has finished by the time you have crossed it.
Mix.SpaceBlend = 0.9

--- Radius in studs around a zone's centre at which its space is fully applied,
--- and the band outside it over which it fades. The overlap is deliberate:
--- hard boundaries make the reverb pop as you cross them.
Mix.SpaceFalloff = 34

function Mix.bus(id: string): Bus?
	for _, bus in Mix.Buses do
		if bus.id == id then
			return bus
		end
	end
	return nil
end

--- Buses a player is allowed to see and set. Structural buses stay out of the
--- menu even though they exist.
function Mix.adjustable(): { Bus }
	local out = {}
	for _, bus in Mix.Buses do
		if bus.label then
			table.insert(out, bus)
		end
	end
	return out
end

return Mix
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="26">
          <Properties>
            <string name="Name">Parry</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Parry — the game's signature defensive mechanic.

	A discrete, timed press on **F**, not a held guard. Block (right mouse) is
	the safe, expensive answer that anyone can hold; parry is the precise one
	that costs nothing if you nail it and everything if you don't. Keeping them
	on separate inputs is what makes that a real decision instead of a hidden
	timing bonus on a button you were already holding.

	  window     the attack must connect inside this, measured from the press
	  cooldown   starts on press, hit or miss. Mistiming is punished by the
	             mechanic being unavailable, not just by the damage
	  recovery   you cannot attack or block during this after a whiff

	RANKS. The skill is permanent and upgradeable, so the parry a veteran throws
	is measurably better than a beginner's — wider window, shorter cooldown,
	stamina back on success. Progression the player can feel in their hands,
	rather than a number on a sheet.

	AUTHORITY. The server owns every parry decision. The client sends "I pressed
	parry at my time T", the server timestamps arrival and checks it against the
	attack's active frames itself. The client predicts visuals only — it plays
	the flash immediately for responsiveness, and the server's verdict is what
	actually cancels the attack.
]]

local Parry = {}

export type Rank = {
	rank: number,
	window: number,
	cooldown: number,
	recovery: number,
	--- Stamina returned on a successful parry.
	refund: number,
	--- Multiplier on how long the enemy is left staggered.
	stagger: number,
	cost: number,
	summary: string,
}

--- Rank 0 is "not unlocked". Ranks are bought with skill points.
Parry.Ranks = {
	{
		rank = 1,
		window = 0.15,
		cooldown = 1.2,
		recovery = 0.45,
		refund = 0,
		stagger = 1.0,
		cost = 1,
		summary = "0.15s window · 1.2s cooldown",
	},
	{
		rank = 2,
		window = 0.19,
		cooldown = 1.0,
		recovery = 0.38,
		refund = 15,
		stagger = 1.1,
		cost = 2,
		summary = "0.19s window · 1.0s cooldown · 15 stamina back",
	},
	{
		rank = 3,
		window = 0.24,
		cooldown = 0.85,
		recovery = 0.3,
		refund = 25,
		stagger = 1.25,
		cost = 3,
		summary = "0.24s window · 0.85s cooldown · 25 stamina · longer stagger",
	},
} :: { Rank }

--- Damage multiplier on hits landed during a parry stagger.
Parry.CounterMultiplier = 2.5

--- How far a parry shoves the attacker back.
Parry.Shove = 52

--- How far a hit that got through shoves *you*. Lower than `Shove`, so a
--- successful parry always moves more than a failed one — the recoil itself
--- tells you which of the two just happened.
Parry.FailShove = 46

--- Grace added server-side to absorb round-trip latency. Without it a player
--- on 120ms would find the mechanic simply doesn't work for them, which reads
--- as the game being broken rather than as lag.
Parry.LatencyGrace = 0.09

--- Hard ceiling on that grace, so a client claiming absurd latency can't widen
--- its own window. Anything past this is rejected outright.
Parry.MaxAcceptedLatency = 0.4

function Parry.forRank(rank: number): Rank?
	return Parry.Ranks[rank]
end

function Parry.maxRank(): number
	return #Parry.Ranks
end

--- Cost to go from `rank` to the next one, or nil at max.
function Parry.upgradeCost(rank: number): number?
	local next_ = Parry.Ranks[rank + 1]
	return next_ and next_.cost
end

return Parry
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="27">
          <Properties>
            <string name="Name">Progression</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Progression — Unique Scenarios and Titles.

	Scenario states are the distinctive mechanic: a scenario can be `taken`,
	meaning another player somewhere in the world claimed it first and it is
	gone for everyone else, permanently. That state has to be visible in the
	log or the exclusivity means nothing to the player who missed it.

	Titles are the durable proof you were the one who got there.
]]

export type Scenario = {
	id: string,
	name: string,
	kicker: string,
	description: string,
	--- "available" | "active" | "cleared" | "taken"
	defaultState: string,
	reward: string?,
}

export type Title = {
	id: string,
	name: string,
	description: string,
	rarity: string,
}

local Progression = {}

Progression.ScenarioStates = {
	available = { label = "Available", tone = "accent" },
	active = { label = "In Progress", tone = "unique" },
	cleared = { label = "Cleared", tone = "success" },
	taken = { label = "Claimed by another player", tone = "dim" },
}

Progression.Scenarios = {
	{
		id = "vorpal_bunny",
		name = "Vorpal Bunny",
		kicker = "Unique Scenario",
		description = "Something small and far too fast is killing travellers on the northern road. "
			.. "Nobody has seen it and lived to describe it.",
		defaultState = "available",
		reward = "Vorpal Fang · Emul",
	},
	{
		id = "lycagon",
		name = "Lycagon the Nightslayer",
		kicker = "Unique Scenario",
		description = "Seven nights, seven forms. It only appears after dark, and it remembers you.",
		defaultState = "available",
		reward = "Nightslayer's Beak",
	},
	{
		id = "wethermon",
		name = "Wethermon the Tombguard",
		kicker = "Unique Scenario",
		description = "A guardian that has not lost in four hundred years, sealed in a tomb "
			.. "nobody has found the entrance to.",
		defaultState = "available",
	},
	{
		id = "rustclad",
		name = "The Rustclad Colossus",
		kicker = "Seven Colossi",
		description = "One of the seven. It has not moved in living memory.",
		defaultState = "available",
		reward = "—",
	},
} :: { Scenario }

Progression.Titles = {
	{
		id = "kusoge_hunter",
		name = "Kusoge Hunter",
		description = "Cleared a trash game that nobody else finished. It counts.",
		rarity = "Unique",
	},
	{
		id = "first_blood",
		name = "First Encounter",
		description = "Won a fight you were never supposed to survive.",
		rarity = "Rare",
	},
	{
		id = "bird_brain",
		name = "Bird-Brain",
		description = "Wore a cursed helm in public for one hundred hours.",
		rarity = "Cursed",
	},
	{
		id = "unbroken",
		name = "Unbroken",
		description = "Cleared a Unique Scenario without dying once.",
		rarity = "Legendary",
	},
} :: { Title }

function Progression.findScenario(id: string?): Scenario?
	for _, scenario in Progression.Scenarios do
		if scenario.id == id then
			return scenario
		end
	end
	return nil
end

function Progression.findTitle(id: string?): Title?
	for _, title in Progression.Titles do
		if title.id == id then
			return title
		end
	end
	return nil
end

return Progression
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="28">
          <Properties>
            <string name="Name">Scale</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Scale — one reference height, and everything derived from it.

	THE BUG THIS EXISTS TO PREVENT. The town was built without a reference and
	drifted enormous: doorways 13 studs tall, shopfronts 34 studs, townsfolk 9
	studs — against a Roblox character that is about 5. The player arrived
	looking like a toddler in a giant's village, and *nothing* in the scene
	said what had gone wrong, because every building was consistently wrong
	together. Proportion errors are invisible from inside the model and
	obvious the moment a person stands next to it.

	So: the character is the ruler. Every architectural number below is a
	multiple of CHARACTER, and anything built anywhere in this game should be
	expressed against these rather than typed as a raw stud count.

	    CHARACTER   5.0    a Roblox R15 humanoid, head to heel
	    DOORWAY     7.5    1.5x — comfortable headroom, reads as a door
	    STOREY      7.0    1.4x — floor to floor
	    COUNTER     3.0    0.6x — waist high on a person
	    TABLE       2.6    0.5x
	    RAILING     3.2    0.65x — hip height

	Rule of thumb when adding anything: stand a 5-stud box next to it in your
	head. If the box looks like a child's toy, the thing is too big.
]]

local Scale = {}

--- The Roblox humanoid this world is built around.
Scale.CHARACTER = 5

--- Clear opening of a door, floor to lintel.
Scale.DOORWAY = 7.5
--- Width of a single doorway. Two people do not pass abreast.
Scale.DOOR_WIDTH = 5.5
--- Floor to floor.
Scale.STOREY = 7
--- Shop counters, bar tops.
Scale.COUNTER = 3
--- Table tops and market stalls.
Scale.TABLE = 2.6
--- Handrails, fences, low walls.
Scale.RAILING = 3.2
--- A town wall you cannot climb but can see over from a tower.
Scale.RAMPART = 14

--- Height of a building with `storeys` floors, including its ground floor.
function Scale.building(storeys: number): number
	return Scale.STOREY * storeys + 1.5
end

--- Converts a legacy number that was authored against the old 9-stud figure.
--- Kept so the retune is auditable rather than a pile of new magic numbers.
function Scale.fromOldFigure(value: number): number
	return value * (Scale.CHARACTER / 9)
end

return Scale
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="29">
          <Properties>
            <string name="Name">Skills</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Skills — unlockable abilities, and the three loadout slots they go into.

	`style = nil` means universal; anything else only appears for that combat
	style, so the tree reads differently depending on what you picked at
	creation. Tier gates ordering and unlock cost, not a hard prerequisite --
	you can rush a tier 3 skill if you bank the points for it.

	Passives are never slotted: they apply the moment they're unlocked. Only
	active skills can occupy Q/E/R, which keeps the loadout decision about
	"what do I press in a fight", not "which of my eleven upgrades is on".
]]

export type Skill = {
	id: string,
	name: string,
	description: string,
	tier: number,
	cost: number,
	--- nil = available to every style.
	style: string?,
	--- Passives apply on unlock and cannot be slotted.
	passive: boolean?,
	--- Active only. Seconds.
	cooldown: number?,
	staminaCost: number?,
}

export type Slot = {
	index: number,
	key: Enum.KeyCode,
	label: string,
	levelRequired: number,
}

local Skills = {}

--[[
	Core skills are permanent, always available to every build, and upgraded by
	rank rather than slotted. They are the game's signature verbs — the things
	every player is expected to know how to do, regardless of what they picked
	at creation.

	Parry is the first. It is bound to its own key rather than a loadout slot
	precisely because it should never compete with a build choice: no player
	should ever be in a fight without it because they spent Q on something else.
]]
Skills.Core = {
	{
		id = "parry",
		name = "Parry",
		key = "F",
		core = true,
		description = "Meet an incoming attack at the exact moment it lands. Cancels it outright, "
			.. "staggers the attacker and opens a counter window. Miss, and you eat the hit "
			.. "with the cooldown already running.",
		tagline = "Timing, not commitment",
	},
}

function Skills.findCore(id: string?)
	for _, skill in Skills.Core do
		if skill.id == id then
			return skill
		end
	end
	return nil
end

--- Three slots, gated by level. The first is available immediately so a new
--- character always has something to press.
Skills.Slots = {
	{ index = 1, key = Enum.KeyCode.Q, label = "Q", levelRequired = 1 },
	{ index = 2, key = Enum.KeyCode.E, label = "E", levelRequired = 10 },
	{ index = 3, key = Enum.KeyCode.R, label = "R", levelRequired = 25 },
} :: { Slot }

Skills.List = {
	-- Universal
	{
		id = "step",
		name = "Quickstep",
		description = "A short dash with 0.2s of invulnerability. The dodge everything else is built on.",
		tier = 1,
		cost = 1,
		cooldown = 3,
		staminaCost = 25,
	},
	{
		id = "read",
		name = "Pattern Read",
		description = "Boss windups display a faint tell 0.1s earlier. Does not slow anything down — "
			.. "it just gives you the frame back.",
		tier = 1,
		cost = 1,
		passive = true,
	},
	{
		id = "secondwind",
		name = "Second Wind",
		description = "Recover 30% stamina instantly. Long cooldown — this is an escape, not a rotation.",
		tier = 2,
		cost = 2,
		cooldown = 90,
		staminaCost = 0,
	},
	{
		id = "endure",
		name = "Endure",
		description = "Survive one lethal hit per encounter at 1 HP.",
		tier = 3,
		cost = 4,
		passive = true,
	},

	-- Unarmed
	{
		id = "flurry",
		name = "Flurry",
		description = "Four strikes in the window most weapons need for one.",
		tier = 1,
		cost = 1,
		style = "unarmed",
		cooldown = 6,
		staminaCost = 35,
	},
	{
		id = "counter",
		name = "Counter Palm",
		description = "Parry on the exact contact frame and convert the hit into damage. No forgiveness window.",
		tier = 3,
		cost = 4,
		style = "unarmed",
		cooldown = 12,
		staminaCost = 20,
	},

	-- Longsword
	{
		id = "riposte",
		name = "Riposte",
		description = "Follow a successful block with a guaranteed critical.",
		tier = 2,
		cost = 2,
		style = "longsword",
		cooldown = 8,
		staminaCost = 25,
	},

	-- Greatsword
	{
		id = "hyperarmor",
		name = "Hyper Armour",
		description = "Cannot be staggered during a windup. You will still take the damage.",
		tier = 2,
		cost = 3,
		style = "greatsword",
		passive = true,
	},

	-- Dual blades
	{
		id = "bloodrush",
		name = "Blood Rush",
		description = "Each consecutive hit adds 4% attack speed, to a cap of 40%. Resets when you stop.",
		tier = 2,
		cost = 3,
		style = "dualblades",
		passive = true,
	},

	-- Bow
	{
		id = "trackshot",
		name = "Tracking Shot",
		description = "Arrows curve toward a marked target. Reduced damage.",
		tier = 2,
		cost = 2,
		style = "bow",
		cooldown = 5,
		staminaCost = 20,
	},

	-- Caster
	{
		id = "quickcast",
		name = "Quickcast",
		description = "Halve one cast time. Choosing when is the entire skill.",
		tier = 2,
		cost = 3,
		style = "caster",
		cooldown = 45,
		staminaCost = 0,
	},
} :: { Skill }

--- Skills available to a given combat style, tier-ordered.
function Skills.forStyle(styleId: string?): { Skill }
	local result = {}
	for _, skill in Skills.List do
		if skill.style == nil or skill.style == styleId then
			table.insert(result, skill)
		end
	end
	table.sort(result, function(a, b)
		if a.tier ~= b.tier then
			return a.tier < b.tier
		end
		return a.name < b.name
	end)
	return result
end

function Skills.find(id: string?): Skill?
	for _, skill in Skills.List do
		if skill.id == id then
			return skill
		end
	end
	return nil
end

function Skills.slot(index: number): Slot?
	return Skills.Slots[index]
end

--- Passives can't be slotted; everything else can.
function Skills.isSlottable(skill: Skill?): boolean
	return skill ~= nil and not skill.passive
end

function Skills.isSlotUnlocked(index: number, level: number): boolean
	local slot = Skills.Slots[index]
	return slot ~= nil and level >= slot.levelRequired
end

return Skills
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="30">
          <Properties>
            <string name="Name">Sky</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Sky — the whole visual grade of the game, by time of day.

	WHY THIS FILE IS THE BIGGEST VISUAL LEVER AVAILABLE.

	A Roblox game does not look expensive because of its models. It looks
	expensive because of light. The difference between "obviously Roblox" and
	"that looks good" is almost entirely: Future lighting, a real atmosphere,
	colour grading, bloom that only catches genuine highlights, depth of field
	that separates foreground from background, and a sun that actually moves.

	Every value here is authored per time-of-day and interpolated, so dawn,
	noon, dusk and night are four distinct looks rather than one look with the
	brightness slider moved.

	The grade is warm and saturated by day, cool and quieter at night.
	Consistency across those four is what makes a world feel art-directed
	instead of assembled.

	It used to say "slightly desaturated by day", and it meant it — see the note
	on `Sky.Grades`. On a world made entirely of untextured parts that is the
	one direction the grade must never go.
]]

local Sky = {}

export type Grade = {
	--- 0-24 clock hour this keyframe describes.
	hour: number,
	name: string,

	brightness: number,
	exposure: number,
	ambient: Color3,
	outdoorAmbient: Color3,
	colorShiftTop: Color3,
	colorShiftBottom: Color3,

	--- Atmosphere
	density: number,
	haze: number,
	glare: number,
	atmosphereColor: Color3,
	atmosphereDecay: Color3,

	--- Post
	bloomIntensity: number,
	bloomThreshold: number,
	contrast: number,
	saturation: number,
	tint: Color3,
	--- Depth of field far-plane blur. Subtle: this is a game, not a photograph.
	farIntensity: number,

	--- Clouds
	cloudCover: number,
	cloudDensity: number,
	cloudColor: Color3,
}

local function rgb(r: number, g: number, b: number): Color3
	return Color3.fromRGB(r, g, b)
end

--[[
	Four keyframes. Anything between them is interpolated, so the transition
	through dawn is continuous rather than a switch being thrown.

	RETUNED, because the first honest look at this in Studio was "too dark, grey
	and washed out" — and it was all three at once, for reasons that compounded:

	  1. Every keyframe *desaturated*. Day sat at -0.08. In a world built
	     entirely from untextured parts, colour is the only visual information
	     there is, and the grade was busy removing it.
	  2. `outdoorAmbient` was neutral grey (126,128,132). Outdoor bounce light is
	     sky-coloured; a neutral fill is what makes a scene read as overcast
	     concrete regardless of what the sun is doing.
	  3. Haze was enormous — Day 1.5, Dusk 3.2, with `density` up at 0.46 — so
	     anything past a few dozen studs dissolved into flat atmosphere.
	  4. `exposure` was *negative* on top of all that.

	And the clock started at 16.5, which interpolates 73% of the way from Day to
	Dusk: brightness 1.75, haze 2.73, density 0.42. The very first thing anyone
	saw was therefore close to the darkest, haziest, least saturated frame in the
	set. The old comment called it "the best light". It was the worst one.

	The direction now is brighter, far clearer, and positively saturated, with
	ambient carrying a colour rather than a grey. Haze is a garnish; it had
	become the main course.
]]
Sky.Grades = {
	{
		hour = 5.5,
		name = "Dawn",
		brightness = 2.1,
		exposure = 0.08,
		ambient = rgb(72, 64, 80),
		outdoorAmbient = rgb(140, 120, 140),
		colorShiftTop = rgb(96, 62, 58),
		colorShiftBottom = rgb(30, 26, 38),
		density = 0.3,
		haze = 1.5,
		glare = 0.5,
		atmosphereColor = rgb(214, 176, 158),
		atmosphereDecay = rgb(92, 74, 92),
		bloomIntensity = 0.7,
		bloomThreshold = 1.1,
		contrast = 0.08,
		saturation = 0.1,
		tint = rgb(255, 244, 236),
		farIntensity = 0.14,
		cloudCover = 0.62,
		cloudDensity = 0.045,
		cloudColor = rgb(226, 196, 186),
	},
	{
		hour = 12,
		name = "Day",
		brightness = 3.0,
		exposure = 0.06,
		ambient = rgb(94, 98, 110),
		outdoorAmbient = rgb(152, 168, 190),
		colorShiftTop = rgb(58, 52, 42),
		colorShiftBottom = rgb(18, 20, 26),
		density = 0.18,
		haze = 0.7,
		glare = 0.14,
		atmosphereColor = rgb(199, 209, 222),
		atmosphereDecay = rgb(106, 112, 125),
		bloomIntensity = 0.5,
		bloomThreshold = 1.6,
		contrast = 0.09,
		saturation = 0.14,
		tint = rgb(252, 250, 245),
		farIntensity = 0.09,
		cloudCover = 0.48,
		cloudDensity = 0.032,
		cloudColor = rgb(248, 248, 250),
	},
	{
		hour = 18.2,
		name = "Dusk",
		brightness = 2.0,
		exposure = 0.08,
		ambient = rgb(78, 64, 68),
		outdoorAmbient = rgb(150, 116, 108),
		colorShiftTop = rgb(122, 66, 44),
		colorShiftBottom = rgb(26, 24, 34),
		density = 0.3,
		haze = 1.6,
		glare = 0.62,
		atmosphereColor = rgb(226, 172, 138),
		atmosphereDecay = rgb(102, 72, 82),
		bloomIntensity = 0.85,
		bloomThreshold = 1.0,
		contrast = 0.09,
		saturation = 0.16,
		tint = rgb(255, 240, 226),
		farIntensity = 0.16,
		cloudCover = 0.7,
		cloudDensity = 0.05,
		cloudColor = rgb(238, 180, 152),
	},
	{
		hour = 23,
		name = "Night",
		brightness = 1.15,
		exposure = 0.22,
		ambient = rgb(40, 48, 68),
		outdoorAmbient = rgb(70, 84, 116),
		colorShiftTop = rgb(24, 32, 56),
		colorShiftBottom = rgb(6, 8, 14),
		density = 0.34,
		haze = 1.3,
		glare = 0.06,
		atmosphereColor = rgb(122, 140, 178),
		atmosphereDecay = rgb(34, 42, 64),
		bloomIntensity = 1.1,
		bloomThreshold = 0.9,
		contrast = 0.12,
		saturation = 0.0,
		tint = rgb(214, 226, 255),
		farIntensity = 0.2,
		cloudCover = 0.55,
		cloudDensity = 0.038,
		cloudColor = rgb(96, 108, 138),
	},
} :: { Grade }

--- Real seconds per in-game hour. A full day is 24x this.
Sky.SecondsPerHour = 55

--- Where the clock starts. Early afternoon, close to the Day keyframe, so a new
--- player's first frame is the brightest and clearest in the cycle. This used
--- to be 16.5, which is most of the way to Dusk and its haze — the single worst
--- first impression the lighting could offer.
Sky.StartHour = 13.5

--------------------------------------------------------------------------------

local function lerpNumber(a: number, b: number, t: number): number
	return a + (b - a) * t
end

--- Interpolated grade for any hour, wrapping through midnight.
function Sky.gradeAt(hour: number): Grade
	local grades = Sky.Grades
	hour = hour % 24

	local before, after = grades[#grades], grades[1]
	for index, grade in grades do
		if grade.hour <= hour then
			before = grade
			after = grades[index + 1] or grades[1]
		end
	end

	-- Span, accounting for the wrap past midnight.
	local span = after.hour - before.hour
	if span <= 0 then
		span += 24
	end
	local into = hour - before.hour
	if into < 0 then
		into += 24
	end
	local t = math.clamp(if span > 0 then into / span else 0, 0, 1)
	-- Smoothstep, so the light eases through a transition instead of ramping
	-- linearly and making dawn look like a dimmer switch.
	t = t * t * (3 - 2 * t)

	local blended = {}
	for key, value in before do
		local target = (after :: any)[key]
		if typeof(value) == "number" and typeof(target) == "number" then
			blended[key] = lerpNumber(value, target, t)
		elseif typeof(value) == "Color3" and typeof(target) == "Color3" then
			blended[key] = value:Lerp(target, t)
		else
			blended[key] = value
		end
	end
	blended.name = if t < 0.5 then before.name else after.name

	return blended :: any
end

--- True while it is dark enough for torches, night creatures and Lycagon.
function Sky.isNight(hour: number): boolean
	hour = hour % 24
	return hour >= 19.5 or hour < 5.5
end

return Sky
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="31">
          <Properties>
            <string name="Name">Theme</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Theme — every color, font, radius and duration in the game UI.

	Nothing downstream is allowed to hardcode a Color3 or a magic number.
	Retheming the whole game should mean editing this file only.

	Palette intent: near-black cool surfaces so the world reads through,
	cyan for the interface itself, gold reserved *exclusively* for Unique
	Scenario moments (it should feel rare because you almost never see it),
	violet for cursed equipment.
]]

local Theme = {}

Theme.Color = {
	-- Surfaces, darkest to lightest
	Void = Color3.fromRGB(6, 8, 12),
	Scrim = Color3.fromRGB(0, 0, 0),
	Panel = Color3.fromRGB(15, 19, 27),
	PanelRaised = Color3.fromRGB(23, 29, 40),
	PanelHover = Color3.fromRGB(32, 40, 54),
	Stroke = Color3.fromRGB(48, 60, 78),
	StrokeBright = Color3.fromRGB(92, 112, 138),

	-- Type
	Text = Color3.fromRGB(233, 239, 247),
	TextMuted = Color3.fromRGB(142, 156, 176),
	TextDim = Color3.fromRGB(88, 100, 118),
	TextInverse = Color3.fromRGB(10, 12, 16),

	-- Vitals
	Health = Color3.fromRGB(211, 58, 70),
	HealthChip = Color3.fromRGB(240, 190, 190),
	Stamina = Color3.fromRGB(84, 202, 140),
	StaminaChip = Color3.fromRGB(206, 244, 224),
	StaminaEmpty = Color3.fromRGB(196, 148, 56),
	Poise = Color3.fromRGB(126, 148, 184),

	-- Identity
	Accent = Color3.fromRGB(62, 194, 214),
	AccentDim = Color3.fromRGB(32, 106, 120),
	Unique = Color3.fromRGB(240, 192, 88),
	Cursed = Color3.fromRGB(162, 84, 212),
	Danger = Color3.fromRGB(228, 74, 74),
	Success = Color3.fromRGB(84, 202, 140),

	-- Combat text
	DamageNormal = Color3.fromRGB(246, 246, 248),
	DamageCrit = Color3.fromRGB(255, 206, 92),
	DamageWeak = Color3.fromRGB(150, 158, 172),
	DamageTaken = Color3.fromRGB(238, 92, 92),
	Heal = Color3.fromRGB(112, 226, 152),
}

--- Item rarity ramp. `Cursed` sits outside the normal progression on
--- purpose -- cursed gear is a tradeoff, not a tier.
Theme.Rarity = {
	Common = { Color = Color3.fromRGB(150, 158, 172), Label = "Common" },
	Uncommon = { Color = Color3.fromRGB(104, 196, 116), Label = "Uncommon" },
	Rare = { Color = Color3.fromRGB(80, 156, 232), Label = "Rare" },
	Epic = { Color = Color3.fromRGB(176, 104, 232), Label = "Epic" },
	Legendary = { Color = Color3.fromRGB(240, 152, 64), Label = "Legendary" },
	Unique = { Color = Color3.fromRGB(240, 192, 88), Label = "Unique" },
	Cursed = { Color = Color3.fromRGB(162, 84, 212), Label = "Cursed" },
}

-- Montserrat carries the slightly-technical game-menu feel without
-- looking like default Roblox UI.
local FAMILY = "Montserrat"

function Theme.font(weight: Enum.FontWeight?, italic: boolean?): Font
	local resolvedWeight = weight or Enum.FontWeight.Medium
	local style = if italic then Enum.FontStyle.Italic else Enum.FontStyle.Normal

	-- Font.fromName throws on an unknown family, and this runs at require
	-- time -- an unresolvable name would take the entire UI down rather than
	-- one label. Degrade to the built-in instead.
	local ok, font = pcall(Font.fromName, FAMILY, resolvedWeight, style)
	if ok then
		return font
	end

	warn(`[Theme] font family "{FAMILY}" unavailable; falling back to BuilderSans`)
	return Font.new("rbxasset://fonts/families/BuilderSans.json", resolvedWeight, style)
end

Theme.Font = {
	Regular = Theme.font(Enum.FontWeight.Regular),
	Medium = Theme.font(Enum.FontWeight.Medium),
	SemiBold = Theme.font(Enum.FontWeight.SemiBold),
	Bold = Theme.font(Enum.FontWeight.Bold),
	Black = Theme.font(Enum.FontWeight.Heavy),
}

--- Spacing scale in *reference* pixels (1920x1080). Responsive applies
--- the viewport scale on top, so always author at reference size.
Theme.Space = {
	xs = 4,
	sm = 8,
	md = 14,
	lg = 22,
	xl = 34,
	xxl = 56,
}

Theme.Radius = {
	sm = UDim.new(0, 3),
	md = UDim.new(0, 5),
	lg = UDim.new(0, 8),
	pill = UDim.new(1, 0),
}

Theme.Time = {
	Instant = 0.06,
	Fast = 0.12,
	Base = 0.2,
	Slow = 0.34,
	Deliberate = 0.6,

	--- How long the health bar's white "chip" trail lingers before
	--- draining. This is the single most important feel number in the
	--- combat HUD -- it's what makes a hit register.
	ChipHold = 0.35,
	ChipDrain = 0.45,
}

Theme.Ease = {
	Standard = Enum.EasingStyle.Quart,
	Enter = Enum.EasingStyle.Quint,
	Exit = Enum.EasingStyle.Quad,
}

--- Layer ordering. Anything creating a ScreenGui picks a name from here.
Theme.Layer = {
	Combat = 10, -- floating damage numbers
	HUD = 20, -- vitals, hotbar, boss bar
	Overlay = 60, -- toasts, scenario banner
	Menu = 100, -- inventory, map, character
	Fullscreen = 200, -- death
	Screen = 250, -- combat flashes and vignettes, over the HUD
	Frontend = 300, -- title, character creation
	Boot = 400, -- dive sequence; must sit above everything
}

return Theme
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="32">
          <Properties>
            <string name="Name">Town</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Town — Firstia's layout, residents, shops and quests.

	Everything about the town that both halves of the game need to agree on
	lives here: where a building stands, who works in it, what they sell, and
	what they'll ask you to do. The server builds and drives from this; the
	client renders shop and dialogue panels straight out of the same tables.

	GEOGRAPHY. The town runs north from the gate the player walks in through:

	    z = -355   town gate (faces the forest, players arrive here)
	    z = -430   gate courtyard + training yard    [built by Firstia.server]
	    z = -505   inner arch
	    z = -550   Guild Row — smithy and apothecary either side of the street
	    z = -660   Market Plaza, fountain at its centre
	    z = -706   The Fireside Tavern, closing the plaza's north side

	Negative Z is north, matching World.luau. All floors sit at y = 0, which is
	World.Courtyard.floor — the whole town is one flat authored plate, because a
	shopfront on a procedural hillside either floats or buries its doorway.

	NPC SCHEDULES are a list of {from = hour, at = ..., facing = ...} entries,
	read as "from this hour until the next entry's hour". They wrap at midnight.
	The town clock is the same one the sky runs on, so the smith really does
	shut the forge when the light goes orange.
]]

local Fishing = require(script.Parent.Fishing)

local Town = {}

local V = Vector3.new

--------------------------------------------------------------------------------
-- Geography
--------------------------------------------------------------------------------

Town.Floor = 0

--[[
	Everything below is sized against Config/Scale, not typed by eye.

	Buildings are two storeys (the guild is three), houses one plus a loft, and
	every footprint is a room a five-stud person could plausibly work in. The
	first pass was authored with no reference and came out roughly twice this,
	which read as a giant's village the moment a player stood in it.
]]

--[[
	The street from the courtyard arch up to the plaza.

	`toZ` stops exactly on the plaza's north edge (centre -660, depth 124, so
	-598) rather than two studs past it. It used to run to -600, which left a
	44 x 2 stud rectangle of street underneath a plaza slab whose top face sits
	at the same height — two coplanar surfaces, which z-fight and read as a
	strip of broken texture at the exact point every player enters the square.

	If you move the plaza, move this with it.
]]
Town.Street = {
	halfWidth = 22,
	fromZ = -505,
	toZ = -598,
}

Town.Plaza = {
	center = V(0, 0, -660),
	width = 176,
	depth = 124,
}

Town.Fountain = V(0, 0, -660)

--- Where a player entering town from the courtyard arrives.
Town.Entrance = V(0, 0, -512)

--[[
	The Colosseum, west of the walls.

	Outside the town rather than inside it, for two reasons: there is no room
	left in the plaza, and an arena you have to walk out to reach is an arena
	you have decided to go to. Reached through a gap in the west wall and a
	short causeway.
]]
Town.Colosseum = {
	center = V(-280, 0, -640),
	--- Fighting floor.
	radius = 62,
	--- Where the west wall opens, and the causeway that leads there.
	gateZ = -640,
	gateWidth = 34,
	causewayFrom = -160,
	causewayTo = -218,
}

--[[
	Buildings.

	`door` is the outward normal of the doorway — the direction you walk *out*
	of the building. Interiors are hollow with a real floor, so an NPC standing
	at a counter is standing in a room, not inside a solid block.
]]
Town.Buildings = {
	{
		id = "smithy",
		name = "The Ashen Anvil",
		sign = "SMITHY",
		kind = "shop",
		center = V(-56, 0, -548),
		size = V(30, 15.5, 27),
		door = V(1, 0, 0),
		roof = "gable",
		accent = Color3.fromRGB(178, 96, 58),
		--- Where the shopkeeper stands, relative to the building centre.
		counter = V(-8, 0, 0),
	},
	{
		id = "apothecary",
		name = "The Green Vial",
		sign = "APOTHECARY",
		kind = "shop",
		center = V(56, 0, -548),
		size = V(28, 15.5, 25),
		door = V(-1, 0, 0),
		roof = "gable",
		accent = Color3.fromRGB(96, 158, 92),
		counter = V(8, 0, 0),
	},
	{
		id = "general",
		name = "Odds & Sundries",
		sign = "GENERAL GOODS",
		kind = "shop",
		center = V(-72, 0, -662),
		size = V(29, 15.5, 27),
		door = V(1, 0, 0),
		roof = "gable",
		accent = Color3.fromRGB(148, 132, 88),
		counter = V(-8, 0, 0),
	},
	{
		id = "guild",
		name = "Adventurers' Guild",
		sign = "GUILD",
		kind = "quest",
		center = V(86, 0, -658),
		size = V(34, 22.5, 31),
		door = V(-1, 0, 0),
		roof = "hip",
		accent = Color3.fromRGB(62, 120, 170),
		counter = V(10, 0, 0),
	},
	{
		id = "tavern",
		name = "The Fireside",
		sign = "TAVERN",
		kind = "rest",
		center = V(0, 0, -718),
		size = V(39, 15.5, 26),
		door = V(0, 0, 1),
		roof = "gable",
		accent = Color3.fromRGB(186, 128, 64),
		counter = V(0, 0, -8),
	},
}

--- Purely decorative housing, so the plaza has a town around it rather than
--- five shops in a field. `tint` indexes the roof palette in Town.server;
--- they are shuffled rather than sequential so no two neighbours match.
Town.Houses = {
	{ at = V(-104, 0, -556), size = V(19.8, 11.0, 18.5), tint = 1 },
	{ at = V(102, 0, -560), size = V(18.5, 12.5, 17.2), tint = 2 },
	{ at = V(-108, 0, -604), size = V(17.2, 9.9, 19.8), tint = 3 },
	{ at = V(106, 0, -608), size = V(19.8, 12.1, 18.5), tint = 4 },
	{ at = V(-122, 0, -700), size = V(21.1, 11.0, 19.8), tint = 5 },
	{ at = V(120, 0, -704), size = V(19.8, 12.5, 18.5), tint = 2 },
	{ at = V(-64, 0, -742), size = V(18.5, 12.1, 17.2), tint = 4 },
	{ at = V(66, 0, -746), size = V(19.8, 11.0, 18.5), tint = 1 },
	{ at = V(-138, 0, -560), size = V(17.2, 9.9, 17.2), tint = 3 },
	{ at = V(140, 0, -636), size = V(18.5, 11.0, 17.2), tint = 5 },

	--[[
		The back streets.

		Rendering the town from above showed the walls enclosing far more
		ground than the town filled — a ring of buildings around the plaza and
		then bare grass out to the battlements. Empty space inside a wall reads
		as an unfinished plot, and it was the single biggest thing making the
		place look like a prototype. These fill the corners.
	]]
	{ at = V(-130, 0, -748), size = V(19.8, 12.1, 18.5), tint = 2 },
	{ at = V(36, 0, -752), size = V(18.5, 12.5, 15.8), tint = 4 },
	{ at = V(108, 0, -748), size = V(19.8, 11.0, 17.2), tint = 1 },
	{ at = V(-142, 0, -604), size = V(15.8, 11.0, 15.8), tint = 5 },
	{ at = V(144, 0, -676), size = V(15.8, 12.1, 15.8), tint = 3 },
	{ at = V(-140, 0, -534), size = V(17.2, 12.5, 17.2), tint = 1 },
	{ at = V(136, 0, -534), size = V(18.5, 12.1, 15.8), tint = 2 },
	{ at = V(-20, 0, -754), size = V(22.4, 11.0, 14.5), tint = 4 },
}

--[[
	Market stalls ring the fountain. Angle in degrees, radius from the centre.

	The ring is not uniform, and can't be: at a constant 52 studs the west
	stall clipped the general store and the south-east one stood *inside* the
	tavern. Radii are pulled per-stall to clear the buildings that face onto
	the plaza while staying outside the fountain's 15-stud base.
]]
Town.Stalls = {
	{ angle = 30, radius = 40, canopy = Color3.fromRGB(178, 88, 76), label = "Produce" },
	{ angle = 95, radius = 42, canopy = Color3.fromRGB(84, 132, 168), label = "Cloth" },
	{ angle = 160, radius = 40, canopy = Color3.fromRGB(156, 140, 78), label = "Fish" },
	{ angle = 228, radius = 38, canopy = Color3.fromRGB(126, 96, 152), label = "Charms" },
	{ angle = 300, radius = 36, canopy = Color3.fromRGB(96, 148, 104), label = "Herbs" },
}

--- Street lanterns. They light when DayNight says it's dark, which is the
--- cheapest possible way to make a town feel inhabited after dusk.
Town.Lanterns = {
	V(-28, 0, -520),
	V(28, 0, -520),
	V(-28, 0, -566),
	V(28, 0, -566),
	V(-28, 0, -604),
	V(28, 0, -604),
	V(-62, 0, -628),
	V(62, 0, -628),
	V(-62, 0, -696),
	V(62, 0, -696),
	V(-30, 0, -700),
	V(30, 0, -700),
	-- Back streets.
	V(-108, 0, -718),
	V(96, 0, -722),
	V(-118, 0, -580),
	V(118, 0, -580),
	V(-96, 0, -762),
	--- Was at z = -770, which is the north wall's own centre line: the lantern
	--- stood buried inside a 5-stud-thick stone panel. Pulled south to clear
	--- both the wall and the house at (66, -746).
	V(70, 0, -760),
}

--------------------------------------------------------------------------------
-- Residents
--------------------------------------------------------------------------------

--[[
	Each NPC declares a palette, a role and a day.

	Work positions sit *behind* the counter, not on it: the counter is a real
	collidable part, and an NPC whose schedule names the same point as their
	shop fixture stands waist-deep in it.

	Home positions name the doorstep, not the house — the house is a solid
	block. Town.server hides a resident once they arrive somewhere marked
	"home", which is what turns walking off the street into going indoors.

	`role` drives what pressing G does:
	    shop   opens their stock
	    quest  offers whatever quest they own that you qualify for
	    talk   dialogue only
	    heal   restores you to full, free, once per visit to town

	Dialogue is keyed by time band so the same character says different things
	at dusk than at dawn. It costs nothing and it is most of what makes a town
	read as alive.
]]
Town.Npcs = {
	{
		id = "rowan",
		name = "Rowan",
		title = "Blacksmith",
		role = "shop",
		shop = "smithy",
		cloth = Color3.fromRGB(92, 62, 48),
		skin = Color3.fromRGB(198, 152, 118),
		hair = Color3.fromRGB(58, 42, 34),
		schedule = {
			{ from = 6, at = V(-67, 0, -548), facing = V(1, 0, 0), state = "work" },
			{ from = 19, at = V(-104, 0, -540), facing = V(0, 0, 1), state = "home" },
		},
		lines = {
			day = {
				"Steel's hot. What do you need?",
				"Rampant Forest chews through blades. Come back when yours starts singing wrong.",
			},
			dusk = { "Forge is banked for the night. Buy quick." },
			night = { "It's late. Whatever you want, it'll be sharper in the morning." },
		},
	},
	{
		id = "maribel",
		name = "Maribel",
		title = "Apothecary",
		role = "shop",
		shop = "apothecary",
		cloth = Color3.fromRGB(72, 108, 78),
		skin = Color3.fromRGB(224, 186, 156),
		hair = Color3.fromRGB(116, 84, 54),
		schedule = {
			{ from = 7, at = V(67, 0, -548), facing = V(-1, 0, 0), state = "work" },
			{ from = 12, at = V(40, 0, -646), facing = V(-1, 0, 0), state = "market" },
			{ from = 14, at = V(67, 0, -548), facing = V(-1, 0, 0), state = "work" },
			{ from = 20, at = V(102, 0, -545), facing = V(0, 0, 1), state = "home" },
		},
		lines = {
			day = {
				"Bleeding, poisoned, or just tired? I have something for two of those.",
				"The bunny out on the road — whatever people tell you, it's faster than that.",
			},
			dusk = { "Take a tonic before you go back out. Please." },
			night = { "You should be asleep. So should I." },
		},
	},
	{
		id = "gus",
		name = "Old Gus",
		title = "Shopkeep",
		role = "shop",
		shop = "general",
		cloth = Color3.fromRGB(112, 100, 72),
		skin = Color3.fromRGB(206, 168, 136),
		hair = Color3.fromRGB(200, 198, 192),
		schedule = {
			{ from = 6, at = V(-83, 0, -662), facing = V(1, 0, 0), state = "work" },
			{ from = 21, at = V(-122, 0, -683), facing = V(0, 0, 1), state = "home" },
		},
		lines = {
			day = {
				"Rope, torches, rations. Boring saves lives.",
				"Everyone wants a sword. Nobody wants a bandage until they want it a lot.",
			},
			dusk = { "Closing up soon. Say the word." },
			night = { "Shop's shut. Emergency only, and this doesn't look like one." },
		},
	},
	{
		id = "ferrel",
		name = "Ferrel",
		title = "Guildmaster",
		role = "quest",
		cloth = Color3.fromRGB(48, 72, 108),
		skin = Color3.fromRGB(188, 148, 122),
		hair = Color3.fromRGB(48, 48, 56),
		schedule = {
			{ from = 5, at = V(99, 0, -658), facing = V(-1, 0, 0), state = "work" },
			{ from = 23, at = V(99, 0, -658), facing = V(-1, 0, 0), state = "work" },
		},
		lines = {
			day = {
				"The board's full and the volunteers aren't. Read it anyway.",
				"Everything past the gate is rated Rampant for a reason.",
			},
			dusk = { "Night contracts pay more. There's a reason for that too." },
			night = { "Still here. Someone has to be." },
		},
	},
	{
		id = "iska",
		name = "Iska",
		title = "Tavernkeeper",
		role = "rest",
		cloth = Color3.fromRGB(136, 88, 56),
		skin = Color3.fromRGB(214, 172, 140),
		hair = Color3.fromRGB(150, 96, 54),
		schedule = {
			{ from = 10, at = V(0, 0, -728), facing = V(0, 0, 1), state = "work" },
			{ from = 3, at = V(0, 0, -728), facing = V(0, 0, 1), state = "work" },
		},
		lines = {
			day = {
				"Sit. Eat. The forest will still be trying to kill you in an hour.",
				"You want rumours? Everyone who went north last week is still north.",
			},
			dusk = { "Busiest hour. Find a stool before the guards do." },
			night = { "Fire's still lit. It usually is." },
		},
	},
	{
		id = "vane",
		name = "Sister Vane",
		title = "Of the Waters",
		role = "heal",
		cloth = Color3.fromRGB(206, 206, 214),
		skin = Color3.fromRGB(230, 200, 178),
		hair = Color3.fromRGB(88, 78, 96),
		schedule = {
			{ from = 5, at = V(0, 0, -640), facing = V(0, 0, 1), state = "pray" },
			{ from = 21, at = V(-64, 0, -727), facing = V(0, 0, 1), state = "home" },
		},
		lines = {
			day = { "The water remembers everyone who drank from it. Even you." },
			dusk = { "Rest while the light lasts." },
			night = { "The fountain doesn't sleep. Neither, apparently, do you." },
		},
	},
	{
		id = "kirian",
		name = "Kirian",
		title = "Gate Guard",
		role = "talk",
		cloth = Color3.fromRGB(70, 78, 96),
		skin = Color3.fromRGB(196, 156, 126),
		hair = Color3.fromRGB(72, 62, 48),
		--- Patrols: gate, street, plaza, back again.
		schedule = {
			{ from = 6, at = V(-14, 0, -512), facing = V(0, 0, 1), state = "patrol" },
			{ from = 10, at = V(-14, 0, -596), facing = V(0, 0, -1), state = "patrol" },
			{ from = 14, at = V(-40, 0, -660), facing = V(1, 0, 0), state = "patrol" },
			{ from = 18, at = V(14, 0, -596), facing = V(0, 0, 1), state = "patrol" },
			{ from = 22, at = V(14, 0, -512), facing = V(0, 0, 1), state = "patrol" },
		},
		lines = {
			day = {
				"Gate's open. Coming back through it is the hard part.",
				"Third party this week that went north. First one that came back.",
			},
			dusk = { "Get inside before full dark. The howling starts early lately." },
			night = { "Nothing gets past me. Nothing that walks, anyway." },
		},
	},
	{
		--[[
			Pencilgon. In the source she is the one who reads the numbers and
			builds the gear, so the crafting bench is hers. She works out of the
			guild rather than a shop of her own, which is also true to her:
			she does not sell anything, she just makes things and tells you
			what is wrong with yours.
		]]
		id = "pencilgon",
		name = "Pencilgon",
		title = "Bench · Crafting",
		role = "craft",
		cloth = Color3.fromRGB(58, 66, 92),
		skin = Color3.fromRGB(232, 204, 182),
		hair = Color3.fromRGB(38, 40, 52),
		schedule = {
			{ from = 8, at = V(96, 0, -650), facing = V(0, 0, 1), state = "work" },
			{ from = 2, at = V(96, 0, -650), facing = V(0, 0, 1), state = "work" },
		},
		lines = {
			day = {
				"Show me the drop. No, the actual drop, not the tooltip.",
				"Everyone wants the boss weapon. Almost nobody keeps the materials for it.",
				"Your build is fine. It is fine. It is not what I would do.",
			},
			dusk = { "Bench is open until it isn't. I lose track." },
			night = { "I'm not still awake. I'm still working. Different thing." },
		},
	},
	{
		--[[
			OiKatzo runs the Colosseum door. A tank in the source, and the one
			who would absolutely be found standing in an arena entrance telling
			people how many waves they can handle.
		]]
		id = "katzo",
		name = "OiKatzo",
		title = "Colosseum · Arena",
		role = "arena",
		cloth = Color3.fromRGB(96, 74, 62),
		skin = Color3.fromRGB(202, 158, 128),
		hair = Color3.fromRGB(52, 44, 38),
		schedule = {
			{ from = 6, at = V(-150, 0, -640), facing = V(-1, 0, 0), state = "work" },
			{ from = 1, at = V(-150, 0, -640), facing = V(-1, 0, 0), state = "work" },
		},
		lines = {
			day = {
				"Five waves. That's the run. You come out or you don't.",
				"Everything in there is real. The rewards are real too, so.",
				"You want my advice? Block more. Everyone dodges. Blocking wins.",
			},
			dusk = { "Night runs hit harder. Pay's the same. Your call." },
			night = { "Still open. I'm always still open." },
		},
	},
	{
		--[[
			Saiga-0. Ranged specialist in the source; here she is the one who
			has been watching the treeline and knows what is in it, which makes
			her the right person to hand out the hunting work.
		]]
		id = "saiga",
		name = "Saiga-0",
		title = "Ranger",
		role = "quest",
		cloth = Color3.fromRGB(64, 84, 72),
		skin = Color3.fromRGB(226, 194, 168),
		hair = Color3.fromRGB(96, 88, 84),
		schedule = {
			{ from = 5, at = V(-34, 0, -612), facing = V(0, 0, -1), state = "watch" },
			{ from = 13, at = V(-14, 0, -524), facing = V(0, 0, 1), state = "watch" },
			{ from = 19, at = V(-34, 0, -612), facing = V(0, 0, -1), state = "watch" },
		},
		lines = {
			day = {
				"I count what goes in and what comes back out. The numbers have been wrong all month.",
				"Something up there is culling. Not hunting — culling. There's a difference and it matters.",
			},
			dusk = { "Dusk is when it moves. Don't be past the treeline at full dark." },
			night = { "Told you. Listen." },
		},
	},
	{
		--[[
			Tull, elder of Rabituza.

			The warren exists so the Vorpal Bunny scenario has a cost. Killing
			the thing on the road is the quest; meeting the people it belonged
			to is what makes it land. He is deliberately not angry with you.
		]]
		id = "tull",
		name = "Elder Tull",
		title = "Of the Warren",
		role = "quest",
		build = "emul",
		cloth = Color3.fromRGB(236, 226, 210),
		skin = Color3.fromRGB(236, 226, 210),
		hair = Color3.fromRGB(206, 196, 182),
		schedule = {
			{ from = 5, at = V(-242, 0, -40), facing = V(0, 0, 1), state = "work" },
			{ from = 20, at = V(-242, 0, -40), facing = V(0, 0, 1), state = "work" },
		},
		lines = {
			day = {
				"You came across the bridge. Most don't. Most don't know we're here.",
				"It was one of ours, out there on the road. Long ago, and it stopped being ours.",
				"We do not ask you to be sorry. We ask you to be careful about what you make of it.",
			},
			dusk = { "The lamps go up at dusk. It is the only hour the whole warren is above ground." },
			night = { "We are nocturnal. You are the one who should be asleep." },
		},
	},
	{
		id = "nenne",
		name = "Nenne",
		title = "Warren Provisioner",
		role = "shop",
		shop = "warren",
		build = "emul",
		cloth = Color3.fromRGB(250, 244, 232),
		skin = Color3.fromRGB(250, 244, 232),
		hair = Color3.fromRGB(240, 178, 190),
		schedule = {
			{ from = 6, at = V(-268, 0, -74), facing = V(1, 0, 0), state = "work" },
			{ from = 22, at = V(-268, 0, -74), facing = V(1, 0, 0), state = "work" },
		},
		lines = {
			day = {
				"Root cake? Root cake. Everyone says no and everyone comes back.",
				"We grow better than we fight. That is not a complaint.",
			},
			dusk = { "Take two. You are walking back through the forest in the dark." },
			night = { "Busiest hour of my day. Sit down." },
		},
	},
	{
		id = "bram",
		name = "Bram",
		title = "Bridgewarden",
		role = "talk",
		build = "emul",
		cloth = Color3.fromRGB(228, 216, 198),
		skin = Color3.fromRGB(228, 216, 198),
		hair = Color3.fromRGB(180, 150, 120),
		schedule = {
			{ from = 6, at = V(-196, 0, -60), facing = V(1, 0, 0), state = "watch" },
			{ from = 21, at = V(-214, 0, -46), facing = V(0, 0, 1), state = "home" },
		},
		lines = {
			day = {
				"Bridge holds. I check it twice a day and it has never not held.",
				"You are the fourth human this season. The other three were also polite. It's a good record.",
			},
			dusk = { "Crossing back? Mind the third plank." },
			night = { "Nothing crosses at night that I would let cross." },
		},
	},
	{
		--[[
			Emul, from the first Unique Scenario.

			The only resident not built from the human figure — `build` names a
			model out of the Mobs catalogue instead. She is in town rather than
			out in the forest because a companion you meet once during a boss
			fight is a cutscene; a companion who is reliably sitting by the
			fountain is a character.
		]]
		id = "emul",
		name = "Emul",
		title = "Rabbiteer",
		role = "talk",
		build = "emul",
		cloth = Color3.fromRGB(250, 244, 232),
		skin = Color3.fromRGB(250, 244, 232),
		hair = Color3.fromRGB(236, 226, 210),
		schedule = {
			{ from = 6, at = V(22, 0, -636), facing = V(-1, 0, 0), state = "play" },
			{ from = 11, at = V(-18, 0, -628), facing = V(0, 0, 1), state = "play" },
			{ from = 16, at = V(8, 0, -690), facing = V(0, 0, 1), state = "play" },
			{ from = 21, at = V(0, 0, -692), facing = V(0, 0, -1), state = "play" },
		},
		lines = {
			day = {
				"Emul! That's me. I'm a Rabbiteer, which is a real thing, whatever the guild says.",
				"You went north? Past the road? And you're still — oh. Oh, you did fight it.",
				"Everyone in there talks about levels. Nobody talks about whether the forest is *nice*.",
			},
			dusk = { "The lanterns go on soon. That's my favourite part of the day, and I'm not embarrassed." },
			night = { "I don't sleep much. Rabbit thing. Probably." },
		},
	},
	{
		id = "pell",
		name = "Pell",
		title = "Local Menace",
		role = "talk",
		cloth = Color3.fromRGB(158, 92, 108),
		skin = Color3.fromRGB(228, 190, 160),
		hair = Color3.fromRGB(90, 62, 40),
		scale = 0.7,
		schedule = {
			{ from = 7, at = V(-28, 0, -668), facing = V(1, 0, 0), state = "play" },
			{ from = 11, at = V(34, 0, -676), facing = V(-1, 0, 0), state = "play" },
			{ from = 15, at = V(-40, 0, -676), facing = V(0, 0, 1), state = "play" },
			{ from = 19, at = V(66, 0, -730), facing = V(0, 0, 1), state = "home" },
		},
		lines = {
			day = {
				"Are you a real adventurer? You don't look like one.",
				"I'm gonna fight the bunny when I'm twelve. Everyone says no. Everyone's wrong.",
			},
			dusk = { "I'm not going in yet. You can't make me." },
			night = { "...don't tell my mum." },
		},
	},
}

--------------------------------------------------------------------------------
-- Shops
--------------------------------------------------------------------------------

--[[
	Stock.

	Prices are in gold, which drops from everything that dies. The curve is
	deliberately steep at the top: the best thing in each shop should cost more
	than a casual player has, so that clearing a boss is what buys it.

	Items carry the same shape as Config/Items entries, so a purchase can go
	down the existing Loot path with no translation.
]]
Town.Shops = {
	smithy = {
		greeting = "Everything on that wall will hold up out there. Mostly.",
		stock = {
			{
				id = "iron_longsword",
				name = "Iron Longsword",
				rarity = "Common",
				slot = "Weapon",
				price = 180,
				description = "Honest iron. Heavier than the mercenary blade and it lands like it.",
				stats = { { label = "Attack", value = "+21" } },
			},
			{
				id = "forest_edge",
				name = "Forest's Edge",
				rarity = "Uncommon",
				slot = "Weapon",
				price = 460,
				description = "Ground from a blade some other adventurer didn't need any more. "
					.. "Bites deeper into anything that grew in the Rampant.",
				stats = { { label = "Attack", value = "+34" }, { label = "vs Beast", value = "+15%" } },
			},
			{
				id = "guard_plate",
				name = "Gatewatch Plate",
				rarity = "Uncommon",
				slot = "Armor",
				price = 520,
				description = "Standard issue for the wall. Slows you a little. Keeps you a lot.",
				stats = { { label = "Defence", value = "+28" }, { label = "Move", value = "-4%" } },
			},
			{
				id = "parry_gauntlet",
				name = "Counterweight Gauntlet",
				rarity = "Rare",
				slot = "Hands",
				price = 1400,
				description = "Weighted to snap the wrist back after a deflection. "
					.. "Widens the parry window — it does not widen your reflexes.",
				stats = { { label = "Parry Window", value = "+0.03s" }, { label = "Defence", value = "+12" } },
			},
		},
	},
	apothecary = {
		greeting = "Mind the shelves. Half of it bites.",
		stock = {
			{
				id = "tonic_small",
				name = "Field Tonic",
				rarity = "Common",
				slot = "Consumable",
				price = 45,
				stack = true,
				description = "Restores 60 health. Tastes like a hedge.",
				stats = { { label = "Heal", value = "60" } },
			},
			{
				id = "tonic_large",
				name = "Surgeon's Draught",
				rarity = "Uncommon",
				slot = "Consumable",
				price = 130,
				stack = true,
				description = "Restores 180 health and most of your confidence.",
				stats = { { label = "Heal", value = "180" } },
			},
			{
				id = "stamina_brew",
				name = "Runner's Brew",
				rarity = "Uncommon",
				slot = "Consumable",
				price = 110,
				stack = true,
				description = "Stamina back to full, instantly. The cramp comes later.",
				stats = { { label = "Stamina", value = "Full" } },
			},
			{
				id = "nightsight",
				name = "Owlseye Extract",
				rarity = "Rare",
				slot = "Consumable",
				price = 300,
				stack = true,
				description = "Six minutes of seeing in the dark. The Lycagon hunts in it, so you may as well too.",
				stats = { { label = "Duration", value = "6 min" } },
			},
		},
	},
	warren = {
		greeting = "Nothing here will kill anything. Most of it is a vegetable.",
		stock = {
			{
				id = "root_cake",
				name = "Root Cake",
				rarity = "Common",
				slot = "Consumable",
				price = 38,
				stack = true,
				description = "Dense, sweet, and it does not go stale. Restores 90 health.",
				stats = { { label = "Heal", value = "90" } },
			},
			{
				id = "warren_boots",
				name = "Warren Treads",
				rarity = "Uncommon",
				slot = "Feet",
				price = 340,
				description = "Rabbit-made, and they are extremely serious about feet. "
					.. "You will notice on the first hill.",
				stats = { { label = "Move", value = "+9%" }, { label = "Fall", value = "-25%" } },
			},
			{
				id = "listening_charm",
				name = "Long-Ear Charm",
				rarity = "Rare",
				slot = "Trinket",
				price = 760,
				description = "The warren hears things coming. This is how. Widens the window "
					.. "you get between a telegraph and the hit landing.",
				stats = { { label = "Telegraph", value = "+0.05s" }, { label = "Stamina", value = "+15" } },
			},
		},
	},
	general = {
		greeting = "If it isn't sharp and isn't magic, I've got it.",
		stock = {
			{
				id = "rope",
				name = "Coil of Rope",
				rarity = "Common",
				slot = "Tool",
				price = 30,
				stack = true,
				description = "Twenty metres. You will be glad of it exactly once, and enormously.",
			},
			{
				id = "torch",
				name = "Pitch Torch",
				rarity = "Common",
				slot = "Tool",
				price = 18,
				stack = true,
				description = "Burns for an hour. Beasts dislike it, which is not the same as fearing it.",
			},
			{
				id = "map_forest",
				name = "Rampant Forest Survey",
				rarity = "Uncommon",
				slot = "Tool",
				price = 250,
				description = "Somebody walked all of this and drew it. Two thirds of it is still accurate.",
			},
			{
				id = "cloak_travel",
				name = "Traveller's Cloak",
				rarity = "Common",
				slot = "Back",
				price = 90,
				description = "Waterproof, mud-coloured, unremarkable. All three on purpose.",
				stats = { { label = "Defence", value = "+6" } },
			},
		},
	},
}

--[[
	What the town will buy back, and for how much. Materials come off corpses.

	The spread between trash and boss drops is deliberate and large. Tuned
	flatter — goblin parts at 22/35/60 against a 140 fang — forty goblins paid
	out more than the Vorpal Bunny did, which quietly makes grinding the
	correct play and the boss a waste of an afternoon. A boss should be worth
	roughly sixty trash mobs, so the interesting choice and the efficient
	choice are the same one.
]]
Town.SellValues = {
	bunny_fang = 420,
	bunny_pelt = 240,
	goblin_ear = 8,
	goblin_iron = 14,
	wolf_fang = 650,
	beast_hide = 18,
}

Town.Materials = {
	bunny_fang = { name = "Vorpal Fang", rarity = "Rare" },
	bunny_pelt = { name = "Vorpal Pelt", rarity = "Uncommon" },
	goblin_ear = { name = "Goblin Ear", rarity = "Common" },
	goblin_iron = { name = "Crude Iron", rarity = "Common" },
	wolf_fang = { name = "Lycagon Fang", rarity = "Rare" },
	beast_hide = { name = "Beast Hide", rarity = "Common" },
}

--[[
	Fish are materials too.

	Merged in from Config/Fishing rather than restated here, so the river's
	stock list stays the single source of truth for what a fish is called and
	what it is worth. Restating them would guarantee the two drift, and the
	symptom would be a fish you can catch and then cannot sell.
]]
for _, fish in Fishing.Stock do
	Town.Materials[fish.id] = { name = fish.name, rarity = fish.rarity }
	Town.SellValues[fish.id] = fish.value
end

--------------------------------------------------------------------------------
-- Quests
--------------------------------------------------------------------------------

--[[
	Quests are a flat list with a prerequisite chain, not a tree.

	`objective.kind`:
	    kill     count of a mob id ("goblin", "vorpal_bunny", "lycagon", ...)
	    collect  count of a material id
	    visit    reach a named place

	Rewards are gold + xp, and optionally an item and a title. A quest that
	only pays numbers is a chore; the ones that end in a title are the ones
	people will actually remember finishing.
]]
Town.Quests = {
	{
		id = "thin_the_woods",
		giver = "ferrel",
		name = "Thin the Woods",
		summary = "Goblins have pushed to within sight of the wall. Cull five.",
		accept = "Five is enough to make the rest reconsider. Go.",
		progress = "They're thickest where the road bends. Keep going.",
		complete = "Good. The wall sleeps easier. So will you.",
		objective = { kind = "kill", target = "goblin", count = 5 },
		reward = { gold = 220, xp = 180 },
	},
	{
		id = "crude_iron",
		giver = "rowan",
		name = "Salvage Run",
		summary = "Goblin iron is filthy, but it's iron. Bring Rowan four pieces.",
		accept = "Four. Don't bring me three and an apology.",
		progress = "Still short. They carry it, so you know where to look.",
		complete = "That'll melt down fine. Here — and the discount's real.",
		objective = { kind = "collect", target = "goblin_iron", count = 4 },
		reward = { gold = 160, xp = 140 },
		requires = "thin_the_woods",
	},
	{
		id = "bunny_season",
		giver = "iska",
		name = "Whatever Is On The Road",
		summary = "Every party that went north stopped coming back. Find out what is doing it, and end it.",
		accept = "You're the fourth to say yes. I'll keep a stool warm and I won't promise anything.",
		progress = "Still out there, then. So are you. That's already better than the others.",
		complete = "A rabbit. It was a rabbit. Drinks are on the house and I need one.",
		objective = { kind = "kill", target = "vorpal_bunny", count = 1 },
		reward = {
			gold = 900,
			xp = 850,
			title = "bunny_slayer",
			item = {
				id = "fang_dagger",
				name = "Fangbreaker",
				rarity = "Rare",
				slot = "Weapon",
				description = "Wound from the tooth that killed everyone else who tried. Light, mean, and very fast.",
				stats = { { label = "Attack", value = "+38" }, { label = "Recovery", value = "Fast" } },
			},
		},
	},
	{
		id = "across_the_water",
		giver = "tull",
		name = "Across The Water",
		summary = "Tull asks you to see the warren properly before you decide what the thing on the road was. "
			.. "Bring him four beast hides — the warren does not hunt, and winter is winter.",
		accept = "Four. And walk the whole village on your way out. That is the real errand.",
		progress = "No hurry. We have been here a long time.",
		complete = "Now you have met us. Whatever you make of the rest is yours to make.",
		objective = { kind = "collect", target = "beast_hide", count = 4 },
		reward = { gold = 420, xp = 380, title = "warren_friend" },
		requires = "bunny_season",
	},
	{
		id = "night_howl",
		giver = "ferrel",
		name = "What Howls At Night",
		summary = "Something hunts the treeline after dark and the patrols won't go out. Kill the Lycagon.",
		accept = "Night contract. Double pay, and I'd still rather you didn't.",
		progress = "It only comes out in the dark. Yes, that is the problem.",
		complete = "Then the patrols go back out tomorrow. That's worth more than the purse.",
		objective = { kind = "kill", target = "lycagon", count = 1 },
		reward = { gold = 1500, xp = 1400, title = "night_hunter" },
		requires = "bunny_season",
	},
	{
		id = "the_count",
		giver = "saiga",
		name = "What The Numbers Say",
		summary = "Saiga has been counting what comes back out of the forest. Clear twelve goblins so she can "
			.. "see whether the treeline is being culled or just hunted.",
		accept = "Twelve. And count them yourself — I want your number, not the guild's.",
		progress = "Keep going. I need the sample, not an anecdote.",
		complete = "Twelve, and the pressure didn't drop. So it isn't the goblins. I thought so.",
		objective = { kind = "kill", target = "goblin", count = 12 },
		reward = { gold = 380, xp = 340 },
		requires = "thin_the_woods",
	},
	{
		id = "prove_it",
		giver = "katzo",
		name = "Five Waves",
		summary = "OiKatzo will not take you seriously until you have cleared a full Colosseum run.",
		accept = "Five waves. Come back when it's done and I'll believe you.",
		progress = "You haven't finished a run. I'd know.",
		complete = "Right. Okay. You're not a tourist. Take it.",
		objective = { kind = "kill", target = "colosseum_run", count = 1 },
		reward = { gold = 700, xp = 620, title = "arena_regular" },
	},
	{
		id = "eel_skins",
		giver = "maribel",
		name = "Three Eels, No Questions",
		summary = "Maribel wants silt eel skin and will not say what for. "
			.. "There are jetties on the river south of the gate.",
		accept = "Three. Alive is better but I accept that is optimistic.",
		progress = "Still short. Strike a bit later than feels right — they always take it deep.",
		complete = "Perfect. No, I'm still not telling you. Here.",
		objective = { kind = "collect", target = "silt_eel", count = 3 },
		reward = { gold = 260, xp = 220 },
	},
	{
		--[[
			Emul's quest, and the one that points at Rabituza.

			She is the only character who has been on both sides of the Vorpal
			Bunny scenario, so she is the right one to ask you to go and look.
			It costs nothing but a walk, which is the point: the reward is that
			you have seen the place.
		]]
		id = "emuls_errand",
		giver = "emul",
		name = "Would You Go And Look",
		summary = "Emul cannot cross the bridge herself — she will not say why. The river below it holds "
			.. "trout that only rise after dark. Bring her two, and she will know you really went.",
		accept = "Two. At night, off the bridge itself. That is the only place they run.",
		progress = "After dark. I did say. They will not come up for you in daylight.",
		complete = "...that is the river. That is exactly the river. Thank you. Genuinely, thank you.",
		--- A material, not a shop item: collect objectives read the material bag,
		--- so a purchasable item as a target would never have ticked over.
		objective = { kind = "collect", target = "moon_trout", count = 2 },
		reward = { gold = 300, xp = 280, title = "river_crosser" },
		requires = "bunny_season",
	},
	{
		id = "the_tombguard",
		giver = "ferrel",
		name = "What Is In The Hill",
		summary = "There is a stair cut into a hill east of the road and nobody who walks down it walks back. "
			.. "The guild wants to know what is at the bottom. Kill the Tombguard.",
		accept = "East, past the treeline, look for two broken statues. Take a light.",
		progress = "Still standing, then. Four centuries is four centuries.",
		complete = "Then the tomb is a tomb again, and not a mouth. Take it — you earned every coin.",
		objective = { kind = "kill", target = "wethermon", count = 1 },
		reward = { gold = 1700, xp = 1600, title = "tomb_breaker" },
		requires = "night_howl",
	},
	{
		id = "cut_the_strings",
		giver = "pencilgon",
		name = "Cut The Strings",
		summary = "Something west of the river is making things that walk. Pencilgon has taken one apart "
			.. "and does not like the joinery. Find whatever is holding them up and end it.",
		accept = "The wood is wrong. It's from here, but it's *wrong*. Go and find out why.",
		progress = "Kill the puppets first. I shouldn't have to say that. I'm saying it.",
		complete = "So it was a person. I'd rather it hadn't been. Take the payment and the recipe.",
		objective = { kind = "kill", target = "setsuna", count = 1 },
		reward = { gold = 2200, xp = 2100, title = "string_cutter" },
		requires = "the_tombguard",
	},
	{
		id = "herbwalk",
		giver = "maribel",
		name = "Nothing Dangerous",
		summary = "Maribel needs beast hide for bandage backing. Six pieces, from anything with a hide.",
		accept = "Six. And if something starts winning, come back without them.",
		progress = "Anything with fur, really. I'm not precious about it.",
		complete = "That's a month of bandages. Take a draught — no, take two.",
		objective = { kind = "collect", target = "beast_hide", count = 6 },
		reward = { gold = 240, xp = 200 },
	},
}

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

function Town.findBuilding(id: string?)
	for _, building in Town.Buildings do
		if building.id == id then
			return building
		end
	end
	return nil
end

function Town.findNpc(id: string?)
	for _, npc in Town.Npcs do
		if npc.id == id then
			return npc
		end
	end
	return nil
end

function Town.findQuest(id: string?)
	for _, quest in Town.Quests do
		if quest.id == id then
			return quest
		end
	end
	return nil
end

function Town.questsFrom(npcId: string)
	local list = {}
	for _, quest in Town.Quests do
		if quest.giver == npcId then
			table.insert(list, quest)
		end
	end
	return list
end

--- Which scheduled entry is active at `hour`. Entries are read as "from this
--- hour until the next one", wrapping at midnight, so a schedule needs no
--- explicit end times and cannot leave a gap.
function Town.scheduleAt(npc, hour: number)
	local best, bestDistance = npc.schedule[1], math.huge

	for _, entry in npc.schedule do
		-- How long ago this entry started, going backwards around the clock.
		local since = (hour - entry.from) % 24
		if since < bestDistance then
			best, bestDistance = entry, since
		end
	end

	return best
end

--- Which dialogue band an hour falls in.
function Town.band(hour: number): string
	if hour >= 5 and hour < 17 then
		return "day"
	elseif hour >= 17 and hour < 20.5 then
		return "dusk"
	end
	return "night"
end

return Town
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="33">
          <Properties>
            <string name="Name">Tutorial</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Tutorial — the guided start in Firstia's training yard.

	Written as dialogue with objectives attached, not a queue of pop-ups. Every
	mechanic is introduced by someone with an opinion about it, and every step
	is completed by *doing the thing* — never by clicking "Next".

	The instructor is Sergeant Balka, who has taught this course too many times
	and has stopped pretending to enjoy it. Her impatience does real work: it
	keeps the copy short, and it gives the player permission to skip ahead by
	simply performing the action before she finishes explaining it.

	Each step:
	    id        stable key
	    say       what the instructor says when it begins
	    objective the line shown in the HUD tracker
	    goal      { kind, count } — how the client detects completion
	    done      what she says when you finish it
	    grant     what the instructor hands you as she teaches it
	    drill     a live exercise the server runs for this step

	GRANTS EXIST BECAUSE A LESSON CANNOT TEACH A TOOL YOU DO NOT HAVE. A fresh
	character owns no skills and has an empty loadout, so "dash with Q" and
	"parry with F" were both uncompletable: Q was unassigned and F was refused
	at rank 0. She issues them at the moment she explains them, which is also
	how it should have read narratively — an instructor equips her students.

	`kind` values are watched by Controllers/Tutorial:
	    move sprint jump attack dodge block parry skill menu equip dummy enemy
]]

local Tutorial = {}

Tutorial.Instructor = {
	name = "Sergeant Balka",
	title = "Firstia Watch",
}

Tutorial.Steps = {
	{
		id = "greet",
		say = "Another one. Right — eyes on me. I'll say each of these once, "
			.. "and the yard will do the rest of the teaching.",
		objective = "Speak with Sergeant Balka",
		goal = { kind = "talk", count = 1 },
		done = "Good. You can hear. That puts you ahead of most.",
	},
	{
		id = "move",
		say = "Walk. W, A, S, D. The camera follows your mouse — look where you're going, " .. "not where you were.",
		objective = "Walk around the yard",
		goal = { kind = "move", count = 24 },
		done = "That's walking. It won't save you.",
	},
	{
		id = "sprint",
		say = "Hold Shift while you move and you'll run. It burns stamina — the green bar. "
			.. "Empty it and you'll be standing still exactly when you'd rather not be.",
		objective = "Sprint until your stamina drops",
		goal = { kind = "sprint", count = 2.2 },
		done = "Feel that drain? Remember it. Stamina is the only resource that kills you.",
	},
	{
		id = "jump",
		say = "Space to jump. Over things, off things. Nothing clever.",
		objective = "Jump twice",
		goal = { kind = "jump", count = 2 },
		done = "Fine.",
	},
	{
		id = "attack",
		say = "Dummy's in front of you. Left mouse swings. Keep pressing and you'll chain — "
			.. "four hits, and the last one hits hardest and leaves you wide open. "
			.. "Learn where that is.",
		objective = "Land 6 hits on the training dummy",
		goal = { kind = "dummy", count = 6 },
		done = "Sloppy, but it fell over. Chain's the point — the fourth hit is a commitment.",
	},
	{
		id = "dodge",
		say = "Q is a dash. Short, fast, and for a fraction of a second nothing can touch you. "
			.. "That fraction is the whole skill.",
		objective = "Dash three times",
		goal = { kind = "dodge", count = 3 },
		-- Issued here: an empty Q slot cannot teach dashing.
		grant = { skill = "step", slot = 1 },
		done = "Good. Dashing away is instinct. Dashing *through* is training.",
	},
	{
		id = "block",
		say = "Hold C to raise your guard. It stops most of a hit and costs you stamina. "
			.. "It's the answer when you don't know the answer.",
		objective = "Hold your guard",
		goal = { kind = "block", count = 1.5 },
		done = "Blocking keeps you alive. It doesn't win anything.",
	},
	{
		id = "parry",
		say = "This is the one that matters. F — not held, tapped, and tapped LATE. "
			.. "You want their weapon already moving. Get it right and you cancel the attack "
			.. "outright and they'll be too off-balance to stop what you do next.",
		objective = "Parry the instructor's attack",
		goal = { kind = "parry", count = 1 },
		-- Rank 1 is taught, not bought. Without it the server refuses F.
		grant = { parryRank = 1 },
		-- And she actually swings at you, or there is nothing to parry.
		drill = "parry",
		done = "THAT. That's the difference between surviving out there and not. "
			.. "It'll get wider with practice — check your skills.",
	},
	{
		id = "skills",
		say = "Q, E and R hold whatever you've learned. Open your menu with B — skills, "
			.. "your bag, your gear, and what you're wearing. It's all one screen.",
		objective = "Open your menu with B",
		goal = { kind = "menu", count = 1 },
		done = "Spend your points when you have them. Hoarding them has never saved anyone.",
	},
	{
		id = "enemy",
		say = "Last thing. There's a straw-man in the pen that hits back — badly, but it hits. "
			.. "Put it down using everything I've told you.",
		objective = "Defeat the practice enemy",
		goal = { kind = "enemy", count = 1 },
		done = "Right. You're not ready, but nobody is. Gate's behind me. Try not to die on the road.",
	},
} :: { any }

--- Awarded on completion, so the guided path isn't strictly worse than
--- Traveler's head start.
Tutorial.Reward = {
	experience = 120,
	skillPoints = 1,
	--- Already granted during the parry lesson; kept here so a player who
	--- somehow skips that step still leaves able to defend themselves.
	parryRank = 1,
	title = "kusoge_hunter",
}

function Tutorial.stepCount(): number
	return #Tutorial.Steps
end

return Tutorial
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="34">
          <Properties>
            <string name="Name">World</string>
            <string name="Source"><![CDATA[--!strict
--[[
	World — the map's fixed geography.

	Shared so the server generator, the spawner and the client's teleport
	commands all agree on where things are, instead of each hardcoding
	coordinates that drift apart.

	Region: the **Rampant Forest**, the beginners' area Sunraku starts in,
	bordering the town of **Firstia** to the north. Bright green woodland,
	crossed by one road.

	Scale note: a Roblox character is ~5 studs, so a stud is roughly a third of
	a metre. The forest is 600 x 440 studs, which is about 200 x 145 metres.
]]

local World = {}

--- Changing this regenerates a different but equally deterministic forest.
World.Seed = 20231001

--------------------------------------------------------------------------------
-- Rampant Forest
--------------------------------------------------------------------------------

World.Forest = {
	MinX = -300,
	MaxX = 300,
	--[[
		Negative Z runs north, toward Firstia.

		MinZ has to reach the courtyard's south wall (z = -355), not stop short
		of it. It used to end at -320, which left a 35-stud strip of nothing
		between the last of the forest floor and the town gate — the player
		walked north up the road and fell out of the world. The courtyard
		flattening in groundHeight already takes the terrain to exactly y = 0
		by the time it arrives, so extending it just closes the seam and gives
		the gate a treeline instead of a cliff edge.
	]]
	MinZ = -360,
	MaxZ = 120,
}

--- The road to Firstia runs north along x = 0. Terrain is flattened inside
--- this half-width so the path is walkable and reads as built, not eroded.
World.RoadHalfWidth = 11

--- Traveler start: on the road in the forest, facing Firstia. No tutorial,
--- and the northern road is not safe.
World.Spawn = Vector3.new(0, 0, 78)

--[[
	Firstia's inner courtyard — where every non-Traveler origin begins.

	The town exterior is a facade seen from the treeline; this is a real,
	enterable space behind the gate with a training yard in it. Ground here is
	flat and authored rather than generated, because a tutorial that teaches
	dodging on a hillside is a tutorial about hillsides.
]]
World.Courtyard = {
	center = Vector3.new(0, 0, -430),
	--- Flat plaza floor height, in world Y.
	floor = 0,
	width = 190,
	depth = 150,
}

--- Guided start: inside the walls, facing the instructor.
World.CourtyardSpawn = Vector3.new(0, 0, -382)
--- Where the training yard sits within the courtyard.
World.TrainingYard = Vector3.new(0, 0, -448)

--- Resolves a spawn position from a chosen origin id.
function World.spawnFor(originId: string?): Vector3
	if originId and originId ~= "traveler" then
		return World.CourtyardSpawn
	end
	return World.Spawn
end

--- Town position. The gate faces the forest.
World.FirstiaPosition = Vector3.new(0, 0, -352)

--- Where the Vorpal Bunny hunts: a clearing on the road north, which is where
--- the scenario says travellers are going missing. Kept clear of trees so the
--- lunge telegraph is never hidden behind a trunk.
World.BunnyArena = Vector3.new(0, 0, -158)
World.BunnyArenaRadius = 58

--[[
	Rabituza — the warren west of the river.

	The Vorpal Bunny's own people, and the reason the scenario is a tragedy
	rather than a monster hunt. Placed across the water on purpose: you have to
	cross a bridge to get there, which makes it somewhere you went rather than
	somewhere you walked past.

	Kept inside Forest.MinX so it stands on generated terrain, and east of the
	river's westmost swing (about x = -166 in this band) so the water never
	runs through it.
]]
World.Rabituza = {
	center = Vector3.new(-242, 0, -60),
	width = 110,
	depth = 130,
}
--- Where the plank bridge crosses, on the line between road and warren.
World.RabituzaBridgeZ = -60

--------------------------------------------------------------------------------
-- Developer gallery, parked well clear of the playable region
--------------------------------------------------------------------------------

World.GalleryCenter = Vector3.new(1200, 0, 0)
World.GalleryView = Vector3.new(1200, 4, 36)
World.GalleryDepth = 44

--------------------------------------------------------------------------------

--- Water surface height. Anything below this is river or pond.
World.WaterLevel = -9

--[[
	The river. A shallow S carved down the map, west of the road, giving the
	forest a landmark, a reason for the ground to fall away, and somewhere for
	the light to reflect off. Flat green everywhere is the strongest possible
	signal that terrain was generated rather than designed.
]]
function World.riverCentre(z: number): number
	return -118 + math.sin(z / 145) * 46 + math.sin(z / 61) * 14
end

--- Distance from the river's centre line at a given z.
function World.riverDistance(x: number, z: number): number
	return math.abs(x - World.riverCentre(z))
end

--- Terrain height at a world position. Deterministic, and the single source of
--- truth: the terrain filler and anything placed on top both call this, so
--- nothing ever floats or sinks.
function World.groundHeight(x: number, z: number): number
	-- Four octaves. One gives rolling putty; four gives ridges, shoulders and
	-- hollows, which is the difference between a golf course and a forest.
	local base = math.noise(x / 210, z / 210, World.Seed) * 34
	local mid = math.noise(x / 96, z / 96, World.Seed + 11) * 13
	local detail = math.noise(x / 41, z / 41, World.Seed + 23) * 4.5
	local grain = math.noise(x / 17, z / 17, World.Seed + 37) * 1.4
	local height = base + mid + detail + grain

	-- Ridge lines: folding the noise makes sharp crests instead of soft mounds.
	local ridge = (1 - math.abs(math.noise(x / 150, z / 150, World.Seed + 5) * 2)) * 16
	height += ridge * 0.5

	-- River valley. The banks fall away steeply, the bed sits under water.
	local river = World.riverDistance(x, z)
	if river < 74 then
		local cut = 1 - math.clamp(river / 74, 0, 1)
		height -= cut * cut * 46
	end

	-- Blend to flat across the road corridor.
	local corridor = math.clamp((math.abs(x) - World.RoadHalfWidth) / 34, 0, 1)
	height *= corridor

	-- Flatten the ground in front of the town gate as well, so the approach
	-- isn't a hillside.
	local toTown = math.abs(z - World.FirstiaPosition.Z)
	if toTown < 90 then
		height *= math.clamp((toTown - 40) / 50, 0, 1)
	end

	-- The bunny arena is levelled so the telegraph is never distorted by slope.
	local arena = Vector3.new(x - World.BunnyArena.X, 0, z - World.BunnyArena.Z).Magnitude
	if arena < World.BunnyArenaRadius then
		height *= math.clamp((arena - World.BunnyArenaRadius * 0.45) / (World.BunnyArenaRadius * 0.55), 0, 1)
	end

	-- The warren clearing, flattened like the courtyard: burrow doorways cut
	-- into a procedural hillside either float or bury themselves.
	local warren = World.Rabituza
	local wx = math.abs(x - warren.center.X) - warren.width / 2
	local wz = math.abs(z - warren.center.Z) - warren.depth / 2
	if wx < 34 and wz < 34 then
		height *= math.clamp(math.max(wx, wz) / 34, 0, 1)
	end

	-- The courtyard is dead flat. A tutorial that teaches dodging on a slope
	-- is a tutorial about slopes.
	local yard = World.Courtyard
	local dx = math.abs(x - yard.center.X) - yard.width / 2
	local dz = math.abs(z - yard.center.Z) - yard.depth / 2
	if dx < 30 and dz < 30 then
		local edge = math.clamp(math.max(dx, dz) / 30, 0, 1)
		height *= edge
	end

	return height
end

function World.isOnRoad(x: number, _z: number): boolean
	return math.abs(x) < World.RoadHalfWidth + 6
end

--- Surface material for a position, from slope and height. Slope-aware
--- materials are most of what stops terrain reading as a green bedsheet.
function World.surfaceMaterial(x: number, z: number): Enum.Material
	local here = World.groundHeight(x, z)

	if here < World.WaterLevel + 2.5 then
		return Enum.Material.Sand
	end

	-- Sample the gradient. Anything steep enough to slide down is rock.
	local dx = World.groundHeight(x + 6, z) - World.groundHeight(x - 6, z)
	local dz = World.groundHeight(x, z + 6) - World.groundHeight(x, z - 6)
	local slope = math.sqrt(dx * dx + dz * dz) / 12

	if slope > 0.85 then
		return Enum.Material.Rock
	elseif slope > 0.5 then
		return Enum.Material.Ground
	end

	if here > 44 then
		return Enum.Material.Slate
	end
	return Enum.Material.Grass
end

--- Too steep or too wet to stand a tree on.
function World.isPlantable(x: number, z: number): boolean
	local here = World.groundHeight(x, z)
	if here < World.WaterLevel + 3 then
		return false
	end
	local dx = World.groundHeight(x + 6, z) - World.groundHeight(x - 6, z)
	local dz = World.groundHeight(x, z + 6) - World.groundHeight(x, z - 6)
	return math.sqrt(dx * dx + dz * dz) / 12 < 0.8
end

return World
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="Folder" referent="35">
        <Properties>
          <string name="Name">Items</string>
        </Properties>
        <Item class="ModuleScript" referent="36">
          <Properties>
            <string name="Name">Weapons</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Weapons — procedural starter weapon models, one per combat style.

	Authored with the **grip at the origin and the weapon extending along +Y**,
	so every one attaches through the same transform. Change that convention
	and every weapon detaches at once, which is the point: one grip to tune,
	not six.

	`build` returns { main = Model, off = Model? } — dual blades and unarmed
	wraps need both hands, everything else only fills the right.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local STEEL = Color3.fromRGB(186, 190, 198)
local STEEL_DARK = Color3.fromRGB(128, 132, 140)
local LEATHER = Color3.fromRGB(78, 58, 44)
local BRONZE = Color3.fromRGB(150, 118, 66)
local WOOD = Color3.fromRGB(96, 72, 50)
local CLOTH = Color3.fromRGB(216, 208, 192)
local ARCANE = Color3.fromRGB(96, 196, 224)

local Weapons = {}

--- Grip, guard and pommel — shared by every bladed weapon.
local function hilt(ctx, gripLength: number, guardWidth: number)
	ctx:cylinder({
		name = "Grip",
		size = V(gripLength, 0.34, 0.34),
		at = V(0, 0, 0),
		rot = V(0, 0, 90),
		color = LEATHER,
		material = Enum.Material.Fabric,
	})
	ctx:block({
		name = "Guard",
		size = V(guardWidth, 0.22, 0.42),
		at = V(0, gripLength / 2 + 0.1, 0),
		color = BRONZE,
		material = Enum.Material.Metal,
	})
	ctx:ball({
		name = "Pommel",
		size = V(0.44, 0.44, 0.44),
		at = V(0, -gripLength / 2 - 0.16, 0),
		color = BRONZE,
		material = Enum.Material.Metal,
	})
end

local function longsword(origin: CFrame): Model
	local ctx = Build.begin("Longsword", origin)
	hilt(ctx, 1.3, 2.2)

	ctx:block({
		name = "Blade",
		size = V(0.46, 6.2, 0.15),
		at = V(0, 3.9, 0),
		color = STEEL,
		material = Enum.Material.Metal,
		reflectance = 0.08,
	})
	ctx:block({
		name = "Fuller",
		size = V(0.14, 5.4, 0.17),
		at = V(0, 3.7, 0),
		color = STEEL_DARK,
		material = Enum.Material.Metal,
	})
	ctx:wedge({
		name = "Tip",
		size = V(0.46, 0.9, 0.15),
		at = V(0, 7.45, 0),
		color = STEEL,
		material = Enum.Material.Metal,
	})

	return ctx:finishRigged()
end

local function greatsword(origin: CFrame): Model
	local ctx = Build.begin("Greatsword", origin)
	hilt(ctx, 2.1, 3.4)

	ctx:block({
		name = "Blade",
		size = V(1.05, 9.6, 0.24),
		at = V(0, 5.9, 0),
		color = STEEL_DARK,
		material = Enum.Material.Metal,
		reflectance = 0.05,
	})
	ctx:block({
		name = "Ricasso",
		size = V(0.6, 1.4, 0.3),
		at = V(0, 1.5, 0),
		color = LEATHER,
		material = Enum.Material.Fabric,
	})
	ctx:wedge({
		name = "Tip",
		size = V(1.05, 1.5, 0.24),
		at = V(0, 11.45, 0),
		color = STEEL_DARK,
		material = Enum.Material.Metal,
	})

	return ctx:finishRigged()
end

local function shortblade(name: string, origin: CFrame): Model
	local ctx = Build.begin(name, origin)
	hilt(ctx, 1.0, 1.3)

	ctx:block({
		name = "Blade",
		size = V(0.34, 3.3, 0.13),
		at = V(0, 2.3, 0),
		color = STEEL,
		material = Enum.Material.Metal,
		reflectance = 0.1,
	})
	ctx:wedge({
		name = "Tip",
		size = V(0.34, 0.7, 0.13),
		at = V(0, 4.3, 0),
		color = STEEL,
		material = Enum.Material.Metal,
	})

	return ctx:finishRigged()
end

local function bow(origin: CFrame): Model
	local ctx = Build.begin("Bow", origin)

	ctx:cylinder({
		name = "Riser",
		size = V(1.5, 0.42, 0.34),
		at = V(0, 0, 0),
		rot = V(0, 0, 90),
		color = LEATHER,
		material = Enum.Material.Fabric,
	})

	-- Limbs, built as short segments stepping outward into a curve.
	for side = -1, 1, 2 do
		for index = 1, 4 do
			local t = index / 4
			ctx:block({
				name = `Limb{side}_{index}`,
				size = V(0.3, 1.35, 0.26),
				at = V(-t * t * 0.9, side * (0.75 + (index - 0.5) * 1.25), 0),
				rot = V(0, 0, side * t * 20),
				color = WOOD,
				material = Enum.Material.Wood,
			})
		end
		ctx:block({
			name = `Nock{side}`,
			size = V(0.22, 0.3, 0.22),
			at = V(-0.95, side * 5.5, 0),
			color = BRONZE,
			material = Enum.Material.Metal,
		})
	end

	ctx:block({
		name = "String",
		size = V(0.06, 11.0, 0.06),
		at = V(-0.9, 0, 0),
		color = CLOTH,
		material = Enum.Material.Fabric,
	})

	return ctx:finishRigged()
end

local function staff(origin: CFrame): Model
	local ctx = Build.begin("Staff", origin)

	ctx:cylinder({
		name = "Haft",
		size = V(9.0, 0.34, 0.34),
		at = V(0, 2.6, 0),
		rot = V(0, 0, 90),
		color = WOOD,
		material = Enum.Material.Wood,
	})
	ctx:cylinder({
		name = "Wrap",
		size = V(1.4, 0.42, 0.42),
		at = V(0, 0, 0),
		rot = V(0, 0, 90),
		color = LEATHER,
		material = Enum.Material.Fabric,
	})

	-- Head: three prongs cradling a floating orb.
	for index = 0, 2 do
		local angle = index * (math.pi * 2 / 3)
		ctx:block({
			name = `Prong{index}`,
			size = V(0.2, 1.6, 0.2),
			at = V(math.cos(angle) * 0.5, 7.3, math.sin(angle) * 0.5),
			rot = V(math.sin(angle) * 16, 0, -math.cos(angle) * 16),
			color = BRONZE,
			material = Enum.Material.Metal,
		})
	end
	ctx:glow({ name = "Orb", size = V(1.1, 1.1, 1.1), at = V(0, 8.3, 0), color = ARCANE })

	return ctx:finishRigged()
end

local function wraps(name: string, origin: CFrame): Model
	local ctx = Build.begin(name, origin)

	for index = 0, 3 do
		ctx:block({
			name = `Wrap{index}`,
			size = V(1.15, 0.3, 1.15),
			at = V(0, -0.15 + index * 0.32, 0),
			rot = V(0, index * 9, 0),
			color = CLOTH,
			material = Enum.Material.Fabric,
		})
	end
	ctx:block({
		name = "Knuckle",
		size = V(1.2, 0.34, 0.36),
		at = V(0, 0.85, -0.42),
		color = LEATHER,
		material = Enum.Material.Fabric,
	})

	return ctx:finishRigged()
end

--- Returns { main, off } for a style id. Unknown styles fall back to wraps, so
--- a bad save can never leave a player empty-handed.
function Weapons.build(styleId: string, origin: CFrame?): { main: Model, off: Model? }
	local at = origin or CFrame.identity

	if styleId == "longsword" then
		return { main = longsword(at) }
	elseif styleId == "greatsword" then
		return { main = greatsword(at) }
	elseif styleId == "dualblades" then
		return { main = shortblade("TwinbladeMain", at), off = shortblade("TwinbladeOff", at) }
	elseif styleId == "bow" then
		return { main = bow(at) }
	elseif styleId == "caster" then
		return { main = staff(at) }
	end

	return { main = wraps("WrapsMain", at), off = wraps("WrapsOff", at) }
end

return Weapons
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="ModuleScript" referent="37">
        <Properties>
          <string name="Name">Mobs</string>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Mobs — the creature registry.

	`width` is the footprint the gallery reserves for each one, and `plateY`
	is where its nameplate floats. Both are authored per creature because they
	differ by an order of magnitude — the Vorpal Bunny is under three studs
	tall and Wethermon is over twelve.
]]

local Mobs = {}

export type Entry = {
	id: string,
	name: string,
	subtitle: string,
	--- Horizontal space to reserve in the gallery line.
	width: number,
	--- Height for the floating nameplate.
	plateY: number,
	--- Accent used for the nameplate rule.
	tone: string,
	build: (origin: CFrame) -> Model,
}

Mobs.Catalog = {
	{
		id = "vorpal_bunny",
		name = "Vorpal Bunny",
		subtitle = "Unique Scenario · the first one that kills you",
		width = 9,
		plateY = 5.4,
		tone = "Unique",
		build = require(script.VorpalBunny),
	},
	{
		id = "goblin",
		name = "Goblin",
		subtitle = "Common · the first thing anyone kills",
		width = 8,
		plateY = 7.4,
		tone = "Success",
		build = require(script.Goblin),
	},
	{
		id = "emul",
		name = "Emul",
		subtitle = "Companion · Rabbiteer",
		width = 9,
		plateY = 5.0,
		tone = "Accent",
		build = require(script.Emul),
	},
	{
		id = "lycagon",
		name = "Lycagon the Nightslayer",
		subtitle = "Unique Scenario · seven forms, seven nights",
		width = 15,
		plateY = 9.6,
		tone = "Unique",
		build = require(script.Lycagon),
	},
	{
		id = "setsuna",
		name = "Setsuna the Puppeteer",
		subtitle = "Unique Scenario · nothing she fights with is her own",
		width = 11,
		plateY = 15.0,
		tone = "Cursed",
		build = require(script.Setsuna),
	},
	{
		id = "wethermon",
		name = "Wethermon the Tombguard",
		subtitle = "Unique Scenario · undefeated for four centuries",
		width = 13,
		plateY = 14.0,
		tone = "Unique",
		build = require(script.Wethermon),
	},
} :: { Entry }

function Mobs.find(id: string): Entry?
	for _, entry in Mobs.Catalog do
		if entry.id == id then
			return entry
		end
	end
	return nil
end

--- Builds a creature at `origin`. Returns nil for an unknown id rather than
--- erroring, so a bad spawner name can't take the server down.
function Mobs.build(id: string, origin: CFrame): Model?
	local entry = Mobs.find(id)
	if not entry then
		warn(`[Mobs] unknown creature "{id}"`)
		return nil
	end
	return entry.build(origin)
end

return Mobs
]]></string>
        </Properties>
        <Item class="ModuleScript" referent="38">
          <Properties>
            <string name="Name">Anim</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Anim — procedural creature animation.

	No keyframes and no imported tracks: every pose is a function of state,
	phase and time, so it retimes *with* the fight instead of fighting it.
	Lengthen a windup and the crouch lengthens with it for free.

	Each function returns `(rootOffset, parts, bodyScale, blendRate)`.

	  rootOffset  moves the whole creature — hop arcs, crouches, skid tilts
	  parts       per-part offsets from the bind pose
	  bodyScale   squash and stretch, applied to sizes AND offsets by Rig
	  blendRate   how hard to ease into this pose. Rig never snaps; this is
	              how a state says "snap anyway" (a dash) or "drift" (breathing)

	SQUASH AND STRETCH is the load-bearing idea. Compress before every hop,
	stretch in the air, squash on landing. It costs nothing, and it is the
	single largest difference between something that reads as an animal and
	something that reads as a model being slid around a field.

	ANTICIPATION AND FOLLOW-THROUGH: nothing starts from neutral and nothing
	ends at neutral. An attack coils backwards before it goes forwards, and
	overshoots before it settles.
]]

local Rig = require(script.Parent.Rig)

local Anim = {}

local V = Vector3.new
local EAR = 2.45 -- bunny ear height, for hinge pivots
local ONE = V(1, 1, 1)

local function bob(t: number, rate: number, amount: number): CFrame
	return CFrame.new(0, math.sin(t * rate) * amount, 0)
end

--- Ears as one call: they almost always move together with a mirrored roll.
local function ears(parts, pitch: number, roll: number)
	parts.EarL = Rig.hinge(EAR, pitch, -roll)
	parts.EarR = Rig.hinge(EAR, pitch, roll)
	parts.EarInnerL = parts.EarL
	parts.EarInnerR = parts.EarR
end

local function paws(parts, offset: CFrame)
	parts.PawL = offset
	parts.PawR = offset
	parts.PawStainL = offset
	parts.PawStainR = offset
end

local function feet(parts, offset: CFrame)
	parts.FootL = offset
	parts.FootR = offset
end

--------------------------------------------------------------------------------
-- Vorpal Bunny
--------------------------------------------------------------------------------

--[[
	`extra` carries live signals the pose needs but the state name can't encode:
	    lookYaw   degrees to turn the head toward the player, independent of
	              which way the body is pointing. Head tracking is most of what
	              makes it feel like it is watching you.
	    frenzy    0-1, scales aggression of the idle motion
]]
function Anim.vorpalBunny(state: string, phase: number, t: number, extra)
	local parts = {}
	local look = (extra and extra.lookYaw) or 0
	local frenzy = (extra and extra.frenzy) or 0
	local track = CFrame.Angles(0, math.rad(math.clamp(look, -62, 62)), 0)

	----------------------------------------------------------------------------
	-- Intro
	----------------------------------------------------------------------------

	if state == "graze" then
		-- Head down, nibbling. Small, harmless, busy.
		local nibble = math.abs(math.sin(t * 7)) * 0.12
		ears(parts, math.sin(t * 1.1) * 5, 3)
		parts.Head = CFrame.new(0, -0.62 - nibble, -0.32)
			* CFrame.Angles(math.rad(38), math.rad(math.sin(t * 0.8) * 10), 0)
		parts.Muzzle = CFrame.new(0, -0.1, 0)
		parts.Nose = bob(t, 12, 0.04)
		parts.Tail = bob(t, 1.6, 0.06)
		return bob(t, 1.7, 0.07), parts, V(1.02, 0.97, 1.02), 0.12
	end

	if state == "alert" then
		-- The head comes up and around. Nothing else moves at all — the
		-- stillness is what makes it land.
		local p = phase ^ 0.6
		ears(parts, 14 * p, 2)
		parts.Head = CFrame.new(0, -0.62 * (1 - p), -0.32 * (1 - p))
			* CFrame.Angles(math.rad(38 * (1 - p)), math.rad(look * p), 0)
		return CFrame.identity, parts, ONE:Lerp(V(1, 1.04, 1), p), 0.09
	end

	if state == "glare" then
		-- Locked on. Only the ears and breath move.
		local breathe = math.sin(t * 2.6)
		ears(parts, -8 + breathe * 2, 5)
		parts.Head = track
		parts.Chest = bob(t, 2.6, 0.05)
		return bob(t, 2.6, 0.06), parts, V(1, 1.05, 1), 0.1
	end

	----------------------------------------------------------------------------
	-- Between attacks — never actually idle
	----------------------------------------------------------------------------

	if state == "observe" then
		-- Weight shifting, breathing, ear flicks, head tracking. The point is
		-- that a screenshot of this creature is never a creature standing
		-- still.
		local breathe = math.sin(t * (2.2 + frenzy))
		local sway = math.sin(t * 0.9)
		local flickWindow = t % (3.4 - frenzy * 1.4)
		local flick = if flickWindow < 0.18 then math.sin(flickWindow / 0.18 * math.pi) * 22 else 0

		ears(parts, breathe * 4 - flick, 3 + sway * 3)
		parts.Head = track * CFrame.Angles(math.rad(breathe * 3), 0, math.rad(sway * 5))
		parts.Nose = bob(t, 9, 0.03)
		parts.Chest = bob(t, 2.2, 0.07)
		parts.Tail = CFrame.Angles(0, math.rad(sway * 14), 0)
		-- Weight shift, side to side.
		local root = CFrame.new(sway * 0.28, math.sin(t * 2.2) * 0.07, 0) * CFrame.Angles(0, 0, math.rad(-sway * 3))
		return root, parts, V(1, 1 + breathe * 0.02, 1), 0.12
	end

	----------------------------------------------------------------------------
	-- Locomotion — compress, stretch, squash
	----------------------------------------------------------------------------

	if state == "compress" then
		-- Anticipation. Everything gathers downward before it goes up.
		local p = phase ^ 0.55
		ears(parts, -30 * p, 4)
		parts.HaunchL = CFrame.new(0, -0.3 * p, -0.3 * p)
		parts.HaunchR = parts.HaunchL
		feet(parts, CFrame.new(0, 0, -0.4 * p))
		paws(parts, CFrame.new(0, -0.15 * p, 0.1 * p))
		parts.Head = track * CFrame.new(0, -0.2 * p, 0)
		local root = CFrame.new(0, -0.7 * p, 0) * CFrame.Angles(math.rad(6 * p), 0, 0)
		return root, parts, ONE:Lerp(V(1.2, 0.7, 1.14), p), 0.55
	end

	if state == "air" then
		-- Stretched along travel. Ears trail, legs extend back, paws forward.
		local arc = math.sin(phase * math.pi)
		ears(parts, -46 - arc * 12, 8)
		feet(parts, CFrame.new(0, -0.15, 1.25))
		paws(parts, CFrame.new(0, 0.25, -0.7))
		parts.Head = track * CFrame.new(0, 0.1, -0.15)
		parts.Tail = CFrame.new(0, 0.18, 0.3)
		local root = CFrame.Angles(math.rad(-16 * arc), 0, 0)
		return root, parts, V(0.86, 1.3, 0.9), 0.42
	end

	if state == "land" then
		-- Impact squash, springing back out. The overshoot is the follow-through.
		local p = phase
		local squash = if p < 0.45 then p / 0.45 else 1 - (p - 0.45) / 0.55
		ears(parts, 24 * squash, 10 * squash)
		parts.HaunchL = CFrame.new(0, -0.28 * squash, 0)
		parts.HaunchR = parts.HaunchL
		paws(parts, CFrame.new(0, -0.1 * squash, -0.35 * squash))
		parts.Head = track * CFrame.new(0, -0.22 * squash, 0)
		local root = CFrame.new(0, -0.42 * squash, 0)
		return root, parts, ONE:Lerp(V(1.26, 0.66, 1.2), squash), 0.6
	end

	----------------------------------------------------------------------------
	-- Attacks
	----------------------------------------------------------------------------

	if state == "crouch" then
		-- The signature tell. Coils hard and early, then holds dead still —
		-- the hold is what you're reading, and stillness reads at any distance.
		local p = math.min(phase / 0.55, 1) ^ 0.6
		local tremor = math.sin(t * 34) * 0.035 * p
		ears(parts, -52 * p, -8 * p)
		parts.HaunchL = CFrame.new(0, -0.4 * p, -0.5 * p)
		parts.HaunchR = parts.HaunchL
		feet(parts, CFrame.new(0, 0, -0.75 * p))
		paws(parts, CFrame.new(0, -0.2 * p, 0.2 * p))
		parts.Head = track * CFrame.new(0, -0.2 * p, -0.34 * p)
		parts.Tail = CFrame.new(0, -0.24 * p, 0.18 * p)
		local root = CFrame.new(tremor, -0.85 * p, 0.62 * p) * CFrame.Angles(math.rad(9 * p), 0, 0)
		return root, parts, ONE:Lerp(V(1.24, 0.72, 1.1), p), 0.5
	end

	if state == "dash" then
		-- Fully committed. Nothing about this pose is neutral.
		ears(parts, -66, 14)
		feet(parts, CFrame.new(0, -0.25, 1.7))
		paws(parts, CFrame.new(0, 0.3, -1.15))
		parts.Head = CFrame.new(0, 0.12, -0.4)
		parts.Tail = CFrame.new(0, 0.24, 0.5)
		local root = CFrame.new(0, 0.45, 0) * CFrame.Angles(math.rad(-19), 0, 0)
		return root, parts, V(0.78, 0.9, 1.5), 0.75
	end

	if state == "slashFollow" then
		-- Follow-through: it has passed you and is still uncoiling.
		local q = 1 - phase
		ears(parts, -40 * q + 18 * phase, 6)
		paws(parts, CFrame.new(0, 0.1 * q, -0.5 * q))
		feet(parts, CFrame.new(0, 0, 0.8 * q))
		parts.Head = CFrame.Angles(0, math.rad(24 * q), 0)
		local root = CFrame.new(0, 0.15 * q, 0) * CFrame.Angles(math.rad(-8 * q), math.rad(12 * q), 0)
		return root, parts, ONE:Lerp(V(0.9, 1.12, 1.2), q), 0.35
	end

	if state == "spin" then
		-- Close-range rotating combo. Spins about its own axis, low and flat.
		local spins = phase * math.pi * 6
		ears(parts, -50, 26)
		paws(parts, CFrame.new(0, 0.15, -0.85))
		feet(parts, CFrame.new(0, -0.1, 0.5))
		local root = CFrame.new(0, 0.55, 0) * CFrame.Angles(math.rad(-24), spins, 0)
		return root, parts, V(1.16, 0.84, 1.16), 0.8
	end

	if state == "leapRise" then
		local p = phase
		ears(parts, -58 * p, 12)
		feet(parts, CFrame.new(0, -0.2, 1.4 * p))
		paws(parts, CFrame.new(0, 0.3 * p, -0.6 * p))
		parts.Head = CFrame.Angles(math.rad(-22 * p), 0, 0)
		local root = CFrame.Angles(math.rad(-30 * p), 0, 0)
		return root, parts, ONE:Lerp(V(0.8, 1.42, 0.86), p), 0.4
	end

	if state == "leapHang" then
		-- The pause at apex, tucked, before it inverts and comes down.
		local tuck = math.sin(phase * math.pi)
		ears(parts, -20, 20)
		feet(parts, CFrame.new(0, 0.4 * tuck, -0.5 * tuck))
		paws(parts, CFrame.new(0, -0.2 * tuck, -0.4 * tuck))
		local root = CFrame.Angles(math.rad(-30 + 120 * phase), 0, 0)
		return root, parts, ONE:Lerp(V(1.1, 0.86, 1.1), tuck), 0.25
	end

	if state == "leapFall" then
		-- Inverted, paws first, everything pointed at the ground.
		ears(parts, 34, 18)
		paws(parts, CFrame.new(0, -0.3, -1.1))
		feet(parts, CFrame.new(0, 0.4, 0.6))
		parts.Head = CFrame.Angles(math.rad(30), 0, 0)
		local root = CFrame.Angles(math.rad(52), 0, 0)
		return root, parts, V(0.88, 1.34, 0.9), 0.7
	end

	if state == "feint" then
		-- The abort. Twists out of the crouch sideways instead of forwards,
		-- which is the visual cue that it was never coming.
		local p = phase
		ears(parts, -30 + 40 * p, -22)
		paws(parts, CFrame.new(0.4 * p, 0.15 * p, 0))
		parts.Head = CFrame.Angles(0, math.rad(-46 * p), 0)
		local root = CFrame.new(0, -0.4 * (1 - p), 0.3 * (1 - p))
			* CFrame.Angles(0, math.rad(-58 * p), math.rad(16 * p))
		return root, parts, ONE:Lerp(V(1.1, 0.9, 1.05), 1 - p), 0.5
	end

	----------------------------------------------------------------------------
	-- Reactions
	----------------------------------------------------------------------------

	if state == "recover" then
		local q = 1 - phase
		local wobble = math.sin(phase * 26) * q
		paws(parts, CFrame.new(0, 0, -0.9 * q))
		parts.PawL = CFrame.new(-0.45 * q, 0, -0.9 * q)
		parts.PawR = CFrame.new(0.45 * q, 0, -0.9 * q)
		ears(parts, 30 * q, 8 * wobble)
		parts.Head = track * CFrame.Angles(0, math.rad(wobble * 9), 0)
		local root = CFrame.new(0, -0.3 * q, 0) * CFrame.Angles(math.rad(18 * q), math.rad(wobble * 5), 0)
		return root, parts, ONE:Lerp(V(1.12, 0.9, 1.08), q), 0.3
	end

	if state == "stagger" then
		-- Parried. Reeling, sagging, wide open — and it must read as a
		-- different silhouette from `recover`, or the parry stops being worth
		-- the risk because you can't tell you earned anything.
		local reel = math.sin(t * 15) * (1 - phase * 0.7)
		ears(parts, 48 + reel * 14, -24)
		parts.Head = CFrame.new(0, -0.34, 0) * CFrame.Angles(math.rad(26), math.rad(reel * 20), 0)
		parts.PawL = CFrame.new(-0.55, -0.15, -0.5)
		parts.PawR = CFrame.new(0.55, -0.15, -0.5)
		parts.PawStainL = parts.PawL
		parts.PawStainR = parts.PawR
		parts.HaunchL = CFrame.new(0, -0.45, 0.2)
		parts.HaunchR = parts.HaunchL
		parts.Tail = CFrame.new(0, -0.4, 0)
		local root = CFrame.new(0, -0.68, 0) * CFrame.Angles(math.rad(-28), math.rad(reel * 13), math.rad(reel * 10))
		return root, parts, V(1.18, 0.76, 1.12), 0.28
	end

	-- Fallback: the dormant breathing pose.
	local breathe = math.sin(t * 1.9)
	ears(parts, breathe * 3, 2)
	parts.Head = bob(t, 1.9, 0.05) * track
	parts.Nose = bob(t, 9, 0.03)
	parts.Chest = bob(t, 1.9, 0.06)
	return bob(t, 1.9, 0.09), parts, ONE, 0.12
end

--------------------------------------------------------------------------------
-- Goblin
--------------------------------------------------------------------------------

--[[
	Small, twitchy and hunched. Where the Vorpal Bunny is still and precise,
	this thing never stops fidgeting — different silhouette, different rhythm,
	so the two are never confused at distance even before you see them clearly.
]]
function Anim.goblin(state: string, phase: number, t: number, extra)
	local parts = {}
	local look = (extra and extra.lookYaw) or 0
	local track = CFrame.Angles(0, math.rad(math.clamp(look, -55, 55)), 0)

	if state == "windup" then
		-- Rears back and raises the spear. Long and obvious: this is the
		-- creature players learn to parry on.
		local p = phase ^ 0.7
		parts.Head = track * CFrame.Angles(math.rad(-16 * p), 0, 0)
		parts.UpperArmL = CFrame.Angles(math.rad(-95 * p), 0, 0)
		parts.UpperArmR = parts.UpperArmL
		parts.ForearmL = CFrame.Angles(math.rad(-40 * p), 0, 0)
		parts.ForearmR = parts.ForearmL
		parts.Haft = CFrame.Angles(math.rad(-70 * p), 0, 0)
		parts.SpearTip = parts.Haft
		parts.Binding = parts.Haft
		local root = CFrame.new(0, 0, 0.7 * p) * CFrame.Angles(math.rad(-12 * p), 0, 0)
		return root, parts, V(1, 1, 1):Lerp(V(1.05, 0.94, 1.05), p), 0.4
	end

	if state == "thrust" then
		parts.UpperArmL = CFrame.Angles(math.rad(28), 0, 0)
		parts.UpperArmR = parts.UpperArmL
		parts.ForearmL = CFrame.Angles(math.rad(12), 0, 0)
		parts.ForearmR = parts.ForearmL
		parts.Haft = CFrame.Angles(math.rad(24), 0, 0)
		parts.SpearTip = parts.Haft
		parts.Binding = parts.Haft
		local root = CFrame.new(0, 0, -1.4) * CFrame.Angles(math.rad(16), 0, 0)
		return root, parts, V(0.94, 0.98, 1.14), 0.7
	end

	if state == "stagger" then
		local reel = math.sin(t * 16) * (1 - phase * 0.6)
		parts.Head = CFrame.new(0, -0.2, 0) * CFrame.Angles(math.rad(22), math.rad(reel * 24), 0)
		parts.UpperArmL = CFrame.Angles(math.rad(34), 0, math.rad(-26))
		parts.UpperArmR = CFrame.Angles(math.rad(34), 0, math.rad(26))
		local root = CFrame.new(0, -0.35, 0) * CFrame.Angles(math.rad(-22), math.rad(reel * 14), math.rad(reel * 10))
		return root, parts, V(1.1, 0.85, 1.08), 0.3
	end

	if state == "walk" then
		-- Scuttling gait: quick, bouncing, arms swinging out of time.
		local c = t * 8.5
		local bounce = math.abs(math.sin(c))
		parts.ThighL = CFrame.Angles(math.rad(math.sin(c) * 34), 0, 0)
		parts.ThighR = CFrame.Angles(math.rad(-math.sin(c) * 34), 0, 0)
		parts.ShinL = CFrame.Angles(math.rad(math.max(-math.sin(c), 0) * 40), 0, 0)
		parts.ShinR = CFrame.Angles(math.rad(math.max(math.sin(c), 0) * 40), 0, 0)
		parts.UpperArmL = CFrame.Angles(math.rad(-math.sin(c) * 26), 0, 0)
		parts.UpperArmR = CFrame.Angles(math.rad(math.sin(c) * 26), 0, 0)
		parts.Head = track * CFrame.Angles(0, 0, math.rad(math.sin(c * 0.5) * 6))
		local root = CFrame.new(0, bounce * 0.34, 0)
			* CFrame.Angles(math.rad(6 - bounce * 5), 0, math.rad(math.sin(c) * 4))
		return root, parts, V(1, 1 + bounce * 0.03, 1), 0.35
	end

	-- Idle: constantly shifting weight, head snapping around, spear tapping.
	local breathe = math.sin(t * 2.6)
	local twitchWindow = t % 2.7
	local twitch = if twitchWindow < 0.2 then math.sin(twitchWindow / 0.2 * math.pi) else 0

	parts.Head = track * CFrame.Angles(math.rad(breathe * 4), math.rad(twitch * 26), math.rad(breathe * 3))
	parts.Belly = CFrame.new(0, breathe * 0.05, 0)
	parts.UpperArmL = CFrame.Angles(math.rad(breathe * 7), 0, 0)
	parts.UpperArmR = CFrame.Angles(math.rad(-breathe * 7), 0, 0)
	parts.Haft = CFrame.Angles(math.rad(math.sin(t * 3.1) * 5), 0, 0)

	local sway = math.sin(t * 1.1)
	local root = CFrame.new(sway * 0.16, breathe * 0.06, 0) * CFrame.Angles(0, 0, math.rad(-sway * 3))
	return root, parts, V(1, 1 + breathe * 0.015, 1), 0.14
end

--------------------------------------------------------------------------------
-- Lycagon
--------------------------------------------------------------------------------

--[[
	Where the bunny is precise and the goblin is twitchy, Lycagon is heavy and
	fluid. Everything moves, all the time, and nothing snaps — a creature this
	size that changed pose instantly would read as a puppet.
]]
function Anim.lycagon(state: string, phase: number, t: number, extra)
	local parts = {}
	local look = (extra and extra.lookYaw) or 0
	local form = (extra and extra.phase) or 1
	local track = CFrame.Angles(0, math.rad(math.clamp(look, -48, 48)), 0)

	local function legs(front: number, back: number)
		parts.ForeLegL = CFrame.Angles(math.rad(front), 0, 0)
		parts.ForeLegR = parts.ForeLegL
		parts.HindLegL = CFrame.Angles(math.rad(back), 0, 0)
		parts.HindLegR = parts.HindLegL
	end

	if state == "crouch" then
		-- Coils onto the haunches, head dropping, ears flat. The bigger the
		-- creature the longer the coil has to read from.
		local p = phase ^ 0.6
		parts.EarL = Rig.hinge(1.55, -46 * p, -10)
		parts.EarR = Rig.hinge(1.55, -46 * p, 10)
		parts.Head = track * CFrame.new(0, -0.9 * p, -0.5 * p)
		parts.Snout = parts.Head
		parts.Jaw = CFrame.Angles(math.rad(16 * p), 0, 0)
		parts.ThighL = CFrame.new(0, -0.5 * p, 0.4 * p)
		parts.ThighR = parts.ThighL
		legs(-14 * p, 22 * p)
		parts.Tail1 = CFrame.Angles(math.rad(-18 * p), 0, 0)
		parts.Tail2 = CFrame.Angles(math.rad(-26 * p), 0, 0)
		parts.Tail3 = CFrame.Angles(math.rad(-34 * p), 0, 0)

		local root = CFrame.new(0, -1.5 * p, 1.1 * p) * CFrame.Angles(math.rad(9 * p), 0, 0)
		return root, parts, ONE:Lerp(V(1.16, 0.8, 1.08), p), 0.42
	end

	if state == "leapRise" or state == "air" then
		local arc = math.sin(phase * math.pi)
		parts.EarL = Rig.hinge(1.55, -58, -16)
		parts.EarR = Rig.hinge(1.55, -58, 16)
		parts.Head = CFrame.new(0, 0.3, -0.5)
		parts.Snout = parts.Head
		parts.Jaw = CFrame.Angles(math.rad(24), 0, 0)
		legs(-40, 44)
		parts.Tail1 = CFrame.Angles(math.rad(24), 0, 0)
		parts.Tail2 = CFrame.Angles(math.rad(32), 0, 0)
		parts.Tail3 = CFrame.Angles(math.rad(40), 0, 0)

		local root = CFrame.Angles(math.rad(-22 * arc), 0, 0)
		return root, parts, V(0.9, 1.2, 1.16), 0.4
	end

	if state == "leapFall" or state == "land" then
		local squash = 1 - phase
		parts.EarL = Rig.hinge(1.55, 26 * squash, -12)
		parts.EarR = Rig.hinge(1.55, 26 * squash, 12)
		parts.Head = CFrame.new(0, -0.6 * squash, 0)
		parts.Snout = parts.Head
		legs(30 * squash, -18 * squash)
		local root = CFrame.new(0, -0.9 * squash, 0) * CFrame.Angles(math.rad(14 * squash), 0, 0)
		return root, parts, ONE:Lerp(V(1.2, 0.76, 1.14), squash), 0.6
	end

	if state == "dash" then
		parts.EarL = Rig.hinge(1.55, -60, -18)
		parts.EarR = Rig.hinge(1.55, -60, 18)
		parts.Jaw = CFrame.Angles(math.rad(30), 0, 0)
		parts.Head = CFrame.new(0, 0.2, -0.6)
		parts.Snout = parts.Head
		legs(-34, 38)
		local root = CFrame.new(0, 0.4, 0) * CFrame.Angles(math.rad(-16), 0, 0)
		return root, parts, V(0.88, 0.96, 1.3), 0.75
	end

	if state == "slashFollow" then
		local q = 1 - phase
		parts.Head = CFrame.Angles(0, math.rad(26 * q), 0)
		parts.Snout = parts.Head
		legs(18 * q, -14 * q)
		local root = CFrame.Angles(math.rad(-8 * q), math.rad(14 * q), 0)
		return root, parts, ONE:Lerp(V(0.95, 1.08, 1.14), q), 0.35
	end

	if state == "recover" then
		local q = 1 - phase
		local heave = math.sin(t * 7) * q
		parts.Head = track * CFrame.new(0, -0.5 * q, 0) * CFrame.Angles(math.rad(12 * q), 0, 0)
		parts.Snout = parts.Head
		parts.Jaw = CFrame.Angles(math.rad(14 * q + heave * 6), 0, 0)
		parts.Chest = CFrame.new(0, heave * 0.2, 0)
		legs(10 * q, -8 * q)
		local root = CFrame.new(0, -0.6 * q, 0) * CFrame.Angles(math.rad(10 * q), 0, 0)
		return root, parts, ONE:Lerp(V(1.1, 0.92, 1.06), q), 0.3
	end

	if state == "stagger" then
		local reel = math.sin(t * 13) * (1 - phase * 0.6)
		parts.EarL = Rig.hinge(1.55, 40, -22)
		parts.EarR = Rig.hinge(1.55, 40, 22)
		parts.Head = CFrame.new(0, -1.5, 0) * CFrame.Angles(math.rad(28), math.rad(reel * 20), 0)
		parts.Snout = parts.Head
		parts.Jaw = CFrame.Angles(math.rad(32), 0, 0)
		legs(24, -20)
		local root = CFrame.new(0, -1.6, 0) * CFrame.Angles(math.rad(-20), math.rad(reel * 12), math.rad(reel * 9))
		return root, parts, V(1.16, 0.78, 1.1), 0.28
	end

	-- Observe: prowling in place. Breathing hard, tail live, head sweeping.
	-- Later forms breathe faster; by seven he is visibly furious.
	local rate = 1.15 + form * 0.12
	local breathe = math.sin(t * rate)
	parts.Chest = CFrame.new(0, breathe * 0.22, -breathe * 0.12)
	parts.Ruff = CFrame.new(0, breathe * 0.26, 0)
	parts.Head = track * CFrame.Angles(math.rad(breathe * 3), 0, 0)
	parts.Snout = parts.Head
	parts.Jaw = CFrame.Angles(math.rad(math.max(math.sin(t * rate * 0.8), 0) * 7), 0, 0)
	parts.EarL = Rig.hinge(1.55, math.sin(t * 0.9) * 8 - form, -4)
	parts.EarR = Rig.hinge(1.55, math.sin(t * 0.9 + 1.4) * 8 - form, 4)

	local sway = math.sin(t * 0.8)
	parts.Tail1 = CFrame.Angles(0, math.rad(sway * 7), 0)
	parts.Tail2 = CFrame.Angles(0, math.rad(sway * 14), 0)
	parts.Tail3 = CFrame.Angles(0, math.rad(sway * 22), 0)
	legs(math.sin(t * 0.7) * 3, -math.sin(t * 0.7) * 3)

	return CFrame.new(0, breathe * 0.16, 0), parts, V(1, 1 + breathe * 0.018, 1), 0.12
end

--------------------------------------------------------------------------------
-- Wethermon
--------------------------------------------------------------------------------

--[[
	Immense and slow. Every pose holds longer than feels comfortable, which is
	the character: he has never needed to hurry.

	Blend rates here are deliberately the lowest in the game except the slam,
	which is the fastest — the contrast between the two is the whole fight.
]]
function Anim.wethermon(state: string, phase: number, t: number, extra)
	local parts = {}
	local look = (extra and extra.lookYaw) or 0
	local track = CFrame.Angles(0, math.rad(math.clamp(look, -40, 40)), 0)

	local function halberd(offset: CFrame)
		parts.Haft = offset
		parts.Blade = offset
		parts.BladeTip = offset
		parts.Spike = offset
		parts.BladeEdge = offset
	end

	if state == "windup" then
		-- Draws the halberd back across the body. Nearly a full second.
		local p = phase ^ 0.8
		parts.UpperArmL = CFrame.Angles(math.rad(-30 * p), 0, math.rad(-18 * p))
		parts.UpperArmR = CFrame.Angles(math.rad(-30 * p), 0, math.rad(18 * p))
		parts.BracerL = CFrame.Angles(math.rad(-20 * p), 0, 0)
		parts.BracerR = parts.BracerL
		halberd(CFrame.Angles(math.rad(-24 * p), math.rad(-58 * p), 0))
		parts.Helm = track
		parts.Visor = track
		parts.Cloak = CFrame.Angles(math.rad(-6 * p), math.rad(-10 * p), 0)

		local root = CFrame.Angles(0, math.rad(-16 * p), 0)
		return root, parts, ONE:Lerp(V(1.04, 0.98, 1.02), p), 0.16
	end

	if state == "sweep" then
		halberd(CFrame.Angles(math.rad(6), math.rad(76), 0))
		parts.UpperArmL = CFrame.Angles(math.rad(14), 0, math.rad(24))
		parts.UpperArmR = CFrame.Angles(math.rad(14), 0, math.rad(-24))
		parts.Cloak = CFrame.Angles(math.rad(10), math.rad(22), 0)
		local root = CFrame.Angles(0, math.rad(28), 0)
		return root, parts, V(1.02, 0.99, 1.04), 0.7
	end

	if state == "raise" then
		-- Both arms overhead. The most readable pose in the game, and it has
		-- to be: what follows cannot be parried.
		local p = phase ^ 0.7
		parts.UpperArmL = CFrame.Angles(math.rad(-150 * p), 0, math.rad(-10 * p))
		parts.UpperArmR = CFrame.Angles(math.rad(-150 * p), 0, math.rad(10 * p))
		parts.BracerL = CFrame.Angles(math.rad(-30 * p), 0, 0)
		parts.BracerR = parts.BracerL
		halberd(CFrame.Angles(math.rad(-130 * p), 0, 0))
		parts.Helm = track * CFrame.Angles(math.rad(-10 * p), 0, 0)
		parts.Visor = parts.Helm
		parts.Cloak = CFrame.Angles(math.rad(-14 * p), 0, 0)

		local root = CFrame.new(0, 0.5 * p, 0) * CFrame.Angles(math.rad(-12 * p), 0, 0)
		return root, parts, ONE:Lerp(V(0.98, 1.06, 0.98), p), 0.18
	end

	if state == "slam" then
		parts.UpperArmL = CFrame.Angles(math.rad(40), 0, 0)
		parts.UpperArmR = parts.UpperArmL
		halberd(CFrame.Angles(math.rad(52), 0, 0))
		parts.Helm = CFrame.Angles(math.rad(24), 0, 0)
		parts.Visor = parts.Helm
		local root = CFrame.new(0, -1.2, 0) * CFrame.Angles(math.rad(26), 0, 0)
		return root, parts, V(1.12, 0.86, 1.1), 0.85
	end

	if state == "step" then
		local stride = math.sin(t * 3.4)
		parts.ThighL = CFrame.Angles(math.rad(stride * 16), 0, 0)
		parts.ThighR = CFrame.Angles(math.rad(-stride * 16), 0, 0)
		parts.ShinL = CFrame.Angles(math.rad(math.max(-stride, 0) * 20), 0, 0)
		parts.ShinR = CFrame.Angles(math.rad(math.max(stride, 0) * 20), 0, 0)
		parts.Cloak = CFrame.Angles(math.rad(-stride * 5), 0, 0)
		parts.Helm = track
		parts.Visor = track
		local root = CFrame.new(0, math.abs(stride) * 0.2, 0) * CFrame.Angles(0, 0, math.rad(stride * 3))
		return root, parts, ONE, 0.2
	end

	if state == "recover" then
		local q = 1 - phase
		parts.UpperArmL = CFrame.Angles(math.rad(18 * q), 0, 0)
		parts.UpperArmR = parts.UpperArmL
		halberd(CFrame.Angles(math.rad(28 * q), math.rad(20 * q), 0))
		parts.Helm = track * CFrame.Angles(math.rad(10 * q), 0, 0)
		parts.Visor = parts.Helm
		local root = CFrame.new(0, -0.4 * q, 0) * CFrame.Angles(math.rad(9 * q), 0, 0)
		return root, parts, ONE:Lerp(V(1.05, 0.95, 1.03), q), 0.14
	end

	if state == "stagger" then
		-- He does not fall. He kneels, and that is worse.
		local settle = math.min(phase * 3, 1)
		parts.ThighL = CFrame.Angles(math.rad(-44 * settle), 0, 0)
		parts.ShinL = CFrame.Angles(math.rad(70 * settle), 0, 0)
		parts.UpperArmL = CFrame.Angles(math.rad(30 * settle), 0, math.rad(-14))
		parts.UpperArmR = CFrame.Angles(math.rad(12 * settle), 0, math.rad(14))
		halberd(CFrame.Angles(math.rad(64 * settle), 0, 0))
		parts.Helm = CFrame.Angles(math.rad(26 * settle), 0, 0)
		parts.Visor = parts.Helm
		parts.Cloak = CFrame.Angles(math.rad(16 * settle), 0, 0)

		local root = CFrame.new(0, -2.2 * settle, 0) * CFrame.Angles(math.rad(12 * settle), 0, 0)
		return root, parts, ONE:Lerp(V(1.06, 0.92, 1.04), settle), 0.22
	end

	-- Idle: four centuries of standing still. Almost nothing moves.
	local drift = math.sin(t * 0.42)
	parts.Cloak = CFrame.Angles(math.rad(drift * 1.8), math.rad(drift * 2.5), 0)
	parts.Helm = track * CFrame.Angles(0, math.rad(math.sin(t * 0.19) * 4), 0)
	parts.Visor = parts.Helm
	parts.HelmBrow = parts.Helm
	parts.Crest = parts.Helm

	return CFrame.new(0, math.sin(t * 0.6) * 0.05, 0), parts, ONE, 0.08
end

--------------------------------------------------------------------------------
-- Gallery idles
--------------------------------------------------------------------------------

local function emulIdle(t: number)
	local parts = {}
	local breathe = math.sin(t * 2.1)

	parts.EarL = Rig.hinge(2.15, breathe * 5, -math.sin(t * 1.4) * 7)
	parts.EarR = Rig.hinge(2.15, breathe * 5, math.sin(t * 1.4) * 7)
	parts.EarInnerL = parts.EarL
	parts.EarInnerR = parts.EarR
	parts.Head = bob(t, 2.1, 0.07) * CFrame.Angles(0, math.rad(math.sin(t * 0.5) * 16), math.rad(breathe * 4))
	parts.Nose = bob(t, 8, 0.03)
	parts.ArmL = CFrame.Angles(math.rad(breathe * 6), 0, 0)
	parts.ArmR = parts.ArmL

	return bob(t, 2.1, 0.1), parts, V(1, 1 + breathe * 0.02, 1), 0.12
end

function Anim.idle(id: string, t: number)
	if id == "vorpal_bunny" then
		return Anim.vorpalBunny("observe", 0, t, nil)
	elseif id == "goblin" then
		return Anim.goblin("idle", 0, t, nil)
	elseif id == "emul" then
		return emulIdle(t)
	elseif id == "lycagon" then
		return Anim.lycagon("observe", 0, t, nil)
	elseif id == "wethermon" then
		return Anim.wethermon("idle", 0, t, nil)
	end
	return CFrame.identity, {}, ONE, 0.1
end

--[[
	Setsuna — hangs, and is never still.

	She has no walk and no idle-breath; she is suspended, so everything is a
	pendulum. The body swings, the crossbar counter-swings a beat behind it,
	and the arms trail. Nothing here ever fully settles, which is the read:
	a thing being held up rather than standing.
]]
function Anim.setsuna(state: string, _phase: number, clock: number, _signals)
	local parts = {}
	local sway = math.sin(clock * 0.9)
	local drift = math.sin(clock * 0.55 + 1.3)

	-- Base hang: slow roll, and a rise and fall on a longer period.
	local rootOffset = CFrame.new(drift * 0.5, math.sin(clock * 0.7) * 0.4, 0)
		* CFrame.Angles(0, drift * 0.12, math.rad(sway * 3.5))

	-- The crossbar lags the body, so the threads read as slack.
	parts.Crossbar = CFrame.Angles(0, 0, math.rad(-sway * 2.2))
	parts.CrossbarSpine = parts.Crossbar

	-- Arms trail whatever the body just did.
	local trail = math.sin(clock * 0.9 - 0.8)
	parts.ArmL = Rig.hinge(4.6, trail * 5)
	parts.ArmR = Rig.hinge(4.6, -trail * 5)
	parts.ForearmL = Rig.hinge(4.0, trail * 8)
	parts.ForearmR = Rig.hinge(4.0, -trail * 8)

	-- Head hangs forward and rolls; the mask never levels.
	parts.Head = CFrame.Angles(math.rad(6 + sway * 2), math.rad(drift * 7), math.rad(sway * 4))
	parts.Mask = parts.Head

	if state == "windup" then
		-- Draws up and back: the only time she gains height.
		rootOffset = rootOffset * CFrame.new(0, 1.6, 1.4)
		parts.ArmL = Rig.hinge(4.6, -38)
		parts.ArmR = Rig.hinge(4.6, -38)
		parts.ForearmL = Rig.hinge(4.0, -52)
		parts.ForearmR = Rig.hinge(4.0, -52)
		return rootOffset, parts, Vector3.new(0.94, 1.1, 0.94), 0.3
	elseif state == "recover" then
		rootOffset = rootOffset * CFrame.new(0, -0.5, -0.9)
		parts.ArmL = Rig.hinge(4.6, 26)
		parts.ArmR = Rig.hinge(4.6, 26)
		return rootOffset, parts, Vector3.new(1.06, 0.93, 1.06), 0.16
	elseif state == "stagger" then
		-- Cut loose. She sags on the threads and the crossbar tips.
		rootOffset = CFrame.new(sway * 1.1, -1.8, 0) * CFrame.Angles(math.rad(14), 0, math.rad(sway * 11))
		parts.Crossbar = CFrame.Angles(0, 0, math.rad(sway * 9))
		parts.CrossbarSpine = parts.Crossbar
		parts.Head = CFrame.Angles(math.rad(28), 0, math.rad(sway * 8))
		parts.Mask = parts.Head
		parts.ArmL = Rig.hinge(4.6, 44)
		parts.ArmR = Rig.hinge(4.6, 44)
		return rootOffset, parts, Vector3.new(1.02, 0.9, 1.02), 0.12
	end

	return rootOffset, parts, nil, 0.09
end

return Anim
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="39">
          <Properties>
            <string name="Name">Emul</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Emul — the rabbit-eared companion from the first Unique Scenario.

	Deliberately built from the same primitives as the Vorpal Bunny with every
	proportion pushed the other way: bigger head relative to body, ears splayed
	out and down instead of swept back, huge dark eyes with a highlight instead
	of small neon ones, no teeth, no stain. Same species, opposite read.

	Authored facing -Z, standing on y = 0.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local FUR = Color3.fromRGB(250, 244, 232)
local FUR_SHADE = Color3.fromRGB(236, 226, 210)
local PINK = Color3.fromRGB(240, 178, 190)
local EYE = Color3.fromRGB(34, 30, 40)
local GLINT = Color3.fromRGB(255, 255, 255)
local COLLAR = Color3.fromRGB(196, 108, 92)

return function(origin: CFrame): Model
	local ctx = Build.begin("Emul", origin)

	ctx:ball({ name = "Body", size = V(1.9, 1.8, 1.9), at = V(0, 1.0, 0), color = FUR })
	ctx:ball({ name = "Tail", size = V(0.6, 0.6, 0.55), at = V(0, 1.05, 1.05), color = FUR })

	-- Oversized head: the single strongest cue that this one is friendly.
	ctx:ball({ name = "Head", size = V(1.78, 1.7, 1.72), at = V(0, 2.16, -0.32), color = FUR })
	ctx:ball({ name = "Muzzle", size = V(0.74, 0.58, 0.62), at = V(0, 1.92, -1.14), color = FUR })
	ctx:ball({ name = "Nose", size = V(0.22, 0.18, 0.2), at = V(0, 2.0, -1.42), color = PINK })
	ctx:pair("ball", { name = "Cheek", size = V(0.44, 0.34, 0.38), at = V(0.66, 1.9, -0.96), color = PINK })

	-- Ears splayed outward and drooping.
	ctx:pair("block", {
		name = "Ear",
		size = V(0.44, 2.15, 0.72),
		at = V(0.62, 3.05, -0.34),
		rot = V(4, 0, -26),
		color = FUR,
	})
	ctx:pair("block", {
		name = "EarInner",
		size = V(0.24, 1.6, 0.5),
		at = V(0.66, 3.0, -0.48),
		rot = V(4, 0, -26),
		color = PINK,
	})

	-- Big dark eyes with a highlight, the classic "this one is safe" signal.
	ctx:pair("ball", { name = "Eye", size = V(0.46, 0.5, 0.46), at = V(0.5, 2.24, -1.06), color = EYE })
	ctx:pair("glow", { name = "Glint", size = V(0.16, 0.16, 0.16), at = V(0.58, 2.38, -1.22), color = GLINT })

	-- Stubby limbs.
	ctx:pair("ball", { name = "Arm", size = V(0.52, 0.78, 0.52), at = V(0.92, 1.02, -0.3), color = FUR_SHADE })
	ctx:pair("ball", { name = "Foot", size = V(0.66, 0.46, 0.92), at = V(0.54, 0.26, -0.42), color = FUR_SHADE })

	-- Collar, so it reads as belonging to someone.
	ctx:cylinder({
		name = "Collar",
		size = V(0.22, 1.62, 1.62),
		at = V(0, 1.56, -0.18),
		rot = V(0, 0, 90),
		color = COLLAR,
		material = Enum.Material.Fabric,
	})

	return ctx:finishRigged()
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="40">
          <Properties>
            <string name="Name">Goblin</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Goblin — the first thing anyone kills in Shangri-La Frontier.

	Sunraku opens the story by taking one out in a single hit with the Mercenary
	Twinblades, and that is exactly the job this creature has here: to be the
	enemy you learn the combat system on, in the field rather than in a training
	yard, and to die satisfyingly.

	Design brief: small, wiry, and *readable*. It has to telegraph clearly
	enough that a player can practise parrying on it, and be weak enough that
	failing costs a lesson rather than a run. Everything about the silhouette is
	pointed — ears, nose, knees, the crude spear — so it never gets confused
	with the round, soft shape of a Vorpal Bunny at distance.

	Authored facing -Z, standing on y = 0.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local SKIN = Color3.fromRGB(108, 138, 84)
local SKIN_DARK = Color3.fromRGB(84, 108, 66)
local RAG = Color3.fromRGB(96, 80, 62)
local LEATHER = Color3.fromRGB(72, 54, 40)
local TOOTH = Color3.fromRGB(232, 226, 200)
local EYE = Color3.fromRGB(232, 176, 62)
local IRON = Color3.fromRGB(126, 128, 132)
local WOOD = Color3.fromRGB(88, 66, 46)

return function(origin: CFrame): Model
	local ctx = Build.begin("Goblin", origin)

	-- Legs: bandy and bent, so it reads as scurrying even standing still.
	ctx:pair("block", { name = "Foot", size = V(0.7, 0.4, 1.1), at = V(0.5, 0.2, -0.15), color = LEATHER })
	ctx:pair(
		"block",
		{ name = "Shin", size = V(0.5, 1.2, 0.55), at = V(0.5, 1.0, 0), rot = V(-8, 0, 0), color = SKIN_DARK }
	)
	ctx:pair(
		"block",
		{ name = "Thigh", size = V(0.62, 1.1, 0.7), at = V(0.5, 2.1, -0.1), rot = V(10, 0, 0), color = SKIN }
	)

	-- Body: hunched, with a rag tied round the middle.
	ctx:block({ name = "Hips", size = V(1.5, 0.7, 0.9), at = V(0, 2.8, 0), color = RAG })
	ctx:ball({ name = "Belly", size = V(1.7, 1.5, 1.4), at = V(0, 3.5, -0.05), color = SKIN })
	ctx:block({ name = "Chest", size = V(1.6, 1.1, 1.1), at = V(0, 4.3, -0.15), rot = V(12, 0, 0), color = SKIN })
	ctx:block({ name = "Sash", size = V(1.75, 0.35, 1.2), at = V(0, 3.9, -0.1), color = RAG })

	-- Arms, long relative to the body. Reach it shouldn't have.
	ctx:pair(
		"block",
		{ name = "UpperArm", size = V(0.45, 1.2, 0.5), at = V(0.95, 4.2, -0.1), rot = V(0, 0, -10), color = SKIN }
	)
	ctx:pair(
		"block",
		{ name = "Forearm", size = V(0.42, 1.2, 0.46), at = V(1.05, 3.1, -0.2), rot = V(-14, 0, -6), color = SKIN_DARK }
	)
	ctx:pair("ball", { name = "Hand", size = V(0.55, 0.5, 0.55), at = V(1.1, 2.45, -0.35), color = SKIN_DARK })

	-- Head: pointed everywhere.
	ctx:block({ name = "Neck", size = V(0.5, 0.35, 0.5), at = V(0, 4.9, -0.2), color = SKIN_DARK })
	ctx:ball({ name = "Head", size = V(1.25, 1.2, 1.3), at = V(0, 5.5, -0.3), color = SKIN })
	ctx:ball({ name = "Snout", size = V(0.5, 0.42, 0.7), at = V(0, 5.35, -0.95), color = SKIN })
	ctx:block({ name = "Brow", size = V(1.2, 0.22, 0.7), at = V(0, 5.82, -0.72), rot = V(-14, 0, 0), color = SKIN_DARK })

	-- Ears: long, swept back, and the clearest read at distance.
	ctx:pair("block", {
		name = "Ear",
		size = V(1.1, 0.28, 0.42),
		at = V(0.85, 5.62, -0.1),
		rot = V(0, -22, 18),
		color = SKIN_DARK,
	})

	ctx:pair("glow", { name = "Eye", size = V(0.2, 0.16, 0.16), at = V(0.3, 5.58, -0.88), color = EYE })
	ctx:pair("block", { name = "Tusk", size = V(0.12, 0.3, 0.12), at = V(0.18, 5.1, -0.95), color = TOOTH })

	-- A spear it made itself, badly.
	ctx:cylinder({
		name = "Haft",
		size = V(6.5, 0.22, 0.22),
		at = V(1.25, 3.4, -0.5),
		rot = V(18, 0, 84),
		color = WOOD,
		material = Enum.Material.Wood,
	})
	ctx:wedge({ name = "SpearTip", size = V(0.18, 1.0, 0.5), at = V(1.9, 6.3, -1.4), rot = V(18, 0, -6), color = IRON })
	ctx:block({ name = "Binding", size = V(0.3, 0.3, 0.3), at = V(1.78, 5.75, -1.25), color = LEATHER })

	return ctx:finishRigged()
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="41">
          <Properties>
            <string name="Name">Lycagon</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Lycagon the Nightslayer — the seven-form wolf that only appears after dark.

	Scale is the point. Next to the Vorpal Bunny in the gallery it should be
	obvious these are not the same kind of problem: roughly thirteen studs nose
	to tail and seven at the shoulder, so a player stands at chest height.

	Near-black fur so it barely reads against a night sky, with molten markings
	along the flanks doing the silhouette work instead. Head is carried low and
	forward — a stalking line, not a heroic one.

	Authored facing -Z, standing on y = 0.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local FUR = Color3.fromRGB(20, 20, 27)
local FUR_DARK = Color3.fromRGB(11, 11, 16)
local FUR_RUFF = Color3.fromRGB(30, 30, 40)
local EMBER = Color3.fromRGB(255, 112, 36)
local EMBER_DEEP = Color3.fromRGB(214, 62, 24)
local CLAW = Color3.fromRGB(206, 200, 190)

return function(origin: CFrame): Model
	local ctx = Build.begin("Lycagon", origin)

	-- Torso
	ctx:ball({ name = "Torso", size = V(3.8, 3.4, 6.4), at = V(0, 4.3, 0.2), color = FUR })
	ctx:ball({ name = "Chest", size = V(4.0, 3.8, 3.2), at = V(0, 4.15, -2.7), color = FUR })
	ctx:ball({ name = "Haunches", size = V(3.7, 3.6, 3.3), at = V(0, 4.35, 2.9), color = FUR })
	ctx:ball({ name = "Ruff", size = V(4.2, 4.0, 2.0), at = V(0, 4.5, -3.6), color = FUR_RUFF })

	-- Neck and head, carried low.
	ctx:block({ name = "Neck", size = V(2.4, 2.2, 2.9), at = V(0, 5.25, -4.15), rot = V(20, 0, 0), color = FUR })
	ctx:block({ name = "Head", size = V(2.15, 1.95, 2.6), at = V(0, 5.95, -5.5), color = FUR })
	ctx:block({ name = "Snout", size = V(1.2, 1.05, 2.0), at = V(0, 5.6, -7.0), color = FUR_DARK })
	ctx:ball({ name = "Nose", size = V(0.46, 0.42, 0.4), at = V(0, 5.78, -7.95), color = FUR_DARK })
	ctx:block({ name = "Jaw", size = V(1.0, 0.42, 1.7), at = V(0, 5.06, -6.9), color = FUR_DARK })

	ctx:pair("block", { name = "Fang", size = V(0.16, 0.62, 0.16), at = V(0.34, 5.0, -7.5), color = CLAW })

	-- Ears, pinned back.
	ctx:pair("wedge", {
		name = "Ear",
		size = V(0.42, 1.55, 1.05),
		at = V(0.88, 7.05, -4.85),
		rot = V(-18, 0, 12),
		color = FUR,
	})

	ctx:pair("glow", { name = "Eye", size = V(0.4, 0.32, 0.36), at = V(0.74, 6.16, -6.15), color = EMBER })

	-- Legs. Front pair straight, rear pair with a visible thigh mass.
	ctx:pair("block", { name = "ForeLeg", size = V(1.05, 3.7, 1.15), at = V(1.42, 1.85, -2.7), color = FUR_DARK })
	ctx:pair("ball", { name = "Shoulder", size = V(1.7, 2.0, 2.0), at = V(1.5, 3.7, -2.7), color = FUR })
	ctx:pair("ball", { name = "Thigh", size = V(1.85, 2.3, 2.1), at = V(1.5, 3.5, 2.9), color = FUR })
	ctx:pair("block", { name = "HindLeg", size = V(1.1, 3.5, 1.2), at = V(1.48, 1.75, 3.0), color = FUR_DARK })

	ctx:pair("block", { name = "ForePaw", size = V(1.3, 0.58, 1.8), at = V(1.42, 0.3, -3.0), color = FUR_DARK })
	ctx:pair("block", { name = "HindPaw", size = V(1.32, 0.58, 1.85), at = V(1.48, 0.3, 3.3), color = FUR_DARK })

	-- Tail, three tapering segments lifted back.
	ctx:ball({ name = "Tail1", size = V(1.3, 1.3, 2.0), at = V(0, 4.7, 4.7), color = FUR })
	ctx:ball({ name = "Tail2", size = V(1.0, 1.0, 1.8), at = V(0, 5.5, 5.9), color = FUR })
	ctx:ball({ name = "Tail3", size = V(0.7, 0.7, 1.5), at = V(0, 6.25, 6.9), color = FUR_RUFF })

	-- Markings. These carry the silhouette when the fur goes invisible at night.
	for index, z in { -1.9, -0.2, 1.5 } do
		ctx:pair("glow", {
			name = `Mark{index}`,
			size = V(0.14, 0.42, 2.1 - index * 0.25),
			at = V(1.86, 4.85, z),
			color = if index == 2 then EMBER else EMBER_DEEP,
		})
	end

	ctx:glow({ name = "Brand", size = V(1.5, 0.16, 0.5), at = V(0, 6.85, -5.6), color = EMBER })
	ctx:pair("glow", { name = "ShoulderRune", size = V(0.16, 0.9, 0.9), at = V(1.94, 4.0, -2.7), color = EMBER_DEEP })

	return ctx:finishRigged()
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="42">
          <Properties>
            <string name="Name">Net</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Net — how a mob's pose gets from the server to your screen.

	THE PROBLEM. Every mob in this game was posed on the server: for each one,
	twenty part CFrames written every Heartbeat. Roblox does not interpolate
	anchored-part CFrame changes — they replicate as batched property updates
	at roughly 20Hz — so what a player at 60fps actually saw was every limb
	teleporting three times a frame. That is the juddering, and it cannot be
	fixed by making the server faster, because the ceiling is the replication
	rate and not the tick rate.

	THE FIX. The server stops sending poses and sends *state*: where the mob is,
	which way it faces, what it is doing, and how far through it is. Five small
	attributes instead of twenty CFrames. The client then runs the exact same
	animation function locally, at its own frame rate, and interpolates the root
	between the server's updates — so the motion is continuous no matter how
	sparsely the state arrives.

	WHAT STAYS AUTHORITATIVE. Everything that decides an outcome. The server
	still owns the mob's real position, its hit detection, its attack timings
	and its health; the client is only allowed to decide what the model *looks*
	like between two states the server already published. A client that lies
	renders a lie to itself and changes nothing about who got hit.

	Attributes are used rather than a RemoteEvent because they replicate on
	change, are automatically sent to late joiners, and clean themselves up
	with the instance — a mob that dies takes its state with it.
]]

local Net = {}

--- Set on any model this system drives, so the client can find them cheaply.
Net.TAG = "NetMob"

--[[
	Publishes a mob's current state. Called by the server in place of posing.

	`signals` carries the few extra numbers an animation needs that the client
	cannot derive — a look angle, a squash amount. Kept to numbers on purpose:
	anything richer belongs in the state name instead.
]]
function Net.publish(model: Model, cframe: CFrame, state: string, phase: number, signals: { [string]: number }?)
	if not model or not model.Parent then
		return
	end

	model:SetAttribute(Net.TAG, true)
	model:SetAttribute("NetCFrame", cframe)
	model:SetAttribute("NetState", state)
	model:SetAttribute("NetPhase", phase)

	if signals then
		for key, value in signals do
			model:SetAttribute(`Net_{key}`, value)
		end
	end
end

--- Declares which animation rig a model uses. Set once at spawn.
function Net.setRig(model: Model, animName: string, scale: number?)
	model:SetAttribute("NetAnim", animName)
	model:SetAttribute("NetScale", scale or 1)
end

--- Reads the signals back on the client, in the shape the Anim functions want.
function Net.signals(model: Model): { [string]: number }
	local out = {}
	for key, value in model:GetAttributes() do
		local name = string.match(key, "^Net_(.+)$")
		if name and typeof(value) == "number" then
			out[name] = value
		end
	end
	return out
end

return Net
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="43">
          <Properties>
            <string name="Name">Rig</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Rig — poses a procedurally-built creature.

	Every part carries `Rest` (its CFrame offset from the model origin) and
	`RestSize`, both recorded by Build. Posing is then:

	    part.CFrame = root * scaled(rest) * offset
	    part.Size   = restSize * bodyScale * partScale

	Three things this does that a plain CFrame rig doesn't:

	SQUASH AND STRETCH. `bodyScale` scales part sizes *and* their distance from
	the root together, so a creature can genuinely compress before a jump and
	stretch in the air. Fake it with translation alone and the silhouette stays
	rigid, which is the difference between "an animal" and "a model being slid
	around".

	BLENDING. `blend` eases the current pose toward the target every frame
	instead of snapping. Nothing in the animation set has to hand-author a
	transition; the rate parameter decides how snappy a given state is —
	high for a dash, low for breathing.

	CACHED BINDING. Walking GetDescendants sixty times a second for a
	twenty-part creature is pure waste, so bind once and hold the list.
]]

local Rig = {}
Rig.__index = Rig

local ONE = Vector3.new(1, 1, 1)

--- Rotation about a point below the part's centre — ears, jaws, legs, tails.
function Rig.hinge(height: number, pitch: number, roll: number?): CFrame
	local half = height / 2
	return CFrame.new(0, -half, 0) * CFrame.Angles(math.rad(pitch), 0, math.rad(roll or 0)) * CFrame.new(0, half, 0)
end

function Rig.bind(model: Model)
	local parts = {}

	for _, descendant in model:GetDescendants() do
		if not descendant:IsA("BasePart") then
			continue
		end
		descendant.Anchored = true

		local rest = descendant:GetAttribute("Rest")
		if rest then
			table.insert(parts, {
				part = descendant,
				name = descendant.Name,
				rest = rest,
				restSize = descendant:GetAttribute("RestSize") or descendant.Size,
			})
		end
	end

	return setmetatable({
		model = model,
		parts = parts,
		--- Smoothed pose state, so blending survives across frames.
		currentRoot = CFrame.identity,
		currentScale = ONE,
		currentOffsets = {},
	}, Rig)
end

--- Snaps straight to a pose with no easing. Use on spawn.
function Rig:apply(rootCFrame: CFrame, offsets: { [string]: CFrame }?, bodyScale: Vector3?)
	local scale = bodyScale or ONE

	for _, entry in self.parts do
		local rest = entry.rest
		-- Scale the offset from the root as well as the part itself, or the
		-- creature inflates instead of squashing.
		local scaled = CFrame.new(rest.Position * scale) * (rest - rest.Position)

		local extra = offsets and offsets[entry.name]
		entry.part.CFrame = if extra then rootCFrame * scaled * extra else rootCFrame * scaled
		entry.part.Size = entry.restSize * scale
	end

	self.currentRoot = rootCFrame
	self.currentScale = scale
	self.currentOffsets = offsets or {}
end

--- Eases toward a pose. `rate` is roughly "fraction of the remaining gap
--- closed per 60th of a second"; 0.15 is soft, 0.6 is snappy.
function Rig:blend(rootCFrame: CFrame, offsets: { [string]: CFrame }?, bodyScale: Vector3?, rate: number, dt: number)
	offsets = offsets or {}
	local scale = bodyScale or ONE

	-- Frame-rate independent easing. Without this the animation visibly
	-- changes speed when the frame rate does.
	local alpha = 1 - (1 - math.clamp(rate, 0.01, 1)) ^ (dt * 60)

	self.currentRoot = self.currentRoot:Lerp(rootCFrame, alpha)
	self.currentScale = self.currentScale:Lerp(scale, alpha)

	for _, entry in self.parts do
		local target = offsets[entry.name] or CFrame.identity
		local current = self.currentOffsets[entry.name] or CFrame.identity
		local blended = current:Lerp(target, alpha)
		self.currentOffsets[entry.name] = blended

		local rest = entry.rest
		local scaled = CFrame.new(rest.Position * self.currentScale) * (rest - rest.Position)

		entry.part.CFrame = self.currentRoot * scaled * blended
		entry.part.Size = entry.restSize * self.currentScale
	end
end

--- A named part, for effects that need to attach to one (eyes, trails).
function Rig:find(name: string): BasePart?
	for _, entry in self.parts do
		if entry.name == name then
			return entry.part
		end
	end
	return nil
end

function Rig:destroy()
	table.clear(self.parts)
	table.clear(self.currentOffsets)
end

return Rig
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="44">
          <Properties>
            <string name="Name">Setsuna</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Setsuna — the puppeteer.

	Built as a silhouette problem. The other three bosses are animals and a
	statue: mass, teeth, weight. This one has to read as a *person* who is
	wrong, which is a harder note and needs the opposite construction — narrow,
	tall, still, and hung rather than standing.

	Three specific choices carry it:

	FLOATING. There is no ground contact. The legs taper to points and hang
	slightly apart, and the whole figure sits above y = 0 with nothing under
	it. A thing that does not touch the floor is unsettling before you know why.

	THE CROSSBAR. A horizontal beam above the shoulders with threads dropping
	from it, so the boss reads as a marionette that is *also* the puppeteer.
	It is the entire character in one part.

	NO FACE. A blank pale mask with two dark slots and nothing else. Every
	other mob in this game has eyes that track; this one has holes.

	Authored facing -Z, hanging at about y = 3.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local ROBE = Color3.fromRGB(38, 34, 48)
local ROBE_LIGHT = Color3.fromRGB(58, 52, 74)
local MASK = Color3.fromRGB(226, 222, 214)
local THREAD = Color3.fromRGB(196, 178, 132)
local GLOW = Color3.fromRGB(188, 120, 236)

return function(origin: CFrame): Model
	local ctx = Build.begin("Setsuna", origin)

	----------------------------------------------------------------------
	-- The crossbar, and the threads coming off it
	----------------------------------------------------------------------

	ctx:block({
		name = "Crossbar",
		size = V(13, 0.55, 0.55),
		at = V(0, 12.6, 0),
		color = THREAD,
		material = Enum.Material.Wood,
	})
	ctx:block({
		name = "CrossbarSpine",
		size = V(0.55, 0.55, 5),
		at = V(0, 12.6, 0),
		color = THREAD,
		material = Enum.Material.Wood,
	})

	-- Slack threads dropping to the shoulders and wrists. Thin, many, and
	-- deliberately not all the same length.
	local drops = { { 5.2, 4.4 }, { -5.2, 4.4 }, { 2.2, 3.1 }, { -2.2, 3.1 }, { 3.6, 5.6 }, { -3.6, 5.6 } }
	for index, drop in drops do
		ctx:block({
			name = `Thread{index}`,
			size = V(0.09, drop[2], 0.09),
			at = V(drop[1], 12.6 - drop[2] / 2, 0),
			color = THREAD,
			material = Enum.Material.SmoothPlastic,
		})
	end

	----------------------------------------------------------------------
	-- The body: narrow, hanging, tapering to nothing
	----------------------------------------------------------------------

	ctx:block({
		name = "Torso",
		size = V(2.6, 4.4, 1.7),
		at = V(0, 7.4, 0),
		color = ROBE,
		material = Enum.Material.Fabric,
	})
	ctx:block({
		name = "Chest",
		size = V(3.1, 1.5, 2.0),
		at = V(0, 8.9, 0),
		color = ROBE_LIGHT,
		material = Enum.Material.Fabric,
	})
	ctx:glow({ name = "Core", size = V(0.9, 0.9, 0.5), at = V(0, 8.2, -0.9), color = GLOW })

	-- Robe skirt: three tapering blocks, not one, so it reads as cloth hanging
	-- rather than a cone stuck on.
	ctx:block({
		name = "RobeUpper",
		size = V(3.0, 2.4, 2.1),
		at = V(0, 4.6, 0.1),
		color = ROBE,
		material = Enum.Material.Fabric,
	})
	ctx:block({
		name = "RobeMid",
		size = V(2.4, 2.2, 1.7),
		at = V(0, 2.9, 0.2),
		color = ROBE,
		material = Enum.Material.Fabric,
	})
	ctx:block({
		name = "RobeHem",
		size = V(1.7, 1.8, 1.2),
		at = V(0, 1.5, 0.3),
		color = Color3.fromRGB(28, 24, 36),
		material = Enum.Material.Fabric,
	})

	-- Legs that end in points and never reach the floor.
	ctx:pair(
		"block",
		{ name = "Leg", size = V(0.6, 3.2, 0.6), at = V(0.75, 1.4, 0.2), rot = V(6, 0, 3), color = ROBE_LIGHT }
	)
	ctx:pair("block", {
		name = "Toe",
		size = V(0.3, 1.1, 0.3),
		at = V(0.85, -0.2, 0.35),
		rot = V(10, 0, 5),
		color = Color3.fromRGB(24, 22, 30),
	})

	----------------------------------------------------------------------
	-- Arms: long, thin, and hung from the crossbar
	----------------------------------------------------------------------

	ctx:pair("block", { name = "Shoulder", size = V(1.1, 1.1, 1.3), at = V(1.8, 9.0, 0), color = ROBE_LIGHT })
	ctx:pair(
		"block",
		{ name = "Arm", size = V(0.62, 4.6, 0.62), at = V(2.3, 6.6, -0.1), rot = V(0, 0, 5), color = ROBE }
	)
	ctx:pair(
		"block",
		{ name = "Forearm", size = V(0.55, 4.0, 0.55), at = V(2.7, 2.7, -0.6), rot = V(-12, 0, 4), color = ROBE }
	)

	-- Hands: splayed, oversized, the only part that isn't narrow.
	ctx:pair(
		"block",
		{ name = "Hand", size = V(1.5, 1.3, 0.5), at = V(2.9, 0.7, -1.1), rot = V(-18, 0, 0), color = MASK }
	)
	for side = -1, 1, 2 do
		for finger = 0, 3 do
			ctx:block({
				name = `Finger{side}_{finger}`,
				size = V(0.16, 1.2, 0.16),
				at = V(side * (2.4 + finger * 0.34), 0.05, -1.4),
				rot = V(-24, 0, side * (finger - 1.5) * 5),
				color = MASK,
			})
		end
	end

	----------------------------------------------------------------------
	-- The head
	----------------------------------------------------------------------

	ctx:block({ name = "Neck", size = V(0.7, 1.0, 0.7), at = V(0, 10.1, 0), color = ROBE_LIGHT })
	ctx:block({
		name = "Head",
		size = V(1.9, 2.3, 1.7),
		at = V(0, 11.3, 0),
		color = ROBE,
		material = Enum.Material.Fabric,
	})
	-- The mask sits proud of the head, like something worn rather than a face.
	ctx:block({
		name = "Mask",
		size = V(1.75, 2.1, 0.35),
		at = V(0, 11.3, -0.9),
		color = MASK,
		material = Enum.Material.Marble,
	})
	ctx:pair(
		"glow",
		{ name = "Eye", size = V(0.3, 0.62, 0.14), at = V(0.42, 11.6, -1.06), color = Color3.fromRGB(20, 16, 26) }
	)
	-- One thin split down the mask. Damage, or a mouth. Never resolved.
	ctx:block({
		name = "MaskSplit",
		size = V(0.1, 1.1, 0.4),
		at = V(-0.2, 10.7, -1.02),
		rot = V(0, 0, 7),
		color = Color3.fromRGB(120, 116, 112),
	})

	-- Hair, hanging forward over the mask's edges.
	for side = -1, 1, 2 do
		ctx:block({
			name = `Hair{side}`,
			size = V(0.45, 3.4, 0.9),
			at = V(side * 1.0, 10.9, -0.2),
			rot = V(0, 0, side * -4),
			color = Color3.fromRGB(22, 20, 28),
		})
	end
	ctx:block({ name = "HairBack", size = V(2.0, 3.8, 0.7), at = V(0, 10.6, 0.85), color = Color3.fromRGB(22, 20, 28) })

	return ctx:finishRigged()
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="45">
          <Properties>
            <string name="Name">VorpalBunny</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Vorpal Bunny — small, white, and the reason the northern road is closed.

	Design intent: the horror is that it reads as *harmless*. A player who has
	seen a rabbit before will not respect it, which is exactly the joke. So the
	silhouette stays a plain round bunny and every menacing signal is a detail
	you only notice up close — the red eyes, the oversized incisors, the
	stained forepaws, the fact that it's crouched to spring rather than sitting.

	Authored facing -Z, standing on y = 0.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local FUR = Color3.fromRGB(243, 241, 234)
local FUR_BRIGHT = Color3.fromRGB(255, 254, 250)
local PINK = Color3.fromRGB(232, 166, 178)
local EYE = Color3.fromRGB(226, 34, 44)
local TOOTH = Color3.fromRGB(252, 251, 244)
local STAIN = Color3.fromRGB(150, 32, 36)

return function(origin: CFrame): Model
	local ctx = Build.begin("VorpalBunny", origin)

	-- Core mass, crouched forward rather than sitting upright.
	ctx:ball({ name = "Body", size = V(2.3, 2.05, 2.7), at = V(0, 1.22, 0.1), color = FUR })
	ctx:ball({ name = "Chest", size = V(1.95, 1.7, 1.6), at = V(0, 1.1, -1.05), color = FUR_BRIGHT })
	ctx:ball({ name = "Tail", size = V(0.75, 0.75, 0.7), at = V(0, 1.5, 1.5), color = FUR_BRIGHT })

	-- Head
	ctx:ball({ name = "Head", size = V(1.6, 1.55, 1.6), at = V(0, 2.16, -1.2), color = FUR })
	ctx:ball({ name = "Muzzle", size = V(0.9, 0.72, 0.85), at = V(0, 1.94, -1.9), color = FUR_BRIGHT })
	ctx:ball({ name = "Nose", size = V(0.24, 0.2, 0.22), at = V(0, 2.02, -2.28), color = PINK })

	-- Ears: swept back, not perked. It is already hunting.
	ctx:pair("block", {
		name = "Ear",
		size = V(0.34, 2.45, 0.62),
		at = V(0.4, 3.3, -0.92),
		rot = V(-16, 0, 9),
		color = FUR,
	})
	ctx:pair("block", {
		name = "EarInner",
		size = V(0.2, 1.85, 0.44),
		at = V(0.42, 3.24, -1.06),
		rot = V(-16, 0, 9),
		color = PINK,
	})

	-- Eyes. Neon, and larger than a real rabbit's -- the one honest tell.
	ctx:pair("glow", { name = "Eye", size = V(0.36, 0.36, 0.36), at = V(0.5, 2.32, -1.84), color = EYE })

	-- Incisors, far too long.
	ctx:pair("block", { name = "Tooth", size = V(0.17, 0.44, 0.14), at = V(0.13, 1.68, -2.12), color = TOOTH })

	-- Forepaws, stained. Whatever it has been doing, it has been doing a lot.
	ctx:pair("ball", { name = "Paw", size = V(0.62, 0.52, 0.8), at = V(0.6, 0.3, -1.2), color = FUR_BRIGHT })
	ctx:pair("ball", { name = "PawStain", size = V(0.5, 0.3, 0.62), at = V(0.6, 0.16, -1.32), color = STAIN })

	-- Haunches, coiled.
	ctx:pair("ball", { name = "Haunch", size = V(1.0, 1.15, 1.45), at = V(0.98, 0.82, 0.5), color = FUR })
	ctx:pair("block", { name = "Foot", size = V(0.72, 0.44, 1.6), at = V(0.92, 0.24, -0.1), color = FUR_BRIGHT })

	return ctx:finishRigged()
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="46">
          <Properties>
            <string name="Name">Wethermon</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Wethermon the Tombguard — four hundred years undefeated, and it shows.

	Built as architecture rather than anatomy: flat planes, hard corners, heavy
	pauldrons, a halberd taller than the player. Nothing about it should look
	fast. The only curves on the whole model are the halberd haft.

	The teal glow is the single living element — visor, chest core, and the
	seams between plates — so in a dark tomb you read the light before the
	shape, which is the intended first impression.

	Authored facing -Z, standing on y = 0.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local STONE = Color3.fromRGB(98, 92, 80)
local STONE_DARK = Color3.fromRGB(66, 62, 55)
local IRON = Color3.fromRGB(44, 42, 48)
local BRONZE = Color3.fromRGB(142, 116, 68)
local GLOW = Color3.fromRGB(86, 224, 212)
local CLOTH = Color3.fromRGB(52, 46, 44)

return function(origin: CFrame): Model
	local ctx = Build.begin("Wethermon", origin)

	-- Legs
	ctx:pair("block", { name = "Boot", size = V(1.55, 0.9, 2.1), at = V(1.0, 0.45, -0.3), color = IRON })
	ctx:pair("block", { name = "Shin", size = V(1.25, 2.0, 1.35), at = V(1.0, 1.75, 0), color = STONE_DARK })
	ctx:pair("block", { name = "Knee", size = V(1.4, 0.5, 1.5), at = V(1.0, 2.85, -0.1), color = BRONZE })
	ctx:pair("block", { name = "Thigh", size = V(1.4, 1.6, 1.5), at = V(1.0, 3.85, 0), color = STONE })

	-- Core
	ctx:block({ name = "Hips", size = V(2.9, 1.2, 1.9), at = V(0, 4.9, 0), color = IRON })
	ctx:block({ name = "Torso", size = V(3.4, 3.0, 2.05), at = V(0, 6.5, 0), color = STONE })
	ctx:block({ name = "Plastron", size = V(3.05, 1.9, 0.5), at = V(0, 6.9, -1.12), color = BRONZE })
	ctx:glow({ name = "Core", size = V(0.85, 0.85, 0.28), at = V(0, 6.9, -1.4), color = GLOW })
	ctx:glow({ name = "Seam", size = V(2.6, 0.1, 0.24), at = V(0, 5.75, -1.08), color = GLOW })

	-- Pauldrons, wider than the torso so the silhouette is a wedge.
	ctx:pair("block", {
		name = "Pauldron",
		size = V(1.9, 1.35, 2.35),
		at = V(2.25, 7.6, 0),
		rot = V(0, 0, -14),
		color = STONE,
	})
	ctx:pair("block", {
		name = "PauldronRim",
		size = V(1.95, 0.28, 2.4),
		at = V(2.3, 6.95, 0),
		rot = V(0, 0, -14),
		color = BRONZE,
	})

	-- Arms
	ctx:pair("block", { name = "UpperArm", size = V(1.05, 1.9, 1.15), at = V(2.35, 6.15, 0), color = STONE_DARK })
	ctx:pair("block", { name = "Bracer", size = V(1.15, 1.6, 1.25), at = V(2.4, 4.75, 0), color = IRON })
	ctx:pair("block", { name = "Hand", size = V(1.05, 0.9, 1.15), at = V(2.42, 3.8, 0), color = STONE_DARK })

	-- Helm
	ctx:block({ name = "Neck", size = V(0.95, 0.6, 0.95), at = V(0, 8.2, 0), color = IRON })
	ctx:block({ name = "Helm", size = V(1.6, 1.7, 1.7), at = V(0, 9.05, -0.05), color = STONE })
	ctx:block({ name = "HelmBrow", size = V(1.7, 0.34, 1.8), at = V(0, 9.72, -0.05), color = BRONZE })
	ctx:block({ name = "Crest", size = V(0.28, 1.25, 1.5), at = V(0, 10.35, 0.1), color = BRONZE })
	ctx:glow({ name = "Visor", size = V(1.15, 0.2, 0.16), at = V(0, 9.05, -0.92), color = GLOW })

	-- Cloak, hanging dead straight. Four centuries of standing still.
	ctx:block({ name = "Cloak", size = V(3.3, 5.4, 0.28), at = V(0, 5.9, 1.25), rot = V(-4, 0, 0), color = CLOTH })
	ctx:block({ name = "CloakClasp", size = V(3.5, 0.4, 0.5), at = V(0, 8.35, 1.15), color = BRONZE })

	-- Halberd, held in the right hand and taller than the guardian.
	ctx:cylinder({
		name = "Haft",
		size = V(11.0, 0.34, 0.34),
		at = V(2.72, 5.6, -0.5),
		rot = V(0, 0, 90),
		color = STONE_DARK,
		material = Enum.Material.Wood,
	})
	ctx:block({ name = "Blade", size = V(0.22, 2.4, 1.5), at = V(2.72, 10.1, -0.95), color = IRON })
	ctx:wedge({ name = "BladeTip", size = V(0.22, 1.5, 1.5), at = V(2.72, 11.9, -0.95), color = IRON })
	ctx:block({ name = "Spike", size = V(0.2, 1.4, 0.5), at = V(2.72, 11.15, 0.2), color = BRONZE })
	ctx:glow({ name = "BladeEdge", size = V(0.1, 2.3, 0.14), at = V(2.72, 10.1, -1.66), color = GLOW })

	return ctx:finishRigged()
end
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="ModuleScript" referent="47">
        <Properties>
          <string name="Name">Network</string>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Network — the RemoteEvent registry.

	The server creates the folder and its events on first require; clients wait
	for them. One place to look for every wire between the two halves.

	    Network.BossState:FireClient(player, payload)   -- server
	    Network.BossState.OnClientEvent:Connect(fn)     -- client

	Trust: right now the client is believed about its own stamina and its
	invulnerability window, because both live in the client-side store. That is
	fine for a solo vertical slice and is the first thing to fix when
	progression moves server-side. Damage dealt and taken is already decided by
	the server.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local FOLDER_NAME = "Remotes"

local EVENTS = {
	--- Server -> client. Boss payload table, or nil when the fight ends.
	"BossState",
	--- Server -> client. (amount: number)
	"PlayerDamaged",
	--- Server -> client. (amount: number, kind: string, worldPosition: Vector3)
	"BossDamaged",
	--- Server -> client. (encounterId: string, rewardXp: number)
	"EncounterCleared",
	--- Client -> server. (comboIndex: number)
	"Attack",
	--- Client -> server. (holding: boolean) -- guard raised or lowered.
	"Block",
	--- Client -> server. No arguments. Asks the server to open a parry window;
	--- the server decides whether it may, and later whether anything landed
	--- inside it. The client never asserts a successful parry.
	"Parry",
	--- Server -> client. (accepted: boolean, cooldown: number, window: number)
	"ParryResult",
	--- Server -> client. (kind, data?) -- landed a hit or absorbed one; drives
	--- hitstop, camera shake, screen flash and knockback.
	"Impact",
	--- Server -> client. (item: table) -- something dropped for you.
	"Loot",
	--- Server -> client. (enemyId: string, rewardXp: number)
	"EnemyDefeated",
	--- Server -> client. (hour: number, isNight: boolean) -- a few times a
	--- second; clients interpolate their own for anything finer.
	"TimeOfDay",
	--- Server -> client. (beat: string) -- boss intro cinematic beats.
	"BossIntro",
	--- Server -> client. (kind: string) -- training-yard progress the client
	--- cannot observe on its own, e.g. a dummy actually taking a hit.
	"TutorialHit",
	--- Client -> server. (originId: string) -- routes the spawn point.
	"OriginChosen",
	--- Server -> client. (profile: table?) -- an assisted attack is inbound;
	--- show the focus effect for profile.realLead seconds. nil clears it.
	"ParryPrompt",
	--- Client -> server. (mode: "off"|"adaptive"|"always")
	"AssistMode",
	--- Client -> server. (drillName: string?) -- asks the instructor to run a
	--- live exercise, e.g. swinging at you so the parry lesson has something
	--- to parry. nil stops whatever is running.
	"TutorialDrill",
	--- Client -> server. (staminaFraction: number) -- reported a few times a
	--- second so the AI can tell when you're out of options. Cosmetic only.
	"Vitals",
	--- Server -> client. (blocked: number, parried: boolean)
	"GuardResult",
	--- Client -> server. (duration: number) -- dodge invulnerability.
	"Invulnerable",
	--- Client -> server. ({ name, race, style }) once creation finishes, so the
	--- server can put the right weapon in your hands.
	"CharacterChosen",

	----------------------------------------------------------------------
	-- Town
	----------------------------------------------------------------------
	--- Server -> client. (profile: table) -- gold, materials, items, quests,
	--- kills, titles. Re-sent whole on every change; it's small, and a client
	--- that can't drift is one fewer class of bug.
	"Profile",
	--- Server -> client. (questId: string, state: string, progress: number)
	--- state is "accepted" | "progress" | "ready" | "done".
	"QuestUpdate",
	--- Client -> server. (npcId: string, action: string, arg: any) -- the one
	--- channel for everything a townsperson can do: "talk", "accept",
	--- "turnin", "buy", "sell", "rest", "heal", "close".
	"Interact",
	--- Server -> client. (payload: table?) -- open a conversation, or nil to
	--- close it. Carries the speaker, their line, and the buttons to offer.
	"Dialogue",
	--- Server -> client. (payload: table?) -- open a shop, or nil to close.
	"Shop",
	--- Server -> client. (payload: table?) -- open the crafting bench, or nil.
	"Craft",
	--- Client -> server. ("start" | "leave") -- Colosseum run control.
	"Arena",
	--- Server -> client. (payload: table?) -- wave counter and banner, or nil
	--- when the run is over and the HUD should clear.
	"ArenaState",
	--- Server -> client. ({state, ...}) -- "cast" | "bite" | "caught" |
	--- "missed" | "early". Casting and striking both come back up the Interact
	--- channel, since a jetty is just another Interactable.
	"Fishing",
}

local Network = {}

local folder: Folder
if RunService:IsServer() then
	folder = ReplicatedStorage:FindFirstChild(FOLDER_NAME)
	if not folder then
		folder = Instance.new("Folder")
		folder.Name = FOLDER_NAME
		folder.Parent = ReplicatedStorage
	end

	for _, name in EVENTS do
		if not folder:FindFirstChild(name) then
			local remote = Instance.new("RemoteEvent")
			remote.Name = name
			remote.Parent = folder
		end
	end
else
	folder = ReplicatedStorage:WaitForChild(FOLDER_NAME)
end

for _, name in EVENTS do
	Network[name] = folder:WaitForChild(name)
end

return Network
]]></string>
        </Properties>
      </Item>
      <Item class="Folder" referent="48">
        <Properties>
          <string name="Name">Util</string>
        </Properties>
        <Item class="ModuleScript" referent="49">
          <Properties>
            <string name="Name">Build</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Build — part-assembly helpers for procedural mob models.

	Every mob is built from primitives rather than imported meshes, so the
	whole creature is version-controlled text you can tweak a number in.

	Convention: a mob is authored facing **-Z**, standing on **y = 0**, centred
	on x = 0. The gallery (and later, spawners) rotate them as needed. Keeping
	that consistent is what lets one layout routine place any of them.

	Assembly: all parts weld to an invisible Root, and only the Root is
	anchored. Move or unanchor the Root and the whole creature follows, so
	these same models can be rigged later without rebuilding them.

	    local ctx = Build.begin("Vorpal Bunny", origin)
	    ctx:ball({ name = "Head", size = V(1.6, 1.55, 1.6), at = V(0, 2.15, -1.15), color = FUR })
	    return ctx:finish()
]]

local Render = require(script.Parent.Render)

local Build = {}

local Context = {}
Context.__index = Context

--- Position + rotation in one call. Angles are degrees.
function Build.cf(x: number, y: number, z: number, rx: number?, ry: number?, rz: number?): CFrame
	local cframe = CFrame.new(x, y, z)
	if rx or ry or rz then
		cframe *= CFrame.Angles(math.rad(rx or 0), math.rad(ry or 0), math.rad(rz or 0))
	end
	return cframe
end

function Build.begin(name: string, origin: CFrame)
	local model = Instance.new("Model")
	model.Name = name

	local root = Instance.new("Part")
	root.Name = "Root"
	root.Size = Vector3.new(1, 1, 1)
	root.CFrame = origin
	root.Transparency = 1
	root.CanCollide = false
	root.CanQuery = false
	root.CanTouch = false
	root.Massless = true
	root.Parent = model

	model.PrimaryPart = root

	return setmetatable({
		model = model,
		root = root,
		origin = origin,
		_parts = {},
	}, Context)
end

type Spec = {
	name: string?,
	size: Vector3,
	--- Position relative to the mob's origin.
	at: Vector3?,
	--- Rotation in degrees, applied after position.
	rot: Vector3?,
	color: Color3,
	material: Enum.Material?,
	transparency: number?,
	reflectance: number?,
	collide: boolean?,
}

function Context:_spawn(className: string, spec: Spec, shape: Enum.PartType?): BasePart
	local part = Instance.new(className)
	part.Name = spec.name or "Part"
	part.Size = spec.size

	if shape and className == "Part" then
		part.Shape = shape
	end

	local at = spec.at or Vector3.zero
	local offset = CFrame.new(at)
	if spec.rot then
		offset *= CFrame.Angles(math.rad(spec.rot.X), math.rad(spec.rot.Y), math.rad(spec.rot.Z))
	end
	part.CFrame = self.origin * offset

	part.Color = spec.color
	part.Material = spec.material or Enum.Material.SmoothPlastic
	part.Transparency = spec.transparency or 0
	part.Reflectance = spec.reflectance or 0
	part.CanCollide = spec.collide == true
	part.CanQuery = false
	part.CanTouch = false
	part.Massless = true
	part.TopSurface = Enum.SurfaceType.Smooth
	part.BottomSurface = Enum.SurfaceType.Smooth
	-- Shadows come from mass, not from every piece of trim. See Util/Render.
	Render.applyShadow(part)
	part.Parent = self.model

	table.insert(self._parts, part)
	return part
end

function Context:block(spec: Spec): BasePart
	return self:_spawn("Part", spec, Enum.PartType.Block)
end

function Context:ball(spec: Spec): BasePart
	return self:_spawn("Part", spec, Enum.PartType.Ball)
end

--- Cylinders run along their **X** axis in Roblox, so Size is
--- (length, diameter, diameter). Rotate with `rot` to stand one upright.
function Context:cylinder(spec: Spec): BasePart
	return self:_spawn("Part", spec, Enum.PartType.Cylinder)
end

function Context:wedge(spec: Spec): BasePart
	return self:_spawn("WedgePart", spec)
end

--- Mirrors a spec across x = 0 and builds both sides. `fn` is the shape
--- method name. Saves writing every limb twice and guarantees symmetry.
function Context:pair(fn: string, spec: Spec)
	local at = spec.at or Vector3.zero
	local rot = spec.rot

	local left = table.clone(spec)
	left.name = (spec.name or "Part") .. "L"
	local right = table.clone(spec)
	right.name = (spec.name or "Part") .. "R"
	right.at = Vector3.new(-at.X, at.Y, at.Z)
	if rot then
		-- Flip the axes that would otherwise lean both limbs the same way.
		right.rot = Vector3.new(rot.X, -rot.Y, -rot.Z)
	end

	return (self :: any)[fn](self, left), (self :: any)[fn](self, right)
end

--- Emissive detail: eyes, runes, visors.
function Context:glow(spec: Spec): BasePart
	local part = self:_spawn("Part", spec, spec.shape or Enum.PartType.Ball)
	part.Material = Enum.Material.Neon
	return part
end

--- Records each part's offset from the origin as a `Rest` attribute. Rig reads
--- these to pose a creature: `part.CFrame = root * rest * animationOffset`.
--- Without it there is no way to recover the bind pose once anything moves.
function Context:_recordRest()
	for _, part in self._parts do
		part:SetAttribute("Rest", self.origin:ToObjectSpace(part.CFrame))
		-- Size too, so Rig can squash and stretch. Without a recorded original
		-- there is nothing to scale *from* once a frame has changed it.
		part:SetAttribute("RestSize", part.Size)
	end
	self.root:SetAttribute("Rest", CFrame.identity)
	self.root:SetAttribute("RestSize", self.root.Size)
end

--- Welds everything to the Root and anchors only the Root. For scenery that
--- never moves a limb.
function Context:finish(): Model
	self:_recordRest()

	for _, part in self._parts do
		local weld = Instance.new("WeldConstraint")
		weld.Part0 = self.root
		weld.Part1 = part
		weld.Parent = self.root
	end

	self.root.Anchored = true
	for _, part in self._parts do
		part.Anchored = false
	end

	return self.model
end

--[[
	Anchors every part and adds no welds, so each one can be posed
	independently. Use for anything that animates; drive it through Rig.

	`keepCollision` preserves each part's own CanCollide. Creatures want it
	off — the server owns their position and colliding with them fights that —
	but scenery that animates still has to be solid. Without this, making trees
	sway in the wind silently let the player walk straight through them.
]]
function Context:finishRigged(keepCollision: boolean?): Model
	self:_recordRest()

	self.root.Anchored = true
	for _, part in self._parts do
		part.Anchored = true
		if not keepCollision then
			part.CanCollide = false
		end
	end

	self.model:SetAttribute("Rigged", true)
	return self.model
end

return Build
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="50">
          <Properties>
            <string name="Name">Create</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Create — declarative Instance construction.

	    Create("TextButton", {
	        Size = UDim2.fromScale(0.2, 0.08),
	        Text = "DIVE IN",
	        Activated = function() print("clicked") end,   -- events connect by name
	        Parent = root,

	        Create("UICorner", { CornerRadius = UDim.new(0, 6) }),  -- array part = children
	    })

	Rules:
	  * string key whose target is an RBXScriptSignal + function value -> :Connect
	  * string key otherwise -> property assignment
	  * numeric key -> child, parented to the new instance
	  * `Parent` is always applied LAST, after children and properties, so the
	    instance enters the DataModel fully formed (one reflow, not N).
]]

local function Create(className: string, spec: { [any]: any }?): any
	local instance = Instance.new(className)
	local props = spec or {}
	local parent = nil

	for key, value in props do
		if key == "Parent" then
			parent = value
			continue
		end

		if type(key) == "number" then
			value.Parent = instance
			continue
		end

		local existing = (instance :: any)[key]
		if typeof(existing) == "RBXScriptSignal" and type(value) == "function" then
			existing:Connect(value)
		else
			(instance :: any)[key] = value
		end
	end

	instance.Parent = parent
	return instance
end

return Create
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="51">
          <Properties>
            <string name="Name">Motion</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Motion — tween helpers with the project's easing baked in.

	Everything animated in the UI goes through here so timing stays
	consistent. If a duration feels wrong, fix it in Theme.Time, not
	at forty call sites.
]]

local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")

local Theme = require(script.Parent.Parent.Config.Theme)

local Motion = {}

--- Standard UI move. Returns the Tween so callers can wait on .Completed.
function Motion.to(instance: Instance, duration: number, props: { [string]: any }, style: EnumItem?, delay: number?)
	local info = TweenInfo.new(duration, style or Theme.Ease.Standard, Enum.EasingDirection.Out, 0, false, delay or 0)
	local tween = TweenService:Create(instance, info, props)
	tween:Play()
	return tween
end

--- Loops forever, reversing. Used for low-health pulses and idle glows.
function Motion.pulse(instance: Instance, duration: number, props: { [string]: any })
	local info = TweenInfo.new(duration, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true)
	local tween = TweenService:Create(instance, info, props)
	tween:Play()
	return tween
end

--- Snappy overshoot for things that should feel like they *land* --
--- scenario banners, crit numbers, panel opens.
function Motion.pop(instance: Instance, duration: number, props: { [string]: any })
	local info = TweenInfo.new(duration, Enum.EasingStyle.Back, Enum.EasingDirection.Out)
	local tween = TweenService:Create(instance, info, props)
	tween:Play()
	return tween
end

--- Per-frame driver for values TweenService can't touch (numbers held in
--- Luau, canvas offsets, custom interpolation). `fn` gets alpha 0->1.
--- Returns a cancel function.
function Motion.drive(duration: number, fn: (alpha: number) -> (), onDone: (() -> ())?)
	local elapsed = 0
	local connection
	connection = RunService.RenderStepped:Connect(function(dt)
		elapsed += dt
		local alpha = math.clamp(elapsed / duration, 0, 1)
		fn(alpha)
		if alpha >= 1 then
			connection:Disconnect()
			if onDone then
				onDone()
			end
		end
	end)

	return function()
		if connection.Connected then
			connection:Disconnect()
		end
	end
end

--- Frame-rate independent exponential smoothing.
--- `speed` is roughly "how many e-folds per second".
function Motion.damp(current: number, target: number, speed: number, dt: number): number
	return target + (current - target) * math.exp(-speed * dt)
end

--------------------------------------------------------------------------------
-- Entrances
--------------------------------------------------------------------------------

--[[
	ONE ENTRANCE, USED EVERYWHERE.

	Panels animated, but everything inside them appeared fully formed the
	instant the panel did — a tab switch was a hard cut from one wall of text to
	another, which is the difference between an interface that was designed and
	one that was assembled. These two functions are the whole fix, and they are
	deliberately small: a short rise and a fade, nothing that could be mistaken
	for a transition in its own right.

	The rise is 8 pixels. That is not a lot on purpose — big travel reads as
	sliding, which is a different and much more annoying effect, and at 8px the
	eye registers direction without registering movement.
]]
local RISE = 8

--[[
	One element arriving. `delay` is what `stagger` uses to sequence a list.

	TWO THINGS THIS HAS TO BE CAREFUL ABOUT, both of which are easy to get
	wrong and neither of which fails loudly:

	1. Only touch properties the instance actually has. `TweenService:Create`
	   throws on an unknown property, so naming `TextTransparency` in a shared
	   props table takes out every Frame it is ever handed.
	2. Restore what was there, never assume opaque. Rows in the controls panel
	   deliberately sit at partial transparency once their key has been used;
	   fading them all to 0 would silently undo that and re-light a list the
	   player had already learned their way out of.
]]
function Motion.enter(instance: GuiObject, delay: number?)
	-- A layout owns its children's Position, so the rise would be overwritten
	-- on the next layout pass and is skipped. The fade still carries it.
	local parent = instance.Parent
	local laidOut = parent
		and (parent:FindFirstChildWhichIsA("UIListLayout") or parent:FindFirstChildWhichIsA("UIGridLayout"))

	if not laidOut then
		local settled = instance.Position
		instance.Position = settled + UDim2.fromOffset(0, RISE)
		Motion.to(instance, Theme.Time.Base, { Position = settled }, Theme.Ease.Enter, delay)
	end

	local fade = {}

	local background = instance.BackgroundTransparency
	if background < 1 then
		instance.BackgroundTransparency = 1
		fade.BackgroundTransparency = background
	end

	if instance:IsA("TextLabel") or instance:IsA("TextButton") or instance:IsA("TextBox") then
		local text = instance.TextTransparency
		if text < 1 then
			instance.TextTransparency = 1
			fade.TextTransparency = text
		end
	end

	if instance:IsA("ImageLabel") or instance:IsA("ImageButton") then
		local image = instance.ImageTransparency
		if image < 1 then
			instance.ImageTransparency = 1
			fade.ImageTransparency = image
		end
	end

	if next(fade) then
		Motion.to(instance, Theme.Time.Base, fade, Theme.Ease.Exit, delay)
	end
end

--[[
	A list arriving, one element after another.

	`step` is the gap between neighbours and is the only number that matters
	here. Too small and it is a fade; too large and the player is waiting on the
	interface, which is unforgivable. 25ms across a handful of rows lands the
	whole list inside a fifth of a second while still reading as a sequence.

	Capped rather than scaled: a forty-item inventory staggered at 25ms would
	take a full second to finish drawing, so past `MAX_STAGGERED` everything
	remaining arrives with the last staggered element. The eye cannot follow
	more than a few in sequence anyway.
]]
local MAX_STAGGERED = 10

function Motion.stagger(children: { GuiObject }, step: number?)
	local gap = step or 0.025
	for index, child in children do
		Motion.enter(child, math.min(index - 1, MAX_STAGGERED) * gap)
	end
end

--- Direct children that are worth animating, in layout order. Skips the
--- non-visual helpers (`UIListLayout`, `UIPadding`, strokes) that would
--- otherwise be counted as rows and throw the timing out.
function Motion.rows(parent: Instance): { GuiObject }
	local out = {}
	for _, child in parent:GetChildren() do
		if child:IsA("GuiObject") then
			table.insert(out, child)
		end
	end
	table.sort(out, function(a, b)
		if a.LayoutOrder ~= b.LayoutOrder then
			return a.LayoutOrder < b.LayoutOrder
		end
		return a.Name < b.Name
	end)
	return out
end

return Motion
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="52">
          <Properties>
            <string name="Name">Render</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Render — per-part rendering cost decisions.

	THE PROBLEM. Lighting.Technology is Future with GlobalShadows on, which is
	what makes this world look like anything at all — real shadows, real light
	bounce off the lanterns and forge fires. It is also, by a wide margin, the
	most expensive thing the renderer does, and its cost scales with the number
	of shadow-casting parts. This project has roughly thirty thousand: every
	window mullion, every carrot top, every flower petal, every one of the
	twenty parts in each of forty-four mobs.

	Almost none of them cast a shadow anyone can see. A 0.35-stud mullion
	throws a shadow thinner than a pixel at any distance you would notice it,
	and it costs exactly as much to compute as a wall.

	THE RULE. Shadows come from mass. A part casts one if its smallest
	dimension clears MIN_AXIS *and* its volume clears MIN_VOLUME — so walls,
	roofs, pillars, tree canopies and building shells still ground themselves
	properly, and trim, foliage detail, jars, cutlery and limb segments stop
	paying for shadows nobody was going to look at.

	Deliberately a size rule rather than a per-call flag. A flag gets forgotten
	on the next prop somebody adds; a rule applied inside the builders is
	correct by default and cannot rot.
]]

local Render = {}

--- Smallest dimension a part needs before it is worth a shadow.
Render.MIN_AXIS = 1.6
--- And enough bulk that the shadow reads as a shape rather than a smear.
Render.MIN_VOLUME = 18

--[[
	Decides whether `part` should cast a shadow, and applies it.

	Called from the part builders rather than from call sites, so every model
	in the game — mobs, props, architecture — gets the same treatment without
	anyone having to remember.
]]
function Render.applyShadow(part: BasePart)
	local size = part.Size
	local smallest = math.min(size.X, size.Y, size.Z)
	local volume = size.X * size.Y * size.Z

	part.CastShadow = smallest >= Render.MIN_AXIS and volume >= Render.MIN_VOLUME
end

--- True if a part of this size would cast. Exposed so the rule can be checked
--- without building anything.
function Render.wouldCast(size: Vector3): boolean
	return math.min(size.X, size.Y, size.Z) >= Render.MIN_AXIS and (size.X * size.Y * size.Z) >= Render.MIN_VOLUME
end

return Render
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="53">
          <Properties>
            <string name="Name">Signal</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Signal — minimal event emitter.

	Deliberately tiny: connect, once, fire, destroy. Handlers run in a
	coroutine each so one erroring listener can't stall the rest.
]]

local Signal = {}
Signal.__index = Signal

export type Connection = {
	Connected: boolean,
	Disconnect: (self: Connection) -> (),
}

function Signal.new()
	return setmetatable({
		_handlers = {} :: { [any]: boolean },
	}, Signal)
end

function Signal:Connect(fn: (...any) -> ()): Connection
	self._handlers[fn] = true

	local connection
	connection = {
		Connected = true,
		Disconnect = function()
			if not connection.Connected then
				return
			end
			connection.Connected = false
			self._handlers[fn] = nil
		end,
	}
	return connection
end

--- Fires at most once, then disconnects itself.
function Signal:Once(fn: (...any) -> ()): Connection
	local connection
	connection = self:Connect(function(...)
		connection:Disconnect()
		fn(...)
	end)
	return connection
end

function Signal:Fire(...: any)
	-- Snapshot first: a handler is allowed to disconnect during dispatch.
	local snapshot = {}
	for fn in self._handlers do
		table.insert(snapshot, fn)
	end

	for _, fn in snapshot do
		task.spawn(fn, ...)
	end
end

function Signal:DisconnectAll()
	table.clear(self._handlers)
end

function Signal:Destroy()
	self:DisconnectAll()
end

return Signal
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="54">
          <Properties>
            <string name="Name">Trove</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Trove — collects things that need cleaning up, then cleans them in one call.

	Accepts Instances, RBXScriptConnections, our own Signal connections,
	tables with :Destroy(), and plain functions.

	Every UI component owns a Trove so :destroy() is always one line and
	never leaks a connection.
]]

local Trove = {}
Trove.__index = Trove

function Trove.new()
	return setmetatable({ _items = {} }, Trove)
end

--- Tracks `item` and returns it, so you can wrap inline:
--- `local frame = trove:add(Create("Frame", {...}))`
function Trove:add<T>(item: T): T
	table.insert(self._items, item)
	return item
end

--- Connects to a signal and tracks the connection in one step.
function Trove:connect(signal: any, fn: (...any) -> ())
	return self:add(signal:Connect(fn))
end

local function cleanupItem(item: any)
	local itemType = typeof(item)

	if itemType == "Instance" then
		item:Destroy()
	elseif itemType == "RBXScriptConnection" then
		item:Disconnect()
	elseif itemType == "function" then
		item()
	elseif itemType == "table" then
		if typeof(item.Disconnect) == "function" then
			item:Disconnect()
		elseif typeof(item.Destroy) == "function" then
			item:Destroy()
		elseif typeof(item.destroy) == "function" then
			item:destroy()
		end
	end
end

--- Cleans everything but keeps the Trove usable.
function Trove:clean()
	-- Reverse order so children are torn down before their parents.
	for i = #self._items, 1, -1 do
		cleanupItem(self._items[i])
	end
	table.clear(self._items)
end

Trove.Destroy = Trove.clean
Trove.destroy = Trove.clean

return Trove
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="Folder" referent="55">
        <Properties>
          <string name="Name">World</string>
        </Properties>
        <Item class="ModuleScript" referent="56">
          <Properties>
            <string name="Name">Detail</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Detail — the props that turn boxes into a place.

	Everything in this game is built from primitives, which means "good
	graphics" cannot come from better textures. It has to come from the three
	things primitives *can* do:

	1. SILHOUETTE. A wall with a framed window, a sill, shutters and a flower
	   box under it reads as a house. The same wall with a flat dark rectangle
	   painted on it reads as a placeholder. Detail costs parts, not fidelity.
	2. LIGHT. Small lights in many places — a forge, a hearth, a lantern, a
	   window — do more for a night scene than any amount of surface work,
	   because they give the geometry something to be shaped by.
	3. VARIATION. Every prop here takes an RNG and jitters its own colour,
	   rotation and proportions. Ten identical barrels read as one barrel
	   copied ten times; ten barrels that each lean differently read as a yard.

	Every function returns a Model parented to `parent`, authored around the
	origin CFrame it's given. They are all pure scenery: anchored, welded, and
	never touched again after the town is built.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Build = require(ReplicatedStorage.Shared.Util.Build)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Scale = require(ReplicatedStorage.Shared.Config.Scale)

local Detail = {}

local V = Vector3.new

local TIMBER = Color3.fromRGB(88, 64, 44)
local TIMBER_DARK = Color3.fromRGB(62, 44, 30)
local IRON = Color3.fromRGB(64, 62, 66)
local IRON_DARK = Color3.fromRGB(42, 40, 44)

--- Nudges a colour by a few percent in each channel. Applied to every prop so
--- no two are literally the same shade.
local function vary(color: Color3, rng: Random, amount: number?): Color3
	local spread = amount or 0.06
	return Color3.new(
		math.clamp(color.R + rng:NextNumber(-spread, spread), 0, 1),
		math.clamp(color.G + rng:NextNumber(-spread, spread), 0, 1),
		math.clamp(color.B + rng:NextNumber(-spread, spread), 0, 1)
	)
end

Detail.vary = vary

--------------------------------------------------------------------------------
-- Architecture
--------------------------------------------------------------------------------

--[[
	A framed window: recessed pane, four-sided frame, sill, mullion cross, and
	optional shutters. Lights up at night through the returned PointLight.

	`facing` is the wall's outward normal; the window is built into the face.
]]
function Detail.window(origin: CFrame, rng: Random, options): (Model, PointLight)
	-- A window a person looks out of: roughly chest-to-head, not a shopfront.
	local width = (options and options.width) or 2.8
	local height = (options and options.height) or 3.4
	local ctx = Build.begin("Window", origin)

	-- Pane, set back so the frame casts a shadow line onto it.
	local pane = ctx:block({
		name = "Pane",
		size = V(width, height, 0.35),
		at = V(0, 0, 0.35),
		color = Color3.fromRGB(38, 40, 48),
		material = Enum.Material.Glass,
	})
	pane.Reflectance = 0.25

	local frame = 0.55
	for _, spec in
		{
			{ name = "FrameTop", size = V(width + frame * 2, frame, 0.9), at = V(0, height / 2 + frame / 2, 0) },
			{ name = "FrameBottom", size = V(width + frame * 2, frame, 0.9), at = V(0, -height / 2 - frame / 2, 0) },
			{ name = "FrameL", size = V(frame, height + frame * 2, 0.9), at = V(width / 2 + frame / 2, 0, 0) },
			{ name = "FrameR", size = V(frame, height + frame * 2, 0.9), at = V(-width / 2 - frame / 2, 0, 0) },
		}
	do
		ctx:block({
			name = spec.name,
			size = spec.size,
			at = spec.at,
			color = vary(TIMBER, rng),
			material = Enum.Material.Wood,
		})
	end

	-- Mullions. Two thin bars is the difference between "window" and "hole".
	ctx:block({ name = "MullionV", size = V(0.28, height, 0.5), at = V(0, 0, 0.1), color = TIMBER_DARK })
	ctx:block({ name = "MullionH", size = V(width, 0.28, 0.5), at = V(0, 0, 0.1), color = TIMBER_DARK })

	-- Sill, sloped so rain would run off it.
	ctx:wedge({
		name = "Sill",
		size = V(width + frame * 3, 0.5, 1.6),
		at = V(0, -height / 2 - frame - 0.25, -0.5),
		rot = V(0, 180, 0),
		color = vary(Color3.fromRGB(150, 143, 128), rng),
		material = Enum.Material.Slate,
	})

	if options and options.shutters then
		for side = -1, 1, 2 do
			local lean = rng:NextNumber(-8, 8)
			ctx:block({
				name = `Shutter{side}`,
				size = V(width * 0.55, height, 0.4),
				at = V(side * (width * 0.78), 0, -0.5),
				rot = V(0, side * (18 + lean), 0),
				color = vary(options.shutterColor or Color3.fromRGB(96, 116, 108), rng),
				material = Enum.Material.WoodPlanks,
			})
		end
	end

	local light = Create("PointLight", {
		Brightness = 0,
		Range = 20,
		Color = Color3.fromRGB(255, 198, 132),
		Parent = pane,
	})

	local model = ctx:finish()
	model.Parent = options and options.parent
	return model, light
end

--- A framed doorway with a lintel beam, jambs, a step and a hanging lamp.
function Detail.doorframe(origin: CFrame, rng: Random, options): (Model, PointLight)
	local width = (options and options.width) or Scale.DOOR_WIDTH
	local height = (options and options.height) or Scale.DOORWAY
	local ctx = Build.begin("Doorframe", origin)

	for side = -1, 1, 2 do
		ctx:block({
			name = `Jamb{side}`,
			size = V(1.1, height, 1.6),
			at = V(side * (width / 2 + 0.55), height / 2, 0),
			color = vary(TIMBER, rng),
			material = Enum.Material.Wood,
		})
	end
	ctx:block({
		name = "Head",
		size = V(width + 3.4, 1.3, 1.9),
		at = V(0, height + 0.65, 0),
		color = vary(TIMBER_DARK, rng),
		material = Enum.Material.Wood,
	})
	-- Step: a doorway flush with the floor never reads as a threshold.
	ctx:block({
		name = "Step",
		size = V(width + 4, 0.6, 3.2),
		at = V(0, 0.3, -1.8),
		color = vary(Color3.fromRGB(138, 132, 120), rng),
		material = Enum.Material.Slate,
	})

	local lamp = ctx:block({
		name = "Lamp",
		size = V(1.5, 1.9, 1.5),
		at = V(0, height - 1.2, -1.6),
		color = Color3.fromRGB(255, 226, 176),
		material = Enum.Material.Neon,
	})
	lamp.Transparency = 0.2
	ctx:block({ name = "LampCap", size = V(1.9, 0.4, 1.9), at = V(0, height - 0.2, -1.6), color = IRON })

	local light = Create("PointLight", {
		Brightness = 0,
		Range = 26,
		Color = Color3.fromRGB(255, 206, 150),
		Parent = lamp,
	})

	local model = ctx:finish()
	model.Parent = options and options.parent
	return model, light
end

--[[
	A chimney with a smoking stack.

	The smoke is the whole point: it is the only thing in a static town that
	moves on its own at every hour, and a roofline with three plumes drifting
	off it reads as inhabited from anywhere in the map.
]]
function Detail.chimney(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("Chimney", origin)
	local height = rng:NextNumber(7, 11)

	ctx:block({
		name = "Stack",
		size = V(3.4, height, 3.4),
		at = V(0, height / 2, 0),
		color = vary(Color3.fromRGB(124, 106, 96), rng),
		material = Enum.Material.Brick,
	})
	ctx:block({
		name = "Cap",
		size = V(4.4, 0.9, 4.4),
		at = V(0, height + 0.45, 0),
		color = vary(Color3.fromRGB(96, 88, 82), rng),
		material = Enum.Material.Slate,
	})
	local pot = ctx:cylinder({
		name = "Pot",
		size = V(1.6, 1.8, 1.8),
		at = V(0, height + 1.6, 0),
		rot = V(0, 0, 90),
		color = Color3.fromRGB(78, 62, 54),
		material = Enum.Material.Concrete,
	})

	Create("ParticleEmitter", {
		Name = "Smoke",
		Texture = "rbxasset://textures/particles/smoke_main.dds",
		Rate = 5,
		Lifetime = NumberRange.new(4.5, 7),
		Speed = NumberRange.new(2.5, 4.5),
		SpreadAngle = Vector2.new(14, 14),
		Rotation = NumberRange.new(0, 360),
		RotSpeed = NumberRange.new(-16, 16),
		Size = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1.6),
			NumberSequenceKeypoint.new(1, 9),
		}),
		Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 0.55),
			NumberSequenceKeypoint.new(0.3, 0.72),
			NumberSequenceKeypoint.new(1, 1),
		}),
		Color = ColorSequence.new(Color3.fromRGB(184, 180, 176), Color3.fromRGB(140, 138, 138)),
		Acceleration = Vector3.new(rng:NextNumber(-1.5, 1.5), 5, rng:NextNumber(-1.5, 1.5)),
		LightEmission = 0.15,
		Parent = pot,
	})

	local model = ctx:finish()
	model.Parent = parent
	return model
end

--------------------------------------------------------------------------------
-- Yard props
--------------------------------------------------------------------------------

function Detail.barrel(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("Barrel", origin)
	local height = rng:NextNumber(2.1, 2.7)
	local wood = vary(Color3.fromRGB(116, 84, 56), rng)

	-- Bulged middle: three stacked cylinders, widest in the centre.
	ctx:cylinder({
		name = "Body",
		size = V(height, 1.9, 1.9),
		at = V(0, height / 2, 0),
		rot = V(0, 0, 90),
		color = wood,
		material = Enum.Material.WoodPlanks,
	})
	ctx:cylinder({
		name = "Belly",
		size = V(height * 0.5, 2.2, 2.2),
		at = V(0, height / 2, 0),
		rot = V(0, 0, 90),
		color = wood,
		material = Enum.Material.WoodPlanks,
	})
	for _, y in { height * 0.22, height * 0.78 } do
		ctx:cylinder({
			name = "Band",
			size = V(0.3, 2.05, 2.05),
			at = V(0, y, 0),
			rot = V(0, 0, 90),
			color = IRON,
			material = Enum.Material.Metal,
		})
	end
	ctx:cylinder({
		name = "Lid",
		size = V(0.25, 1.85, 1.85),
		at = V(0, height, 0),
		rot = V(0, 0, 90),
		color = vary(TIMBER_DARK, rng),
		material = Enum.Material.Wood,
	})

	local model = ctx:finish()
	model:PivotTo(origin * CFrame.Angles(0, rng:NextNumber(0, math.pi * 2), 0))
	model.Parent = parent
	return model
end

function Detail.crate(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("Crate", origin)
	local size = rng:NextNumber(1.7, 2.5)
	local wood = vary(Color3.fromRGB(134, 102, 66), rng)

	ctx:block({
		name = "Box",
		size = V(size, size, size),
		at = V(0, size / 2, 0),
		color = wood,
		material = Enum.Material.WoodPlanks,
		collide = true,
	})
	-- Corner battens: the cheapest possible read of "crate" over "cube".
	for _, axis in { "X", "Z" } do
		for side = -1, 1, 2 do
			ctx:block({
				name = `Batten{axis}{side}`,
				size = if axis == "X" then V(size + 0.2, 0.5, 0.45) else V(0.45, 0.5, size + 0.2),
				at = if axis == "X" then V(0, size * 0.78, side * size / 2) else V(side * size / 2, size * 0.78, 0),
				color = vary(TIMBER_DARK, rng),
				material = Enum.Material.Wood,
			})
		end
	end

	local model = ctx:finish()
	model:PivotTo(origin * CFrame.Angles(0, rng:NextNumber(-0.5, 0.5), 0))
	model.Parent = parent
	return model
end

function Detail.sack(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("Sack", origin)
	local cloth = vary(Color3.fromRGB(186, 168, 130), rng, 0.05)

	ctx:ball({
		name = "Bulge",
		size = V(2.6, 2.2, 2.4),
		at = V(0, 1.1, 0),
		color = cloth,
		material = Enum.Material.Fabric,
	})
	ctx:ball({
		name = "Top",
		size = V(1.7, 1.5, 1.6),
		at = V(0, 2.3, 0),
		color = cloth,
		material = Enum.Material.Fabric,
	})
	ctx:cylinder({
		name = "Tie",
		size = V(0.3, 1.2, 1.2),
		at = V(0, 2.9, 0),
		rot = V(0, 0, 90),
		color = vary(Color3.fromRGB(120, 96, 62), rng),
	})

	local model = ctx:finish()
	model:PivotTo(origin * CFrame.Angles(0, rng:NextNumber(0, math.pi * 2), 0))
	model.Parent = parent
	return model
end

--- A shelf unit with jars on it. Fills the back wall of any shop.
function Detail.shelf(origin: CFrame, rng: Random, options): Model
	local ctx = Build.begin("Shelf", origin)
	local width = (options and options.width) or 7
	local height = (options and options.height) or 6.5
	local jarColor = (options and options.jarColor) or Color3.fromRGB(120, 168, 140)

	for side = -1, 1, 2 do
		ctx:block({
			name = `Upright{side}`,
			size = V(0.6, height, 2.6),
			at = V(side * width / 2, height / 2, 0),
			color = vary(TIMBER, rng),
			material = Enum.Material.Wood,
		})
	end
	for level = 1, 3 do
		local y = height * (level / 3.4)
		ctx:block({
			name = `Board{level}`,
			size = V(width, 0.45, 2.6),
			at = V(0, y, 0),
			color = vary(TIMBER, rng),
			material = Enum.Material.WoodPlanks,
		})

		-- Contents, jittered so no two shelves read as the same shelf.
		local count = rng:NextInteger(3, 6)
		for jar = 1, count do
			local jarHeight = rng:NextNumber(0.9, 1.9)
			local x = -width / 2 + 1.2 + (jar - 1) * ((width - 2.4) / math.max(count - 1, 1))
			local part = ctx:cylinder({
				name = `Jar{level}_{jar}`,
				size = V(jarHeight, rng:NextNumber(0.8, 1.3), rng:NextNumber(0.8, 1.3)),
				at = V(x + rng:NextNumber(-0.3, 0.3), y + jarHeight / 2 + 0.22, rng:NextNumber(-0.3, 0.3)),
				rot = V(0, 0, 90),
				color = vary(jarColor, rng, 0.16),
				material = Enum.Material.Glass,
			})
			part.Transparency = rng:NextNumber(0.15, 0.4)
		end
	end

	local model = ctx:finish()
	model.Parent = options and options.parent
	return model
end

--------------------------------------------------------------------------------
-- Fire
--------------------------------------------------------------------------------

--[[
	A hearth or forge fire.

	Returns the model plus its Fire and PointLight so a caller can pulse them.
	`scale` 1 is a domestic hearth; 1.6 is a working forge.
]]
function Detail.fire(origin: CFrame, rng: Random, options): (Model, PointLight)
	local scale = (options and options.scale) or 1
	local ctx = Build.begin("Fire", origin)

	-- Stone surround.
	for index = 0, 7 do
		local angle = index / 8 * math.pi * 2
		ctx:block({
			name = `Stone{index}`,
			size = V(
				rng:NextNumber(1.4, 2.2) * scale,
				rng:NextNumber(0.9, 1.5) * scale,
				rng:NextNumber(1.2, 1.9) * scale
			),
			at = V(math.cos(angle) * 3.2 * scale, 0.6 * scale, math.sin(angle) * 3.2 * scale),
			rot = V(0, math.deg(angle), rng:NextNumber(-8, 8)),
			color = vary(Color3.fromRGB(122, 118, 112), rng),
			material = Enum.Material.Rock,
		})
	end

	-- Logs.
	for index = 1, 4 do
		local angle = index / 4 * math.pi * 2 + rng:NextNumber(-0.3, 0.3)
		ctx:cylinder({
			name = `Log{index}`,
			size = V(4 * scale, 0.85 * scale, 0.85 * scale),
			at = V(math.cos(angle) * 0.5 * scale, 0.9 * scale, math.sin(angle) * 0.5 * scale),
			rot = V(rng:NextNumber(-14, 14), math.deg(angle), 22),
			color = vary(Color3.fromRGB(64, 46, 34), rng),
			material = Enum.Material.Wood,
		})
	end

	local ember = ctx:block({
		name = "Embers",
		size = V(2.6 * scale, 0.8 * scale, 2.6 * scale),
		at = V(0, 0.7 * scale, 0),
		color = Color3.fromRGB(255, 132, 44),
		material = Enum.Material.Neon,
	})

	Create("Fire", {
		Heat = 12 * scale,
		Size = 7 * scale,
		Color = Color3.fromRGB(255, 156, 60),
		SecondaryColor = Color3.fromRGB(255, 92, 32),
		Parent = ember,
	})

	Create("ParticleEmitter", {
		Name = "Sparks",
		Texture = "rbxasset://textures/particles/sparkles_main.dds",
		Rate = 14 * scale,
		Lifetime = NumberRange.new(0.8, 1.8),
		Speed = NumberRange.new(4, 9),
		SpreadAngle = Vector2.new(22, 22),
		Size = NumberSequence.new({ NumberSequenceKeypoint.new(0, 0.5), NumberSequenceKeypoint.new(1, 0) }),
		Transparency = NumberSequence.new({ NumberSequenceKeypoint.new(0, 0.1), NumberSequenceKeypoint.new(1, 1) }),
		Color = ColorSequence.new(Color3.fromRGB(255, 210, 120), Color3.fromRGB(255, 96, 40)),
		Acceleration = Vector3.new(0, 7, 0),
		LightEmission = 1,
		Parent = ember,
	})

	local light = Create("PointLight", {
		Brightness = 3 * scale,
		Range = 34 * scale,
		Color = Color3.fromRGB(255, 168, 96),
		Parent = ember,
	})

	local model = ctx:finish()
	model.Parent = options and options.parent
	return model, light
end

--------------------------------------------------------------------------------
-- Shop fixtures
--------------------------------------------------------------------------------

--- An anvil on a stump. The single prop that says "smithy" on its own.
function Detail.anvil(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("Anvil", origin)

	ctx:cylinder({
		name = "Stump",
		size = V(3.2, 3.4, 3.4),
		at = V(0, 1.6, 0),
		rot = V(0, 0, 90),
		color = vary(Color3.fromRGB(78, 58, 40), rng),
		material = Enum.Material.Wood,
		collide = true,
	})
	ctx:block({
		name = "Base",
		size = V(2.6, 0.7, 1.8),
		at = V(0, 3.5, 0),
		color = IRON_DARK,
		material = Enum.Material.Metal,
	})
	ctx:block({
		name = "Waist",
		size = V(1.5, 0.9, 1.2),
		at = V(0, 4.3, 0),
		color = IRON_DARK,
		material = Enum.Material.Metal,
	})
	ctx:block({
		name = "Face",
		size = V(4.2, 0.9, 1.7),
		at = V(0, 5.2, 0),
		color = IRON,
		material = Enum.Material.Metal,
		collide = true,
	})
	-- Horn: the tapered end is what makes an anvil an anvil.
	ctx:wedge({
		name = "Horn",
		size = V(1.6, 0.85, 1.7),
		at = V(2.9, 5.2, 0),
		rot = V(0, 90, 0),
		color = IRON,
		material = Enum.Material.Metal,
	})

	local model = ctx:finish()
	model:PivotTo(origin * CFrame.Angles(0, rng:NextNumber(-0.4, 0.4), 0))
	model.Parent = parent
	return model
end

--- A rack of weapons leaning against a wall.
function Detail.weaponRack(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("WeaponRack", origin)

	ctx:block({
		name = "Back",
		size = V(11, 9, 0.8),
		at = V(0, 4.5, 0),
		color = vary(TIMBER, rng),
		material = Enum.Material.WoodPlanks,
	})
	ctx:block({
		name = "Rail",
		size = V(11, 0.6, 1.6),
		at = V(0, 6.4, -0.8),
		color = vary(TIMBER_DARK, rng),
		material = Enum.Material.Wood,
	})

	for index = 1, 5 do
		local x = -4.4 + (index - 1) * 2.2
		local lean = rng:NextNumber(-7, 7)
		local length = rng:NextNumber(5, 7.5)
		ctx:block({
			name = `Blade{index}`,
			size = V(0.5, length, 0.16),
			at = V(x, length / 2 + 1.4, -1.2),
			rot = V(0, 0, lean),
			color = vary(Color3.fromRGB(168, 172, 180), rng, 0.04),
			material = Enum.Material.Metal,
		})
		ctx:block({
			name = `Grip{index}`,
			size = V(0.42, 1.5, 0.42),
			at = V(x - math.sin(math.rad(lean)) * 0.9, 0.9, -1.2),
			rot = V(0, 0, lean),
			color = vary(Color3.fromRGB(74, 52, 38), rng),
			material = Enum.Material.Fabric,
		})
	end

	local model = ctx:finish()
	model.Parent = parent
	return model
end

--- The guild's contract board: a frame with papers pinned to it.
function Detail.questBoard(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("QuestBoard", origin)

	for side = -1, 1, 2 do
		ctx:block({
			name = `Post{side}`,
			size = V(0.9, 12, 0.9),
			at = V(side * 6, 6, 0),
			color = vary(TIMBER, rng),
			material = Enum.Material.Wood,
			collide = true,
		})
	end
	ctx:block({
		name = "Board",
		size = V(13, 8.5, 0.5),
		at = V(0, 8, 0),
		color = vary(Color3.fromRGB(96, 72, 50), rng),
		material = Enum.Material.WoodPlanks,
	})
	ctx:block({
		name = "Ledge",
		size = V(13.6, 0.5, 1.4),
		at = V(0, 3.6, -0.4),
		color = vary(TIMBER_DARK, rng),
		material = Enum.Material.Wood,
	})

	-- Pinned notices, each at its own angle.
	for index = 1, 7 do
		ctx:block({
			name = `Notice{index}`,
			size = V(rng:NextNumber(1.8, 2.8), rng:NextNumber(2.2, 3.2), 0.1),
			at = V(rng:NextNumber(-5.2, 5.2), rng:NextNumber(5.4, 10.6), -0.32),
			rot = V(0, 0, rng:NextNumber(-9, 9)),
			color = vary(Color3.fromRGB(226, 214, 188), rng, 0.04),
			material = Enum.Material.SmoothPlastic,
		})
	end

	local model = ctx:finish()
	model.Parent = parent
	return model
end

--- A table with benches, for the tavern.
function Detail.table(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("Table", origin)
	local wood = vary(Color3.fromRGB(112, 82, 54), rng)

	ctx:block({
		name = "Top",
		size = V(9, 0.7, 5),
		at = V(0, 4.2, 0),
		color = wood,
		material = Enum.Material.WoodPlanks,
		collide = true,
	})
	for xSide = -1, 1, 2 do
		for zSide = -1, 1, 2 do
			ctx:block({
				name = "Leg",
				size = V(0.8, 4.2, 0.8),
				at = V(xSide * 3.8, 2.1, zSide * 1.9),
				color = vary(TIMBER_DARK, rng),
			})
		end
	end
	for side = -1, 1, 2 do
		ctx:block({
			name = `Bench{side}`,
			size = V(9, 0.6, 1.9),
			at = V(0, 2.5, side * 4),
			color = wood,
			material = Enum.Material.WoodPlanks,
			collide = true,
		})
		for xSide = -1, 1, 2 do
			ctx:block({
				name = "BenchLeg",
				size = V(0.7, 2.5, 1.7),
				at = V(xSide * 3.6, 1.25, side * 4),
				color = vary(TIMBER_DARK, rng),
			})
		end
	end

	-- Something on the table, so it isn't a bare slab.
	for index = 1, rng:NextInteger(1, 3) do
		local mug = ctx:cylinder({
			name = `Mug{index}`,
			size = V(1.1, 0.9, 0.9),
			at = V(rng:NextNumber(-3, 3), 5.1, rng:NextNumber(-1.5, 1.5)),
			rot = V(0, 0, 90),
			color = vary(Color3.fromRGB(150, 128, 102), rng),
			material = Enum.Material.Concrete,
		})
		mug.Name = `Mug{index}`
	end

	local model = ctx:finish()
	model:PivotTo(origin * CFrame.Angles(0, rng:NextNumber(-0.35, 0.35), 0))
	model.Parent = parent
	return model
end

--------------------------------------------------------------------------------
-- Street furniture
--------------------------------------------------------------------------------

function Detail.flowerBox(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("FlowerBox", origin)
	local palette = {
		Color3.fromRGB(222, 96, 110),
		Color3.fromRGB(240, 196, 84),
		Color3.fromRGB(168, 124, 220),
		Color3.fromRGB(246, 246, 240),
	}

	ctx:block({
		name = "Box",
		size = V(5.4, 1.5, 1.8),
		at = V(0, 0.75, 0),
		color = vary(TIMBER, rng),
		material = Enum.Material.WoodPlanks,
	})
	ctx:block({
		name = "Soil",
		size = V(5, 0.4, 1.5),
		at = V(0, 1.5, 0),
		color = Color3.fromRGB(58, 44, 34),
		material = Enum.Material.Ground,
	})

	for index = 1, 9 do
		local x = rng:NextNumber(-2.2, 2.2)
		local z = rng:NextNumber(-0.5, 0.5)
		local stem = rng:NextNumber(0.7, 1.5)
		ctx:block({
			name = `Stem{index}`,
			size = V(0.14, stem, 0.14),
			at = V(x, 1.6 + stem / 2, z),
			color = Color3.fromRGB(84, 132, 72),
		})
		ctx:ball({
			name = `Bloom{index}`,
			size = V(0.65, 0.55, 0.65),
			at = V(x, 1.6 + stem, z),
			color = vary(palette[rng:NextInteger(1, #palette)], rng, 0.05),
		})
	end

	local model = ctx:finish()
	model.Parent = parent
	return model
end

--- A hand cart, tipped on its shafts.
function Detail.cart(origin: CFrame, rng: Random, parent: Instance?): Model
	local ctx = Build.begin("Cart", origin)
	local wood = vary(Color3.fromRGB(104, 76, 50), rng)

	ctx:block({
		name = "Bed",
		size = V(7, 0.6, 4.4),
		at = V(0, 3.2, 0),
		color = wood,
		material = Enum.Material.WoodPlanks,
	})
	for side = -1, 1, 2 do
		ctx:block({
			name = `Side{side}`,
			size = V(7, 2, 0.4),
			at = V(0, 4.2, side * 2.2),
			color = wood,
			material = Enum.Material.WoodPlanks,
		})
		ctx:cylinder({
			name = `Wheel{side}`,
			size = V(0.5, 5.2, 5.2),
			at = V(-1.2, 2.6, side * 2.6),
			rot = V(0, 0, 0),
			color = vary(TIMBER_DARK, rng),
			material = Enum.Material.Wood,
		})
	end
	ctx:block({
		name = "EndPanel",
		size = V(0.4, 2, 4.4),
		at = V(-3.4, 4.2, 0),
		color = wood,
		material = Enum.Material.WoodPlanks,
	})
	for side = -1, 1, 2 do
		ctx:block({
			name = `Shaft{side}`,
			size = V(5, 0.4, 0.4),
			at = V(4.6, 2.4, side * 1.4),
			rot = V(0, 0, -14),
			color = wood,
		})
	end

	local model = ctx:finish()
	model:PivotTo(origin * CFrame.Angles(0, rng:NextNumber(0, math.pi * 2), 0))
	model.Parent = parent
	return model
end

--- Low fence, `length` studs long, running along +X.
function Detail.fence(origin: CFrame, rng: Random, length: number, parent: Instance?): Model
	local ctx = Build.begin("Fence", origin)
	local posts = math.max(math.floor(length / 6), 2)

	for index = 0, posts do
		local x = -length / 2 + index * (length / posts)
		ctx:block({
			name = `Post{index}`,
			size = V(0.7, rng:NextNumber(4, 4.8), 0.7),
			at = V(x, 2.2, 0),
			rot = V(0, rng:NextNumber(-4, 4), rng:NextNumber(-3, 3)),
			color = vary(TIMBER, rng),
			material = Enum.Material.Wood,
		})
	end
	for _, y in { 1.6, 3.4 } do
		ctx:block({
			name = "Rail",
			size = V(length, 0.4, 0.35),
			at = V(0, y, 0),
			color = vary(TIMBER_DARK, rng),
			material = Enum.Material.Wood,
		})
	end

	local model = ctx:finish()
	model.Parent = parent
	return model
end

--- A hanging banner on a pole, in the town's colours.
function Detail.banner(origin: CFrame, rng: Random, color: Color3, parent: Instance?): Model
	local ctx = Build.begin("Banner", origin)

	ctx:cylinder({
		name = "Pole",
		size = V(18, 0.7, 0.7),
		at = V(0, 9, 0),
		rot = V(0, 0, 90),
		color = IRON,
		material = Enum.Material.Metal,
	})
	ctx:block({ name = "Arm", size = V(3.4, 0.4, 0.4), at = V(1.5, 17.4, 0), color = IRON })
	local cloth = ctx:block({
		name = "Cloth",
		size = V(4.4, 9, 0.22),
		at = V(2.8, 12.6, 0),
		rot = V(0, 0, rng:NextNumber(-3, 3)),
		color = vary(color, rng, 0.04),
		material = Enum.Material.Fabric,
	})
	-- Pointed tail, so the banner doesn't end in a flat edge.
	ctx:wedge({
		name = "Tail",
		size = V(0.22, 2.2, 4.4),
		at = V(2.8, 6.9, 0),
		rot = V(0, 90, 180),
		color = cloth.Color,
		material = Enum.Material.Fabric,
	})

	local model = ctx:finish()
	model.Parent = parent
	return model
end

return Detail
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="57">
          <Properties>
            <string name="Name">Firstia</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Firstia — the town the Rampant Forest borders.

	Built as a destination, not a place: walls, a gate, towers and the rooftops
	rising behind them, meant to be seen from the treeline. Sunraku starts in
	the forest rather than a tutorial town because of his Wanderer origin, so
	the correct first impression of Firstia is a silhouette on the horizon you
	are walking *toward*.

	Nothing here is enterable yet. When it is, this file becomes the exterior
	shell and the interior gets its own.

	Authored facing -Z (gate faces the forest), standing on y = 0.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local WALL = Color3.fromRGB(150, 142, 124)
local WALL_DARK = Color3.fromRGB(118, 111, 96)
local ROOF = Color3.fromRGB(134, 74, 58)
local ROOF_ALT = Color3.fromRGB(108, 88, 76)
local TIMBER = Color3.fromRGB(88, 66, 48)
local BANNER = Color3.fromRGB(62, 120, 148)

local WALL_HEIGHT = 26 -- ~9m: tall enough to read as fortification at distance
local WALL_LENGTH = 300

return function(origin: CFrame): Model
	local ctx = Build.begin("Firstia", origin)
	local rng = Random.new(4242)

	-- Curtain wall, split either side of the gate.
	for side = -1, 1, 2 do
		ctx:block({
			name = `Wall{side}`,
			size = V(WALL_LENGTH / 2 - 16, WALL_HEIGHT, 8),
			at = V(side * (WALL_LENGTH / 4 + 8), WALL_HEIGHT / 2, 0),
			color = WALL,
			material = Enum.Material.Brick,
			collide = true,
		})
		-- Crenellations, so the top edge isn't a flat line at distance.
		for index = 0, 11 do
			ctx:block({
				name = `Merlon{side}_{index}`,
				size = V(4.5, 3.2, 8.4),
				at = V(side * (24 + index * 11), WALL_HEIGHT + 1.6, 0),
				color = WALL_DARK,
				material = Enum.Material.Brick,
			})
		end
	end

	-- Gatehouse
	ctx:pair("block", {
		name = "GateTower",
		size = V(14, WALL_HEIGHT + 14, 14),
		at = V(16, (WALL_HEIGHT + 14) / 2, 0),
		color = WALL,
		material = Enum.Material.Brick,
		collide = true,
	})
	ctx:pair("block", {
		name = "GateTowerCap",
		size = V(17, 3, 17),
		at = V(16, WALL_HEIGHT + 15.5, 0),
		color = WALL_DARK,
		material = Enum.Material.Brick,
	})
	ctx:pair("wedge", {
		name = "GateRoof",
		size = V(17, 11, 17),
		at = V(16, WALL_HEIGHT + 22, 0),
		rot = V(0, 90, 0),
		color = ROOF,
		material = Enum.Material.Slate,
	})

	-- Gate arch and the dark opening under it.
	ctx:block({
		name = "GateLintel",
		size = V(20, 7, 9),
		at = V(0, WALL_HEIGHT - 3.5, 0),
		color = WALL,
		material = Enum.Material.Brick,
		collide = true,
	})
	ctx:block({
		name = "GateShadow",
		size = V(18, WALL_HEIGHT - 7, 1),
		at = V(0, (WALL_HEIGHT - 7) / 2, 0),
		color = Color3.fromRGB(24, 22, 22),
		material = Enum.Material.Slate,
	})
	ctx:pair("block", {
		name = "GatePost",
		size = V(2.2, WALL_HEIGHT - 7, 9.5),
		at = V(9.6, (WALL_HEIGHT - 7) / 2, 0),
		color = TIMBER,
		material = Enum.Material.Wood,
	})

	-- Banners either side of the gate.
	ctx:pair("block", {
		name = "Banner",
		size = V(3.4, 12, 0.3),
		at = V(12, WALL_HEIGHT - 9, -4.4),
		color = BANNER,
		material = Enum.Material.Fabric,
	})

	-- Watchtowers at the wall ends.
	for side = -1, 1, 2 do
		ctx:cylinder({
			name = `Watchtower{side}`,
			size = V(46, 17, 17),
			at = V(side * (WALL_LENGTH / 2 - 4), 23, 0),
			rot = V(0, 0, 90),
			color = WALL,
			material = Enum.Material.Brick,
			collide = true,
		})
		ctx:ball({
			name = `WatchtowerRoof{side}`,
			size = V(21, 15, 21),
			at = V(side * (WALL_LENGTH / 2 - 4), 50, 0),
			color = ROOF,
			material = Enum.Material.Slate,
		})
	end

	-- Rooftops rising behind the wall. Deterministic RNG so the skyline is
	-- the same every build.
	for index = 1, 26 do
		local x = rng:NextNumber(-WALL_LENGTH / 2 + 20, WALL_LENGTH / 2 - 20)
		local z = rng:NextNumber(14, 90)
		local width = rng:NextNumber(14, 26)
		local height = rng:NextNumber(12, 30)

		ctx:block({
			name = `House{index}`,
			size = V(width, height, width * rng:NextNumber(0.8, 1.2)),
			at = V(x, height / 2, z),
			color = if rng:NextNumber() < 0.5 then WALL else WALL_DARK,
			material = Enum.Material.Brick,
		})
		ctx:wedge({
			name = `HouseRoof{index}`,
			size = V(width * 1.1, rng:NextNumber(6, 11), width * 1.2),
			at = V(x, height + 4, z),
			rot = V(0, rng:NextInteger(0, 1) * 90, 0),
			color = if rng:NextNumber() < 0.6 then ROOF else ROOF_ALT,
			material = Enum.Material.Slate,
		})
	end

	-- Keep spire, the tallest thing on the skyline.
	ctx:block({
		name = "Keep",
		size = V(34, 62, 34),
		at = V(-30, 31, 118),
		color = WALL,
		material = Enum.Material.Brick,
	})
	ctx:ball({
		name = "KeepRoof",
		size = V(40, 34, 40),
		at = V(-30, 74, 118),
		color = ROOF,
		material = Enum.Material.Slate,
	})

	return ctx:finish()
end
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="58">
          <Properties>
            <string name="Name">Foliage</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Foliage — procedural trees, rocks and undergrowth for the Rampant Forest.

	SCALE. A Roblox character is about 5 studs tall, so a stud is roughly a
	third of a metre. Default Roblox "trees" are ~20 studs, which is a 6m
	shrub — that is why so many Roblox forests read as toys. Real canopy trees
	are 15-25m, so these are 45-75 studs, and standing under one should feel
	like standing under a tree.

	Everything takes a Random so a world seed produces the same forest twice.
	Never call math.random in here.
]]

local Build = require(game:GetService("ReplicatedStorage").Shared.Util.Build)

local V = Vector3.new

local Foliage = {}

local BARK = { Color3.fromRGB(74, 58, 44), Color3.fromRGB(64, 50, 39), Color3.fromRGB(86, 68, 52) }
local LEAF = {
	Color3.fromRGB(64, 118, 52),
	Color3.fromRGB(78, 134, 58),
	Color3.fromRGB(54, 102, 46),
	Color3.fromRGB(92, 146, 62),
}
local STONE = { Color3.fromRGB(104, 104, 100), Color3.fromRGB(88, 90, 88), Color3.fromRGB(118, 116, 110) }

local function pick(rng: Random, list: { Color3 }): Color3
	return list[rng:NextInteger(1, #list)]
end

--- A broadleaf tree: tapering trunk, a few boughs, and layered canopy blobs.
--- `scale` 1.0 is roughly 55 studs tall.
function Foliage.tree(origin: CFrame, rng: Random, scale: number?): Model
	local s = scale or rng:NextNumber(0.75, 1.35)
	local height = 42 * s
	local bark = pick(rng, BARK)
	local leaf = pick(rng, LEAF)

	local ctx = Build.begin("Tree", origin)

	-- Trunk in three tapering segments, each leaning slightly for character.
	local segments = 3
	local y = 0
	for index = 1, segments do
		local segHeight = height / segments
		local radius = (2.6 - index * 0.55) * s
		ctx:cylinder({
			name = `Trunk{index}`,
			size = V(segHeight, radius * 2, radius * 2),
			at = V(rng:NextNumber(-0.6, 0.6) * s, y + segHeight / 2, rng:NextNumber(-0.6, 0.6) * s),
			rot = V(rng:NextNumber(-3, 3), 0, 90 + rng:NextNumber(-3, 3)),
			color = bark,
			material = Enum.Material.Wood,
			collide = index == 1,
		})
		y += segHeight * 0.94
	end

	-- Boughs reaching out before the canopy starts.
	for index = 1, rng:NextInteger(2, 4) do
		local angle = rng:NextNumber(0, math.pi * 2)
		local reach = rng:NextNumber(4, 8) * s
		ctx:cylinder({
			name = `Bough{index}`,
			size = V(reach * 2, 1.1 * s, 1.1 * s),
			at = V(math.cos(angle) * reach * 0.5, height * rng:NextNumber(0.62, 0.82), math.sin(angle) * reach * 0.5),
			rot = V(0, -math.deg(angle), rng:NextNumber(22, 42)),
			color = bark,
			material = Enum.Material.Wood,
		})
	end

	-- Canopy: overlapping blobs rather than one sphere, so the outline is
	-- irregular and reads as foliage instead of a lollipop.
	local canopyBase = height * 0.78
	for index = 1, rng:NextInteger(5, 8) do
		local spread = rng:NextNumber(0, 9) * s
		local angle = rng:NextNumber(0, math.pi * 2)
		local size = rng:NextNumber(11, 17) * s
		ctx:ball({
			name = `Canopy{index}`,
			size = V(size, size * rng:NextNumber(0.62, 0.8), size),
			at = V(math.cos(angle) * spread, canopyBase + rng:NextNumber(0, 9) * s, math.sin(angle) * spread),
			color = if rng:NextNumber() < 0.3 then pick(rng, LEAF) else leaf,
			material = Enum.Material.Grass,
		})
	end

	-- Rigged rather than welded: Ambience moves canopies individually, and a
	-- welded assembly can only move as one lump.
	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--- Narrow conifer, for ridgelines and to break up the broadleaf mass.
function Foliage.pine(origin: CFrame, rng: Random, scale: number?): Model
	local s = scale or rng:NextNumber(0.8, 1.4)
	local height = 46 * s
	local bark = pick(rng, BARK)
	local leaf = pick(rng, LEAF)

	local ctx = Build.begin("Pine", origin)

	ctx:cylinder({
		name = "Trunk",
		size = V(height, 2.4 * s, 2.4 * s),
		at = V(0, height / 2, 0),
		rot = V(0, 0, 90),
		color = bark,
		material = Enum.Material.Wood,
		collide = true,
	})

	local tiers = rng:NextInteger(4, 6)
	for index = 1, tiers do
		local t = (index - 1) / tiers
		local width = (15 - t * 10) * s
		ctx:ball({
			name = `Tier{index}`,
			size = V(width, width * 0.7, width),
			at = V(0, height * (0.34 + t * 0.6), 0),
			color = leaf,
			material = Enum.Material.Grass,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--- Irregular boulder cluster.
function Foliage.rock(origin: CFrame, rng: Random, scale: number?): Model
	local s = scale or rng:NextNumber(0.6, 1.8)
	local ctx = Build.begin("Rock", origin)

	for index = 1, rng:NextInteger(2, 4) do
		local size = rng:NextNumber(3.5, 8) * s
		ctx:block({
			name = `Chunk{index}`,
			size = V(size, size * rng:NextNumber(0.5, 0.85), size * rng:NextNumber(0.7, 1.1)),
			at = V(rng:NextNumber(-2, 2) * s, size * 0.3, rng:NextNumber(-2, 2) * s),
			rot = V(rng:NextNumber(-14, 14), rng:NextNumber(0, 360), rng:NextNumber(-14, 14)),
			color = pick(rng, STONE),
			material = Enum.Material.Rock,
			collide = true,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--- Low shrub. Cheap, and does most of the work of making ground look alive.
function Foliage.bush(origin: CFrame, rng: Random, scale: number?): Model
	local s = scale or rng:NextNumber(0.7, 1.5)
	local leaf = pick(rng, LEAF)
	local ctx = Build.begin("Bush", origin)

	for index = 1, rng:NextInteger(2, 4) do
		local size = rng:NextNumber(3, 5.5) * s
		ctx:ball({
			name = `Clump{index}`,
			size = V(size, size * 0.75, size),
			at = V(rng:NextNumber(-1.6, 1.6) * s, size * 0.34, rng:NextNumber(-1.6, 1.6) * s),
			color = leaf,
			material = Enum.Material.Grass,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--[[
	Fern. Fronds radiating from a crown — the single most useful piece of
	forest-floor dressing, because it fills the gap between grass tufts and
	bushes that otherwise reads as bare ground.
]]
function Foliage.fern(origin: CFrame, rng: Random, scale: number?): Model
	local s = scale or rng:NextNumber(0.7, 1.5)
	local leaf = pick(rng, LEAF)
	local ctx = Build.begin("Fern", origin)

	local fronds = rng:NextInteger(5, 8)
	for index = 1, fronds do
		local angle = (index / fronds) * math.pi * 2 + rng:NextNumber(-0.3, 0.3)
		local length = rng:NextNumber(2.6, 4.4) * s
		ctx:block({
			name = `Frond{index}`,
			size = V(0.5 * s, 0.12, length),
			at = V(math.cos(angle) * length * 0.42, 0.5 * s + rng:NextNumber(0, 0.4), math.sin(angle) * length * 0.42),
			rot = V(rng:NextNumber(-26, -12), -math.deg(angle), 0),
			color = leaf,
			material = Enum.Material.Grass,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--- Wildflowers. Colour on the ground, and the cheapest way to make a glade
--- read as a glade rather than a gap.
function Foliage.flowers(origin: CFrame, rng: Random): Model
	local BLOOM = {
		Color3.fromRGB(232, 214, 96),
		Color3.fromRGB(226, 122, 168),
		Color3.fromRGB(150, 152, 232),
		Color3.fromRGB(244, 244, 236),
	}
	local bloom = BLOOM[rng:NextInteger(1, #BLOOM)]
	local stem = pick(rng, LEAF)
	local ctx = Build.begin("Flowers", origin)

	for index = 1, rng:NextInteger(4, 8) do
		local x, z = rng:NextNumber(-1.8, 1.8), rng:NextNumber(-1.8, 1.8)
		local height = rng:NextNumber(0.9, 1.8)
		ctx:block({
			name = `Stem{index}`,
			size = V(0.09, height, 0.09),
			at = V(x, height / 2, z),
			rot = V(rng:NextNumber(-10, 10), 0, rng:NextNumber(-10, 10)),
			color = stem,
			material = Enum.Material.Grass,
		})
		ctx:ball({
			name = `Bloom{index}`,
			size = V(0.36, 0.24, 0.36),
			at = V(x, height + 0.08, z),
			color = bloom,
			material = Enum.Material.Grass,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--- Mushrooms. Deep woods only; they say "this part of the forest is old".
function Foliage.mushrooms(origin: CFrame, rng: Random): Model
	local CAP = {
		Color3.fromRGB(186, 78, 62),
		Color3.fromRGB(206, 190, 160),
		Color3.fromRGB(122, 96, 74),
	}
	local cap = CAP[rng:NextInteger(1, #CAP)]
	local ctx = Build.begin("Mushrooms", origin)

	for index = 1, rng:NextInteger(2, 5) do
		local x, z = rng:NextNumber(-1.4, 1.4), rng:NextNumber(-1.4, 1.4)
		local height = rng:NextNumber(0.4, 1.1)
		local width = height * rng:NextNumber(0.8, 1.4)
		ctx:cylinder({
			name = `Stalk{index}`,
			size = V(height, 0.22, 0.22),
			at = V(x, height / 2, z),
			rot = V(0, 0, 90),
			color = Color3.fromRGB(226, 216, 196),
		})
		ctx:ball({
			name = `Cap{index}`,
			size = V(width, width * 0.55, width),
			at = V(x, height, z),
			color = cap,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--- A fallen trunk, half-sunk and mossy. Breaks up the vertical rhythm of a
--- forest, which is what makes one read as old growth rather than a plantation.
function Foliage.log(origin: CFrame, rng: Random): Model
	local bark = pick(rng, BARK)
	local moss = pick(rng, LEAF)
	local length = rng:NextNumber(9, 18)
	local radius = rng:NextNumber(1.1, 1.9)
	local ctx = Build.begin("FallenLog", origin)

	ctx:cylinder({
		name = "Trunk",
		size = V(length, radius * 2, radius * 2),
		at = V(0, radius * 0.75, 0),
		rot = V(0, rng:NextNumber(-12, 12), 0),
		color = bark,
		material = Enum.Material.Wood,
		collide = true,
	})

	for index = 1, rng:NextInteger(2, 4) do
		ctx:ball({
			name = `Moss{index}`,
			size = V(radius * 1.5, radius * 0.5, radius * 1.5),
			at = V(rng:NextNumber(-length / 2.4, length / 2.4), radius * 1.4, rng:NextNumber(-0.4, 0.4)),
			color = moss,
			material = Enum.Material.Grass,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--- Reeds. Waterline only — a bank with nothing growing on it looks unfinished.
function Foliage.reeds(origin: CFrame, rng: Random): Model
	local leaf = Color3.fromRGB(96, 128, 74)
	local ctx = Build.begin("Reeds", origin)

	for index = 1, rng:NextInteger(6, 12) do
		local height = rng:NextNumber(3, 6.5)
		ctx:block({
			name = `Reed{index}`,
			size = V(0.14, height, 0.14),
			at = V(rng:NextNumber(-2, 2), height / 2, rng:NextNumber(-2, 2)),
			rot = V(rng:NextNumber(-14, 14), rng:NextNumber(0, 360), rng:NextNumber(-14, 14)),
			color = leaf,
			material = Enum.Material.Grass,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

--- A few blades of tall grass. Non-colliding dressing.
function Foliage.grass(origin: CFrame, rng: Random): Model
	local leaf = pick(rng, LEAF)
	local ctx = Build.begin("Grass", origin)

	for index = 1, rng:NextInteger(3, 6) do
		local height = rng:NextNumber(1.6, 3.4)
		ctx:block({
			name = `Blade{index}`,
			size = V(0.24, height, 0.24),
			at = V(rng:NextNumber(-1.2, 1.2), height / 2, rng:NextNumber(-1.2, 1.2)),
			rot = V(rng:NextNumber(-16, 16), rng:NextNumber(0, 360), rng:NextNumber(-16, 16)),
			color = leaf,
			material = Enum.Material.Grass,
		})
	end

	local model = ctx:finishRigged(true)
	model:SetAttribute("Foliage", true)
	return model
end

return Foliage
]]></string>
          </Properties>
        </Item>
      </Item>
    </Item>
  </Item>
  <Item class="ServerScriptService" referent="59">
    <Properties>
      <string name="Name">ServerScriptService</string>
    </Properties>
    <Item class="Folder" referent="60">
      <Properties>
        <string name="Name">Server</string>
      </Properties>
      <Item class="Script" referent="61">
        <Properties>
          <string name="Name">Avatar</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Avatar — puts the character you built onto the body you play.

	Creation used to ask for a name, a race and a style and then hand you the
	stock Roblox avatar. Every choice was invisible on the thing you look at
	for the whole game.

	WHAT THIS TOUCHES:
	  BodyColors    skin on head and limbs, outfit on torso and legs
	  Humanoid      body scale, from the chosen build
	  Hair          a small procedural piece welded to the head

	HAIR IS BUILT, NOT UPLOADED. Every accessory on the Roblox catalogue is an
	asset id this game does not own and cannot guarantee still exists, and the
	rest of the world here is procedural anyway — a mesh wig on a blocky town
	would look more wrong, not less.

	Re-applied on every respawn, because CharacterAdded hands back a fresh
	default body each time and a character that forgets its own face after one
	death is worse than one that never had one.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Appearance = require(ReplicatedStorage.Shared.Config.Appearance)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Network = require(ReplicatedStorage.Shared.Network)

local looks: { [Player]: any } = {}

--------------------------------------------------------------------------------

local function applyColors(character: Model, look)
	local skin = Appearance.skin(look.skin).color
	local outfit = Appearance.outfit(look.outfit).color

	-- BodyColors covers R6 and R15 alike, and survives the engine rebuilding
	-- limbs underneath us.
	local colors = character:FindFirstChildOfClass("BodyColors")
	if not colors then
		colors = Instance.new("BodyColors")
		colors.Parent = character
	end

	colors.HeadColor3 = skin
	colors.LeftArmColor3 = skin
	colors.RightArmColor3 = skin
	colors.TorsoColor3 = outfit
	colors.LeftLegColor3 = outfit
	colors.RightLegColor3 = outfit
end

local function applyBuild(character: Model, look)
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid then
		return
	end
	local build = Appearance.build(look.build)

	--[[
		Scale values are Humanoid children on R15 and simply absent on R6.
		Missing ones are created rather than skipped, because a fresh character
		sometimes arrives before the engine has added them and the alternative
		is a build that applies only on the second respawn.
	]]
	local function scale(name: string, value: number)
		local existing = humanoid:FindFirstChild(name)
		if existing and existing:IsA("NumberValue") then
			existing.Value = value
		end
	end

	scale("BodyHeightScale", build.height)
	scale("BodyWidthScale", build.width)
	scale("BodyDepthScale", build.width)
	scale("HeadScale", build.head)
end

--[[
	Hair: a few blocks welded to the head.

	Shaped rather than symmetrical — a longer sweep on one side and a shorter
	one on the other. A perfectly mirrored cap reads as a helmet, and the
	asymmetry is most of what makes it read as hair at all at this resolution.
]]
local function applyHair(character: Model, look)
	local head = character:FindFirstChild("Head")
	if not head then
		return
	end

	local existing = character:FindFirstChild("SLFHair")
	if existing then
		existing:Destroy()
	end

	local color = Appearance.hair(look.hair).color
	local folder = Create("Folder", { Name = "SLFHair", Parent = character })
	local size = head.Size

	local function piece(name: string, dimensions: Vector3, offset: Vector3, tilt: number?)
		local part = Create("Part", {
			Name = name,
			Size = dimensions,
			Color = color,
			Material = Enum.Material.SmoothPlastic,
			CanCollide = false,
			CanQuery = false,
			CanTouch = false,
			Massless = true,
			TopSurface = Enum.SurfaceType.Smooth,
			BottomSurface = Enum.SurfaceType.Smooth,
			Parent = folder,
		})
		part.CFrame = head.CFrame * CFrame.new(offset) * CFrame.Angles(0, 0, math.rad(tilt or 0))

		local weld = Instance.new("WeldConstraint")
		weld.Part0 = head
		weld.Part1 = part
		weld.Parent = part
		return part
	end

	-- Cap over the crown, slightly proud of the head so it never z-fights.
	piece("Cap", Vector3.new(size.X * 1.06, size.Y * 0.42, size.Z * 1.06), Vector3.new(0, size.Y * 0.36, 0))
	-- Back, falling to the nape.
	piece("Back", Vector3.new(size.X * 1.02, size.Y * 0.66, size.Z * 0.34), Vector3.new(0, size.Y * 0.05, size.Z * 0.4))
	-- Fringe, asymmetric.
	piece(
		"FringeL",
		Vector3.new(size.X * 0.4, size.Y * 0.4, size.Z * 0.3),
		Vector3.new(-size.X * 0.3, size.Y * 0.2, -size.Z * 0.5),
		8
	)
	piece(
		"FringeR",
		Vector3.new(size.X * 0.26, size.Y * 0.26, size.Z * 0.3),
		Vector3.new(size.X * 0.33, size.Y * 0.28, -size.Z * 0.5),
		-6
	)
end

local function apply(player: Player, character: Model?)
	character = character or player.Character
	local look = looks[player]
	if not (character and look) then
		return
	end

	local ok, err = pcall(function()
		applyColors(character, look)
		applyBuild(character, look)
		applyHair(character, look)
	end)
	if not ok then
		warn(`[Avatar] {player.Name}: {err}`)
	end
end

--------------------------------------------------------------------------------

Network.CharacterChosen.OnServerEvent:Connect(function(player, character)
	if typeof(character) ~= "table" then
		return
	end
	-- Sanitised rather than trusted: the payload decides how the body looks,
	-- so an unknown id has to resolve to a real swatch, not to nil.
	looks[player] = Appearance.sanitise(character.appearance, character.race)
	apply(player)
end)

Players.PlayerAdded:Connect(function(player)
	player.CharacterAdded:Connect(function(character)
		-- The engine is still assembling limbs for a moment after this fires.
		task.wait(0.4)
		apply(player, character)
	end)
end)

Players.PlayerRemoving:Connect(function(player)
	looks[player] = nil
end)
]]></string>
        </Properties>
      </Item>
      <Item class="Folder" referent="62">
        <Properties>
          <string name="Name">Boss</string>
        </Properties>
        <Item class="Script" referent="63">
          <Properties>
            <string name="Name">Lycagon</string>
            <token name="RunContext">0</token>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Lycagon the Nightslayer.

	SEVEN FORMS, SEVEN NIGHTS — and here, seven phases and a boss that does not
	exist during the day. Walk into his clearing at noon and there is nothing
	there but claw marks. Come back after dark and he is waiting.

	That one rule is the whole personality. A boss you can only fight at night
	turns the day/night cycle from decoration into a schedule, makes "it only
	appears after dark" a thing the player discovers rather than reads, and
	means the first time you meet him you were probably just passing through.

	He remembers you: every phase he gets faster, leaps further and spends less
	time recovering. By phase seven he barely stops moving.

	Contrast with the Vorpal Bunny is deliberate. The bunny is small, still, and
	precise — one attack read perfectly. Lycagon is enormous, constantly moving,
	and throws combinations. Where the bunny teaches timing, Lycagon teaches
	spacing: almost everything he does is answered by being somewhere else.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Anim = require(ReplicatedStorage.Shared.Mobs.Anim)
local Sky = require(ReplicatedStorage.Shared.Config.Sky)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)

local Runtime = require(script.Parent.Runtime)

local V = Vector3.new
local Lighting = game:GetService("Lighting")

--- Deep in the woods, well off the road. You have to go looking.
local ARENA = V(-186, 0, -212)

--------------------------------------------------------------------------------
-- Actions
--------------------------------------------------------------------------------

--- Everything scales with phase: later forms are faster and hit harder.
local function scaleFor(phase: number): number
	return 1 - (phase - 1) * 0.075
end

local Actions = {}

--- Signature: a pounce that covers the whole clearing. Parryable, but the
--- reach means a bad read is unanswerable — this is why you learn to move.
function Actions.pounce(ctx)
	local scale = scaleFor(ctx.phase())
	local locked

	ctx.audio("growl")
	ctx.await(0.72 * scale, "crouch", function(phase)
		local _, playerPosition = ctx.player()
		if playerPosition and not locked then
			ctx.face(playerPosition - ctx.position())
		end
		if phase >= 0.6 and not locked then
			locked = ctx.facing()
		end
		ctx.telegraph(true, locked ~= nil, locked or ctx.facing(), 46, 16)
	end)
	ctx.telegraph(false)

	local hold = ctx.beginAssist()
	if hold > 0 then
		ctx.await(hold, "crouch")
	end

	local from = ctx.position()
	local direction = locked or ctx.facing()
	local target = from + direction * 42
	target = V(target.X, ctx.groundAt(target.X, target.Z), target.Z)

	ctx.audio("dash")
	Vfx.speedLines(from + V(0, 4, 0), target + V(0, 4, 0), Color3.fromRGB(255, 112, 36))

	ctx.leap(
		target,
		16,
		0.42 * scale,
		{ compress = "crouch", compressTime = 0.06, air = "leapRise", land = "leapFall" }
	)
	-- Lands on you. Parryable, but the reach means a bad read is
	-- unanswerable — this is the attack that teaches spacing.
	ctx.quake(15, 78)

	ctx.await(0.34, "slashFollow")
	ctx.await(0.78 * scale, "recover")
end

--- Three swipes, alternating sides. Each is its own parry.
function Actions.rend(ctx)
	local scale = scaleFor(ctx.phase())

	ctx.audio("screech")
	ctx.await(0.5 * scale, "crouch", function(phase)
		local _, playerPosition = ctx.player()
		if playerPosition then
			ctx.face(playerPosition - ctx.position())
		end
		ctx.telegraph(true, phase >= 0.55, ctx.facing(), 20, 22)
	end)
	ctx.telegraph(false)

	for index = 1, 3 do
		local hold = ctx.beginAssist()
		if hold > 0 then
			ctx.await(hold, "crouch")
		end

		ctx.await(0.16 * scale, "dash", function()
			local _, playerPosition = ctx.player()
			if playerPosition then
				ctx.setPosition(ctx.position() + ctx.facing() * 0.4)
			end
		end)

		ctx.strike(16, 42)
		Vfx.slash(ctx.position() + V(0, 5, 0), ctx.facing(), 14, Color3.fromRGB(255, 120, 60))
		ctx.audio("slash")

		if index < 3 then
			ctx.await(0.26 * scale, "slashFollow")
		end
	end

	ctx.await(0.9 * scale, "recover")
end

--[[
	Howl. Unparryable on purpose — you cannot parry a sound. It is a pure
	spacing check: get out of the ring or take it, and it exists so the fight
	can never be won standing in one place tapping F.
]]
function Actions.howl(ctx)
	local scale = scaleFor(ctx.phase())

	ctx.await(0.9 * scale, "crouch", function(phase)
		ctx.telegraph(true, phase >= 0.5, ctx.facing(), 0, 0)
		if phase > 0.5 then
			Vfx.ring(ctx.position() + V(0, 0.3, 0), 26 * phase, Color3.fromRGB(255, 150, 60), 0.2, 0.3)
		end
	end)
	ctx.telegraph(false)

	ctx.audio("screech")
	ctx.quake(26, 66, true)

	ctx.await(1.1 * scale, "recover")
end

--- Circles, looking for an angle. He is never standing still.
function Actions.stalk(ctx)
	local scale = scaleFor(ctx.phase())

	for _ = 1, 3 do
		local _, playerPosition = ctx.player()
		if not playerPosition then
			return
		end

		local toPlayer = playerPosition - ctx.position()
		local flat = V(toPlayer.X, 0, toPlayer.Z)
		local tangent = V(-flat.Unit.Z, 0, flat.Unit.X) * (if math.random() < 0.5 then 1 else -1)
		local radial = if flat.Magnitude > 34 then flat.Unit else -flat.Unit * 0.3

		local step = (tangent * 0.8 + radial * 0.6)
		local landing = ctx.position() + step.Unit * 22
		landing = V(landing.X, ctx.groundAt(landing.X, landing.Z), landing.Z)

		ctx.leap(landing, 7, 0.34 * scale, { compress = "crouch", compressTime = 0.07, air = "air", land = "land" })
		ctx.face(playerPosition - ctx.position())
		ctx.await(0.14, "observe")
	end
end

function Actions.stagger(ctx, duration: number)
	ctx.audio("screech")
	ctx.await(duration, "stagger")
	local _, playerPosition = ctx.player()
	if playerPosition then
		ctx.face(playerPosition - ctx.position())
	end
	ctx.await(0.25, "observe")
end

--------------------------------------------------------------------------------
-- Decisions
--------------------------------------------------------------------------------

--[[
	Simple, and different from the bunny on purpose. The bunny reads *you*;
	Lycagon reads the *distance*. Far away he closes with a pounce, mid-range
	he circles, close he rends, and the howl comes out when you have been
	standing on top of him too long.
]]
local function choose(_ctx, signals)
	local roll = math.random()

	if signals.distance > 40 then
		return if roll < 0.75 then "pounce" else "stalk"
	elseif signals.distance < 16 then
		if roll < 0.4 then
			return "howl"
		end
		return if roll < 0.85 then "rend" else "stalk"
	end

	if signals.last == "stalk" then
		return if roll < 0.55 then "pounce" else "rend"
	end
	return if roll < 0.4 then "stalk" elseif roll < 0.75 then "pounce" else "rend"
end

--------------------------------------------------------------------------------

Runtime.create({
	id = "lycagon",
	name = "Lycagon the Nightslayer",
	title = "Unique Scenario",
	mobId = "lycagon",
	maxHealth = 4200,
	phases = 7,
	rewardXp = 1400,
	respawn = 60,

	arena = ARENA,
	aggroRange = 58,
	leash = 190,
	punish = 1.5,
	staggerTime = 2.0,
	hitHeight = 5,
	hurtboxBonus = 6,
	knockbackScale = 1.4,

	idleState = "observe",
	dormantState = "observe",

	--- The rule that defines him. Daylight and he simply is not a fight.
	available = function()
		return Sky.isNight(Lighting.ClockTime)
	end,

	animate = Anim.lycagon,
	actions = Actions,
	choose = choose,

	onPhase = function(ctx, phase)
		-- Each form announces itself. Seven of these across a fight is the
		-- pacing: you always know you have made progress.
		ctx.audio("screech")
		Vfx.ring(ctx.position() + V(0, 0.3, 0), 30, Color3.fromRGB(255, 120, 40), 0.7, 0.4)
		Vfx.leaves(ctx.position(), 14)
		if phase >= 5 then
			Vfx.parryBurst(ctx.position() + V(0, 6, 0))
		end
	end,
})

print(`[Lycagon] waiting at {ARENA} — nights only`)
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="64">
          <Properties>
            <string name="Name">Runtime</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Boss/Runtime — the machinery every boss fight shares.

	The Vorpal Bunny encounter proved this shape works: a coroutine driven one
	frame at a time, a context object that owns position, facing, animation
	state, telegraph, hitstop, knockback and damage, and an action set written
	as straight-line sequences. Writing that twice more by hand would be eight
	hundred lines of copy each and three places for the same bug to live.

	So a boss is now: a config table, an action set, an animation function, and
	a `Runtime.create` call. Everything that makes a fight *feel* right —
	hitstop, parry staggers, punish windows, leashing, phase transitions, the
	assist — is inherited rather than reimplemented.

	What a boss still has to bring itself is personality: its own attacks, its
	own poses, its own reasons to choose between them. The framework refuses to
	make those decisions for it, because that is the only part that matters.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Mobs = require(ReplicatedStorage.Shared.Mobs)
local Net = require(ReplicatedStorage.Shared.Mobs.Net)
local Network = require(ReplicatedStorage.Shared.Network)
local Rig = require(ReplicatedStorage.Shared.Mobs.Rig)
local Parry = require(ReplicatedStorage.Shared.Config.Parry)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)
local Rewards = require(script.Parent.Parent.Rewards)
local World = require(ReplicatedStorage.Shared.Config.World)

local Assist = require(script.Parent.Parent.Combat.Assist)
local Guard = require(script.Parent.Parent.Combat.Guard)

local V = Vector3.new
local ABORT = {}

local Runtime = {}

local folder = Workspace:FindFirstChild("Bosses")
if not folder then
	folder = Create("Folder", { Name = "Bosses", Parent = Workspace })
end

--------------------------------------------------------------------------------

local function flatten(v: Vector3): Vector3
	local flat = V(v.X, 0, v.Z)
	return if flat.Magnitude < 0.001 then V(0, 0, -1) else flat.Unit
end

local function rootOf(player: Player): BasePart?
	local character = player.Character
	return character and character:FindFirstChild("HumanoidRootPart")
end

--------------------------------------------------------------------------------

--[[
	`spec` fields:
	    id, name, title, maxHealth, phases, rewardXp
	    arena          world position
	    aggroRange, leash
	    mobId          which model to build
	    animate        (state, phase, t, extra) -> rootOffset, parts, scale, rate
	    actions        { [name] = function(ctx, scale) }
	    choose         (ctx, signals) -> action name
	    punish         damage multiplier while recovering
	    staggerTime    base parry stagger
	    onDefeated     optional
	    available      optional () -> boolean; false keeps it dormant
]]
function Runtime.create(spec)
	local boss = {
		spec = spec,
		model = nil,
		rig = nil,
		telegraph = nil,

		health = spec.maxHealth,
		position = spec.arena,
		facing = V(0, 0, -1),
		lookYaw = 0,

		animState = "idle",
		animPhase = 0,

		action = nil,
		aborted = false,
		lastAction = nil,
		pendingStagger = false,
		staggerFor = spec.staggerTime,

		engaged = false,
		hitstopUntil = 0,
		knockback = V(0, 0, 0),

		phase = 1,
		attackReadyAt = {},
	}

	----------------------------------------------------------------------------

	--[[
		Where the boss's feet go.

		Defaults to the generated terrain, but a boss can declare a flat
		`floorY` instead — which is what an indoor encounter needs. Wethermon
		stands 26 studs below the surface in his own hall; grounding him on the
		terrain above would leave him floating inside the ceiling of the room
		built for him.
	]]
	local function groundAt(x: number, z: number): number
		if spec.floorY then
			return spec.floorY
		end
		return World.groundHeight(x, z)
	end

	local function nearestPlayer(): (Player?, Vector3?, Vector3?)
		local best, bestDistance = nil, math.huge
		for _, player in Players:GetPlayers() do
			local root = rootOf(player)
			if root then
				local distance = (root.Position - boss.position).Magnitude
				if distance < bestDistance then
					best, bestDistance = player, distance
				end
			end
		end
		if not best then
			return nil, nil, nil
		end
		local root = rootOf(best)
		return best, root and root.Position, root and root.CFrame.LookVector
	end

	local function broadcastState()
		local payload = if boss.engaged
			then {
				name = spec.name,
				title = spec.title,
				health = boss.health,
				maxHealth = spec.maxHealth,
				phase = boss.phase,
				maxPhase = spec.phases,
				unique = true,
			}
			else nil
		for _, player in Players:GetPlayers() do
			Network.BossState:FireClient(player, payload)
		end
	end

	local function updateTelegraph(
		visible: boolean,
		committed: boolean?,
		direction: Vector3?,
		length: number?,
		width: number?
	)
		if not boss.telegraph then
			return
		end
		if not visible then
			boss.telegraph.Transparency = 1
			return
		end
		local span = math.max(length or 0, 6)
		local dir = direction or boss.facing
		local mid = boss.position + dir * (span / 2)

		boss.telegraph.Size = V(width or 12, 0.14, span)
		boss.telegraph.CFrame = CFrame.lookAt(V(mid.X, groundAt(mid.X, mid.Z) + 0.18, mid.Z), mid + dir)
		boss.telegraph.Transparency = if committed then 0.2 else 0.78
		boss.telegraph.Color = if committed then Theme.Color.Health else Theme.Color.StaminaEmpty
	end

	----------------------------------------------------------------------------
	-- Context handed to actions
	----------------------------------------------------------------------------

	local ctx = {}
	ctx.groundAt = groundAt
	ctx.vfx = function()
		return Vfx
	end

	function ctx.position()
		return boss.position
	end
	function ctx.setPosition(value: Vector3)
		boss.position = value
	end
	function ctx.facing()
		return boss.facing
	end
	function ctx.face(direction: Vector3)
		boss.facing = flatten(direction)
	end
	function ctx.player()
		return nearestPlayer()
	end
	function ctx.health()
		return boss.health / spec.maxHealth
	end
	function ctx.phase()
		return boss.phase
	end
	function ctx.audio(name: string)
		Audio.playAt(name, boss.position)
	end
	function ctx.audioAt(name: string, at: Vector3)
		Audio.playAt(name, at)
	end
	function ctx.telegraph(visible, committed, direction, length, width)
		updateTelegraph(visible, committed, direction, length, width)
	end
	function ctx.rig()
		return boss.rig
	end

	--- Opens an assisted approach, returning how long to hold before striking.
	function ctx.beginAssist(): number
		local player = nearestPlayer()
		if not player then
			return 0
		end
		local profile = Assist.plan(player)
		if not profile then
			return 0
		end
		Assist.announce(player, profile)
		return profile.impactDelay
	end

	function ctx.await(duration: number, stateName: string, onUpdate)
		boss.animState = stateName
		local elapsed = 0
		repeat
			local dt = coroutine.yield()
			if boss.aborted then
				error(ABORT)
			end
			elapsed += dt
			boss.animPhase = if duration > 0 then math.clamp(elapsed / duration, 0, 1) else 1
			if onUpdate then
				onUpdate(boss.animPhase, dt)
			end
		until elapsed >= duration
	end

	--- Travels an arc to a landing point, with the usual four beats.
	function ctx.leap(target: Vector3, height: number, airTime: number, states)
		local from = boss.position
		states = states or {}
		ctx.await(states.compressTime or 0.1, states.compress or "compress")
		ctx.audioAt("hop", from)
		Vfx.dust(from, 0.9)

		ctx.await(airTime, states.air or "air", function(phase)
			local ground = from:Lerp(target, phase)
			boss.position = ground + V(0, math.sin(phase * math.pi) * height, 0)
		end)

		boss.position = target
		Vfx.landing(target, states.impact or 1.2)
		ctx.audioAt("heavyLand", target)
		ctx.await(states.landTime or 0.14, states.land or "land")
	end

	--[[
		Hits the player if in range. `unparryable` attacks must be dodged —
		every boss should have at least one, or the fight collapses into
		standing still and pressing F on rhythm.
	]]
	function ctx.strike(radius: number, damage: number, unparryable: boolean?): boolean
		local player, playerPosition = nearestPlayer()
		if not (player and playerPosition) then
			return false
		end
		local flat = V(playerPosition.X - boss.position.X, 0, playerPosition.Z - boss.position.Z)
		if flat.Magnitude > radius then
			return false
		end

		local outcome, staggerScale = Guard.resolve(player, {
			damage = damage,
			from = boss.position,
			unparryable = unparryable,
		})
		if not unparryable then
			Assist.finish(player, outcome == "parried")
		end

		if outcome == "dodged" then
			return false
		end
		if outcome == "parried" then
			boss.aborted = true
			boss.pendingStagger = true
			boss.staggerFor = spec.staggerTime * (if staggerScale > 0 then staggerScale else 1)
			boss.hitstopUntil = os.clock() + Encounters.Hitstop.parry
			boss.knockback = -boss.facing * Parry.Shove
			updateTelegraph(false)
			Vfx.leaves(boss.position, 8)
			for _, other in Players:GetPlayers() do
				Network.Impact:FireClient(other, "parry", { position = boss.position })
			end
		end
		return true
	end

	--- A radial slam: hits everything inside `radius`, with a ring to match.
	function ctx.quake(radius: number, damage: number, unparryable: boolean?)
		Vfx.ring(boss.position + V(0, 0.2, 0), radius, Theme.Color.Danger, 0.45, 0.4)
		Vfx.landing(boss.position, 2.2)
		Vfx.leaves(boss.position, 16)
		ctx.audio("heavyLand")
		ctx.strike(radius, damage, unparryable)
	end

	----------------------------------------------------------------------------

	local function startAction(fn, ...)
		boss.aborted = false
		local args = table.pack(...)
		boss.action = coroutine.create(function()
			fn(ctx, table.unpack(args, 1, args.n))
		end)
	end

	local function spawn()
		if boss.model then
			boss.model:Destroy()
		end
		if boss.telegraph then
			boss.telegraph:Destroy()
		end

		local arena = spec.arena
		boss.position = V(arena.X, groundAt(arena.X, arena.Z), arena.Z)

		local model = Mobs.build(spec.mobId, CFrame.new(boss.position))
		if not model then
			warn(`[Boss] could not build {spec.mobId}`)
			return
		end
		model.Name = spec.name
		model.Parent = folder
		boss.model = model
		boss.rig = Rig.bind(model)
		Net.setRig(model, spec.animName or spec.mobId or spec.id)

		boss.telegraph = Create("Part", {
			Name = "Telegraph",
			Anchored = true,
			CanCollide = false,
			CanQuery = false,
			Size = V(12, 0.14, 1),
			Color = Theme.Color.Health,
			Material = Enum.Material.Neon,
			Transparency = 1,
			Parent = folder,
		})

		boss.health = spec.maxHealth
		boss.phase = 1
		boss.engaged = false
		boss.action = nil
		boss.lastAction = nil

		-- Lets a boss tear down anything it spawned. Without this, a leash or a
		-- respawn leaves its minions alive and hunting an empty arena.
		if spec.onReset then
			spec.onReset(boss)
		end

		broadcastState()
	end

	local function defeated()
		boss.engaged = false
		boss.action = nil
		boss.aborted = true
		updateTelegraph(false)
		Audio.stopMusic()
		Audio.playAt("defeat", boss.position)
		Vfx.parryBurst(boss.position + V(0, 4, 0))
		Vfx.leaves(boss.position, 26)

		for _, player in Players:GetPlayers() do
			Network.EncounterCleared:FireClient(player, spec.id, spec.rewardXp)
			Rewards.grant(player, spec.mobId or spec.id, boss.position)
		end
		broadcastState()

		local corpse = boss.model
		boss.model, boss.rig = nil, nil
		if corpse then
			task.spawn(function()
				for _ = 1, 40 do
					if not corpse.Parent then
						return
					end
					corpse:PivotTo(corpse:GetPivot() * CFrame.new(0, -0.14, 0))
					task.wait(0.03)
				end
				corpse:Destroy()
			end)
		end

		if spec.onDefeated then
			spec.onDefeated()
		end
		task.delay(spec.respawn or 40, spawn)
	end

	----------------------------------------------------------------------------

	RunService.Heartbeat:Connect(function(dt)
		if not (boss.model and boss.rig) then
			return
		end
		if os.clock() < boss.hitstopUntil then
			return
		end

		if boss.knockback.Magnitude > 0.1 then
			boss.position += boss.knockback * dt
			boss.position = V(boss.position.X, groundAt(boss.position.X, boss.position.Z), boss.position.Z)
			boss.knockback = boss.knockback:Lerp(V(0, 0, 0), math.min(dt * 7, 1))
		end

		local player, playerPosition = nearestPlayer()
		local distance = if playerPosition then (playerPosition - boss.position).Magnitude else math.huge

		-- Phase, derived from health so it can never disagree with the bar.
		local wanted = math.clamp(spec.phases - math.floor(boss.health / spec.maxHealth * spec.phases), 1, spec.phases)
		if wanted ~= boss.phase then
			boss.phase = wanted
			broadcastState()
			-- The pulse under the fight quickens with each phase.
			Audio.setIntensity(wanted)
			if spec.onPhase then
				spec.onPhase(ctx, wanted)
			end
		end

		if boss.engaged and distance > spec.leash then
			boss.engaged = false
			boss.health = spec.maxHealth
			boss.action = nil
			updateTelegraph(false)
			Audio.stopMusic()
			broadcastState()
		end

		if boss.action then
			if coroutine.status(boss.action) == "suspended" then
				local ok, err = coroutine.resume(boss.action, dt)
				if not ok and err ~= ABORT then
					warn(`[Boss:{spec.id}] {err}`)
				end
				if not ok then
					boss.action = nil
				end
			end
			if boss.action and coroutine.status(boss.action) == "dead" then
				boss.action = nil
			end
		end

		local dormant = spec.available and not spec.available()

		if not boss.action and boss.health > 0 then
			if boss.pendingStagger then
				boss.pendingStagger = false
				boss.lastAction = nil
				startAction(spec.actions.stagger or function(c, d)
					c.await(d, "stagger")
				end, boss.staggerFor)
			elseif dormant then
				boss.animState = spec.dormantState or "idle"
				if boss.engaged then
					boss.engaged = false
					broadcastState()
				end
			elseif player and distance <= spec.aggroRange then
				if not boss.engaged then
					boss.engaged = true
					broadcastState()
					Audio.startMusic()
					if spec.onEngage then
						startAction(spec.onEngage)
					end
				else
					local choice = spec.choose(ctx, {
						distance = distance,
						health = boss.health / spec.maxHealth,
						phase = boss.phase,
						last = boss.lastAction,
					})
					boss.lastAction = choice
					local run = spec.actions[choice]
					if run then
						startAction(run)
					end
				end
			else
				boss.animState = spec.idleState or "idle"
			end
		end

		if playerPosition then
			local toPlayer = flatten(playerPosition - boss.position)
			local cross = boss.facing.X * toPlayer.Z - boss.facing.Z * toPlayer.X
			boss.lookYaw = math.deg(math.atan2(cross, math.clamp(boss.facing:Dot(toPlayer), -1, 1)))
		end

		--[[
			Publish, don't pose.

			The client runs `spec.animate` itself against these attributes and
			interpolates between them, which is the only way to get smooth
			motion out of anchored parts — see Mobs/Net. The server keeps
			`boss.position` as the truth every hitbox is measured against.
		]]
		Net.publish(
			boss.model,
			CFrame.lookAt(boss.position, boss.position + boss.facing),
			boss.animState,
			boss.animPhase,
			{ health = boss.health / spec.maxHealth, phase = boss.phase }
		)
	end)

	----------------------------------------------------------------------------
	-- Player damage
	----------------------------------------------------------------------------

	Network.Attack.OnServerEvent:Connect(function(player, comboIndex)
		if typeof(comboIndex) ~= "number" or not boss.model or boss.health <= 0 or not boss.engaged then
			return
		end
		if Guard.inRecovery(player) then
			return
		end

		local now = os.clock()
		local step = Encounters.comboStep(comboIndex)
		if boss.attackReadyAt[player] and now < boss.attackReadyAt[player] then
			return
		end
		boss.attackReadyAt[player] = now + (step.windup + step.active) * 0.85

		local root = rootOf(player)
		if not root then
			return
		end

		local toBoss = boss.position - root.Position
		local flat = V(toBoss.X, 0, toBoss.Z)
		if flat.Magnitude > Encounters.PlayerAttack.range + (spec.hurtboxBonus or 0) then
			return
		end
		local look = flatten(root.CFrame.LookVector)
		if math.deg(math.acos(math.clamp(look:Dot(flatten(flat)), -1, 1))) > Encounters.PlayerAttack.arc then
			return
		end

		local multiplier = if boss.animState == "stagger"
			then Parry.CounterMultiplier
			elseif string.find(boss.animState, "recover") then spec.punish
			else 1
		local crit = math.random() < Encounters.PlayerAttack.critChance

		--[[
			Optional per-boss damage scaling.

			A boss can be conditionally armoured — Setsuna takes 15% while any
			puppet of hers is still standing, which is how "kill the adds
			first" gets *taught* rather than written in a tooltip. Returning
			below 1 here is a strong statement and should always come with a
			visible reason on screen.
		]]
		local scale = if spec.damageScale then spec.damageScale(boss) else 1
		local dealt =
			math.floor(step.damage * multiplier * scale * (if crit then Encounters.PlayerAttack.critMultiplier else 1))
		-- Never round a real hit down to nothing: a swing that connects has to
		-- show a number, or the player concludes the game is broken.
		dealt = math.max(dealt, 1)

		boss.health = math.max(boss.health - dealt, 0)
		boss.knockback = flatten(boss.position - root.Position) * step.knockback * (spec.knockbackScale or 3)
		-- An armoured hit never reads as heavy, whatever the swing was.
		local heavy = (step.knockback >= 30 or multiplier > 1) and scale >= 1
		boss.hitstopUntil = now + (if heavy then Encounters.Hitstop.heavy else Encounters.Hitstop.light)

		local at = boss.position + V(0, spec.hitHeight or 3, 0)
		Vfx.sparks(at, if heavy then 26 else 12, Color3.fromRGB(255, 214, 160))
		Vfx.slash(at, look, if heavy then 8 else 5.5, Color3.fromRGB(255, 236, 200))
		Audio.playAt(if heavy then "hitHeavy" else "hitLight", boss.position)

		Network.BossDamaged:FireClient(player, dealt, if crit or multiplier > 1 then "crit" else "normal", at)
		Network.Impact:FireClient(player, if heavy then "heavy" else "light")
		broadcastState()

		if boss.health <= 0 then
			defeated()
		end
	end)

	Players.PlayerRemoving:Connect(function(player)
		boss.attackReadyAt[player] = nil
	end)

	spawn()
	return boss
end

return Runtime
]]></string>
          </Properties>
        </Item>
        <Item class="Script" referent="65">
          <Properties>
            <string name="Name">Setsuna</string>
            <token name="RunContext">0</token>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Setsuna the Puppeteer — the fourth Unique Scenario.

	The other three bosses each teach one thing very hard: the Vorpal Bunny
	teaches the parry, the Lycagon teaches fighting blind, the Wethermon
	teaches that some attacks cannot be parried at all. What none of them
	teaches is **target priority**, and it is the skill every player is missing
	when they first meet a fight with more than one thing in it.

	So Setsuna does not fight you. She hangs at the far side of her arena and
	is barely damageable while her threads are attached; what fights you is
	whatever she is holding up. The lesson is that hitting the thing in front
	of you is not always the correct move, and the game states it by making the
	thing in front of you *irrelevant* until you deal with what is behind it.

	THE LOOP:
	  1. She raises puppets. They close and swing on the same four beats every
	     enemy in this game uses, so nothing new has to be learned to fight them.
	  2. While puppets are up she takes 15% damage. Beating on her is not wrong
	     exactly — it is just very slow, and the game lets you find that out.
	  3. Cut every puppet down and the threads go slack. That is the window:
	     full damage, no guard, and she cannot act. It is generous on purpose,
	     because the lesson is only taught if clearing the adds visibly pays.
	  4. She reels in and raises more, one wave stronger.

	Her own attacks are a thread lash you can parry and a marionette pull you
	cannot, so the Wethermon's lesson still applies while you are learning this
	one.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Anim = require(ReplicatedStorage.Shared.Mobs.Anim)
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Build = require(ReplicatedStorage.Shared.Util.Build)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Net = require(ReplicatedStorage.Shared.Mobs.Net)
local Rig = require(ReplicatedStorage.Shared.Mobs.Rig)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)
local World = require(ReplicatedStorage.Shared.Config.World)

local Guard = require(script.Parent.Parent.Combat.Guard)
local Runtime = require(script.Parent.Runtime)

local V = Vector3.new

local ARENA = V(-230, 0, -250)
local VIOLET = Color3.fromRGB(188, 120, 236)
local THREAD = Color3.fromRGB(206, 188, 142)

--- Damage she takes while any puppet is still standing.
local SHIELDED = 0.15
--- How long the threads stay slack after the last puppet falls.
local SLACK = 6.5

--- Puppets per wave, by phase. Climbs in count, not in health: being
--- surrounded is the problem being posed.
local WAVE = { 2, 3, 4, 5 }

local folder: Folder
local puppets: { any } = {}
local slackUntil = 0
local waveIndex = 0

--------------------------------------------------------------------------------
-- Puppets
--------------------------------------------------------------------------------

--- A marionette: crude, wooden, and hung from a thread that runs off-screen.
--- Built inline rather than reusing a mob, because the thread going up is the
--- whole point and no existing model has one.
local function buildPuppet(origin: CFrame, rng: Random): Model
	local ctx = Build.begin("Puppet", origin)
	local wood = Color3.fromRGB(150, 118, 82):Lerp(Color3.new(), rng:NextNumber(0, 0.18))

	ctx:block({ name = "Thread", size = V(0.07, 26, 0.07), at = V(0, 20, 0), color = THREAD })
	ctx:block({
		name = "Torso",
		size = V(1.9, 2.4, 1.1),
		at = V(0, 5.0, 0),
		color = wood,
		material = Enum.Material.Wood,
	})
	ctx:block({ name = "Hips", size = V(1.6, 0.8, 1.0), at = V(0, 3.6, 0), color = wood, material = Enum.Material.Wood })
	ctx:block({ name = "Head", size = V(1.2, 1.3, 1.2), at = V(0, 6.9, 0), color = wood, material = Enum.Material.Wood })
	ctx:pair("glow", { name = "Eye", size = V(0.16, 0.16, 0.1), at = V(0.3, 7.0, -0.62), color = VIOLET })
	ctx:pair("block", { name = "Arm", size = V(0.55, 2.3, 0.55), at = V(1.3, 4.8, 0), rot = V(0, 0, 6), color = wood })
	ctx:pair("block", { name = "Leg", size = V(0.6, 2.6, 0.6), at = V(0.5, 2.0, 0), color = wood })
	-- Joints, so it reads as articulated rather than carved from one block.
	ctx:pair(
		"ball",
		{ name = "Joint", size = V(0.7, 0.7, 0.7), at = V(1.3, 3.6, 0), color = Color3.fromRGB(96, 74, 52) }
	)

	return ctx:finishRigged()
end

local function raiseWave(count: number, health: number, damage: number)
	local rng = Random.new(waveIndex * 97 + count)

	for index = 1, count do
		local angle = (index / count) * math.pi * 2 + waveIndex
		local at = ARENA + V(math.cos(angle) * 26, 0, math.sin(angle) * 26)
		at = V(at.X, World.groundHeight(at.X, at.Z), at.Z)

		local model = buildPuppet(CFrame.lookAt(at, ARENA), rng)
		model.Parent = folder
		-- Puppets have no rig of their own; the goblin cycle reads correctly
		-- on a marionette and needs no second animation set.
		Net.setRig(model, "goblin")

		-- Dropped in rather than appearing: they fall the last few studs and
		-- land, which is a beat the player can read as "more are coming".
		Vfx.sparks(at + V(0, 8, 0), 10, VIOLET)

		table.insert(puppets, {
			model = model,
			rig = Rig.bind(model),
			position = at,
			facing = (ARENA - at).Unit,
			health = health,
			damage = damage,
			state = "close",
			timer = 0.35 + index * 0.12,
			phase = rng:NextNumber(0, 6),
		})
	end

	Audio.playAt("screech", ARENA)
end

local function dropPuppet(index: number)
	local entry = puppets[index]
	Vfx.sparks(entry.position + V(0, 4, 0), 12, VIOLET)
	Vfx.dust(entry.position, 0.6)
	Audio.playAt("hitHeavy", entry.position)
	if entry.model then
		entry.model:Destroy()
	end
	table.remove(puppets, index)

	-- Last one down: the threads go slack and she is open.
	if #puppets == 0 then
		slackUntil = os.clock() + SLACK
		Audio.playAt("parry", ARENA)
		Vfx.parryBurst(ARENA + V(0, 8, 0))
		Vfx.ring(ARENA + V(0, 0.3, 0), 30, THREAD, 0.9, 0.4)
	end
end

local function clearPuppets()
	for _, entry in puppets do
		if entry.model then
			entry.model:Destroy()
		end
	end
	table.clear(puppets)
	slackUntil = 0
	waveIndex = 0
end

local function rootOf(player: Player): BasePart?
	local character = player.Character
	return character and character:FindFirstChild("HumanoidRootPart") :: BasePart?
end

--- One puppet's frame: approach, telegraph, swing, recover. Same four beats
--- as every other enemy, so a player who can fight a goblin can fight these.
local function stepPuppet(entry, dt: number, player: Player)
	local root = rootOf(player)
	if not root then
		return
	end

	local flat = V(root.Position.X - entry.position.X, 0, root.Position.Z - entry.position.Z)
	local distance = flat.Magnitude
	entry.timer -= dt

	if distance > 0.1 then
		entry.facing = entry.facing:Lerp(flat.Unit, math.clamp(dt * 5, 0, 1))
	end

	if entry.state == "close" then
		if distance > 8.5 then
			entry.position += flat.Unit * 11 * dt
			entry.position =
				V(entry.position.X, World.groundHeight(entry.position.X, entry.position.Z), entry.position.Z)
		elseif entry.timer <= 0 then
			entry.state = "telegraph"
			entry.timer = 0.7
			Vfx.closingRing(entry.position + V(0, 0.3, 0), 9, VIOLET, 0.7)
			Audio.playAt("growl", entry.position)
		end
	elseif entry.state == "telegraph" and entry.timer <= 0 then
		entry.state = "recover"
		entry.timer = 1.15
		Audio.playAt("slash", entry.position)
		Vfx.slash(entry.position + V(0, 4, 0), entry.facing, 5, VIOLET)
		if distance <= 10 then
			Guard.resolve(player, { damage = entry.damage, from = entry.position })
		end
	elseif entry.state == "recover" and entry.timer <= 0 then
		entry.state = "close"
	end

	-- Puppets hang: they sway on their thread and never quite settle.
	entry.phase += dt * (if entry.state == "close" then 6 else 2.4)
	local sway = math.sin(entry.phase) * 0.09
	local lunge = if entry.state == "telegraph" then -0.6 else 0

	Net.publish(
		entry.model,
		CFrame.lookAt(entry.position, entry.position + entry.facing)
			* CFrame.Angles(0, 0, sway)
			* CFrame.new(0, math.abs(math.sin(entry.phase * 0.5)) * 0.2, lunge),
		entry.state,
		entry.phase,
		nil
	)
end

--------------------------------------------------------------------------------
-- Her own actions
--------------------------------------------------------------------------------

local Actions = {}

--- Thread lash. Long, thin, parryable — her only clean tell.
function Actions.lash(ctx)
	local scale = 1 - (ctx.phase() - 1) * 0.07
	ctx.telegraph(true, false, ctx.facing(), 34, 6)
	-- windRush, not "swoosh": that is the source file, not a declared cue.
	ctx.audio("windRush")

	ctx.await(0.62 * scale, "windup", function() end)

	ctx.telegraph(true, true, ctx.facing(), 34, 6)
	ctx.audio("slash")
	Vfx.slash(ctx.position() + V(0, 7, 0), ctx.facing(), 12, THREAD)
	ctx.strike(15, 78)

	ctx.await(0.85 * scale, "recover", function() end)
	ctx.telegraph(false)
end

--- Marionette pull: unparryable, and it drags you into whatever is waiting.
function Actions.pull(ctx)
	ctx.telegraph(true, false, ctx.facing(), 40, 12)
	ctx.audio("growl")
	Vfx.ring(ctx.position() + V(0, 0.3, 0), 40, VIOLET, 0.8, 0.35)

	ctx.await(0.9, "windup", function() end)

	ctx.audio("screech")
	-- Unparryable: the Wethermon already taught that some things must simply
	-- be avoided, so this reuses a lesson rather than adding one.
	ctx.quake(40, 62, true)
	Vfx.parryBurst(ctx.position() + V(0, 6, 0))

	ctx.await(1.4, "recover", function() end)
	ctx.telegraph(false)
end

--- Raises a wave. She does nothing else while they stand.
function Actions.raise(ctx)
	ctx.audio("screech")
	Vfx.ring(ctx.position() + V(0, 0.3, 0), 30, VIOLET, 1.0, 0.4)

	ctx.await(1.0, "windup", function() end)

	waveIndex += 1
	local phase = ctx.phase()
	local count = WAVE[math.clamp(phase, 1, #WAVE)]
	raiseWave(count, 150 + phase * 40, 22 + phase * 6)

	ctx.await(0.8, "recover", function() end)
end

--- The open window. She hangs still and takes everything.
function Actions.slack(ctx)
	ctx.audio("breath")
	ctx.await(math.max(slackUntil - os.clock(), 0.5), "stagger", function() end)
end

function Actions.stagger(ctx)
	ctx.audio("hurt")
	ctx.await(2.2, "stagger", function() end)
end

--------------------------------------------------------------------------------

local function choose(_ctx, signals)
	-- Threads slack: she is helpless and the fight pauses on purpose.
	if os.clock() < slackUntil then
		return "slack"
	end
	-- Nothing standing: put something up before doing anything else.
	if #puppets == 0 then
		return "raise"
	end

	local roll = math.random()
	if signals.distance > 26 or roll < 0.34 then
		return "pull"
	end
	return "lash"
end

--------------------------------------------------------------------------------

Runtime.create({
	id = "setsuna",
	name = "Setsuna the Puppeteer",
	title = "Unique Scenario",
	mobId = "setsuna",
	maxHealth = 5200,
	phases = 4,
	rewardXp = 2400,
	respawn = 75,

	arena = ARENA,
	aggroRange = 52,
	leash = 170,
	punish = 1.4,
	staggerTime = 2.2,
	hitHeight = 8,
	hurtboxBonus = 3,
	--- She hangs. Nothing moves her.
	knockbackScale = 0.1,

	idleState = "idle",

	animate = Anim.setsuna,
	actions = Actions,
	choose = choose,

	--- Barely damageable while anything she is holding is still standing.
	damageScale = function()
		return if #puppets > 0 then SHIELDED else 1
	end,

	onPhase = function(ctx, phase)
		ctx.audio("screech")
		Vfx.parryBurst(ctx.position() + V(0, 9, 0))
		Vfx.ring(ctx.position() + V(0, 0.3, 0), 36, VIOLET, 1.0, 0.4)
		-- Every phase she cuts the current wave loose and puts up a bigger one.
		clearPuppets()
		waveIndex = phase - 1
	end,

	onReset = function()
		clearPuppets()
	end,
})

--------------------------------------------------------------------------------

local function build()
	local existing = Workspace:FindFirstChild("SetsunaPuppets")
	if existing then
		existing:Destroy()
	end
	folder = Create("Folder", { Name = "SetsunaPuppets", Parent = Workspace })
end

build()

--------------------------------------------------------------------------------
-- Damage into the puppets
--------------------------------------------------------------------------------

--[[
	Puppets are hit through the same Attack remote as everything else, and a
	swing picks the single nearest one. Cleave would defeat the entire lesson:
	if one swing clears the wave, there is no priority decision to make.
]]
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Network = require(ReplicatedStorage.Shared.Network)

Network.Attack.OnServerEvent:Connect(function(player, comboIndex)
	if typeof(comboIndex) ~= "number" or #puppets == 0 then
		return
	end
	local root = rootOf(player)
	if not root then
		return
	end

	local step = Encounters.comboStep(comboIndex)
	local best, bestIndex, bestDistance = nil, nil, Encounters.PlayerAttack.range

	for index, entry in puppets do
		local distance = (entry.position - root.Position).Magnitude
		if distance < bestDistance then
			best, bestIndex, bestDistance = entry, index, distance
		end
	end
	if not best then
		return
	end

	best.health -= step.damage
	local at = best.position + V(0, 5, 0)
	Vfx.sparks(at, 8, THREAD)
	Audio.playAt("hitLight", at)
	Network.BossDamaged:FireClient(player, step.damage, "normal", at)

	if best.health <= 0 then
		dropPuppet(bestIndex)
	end
end)

RunService.Heartbeat:Connect(function(dt)
	if #puppets == 0 then
		return
	end
	-- Puppets only act while someone is here to act at.
	local target
	for _, player in Players:GetPlayers() do
		if rootOf(player) then
			target = player
			break
		end
	end
	if not target then
		clearPuppets()
		return
	end

	for index = #puppets, 1, -1 do
		if puppets[index].health <= 0 then
			dropPuppet(index)
		else
			stepPuppet(puppets[index], dt, target)
		end
	end
end)

Players.PlayerRemoving:Connect(function()
	if #Players:GetPlayers() <= 1 then
		clearPuppets()
	end
end)

print(`[Setsuna] hanging at {ARENA}`)
]]></string>
          </Properties>
        </Item>
        <Item class="Script" referent="66">
          <Properties>
            <string name="Name">Wethermon</string>
            <token name="RunContext">0</token>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Wethermon the Tombguard.

	Four hundred years undefeated, and he fights like it: he never rushes, never
	feints, and never misses twice. Everything he does is telegraphed for a full
	second and hits like a building.

	DESIGNED AS THE OPPOSITE OF THE OTHER TWO.

	  Vorpal Bunny  small, still, one attack, read the timing
	  Lycagon       enormous, never still, combinations, read the spacing
	  Wethermon     immense, slow, unparryable slams, read your own greed

	Half his moveset cannot be parried. The halberd sweep can — it is a blade,
	and meeting a blade is what a parry is — but the slam and the quake are four
	hundred pounds of armour arriving, and no amount of timing answers that. You
	move, or you die. A player who has learned to solve everything with F meets
	the first boss that refuses to accept it.

	He punishes greed specifically: the recovery on his sweep is long and
	inviting, and the slam that follows it is not.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Anim = require(ReplicatedStorage.Shared.Mobs.Anim)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)

local Runtime = require(script.Parent.Runtime)

local V = Vector3.new

--- The far south-east of the forest, past the river. The longest walk on the
--- map, which is the point: nobody stumbles into this one.
local ARENA = V(214, 0, 62)

local TEAL = Color3.fromRGB(86, 224, 212)

--------------------------------------------------------------------------------

local Actions = {}

--- The one thing you can parry. A wide, slow arc of the halberd.
function Actions.sweep(ctx)
	ctx.audio("growl")
	ctx.await(0.95, "windup", function(phase)
		local _, playerPosition = ctx.player()
		if playerPosition and phase < 0.7 then
			ctx.face(playerPosition - ctx.position())
		end
		ctx.telegraph(true, phase >= 0.7, ctx.facing(), 22, 30)
	end)
	ctx.telegraph(false)

	local hold = ctx.beginAssist()
	if hold > 0 then
		ctx.await(hold, "windup")
	end

	ctx.await(0.22, "sweep", function()
		ctx.setPosition(ctx.position() + ctx.facing() * 0.3)
	end)

	ctx.strike(17, 74)
	Vfx.slash(ctx.position() + V(0, 6, 0), ctx.facing(), 20, TEAL)
	ctx.audio("slash")

	-- Deliberately long and inviting. The next thing he does is not.
	ctx.await(1.35, "recover")
end

--[[
	Overhead slam. Unparryable.

	Tracks you through the windup, commits late, and lands in a circle. This is
	the attack that teaches a parry-first player that the button is not always
	the answer.
]]
function Actions.slam(ctx)
	local landing = ctx.position()

	ctx.audio("growl")
	ctx.await(1.15, "raise", function(phase)
		local _, playerPosition = ctx.player()
		if playerPosition and phase < 0.78 then
			landing = V(playerPosition.X, ctx.groundAt(playerPosition.X, playerPosition.Z), playerPosition.Z)
			ctx.face(playerPosition - ctx.position())
		end
		-- Amber, not red: this one is a move-out-of-the-way, not a meet-it.
		Vfx.ring(landing + V(0, 0.25, 0), 15 * (1 - phase * 0.75), Color3.fromRGB(255, 170, 70), 0.18, 0.32)
	end)

	ctx.await(0.24, "slam", function(phase)
		ctx.setPosition(ctx.position():Lerp(landing, phase * 0.55))
	end)

	ctx.quake(15, 96, true)
	ctx.await(1.5, "recover")
end

--- Radial quake, also unparryable. Punishes standing anywhere near him.
function Actions.quake(ctx)
	ctx.await(1.0, "raise", function(phase)
		Vfx.ring(ctx.position() + V(0, 0.25, 0), 30 * phase, Color3.fromRGB(255, 170, 70), 0.2, 0.35)
	end)

	ctx.audio("heavyLand")
	ctx.quake(30, 68, true)

	-- Pillars of light where the ground broke. Arena interaction, and it marks
	-- where not to stand for the next few seconds.
	for index = 1, 6 do
		local angle = (index / 6) * math.pi * 2
		local at = ctx.position() + V(math.cos(angle) * 18, 0, math.sin(angle) * 18)
		Vfx.ring(V(at.X, ctx.groundAt(at.X, at.Z) + 0.2, at.Z), 7, TEAL, 0.8, 0.25)
	end

	ctx.await(1.6, "recover")
end

--- Closes the gap. Slow, loud, and impossible to miss coming.
function Actions.advance(ctx)
	for _ = 1, 3 do
		local _, playerPosition = ctx.player()
		if not playerPosition then
			return
		end
		ctx.face(playerPosition - ctx.position())

		ctx.await(0.5, "step", function(phase)
			local step = ctx.facing() * 9 * (1 / 60)
			ctx.setPosition(ctx.position() + step)
			if phase > 0.9 then
				Vfx.dust(ctx.position(), 0.8)
			end
		end)
		ctx.audio("heavyLand")
	end
end

function Actions.stagger(ctx, duration: number)
	-- Even staggered he does not fall. He kneels.
	ctx.audio("blocked")
	Vfx.sparks(ctx.position() + V(0, 6, 0), 30, TEAL)
	ctx.await(duration, "stagger")
	ctx.await(0.35, "idle")
end

--------------------------------------------------------------------------------

--[[
	He does not improvise. The pattern is: close if far, sweep if in reach,
	and punish anyone who overstayed the sweep's recovery with a slam.

	Phase 2 adds the quake; phase 3 chains slams. Nothing here is random enough
	to be unfair, which is the compliment the fight is paying you — it is
	entirely learnable, and losing is entirely your fault.
]]
local function choose(_ctx, signals)
	local roll = math.random()

	if signals.distance > 26 then
		return if roll < 0.7 then "advance" else "quake"
	end

	-- Straight after a sweep, the slam is the punish for greed.
	if signals.last == "sweep" then
		return if roll < 0.65 then "slam" else "sweep"
	end

	if signals.phase >= 3 and roll < 0.35 then
		return "slam"
	end
	if signals.phase >= 2 and roll < 0.3 then
		return "quake"
	end
	return if roll < 0.7 then "sweep" else "slam"
end

--------------------------------------------------------------------------------

Runtime.create({
	id = "wethermon",
	name = "Wethermon the Tombguard",
	title = "Unique Scenario",
	mobId = "wethermon",
	maxHealth = 4800,
	phases = 3,
	rewardXp = 1900,
	respawn = 60,

	arena = ARENA,
	--- The tomb's hall floor. Must match Tomb.server's FLOOR.
	floorY = -26,
	aggroRange = 46,
	leash = 150,
	punish = 1.6,
	staggerTime = 2.4,
	hitHeight = 6,
	hurtboxBonus = 5,
	--- Barely moves when hit. Four hundred years of not being moved.
	knockbackScale = 0.35,

	idleState = "idle",

	animate = Anim.wethermon,
	actions = Actions,
	choose = choose,

	onPhase = function(ctx, phase)
		ctx.audio("screech")
		Vfx.ring(ctx.position() + V(0, 0.3, 0), 34, TEAL, 0.9, 0.45)
		Vfx.parryBurst(ctx.position() + V(0, 7, 0))
		if phase >= 3 then
			-- The seals break. He stops holding back.
			for index = 1, 8 do
				local angle = (index / 8) * math.pi * 2
				local at = ctx.position() + V(math.cos(angle) * 22, 0, math.sin(angle) * 22)
				Vfx.ring(V(at.X, ctx.groundAt(at.X, at.Z) + 0.2, at.Z), 9, TEAL, 1.2, 0.3)
			end
		end
	end,
})

print(`[Wethermon] sealed at {ARENA}`)
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="Folder" referent="67">
        <Properties>
          <string name="Name">Bunny</string>
        </Properties>
        <Item class="ModuleScript" referent="68">
          <Properties>
            <string name="Name">Actions</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Actions — the Vorpal Bunny's move set, written as sequences.

	Each action is a coroutine driven a frame at a time by the encounter runner.
	Writing them as straight-line scripts rather than state-machine branches is
	what makes multi-beat attacks (three dashes, a leap with a hang at apex, a
	feint that aborts partway) readable at all.

	`ctx` is supplied by the runner and provides the world: awaiting time in a
	named animation state, hopping, striking, telegraphing, effects and audio.
	Actions never touch instances directly.

	EVERY attack follows anticipation -> commit -> follow-through. The
	anticipation is long and still (you read it), the commit is fast (you can't
	react to it, only to the anticipation), and the follow-through leaves it
	open (you get paid for reading it).
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Config = require(ReplicatedStorage.Shared.Config.Bunny)
local Motor = require(script.Parent.Motor)

local Actions = {}

local ATTACKS = Config.Attacks
local HOP = Config.Hop

--------------------------------------------------------------------------------
-- Shared beats
--------------------------------------------------------------------------------

--[[
	The crouch. Tracks you until `commitAt`, then locks and the telegraph goes
	solid. Returns the locked direction.

	Locking partway is the core reading skill: walking away early doesn't save
	you, and once it's committed you know exactly where it's going.
]]
local function windUp(ctx, attack, scale: number)
	local locked = nil
	local length = attack.dash and attack.distance or 0

	ctx.audio("growl")

	ctx.await(attack.windup * scale, "crouch", function(phase)
		if not locked then
			local _, playerPosition = ctx.player()
			if playerPosition then
				ctx.face(playerPosition - ctx.position())
			end
			if phase >= attack.commitAt then
				locked = ctx.facing()
				ctx.eyeFlash()
				ctx.audio("hitLight")
			end
		end
		ctx.telegraph(true, locked ~= nil, locked or ctx.facing(), length, attack.radius)
	end)

	-- Assisted players get a held beat at the top of the coil before it
	-- commits. It reads as the creature hanging on the edge of the lunge,
	-- which is exactly the "heightened concentration" the effect is selling.
	local hold = ctx.beginAssist()
	if hold and hold > 0 then
		ctx.await(hold, "crouch", function()
			ctx.telegraph(true, true, locked or ctx.facing(), length, attack.radius)
		end)
	end

	ctx.telegraph(false)
	return locked or ctx.facing()
end

--- Travels a straight line at speed, hitting once. Used by both dash attacks.
--- The travel is measured against you rather than fixed, so the skid always
--- ends inside your reach — see `Motor.dashTravel`.
local function dashThrough(ctx, attack, direction: Vector3, scale: number)
	local from = ctx.position()
	local _, playerPosition = ctx.player()
	local to =
		Motor.dashTarget(from, direction, Motor.dashTravel(from, direction, playerPosition, attack), ctx.groundAt)
	local landed = false

	ctx.audio("dash")
	ctx.audioAt("windRush", from)
	ctx.vfx().speedLines(from + Vector3.new(0, 2, 0), to + Vector3.new(0, 2, 0), Color3.fromRGB(226, 34, 44))
	ctx.trail(true)

	ctx.await(attack.dash * scale, "dash", function(phase)
		ctx.setPosition(from:Lerp(to, phase))
		if not landed and ctx.strike(attack.radius, attack.damage) then
			landed = true
		end
	end)

	ctx.trail(false)
	ctx.vfx().slash(ctx.position() + Vector3.new(0, 2, 0), direction, 9, Color3.fromRGB(255, 92, 96))
	ctx.vfx().dust(ctx.position(), 0.9)
	ctx.audioAt("slash", ctx.position())
	return landed
end

--- Skid, wobble, resettle. The window you were reading the tell for.
local function recover(ctx, duration: number)
	ctx.await(math.min(duration * 0.35, 0.3), "slashFollow")
	ctx.await(duration * 0.65, "recover")
end

--------------------------------------------------------------------------------
-- Attacks
--------------------------------------------------------------------------------

function Actions.dashSlash(ctx, scale: number)
	local attack = ATTACKS.dashSlash
	local direction = windUp(ctx, attack, scale)
	dashThrough(ctx, attack, direction, scale)
	recover(ctx, attack.recover * scale)
end

function Actions.tripleDash(ctx, scale: number)
	local attack = ATTACKS.tripleDash

	for index = 1, attack.repeats do
		-- Every repeat re-aims and re-telegraphs, so each is its own read
		-- rather than one attack that happens to hit three times.
		local direction = windUp(ctx, attack, scale * (if index == 1 then 1 else 0.85))
		dashThrough(ctx, attack, direction, scale)

		if index < attack.repeats then
			ctx.await(attack.between * scale, "recover")
		end
	end

	recover(ctx, attack.recover * scale)
end

--[[
	The feint. Identical to a Dash Slash right up to just past the point a
	Dash Slash would commit — so a player who has learned to parry the instant
	the telegraph goes solid gets punished for it, and has to learn to parry on
	the dash instead.
]]
function Actions.fakeAttack(ctx, scale: number)
	local attack = ATTACKS.fakeAttack

	ctx.audio("growl")
	ctx.await(attack.windup * attack.cancelAt * scale, "crouch", function(phase)
		local _, playerPosition = ctx.player()
		if playerPosition then
			ctx.face(playerPosition - ctx.position())
		end
		-- Sells it: the telegraph goes solid, exactly as it would for a real one,
		-- at the same width and the same reach.
		ctx.telegraph(true, phase >= 0.88, ctx.facing(), ATTACKS.dashSlash.distance, ATTACKS.dashSlash.radius)
	end)

	ctx.telegraph(false)
	ctx.audioAt("hop", ctx.position())
	ctx.vfx().dust(ctx.position(), 0.7)

	-- Twists out of the crouch and relocates behind you in one motion.
	local _, playerPosition, playerLook = ctx.player()
	local behind = if playerPosition
		then Motor.behindTarget(playerPosition, playerLook, attack.behindDistance, ctx.groundAt)
		else ctx.position()

	local from = ctx.position()
	ctx.trail(true)
	ctx.await(attack.reposition * scale, "feint", function(phase)
		ctx.setPosition(Motor.arc(from, behind, HOP.longHeight, phase))
	end)
	ctx.trail(false)

	ctx.vfx().landing(behind, 0.8)
	ctx.audioAt("land", behind)

	local _, nowPosition = ctx.player()
	if nowPosition then
		ctx.face(nowPosition - ctx.position())
	end

	ctx.await(attack.recover * scale, "recover")
end

--[[
	Leap. Rises out of view, hangs, then inverts and comes down on top of you.
	The hang is the whole point: you lose sight of it and have to move.

	It used to re-aim at your feet for the entire hang, which meant the landing
	spot was only fixed 0.2s before impact across an 11-stud radius. Nothing on
	foot covers 11 studs in 0.2s, so the one attack in the move set documented
	as having to be *moved out of* was the one attack that could not be, and at
	104 damage against 100 starting health it killed outright rather than
	leaving the sliver everything else in this fight leaves.

	Now it stops re-aiming partway through the hang and the committed spot is
	drawn on the ground. What you lose sight of is the creature; what you are
	given instead is a circle and just under four tenths of a second, which is a
	sprint and not a walk. The guess is still real — it is guessing whether to
	break early and eat the stamina, or hold and trust your read.
]]
function Actions.leapAttack(ctx, scale: number)
	local attack = ATTACKS.leapAttack
	local from = ctx.position()

	ctx.audio("growl")

	-- No ground indicator during the coil. Drawing the dash's forward lane here
	-- made the two windups look identical at exactly the moment their answers
	-- diverge — one is parried standing still, the other has to be run from.
	-- The commit still gets the eye flash every other attack gets, so the tell
	-- reads as "it has decided" rather than "it is going that way".
	local committed = false
	ctx.await(attack.windup * scale, "crouch", function(phase)
		local _, playerPosition = ctx.player()
		if playerPosition then
			ctx.face(playerPosition - ctx.position())
		end
		if not committed and phase >= attack.commitAt then
			committed = true
			ctx.eyeFlash()
			ctx.audio("hitLight")
		end
	end)

	local apex = from + Vector3.new(0, attack.height, 0)
	ctx.audioAt("windRush", from)
	ctx.vfx().dust(from, 1.2)

	ctx.await(attack.rise * scale, "leapRise", function(phase)
		ctx.setPosition(from:Lerp(apex, phase ^ 0.7))
	end)

	-- Re-aims for the first `trackUntil` of the hang, then locks and marks it.
	-- Falls back to where it took off from, not to the apex: with nobody to
	-- track, the old default left it hanging 46 studs in the air.
	local landing = Vector3.new(from.X, ctx.groundAt(from.X, from.Z), from.Z)
	ctx.await(attack.hang * scale, "leapHang", function(phase)
		if phase < attack.trackUntil then
			local _, playerPosition = ctx.player()
			if playerPosition then
				landing =
					Vector3.new(playerPosition.X, ctx.groundAt(playerPosition.X, playerPosition.Z), playerPosition.Z)
				ctx.face(playerPosition - ctx.position())
			end
		else
			ctx.marker(true, landing, attack.radius, true)
		end
	end)

	local dropFrom = ctx.position()
	local target = landing
	ctx.trail(true)
	ctx.await(attack.fall * scale, "leapFall", function(phase)
		ctx.setPosition(dropFrom:Lerp(target, phase ^ 1.8))
		ctx.marker(true, target, attack.radius, true)
	end)
	ctx.trail(false)
	ctx.marker(false)

	-- Impact.
	ctx.setPosition(target)
	-- Unparryable: this one has to be moved out of, not answered.
	ctx.strike(attack.radius, attack.damage, attack.unparryable)
	ctx.vfx().landing(target, 2.2)
	ctx.vfx().ring(target + Vector3.new(0, 0.2, 0), attack.radius * 1.6, Color3.fromRGB(255, 120, 90), 0.45, 0.35)
	ctx.vfx().leaves(target, 14)
	ctx.audioAt("heavyLand", target)
	ctx.shakeArena(target, 1.4)

	recover(ctx, attack.recover * scale)
end

--[[
	Close-range spin. Multiple hits, each one its own parry check.

	Marked with a disc rather than the forward lane it used to draw. This attack
	hits everything within 8.5 studs of the creature in every direction — 17
	studs across, against a lane that was drawn 12 wide and pointing one way, so
	standing behind it or off to the side put you outside everything the game
	had told you was dangerous and still inside the damage. The disc also has to
	follow, because the spin drifts toward you while it turns.
]]
function Actions.spinSlash(ctx, scale: number)
	local attack = ATTACKS.spinSlash

	ctx.audio("screech")
	ctx.await(attack.windup * scale, "crouch", function(phase)
		local _, playerPosition = ctx.player()
		if playerPosition then
			ctx.face(playerPosition - ctx.position())
		end
		ctx.marker(true, ctx.position(), attack.radius, phase >= attack.commitAt)
	end)

	ctx.trail(true)
	for _ = 1, attack.hits do
		ctx.audioAt("spin", ctx.position())
		ctx.await(attack.between * scale, "spin", function()
			-- Drifts toward you through the spin so it can't be simply walked
			-- out of, but slowly enough that it can be.
			local _, playerPosition = ctx.player()
			if playerPosition then
				local toward = Motor.flatten(playerPosition - ctx.position())
				ctx.setPosition(ctx.position() + toward * 0.14)
			end
			ctx.marker(true, ctx.position(), attack.radius, true)
		end)

		ctx.strike(attack.radius, attack.damage)
		ctx.vfx().slash(ctx.position() + Vector3.new(0, 1.8, 0), ctx.facing(), 7, Color3.fromRGB(255, 140, 120))
	end
	ctx.trail(false)
	ctx.marker(false)

	recover(ctx, attack.recover * scale)
end

--- Disengage. Buys it space, buys you nothing.
function Actions.retreat(ctx, scale: number)
	local attack = ATTACKS.retreat

	for _ = 1, attack.hops do
		local _, playerPosition = ctx.player()
		local away = if playerPosition then -Motor.flatten(playerPosition - ctx.position()) else ctx.facing()
		-- Backward *and* sideways, so a retreat still isn't a straight line.
		local skew = Vector3.new(-away.Z, 0, away.X) * ((math.random() - 0.5) * 1.4)
		local landing = ctx.position() + (away + skew).Unit * HOP.longDistance

		ctx.hop(
			Vector3.new(landing.X, ctx.groundAt(landing.X, landing.Z), landing.Z),
			HOP.longHeight * 0.7,
			HOP.longAirTime * scale
		)

		if playerPosition then
			ctx.face(playerPosition - ctx.position())
		end
	end

	ctx.await(attack.recover * scale, "observe")
end

--[[
	Parried. Reeling and wide open — this is the window the whole guard
	mechanic exists to buy, so it is long enough to land a full combo chain and
	poses distinctly enough that you can tell it apart from an ordinary
	recovery at a glance.
]]
function Actions.stagger(ctx, duration: number)
	ctx.audio("screech")
	ctx.await(duration, "stagger")

	-- Shakes it off and immediately re-orients on you.
	local _, playerPosition = ctx.player()
	if playerPosition then
		ctx.face(playerPosition - ctx.position())
	end
	ctx.await(0.25, "observe")
end

--------------------------------------------------------------------------------
-- Between attacks
--------------------------------------------------------------------------------

--[[
	The rest behaviour, and the most important one for how the fight *reads*.

	It is never standing still. It circles, occasionally reverses, sometimes
	vaults onto a rock, and sometimes ducks behind a tree before an attack. The
	brief `observe` beats between hops are where the head tracking and ear
	twitches live — it is watching you the whole time.
]]
function Actions.circle(ctx, hops: number, scale: number)
	for index = 1, hops do
		local _, playerPosition = ctx.player()
		if not playerPosition then
			return
		end

		if math.random() < HOP.reverseChance then
			ctx.reverseSpin()
		end

		local useCover = index == hops and math.random() < 0.3
		local landing = nil

		if useCover then
			landing = ctx.coverTarget(playerPosition)
		end
		if not landing then
			landing = Motor.circleTarget(
				ctx.position(),
				playerPosition,
				Config.Perception.circleRadius,
				ctx.spin(),
				HOP.lateralBias,
				HOP.mediumDistance,
				ctx.groundAt
			)
		end

		local vault = math.random() < HOP.vaultChance
		ctx.hop(
			landing,
			(if vault then HOP.longHeight else HOP.height) * scale,
			(if vault then HOP.longAirTime else HOP.airTime) * scale
		)

		ctx.face(playerPosition - ctx.position())

		-- A held beat, watching. Short, so it never reads as idling.
		ctx.await(0.1 + math.random() * 0.16, "observe")
	end
end

return Actions
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="69">
          <Properties>
            <string name="Name">Brain</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Brain — utility scoring for the Vorpal Bunny.

	It chooses; it does not cycle. Every action is scored against live signals
	and the best one wins, so the same creature plays differently against a
	turtling player and a reckless one — and the same situation twice doesn't
	produce the same answer, because of a deliberate noise term.

	SIGNALS, and what the creature does about each:

	  distance        close   -> spin, or disengage. far -> leap or triple dash
	  playerStamina   low     -> press hard; you can't block or dodge much
	  recentParries   high    -> feint. You have learned to bite, so bait it
	  aggression      high    -> make space and force you to come to it
	  healthFraction  low     -> frenzy; faster, and far more feints

	The parry-punish rule is the one that makes it feel like it is reading you.
	A player who learns the parry starts winning cleanly, so the creature starts
	throwing fake attacks that look identical up to the commit point. The
	counter-play is to stop parrying on sight and start parrying on *commit* —
	which is a harder skill, learned because the boss taught it.

	Returns (actionName, debugScores) so tuning can be inspected live.
]]

local Config = require(game:GetService("ReplicatedStorage").Shared.Config.Bunny)

local Brain = {}

local CANDIDATES = { "dashSlash", "tripleDash", "fakeAttack", "leapAttack", "spinSlash", "retreat" }

local function apply(scores, table_)
	if not table_ then
		return
	end
	for name, multiplier in table_ do
		if scores[name] then
			scores[name] *= multiplier
		end
	end
end

--[[
	`signals` fields:
	    distance        studs to the player
	    stamina         0-1 fraction of the player's stamina
	    parries         parries landed on it inside the memory window
	    aggression      player attacks inside the aggression window
	    health          0-1 fraction of its own health
	    last            previously chosen action name
]]
function Brain.choose(signals)
	local utility = Config.Utility
	local scores = {}

	for _, name in CANDIDATES do
		local attack = Config.Attacks[name]
		scores[name] = attack and attack.weight or 0.5
	end

	-- Spacing dictates what is even sensible.
	if signals.distance <= Config.Perception.crowdedRange then
		apply(scores, utility.closeUp)
	elseif signals.distance >= Config.Perception.farRange then
		apply(scores, utility.farOff)
	end

	-- You are out of stamina. This is the moment to be unfair.
	if signals.stamina <= utility.staminaBelow then
		apply(scores, utility.pressAdvantage)
	end

	-- You have been parrying it. Start selling fakes.
	if signals.parries >= utility.parryMemory then
		apply(scores, utility.parryPunish)
	end

	-- You have been swinging a lot. Take the space away.
	if signals.aggression >= utility.aggressionThreshold then
		apply(scores, utility.spacing)
	end

	-- Frenzy leans on feints and pressure rather than raw repetition.
	local frenzied = signals.health <= Config.Frenzy.below
	if frenzied then
		scores.fakeAttack = (scores.fakeAttack or 0) * (1 + Config.Frenzy.fakeBonus)
		scores.retreat = (scores.retreat or 0) * 0.5
	end

	-- Never twice running unless everything else scored terribly.
	if signals.last and scores[signals.last] then
		scores[signals.last] *= utility.repeatPenalty
	end

	-- Retreat is a spacing tool, never an opener.
	if signals.distance > Config.Perception.circleRadius then
		scores.retreat = (scores.retreat or 0) * 0.25
	end

	local best, bestScore = "dashSlash", -1
	for name, score in scores do
		local jittered = score * (1 + (math.random() - 0.5) * 2 * utility.noise)
		if jittered > bestScore then
			best, bestScore = name, jittered
		end
	end

	return best, scores
end

--- Rest between actions, shortened in frenzy. It is circling throughout, so
--- this is thinking time, not standing time.
function Brain.restDuration(health: number): number
	local rest = Config.Rest
	local base = rest.min + math.random() * (rest.max - rest.min)
	if health <= Config.Frenzy.below then
		base *= Config.Frenzy.timing
	end
	return base
end

return Brain
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="70">
          <Properties>
            <string name="Name">Motor</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Motor — hop locomotion maths.

	Pure functions: given a situation, where should the next hop land? No state,
	no side effects, so the movement rules can be reasoned about and changed
	without touching the AI or the animation.

	THE RULE: it never travels in a straight line. Every target has a lateral
	component, including approaches. A creature that beelines is a creature you
	can predict, and a predictable predator isn't frightening. `lateralBias`
	controls how much sideways goes into each hop — at 0 it would charge, at 1
	it would only orbit.
]]

local Motor = {}

local function flatten(v: Vector3): Vector3
	local flat = Vector3.new(v.X, 0, v.Z)
	return if flat.Magnitude < 0.001 then Vector3.new(0, 0, -1) else flat.Unit
end

--- Parabolic arc position at `phase` (0-1) between two ground points.
function Motor.arc(from: Vector3, to: Vector3, height: number, phase: number): Vector3
	local ground = from:Lerp(to, phase)
	return ground + Vector3.new(0, math.sin(phase * math.pi) * height, 0)
end

--[[
	The default hop while sizing you up: mostly sideways, slightly toward or
	away from you depending on whether it's inside its preferred spacing.

	`spin` is +1 or -1 — which way round it's currently orbiting. Flipping it
	occasionally is what turns circling into something you can't lead.
]]
function Motor.circleTarget(
	position: Vector3,
	playerPosition: Vector3,
	radius: number,
	spin: number,
	lateralBias: number,
	distance: number,
	groundAt: (number, number) -> number
): Vector3
	local toPlayer = playerPosition - position
	local flat = flatten(toPlayer)
	local gap = Vector3.new(toPlayer.X, 0, toPlayer.Z).Magnitude

	-- Radial component: close if too far, back off if too near, hold if inside
	-- a comfortable band. The band stops it oscillating in and out.
	local radial = 0
	if gap > radius + 4 then
		radial = 1
	elseif gap < radius - 5 then
		radial = -1
	end

	local tangent = Vector3.new(-flat.Z, 0, flat.X) * spin
	local direction = (flat * radial * (1 - lateralBias) + tangent * lateralBias)
	if direction.Magnitude < 0.01 then
		direction = tangent
	end

	local landing = position + direction.Unit * distance
	return Vector3.new(landing.X, groundAt(landing.X, landing.Z), landing.Z)
end

--- Directly behind the player, relative to where they're looking. Used by the
--- feint, which is the whole reason the fake attack is frightening.
function Motor.behindTarget(
	playerPosition: Vector3,
	playerLook: Vector3,
	distance: number,
	groundAt: (number, number) -> number
): Vector3
	local back = -flatten(playerLook)
	-- Offset slightly off the exact centreline so it doesn't land in a
	-- perfectly readable spot every time.
	local skew = Vector3.new(-back.Z, 0, back.X) * ((math.random() - 0.5) * 6)
	local landing = playerPosition + back * distance + skew
	return Vector3.new(landing.X, groundAt(landing.X, landing.Z), landing.Z)
end

--- A landing spot that puts something solid between it and you. Breaking line
--- of sight before an attack is the difference between an enemy that moves and
--- one that hunts.
function Motor.coverTarget(
	position: Vector3,
	playerPosition: Vector3,
	obstacles: { Vector3 },
	maxRange: number,
	groundAt: (number, number) -> number
): Vector3?
	local best, bestScore = nil, 0

	for _, obstacle in obstacles do
		local toObstacle = obstacle - position
		if Vector3.new(toObstacle.X, 0, toObstacle.Z).Magnitude > maxRange then
			continue
		end

		-- Prefer cover that is roughly between us and the player, and not so
		-- close to them that hiding is pointless.
		local fromPlayer = obstacle - playerPosition
		local gap = Vector3.new(fromPlayer.X, 0, fromPlayer.Z).Magnitude
		if gap < 9 then
			continue
		end

		local behind = obstacle + flatten(fromPlayer) * 4
		local score = 1 / (1 + math.abs(gap - 20)) + math.random() * 0.4
		if score > bestScore then
			best, bestScore = behind, score
		end
	end

	if not best then
		return nil
	end
	return Vector3.new(best.X, groundAt(best.X, best.Z), best.Z)
end

--- Straight-line dash destination, clamped to the arena floor.
function Motor.dashTarget(
	position: Vector3,
	direction: Vector3,
	distance: number,
	groundAt: (number, number) -> number
): Vector3
	local landing = position + flatten(direction) * distance
	return Vector3.new(landing.X, groundAt(landing.X, landing.Z), landing.Z)
end

--[[
	How far a dash should actually travel.

	The naive answer is a flat `attack.distance`, which is what this used to do,
	and it turns the punish window into a lottery. Thrown from on top of you a
	33-stud dash ends 24 studs past; thrown from its circling radius it ends 14
	past; only from somewhere in the middle does it stop inside your 11-stud
	reach. The long recovery exists so you can hit it, and you can only hit it
	if you can reach it — the fight had this exact failure once before, at 27
	studs of overshoot, and it was unwinnable.

	So it aims to stop `overshoot` studs beyond you, measured *along the locked
	direction* rather than as a straight-line distance. The direction commits
	partway through the windup and you have the rest of it to step off that
	line, so sidestepping the commit makes the dash sail harmlessly past. That
	is the payoff for reading it, and it only works if the travel is measured
	against the line rather than against you.

	`minDistance` keeps a dash thrown from point-blank reading as a dash rather
	than a twitch; `distance` remains the ceiling, so out of range it falls
	short and whiffs.
]]
function Motor.dashTravel(position: Vector3, direction: Vector3, playerPosition: Vector3?, attack): number
	if not playerPosition then
		return attack.distance
	end

	local toPlayer = playerPosition - position
	local along = flatten(direction):Dot(Vector3.new(toPlayer.X, 0, toPlayer.Z))
	return math.clamp(along + attack.overshoot, attack.minDistance, attack.distance)
end

Motor.flatten = flatten

return Motor
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="Script" referent="71">
        <Properties>
          <string name="Name">Colosseum</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Colosseum — five waves, one floor, no exit until it's done.

	The town gives you shops and errands. This gives you the other thing an
	action game needs: a place whose whole purpose is to fight in, that you
	choose to enter, that escalates, and that pays.

	WHY WAVES AND NOT A BOSS. The three bosses each teach one pattern very
	hard. What the game had no answer for was *many things at once* — spacing,
	target selection, and deciding which of four telegraphs is the one that
	will actually hit you. Waves are the only shape that teaches that, and it
	is the skill every player is missing when they first meet the Lycagon.

	Fighters route through the same Guard as every other enemy, so blocking,
	parrying and dodging all behave exactly as they do in the field. An arena
	with its own combat rules would be a minigame; this is the game, denser.

	The floor is west of the town walls, reached by a causeway. OiKatzo takes
	entries at the gate.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Detail = require(ReplicatedStorage.Shared.World.Detail)
local Render = require(ReplicatedStorage.Shared.Util.Render)
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Mobs = require(ReplicatedStorage.Shared.Mobs)
local Net = require(ReplicatedStorage.Shared.Mobs.Net)
local Network = require(ReplicatedStorage.Shared.Network)
local Rig = require(ReplicatedStorage.Shared.Mobs.Rig)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Town = require(ReplicatedStorage.Shared.Config.Town)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)

local Guard = require(script.Parent.Combat.Guard)
local Profile = require(script.Parent.Profile)

local V = Vector3.new

local FOLDER = "Colosseum"
local ARENA = Town.Colosseum

local SAND = Color3.fromRGB(196, 172, 132)
local STONE = Color3.fromRGB(146, 140, 126)
local STONE_DARK = Color3.fromRGB(108, 102, 92)
local BANNER = Color3.fromRGB(62, 120, 148)

--[[
	Five waves.

	`count` climbs faster than `health` on purpose: the difficulty should come
	from being surrounded, not from every individual enemy taking longer to
	kill. A wave of eight weak things is a spacing problem. A wave of two tough
	things is just a slower version of wave one.
]]
local WAVES = {
	{ count = 3, health = 120, damage = 14, speed = 12, gold = 60 },
	{ count = 4, health = 150, damage = 16, speed = 13, gold = 90 },
	{ count = 6, health = 170, damage = 19, speed = 14, gold = 140 },
	{ count = 7, health = 200, damage = 22, speed = 15, gold = 200 },
	{ count = 9, health = 240, damage = 26, speed = 16, gold = 340 },
}

local RUN_BONUS = 600
local RUN_XP = 900
local BREATHER = 5

--- Fighter AI timings. Deliberately slower than the field goblins: in a crowd
--- you need longer to read each individual windup or the whole thing is noise.
local NOTICE = 62
local REACH = 9.5
local WINDUP = 0.75
local RECOVER = 1.1

local folder: Folder
local fighters: { any } = {}
local run = {
	active = false,
	player = nil :: Player?,
	wave = 0,
	gold = 0,
}

--------------------------------------------------------------------------------
-- The building
--------------------------------------------------------------------------------

local function slab(name: string, size: Vector3, at: Vector3, color: Color3, material: Enum.Material?): BasePart
	local part = Create("Part", {
		Name = name,
		Anchored = true,
		Size = size,
		Position = at,
		Color = color,
		Material = material or Enum.Material.Slate,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
		Parent = folder,
	})
	-- Shadows come from mass; trim and clutter stop paying for them.
	Render.applyShadow(part)
	return part
end

local function buildArena()
	local center = ARENA.center
	local rng = Random.new(5150)

	--[[
		Ground plate.

		Deliberately stops short of the town's own ground (which reaches x=-180)
		and sits 0.6 studs lower than it. Two ground planes whose top faces are
		at exactly the same height and overlap will z-fight — the seam flickers
		as the camera moves, and it is the single most obvious "this was
		assembled wrong" artefact a scene can have. The causeway bridges the
		gap, riding slightly proud of both.
	]]
	slab(
		"Ground",
		V(202, 2, ARENA.radius * 2 + 90),
		center + V(-11, -1.6, 0),
		Color3.fromRGB(122, 116, 96),
		Enum.Material.Ground
	)

	-- Fighting floor: sand, so hits kick up dust that reads against stone. Its
	-- top sits at y=0, clearly above the plate around it, so the arena reads
	-- as a laid floor rather than a recoloured patch of dirt.
	slab("Floor", V(ARENA.radius * 2, 1, ARENA.radius * 2), center + V(0, -0.5, 0), SAND, Enum.Material.Sand)

	--[[
		The bowl.

		Three stepped rings of seating rather than a smooth wall. Steps read as
		a structure people sit in; a cylinder reads as a bucket. Each ring is
		built from segments around the circle, which also gives the silhouette
		the faceting real stonework has.
	]]
	local segments = 30
	for tier = 0, 3 do
		local ringRadius = ARENA.radius + 6 + tier * 9
		local height = 9 + tier * 7

		for index = 0, segments - 1 do
			local angle = index / segments * math.pi * 2
			local at = center + V(math.cos(angle) * ringRadius, height / 2, math.sin(angle) * ringRadius)

			local block = slab(
				`Tier{tier}_{index}`,
				V(ringRadius * 2 * math.pi / segments * 1.08, height, 9),
				at,
				Detail.vary(if tier % 2 == 0 then STONE else STONE_DARK, rng, 0.04),
				Enum.Material.Brick
			)
			block.CFrame = CFrame.new(at) * CFrame.Angles(0, -angle, 0)
			block.CanCollide = true
		end
	end

	-- The wall the fighters cannot leave and the player cannot climb.
	for index = 0, segments * 2 - 1 do
		local angle = index / (segments * 2) * math.pi * 2
		local at = center + V(math.cos(angle) * (ARENA.radius + 2), 7, math.sin(angle) * (ARENA.radius + 2))
		local block = slab(
			`Barrier{index}`,
			V(ARENA.radius * 2 * math.pi / (segments * 2) * 1.1, 14, 3),
			at,
			Detail.vary(STONE_DARK, rng, 0.03),
			Enum.Material.Brick
		)
		block.CFrame = CFrame.new(at) * CFrame.Angles(0, -angle, 0)
		block.CanCollide = true
	end

	--[[
		The entrance tunnel, cut through the bowl on the east side.

		Built as a gap in the barrier plus a lintel over it, the same way the
		town gate is: a hole with a header reads as a way in, a missing block
		reads as a mistake.
	]]
	local mouth = center + V(ARENA.radius + 2, 0, 0)
	for index = 0, segments * 2 - 1 do
		local angle = index / (segments * 2) * math.pi * 2
		if math.abs(math.atan2(math.sin(angle), math.cos(angle))) < 0.22 then
			local existing = folder:FindFirstChild(`Barrier{index}`)
			if existing then
				existing:Destroy()
			end
			for tier = 0, 3 do
				local ring = folder:FindFirstChild(`Tier{tier}_{math.floor(index / 2)}`)
				if ring and tier < 2 then
					ring:Destroy()
				end
			end
		end
	end
	slab("TunnelLintel", V(6, 5, 30), mouth + V(2, 16, 0), STONE, Enum.Material.Brick)
	slab("TunnelFloor", V(40, 1, 26), mouth + V(18, -0.5, 0), SAND, Enum.Material.Sand)

	-- Causeway from the town's west wall out to the tunnel mouth.
	local causewayCenter = (ARENA.causewayFrom + ARENA.causewayTo) / 2
	-- Top face at y = 0.15: above the town plate it overlaps and above the
	-- arena plate it lands on, so neither seam can z-fight. A 0.15 lip is
	-- invisible to look at and nothing to walk over.
	slab(
		"Causeway",
		V(math.abs(ARENA.causewayFrom - ARENA.causewayTo) + 60, 1, 26),
		V(causewayCenter - 12, -0.35, ARENA.gateZ),
		Color3.fromRGB(126, 121, 112),
		Enum.Material.Cobblestone
	)
	for side = -1, 1, 2 do
		slab(
			`CausewayKerb{side}`,
			V(math.abs(ARENA.causewayFrom - ARENA.causewayTo) + 60, 1.6, 2),
			V(causewayCenter - 12, 0.45, ARENA.gateZ + side * 13),
			STONE,
			Enum.Material.Slate
		)
	end

	-- Banners around the rim, and braziers either side of the mouth.
	for index = 0, 7 do
		local angle = index / 8 * math.pi * 2 + 0.4
		local at = center + V(math.cos(angle) * (ARENA.radius + 34), 37, math.sin(angle) * (ARENA.radius + 34))
		Detail.banner(CFrame.lookAt(at, at + V(-math.cos(angle), 0, -math.sin(angle))), rng, BANNER, folder)
	end

	for side = -1, 1, 2 do
		Detail.fire(CFrame.new(mouth + V(-6, 0, side * 15)), rng, { scale = 1.4, parent = folder })
	end

	-- A few props on the sand, for cover and for scale.
	for _ = 1, 6 do
		local angle = rng:NextNumber(0, math.pi * 2)
		local distance = rng:NextNumber(ARENA.radius * 0.35, ARENA.radius * 0.8)
		local at = center + V(math.cos(angle) * distance, 0, math.sin(angle) * distance)
		if rng:NextNumber() < 0.5 then
			Detail.crate(CFrame.new(at), rng, folder)
		else
			Detail.barrel(CFrame.new(at), rng, folder)
		end
	end
end

--------------------------------------------------------------------------------
-- Fighters
--------------------------------------------------------------------------------

local function rootOf(player: Player): BasePart?
	local character = player.Character
	return character and character:FindFirstChild("HumanoidRootPart") :: BasePart?
end

local function spawnFighter(index: number, wave)
	local angle = index / wave.count * math.pi * 2 + run.wave
	local at = ARENA.center + V(math.cos(angle) * (ARENA.radius - 12), 0, math.sin(angle) * (ARENA.radius - 12))

	local model = Mobs.build("goblin", CFrame.lookAt(at, ARENA.center))
	if not model then
		return
	end
	model.Parent = folder
	Net.setRig(model, "goblin")

	-- Arriving in a puff of sand, rather than blinking into existence.
	Vfx.dust(at, 1.2)
	Audio.playAt("hop", at)

	table.insert(fighters, {
		model = model,
		rig = Rig.bind(model),
		position = at,
		facing = (ARENA.center - at).Unit,
		health = wave.health,
		maxHealth = wave.health,
		damage = wave.damage,
		speed = wave.speed,
		state = "close",
		timer = 0,
		phase = math.random() * 6,
	})
end

local function killFighter(entry, index: number)
	Vfx.sparks(entry.position + V(0, 4, 0), 14, Theme.Color.DamageCrit)
	Vfx.dust(entry.position, 0.8)
	Audio.playAt("defeat", entry.position)
	if entry.model then
		entry.model:Destroy()
	end
	table.remove(fighters, index)
end

--- One fighter's frame. Approach, telegraph, strike, recover — the same four
--- beats as every other enemy, so nothing here has to be learned separately.
local function stepFighter(entry, dt: number, player: Player)
	local root = rootOf(player)
	if not root then
		return
	end

	local toPlayer = root.Position - entry.position
	local flat = V(toPlayer.X, 0, toPlayer.Z)
	local distance = flat.Magnitude
	entry.timer -= dt

	if distance < NOTICE and distance > 0.1 then
		entry.facing = entry.facing:Lerp(flat.Unit, math.clamp(dt * 6, 0, 1))
	end

	if entry.state == "close" then
		if distance > REACH then
			-- Fighters keep a little distance from each other so a wave arrives
			-- as a ring rather than a single overlapping blob.
			local separation = V(0, 0, 0)
			for _, other in fighters do
				if other ~= entry then
					local away = entry.position - other.position
					if away.Magnitude < 9 and away.Magnitude > 0.1 then
						separation += away.Unit * (9 - away.Magnitude) * 0.4
					end
				end
			end
			entry.position += (flat.Unit * entry.speed + separation) * dt
		else
			entry.state = "telegraph"
			entry.timer = WINDUP
			Vfx.closingRing(entry.position + V(0, 0.3, 0), REACH, Color3.fromRGB(255, 170, 90), WINDUP)
			Audio.playAt("growl", entry.position)
		end
	elseif entry.state == "telegraph" and entry.timer <= 0 then
		entry.state = "recover"
		entry.timer = RECOVER

		Audio.playAt("slash", entry.position)
		Vfx.slash(entry.position + V(0, 4, 0), entry.facing, 6, Color3.fromRGB(255, 220, 180))

		if distance <= REACH + 1.5 then
			-- Straight through Guard, so blocking and parrying work here
			-- exactly as they do against the bosses.
			Guard.resolve(player, { damage = entry.damage, from = entry.position })
		end
	elseif entry.state == "recover" and entry.timer <= 0 then
		entry.state = "close"
	end

	local moving = entry.state == "close" and distance > REACH
	entry.phase += dt * (if moving then 9 else 2)
	local bob = if moving then math.abs(math.sin(entry.phase)) * 0.5 else math.sin(entry.phase) * 0.12
	local lunge = if entry.state == "telegraph" then -0.7 else 0

	Net.publish(
		entry.model,
		CFrame.lookAt(entry.position + V(0, bob, 0), entry.position + entry.facing + V(0, bob, 0))
			* CFrame.new(0, 0, lunge),
		entry.state,
		entry.phase,
		nil
	)
end

--------------------------------------------------------------------------------
-- The run
--------------------------------------------------------------------------------

local function clearFighters()
	for _, entry in fighters do
		if entry.model then
			entry.model:Destroy()
		end
	end
	table.clear(fighters)
end

local function announce(text: string, sub: string?)
	local player = run.player
	if player then
		Network.ArenaState:FireClient(player, {
			active = run.active,
			wave = run.wave,
			totalWaves = #WAVES,
			remaining = #fighters,
			banner = text,
			sub = sub,
		})
	end
end

local function endRun(won: boolean)
	local player = run.player
	clearFighters()
	run.active = false
	run.wave = 0

	if not player then
		return
	end

	if won then
		Profile.addGold(player, run.gold + RUN_BONUS)
		Profile.recordKill(player, "colosseum_run")
		Network.EnemyDefeated:FireClient(player, "colosseum", RUN_XP)
		Audio.playAt("levelUp", ARENA.center)
		Vfx.parryBurst(ARENA.center + V(0, 6, 0))
		announce("RUN CLEARED", `{run.gold + RUN_BONUS} gold`)
	else
		announce("RUN ENDED", "The floor is yours again when you are.")
	end

	Audio.stopMusic()
	run.player = nil
	run.gold = 0

	task.delay(4, function()
		if not run.active then
			Network.ArenaState:FireClient(player, nil)
		end
	end)
end

local function startRun(player: Player)
	if run.active then
		return false
	end

	run.active = true
	run.player = player
	run.wave = 0
	run.gold = 0

	Audio.startMusic()

	task.spawn(function()
		for index, wave in WAVES do
			if not run.active then
				return
			end

			run.wave = index
			Audio.setIntensity(index + 1)
			announce(`WAVE {index} / {#WAVES}`, `{wave.count} incoming`)

			for slot = 1, wave.count do
				spawnFighter(slot, wave)
			end

			-- Wait for the floor to clear, or for the run to be abandoned.
			while run.active and #fighters > 0 do
				task.wait(0.25)
			end
			if not run.active then
				return
			end

			run.gold += wave.gold
			Profile.addGold(player, wave.gold)

			if index < #WAVES then
				announce(`WAVE {index} CLEAR`, `{BREATHER}s — next wave incoming`)
				task.wait(BREATHER)
			end
		end

		if run.active then
			endRun(true)
		end
	end)

	return true
end

--------------------------------------------------------------------------------
-- Damage in
--------------------------------------------------------------------------------

--[[
	Player swings land on the nearest fighter in reach.

	Shares the Attack remote with the rest of the game, and picks a single
	target rather than cleaving: in a crowd, "which one did I just hit" has to
	be answerable, and a swing that quietly damages four enemies makes crowd
	positioning meaningless.
]]
Network.Attack.OnServerEvent:Connect(function(player, comboIndex)
	if typeof(comboIndex) ~= "number" or not run.active or run.player ~= player then
		return
	end
	local root = rootOf(player)
	if not root then
		return
	end

	local step = Encounters.comboStep(comboIndex)
	local best, bestIndex, bestDistance = nil, nil, Encounters.PlayerAttack.range

	for index, entry in fighters do
		local distance = (entry.position - root.Position).Magnitude
		if distance < bestDistance then
			best, bestIndex, bestDistance = entry, index, distance
		end
	end

	if not best then
		return
	end

	best.health -= step.damage
	local at = best.position + V(0, 4, 0)

	Vfx.sparks(at, if step.knockback >= 30 then 18 else 9, Color3.fromRGB(255, 226, 170))
	Vfx.slash(at, root.CFrame.LookVector, 6, Color3.fromRGB(255, 240, 210))
	Audio.playAt(if step.knockback >= 30 then "hitHeavy" else "hitLight", at)

	Network.BossDamaged:FireClient(player, step.damage, "normal", at)
	Network.Impact:FireClient(player, if step.knockback >= 30 then "heavy" else "light")

	-- Knocked back, so a heavy swing visibly buys space in a crowd.
	if step.knockback > 0 and bestDistance > 0.1 then
		local away = (best.position - root.Position).Unit
		best.position += away * (step.knockback * 0.06)
	end

	if best.health <= 0 then
		killFighter(best, bestIndex)
		announce(`WAVE {run.wave} / {#WAVES}`, `{#fighters} left`)
	end
end)

--------------------------------------------------------------------------------

Network.Arena.OnServerEvent:Connect(function(player, action)
	if action == "start" then
		local root = rootOf(player)
		if not root then
			return
		end
		-- Must actually be standing at the arena. The gate NPC is the intended
		-- route in, but the check is on position, not on having talked to him.
		if (V(root.Position.X, 0, root.Position.Z) - ARENA.center).Magnitude > ARENA.radius + 80 then
			return
		end
		startRun(player)
	elseif action == "leave" and run.player == player then
		endRun(false)
	end
end)

Players.PlayerRemoving:Connect(function(player)
	if run.player == player then
		endRun(false)
	end
end)

--------------------------------------------------------------------------------

local function build()
	local existing = Workspace:FindFirstChild(FOLDER)
	if existing then
		existing:Destroy()
	end
	folder = Create("Folder", { Name = FOLDER, Parent = Workspace })
	buildArena()
	print(`[Colosseum] arena ready at {ARENA.center} — {#WAVES} waves`)
end

build()

RunService.Heartbeat:Connect(function(dt)
	local player = run.player
	if not (run.active and player) then
		return
	end

	-- Dying, or wandering off the floor, ends the run rather than leaving
	-- fighters swinging at nobody.
	local root = rootOf(player)
	if not root then
		endRun(false)
		return
	end
	if (V(root.Position.X, 0, root.Position.Z) - ARENA.center).Magnitude > ARENA.radius + 60 then
		endRun(false)
		return
	end

	for index = #fighters, 1, -1 do
		local entry = fighters[index]
		if entry.health <= 0 then
			killFighter(entry, index)
		else
			stepFighter(entry, dt, player)
		end
	end
end)
]]></string>
        </Properties>
      </Item>
      <Item class="Folder" referent="72">
        <Properties>
          <string name="Name">Combat</string>
        </Properties>
        <Item class="ModuleScript" referent="73">
          <Properties>
            <string name="Name">Assist</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Assist — per-player adaptive parry help.

	Holds each player's mode, their current streaks, and how much help they have
	earned. Any attack that wants to be parryable asks `Assist.begin` right
	before it commits; if that returns a profile, the attack stretches its
	approach by `profile.slow` and the client shows the focus effect for the
	same span.

	The server owns this because it owns the attack timing. A client cannot
	widen its own window, only its own visuals.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Config = require(ReplicatedStorage.Shared.Config.Assist)
local Network = require(ReplicatedStorage.Shared.Network)

local Assist = {}

type State = {
	mode: string,
	--- Accumulated failures. Drives both how much time stretches and which
	--- stage of help is showing; one number so the two can never disagree.
	failures: number,
	successStreak: number,
	--- Set while an assisted approach is in flight, so a hit can be graded.
	pending: boolean,
	--- Identifies the in-flight approach, so a late safety net can't clear a
	--- newer one.
	token: number,
}

local states: { [Player]: State } = {}

local function stateFor(player: Player): State
	local state = states[player]
	if not state then
		state = {
			mode = Config.DefaultMode,
			failures = 0,
			successStreak = 0,
			pending = false,
			token = 0,
		}
		states[player] = state
	end
	return state
end

--------------------------------------------------------------------------------

function Assist.setMode(player: Player, mode: string)
	local valid = false
	for _, entry in Config.Modes do
		if entry.id == mode then
			valid = true
			break
		end
	end
	if not valid then
		return
	end

	local state = stateFor(player)
	state.mode = mode
	if mode == "off" then
		state.failures = 0
	end
end

function Assist.modeOf(player: Player): string
	return stateFor(player).mode
end

--------------------------------------------------------------------------------

--- A parry landed. Help withdraws quickly — the moment you can do it, the
--- game should get out of the way and let you own it.
function Assist.recordSuccess(player: Player)
	local state = stateFor(player)
	state.successStreak += 1

	if state.successStreak >= Config.SuccessesBeforeOff then
		state.failures = 0
	else
		state.failures = math.max(state.failures - Config.FailuresPerSuccess, 0)
	end
end

--- An assisted attack landed without being parried, or a press whiffed.
--- Help arrives slowly: you have to genuinely struggle before it offers.
function Assist.recordFailure(player: Player)
	local state = stateFor(player)
	state.successStreak = 0
	state.failures = math.min(state.failures + 1, Config.MaxFailures)
end

function Assist.reset(player: Player)
	local state = stateFor(player)
	state.failures = 0
	state.successStreak = 0
	state.pending = false
end

--------------------------------------------------------------------------------

--- How far the slow has ramped, 0-1, from accumulated failures.
local function strengthFrom(failures: number): number
	return math.clamp(failures / Config.FailuresForFullStrength, 0, 1)
end

--[[
	What the player should get right now: the slow amount, and which stage of
	visible help. Returns nil for no assist at all.

	Off      never assists.
	Always   sits permanently at the ring stage — slowed and ringed, but never
	         prompted. The key prompt is something a struggling player earns;
	         handing it out by setting would undercut the whole escalation.
	Adaptive escalates by failures and collapses on success.
]]
local function currentAssist(state: State)
	if state.mode == "off" then
		return nil
	end

	if state.mode == "always" then
		local stage = Config.Stages[Config.AlwaysTier]
		local strength = math.max(strengthFrom(state.failures), Config.AlwaysStrength)
		return strength, stage
	end

	local stage = Config.stageFor(state.failures)
	if not stage then
		return nil
	end
	return strengthFrom(state.failures), stage
end

--[[
	Called immediately before an attack commits to its impact.

	Returns a profile when the approach should be stretched, or nil to strike at
	full speed. On a profile, the caller must:
	    1. wait `profile.lead / profile.slow` real seconds
	    2. then resolve the hit
	The client is told the same numbers so its ring closes exactly on impact.
]]
--- Works out the profile without announcing it. Callers that need the timing
--- up front — to size a telegraph that has to close on impact — plan first and
--- announce at the moment the ring should actually start.
--- Current stage, for anything that wants to report progress.
function Assist.stageOf(player: Player)
	local _, stage = currentAssist(stateFor(player))
	return stage
end

function Assist.plan(player: Player)
	local state = stateFor(player)
	local strength, stage = currentAssist(state)
	if not (strength and stage) then
		return nil
	end

	local profile = Config.profile(strength, stage)
	--- Real seconds of ring the player actually sees.
	profile.realLead = profile.lead / profile.slow

	--[[
		LATENCY COMPENSATION — without this the whole system is unwinnable.

		The prompt takes half a round trip to reach the client, and the press
		takes half a round trip to come back. So a player who presses at the
		exact moment their ring closes has their press arrive a full round trip
		after the server started counting. If the server resolves the hit at
		`realLead`, that press lands *after* the attack already resolved. Every
		time. No amount of timing skill beats it.

		So the server holds the strike for the ring plus the measured round
		trip, and biases a little later still: pressing early is forgiving
		(your window stays open), pressing late is not.
	]]
	local ping = 0
	pcall(function()
		ping = player:GetNetworkPing()
	end)
	ping = math.clamp(ping, 0, Config.MaxCompensation)
	profile.ping = ping
	profile.impactDelay = profile.realLead + ping + Config.ImpactBias

	return profile
end

--- Fires the prompt and opens the grading window. Call this exactly
--- `profile.impactDelay` before the hit resolves.
function Assist.announce(player: Player, profile)
	local state = stateFor(player)
	state.pending = true
	state.token += 1
	local token = state.token

	Network.ParryPrompt:FireClient(player, profile)

	-- Safety net. An attack that never resolves — you dodged clean out of
	-- range, the enemy was interrupted, the drill stopped — would otherwise
	-- leave `pending` stuck and the streak ungraded forever.
	task.delay(profile.impactDelay + 1.5, function()
		local current = states[player]
		if current and current.pending and current.token == token then
			current.pending = false
			Network.ParryPrompt:FireClient(player, nil)
		end
	end)

	return profile
end

--- Plan and announce together, for callers that strike immediately after.
function Assist.begin(player: Player)
	local profile = Assist.plan(player)
	if profile then
		Assist.announce(player, profile)
	end
	return profile
end

--- Grades the attack that `announce` opened, then clears the prompt.
function Assist.finish(player: Player, parried: boolean)
	local state = stateFor(player)
	local wasPending = state.pending
	state.pending = false
	state.token += 1

	if parried then
		Assist.recordSuccess(player)
	else
		Assist.recordFailure(player)
	end

	if wasPending then
		Network.ParryPrompt:FireClient(player, nil)
	end
end

--- Grading for attacks that were never assisted, so streaks still track.
function Assist.grade(player: Player, parried: boolean)
	if parried then
		Assist.recordSuccess(player)
	else
		Assist.recordFailure(player)
	end
end

Players.PlayerRemoving:Connect(function(player)
	states[player] = nil
end)

return Assist
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="74">
          <Properties>
            <string name="Name">Guard</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Guard — server-authoritative parry, block and dodge resolution.

	Every enemy in the game routes incoming hits through `Guard.resolve`, so
	parrying is a property of the combat system rather than something the
	Vorpal Bunny happens to support. A new enemy gets it by calling one
	function.

	AUTHORITY. The client never reports "I parried". It asks to *open a window*
	("I pressed F"), and the server timestamps that request on arrival, checks
	cooldown, and later decides for itself whether an attack landed inside the
	window. A client can spam the request and gain nothing but its own cooldown.

	LATENCY. A fixed grace is added to the window, because otherwise a player on
	120ms ping finds that a 0.15s mechanic simply does not work for them — which
	reads as the game being broken, not as their connection. The grace is capped
	so a client can't benefit from claiming to be far away.

	Outcomes, in resolution order:
	    dodged    i-frames were active (Quickstep)
	    parried   inside the parry window, and the attack was parryable
	    blocked   guard was up but untimed
	    broken    guard was up with nothing left to hold it with
	    hit       nothing was up
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Network = require(ReplicatedStorage.Shared.Network)
local Parry = require(ReplicatedStorage.Shared.Config.Parry)

local Assist = require(script.Parent.Assist)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)

local BLOCK = Encounters.Block

local Guard = {}

type State = {
	--- Timestamp the parry window closes. nil when not parrying.
	parryUntil: number?,
	parryReadyAt: number,
	--- Blocked from attacking until this, after a whiffed parry.
	recoveryUntil: number,
	--- Guard raised at this time, or nil.
	blockedAt: number?,
	invulnerableUntil: number,
	rank: number,
	--- 0-1 fraction, from the periodic vitals report. Up to ~250ms stale, which
	--- is fine for a guard break: it decides whether you were already scraping
	--- the bottom of the bar, not an exact frame.
	stamina: number,
}

local states: { [Player]: State } = {}

local function stateFor(player: Player): State
	local state = states[player]
	if not state then
		state = {
			parryUntil = nil,
			parryReadyAt = 0,
			recoveryUntil = 0,
			blockedAt = nil,
			invulnerableUntil = 0,
			rank = 0,
			stamina = 1,
		}
		states[player] = state
	end
	return state
end

--------------------------------------------------------------------------------
-- Player-driven state
--------------------------------------------------------------------------------

--- Progression lives on the client for now, same as health and stamina, so the
--- rank arrives with the vitals report. Clamped hard: an out-of-range claim
--- gets the lowest rank, not the highest.
function Guard.setRank(player: Player, rank: number)
	local state = stateFor(player)
	if typeof(rank) ~= "number" or rank ~= rank then
		return
	end
	state.rank = math.clamp(math.floor(rank), 0, Parry.maxRank())
end

function Guard.rank(player: Player): number
	return stateFor(player).rank
end

--- Stamina rides the same vitals report as the rank. Held here rather than in
--- each enemy script so that a creature's AI and the guard break read the same
--- number — the Vorpal Bunny's brain presses hardest exactly when your guard is
--- about to fail, and that only lands if both are looking at the same value.
function Guard.setStamina(player: Player, fraction: number)
	if typeof(fraction) ~= "number" or fraction ~= fraction then
		return
	end
	stateFor(player).stamina = math.clamp(fraction, 0, 1)
end

function Guard.stamina(player: Player): number
	return stateFor(player).stamina
end

--- "I pressed F." Returns (accepted, reason, cooldown, window) so the client
--- can start its predicted visuals — or say precisely why it can't.
function Guard.requestParry(player: Player): (boolean, string, number, number)
	local state = stateFor(player)
	local now = os.clock()

	if state.rank <= 0 then
		return false, "locked", 0, 0
	end

	local tier = Parry.forRank(state.rank)
	if not tier then
		return false, "locked", 0, 0
	end

	-- Cooldown is enforced here and nowhere else. This is what stops spam.
	if now < state.parryReadyAt then
		return false, "cooldown", state.parryReadyAt - now, 0
	end

	-- A whiffed parry locks you out briefly; pressing again inside that is
	-- exactly the panic-mash the recovery exists to punish.
	if now < state.recoveryUntil then
		return false, "recovering", state.recoveryUntil - now, 0
	end

	local window = tier.window + Parry.LatencyGrace
	state.parryUntil = now + window
	state.parryReadyAt = now + tier.cooldown
	-- A whiff locks you out of attacking; the server decides that later, when
	-- the window closes without having caught anything.
	state.recoveryUntil = 0

	return true, "ok", tier.cooldown, window
end

function Guard.setBlocking(player: Player, holding: boolean)
	local state = stateFor(player)
	if holding then
		-- Only stamp when it goes up; re-sending while held must not refresh
		-- anything.
		if not state.blockedAt then
			state.blockedAt = os.clock()
		end
	else
		state.blockedAt = nil
	end
end

function Guard.setInvulnerable(player: Player, duration: number)
	if typeof(duration) ~= "number" then
		return
	end
	stateFor(player).invulnerableUntil = os.clock() + math.clamp(duration, 0, 0.5)
end

--- True while a whiffed parry is still locking the player out.
function Guard.inRecovery(player: Player): boolean
	return os.clock() < stateFor(player).recoveryUntil
end

--------------------------------------------------------------------------------
-- Resolution
--------------------------------------------------------------------------------

--[[
	Resolves one incoming hit.

	`options`:
	    damage        full damage if nothing stops it
	    from          world position the attack came from
	    unparryable   true for attacks that must be dodged, not answered

	Returns the outcome string and, for a parry, the stagger duration the
	attacker should sit in.
]]
function Guard.resolve(player: Player, options): (string, number)
	local state = stateFor(player)
	local now = os.clock()
	local from = options.from or Vector3.zero
	local damage = options.damage or 0

	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	local at = (root and root.Position or from) + Vector3.new(0, 2, 0)

	-- 1. Dodged outright.
	if now < state.invulnerableUntil then
		return "dodged", 0
	end

	-- 2. Parry. Consumed on use so one press can't answer two hits.
	if state.parryUntil and now <= state.parryUntil then
		state.parryUntil = nil

		if options.unparryable then
			-- The window was open and correctly timed, but this attack simply
			-- cannot be answered. Treated as a block so a well-timed read is
			-- never worse than doing nothing.
			local through = math.floor(damage * (1 - BLOCK.damageReduction))
			Network.PlayerDamaged:FireClient(player, through)
			Network.Impact:FireClient(player, "unparryable")
			Audio.playAt("blocked", from)
			Vfx.sparks(at, 14, Color3.fromRGB(255, 170, 90))
			return "blocked", 0
		end

		local tier = Parry.forRank(state.rank)
		if tier and tier.refund > 0 then
			Network.GuardResult:FireClient(player, -tier.refund, true)
		else
			Network.GuardResult:FireClient(player, 0, true)
		end

		Vfx.parryBurst(at)
		Audio.playAt("parry", from)
		Network.Impact:FireClient(player, "parry", { position = at })

		return "parried", Parry.forRank(state.rank).stagger
	end

	-- 3. Blocked — or broken.
	if state.blockedAt then
		-- The guard break. Without this, block is a flat 78% wall that keeps
		-- working forever once stamina hits zero, because the cost is only ever
		-- subtracted client-side and nothing checks the result. "Blocking is a
		-- resource, not a wall" was true of the config comment and false of the
		-- game. Turning up with an empty bar now costs you the whole hit.
		if state.stamina < BLOCK.guardBreakBelow then
			-- Guard drops. Re-raising it is a deliberate press, so a broken
			-- guard cannot silently keep absorbing the rest of a combo.
			state.blockedAt = nil

			local direction = if root then (root.Position - from) else Vector3.new(0, 0, -1)
			direction = Vector3.new(direction.X, 0, direction.Z)
			direction = if direction.Magnitude < 0.01 then Vector3.new(0, 0, -1) else direction.Unit

			Network.PlayerDamaged:FireClient(player, damage)
			Network.Impact:FireClient(player, "guardBreak", { direction = direction, power = Parry.FailShove * 1.3 })
			Audio.playAt("guardBreak", from)
			Vfx.sparks(at, 26, Color3.fromRGB(255, 120, 90))
			return "broken", 0
		end

		local through = math.floor(damage * (1 - BLOCK.damageReduction))
		Network.PlayerDamaged:FireClient(player, through)
		Network.GuardResult:FireClient(player, BLOCK.staminaPerHit, false)
		Network.Impact:FireClient(player, "blocked")
		Audio.playAt("blocked", from)
		Vfx.sparks(at, 18, Color3.fromRGB(200, 220, 255))
		return "blocked", 0
	end

	-- 4. Straight through.
	local direction = if root then (root.Position - from) else Vector3.new(0, 0, -1)
	direction = Vector3.new(direction.X, 0, direction.Z)
	direction = if direction.Magnitude < 0.01 then Vector3.new(0, 0, -1) else direction.Unit

	Network.PlayerDamaged:FireClient(player, damage)
	Network.Impact:FireClient(player, "hurt", { direction = direction, power = Parry.FailShove })
	Vfx.bloodSlash(at, direction)
	Audio.playAt("hurt", from)

	return "hit", 0
end

--- Self-driven, once a frame. This used to be called from the Vorpal Bunny's
--- heartbeat, which meant parry windows stopped closing during its respawn —
--- a stale window could then answer an attack it was never opened for, and the
--- whiff lockout silently stopped applying. Guard is used by every enemy and
--- by the training yard, so it owns its own clock.
local function tick()
	local now = os.clock()
	for player, state in states do
		if state.parryUntil and now > state.parryUntil then
			state.parryUntil = nil
			-- Whiffed. Lock out attacking for the rank's recovery.
			local tier = Parry.forRank(state.rank)
			state.recoveryUntil = now + (tier and tier.recovery or 0.4)
			Network.Impact:FireClient(player, "parryWhiff")
			-- A press into empty air counts against you: the assist should
			-- notice someone mashing F as much as someone never pressing it.
			Assist.recordFailure(player)
		end
	end
end

--------------------------------------------------------------------------------
-- Remotes
--------------------------------------------------------------------------------

--[[
	These live here, not in an enemy script.

	They were originally connected inside the Vorpal Bunny's script, which meant
	the entire guard system — parry, block, dodge, rank — was silently coupled
	to one creature existing. The training yard uses Guard too, and so will
	every enemy added later. Global player state belongs with the global module.
]]

--- The rank rides along with the press. The periodic vitals report can be up to
--- 250ms stale, and a player who just unlocked the parry pressing F inside that
--- window would be told they don't have it.
Network.Parry.OnServerEvent:Connect(function(player, claimedRank)
	if typeof(claimedRank) == "number" then
		Guard.setRank(player, claimedRank)
	end
	local accepted, reason, cooldown, window = Guard.requestParry(player)
	Network.ParryResult:FireClient(player, accepted, reason, cooldown, window)
end)

Network.Block.OnServerEvent:Connect(function(player, holding)
	Guard.setBlocking(player, holding == true)
end)

--- The periodic vitals report. This used to be connected inside the Vorpal
--- Bunny's script, which meant the guard break only existed while that one
--- creature was alive; every other enemy saw a permanently full stamina bar and
--- could never break a guard. Same reasoning as the remotes above.
Network.Vitals.OnServerEvent:Connect(function(player, stamina, parryRank)
	Guard.setStamina(player, stamina)
	Guard.setRank(player, parryRank or 0)
end)

Network.Invulnerable.OnServerEvent:Connect(function(player, duration)
	Guard.setInvulnerable(player, duration)
end)

RunService.Heartbeat:Connect(tick)

Players.PlayerRemoving:Connect(function(player)
	states[player] = nil
end)

return Guard
]]></string>
          </Properties>
        </Item>
      </Item>
      <Item class="Script" referent="75">
        <Properties>
          <string name="Name">DayNight</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	DayNight — drives the entire visual grade from a moving clock.

	This is the single highest-leverage visual system in the game. A Roblox
	world does not look expensive because of its models; it looks expensive
	because of light. A sun that moves, an atmosphere that thickens at dusk,
	bloom that only catches real highlights, and colour grading that shifts warm
	to cold do more for perceived quality than any amount of geometry.

	Every property is interpolated from Config/Sky, so dawn, noon, dusk and
	night are four authored looks rather than one look with a brightness slider.

	Also drives gameplay: `Sky.isNight` is what Lycagon will wait for, and what
	makes torches worth lighting.
]]

local Lighting = game:GetService("Lighting")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Network = require(ReplicatedStorage.Shared.Network)
local Sky = require(ReplicatedStorage.Shared.Config.Sky)

local clockHour = Sky.StartHour
local lastBroadcastHour = -1
local wasNight = false

--------------------------------------------------------------------------------
-- Instances
--------------------------------------------------------------------------------

local function ensure(className: string, name: string, parent: Instance): Instance
	local existing = parent:FindFirstChild(name)
	if existing and existing.ClassName == className then
		return existing
	end
	if existing then
		existing:Destroy()
	end
	local created = Instance.new(className)
	created.Name = name
	created.Parent = parent
	return created
end

local atmosphere = ensure("Atmosphere", "Atmosphere", Lighting)
local grade = ensure("ColorCorrectionEffect", "Grade", Lighting)
local bloom = ensure("BloomEffect", "Glow", Lighting)
local depth = ensure("DepthOfFieldEffect", "Depth", Lighting)
local rays = ensure("SunRaysEffect", "Rays", Lighting)

--[[
	Volumetric clouds. Genuinely free-looking sky at almost no cost, and the
	single most common thing missing from Roblox games that read as flat — an
	empty skybox is the first thing that says "default".
]]
local clouds = ensure("Clouds", "Clouds", Workspace.Terrain)

--------------------------------------------------------------------------------

local function apply(hour: number)
	local g = Sky.gradeAt(hour)

	Lighting.ClockTime = hour
	Lighting.Brightness = g.brightness
	Lighting.ExposureCompensation = g.exposure
	Lighting.Ambient = g.ambient
	Lighting.OutdoorAmbient = g.outdoorAmbient
	Lighting.ColorShift_Top = g.colorShiftTop
	Lighting.ColorShift_Bottom = g.colorShiftBottom
	Lighting.GlobalShadows = true
	Lighting.EnvironmentDiffuseScale = 0.45
	Lighting.EnvironmentSpecularScale = 0.4

	atmosphere.Density = g.density
	atmosphere.Haze = g.haze
	atmosphere.Glare = g.glare
	atmosphere.Color = g.atmosphereColor
	atmosphere.Decay = g.atmosphereDecay
	atmosphere.Offset = 0.15

	bloom.Intensity = g.bloomIntensity
	bloom.Threshold = g.bloomThreshold
	bloom.Size = 24

	grade.Contrast = g.contrast
	grade.Saturation = g.saturation
	grade.TintColor = g.tint
	-- Slightly positive now. It was -0.02: a small darkening applied on top of
	-- an already dark grade at every hour of the day. The kind of value that
	-- does nothing visible alone and matters a lot once four of them stack.
	grade.Brightness = 0.02

	-- Far-plane only. Blurring anything near the player in a third-person
	-- action game makes it unplayable, not cinematic.
	depth.FarIntensity = g.farIntensity
	depth.NearIntensity = 0
	depth.FocusDistance = 24
	depth.InFocusRadius = 110

	-- Sun rays only make sense when there is a sun.
	rays.Intensity = if Sky.isNight(hour) then 0 else 0.1 + g.glare * 0.14

	clouds.Cover = g.cloudCover
	clouds.Density = g.cloudDensity
	clouds.Color = g.cloudColor
end

--------------------------------------------------------------------------------

apply(clockHour)

RunService.Heartbeat:Connect(function(dt)
	clockHour = (clockHour + dt / Sky.SecondsPerHour) % 24
	apply(clockHour)

	-- Clients are told the hour a few times a second, not every frame; they
	-- interpolate their own for anything that needs it.
	if math.abs(clockHour - lastBroadcastHour) > 0.05 then
		lastBroadcastHour = clockHour
		Network.TimeOfDay:FireAllClients(clockHour, Sky.isNight(clockHour))
	end

	local night = Sky.isNight(clockHour)
	if night ~= wasNight then
		wasNight = night
		print(`[DayNight] {if night then "nightfall" else "daybreak"} at {string.format("%.1f", clockHour)}`)
	end
end)

print(`[DayNight] clock running — {Sky.SecondsPerHour}s per hour, full day {Sky.SecondsPerHour * 24 / 60}min`)
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="76">
        <Properties>
          <string name="Name">Encounter</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Encounter — the Vorpal Bunny fight.

	The creature is a hunter, not a target. It is never stationary: between
	attacks it circles, reverses, vaults onto rocks and occasionally ducks
	behind a tree before committing. Its head tracks you the entire time.

	ARCHITECTURE

	  Config/Bunny   every number
	  Bunny/Motor    hop maths — where the next landing goes
	  Bunny/Brain    utility scoring — what it decides to do
	  Bunny/Actions  the move set, written as coroutine sequences
	  this file      the runtime: context, driving, damage, presentation

	Actions are coroutines resumed one frame at a time. That is what makes a
	three-beat attack (crouch, dash, follow-through) or a leap with a hang at
	apex readable as code instead of a twenty-branch state machine.

	MOVEMENT is CFrame-driven with every part anchored. Pathfinding and physics
	would add jitter, and this fight lives or dies on the dash going exactly
	where the telegraph said. A boss you can't trust is a boss you can't learn.
]]

local Debris = game:GetService("Debris")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Config = require(ReplicatedStorage.Shared.Config.Bunny)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Mobs = require(ReplicatedStorage.Shared.Mobs)
local Net = require(ReplicatedStorage.Shared.Mobs.Net)
local Network = require(ReplicatedStorage.Shared.Network)
local Rig = require(ReplicatedStorage.Shared.Mobs.Rig)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)
local Rewards = require(script.Parent.Rewards)
local World = require(ReplicatedStorage.Shared.Config.World)

local Assist = require(script.Parent.Combat.Assist)
local Guard = require(script.Parent.Combat.Guard)
local Parry = require(game:GetService("ReplicatedStorage").Shared.Config.Parry)

local Actions = require(script.Parent.Bunny.Actions)
local Brain = require(script.Parent.Bunny.Brain)
local Motor = require(script.Parent.Bunny.Motor)

local ID = Config.Identity
local HOP = Config.Hop
local GUARD = Config.Guard
local HITSTOP = Encounters.Hitstop
local PLAYER_ATTACK = Encounters.PlayerAttack

local RESPAWN_DELAY = 22
local ABORT = {} -- sentinel thrown to unwind an interrupted action

local folder = Create("Folder", { Name = "Encounters", Parent = Workspace })

--------------------------------------------------------------------------------
-- State
--------------------------------------------------------------------------------

local bunny, rig, telegraph, marker, trail
local eyes: { BasePart } = {}
local obstacles: { Vector3 } = {}

local health = ID.maxHealth
local position = Vector3.zero
local facing = Vector3.new(0, 0, -1)
local lookYaw = 0
local spin = 1

local animState = "observe"
local animPhase = 0

local action: thread? = nil
local actionToken = 0
local aborted = false
local lastAction: string? = nil

local engaged = false
local introPlayed = false
local hitstopUntil = 0
local knockback = Vector3.zero

--- Set by a parry. Aborting the current action isn't enough on its own — the
--- runner would just pick a new one next frame and the reward would vanish.
local pendingStagger = false
--- Scaled by the parry rank, so a better parry buys a longer opening.
local staggerDuration = 0

--- Live signals the brain reads. Stamina is not among them: it belongs to
--- `Guard`, which needs the same number for the guard break, and two copies fed
--- by the same remote is how they drift.
local parryCount = 0
local parryDecayAt = 0
local aggression: { number } = {}

local attackReadyAt: { [Player]: number } = {}

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

local function groundAt(x: number, z: number): number
	return World.groundHeight(x, z)
end

local function rootOf(player: Player): BasePart?
	local character = player.Character
	return character and character:FindFirstChild("HumanoidRootPart")
end

local function frenzied(): boolean
	return health / ID.maxHealth <= Config.Frenzy.below
end

--- Timing multiplier. Frenzy compresses every windup and rest.
local function timeScale(): number
	return if frenzied() then Config.Frenzy.timing else 1
end

local function nearestPlayer(): (Player?, Vector3?, Vector3?)
	local best, bestDistance = nil, math.huge
	for _, player in Players:GetPlayers() do
		local root = rootOf(player)
		if root then
			local distance = (root.Position - position).Magnitude
			if distance < bestDistance then
				best, bestDistance = player, distance
			end
		end
	end

	if not best then
		return nil, nil, nil
	end
	local root = rootOf(best)
	return best, root and root.Position, root and root.CFrame.LookVector
end

local function broadcastState()
	local payload = if engaged
		then {
			name = ID.name,
			title = ID.title,
			health = health,
			maxHealth = ID.maxHealth,
			phase = if frenzied() then 2 else 1,
			maxPhase = ID.maxPhase,
			unique = ID.unique,
		}
		else nil

	for _, player in Players:GetPlayers() do
		Network.BossState:FireClient(player, payload)
	end
end

local function broadcastImpact(kind: string, data)
	for _, player in Players:GetPlayers() do
		Network.Impact:FireClient(player, kind, data)
	end
end

--------------------------------------------------------------------------------
-- Presentation helpers
--------------------------------------------------------------------------------

local function setEyeGlow(intensity: number)
	for _, eye in eyes do
		if eye.Parent then
			eye.Color = Color3.fromRGB(226, 34, 44):Lerp(Color3.fromRGB(255, 210, 190), intensity * 0.55)
			eye.Material = Enum.Material.Neon
		end
	end
end

--[[
	THE TELEGRAPH HAS TO BE THE HITBOX.

	There are two shapes because there are two kinds of attack, and drawing one
	with the other's shape is a lie the player pays for.

	  lane    a dash. Sweeps forward, so it is a bar `radius * 2` wide running
	          the length of the travel.
	  ring    a spin or a landing. Hits everything within `radius` of a point,
	          so it is a disc of exactly that radius.

	Both used to be drawn as a lane, always `dashSlash.radius * 2` = 12 studs
	wide regardless of which attack was winding up. The Spin Slash's real reach
	is 17 studs across and the Leap's was 22, so both drew a danger zone
	narrower than the damage — stand at the edge of the marked area, outside it,
	and get hit anyway. The Leap's lane was worse than narrow: it pointed
	forward out of a creature that was about to jump straight up and come down
	somewhere else entirely.
]]
local function updateTelegraph(
	visible: boolean,
	committed: boolean?,
	direction: Vector3?,
	length: number?,
	radius: number?
)
	if not telegraph then
		return
	end
	if not visible then
		telegraph.Transparency = 1
		return
	end

	local span = math.max(length or 0, 6)
	local dir = direction or facing
	local mid = position + dir * (span / 2)

	telegraph.Size = Vector3.new((radius or Config.Attacks.dashSlash.radius) * 2, 0.12, span)
	telegraph.CFrame = CFrame.lookAt(Vector3.new(mid.X, groundAt(mid.X, mid.Z) + 0.16, mid.Z), mid + dir)
	telegraph.Transparency = if committed then 0.2 else 0.78
	telegraph.Color = if committed then Theme.Color.Health else Theme.Color.StaminaEmpty
end

--- The radial half. A flat cylinder matching `strike`'s distance check exactly,
--- so what is drawn and what connects are the same circle.
local function updateMarker(visible: boolean, at: Vector3?, radius: number?, committed: boolean?)
	if not marker then
		return
	end
	if not visible or not at then
		marker.Transparency = 1
		return
	end

	local span = (radius or 6) * 2
	-- A cylinder's length runs along X, so it has to be tipped onto its side to
	-- lie flat. Sits below the lane's 0.16 rather than level with it: two top
	-- faces at the same height z-fight and read as a broken texture.
	marker.Size = Vector3.new(0.12, span, span)
	marker.CFrame = CFrame.new(at.X, groundAt(at.X, at.Z) + 0.14, at.Z) * CFrame.Angles(0, 0, math.rad(90))
	marker.Transparency = if committed then 0.35 else 0.82
	marker.Color = if committed then Theme.Color.Health else Theme.Color.StaminaEmpty
end

--- The forest reacts. Cheap, and it stops the arena feeling like a backdrop.
local function shakeArena(origin: Vector3, power: number)
	Vfx.leaves(origin, math.floor(6 * power))
	Vfx.dust(origin, power * 0.6)
end

--------------------------------------------------------------------------------
-- Combat resolution
--------------------------------------------------------------------------------

--- Everything the creature does to a player goes through the shared Guard, so
--- parry, block and dodge behave identically here and in every enemy added
--- later. This file decides *that* it hit you; Guard decides what that means.
local function resolveHit(player: Player, damage: number, unparryable: boolean?): boolean
	local outcome, staggerScale = Guard.resolve(player, {
		damage = damage,
		from = position,
		unparryable = unparryable,
	})

	-- Every parryable hit grades the assist, so streaks track in the field and
	-- not only in the training yard.
	if not unparryable then
		Assist.finish(player, outcome == "parried")
	end

	if outcome == "dodged" then
		return false
	end

	if outcome == "parried" then
		parryCount += 1
		parryDecayAt = os.clock() + Config.Utility.parryMemory * 4

		-- Kill whatever it was doing, then force the stagger as the next action.
		aborted = true
		pendingStagger = true
		staggerDuration = GUARD.staggerTime * (staggerScale > 0 and staggerScale or 1)
		hitstopUntil = os.clock() + HITSTOP.parry
		knockback = -facing * Parry.Shove

		updateTelegraph(false)
		updateMarker(false)
		shakeArena(position, 1.2)
		broadcastImpact("parry", { position = position })
	end

	return true
end

--------------------------------------------------------------------------------
-- Action context
--------------------------------------------------------------------------------

local ctx = {}

ctx.groundAt = groundAt
ctx.vfx = function()
	return Vfx
end

function ctx.position()
	return position
end
function ctx.setPosition(value: Vector3)
	position = value
end
function ctx.facing()
	return facing
end
function ctx.face(direction: Vector3)
	facing = Motor.flatten(direction)
end
function ctx.spin()
	return spin
end
function ctx.reverseSpin()
	spin = -spin
end
function ctx.player()
	return nearestPlayer()
end
function ctx.audio(name: string)
	Audio.playAt(name, position)
end
function ctx.audioAt(name: string, at: Vector3)
	Audio.playAt(name, at)
end
function ctx.shakeArena(origin: Vector3, power: number)
	shakeArena(origin, power)
end
function ctx.telegraph(visible: boolean, committed: boolean?, direction: Vector3?, length: number?, radius: number?)
	updateTelegraph(visible, committed, direction, length, radius)
end
function ctx.marker(visible: boolean, at: Vector3?, radius: number?, committed: boolean?)
	updateMarker(visible, at, radius, committed)
end
function ctx.eyeFlash()
	setEyeGlow(1)
	task.delay(0.18, function()
		setEyeGlow(0)
	end)
end
function ctx.trail(enabled: boolean)
	if trail then
		trail.Enabled = enabled
	end
end
--- Opens an assisted approach for whoever is being attacked, and returns how
--- long the creature should hold before committing (0 for no assist).
function ctx.beginAssist(): number
	local player = nearestPlayer()
	if not player then
		return 0
	end
	local profile = Assist.begin(player)
	return if profile then profile.impactDelay else 0
end

function ctx.coverTarget(playerPosition: Vector3)
	return Motor.coverTarget(position, playerPosition, obstacles, HOP.longDistance * 1.6, groundAt)
end

--- Yields for `duration`, holding an animation state and ticking its phase.
function ctx.await(duration: number, stateName: string, onUpdate)
	animState = stateName
	local elapsed = 0

	-- Zero-length beats still need one frame, or the pose never renders.
	repeat
		local dt = coroutine.yield()
		if aborted then
			error(ABORT)
		end
		elapsed += dt
		animPhase = if duration > 0 then math.clamp(elapsed / duration, 0, 1) else 1
		if onUpdate then
			onUpdate(animPhase, dt)
		end
	until elapsed >= duration
end

--- One full hop: compress, launch, arc, land, squash.
function ctx.hop(target: Vector3, height: number, airTime: number)
	local from = position

	ctx.await(HOP.compress, "compress")
	Audio.playAt("hop", from)
	Vfx.dust(from, 0.45)

	ctx.await(airTime, "air", function(phase)
		position = Motor.arc(from, target, height, phase)
	end)

	position = target
	Vfx.landing(target, 0.55)
	Audio.playAt("land", target)

	ctx.await(HOP.land, "land")
end

--- Hits the player if they're inside `radius`. Returns whether anything landed.
function ctx.strike(radius: number, damage: number, unparryable: boolean?): boolean
	local player, playerPosition = nearestPlayer()
	if not (player and playerPosition) then
		return false
	end

	local flat = Vector3.new(playerPosition.X - position.X, 0, playerPosition.Z - position.Z)
	if flat.Magnitude > radius then
		return false
	end

	return resolveHit(player, damage, unparryable)
end

--------------------------------------------------------------------------------
-- Spawning
--------------------------------------------------------------------------------

local function cacheObstacles()
	table.clear(obstacles)
	local forest = Workspace:FindFirstChild("RampantForest")
	if not forest then
		return
	end

	local arena = World.BunnyArena
	for _, model in forest:GetChildren() do
		if not model:IsA("Model") or not model.PrimaryPart then
			continue
		end
		local at = model.PrimaryPart.Position
		if (Vector3.new(at.X, 0, at.Z) - Vector3.new(arena.X, 0, arena.Z)).Magnitude < World.BunnyArenaRadius + 45 then
			table.insert(obstacles, at)
		end
	end
end

local function despawn()
	if bunny then
		bunny:Destroy()
	end
	if telegraph then
		telegraph:Destroy()
	end
	if marker then
		marker:Destroy()
	end
	bunny, rig, telegraph, marker, trail = nil, nil, nil, nil, nil
	table.clear(eyes)
end

local function spawnBunny()
	despawn()

	local arena = World.BunnyArena
	position = Vector3.new(arena.X, groundAt(arena.X, arena.Z), arena.Z)
	facing = Vector3.new(0, 0, 1)

	local model = Mobs.build("vorpal_bunny", CFrame.new(position))
	if not model then
		warn("[Encounter] could not build the Vorpal Bunny")
		return
	end

	model.Name = "VorpalBunny"
	model.Parent = folder
	bunny = model
	rig = Rig.bind(model)
	-- Without this the client would fall back to the goblin cycle and the
	-- Vorpal Bunny would hop like a goblin walks.
	Net.setRig(model, "vorpalBunny")

	for _, name in { "EyeL", "EyeR" } do
		local eye = rig:find(name)
		if eye then
			table.insert(eyes, eye)
		end
	end

	local body = rig:find("Body")
	if body then
		trail = Vfx.attachTrail(body, Color3.fromRGB(255, 148, 150))
	end

	telegraph = Create("Part", {
		Name = "Telegraph",
		Anchored = true,
		CanCollide = false,
		CanQuery = false,
		Size = Vector3.new(6, 0.12, 1),
		Color = Theme.Color.Health,
		Material = Enum.Material.Neon,
		Transparency = 1,
		Parent = folder,
	})

	marker = Create("Part", {
		Name = "TelegraphMarker",
		Shape = Enum.PartType.Cylinder,
		Anchored = true,
		CanCollide = false,
		CanQuery = false,
		CastShadow = false,
		Size = Vector3.new(0.12, 12, 12),
		Color = Theme.Color.Health,
		Material = Enum.Material.Neon,
		Transparency = 1,
		Parent = folder,
	})

	health = ID.maxHealth
	engaged = false
	introPlayed = false
	action = nil
	lastAction = nil
	animState = "graze"
	parryCount = 0
	table.clear(aggression)

	cacheObstacles()
	broadcastState()
end

--------------------------------------------------------------------------------
-- Sequencing
--------------------------------------------------------------------------------

local function startAction(fn, ...)
	aborted = false
	actionToken += 1
	local args = table.pack(...)
	action = coroutine.create(function()
		fn(ctx, table.unpack(args, 1, args.n))
	end)
end

--- The boss intro. It keeps eating until you're close enough to matter.
local function introSequence(_ctx)
	local intro = Config.Intro

	broadcastImpact("introStart")
	for _, player in Players:GetPlayers() do
		Network.BossIntro:FireClient(player, "start")
	end

	ctx.await(intro.graze, "graze")

	for _, player in Players:GetPlayers() do
		Network.BossIntro:FireClient(player, "silence")
	end
	ctx.await(intro.silence, "graze")

	-- It turns.
	ctx.await(intro.turn, "alert", function()
		local _, playerPosition = nearestPlayer()
		if playerPosition then
			ctx.face(playerPosition - position)
		end
	end)

	-- Eyes come up.
	Audio.playAt("introStare", position)
	setEyeGlow(1)
	for _, player in Players:GetPlayers() do
		Network.BossIntro:FireClient(player, "glare")
	end
	ctx.await(intro.glare, "glare")

	-- Bar, title, music.
	engaged = true
	broadcastState()
	Audio.startMusic()
	Audio.playAt("screech", position)
	Vfx.ring(position, 12, Color3.fromRGB(226, 34, 44), 0.5, 0.3)
	shakeArena(position, 1.0)
	for _, player in Players:GetPlayers() do
		Network.BossIntro:FireClient(player, "reveal")
	end

	ctx.await(intro.reveal, "glare")
	setEyeGlow(0)
	introPlayed = true
end

--- One decision, then the action that follows from it.
local function combatSequence(_ctx)
	local scale = timeScale()

	-- It circles while it thinks. There is no standing still.
	Actions.circle(ctx, Config.Rest.circleHops, scale)
	ctx.await(Brain.restDuration(health / ID.maxHealth) * 0.4, "observe")

	local player, playerPosition = nearestPlayer()
	if not (player and playerPosition) then
		return
	end

	local now = os.clock()
	local recent = 0
	for _, stamp in aggression do
		if now - stamp <= Config.Utility.aggressionWindow then
			recent += 1
		end
	end

	local choice = Brain.choose({
		distance = (playerPosition - position).Magnitude,
		stamina = Guard.stamina(player),
		parries = parryCount,
		aggression = recent,
		health = health / ID.maxHealth,
		last = lastAction,
	})

	lastAction = choice
	local run = Actions[choice]
	if run then
		run(ctx, scale)
	end
end

--------------------------------------------------------------------------------
-- Main loop
--------------------------------------------------------------------------------

RunService.Heartbeat:Connect(function(dt)
	if not bunny or not rig then
		return
	end

	-- Hitstop freezes everything: clock, movement, pose. Skipping the frame
	-- outright is what makes a hit land rather than merely register.
	if os.clock() < hitstopUntil then
		return
	end

	if parryCount > 0 and os.clock() > parryDecayAt then
		parryCount = 0
	end

	-- Knockback decays rather than teleporting.
	if knockback.Magnitude > 0.1 then
		position += knockback * dt
		position = Vector3.new(position.X, groundAt(position.X, position.Z), position.Z)
		knockback = knockback:Lerp(Vector3.zero, math.min(dt * 7, 1))
	end

	local player, playerPosition = nearestPlayer()
	local distance = if playerPosition then (playerPosition - position).Magnitude else math.huge

	-- Leash.
	if engaged and distance > Config.Perception.leash then
		engaged = false
		health = ID.maxHealth
		action = nil
		updateTelegraph(false)
		updateMarker(false)
		Audio.stopMusic()
		broadcastState()
	end

	-- Drive the current action.
	if action then
		if coroutine.status(action) == "suspended" then
			local ok, err = coroutine.resume(action, dt)
			if not ok and err ~= ABORT then
				warn(`[Encounter] action error: {err}`)
			end
			if not ok then
				action = nil
			end
		end
		if action and coroutine.status(action) == "dead" then
			action = nil
		end
	end

	-- Pick the next thing to do. Stagger jumps the queue.
	if not action and health > 0 then
		if pendingStagger then
			pendingStagger = false
			lastAction = nil
			startAction(Actions.stagger, if staggerDuration > 0 then staggerDuration else GUARD.staggerTime)
		elseif not introPlayed then
			if player and distance <= Config.Perception.aggroRange then
				startAction(introSequence)
			else
				animState = "graze"
			end
		elseif engaged and player then
			startAction(combatSequence)
		elseif player and distance <= Config.Perception.aggroRange then
			engaged = true
			broadcastState()
			Audio.startMusic()
		else
			animState = "observe"
		end
	end

	-- Head tracking: the single strongest signal that it is watching you.
	if playerPosition then
		local toPlayer = Motor.flatten(playerPosition - position)
		local cross = facing.X * toPlayer.Z - facing.Z * toPlayer.X
		local dot = math.clamp(facing:Dot(toPlayer), -1, 1)
		lookYaw = math.deg(math.atan2(cross, dot))
	else
		lookYaw = 0
	end

	-- lookYaw rides along as a signal: the client needs it to keep the
	-- creature's head tracking the player between published states.
	Net.publish(bunny, CFrame.lookAt(position, position + facing), animState, animPhase, { lookYaw = lookYaw })
end)

--------------------------------------------------------------------------------
-- Death
--------------------------------------------------------------------------------

local function onDefeated()
	engaged = false
	action = nil
	aborted = true
	updateTelegraph(false)
	updateMarker(false)
	Audio.stopMusic()
	Audio.playAt("defeat", position)

	Vfx.parryBurst(position + Vector3.new(0, 2, 0))
	Vfx.leaves(position, 20)

	for _, player in Players:GetPlayers() do
		Network.EncounterCleared:FireClient(player, ID.id, ID.rewardXp)
		Rewards.grant(player, ID.id, position)
	end
	broadcastState()

	local corpse = bunny
	bunny, rig = nil, nil
	if corpse then
		task.spawn(function()
			for _ = 1, 32 do
				if not corpse.Parent then
					return
				end
				corpse:PivotTo(corpse:GetPivot() * CFrame.new(0, -0.11, 0))
				task.wait(0.03)
			end
			corpse:Destroy()
		end)
		Debris:AddItem(corpse, 3)
	end

	task.delay(RESPAWN_DELAY, spawnBunny)
end

--------------------------------------------------------------------------------
-- Player input
--------------------------------------------------------------------------------

Network.Attack.OnServerEvent:Connect(function(player, comboIndex)
	if typeof(comboIndex) ~= "number" then
		return
	end

	local step = Encounters.comboStep(comboIndex)
	local now = os.clock()
	if attackReadyAt[player] and now < attackReadyAt[player] then
		return
	end
	attackReadyAt[player] = now + (step.windup + step.active) * 0.85

	-- A whiffed parry has a real cost: you cannot swing through it.
	if Guard.inRecovery(player) then
		return
	end

	table.insert(aggression, now)
	if #aggression > 24 then
		table.remove(aggression, 1)
	end

	if not bunny or health <= 0 or not engaged then
		return
	end

	local root = rootOf(player)
	if not root then
		return
	end

	local toBunny = position - root.Position
	local flat = Vector3.new(toBunny.X, 0, toBunny.Z)
	if flat.Magnitude > PLAYER_ATTACK.range then
		return
	end

	local look = Motor.flatten(root.CFrame.LookVector)
	local angle = math.deg(math.acos(math.clamp(look:Dot(Motor.flatten(flat)), -1, 1)))
	if angle > PLAYER_ATTACK.arc then
		return
	end

	local multiplier = if animState == "stagger"
		then GUARD.counter
		elseif animState == "recover" or animState == "slashFollow" then GUARD.punish
		else 1

	local isCrit = math.random() < PLAYER_ATTACK.critChance
	local dealt = math.floor(step.damage * multiplier * (if isCrit then PLAYER_ATTACK.critMultiplier else 1))
	health = math.max(health - dealt, 0)

	knockback = Motor.flatten(position - root.Position) * step.knockback * 6
	local heavy = step.knockback >= 30 or multiplier > 1
	hitstopUntil = now + (if heavy then HITSTOP.heavy else HITSTOP.light)

	local impactAt = position + Vector3.new(0, 2, 0)
	Vfx.sparks(impactAt, if heavy then 26 else 12, Color3.fromRGB(255, 214, 160))
	Vfx.slash(impactAt, look, if heavy then 8 else 5.5, Color3.fromRGB(255, 236, 200))
	Audio.playAt(if heavy then "hitHeavy" else "hitLight", position)

	Network.BossDamaged:FireClient(player, dealt, if isCrit or multiplier > 1 then "crit" else "normal", impactAt)
	Network.Impact:FireClient(player, if heavy then "heavy" else "light")
	broadcastState()

	if health <= 0 then
		onDefeated()
	end
end)

-- Parry, block, dodge and the vitals report are all handled by Combat/Guard,
-- which owns them for every enemy. This script only cares about the creature's
-- own signals, and asks Guard for the player's.

Players.PlayerRemoving:Connect(function(player)
	attackReadyAt[player] = nil
end)

Players.PlayerAdded:Connect(function(player)
	task.wait(1)
	-- Joining players always start with no boss bar; it appears when the
	-- encounter engages them.
	Network.BossState:FireClient(player, nil)
end)

spawnBunny()
print(`[Encounter] Vorpal Bunny hunting at {World.BunnyArena}`)
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="77">
        <Properties>
          <string name="Name">Equipment</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Equipment — puts the starter weapon in the player's hands.

	The client picks a combat style at creation and tells the server; the server
	builds the matching model and welds it on. Doing it here rather than on the
	client means other players see your weapon too, which is the whole point of
	it being visible.

	Re-attaches on every respawn, because the character model is rebuilt from
	scratch each time and takes your sword with it.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Network = require(ReplicatedStorage.Shared.Network)
local Weapons = require(ReplicatedStorage.Shared.Items.Weapons)

--- Weapons are authored with the grip at origin and the blade along +Y. This
--- transform is the ONE place that convention meets the hand, so if everything
--- sits at the wrong angle, it is this and only this that needs changing.
local GRIP = CFrame.new(0, -0.5, 0) * CFrame.Angles(math.rad(-90), 0, 0)

local ATTACHMENT_TAG = "StarterWeapon"

local chosenStyle: { [Player]: string } = {}

--- R15 first, then R6, so this works whichever avatar type the place allows.
local function handOf(character: Model, side: string): BasePart?
	return character:FindFirstChild(`{side}Hand`) or character:FindFirstChild(`{side} Arm`)
end

local function clearWeapons(character: Model)
	for _, child in character:GetChildren() do
		if child:GetAttribute(ATTACHMENT_TAG) then
			child:Destroy()
		end
	end
end

local function attach(model: Model, hand: BasePart, character: Model)
	model:SetAttribute(ATTACHMENT_TAG, true)
	model:PivotTo(hand.CFrame * GRIP)

	-- Weld while everything is still anchored, so each constraint captures the
	-- pose we just set. Unanchor afterwards or the weapon pins the arm in place.
	local parts = {}
	for _, descendant in model:GetDescendants() do
		if descendant:IsA("BasePart") then
			table.insert(parts, descendant)
			local weld = Instance.new("WeldConstraint")
			weld.Part0 = hand
			weld.Part1 = descendant
			weld.Parent = descendant
		end
	end

	for _, part in parts do
		part.Anchored = false
		part.CanCollide = false
		part.CanQuery = false
		part.Massless = true
	end

	model.Parent = character
end

local function equip(player: Player, character: Model?)
	character = character or player.Character
	if not character then
		return
	end

	local style = chosenStyle[player]
	if not style then
		return
	end

	local rightHand = handOf(character, "Right")
	if not rightHand then
		return
	end

	clearWeapons(character)

	local built = Weapons.build(style)
	attach(built.main, rightHand, character)

	if built.off then
		local leftHand = handOf(character, "Left")
		if leftHand then
			attach(built.off, leftHand, character)
		else
			built.off:Destroy()
		end
	end
end

Network.CharacterChosen.OnServerEvent:Connect(function(player, character)
	if typeof(character) ~= "table" or typeof(character.style) ~= "string" then
		return
	end
	chosenStyle[player] = character.style
	equip(player)
end)

Players.PlayerAdded:Connect(function(player)
	player.CharacterAdded:Connect(function(character)
		-- Give the limbs a moment to exist before welding to them.
		task.wait(0.4)
		equip(player, character)
	end)
end)

Players.PlayerRemoving:Connect(function(player)
	chosenStyle[player] = nil
end)
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="78">
        <Properties>
          <string name="Name">Firstia</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Firstia — the inner courtyard and training yard.

	The town exterior is a facade seen from the treeline. This builds the one
	part that is actually enterable: the courtyard behind the gate, where every
	non-Traveler origin begins.

	Contains the instructor, three training dummies, and a practice enemy pen.
	Dummies are damageable through the same Attack path as real enemies, so the
	tutorial teaches the real combat system rather than a mock-up of it — if
	hitting a dummy felt different from hitting a boss, the tutorial would be
	teaching the wrong thing.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Build = require(ReplicatedStorage.Shared.Util.Build)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Assist = require(script.Parent.Combat.Assist)
local Guard = require(script.Parent.Combat.Guard)
local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Tutorial = require(ReplicatedStorage.Shared.Config.Tutorial)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)
local World = require(ReplicatedStorage.Shared.Config.World)

local V = Vector3.new

local FOLDER = "FirstiaCourtyard"
local STONE = Color3.fromRGB(148, 140, 124)
local STONE_DARK = Color3.fromRGB(112, 105, 92)
local TIMBER = Color3.fromRGB(92, 68, 48)
local STRAW = Color3.fromRGB(196, 168, 96)
local CLOTH = Color3.fromRGB(78, 96, 126)

local DUMMY_HEALTH = 240

local folder: Folder
local dummies: { [Model]: { health: number, root: BasePart } } = {}
local practiceEnemy: { model: Model?, health: number } = { model = nil, health = 0 }

--------------------------------------------------------------------------------
-- Props
--------------------------------------------------------------------------------

--- A humanoid-ish figure built from primitives. Used for both the instructor
--- and the practice enemy, with different palettes.
local function buildFigure(name: string, origin: CFrame, cloth: Color3, skin: Color3): Model
	local ctx = Build.begin(name, origin)

	ctx:pair("block", { name = "Boot", size = V(1.1, 0.8, 1.5), at = V(0.7, 0.4, -0.2), color = TIMBER })
	ctx:pair("block", { name = "Leg", size = V(0.95, 2.3, 1.0), at = V(0.7, 1.9, 0), color = cloth })
	ctx:block({ name = "Hips", size = V(2.4, 0.9, 1.3), at = V(0, 3.3, 0), color = cloth })
	ctx:block({ name = "Torso", size = V(2.7, 2.6, 1.5), at = V(0, 5.0, 0), color = cloth })
	ctx:block({ name = "Collar", size = V(2.9, 0.4, 1.7), at = V(0, 6.2, 0), color = STONE_DARK })
	ctx:pair("block", { name = "Arm", size = V(0.85, 2.4, 0.9), at = V(1.75, 4.9, 0), color = cloth })
	ctx:pair("block", { name = "Hand", size = V(0.9, 0.8, 0.95), at = V(1.78, 3.5, 0), color = skin })
	ctx:block({ name = "Neck", size = V(0.8, 0.5, 0.8), at = V(0, 6.6, 0), color = skin })
	ctx:block({ name = "Head", size = V(1.4, 1.5, 1.4), at = V(0, 7.5, 0), color = skin })
	ctx:pair(
		"glow",
		{ name = "Eye", size = V(0.16, 0.16, 0.16), at = V(0.34, 7.7, -0.7), color = Color3.fromRGB(40, 40, 48) }
	)

	return ctx:finish()
end

local function buildDummy(origin: CFrame, index: number): Model
	local ctx = Build.begin(`Dummy{index}`, origin)

	ctx:cylinder({ name = "Post", size = V(6.5, 0.6, 0.6), at = V(0, 3.2, 0), rot = V(0, 0, 90), color = TIMBER })
	ctx:block({ name = "Base", size = V(2.6, 0.6, 2.6), at = V(0, 0.3, 0), color = STONE_DARK, collide = true })
	ctx:block({ name = "Body", size = V(2.2, 2.6, 1.4), at = V(0, 4.6, 0), color = STRAW, collide = true })
	ctx:block({ name = "Crossbar", size = V(4.6, 0.5, 0.5), at = V(0, 5.6, 0), color = TIMBER })
	ctx:ball({ name = "Head", size = V(1.5, 1.5, 1.5), at = V(0, 6.6, 0), color = STRAW })
	ctx:block({ name = "Band", size = V(2.3, 0.3, 1.5), at = V(0, 5.2, 0), color = CLOTH })

	local model = ctx:finish()
	model:SetAttribute("TrainingDummy", true)
	return model
end

--------------------------------------------------------------------------------
-- Courtyard
--------------------------------------------------------------------------------

local function slab(name: string, size: Vector3, position: Vector3, color: Color3, material: Enum.Material?)
	return Create("Part", {
		Name = name,
		Anchored = true,
		Size = size,
		Position = position,
		Color = color,
		Material = material or Enum.Material.Slate,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
		Parent = folder,
	})
end

local function buildCourtyard()
	local yard = World.Courtyard
	local center = yard.center

	--[[
		Plaza floor, sitting 0.25 proud of the terrain rather than flush with it.

		`groundHeight` flattens to exactly 0 across the courtyard, and this slab
		used to put its top face at exactly 0 as well — so along the southern
		edge, where the flattened terrain runs under the paving, two surfaces
		met at the same height and z-fought. That strip is the first thing a
		player sees walking up the road to the town gate.
	]]
	slab("Plaza", V(yard.width, 2, yard.depth), center - V(0, 0.75, 0), STONE, Enum.Material.Cobblestone)

	--[[
		Perimeter walls.

		Both ends are open: the SOUTH side faces the forest and is where the
		player walks in (World.FirstiaPosition is south of the courtyard
		centre), and the NORTH side is the arch through into the town proper.
		A solid south wall here sealed the courtyard off from the road you
		arrive on — you could reach the gate and not get through it.
	]]
	local wallHeight = 24
	slab(
		"WallW",
		V(4, wallHeight, yard.depth),
		center + V(-yard.width / 2, wallHeight / 2, 0),
		STONE_DARK,
		Enum.Material.Brick
	)
	slab(
		"WallE",
		V(4, wallHeight, yard.depth),
		center + V(yard.width / 2, wallHeight / 2, 0),
		STONE_DARK,
		Enum.Material.Brick
	)
	-- South: the town gate, facing the forest road.
	for side = -1, 1, 2 do
		slab(
			`WallS{side}`,
			V(yard.width / 2 - 14, wallHeight, 4),
			center + V(side * (yard.width / 4 + 7), wallHeight / 2, yard.depth / 2),
			STONE_DARK,
			Enum.Material.Brick
		)
	end
	slab("GateLintel", V(32, 6, 5), center + V(0, wallHeight - 3, yard.depth / 2), STONE, Enum.Material.Brick)

	-- North: the arch through into Firstia's street.
	for side = -1, 1, 2 do
		slab(
			`WallN{side}`,
			V(yard.width / 2 - 14, wallHeight, 4),
			center + V(side * (yard.width / 4 + 7), wallHeight / 2, -yard.depth / 2),
			STONE_DARK,
			Enum.Material.Brick
		)
	end
	slab("ArchLintel", V(32, 6, 5), center + V(0, wallHeight - 3, -yard.depth / 2), STONE, Enum.Material.Brick)

	-- Training yard floor: packed sand, visually distinct from the plaza so the
	-- space reads as purpose-built.
	slab(
		"YardFloor",
		V(70, 0.4, 52),
		World.TrainingYard + V(0, 0.2, 0),
		Color3.fromRGB(168, 148, 116),
		Enum.Material.Sand
	)

	-- Weapon racks and crates for dressing.
	for index = -1, 1, 2 do
		slab(`Rack{index}`, V(1, 5, 12), World.TrainingYard + V(index * 40, 2.5, 0), TIMBER, Enum.Material.Wood)
	end
	for index = 1, 5 do
		slab(
			`Crate{index}`,
			V(3.4, 3.4, 3.4),
			center + V(-yard.width / 2 + 10 + index * 4, 1.7, yard.depth / 2 - 12 - (index % 2) * 5),
			TIMBER,
			Enum.Material.Wood
		)
	end
end

--------------------------------------------------------------------------------
-- Occupants
--------------------------------------------------------------------------------

local function nameplate(model: Model, title: string, subtitle: string, tone: Color3)
	Create("BillboardGui", {
		Name = "Nameplate",
		Size = UDim2.fromOffset(260, 46),
		StudsOffsetWorldSpace = V(0, 9.6, 0),
		MaxDistance = 90,
		LightInfluence = 0,
		Parent = model.PrimaryPart,

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 22),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = title,
			TextSize = 16,
			TextColor3 = Theme.Color.Text,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.3,
		}),
		Create("TextLabel", {
			Position = UDim2.fromOffset(0, 22),
			Size = UDim2.new(1, 0, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = subtitle,
			TextSize = 12,
			TextColor3 = tone,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.45,
		}),
	})
end

local function populate()
	-- Instructor, facing the spawn point.
	local instructorAt = World.TrainingYard + V(0, 0.4, -16)
	local instructor = buildFigure(
		"Instructor",
		CFrame.lookAt(instructorAt, V(World.CourtyardSpawn.X, instructorAt.Y, World.CourtyardSpawn.Z)),
		Color3.fromRGB(64, 78, 104),
		Color3.fromRGB(206, 170, 140)
	)
	instructor.Parent = folder
	instructor:SetAttribute("Instructor", true)
	nameplate(instructor, Tutorial.Instructor.name, Tutorial.Instructor.title, Theme.Color.Accent)

	-- Three dummies in a row.
	for index = -1, 1 do
		local at = World.TrainingYard + V(index * 12, 0.4, 8)
		local dummy = buildDummy(CFrame.lookAt(at, at + V(0, 0, -1)), index + 2)
		dummy.Parent = folder
		nameplate(dummy, "Training Dummy", `{DUMMY_HEALTH} HP`, Theme.Color.TextMuted)
		dummies[dummy] = { health = DUMMY_HEALTH, root = dummy.PrimaryPart }
	end

	-- Practice enemy, in its own pen at the far end.
	local penAt = World.TrainingYard + V(0, 0.4, 24)
	local enemy =
		buildFigure("PracticeEnemy", CFrame.lookAt(penAt, penAt + V(0, 0, -1)), Color3.fromRGB(96, 72, 64), STRAW)
	enemy.Parent = folder
	enemy:SetAttribute("PracticeEnemy", true)
	nameplate(enemy, "Straw Soldier", "Practice enemy", Theme.Color.StaminaEmpty)
	practiceEnemy.model = enemy
	practiceEnemy.health = 320
end

--------------------------------------------------------------------------------
-- Damage
--------------------------------------------------------------------------------

--- Nearest damageable training target within reach of a player's swing.
local function targetFor(root: BasePart): (Model?, any)
	local best, bestData, bestDistance = nil, nil, Encounters.PlayerAttack.range

	for model, data in dummies do
		if data.health > 0 and data.root then
			local distance = (data.root.Position - root.Position).Magnitude
			if distance < bestDistance then
				best, bestData, bestDistance = model, data, distance
			end
		end
	end

	local enemy = practiceEnemy.model
	if enemy and practiceEnemy.health > 0 and enemy.PrimaryPart then
		local distance = (enemy.PrimaryPart.Position - root.Position).Magnitude
		if distance < bestDistance then
			return enemy, practiceEnemy
		end
	end

	return best, bestData
end

--- Dummies route through the same Attack event as real enemies, so the
--- tutorial teaches the real combat system rather than a mock-up of it.
Network.Attack.OnServerEvent:Connect(function(player, comboIndex)
	if typeof(comboIndex) ~= "number" then
		return
	end

	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not root then
		return
	end

	local model, data = targetFor(root)
	if not (model and data) then
		return
	end

	local step = Encounters.comboStep(comboIndex)
	local at = (model.PrimaryPart and model.PrimaryPart.Position or root.Position) + V(0, 4, 0)

	data.health -= step.damage
	Vfx.sparks(at, if step.knockback >= 30 then 22 else 10, Color3.fromRGB(255, 226, 170))
	Vfx.slash(at, root.CFrame.LookVector, 6, Color3.fromRGB(255, 240, 210))
	Audio.playAt("hitLight", at)

	Network.BossDamaged:FireClient(player, step.damage, "normal", at)
	Network.Impact:FireClient(player, if step.knockback >= 30 then "heavy" else "light")
	-- "enemyHit" is deliberately not "enemy": the final step completes on the
	-- kill, not on the first swing that connects.
	Network.TutorialHit:FireClient(player, if model:GetAttribute("PracticeEnemy") then "enemyHit" else "dummy")

	if data.health <= 0 then
		Vfx.landing(model:GetPivot().Position, 1.2)
		if model:GetAttribute("PracticeEnemy") then
			Network.TutorialHit:FireClient(player, "enemyDown")
			practiceEnemy.model = nil
			task.delay(12, function()
				-- Comes back so the step can be retried.
				populate()
			end)
		else
			dummies[model] = nil
		end
		model:Destroy()
	end
end)

--------------------------------------------------------------------------------

--------------------------------------------------------------------------------
-- Drills
--------------------------------------------------------------------------------

--[[
	The parry drill: the instructor swings at you on a loop until you get one.

	Deliberately gentle. The windup is far longer than anything in the field
	(1.0s against the Vorpal Bunny's 0.5s), it telegraphs on the ground, and a
	clean hit costs 10 health rather than 92. The lesson is the *timing*, and a
	student who dies learning it has learned the wrong thing.

	Routed through the same Guard as every real enemy, so what you practise
	here is exactly what you will do out there — including that blocking works,
	dodging works, and mistiming costs you.
]]
local WINDUP = 1.0
local ACTIVE = 0.22
local REST = 1.5
local REACH = 9
local DRILL_DAMAGE = 10

local drills: { [Player]: string? } = {}
local drillThread: thread? = nil

local function instructorModel(): Model?
	if not folder then
		return nil
	end
	for _, child in folder:GetChildren() do
		if child:IsA("Model") and child:GetAttribute("Instructor") then
			return child
		end
	end
	return nil
end

local function runParryDrill(player: Player)
	while drills[player] == "parry" do
		local instructor = instructorModel()
		local character = player.Character
		local root = character and character:FindFirstChild("HumanoidRootPart")

		if not (instructor and instructor.PrimaryPart and root) then
			task.wait(0.5)
			continue
		end

		local origin = instructor.PrimaryPart.Position
		local toPlayer = root.Position - origin
		local flat = Vector3.new(toPlayer.X, 0, toPlayer.Z)

		-- Waits for you to come into range rather than chasing. She is
		-- teaching, not hunting.
		if flat.Magnitude > REACH + 6 then
			Network.TutorialDrill:FireClient(player, "waiting")
			task.wait(0.4)
			continue
		end

		-- Ask for the assist first, so the telegraph can be sized to the real
		-- time before impact rather than guessing.
		local profile = Assist.plan(player)
		local total = WINDUP + (if profile then profile.impactDelay else 0)

		-- Telegraph that CLOSES on the strike. An expanding ring says
		-- "something happened"; a closing one says "now", which is the only
		-- thing a parry cue can usefully say.
		Network.TutorialDrill:FireClient(player, "windup")
		local mark = Vfx.closingRing(origin + V(0, 0.3, 0), REACH, Color3.fromRGB(255, 190, 90), total)
		Audio.playAt("growl", origin)

		task.wait(WINDUP)

		if drills[player] ~= "parry" then
			if mark then
				mark:Destroy()
			end
			if profile then
				Assist.finish(player, false)
			end
			return
		end

		-- She holds the strike at the top of the swing while the client shows
		-- the focus effect. The parry window is untouched — this only buys
		-- real seconds to react.
		if profile then
			-- Announced HERE, not when it was planned: the focus ring has to
			-- start at the top of the swing, not at the start of the windup.
			Assist.announce(player, profile)
			-- Compensated: ring + round trip + bias. See Assist.plan.
			task.wait(profile.impactDelay)
			if drills[player] ~= "parry" then
				Assist.finish(player, false)
				if mark then
					mark:Destroy()
				end
				return
			end
		end

		-- Swing.
		Network.TutorialDrill:FireClient(player, "strike")
		Audio.playAt("slash", origin)
		Vfx.slash(origin + V(0, 4, 0), flat.Unit, 7, Color3.fromRGB(255, 220, 180))

		character = player.Character
		root = character and character:FindFirstChild("HumanoidRootPart")
		local parried = false
		if root and (root.Position - origin).Magnitude <= REACH then
			local outcome = Guard.resolve(player, { damage = DRILL_DAMAGE, from = origin })
			parried = outcome == "parried"
		end

		if profile then
			Assist.finish(player, parried)
		else
			Assist.grade(player, parried)
		end

		task.wait(ACTIVE + REST)
	end
end

Network.AssistMode.OnServerEvent:Connect(function(player, mode)
	if typeof(mode) == "string" then
		Assist.setMode(player, mode)
	end
end)

Network.TutorialDrill.OnServerEvent:Connect(function(player, drill)
	if drill ~= nil and typeof(drill) ~= "string" then
		return
	end
	drills[player] = drill
	if drill == "parry" then
		-- Fresh streaks each time the lesson starts.
		Assist.reset(player)
	end

	if drill == "parry" and not drillThread then
		drillThread = task.spawn(function()
			runParryDrill(player)
			drillThread = nil
		end)
	end
end)

--------------------------------------------------------------------------------
-- Spawn routing
--------------------------------------------------------------------------------

--[[
	Origin decides where you wake up. Traveler is dropped on the forest road
	with no instruction; everyone else starts inside these walls with an
	instructor.

	Done by teleport rather than by SpawnLocation teams, because the choice is
	made during character creation — after the character has already spawned
	somewhere.
]]
Network.OriginChosen.OnServerEvent:Connect(function(player, originId)
	if typeof(originId) ~= "string" then
		return
	end

	local target = World.spawnFor(originId)
	local facing = if originId == "traveler" then World.FirstiaPosition else World.TrainingYard

	local function place(character: Model)
		local root = character:FindFirstChild("HumanoidRootPart")
		if not root then
			return
		end
		local y = if originId == "traveler" then World.groundHeight(target.X, target.Z) else World.Courtyard.floor
		root.CFrame = CFrame.lookAt(V(target.X, y + 5, target.Z), V(facing.X, y + 5, facing.Z))
	end

	local character = player.Character
	if character then
		place(character)
	end
	-- Respawns keep sending you back to your origin's start until the world
	-- has real checkpoints.
	player.CharacterAdded:Connect(function(newCharacter)
		task.wait(0.3)
		place(newCharacter)
	end)
end)

local function build()
	local existing = Workspace:FindFirstChild(FOLDER)
	if existing then
		existing:Destroy()
	end
	table.clear(dummies)

	folder = Create("Folder", { Name = FOLDER, Parent = Workspace })
	buildCourtyard()
	populate()

	Create("SpawnLocation", {
		Name = "CourtyardSpawn",
		Anchored = true,
		Neutral = true,
		Enabled = false, -- routed explicitly by origin, not by Roblox
		Size = V(14, 1, 14),
		CFrame = CFrame.new(World.CourtyardSpawn + V(0, 0.5, 0)) * CFrame.Angles(0, math.pi, 0),
		Color = STONE_DARK,
		Material = Enum.Material.Slate,
		Transparency = 0.4,
		Parent = folder,
	})

	print(`[Firstia] courtyard ready at {World.Courtyard.center}`)
end

build()
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="79">
        <Properties>
          <string name="Name">Fishing</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Fishing — jetties on the river, and the server half of the catch.

	Reuses the interaction system rather than claiming a keybind: each jetty is
	an `Interactable` model exactly like a townsperson, so walking onto one and
	pressing G casts. Adding a verb to a system the player already knows beats
	teaching them a new key, and it means the prompt, the range check and the
	"who are you talking to" logic are all already written.

	AUTHORITY sits here for the same reason it does for the parry: the server
	decides when a bite happens and timestamps the strike's arrival, so the
	client can render the tension bar however it likes without being able to
	assert a catch. `Fishing.grade` runs on the arrival time, not on a number
	the client sent.

	Jetties are placed along the river by World.riverCentre, so they follow the
	water wherever the terrain generator put it.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Build = require(ReplicatedStorage.Shared.Util.Build)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Detail = require(ReplicatedStorage.Shared.World.Detail)
local Fishing = require(ReplicatedStorage.Shared.Config.Fishing)
local Network = require(ReplicatedStorage.Shared.Network)
local Sky = require(ReplicatedStorage.Shared.Config.Sky)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)
local World = require(ReplicatedStorage.Shared.Config.World)

local Lighting = game:GetService("Lighting")
local Profile = require(script.Parent.Profile)

local V = Vector3.new

local FOLDER = "FishingSpots"
local TIMBER = Color3.fromRGB(88, 64, 44)

--- Where along the road's river the jetties go.
local SPOT_Z = { 40, -20, -80, -230 }

local folder: Folder
local spots: { [string]: { position: Vector3 } } = {}

--- One cast per player at a time.
local casts: { [Player]: any } = {}

--------------------------------------------------------------------------------
-- Jetties
--------------------------------------------------------------------------------

local function buildJetty(index: number, z: number)
	local riverX = World.riverCentre(z)
	local bankX = riverX + 26
	local y = World.groundHeight(bankX, z)

	local origin = CFrame.lookAt(V(bankX, y, z), V(riverX, y, z))
	local ctx = Build.begin(`FishingSpot{index}`, origin)
	local rng = Random.new(index * 313)

	-- Deck running out over the water.
	ctx:block({
		name = "Deck",
		size = V(9, 0.7, 26),
		at = V(0, 0.4, -13),
		color = Detail.vary(Color3.fromRGB(118, 88, 58), rng),
		material = Enum.Material.WoodPlanks,
		collide = true,
	})
	for post = 0, 4 do
		for side = -1, 1, 2 do
			ctx:block({
				name = `Pile{post}{side}`,
				size = V(0.9, 9, 0.9),
				at = V(side * 3.8, -4, -3 - post * 5.5),
				color = Detail.vary(Color3.fromRGB(74, 54, 38), rng),
				material = Enum.Material.Wood,
			})
		end
	end
	-- Handrail on one side only, so the deck reads as a working jetty rather
	-- than a fenced platform.
	ctx:block({
		name = "Rail",
		size = V(0.5, 0.5, 24),
		at = V(4.2, 3, -13),
		color = TIMBER,
		material = Enum.Material.Wood,
	})
	for post = 0, 3 do
		ctx:block({ name = `RailPost{post}`, size = V(0.5, 3, 0.5), at = V(4.2, 1.5, -4 - post * 7), color = TIMBER })
	end

	-- A crate and a lantern, so the spot reads as somewhere people come back to.
	ctx:block({
		name = "Crate",
		size = V(3, 3, 3),
		at = V(-2.6, 2.2, -3),
		color = Detail.vary(Color3.fromRGB(134, 102, 66), rng),
		material = Enum.Material.WoodPlanks,
	})
	local lamp = ctx:block({
		name = "Lamp",
		size = V(1.6, 2, 1.6),
		at = V(3.4, 8, -22),
		color = Color3.fromRGB(255, 226, 176),
		material = Enum.Material.Neon,
	})
	ctx:block({ name = "LampPost", size = V(0.6, 8, 0.6), at = V(3.4, 4, -22), color = Color3.fromRGB(54, 52, 50) })
	Create("PointLight", { Brightness = 1.6, Range = 26, Color = Color3.fromRGB(255, 206, 150), Parent = lamp })

	local model = ctx:finish()
	model.Parent = folder

	local id = `fish{index}`
	model:SetAttribute("NpcId", id)
	model:SetAttribute("NpcName", "the river")
	model:SetAttribute("Interactable", true)

	Create("BillboardGui", {
		Name = "Nameplate",
		Size = UDim2.fromOffset(200, 34),
		StudsOffsetWorldSpace = V(0, 8, -10),
		MaxDistance = 120,
		LightInfluence = 0,
		Parent = model.PrimaryPart,

		Create("TextLabel", {
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = "Fishing Spot",
			TextSize = 14,
			TextColor3 = Theme.Color.Accent,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.35,
		}),
	})

	-- The point the float lands on, out over the water.
	spots[id] = { position = origin * CFrame.new(0, 0, -22).Position }
end

--------------------------------------------------------------------------------
-- The cast
--------------------------------------------------------------------------------

local function clearCast(player: Player)
	local cast = casts[player]
	if cast and cast.float then
		cast.float:Destroy()
	end
	casts[player] = nil
end

local function float(at: Vector3): BasePart
	local part = Create("Part", {
		Name = "Float",
		Anchored = true,
		CanCollide = false,
		CanQuery = false,
		Shape = Enum.PartType.Ball,
		Size = V(1.2, 1.2, 1.2),
		Position = at + V(0, World.WaterLevel + 1, 0) * 0,
		Color = Color3.fromRGB(228, 88, 84),
		Material = Enum.Material.SmoothPlastic,
		Parent = folder,
	})
	part.Position = V(at.X, World.WaterLevel + 0.6, at.Z)
	return part
end

local function beginCast(player: Player, id: string)
	local spot = spots[id]
	if not spot or casts[player] then
		return
	end

	local bob = float(spot.position)
	Vfx.sparks(bob.Position, 6, Color3.fromRGB(160, 210, 230))
	Audio.playAt("splash", bob.Position)

	local cast = {
		float = bob,
		spot = id,
		state = "waiting",
		token = os.clock(),
	}
	casts[player] = cast

	Network.Fishing:FireClient(player, { state = "cast" })

	local wait = Fishing.BiteDelay[1] + math.random() * (Fishing.BiteDelay[2] - Fishing.BiteDelay[1])
	task.delay(wait, function()
		-- The player may have reeled in, walked off or logged out.
		if casts[player] ~= cast then
			return
		end

		cast.state = "bite"
		cast.biteAt = os.clock()

		Audio.playAt("splash", bob.Position)
		Vfx.ring(bob.Position, 7, Color3.fromRGB(180, 226, 246), 0.6)
		Network.Fishing:FireClient(player, { state = "bite", window = Fishing.HookWindow })

		task.delay(Fishing.HookWindow, function()
			-- Missed it entirely.
			if casts[player] == cast and cast.state == "bite" then
				Network.Fishing:FireClient(player, { state = "missed" })
				Audio.playAt("uiClose", bob.Position)
				clearCast(player)
			end
		end)
	end)
end

--[[
	Resolves a strike.

	The grade comes from when the press *arrived at the server*, measured
	against the bite the server itself timestamped. The client never sends a
	number that decides anything — same rule as the parry, and for the same
	reason.
]]
local function strike(player: Player)
	local cast = casts[player]
	if not cast then
		return
	end

	if cast.state ~= "bite" then
		-- Struck before the bite: the float goes under and the fish leaves.
		Network.Fishing:FireClient(player, { state = "early" })
		Audio.playAt("uiClose", cast.float.Position)
		clearCast(player)
		return
	end

	local elapsed = os.clock() - cast.biteAt
	-- -1 at the open, 0 at the centre, +1 at the close.
	local offset = (elapsed / Fishing.HookWindow) * 2 - 1
	local quality, label = Fishing.grade(offset)

	local night = Sky.isNight(Lighting.ClockTime)
	local fish = Fishing.roll(quality, night, Random.new(math.floor(os.clock() * 1000)))

	local at = cast.float.Position
	Vfx.sparks(at, 14, (Theme.Rarity[fish.rarity] or Theme.Rarity.Common).Color)
	Vfx.ring(at, 9, Color3.fromRGB(200, 236, 250), 0.5)
	Audio.playAt("splash", at)
	Audio.playAt(if fish.rarity == "Epic" then "unique" elseif quality >= 0.8 then "levelUp" else "loot", at)

	Profile.addMaterial(player, fish.id, 1)
	Network.Loot:FireClient(player, {
		id = fish.id,
		name = fish.name,
		rarity = fish.rarity,
		slot = "Fish",
		description = fish.description,
		quantity = 1,
	})
	Network.Fishing:FireClient(player, {
		state = "caught",
		fish = { id = fish.id, name = fish.name, rarity = fish.rarity, value = fish.value },
		quality = quality,
		grade = label,
	})

	clearCast(player)
end

--------------------------------------------------------------------------------

Network.Interact.OnServerEvent:Connect(function(player, npcId, action)
	if typeof(npcId) ~= "string" or not spots[npcId] then
		return
	end

	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not root then
		return
	end
	-- Range is enforced here as well as on the client, because the client's
	-- range check is a convenience and this one is the rule.
	if (root.Position - spots[npcId].position).Magnitude > 46 then
		return
	end

	if action ~= "talk" then
		return
	end

	-- One button does both jobs: cast if idle, strike if a line is out. A
	-- separate "reel" key would be a key that is wrong to press most of the
	-- time.
	if casts[player] then
		strike(player)
	else
		beginCast(player, npcId)
	end
end)

Players.PlayerRemoving:Connect(clearCast)

--------------------------------------------------------------------------------

local function build()
	local existing = Workspace:FindFirstChild(FOLDER)
	if existing then
		existing:Destroy()
	end
	table.clear(spots)

	folder = Create("Folder", { Name = FOLDER, Parent = Workspace })
	for index, z in SPOT_Z do
		buildJetty(index, z)
	end

	print(`[Fishing] {#SPOT_Z} jetties on the river, {#Fishing.Stock} things in it`)
end

build()
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="80">
        <Properties>
          <string name="Name">Main</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Main — server entry point.

	Intentionally near-empty. The UI layer is being built first and is driven
	entirely by the client-side UIState store, so there is nothing to
	replicate yet.

	When gameplay lands, this is where the authoritative pieces go: combat
	validation, the global Unique Scenario claim (a DataStore UpdateAsync
	against a single key per scenario, so the first player across every server
	takes it permanently), and player profiles.
]]

local Players = game:GetService("Players")

Players.PlayerAdded:Connect(function(player)
	print(`[SLF] {player.Name} joined`)
end)
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="81">
        <Properties>
          <string name="Name">MobGallery</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	MobGallery — lines every creature up on a platform so they can be looked at
	side by side.

	A reference lineup, not a level. Seeing them at true relative scale in one
	frame is the fastest way to judge whether the silhouettes read, and it's
	the thing to delete once creatures spawn in a real world.

	Rebuilds itself on every server start, so editing a mob file and
	reconnecting Rojo shows the change immediately.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Anim = require(ReplicatedStorage.Shared.Mobs.Anim)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Mobs = require(ReplicatedStorage.Shared.Mobs)
local Rig = require(ReplicatedStorage.Shared.Mobs.Rig)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local World = require(ReplicatedStorage.Shared.Config.World)

--- Bound rigs, animated together on one Heartbeat.
local exhibits: { { id: string, rig: any, base: CFrame } } = {}

local FOLDER_NAME = "MobGallery"
local PLATFORM_PADDING = 14

local function buildNameplate(entry, model: Model)
	local tone = Theme.Color[entry.tone] or Theme.Color.Accent

	local billboard = Create("BillboardGui", {
		Name = "Nameplate",
		Size = UDim2.fromOffset(340, 56),
		StudsOffsetWorldSpace = Vector3.new(0, entry.plateY, 0),
		AlwaysOnTop = false,
		MaxDistance = 220,
		LightInfluence = 0,
		Parent = model.PrimaryPart,

		Create("TextLabel", {
			Name = "Title",
			Size = UDim2.new(1, 0, 0, 24),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = string.upper(entry.name),
			TextSize = 19,
			TextColor3 = Theme.Color.Text,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.3,
			TextScaled = false,
		}),

		Create("Frame", {
			Name = "Rule",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, 27),
			Size = UDim2.fromOffset(120, 1),
			BackgroundColor3 = tone,
			BorderSizePixel = 0,

			Create("UIGradient", {
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 1),
					NumberSequenceKeypoint.new(0.5, 0),
					NumberSequenceKeypoint.new(1, 1),
				}),
			}),
		}),

		Create("TextLabel", {
			Name = "Subtitle",
			Position = UDim2.fromOffset(0, 32),
			Size = UDim2.new(1, 0, 0, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = entry.subtitle,
			TextSize = 13,
			TextColor3 = tone,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.45,
		}),
	})

	return billboard
end

local function build()
	local existing = Workspace:FindFirstChild(FOLDER_NAME)
	if existing then
		existing:Destroy()
	end

	local folder = Create("Folder", { Name = FOLDER_NAME, Parent = Workspace })

	local totalWidth = 0
	for _, entry in Mobs.Catalog do
		totalWidth += entry.width
	end

	local center = World.GalleryCenter

	-- Platform. Top surface sits exactly on the gallery's y, because every
	-- creature is authored standing on y = 0.
	Create("Part", {
		Name = "Platform",
		Anchored = true,
		Size = Vector3.new(totalWidth + PLATFORM_PADDING * 2, 2, World.GalleryDepth),
		Position = center - Vector3.new(0, 1, 0),
		Color = Color3.fromRGB(38, 38, 44),
		Material = Enum.Material.Slate,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
		Parent = folder,
	})

	Create("Part", {
		Name = "Kerb",
		Anchored = true,
		CanCollide = false,
		Size = Vector3.new(totalWidth + PLATFORM_PADDING * 2, 0.16, 0.6),
		Position = center + Vector3.new(0, 0.08, World.GalleryDepth / 2 - 0.6),
		Color = Theme.Color.Accent,
		Material = Enum.Material.Neon,
		Parent = folder,
	})

	-- Lay the line out left to right, each creature centred in its own slot.
	local cursor = -totalWidth / 2

	for _, entry in Mobs.Catalog do
		local x = cursor + entry.width / 2
		cursor += entry.width

		-- Rotated 180 degrees: creatures are authored facing -Z, and the
		-- viewer stands on the +Z side of the platform.
		local origin = CFrame.new(center + Vector3.new(x, 0, 0)) * CFrame.Angles(0, math.pi, 0)

		local ok, model = pcall(entry.build, origin)
		if not ok then
			warn(`[MobGallery] {entry.id} failed to build: {model}`)
			continue
		end

		model.Name = entry.name
		model.Parent = folder
		buildNameplate(entry, model)

		-- Idling matters more than it sounds: a frozen model reads as a
		-- statue, and you can't judge a silhouette that isn't moving.
		table.insert(exhibits, { id = entry.id, rig = Rig.bind(model), base = origin })

		-- A dim pool of light under each one so the dark fur separates from
		-- the dark platform.
		Create("PointLight", {
			Brightness = 0.6,
			Range = 16,
			Color = Theme.Color[entry.tone] or Theme.Color.Accent,
			Parent = model.PrimaryPart,
		})
	end

	print(`[MobGallery] {#Mobs.Catalog} creatures at {center}. Type /gallery in chat to teleport.`)
end

build()

-- Offset each creature's clock so they don't all breathe in lockstep, which
-- reads as a machine rather than four animals.
RunService.Heartbeat:Connect(function()
	local t = os.clock()
	for index, exhibit in exhibits do
		local rootOffset, parts = Anim.idle(exhibit.id, t + index * 1.7)
		exhibit.rig:apply(exhibit.base * rootOffset, parts)
	end
end)
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="82">
        <Properties>
          <string name="Name">Profile</string>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Profile — the server's copy of who you are.

	Gold, materials, owned items, quest state and kill counts. The client is
	shown all of it and trusted with none of it: every mutation goes through
	this module on the server, and the client's copy exists only to draw
	panels.

	PERSISTENCE. Saves to a DataStore, but never *depends* on one. Studio
	without API access enabled, or a datastore outage, both surface as a failed
	pcall — and the correct response to that is a playable session that doesn't
	save, not a player who can't move. `saved` on the profile records which
	happened, so a save failure can be surfaced honestly rather than silently
	losing someone's afternoon.

	Autosave runs on a timer and on leave; the leave path is the one that
	matters, and it deliberately blocks in BindToClose so the server cannot
	shut down mid-write.
]]

local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Network = require(ReplicatedStorage.Shared.Network)
local Town = require(ReplicatedStorage.Shared.Config.Town)

local Profile = {}

local STORE_NAME = "SLF_Profile_v1"
local AUTOSAVE_SECONDS = 120
local STARTING_GOLD = 120

local store: DataStore? = nil
do
	local ok, result = pcall(function()
		return DataStoreService:GetDataStore(STORE_NAME)
	end)
	store = if ok then result else nil
	if not store then
		warn("[Profile] no DataStore access — this session will not persist")
	end
end

export type Data = {
	gold: number,
	--- materialId -> count
	materials: { [string]: number },
	--- itemId -> count (stackables) or 1
	items: { [string]: number },
	--- questId -> "active" | "done"
	quests: { [string]: string },
	--- questId -> progress count
	questProgress: { [string]: number },
	--- mobId -> lifetime kills
	kills: { [string]: number },
	titles: { [string]: boolean },
	visited: { [string]: boolean },
	saved: boolean,
}

local profiles: { [Player]: Data } = {}

local function blank(): Data
	return {
		gold = STARTING_GOLD,
		materials = {},
		items = {},
		quests = {},
		questProgress = {},
		kills = {},
		titles = {},
		visited = {},
		saved = false,
	}
end

--- Fills in anything a stored profile predates, so adding a field here never
--- breaks a save written before it existed.
local function migrate(data): Data
	local fresh = blank()
	if typeof(data) ~= "table" then
		return fresh
	end
	for key, default in fresh do
		if typeof(data[key]) ~= typeof(default) then
			data[key] = default
		end
	end
	return data
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

local function load(player: Player)
	local data = blank()

	if store then
		local ok, result = pcall(function()
			return store:GetAsync(`p_{player.UserId}`)
		end)
		if ok then
			data = migrate(result)
			data.saved = true
		else
			warn(`[Profile] load failed for {player.Name}: {result}`)
		end
	end

	profiles[player] = data
	Profile.push(player)
end

local function save(player: Player)
	local data = profiles[player]
	if not (store and data) then
		return
	end

	local ok, err = pcall(function()
		store:SetAsync(`p_{player.UserId}`, data)
	end)
	if not ok then
		warn(`[Profile] save failed for {player.Name}: {err}`)
	end
end

function Profile.get(player: Player): Data?
	return profiles[player]
end

--- Sends the whole profile to its owner. Cheap enough to do on every change:
--- it is a handful of small maps, and a client that can never be a frame out
--- of date is one fewer class of bug.
function Profile.push(player: Player)
	local data = profiles[player]
	if not data then
		return
	end
	Network.Profile:FireClient(player, {
		gold = data.gold,
		materials = data.materials,
		items = data.items,
		quests = data.quests,
		questProgress = data.questProgress,
		kills = data.kills,
		titles = data.titles,
		persistent = data.saved or store ~= nil,
	})
end

--------------------------------------------------------------------------------
-- Currency and goods
--------------------------------------------------------------------------------

function Profile.addGold(player: Player, amount: number)
	local data = profiles[player]
	if not data or amount <= 0 then
		return
	end
	data.gold += math.floor(amount)
	Profile.push(player)
end

--- Returns false and changes nothing if they can't afford it, so callers can
--- treat this as the authorisation check rather than checking separately and
--- racing themselves.
function Profile.spendGold(player: Player, amount: number): boolean
	local data = profiles[player]
	if not data or amount < 0 or data.gold < amount then
		return false
	end
	data.gold -= amount
	Profile.push(player)
	return true
end

function Profile.addMaterial(player: Player, id: string, count: number?)
	local data = profiles[player]
	if not data then
		return
	end
	data.materials[id] = (data.materials[id] or 0) + (count or 1)
	Profile.advanceCollect(player, id)
	Profile.push(player)
end

--[[
	Removes materials and re-syncs any collect quest that wanted them.

	The re-sync is the point. Without it you could accept "bring me six hides",
	gather six, watch the quest go green, sell all six to the same shopkeeper,
	and then click "Hand in" forever — the log still said ready, and the
	hand-in failed silently because the materials were gone. Quest progress for
	a collect objective has to mean "what is in the bag right now", in both
	directions.
]]
function Profile.takeMaterial(player: Player, id: string, count: number): boolean
	local data = profiles[player]
	if not data or (data.materials[id] or 0) < count then
		return false
	end
	data.materials[id] -= count
	if data.materials[id] <= 0 then
		data.materials[id] = nil
	end
	Profile.advanceCollect(player, id)
	Profile.push(player)
	return true
end

function Profile.addItem(player: Player, id: string, count: number?)
	local data = profiles[player]
	if not data then
		return
	end
	data.items[id] = (data.items[id] or 0) + (count or 1)
	Profile.push(player)
end

function Profile.hasTitle(player: Player, id: string): boolean
	local data = profiles[player]
	return data ~= nil and data.titles[id] == true
end

function Profile.grantTitle(player: Player, id: string)
	local data = profiles[player]
	if not data or data.titles[id] then
		return
	end
	data.titles[id] = true
	Profile.push(player)
end

--------------------------------------------------------------------------------
-- Quests
--------------------------------------------------------------------------------

function Profile.questState(player: Player, questId: string): string
	local data = profiles[player]
	if not data then
		return "unavailable"
	end
	return data.quests[questId] or "available"
end

--- A quest is offerable when it isn't already taken and its prerequisite is
--- finished. Prerequisites are single-parent by design — a town this size
--- doesn't need a graph, and a chain is legible in the log.
function Profile.canAccept(player: Player, quest): boolean
	local data = profiles[player]
	if not data or data.quests[quest.id] then
		return false
	end
	if quest.requires and data.quests[quest.requires] ~= "done" then
		return false
	end
	return true
end

function Profile.accept(player: Player, quest): boolean
	local data = profiles[player]
	if not (data and Profile.canAccept(player, quest)) then
		return false
	end
	data.quests[quest.id] = "active"
	data.questProgress[quest.id] = 0

	-- Credit anything already in the bag. Being told to fetch six hides when
	-- six hides are in your pocket is the kind of thing that reads as a bug.
	if quest.objective.kind == "collect" then
		data.questProgress[quest.id] = math.min(data.materials[quest.objective.target] or 0, quest.objective.count)
	end

	Profile.push(player)
	Network.QuestUpdate:FireClient(player, quest.id, "accepted", Profile.progressOf(player, quest))
	return true
end

function Profile.progressOf(player: Player, quest): number
	local data = profiles[player]
	if not data then
		return 0
	end
	return math.min(data.questProgress[quest.id] or 0, quest.objective.count)
end

function Profile.isComplete(player: Player, quest): boolean
	return Profile.questState(player, quest.id) == "active"
		and Profile.progressOf(player, quest) >= quest.objective.count
end

local function bump(player: Player, quest, amount: number)
	local data = profiles[player]
	if not data or data.quests[quest.id] ~= "active" then
		return
	end

	local before = data.questProgress[quest.id] or 0
	if before >= quest.objective.count then
		return
	end

	local after = math.min(before + amount, quest.objective.count)
	data.questProgress[quest.id] = after

	Network.QuestUpdate:FireClient(
		player,
		quest.id,
		if after >= quest.objective.count then "ready" else "progress",
		after
	)
end

--- Called when something dies. Kill counts are lifetime, and separate from
--- quest progress, so a "kill 5" quest taken after 20 goblins still asks for 5.
function Profile.recordKill(player: Player, mobId: string)
	local data = profiles[player]
	if not data then
		return
	end
	data.kills[mobId] = (data.kills[mobId] or 0) + 1

	for _, quest in Town.Quests do
		if quest.objective.kind == "kill" and quest.objective.target == mobId then
			bump(player, quest, 1)
		end
	end

	Profile.push(player)
end

--- Collect objectives re-read the bag rather than counting deltas, so selling
--- the materials genuinely un-completes the quest instead of leaving it
--- claimable with an empty pack.
function Profile.advanceCollect(player: Player, materialId: string)
	local data = profiles[player]
	if not data then
		return
	end

	for _, quest in Town.Quests do
		if
			quest.objective.kind == "collect"
			and quest.objective.target == materialId
			and data.quests[quest.id] == "active"
		then
			local held = math.min(data.materials[materialId] or 0, quest.objective.count)
			local before = data.questProgress[quest.id] or 0
			data.questProgress[quest.id] = held
			if held ~= before then
				Network.QuestUpdate:FireClient(
					player,
					quest.id,
					if held >= quest.objective.count then "ready" else "progress",
					held
				)
			end
		end
	end
end

--- Hands in a finished quest. Returns the reward table so the caller can play
--- the celebration, or nil if it wasn't actually finished.
function Profile.turnIn(player: Player, quest)
	local data = profiles[player]
	if not (data and Profile.isComplete(player, quest)) then
		return nil
	end

	-- Checked, then marked done, then taken — in that order. advanceCollect
	-- only re-syncs quests still marked "active", so taking the materials
	-- after the state flip can't rewind the progress we just finalised.
	if quest.objective.kind == "collect" then
		if (data.materials[quest.objective.target] or 0) < quest.objective.count then
			return nil
		end
	end

	data.quests[quest.id] = "done"
	data.questProgress[quest.id] = quest.objective.count

	if quest.objective.kind == "collect" then
		Profile.takeMaterial(player, quest.objective.target, quest.objective.count)
	end

	local reward = quest.reward
	if reward.gold then
		data.gold += reward.gold
	end
	if reward.title then
		data.titles[reward.title] = true
	end
	if reward.item then
		data.items[reward.item.id] = (data.items[reward.item.id] or 0) + 1
	end

	Profile.push(player)
	Network.QuestUpdate:FireClient(player, quest.id, "done", quest.objective.count)
	return reward
end

--------------------------------------------------------------------------------
-- Wiring
--------------------------------------------------------------------------------

Players.PlayerAdded:Connect(function(player)
	task.spawn(load, player)
end)

Players.PlayerRemoving:Connect(function(player)
	save(player)
	profiles[player] = nil
end)

-- Anyone already in (the server script may load after the first join).
for _, player in Players:GetPlayers() do
	if not profiles[player] then
		task.spawn(load, player)
	end
end

task.spawn(function()
	while true do
		task.wait(AUTOSAVE_SECONDS)
		for player in profiles do
			save(player)
		end
	end
end)

game:BindToClose(function()
	for player in profiles do
		save(player)
	end
end)

return Profile
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="83">
        <Properties>
          <string name="Name">Rabituza</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Rabituza — the warren west of the river.

	The second settlement, and the one that gives the Vorpal Bunny scenario a
	cost. Firstia sends you to kill the thing on the road; this is where the
	thing came from. You reach it by choosing to cross a bridge nobody told you
	about, which is the whole point — a tragedy you walked into is worth more
	than one you were handed in a cutscene.

	BUILT SMALL AND ROUND, against Firstia's tall and square. Burrow mounds
	rather than houses, round doorways rather than rectangles, no straight
	street, and nothing above eight studs except the moot post. A second town
	that used the same vocabulary as the first would just be Firstia again with
	different signs; the read has to change before the player has read a word.

	Residents live in Config/Town like everyone else, so schedules, dialogue,
	shops and quests all work here for free — this file only builds the ground
	they stand on.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local Build = require(ReplicatedStorage.Shared.Util.Build)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Detail = require(ReplicatedStorage.Shared.World.Detail)
local Render = require(ReplicatedStorage.Shared.Util.Render)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local World = require(ReplicatedStorage.Shared.Config.World)

local V = Vector3.new

local FOLDER = "Rabituza"
local WARREN = World.Rabituza

local EARTH = Color3.fromRGB(96, 78, 58)
local TURF = Color3.fromRGB(104, 128, 76)
local TIMBER = Color3.fromRGB(96, 72, 50)
local THATCH = Color3.fromRGB(198, 172, 108)
local DOOR = Color3.fromRGB(58, 44, 34)

local folder: Folder

--- Mound positions, relative to the warren centre. Scattered rather than laid
--- out: a warren has no street plan and should not look like it does.
local MOUNDS = {
	{ at = V(-26, 0, -14), size = 26 },
	{ at = V(-30, 0, 26), size = 21 },
	{ at = V(2, 0, 34), size = 19 },
	{ at = V(28, 0, 22), size = 23 },
	{ at = V(30, 0, -18), size = 20 },
	{ at = V(4, 0, -40), size = 24 },
	{ at = V(-14, 0, 52), size = 17 },
	{ at = V(22, 0, -48), size = 18 },
}

local function slab(name: string, size: Vector3, at: Vector3, color: Color3, material: Enum.Material?): BasePart
	local part = Create("Part", {
		Name = name,
		Anchored = true,
		Size = size,
		Position = at,
		Color = color,
		Material = material or Enum.Material.Grass,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
		Parent = folder,
	})
	-- Shadows come from mass; trim and clutter stop paying for them.
	Render.applyShadow(part)
	return part
end

--[[
	One burrow: a turf dome with a round doorway, a chimney and a lit window.

	The dome is a ball sunk into the ground rather than a hemisphere mesh —
	half of it is below the plate, so the silhouette is a curve meeting earth
	with no visible seam.
]]
local function buildMound(index: number, spec, rng: Random)
	local at = WARREN.center + spec.at
	local size = spec.size

	local ctx = Build.begin(`Burrow{index}`, CFrame.lookAt(at, at + V(0, 0, 1)))

	ctx:ball({
		name = "Turf",
		size = V(size, size * 0.95, size),
		at = V(0, size * 0.26, 0),
		color = Detail.vary(TURF, rng, 0.05),
		material = Enum.Material.Grass,
		collide = true,
	})
	-- Earth showing through at the base, so it reads as dug rather than placed.
	ctx:cylinder({
		name = "Skirt",
		size = V(1.6, size * 0.98, size * 0.98),
		at = V(0, 0.8, 0),
		rot = V(0, 0, 90),
		color = Detail.vary(EARTH, rng, 0.04),
		material = Enum.Material.Ground,
	})

	-- Round doorway: a dark disc set into a timber ring.
	local doorSize = size * 0.34
	ctx:cylinder({
		name = "DoorRing",
		size = V(1.2, doorSize + 2.2, doorSize + 2.2),
		at = V(0, doorSize * 0.55, -size * 0.46),
		rot = V(0, 90, 0),
		color = Detail.vary(TIMBER, rng),
		material = Enum.Material.Wood,
	})
	ctx:cylinder({
		name = "Door",
		size = V(0.6, doorSize, doorSize),
		at = V(0, doorSize * 0.55, -size * 0.5),
		rot = V(0, 90, 0),
		color = DOOR,
		material = Enum.Material.Slate,
	})
	-- Step and a scatter of stones outside it.
	ctx:block({
		name = "Step",
		size = V(doorSize + 3, 0.5, 3),
		at = V(0, 0.25, -size * 0.56),
		color = Detail.vary(Color3.fromRGB(138, 132, 120), rng),
		material = Enum.Material.Rock,
	})

	-- Round window, lit from inside.
	local pane = ctx:cylinder({
		name = "Window",
		size = V(0.7, size * 0.16, size * 0.16),
		at = V(size * 0.3, size * 0.36, -size * 0.36),
		rot = V(0, 62, 0),
		color = Color3.fromRGB(255, 214, 148),
		material = Enum.Material.Neon,
	})
	pane.Transparency = 0.25
	Create("PointLight", { Brightness = 1.4, Range = 18, Color = Color3.fromRGB(255, 206, 150), Parent = pane })

	-- Chimney pipe out of the turf, smoking.
	local pipe = ctx:cylinder({
		name = "Pipe",
		size = V(size * 0.3, 1.7, 1.7),
		at = V(-size * 0.16, size * 0.72, size * 0.1),
		rot = V(0, 0, 90),
		color = Detail.vary(Color3.fromRGB(120, 104, 92), rng),
		material = Enum.Material.Concrete,
	})
	Create("ParticleEmitter", {
		Name = "Smoke",
		Texture = "rbxasset://textures/particles/smoke_main.dds",
		Rate = 3,
		Lifetime = NumberRange.new(3.5, 6),
		Speed = NumberRange.new(2, 3.5),
		SpreadAngle = Vector2.new(16, 16),
		Size = NumberSequence.new({ NumberSequenceKeypoint.new(0, 1.1), NumberSequenceKeypoint.new(1, 6) }),
		Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 0.6),
			NumberSequenceKeypoint.new(1, 1),
		}),
		Color = ColorSequence.new(Color3.fromRGB(188, 184, 178)),
		Acceleration = V(rng:NextNumber(-1, 1), 4.5, rng:NextNumber(-1, 1)),
		Parent = pipe,
	})

	local model = ctx:finish()
	model:PivotTo(CFrame.new(at) * CFrame.Angles(0, rng:NextNumber(0, math.pi * 2), 0))
	model.Parent = folder
end

--- The moot: a ring of stones round a communal fire, with a carved post.
local function buildMoot(rng: Random)
	local at = WARREN.center + V(0, 0, -6)

	slab("MootFloor", V(34, 1, 34), at + V(0, 0.3, 0), Color3.fromRGB(148, 136, 112), Enum.Material.Sand)
	for index = 0, 9 do
		local angle = index / 10 * math.pi * 2
		local stone = slab(
			`MootStone{index}`,
			V(rng:NextNumber(2.4, 3.6), rng:NextNumber(2, 3.4), rng:NextNumber(2.4, 3.6)),
			at + V(math.cos(angle) * 15, 1.4, math.sin(angle) * 15),
			Detail.vary(Color3.fromRGB(132, 126, 118), rng),
			Enum.Material.Rock
		)
		stone.CFrame = CFrame.new(stone.Position) * CFrame.Angles(0, angle, math.rad(rng:NextNumber(-7, 7)))
	end

	Detail.fire(CFrame.new(at + V(0, 0.8, 0)), rng, { scale = 1.3, parent = folder })

	-- The post. The tallest thing in the village, and only eight studs of it.
	local post = slab("MootPost", V(1.8, 16, 1.8), at + V(0, 8, -13), TIMBER, Enum.Material.Wood)
	post.CanCollide = true
	for index = 1, 3 do
		slab(
			`MootBand{index}`,
			V(3, 0.6, 3),
			at + V(0, 4 + index * 3.5, -13),
			Detail.vary(Color3.fromRGB(198, 168, 96), rng),
			Enum.Material.Metal
		)
	end
	local ears = slab("MootEars", V(1.2, 6, 4.5), at + V(0, 18, -13), THATCH, Enum.Material.Fabric)
	ears.CFrame = CFrame.new(ears.Position) * CFrame.Angles(math.rad(-12), 0, 0)
end

--- Root gardens. Rows of little green tufts with something orange under them.
local function buildGardens(rng: Random)
	for _, plot in { V(-40, 0, 40), V(34, 0, 44), V(-46, 0, -40) } do
		local at = WARREN.center + plot
		slab("GardenSoil", V(22, 0.6, 16), at + V(0, 0.3, 0), Color3.fromRGB(72, 54, 40), Enum.Material.Ground)

		for row = 0, 4 do
			for col = 0, 3 do
				local x = -9 + row * 4.5
				local z = -6 + col * 4
				slab(
					"Root",
					V(1.1, 1.4, 1.1),
					at + V(x, 1.1, z),
					Color3.fromRGB(216, 128, 56),
					Enum.Material.SmoothPlastic
				)
				for leaf = -1, 1 do
					local top = slab(
						"Leaf",
						V(0.35, 2.2, 0.35),
						at + V(x + leaf * 0.5, 2.6, z),
						Detail.vary(Color3.fromRGB(96, 152, 74), rng),
						Enum.Material.Grass
					)
					top.CFrame = CFrame.new(top.Position) * CFrame.Angles(0, 0, math.rad(leaf * 18))
				end
			end
		end
		Detail.fence(CFrame.new(at + V(0, 0, -9)), rng, 22, folder)
	end
end

--[[
	The bridge.

	Without it the warren is unreachable — the river runs between it and the
	road. Built from planks on piles at the water line, following
	World.riverCentre so it lands on the actual banks rather than a guess.
]]
local function buildBridge(rng: Random)
	local z = World.RabituzaBridgeZ
	local centre = World.riverCentre(z)
	local eastX = centre + 34
	local westX = centre - 34
	--[[
		Deck height.

		Rendering the warren showed the deck three studs below the clearing —
		a step a character cannot reliably walk up, which would have made the
		only route to the village a place you get stuck. One stud below the
		flattened ground instead, so the west end is a step you don't notice
		and the ramps only have to solve the east bank's real terrain.
	]]
	local deckY = World.WaterLevel + 8

	slab(
		"BridgeDeck",
		V(math.abs(eastX - westX) + 8, 1.2, 12),
		V((eastX + westX) / 2, deckY, z),
		Detail.vary(Color3.fromRGB(122, 92, 60), rng),
		Enum.Material.WoodPlanks
	).CanCollide =
		true

	for index = 0, 5 do
		local x = westX + index * (math.abs(eastX - westX) / 5)
		for side = -1, 1, 2 do
			slab(`BridgePile{index}{side}`, V(1.2, 16, 1.2), V(x, deckY - 8, z + side * 5), TIMBER, Enum.Material.Wood)
			slab(`BridgeRailPost{index}{side}`, V(0.8, 4, 0.8), V(x, deckY + 2.5, z + side * 5.4), TIMBER)
		end
	end
	for side = -1, 1, 2 do
		slab(
			`BridgeRail{side}`,
			V(math.abs(eastX - westX) + 8, 0.6, 0.6),
			V((eastX + westX) / 2, deckY + 4.2, z + side * 5.4),
			TIMBER,
			Enum.Material.Wood
		)
	end

	--[[
		Ramps onto the banks.

		Long and shallow rather than short and steep: the east bank is real
		generated terrain whose height here is whatever the noise decided, so
		the ramp has to cover a range rather than a known step. 40 studs at 6
		degrees climbs about four, which covers the river valley's cut on
		either side.
	]]
	for _, side in { -1, 1 } do
		local x = if side < 0 then westX - 20 else eastX + 20
		local ramp = slab(
			`BridgeRamp{side}`,
			V(40, 1.4, 12),
			V(x, deckY - 1.6, z),
			Color3.fromRGB(112, 100, 78),
			Enum.Material.Ground
		)
		ramp.CFrame = CFrame.new(ramp.Position) * CFrame.Angles(0, 0, math.rad(side * 6))
		ramp.CanCollide = true
	end

	-- A sign at the east end, since nothing else tells you the warren is there.
	local board = slab("BridgeSign", V(0.8, 6, 14), V(eastX + 20, deckY + 4, z + 9), TIMBER, Enum.Material.Wood)
	slab("BridgeSignPost", V(1, 8, 1), V(eastX + 20, deckY, z + 9), TIMBER, Enum.Material.Wood)
	Create("SurfaceGui", {
		Face = Enum.NormalId.Back,
		PixelsPerStud = 26,
		LightInfluence = 0,
		Parent = board,

		Create("TextLabel", {
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = "RABITUZA",
			TextScaled = true,
			TextColor3 = Theme.Color.Accent,
		}),
	})
end

--------------------------------------------------------------------------------

local function build()
	local existing = Workspace:FindFirstChild(FOLDER)
	if existing then
		existing:Destroy()
	end
	folder = Create("Folder", { Name = FOLDER, Parent = Workspace })

	local rng = Random.new(60606)

	-- Clearing floor, slightly proud of the terrain the flattening leaves at
	-- y = 0, so the two never fight for the same pixels.
	slab(
		"Clearing",
		V(WARREN.width, 1, WARREN.depth),
		WARREN.center + V(0, -0.4, 0),
		Color3.fromRGB(112, 132, 84),
		Enum.Material.Grass
	)

	buildMoot(rng)
	for index, spec in MOUNDS do
		buildMound(index, spec, rng)
	end
	buildGardens(rng)
	buildBridge(rng)

	-- Lamps on posts around the clearing, on the same night pass Firstia uses.
	for index = 0, 5 do
		local angle = index / 6 * math.pi * 2
		local at = WARREN.center + V(math.cos(angle) * 44, 0, math.sin(angle) * 50)
		slab(`LampPost{index}`, V(0.8, 11, 0.8), at + V(0, 5.5, 0), TIMBER, Enum.Material.Wood)
		local bulb =
			slab(`Lamp{index}`, V(2.6, 2.6, 2.6), at + V(0, 11.4, 0), Color3.fromRGB(255, 220, 168), Enum.Material.Neon)
		bulb.Shape = Enum.PartType.Ball
		bulb.Transparency = 0.2
		Create("PointLight", { Brightness = 1.5, Range = 30, Color = Color3.fromRGB(255, 208, 156), Parent = bulb })
	end

	print(`[Rabituza] warren ready at {WARREN.center} — {#MOUNDS} burrows, bridge at z={World.RabituzaBridgeZ}`)
end

build()
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="84">
        <Properties>
          <string name="Name">Rewards</string>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Rewards — what a corpse is worth.

	One place that answers "something died; pay for it", so the goblin patrol,
	the Vorpal Bunny and the two night bosses all pay out through the same code
	and the same drop-table shape. Before this, gold didn't exist and drops
	were toasts that vanished — you could kill a hundred goblins and own
	nothing.

	Drop ids are Town material ids on purpose: everything that falls off a
	corpse is something a shopkeeper will buy or a quest will ask for. A drop
	that is only ever inventory clutter is a drop that teaches players to
	ignore loot.

	Gold is a range rather than a constant so two kills of the same thing don't
	read as a vending machine. The ranges are set against Town.SellValues so
	that a boss is worth roughly sixty goblins once drops are sold — which is
	what makes "go and fight the scary thing" the efficient choice as well as
	the interesting one. Change one side of that and check the other.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Town = require(ReplicatedStorage.Shared.Config.Town)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)

local Profile = require(script.Parent.Profile)

local Rewards = {}

--- mobId -> { gold = {low, high}, drops = { {id, chance, count?} } }
local TABLES = {
	goblin = {
		gold = { 6, 14 },
		drops = {
			{ id = "goblin_ear", chance = 0.62 },
			{ id = "goblin_iron", chance = 0.34 },
			{ id = "beast_hide", chance = 0.22 },
		},
	},
	vorpal_bunny = {
		gold = { 620, 880 },
		drops = {
			{ id = "bunny_fang", chance = 1 },
			{ id = "bunny_pelt", chance = 0.85 },
			{ id = "beast_hide", chance = 1, count = 3 },
		},
	},
	lycagon = {
		gold = { 1250, 1700 },
		drops = {
			{ id = "wolf_fang", chance = 1 },
			{ id = "beast_hide", chance = 1, count = 4 },
		},
	},
	setsuna = {
		gold = { 1800, 2300 },
		drops = {
			{ id = "wolf_fang", chance = 0.4 },
			{ id = "bunny_fang", chance = 0.5 },
			{ id = "goblin_iron", chance = 1, count = 8 },
			{ id = "beast_hide", chance = 1, count = 3 },
		},
	},
	wethermon = {
		gold = { 1500, 1950 },
		drops = {
			{ id = "goblin_iron", chance = 1, count = 6 },
			{ id = "beast_hide", chance = 0.5, count = 2 },
		},
	},
}

--[[
	Pays a player for a kill: gold, drops, quest credit and the lifetime count.

	`at` is only used for the sparkle, and is optional — a kill credited from
	somewhere with no world position still pays.
]]
function Rewards.grant(player: Player, mobId: string, at: Vector3?)
	local table_ = TABLES[mobId]
	if not table_ then
		-- Unknown mob still counts toward quests; it just has nothing to drop.
		Profile.recordKill(player, mobId)
		return
	end

	local gold = math.random(table_.gold[1], table_.gold[2])
	Profile.addGold(player, gold)
	Network.Loot:FireClient(player, {
		id = "gold",
		name = `{gold} Gold`,
		rarity = "Common",
		quantity = gold,
		description = "Spends anywhere in Firstia.",
	})

	for _, drop in table_.drops do
		if math.random() < drop.chance then
			local count = drop.count or 1
			local material = Town.Materials[drop.id]
			Profile.addMaterial(player, drop.id, count)

			Network.Loot:FireClient(player, {
				id = drop.id,
				name = material and material.name or drop.id,
				rarity = material and material.rarity or "Common",
				quantity = count,
			})

			if at then
				local rarity = Theme.Rarity[material and material.rarity or "Common"]
				Vfx.sparks(at + Vector3.new(0, 2, 0), 8, rarity.Color)
			end
		end
	end

	-- Last, so a quest that completes on this kill has its materials already
	-- banked when the "objective complete" toast fires.
	Profile.recordKill(player, mobId)
end

return Rewards
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="85">
        <Properties>
          <string name="Name">Roamers</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Roamers — the common enemies that live in the Rampant Forest.

	Goblins, wandering between waypoints, noticing you, closing, and stabbing.
	They exist for three reasons, in order of importance:

	  1. The forest was a walking simulator. A world with one boss in it and
	     nothing else is a corridor with a room at the end.
	  2. They are where a player *practises*. The Vorpal Bunny punishes a
	     mistake with 92 damage; a goblin punishes it with 14. You learn the
	     parry here and spend it there.
	  3. They drop things, which is what makes walking somewhere worthwhile.

	Every attack routes through Combat/Guard, so parry, block and dodge behave
	exactly as they do against the boss. Practising on a goblin has to teach the
	real system or it teaches nothing.

	AI is deliberately simpler than the Vorpal Bunny's — no utility scoring, no
	feints. A common enemy that outthinks you isn't common, it's a boss. What
	they do have is a long readable telegraph, a real recovery window, and the
	sense to run away when they're losing.
]]

local Lighting = game:GetService("Lighting")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Mobs = require(ReplicatedStorage.Shared.Mobs)
local Net = require(ReplicatedStorage.Shared.Mobs.Net)
local Sky = require(ReplicatedStorage.Shared.Config.Sky)
local Network = require(ReplicatedStorage.Shared.Network)
local Rewards = require(script.Parent.Rewards)
local Rig = require(ReplicatedStorage.Shared.Mobs.Rig)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)
local World = require(ReplicatedStorage.Shared.Config.World)

local Assist = require(script.Parent.Combat.Assist)
local Guard = require(script.Parent.Combat.Guard)

local V = Vector3.new

--------------------------------------------------------------------------------
-- Tuning
--------------------------------------------------------------------------------

--- How many roam the forest at once, across all species.
local COUNT = 14

local LEASH = 95

--- Long and obvious. This is the creature players learn to parry on.
local COMMIT_AT = 0.6
local THRUST = 0.2
local REST_MIN, REST_MAX = 0.9, 2.1

--- Runs at this fraction of health, because a thing that fights to the death
--- regardless is a target, not a creature.
local FLEE_BELOW = 0.22

local RESPAWN = 30
local PUNISH = 1.5
local COUNTER = 2.5

--------------------------------------------------------------------------------

local folder = Create("Folder", { Name = "Roamers", Parent = Workspace })
local roamers: { any } = {}

local function groundAt(x: number, z: number): number
	return World.groundHeight(x, z)
end

local function flatten(v: Vector3): Vector3
	local flat = V(v.X, 0, v.Z)
	return if flat.Magnitude < 0.001 then V(0, 0, -1) else flat.Unit
end

local function rootOf(player: Player): BasePart?
	local character = player.Character
	return character and character:FindFirstChild("HumanoidRootPart")
end

--- A spot in the forest that isn't the road, the arena, spawn, or a cliff.
local function findHome(rng: Random): Vector3?
	for _ = 1, 40 do
		local x = rng:NextNumber(World.Forest.MinX + 40, World.Forest.MaxX - 40)
		local z = rng:NextNumber(World.Forest.MinZ + 60, World.Forest.MaxZ - 40)

		if World.isOnRoad(x, z) or not World.isPlantable(x, z) then
			continue
		end
		if (V(x, 0, z) - V(World.Spawn.X, 0, World.Spawn.Z)).Magnitude < 70 then
			continue
		end
		if (V(x, 0, z) - V(World.BunnyArena.X, 0, World.BunnyArena.Z)).Magnitude < World.BunnyArenaRadius + 25 then
			continue
		end
		return V(x, groundAt(x, z), z)
	end
	return nil
end

--------------------------------------------------------------------------------

local function healthBar(model: Model, title: string): (BillboardGui, Frame)
	local gui = Create("BillboardGui", {
		Name = "Vitals",
		Size = UDim2.fromOffset(120, 26),
		StudsOffsetWorldSpace = V(0, 7.6, 0),
		MaxDistance = 110,
		LightInfluence = 0,
		Parent = model.PrimaryPart,

		Create("TextLabel", {
			Name = "Title",
			Size = UDim2.new(1, 0, 0, 13),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = title,
			TextSize = 11,
			TextColor3 = Theme.Color.Text,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.3,
		}),

		Create("Frame", {
			Name = "Track",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, 15),
			Size = UDim2.fromOffset(84, 4),
			BackgroundColor3 = Theme.Color.Void,
			BackgroundTransparency = 0.25,
			BorderSizePixel = 0,

			Create("UICorner", { CornerRadius = Theme.Radius.pill }),
		}),
	})

	local fill = Create("Frame", {
		Name = "Fill",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.Color.Health,
		BorderSizePixel = 0,
		Parent = gui.Track,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	-- Hidden until hurt: a forest full of health bars is a HUD, not a forest.
	gui.Enabled = false
	return gui, fill
end

--------------------------------------------------------------------------------

--[[
	Species.

	The forest had exactly one thing in it, which is the difference between a
	world and a corridor with a goblin in it. These four share one AI because
	the four beats of approach / telegraph / strike / recover are the game's
	whole combat grammar — what changes is the *numbers*, and numbers are what
	make an encounter read differently.

	    scout   fast, fragile, closes before you are ready
	    goblin  the baseline everything else is judged against
	    brute   slow and heavy; you cannot out-trade it, only out-time it
	    wolf    lesser kin of the Lycagon, night only, and it hits like it

	The wolf reuses the Lycagon's model at 42% scale rather than getting one of
	its own. That is not a shortcut — a smaller version of the thing that hunts
	this treeline is exactly what a lesser wolf *is*, and seeing one at night
	before you ever meet the Lycagon does more setup than any dialogue could.

	`night` gates spawning to the dark. `weight` is relative spawn chance.
]]
local SPECIES = {
	{
		id = "goblin_scout",
		mob = "goblin",
		anim = "goblin",
		name = "Goblin Scout",
		scale = 0.85,
		weight = 26,
		health = 80,
		damage = 10,
		walk = 12,
		chase = 20,
		notice = 54,
		reach = 8,
		windup = 0.62,
		recover = 0.85,
		xp = 26,
	},
	{
		id = "goblin",
		mob = "goblin",
		anim = "goblin",
		name = "Goblin",
		scale = 1,
		weight = 34,
		health = 130,
		damage = 14,
		walk = 9,
		chase = 15,
		notice = 42,
		reach = 8.5,
		windup = 0.85,
		recover = 1.0,
		xp = 34,
	},
	{
		id = "goblin_brute",
		mob = "goblin",
		anim = "goblin",
		name = "Goblin Brute",
		scale = 1.3,
		weight = 16,
		health = 260,
		damage = 26,
		walk = 6,
		chase = 11,
		notice = 38,
		reach = 10.5,
		--- Long enough that you can always read it, hard enough that you must.
		windup = 1.15,
		recover = 1.35,
		xp = 68,
	},
	{
		id = "dire_wolf",
		mob = "lycagon",
		anim = "lycagon",
		name = "Dire Wolf",
		scale = 0.42,
		weight = 24,
		night = true,
		health = 170,
		damage = 22,
		walk = 13,
		chase = 23,
		notice = 62,
		reach = 9,
		windup = 0.58,
		recover = 0.8,
		xp = 58,
	},
}

--- Picks a species for the current hour. Night doubles the wolves' weight on
--- top of gating them, so the forest genuinely changes character after dark
--- rather than just adding one more thing to it.
local function pickSpecies(rng: Random)
	local night = Sky.isNight(Lighting.ClockTime)
	local pool, total = {}, 0

	for _, species in SPECIES do
		if species.night and not night then
			continue
		end
		local weight = species.weight * (if species.night then 2 else 1)
		table.insert(pool, { species = species, weight = weight })
		total += weight
	end

	local pick = rng:NextNumber() * total
	for _, entry in pool do
		pick -= entry.weight
		if pick <= 0 then
			return entry.species
		end
	end
	return SPECIES[2]
end

local function spawnOne(rng: Random)
	local home = findHome(rng)
	if not home then
		return
	end

	local species = pickSpecies(rng)
	local model = Mobs.build(species.mob, CFrame.new(home))
	if not model then
		return
	end
	model.Parent = folder

	Net.setRig(model, species.anim, species.scale)
	local gui, fill = healthBar(model, species.name)

	table.insert(roamers, {
		model = model,
		rig = Rig.bind(model),
		gui = gui,
		fill = fill,
		home = home,
		position = home,
		facing = V(0, 0, -1),
		species = species,
		health = species.health,
		state = "idle",
		clock = 0,
		rest = rng:NextNumber(REST_MIN, REST_MAX),
		waypoint = home,
		target = nil,
		lookYaw = 0,
		hitstop = 0,
		committed = nil,
		struck = false,
		staggerFor = 1.4,
	})
end

local function despawn(entry, index: number)
	entry.gui:Destroy()
	local corpse = entry.model
	table.remove(roamers, index)

	task.spawn(function()
		for _ = 1, 26 do
			if not corpse.Parent then
				return
			end
			corpse:PivotTo(corpse:GetPivot() * CFrame.new(0, -0.09, 0))
			task.wait(0.03)
		end
		corpse:Destroy()
	end)
end

--------------------------------------------------------------------------------
-- Loot
--------------------------------------------------------------------------------

--- Dropping something is what makes walking over here worth the walk.
local function rollLoot(player: Player, at: Vector3, species)
	-- Gold, materials, quest credit and the lifetime kill count all come from
	-- Rewards, so a goblin pays out of the same table the bosses do.
	-- Every species pays out of the goblin table for now; only the XP and the
	-- name differ, so a Brute is worth beating without needing its own drops.
	Rewards.grant(player, "goblin", at)
	Network.EnemyDefeated:FireClient(player, species.id, species.xp)
end

--------------------------------------------------------------------------------
-- AI
--------------------------------------------------------------------------------

local function nearestPlayer(from: Vector3, range: number): (Player?, Vector3?)
	local best, bestDistance = nil, range
	for _, player in Players:GetPlayers() do
		local root = rootOf(player)
		if root then
			local distance = (root.Position - from).Magnitude
			if distance < bestDistance then
				best, bestDistance = player, distance
			end
		end
	end
	return best, best and rootOf(best) and rootOf(best).Position
end

local function setState(entry, state: string)
	entry.state = state
	entry.clock = 0
end

local function stepOne(entry, dt: number)
	if entry.hitstop > 0 then
		entry.hitstop -= dt
		return
	end

	entry.clock += dt

	local player, playerPosition = nearestPlayer(entry.position, if entry.target then LEASH else entry.species.notice)
	entry.target = player

	-- Head tracking, always, whatever it is doing.
	if playerPosition then
		local toPlayer = flatten(playerPosition - entry.position)
		local cross = entry.facing.X * toPlayer.Z - entry.facing.Z * toPlayer.X
		entry.lookYaw = math.deg(math.atan2(cross, math.clamp(entry.facing:Dot(toPlayer), -1, 1)))
	else
		entry.lookYaw = 0
	end

	local fleeing = entry.health / entry.species.health <= FLEE_BELOW

	if entry.state == "stagger" then
		-- A higher parry rank buys a longer opening here too, exactly as it
		-- does against the boss. Practising has to teach the real numbers.
		if entry.clock >= (entry.staggerFor or 1.4) then
			setState(entry, "idle")
		end
	elseif entry.state == "windup" then
		if not entry.committed and entry.clock >= entry.species.windup * COMMIT_AT then
			entry.committed = if playerPosition then flatten(playerPosition - entry.position) else entry.facing
			Audio.playAt("growl", entry.position)
		end
		if playerPosition and not entry.committed then
			entry.facing = flatten(playerPosition - entry.position)
		end
		if entry.clock >= entry.species.windup then
			entry.struck = false
			setState(entry, "thrust")
		end
	elseif entry.state == "thrust" then
		local direction = entry.committed or entry.facing
		entry.position += direction * 22 * dt
		entry.position = V(entry.position.X, groundAt(entry.position.X, entry.position.Z), entry.position.Z)

		if not entry.struck and playerPosition then
			local flat = V(playerPosition.X - entry.position.X, 0, playerPosition.Z - entry.position.Z)
			if flat.Magnitude <= entry.species.reach then
				entry.struck = true
				local outcome, staggerScale =
					Guard.resolve(player, { damage = entry.species.damage, from = entry.position })
				Assist.finish(player, outcome == "parried")
				if outcome == "parried" then
					entry.staggerFor = 1.4 * (if staggerScale > 0 then staggerScale else 1)
					setState(entry, "stagger")
					entry.hitstop = Encounters.Hitstop.parry
					entry.committed = nil
					Vfx.sparks(entry.position + V(0, 4, 0), 20, Color3.fromRGB(255, 240, 200))
					return
				end
			end
		end

		if entry.clock >= THRUST then
			entry.committed = nil
			setState(entry, "recover")
		end
	elseif entry.state == "recover" then
		if entry.clock >= entry.species.recover then
			entry.rest = REST_MIN + math.random() * (REST_MAX - REST_MIN)
			setState(entry, "idle")
		end
	elseif fleeing and playerPosition then
		-- Running. Not bravely.
		local away = -flatten(playerPosition - entry.position)
		entry.position += away * entry.species.chase * 1.1 * dt
		entry.position = V(entry.position.X, groundAt(entry.position.X, entry.position.Z), entry.position.Z)
		entry.facing = away
		entry.state = "walk"
	elseif playerPosition then
		-- Engaged: close, then strike when in reach and rested.
		local toPlayer = playerPosition - entry.position
		local flat = V(toPlayer.X, 0, toPlayer.Z)
		entry.facing = flatten(toPlayer)

		if flat.Magnitude > entry.species.reach - 1 then
			entry.position += entry.facing * entry.species.chase * dt
			entry.position = V(entry.position.X, groundAt(entry.position.X, entry.position.Z), entry.position.Z)
			entry.state = "walk"
		elseif entry.clock >= entry.rest then
			entry.committed = nil
			setState(entry, "windup")
		else
			entry.state = "idle"
		end
	else
		-- Unbothered: wanders between points near home.
		local toWaypoint = entry.waypoint - entry.position
		local flat = V(toWaypoint.X, 0, toWaypoint.Z)

		if flat.Magnitude < 4 then
			local angle = math.random() * math.pi * 2
			local reach = 12 + math.random() * 26
			local wx = entry.home.X + math.cos(angle) * reach
			local wz = entry.home.Z + math.sin(angle) * reach
			entry.waypoint = V(wx, groundAt(wx, wz), wz)
			entry.state = "idle"
		else
			entry.facing = flatten(toWaypoint)
			entry.position += entry.facing * entry.species.walk * dt
			entry.position = V(entry.position.X, groundAt(entry.position.X, entry.position.Z), entry.position.Z)
			entry.state = "walk"
		end
	end

	local phase = if entry.state == "windup" then math.clamp(entry.clock / entry.species.windup, 0, 1) else 0
	--[[
		Each species animates with its own rig and at its own scale.

		`scale` here multiplies the rig's animated squash rather than replacing
		it — a dire wolf still stretches into its lunge, it just does all of it
		at 42% of a Lycagon's size. Overwriting the animation's scale instead
		would give the smaller mobs a rigid, sliding read.
	]]
	-- Published rather than posed: the client animates and interpolates.
	Net.publish(
		entry.model,
		CFrame.lookAt(entry.position, entry.position + entry.facing),
		entry.state,
		phase,
		{ lookYaw = entry.lookYaw }
	)
end

--------------------------------------------------------------------------------

RunService.Heartbeat:Connect(function(dt)
	for index = #roamers, 1, -1 do
		local entry = roamers[index]
		if not entry.model.Parent then
			table.remove(roamers, index)
			continue
		end
		local ok, err = pcall(stepOne, entry, dt)
		if not ok then
			warn(`[Roamers] {err}`)
		end
	end
end)

--------------------------------------------------------------------------------
-- Taking damage
--------------------------------------------------------------------------------

Network.Attack.OnServerEvent:Connect(function(player, comboIndex)
	if typeof(comboIndex) ~= "number" or Guard.inRecovery(player) then
		return
	end

	local root = rootOf(player)
	if not root then
		return
	end

	local step = Encounters.comboStep(comboIndex)
	local look = flatten(root.CFrame.LookVector)

	local best, bestIndex, bestDistance = nil, nil, Encounters.PlayerAttack.range
	for index, entry in roamers do
		local toEntry = entry.position - root.Position
		local flat = V(toEntry.X, 0, toEntry.Z)
		if flat.Magnitude >= bestDistance then
			continue
		end
		local angle = math.deg(math.acos(math.clamp(look:Dot(flatten(flat)), -1, 1)))
		if angle > Encounters.PlayerAttack.arc then
			continue
		end
		best, bestIndex, bestDistance = entry, index, flat.Magnitude
	end

	if not best then
		return
	end

	local multiplier = if best.state == "stagger" then COUNTER elseif best.state == "recover" then PUNISH else 1
	local crit = math.random() < Encounters.PlayerAttack.critChance
	local dealt = math.floor(step.damage * multiplier * (if crit then Encounters.PlayerAttack.critMultiplier else 1))

	best.health = math.max(best.health - dealt, 0)
	best.hitstop = if step.knockback >= 30 or multiplier > 1 then Encounters.Hitstop.heavy else Encounters.Hitstop.light
	best.position += flatten(best.position - root.Position) * step.knockback * 0.06

	best.gui.Enabled = true
	best.fill.Size = UDim2.fromScale(best.health / best.species.health, 1)

	local at = best.position + V(0, 4, 0)
	Vfx.sparks(at, if multiplier > 1 then 20 else 9, Color3.fromRGB(255, 226, 170))
	Vfx.slash(at, look, 5.5, Color3.fromRGB(255, 240, 210))
	Audio.playAt(if multiplier > 1 then "hitHeavy" else "hitLight", best.position)

	Network.BossDamaged:FireClient(player, dealt, if crit or multiplier > 1 then "crit" else "normal", at)
	Network.Impact:FireClient(player, if multiplier > 1 then "heavy" else "light")

	if best.health <= 0 then
		Vfx.landing(best.position, 0.9)
		Audio.playAt("defeat", best.position)
		rollLoot(player, best.position, best.species)
		despawn(best, bestIndex)

		task.delay(RESPAWN, function()
			spawnOne(Random.new(os.clock() * 1000 // 1))
		end)
	end
end)

--------------------------------------------------------------------------------

local rng = Random.new(World.Seed + 4242)
for _ = 1, COUNT do
	spawnOne(rng)
end
print(`[Roamers] {#roamers} goblins in the Rampant Forest`)
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="86">
        <Properties>
          <string name="Name">Tomb</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	The Tomb — Wethermon's, built around him.

	He has been "the Tombguard" since he was written and there has never been a
	tomb. That is a real gap and not a cosmetic one: a boss standing in an open
	field is a boss with no reason to be there, and "guard" is a job that needs
	a thing to guard.

	IT IS THE FIRST ENCLOSED SPACE IN THE GAME. Everything until now is
	outdoors — forest, road, plaza, arena floor. A corridor does things none of
	those can: it takes the camera off you, it removes the option to back away
	forever, and it makes a torch matter. The approach is deliberately long and
	narrow before it opens, because the hall only feels large if you arrived
	through something small.

	LAID OUT AS A SEQUENCE, not a room:

	    forecourt   broken steps down between two shattered guardian statues
	    descent     a stair into the hill, daylight behind you
	    corridor    68 studs, low ceiling, torches at intervals
	    antechamber sarcophagi, a collapsed side passage, one door
	    hall        pillars, Wethermon, and the thing he is standing on

	Built around Wethermon's existing arena rather than moving him, so the
	fight's tuning, leash and arena maths are all untouched.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Terrain = game:GetService("Workspace").Terrain
local Workspace = game:GetService("Workspace")

local Create = require(ReplicatedStorage.Shared.Util.Create)
local Detail = require(ReplicatedStorage.Shared.World.Detail)
local Render = require(ReplicatedStorage.Shared.Util.Render)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local World = require(ReplicatedStorage.Shared.Config.World)

local V = Vector3.new

local FOLDER = "Tomb"

--- Must match Boss/Wethermon's ARENA. The hall is centred on him.
local ARENA = V(214, 0, 62)

--- The hall floor sits below the terrain, so the whole thing is genuinely
--- underground rather than a shed on a hill.
local FLOOR = -26
local HALL_RADIUS = 62
local CEILING = 34

local STONE = Color3.fromRGB(118, 114, 104)
local STONE_DARK = Color3.fromRGB(84, 81, 75)
local STONE_PALE = Color3.fromRGB(146, 142, 130)
local TEAL = Color3.fromRGB(96, 196, 188)

local folder: Folder

local function slab(name: string, size: Vector3, at: Vector3, color: Color3, material: Enum.Material?): BasePart
	local part = Create("Part", {
		Name = name,
		Anchored = true,
		Size = size,
		Position = at,
		Color = color,
		Material = material or Enum.Material.Slate,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
		Parent = folder,
	})
	-- Shadows come from mass; trim and clutter stop paying for them.
	Render.applyShadow(part)
	return part
end

--- A wall-mounted torch that actually lights its stretch of corridor.
local function sconce(at: Vector3, rng: Random)
	slab("Bracket", V(1, 1.6, 1), at, Color3.fromRGB(52, 50, 48), Enum.Material.Metal)
	local flame = slab("Flame", V(1.5, 2.2, 1.5), at + V(0, 1.8, 0), Color3.fromRGB(120, 240, 226), Enum.Material.Neon)
	flame.Transparency = 0.25

	Create("Fire", {
		Heat = 6,
		Size = 4,
		Color = TEAL,
		SecondaryColor = Color3.fromRGB(40, 120, 140),
		Parent = flame,
	})
	Create("PointLight", {
		Brightness = 2.4 + rng:NextNumber(-0.3, 0.3),
		Range = 34,
		Color = TEAL,
		Parent = flame,
	})
end

--------------------------------------------------------------------------------
-- The approach
--------------------------------------------------------------------------------

--[[
	Corridor and stair, running in along +X toward the hall.

	The corridor's mouth is at the hall's edge; everything is measured back from
	there so the tomb always meets the hall exactly, whatever the hall's radius
	becomes.
]]
local CORRIDOR_LENGTH = 68
local CORRIDOR_WIDTH = 16
local DESCENT_LENGTH = 54

local function buildApproach(rng: Random)
	local mouth = ARENA - V(HALL_RADIUS, 0, 0)
	local corridorMidX = mouth.X - CORRIDOR_LENGTH / 2

	-- Corridor: floor, ceiling, two walls.
	slab(
		"CorridorFloor",
		V(CORRIDOR_LENGTH, 2, CORRIDOR_WIDTH),
		V(corridorMidX, FLOOR - 1, ARENA.Z),
		STONE,
		Enum.Material.Slate
	).CanCollide =
		true
	slab(
		"CorridorCeiling",
		V(CORRIDOR_LENGTH, 2, CORRIDOR_WIDTH + 6),
		V(corridorMidX, FLOOR + 17, ARENA.Z),
		STONE_DARK,
		Enum.Material.Slate
	).CanCollide =
		true

	for side = -1, 1, 2 do
		slab(
			`CorridorWall{side}`,
			V(CORRIDOR_LENGTH, 18, 3),
			V(corridorMidX, FLOOR + 9, ARENA.Z + side * (CORRIDOR_WIDTH / 2 + 1.5)),
			Detail.vary(STONE, rng, 0.03),
			Enum.Material.Brick
		).CanCollide =
			true

		-- Pilasters every twelve studs: a plain 68-stud wall reads as a hallway
		-- in an office block.
		for index = 0, 5 do
			slab(
				`Pilaster{side}{index}`,
				V(2.4, 18, 1.6),
				V(mouth.X - 6 - index * 12, FLOOR + 9, ARENA.Z + side * (CORRIDOR_WIDTH / 2 - 0.4)),
				STONE_PALE,
				Enum.Material.Brick
			)
		end
	end

	-- Torches, alternating sides so the light staggers down the corridor.
	for index = 0, 4 do
		local side = if index % 2 == 0 then 1 else -1
		sconce(V(mouth.X - 8 - index * 14, FLOOR + 11, ARENA.Z + side * (CORRIDOR_WIDTH / 2 - 1)), rng)
	end

	----------------------------------------------------------------------
	-- Descending stair, from the surface down to the corridor
	----------------------------------------------------------------------

	local stairTopX = mouth.X - CORRIDOR_LENGTH - DESCENT_LENGTH
	local surfaceY = World.groundHeight(stairTopX, ARENA.Z)
	local steps = 26

	for index = 0, steps do
		local t = index / steps
		local x = stairTopX + t * DESCENT_LENGTH
		local y = surfaceY + (FLOOR - surfaceY) * t
		slab(
			`Step{index}`,
			V(DESCENT_LENGTH / steps + 1.5, 2.4, CORRIDOR_WIDTH),
			V(x, y, ARENA.Z),
			Detail.vary(STONE, rng, 0.04),
			Enum.Material.Slate
		).CanCollide =
			true

		-- Walls follow the stair down, so you are in a cut rather than on a ramp.
		if index % 2 == 0 then
			for side = -1, 1, 2 do
				slab(
					`StairWall{index}{side}`,
					V(DESCENT_LENGTH / steps * 2 + 1, 22, 3),
					V(x, y + 12, ARENA.Z + side * (CORRIDOR_WIDTH / 2 + 1.5)),
					Detail.vary(STONE_DARK, rng, 0.03),
					Enum.Material.Brick
				).CanCollide =
					true
			end
		end
		if index % 8 == 4 then
			sconce(V(x, y + 12, ARENA.Z + (CORRIDOR_WIDTH / 2 - 1)), rng)
		end
	end

	----------------------------------------------------------------------
	-- Forecourt: the bit you see from outside
	----------------------------------------------------------------------

	local court = V(stairTopX - 22, surfaceY, ARENA.Z)
	slab("Forecourt", V(44, 2, 46), court + V(0, -1, 0), STONE_PALE, Enum.Material.Slate).CanCollide = true

	-- Two broken guardians flanking the entrance. Deliberately asymmetric: one
	-- has lost its head, the other is intact. Ruins are not tidy.
	for side = -1, 1, 2 do
		local at = court + V(8, 0, side * 16)
		slab(`GuardianBase{side}`, V(7, 3, 7), at + V(0, 1.5, 0), STONE_DARK, Enum.Material.Rock)
		slab(`GuardianBody{side}`, V(5, 13, 4.5), at + V(0, 9.5, 0), STONE, Enum.Material.Rock)
		slab(`GuardianArms{side}`, V(8.5, 3.5, 4), at + V(0, 13, 0), STONE, Enum.Material.Rock)
		if side == 1 then
			slab("GuardianHead", V(3.6, 4, 3.6), at + V(0, 18, 0), STONE, Enum.Material.Rock)
			slab("GuardianCrown", V(4.6, 1, 4.6), at + V(0, 20.3, 0), STONE_DARK, Enum.Material.Rock)
		else
			-- Its head, on the floor where it fell.
			local head = slab("FallenHead", V(3.6, 4, 3.6), court + V(-6, 2, side * 9), STONE, Enum.Material.Rock)
			head.CFrame = CFrame.new(head.Position) * CFrame.Angles(math.rad(74), math.rad(28), 0)
		end
	end

	-- Lintel over the stair mouth, cracked in two.
	for side = -1, 1, 2 do
		local piece = slab(
			`Lintel{side}`,
			V(6, 4, 12),
			V(stairTopX - 2, surfaceY + 15, ARENA.Z + side * 6.5),
			STONE_DARK,
			Enum.Material.Rock
		)
		piece.CFrame = CFrame.new(piece.Position) * CFrame.Angles(math.rad(side * 5), 0, math.rad(side * 3))
	end
end

--------------------------------------------------------------------------------
-- The hall
--------------------------------------------------------------------------------

local function buildHall(rng: Random)
	slab(
		"HallFloor",
		V(HALL_RADIUS * 2 + 20, 3, HALL_RADIUS * 2 + 20),
		ARENA + V(0, FLOOR - 1.5, 0),
		STONE,
		Enum.Material.Slate
	).CanCollide =
		true

	-- Inlaid ring around the middle, so the fighting space has an edge you can
	-- read without looking down.
	local ring = slab(
		"Inlay",
		V(HALL_RADIUS * 1.5, 0.2, HALL_RADIUS * 1.5),
		ARENA + V(0, FLOOR + 0.1, 0),
		TEAL,
		Enum.Material.Neon
	)
	ring.Transparency = 0.72

	slab(
		"HallCeiling",
		V(HALL_RADIUS * 2 + 20, 4, HALL_RADIUS * 2 + 20),
		ARENA + V(0, FLOOR + CEILING, 0),
		STONE_DARK,
		Enum.Material.Slate
	).CanCollide =
		true

	-- Walls: sixteen faceted segments rather than four flat ones.
	local segments = 16
	for index = 0, segments - 1 do
		local angle = index / segments * math.pi * 2
		local at = ARENA
			+ V(math.cos(angle) * (HALL_RADIUS + 4), FLOOR + CEILING / 2, math.sin(angle) * (HALL_RADIUS + 4))
		-- Leave the corridor's mouth open.
		if math.abs(math.atan2(math.sin(angle), math.cos(angle)) - math.pi) < 0.26 then
			continue
		end
		local block = slab(
			`HallWall{index}`,
			V((HALL_RADIUS + 4) * 2 * math.pi / segments * 1.1, CEILING, 6),
			at,
			Detail.vary(STONE, rng, 0.03),
			Enum.Material.Brick
		)
		block.CFrame = CFrame.new(at) * CFrame.Angles(0, -angle, 0)
		block.CanCollide = true
	end

	-- Pillars in a ring, well inside the wall so they break sightlines without
	-- ever blocking the arena's centre.
	for index = 0, 7 do
		local angle = index / 8 * math.pi * 2 + 0.4
		local at = ARENA + V(math.cos(angle) * (HALL_RADIUS - 16), 0, math.sin(angle) * (HALL_RADIUS - 16))

		slab(`PillarBase{index}`, V(9, 2.4, 9), at + V(0, FLOOR + 1.2, 0), STONE_DARK, Enum.Material.Rock).CanCollide =
			true
		local shaft = slab(
			`Pillar{index}`,
			V(14, CEILING - 6, 14),
			at + V(0, FLOOR + CEILING / 2, 0),
			Detail.vary(STONE_PALE, rng, 0.03),
			Enum.Material.Marble
		)
		shaft.Shape = Enum.PartType.Cylinder
		shaft.CFrame = CFrame.new(shaft.Position) * CFrame.Angles(0, 0, math.rad(90))
		shaft.CanCollide = true
		slab(`PillarCap{index}`, V(11, 2.6, 11), at + V(0, FLOOR + CEILING - 3, 0), STONE_DARK, Enum.Material.Rock)

		sconce(at + V(0, FLOOR + 13, 0), rng)
	end

	-- The dais he stands on. Three shallow steps, so the centre of the room is
	-- visibly the point of the room.
	for step = 1, 3 do
		slab(
			`Dais{step}`,
			V(46 - step * 8, 1.2, 46 - step * 8),
			ARENA + V(0, FLOOR + 0.6 + (step - 1) * 1.2, 0),
			STONE_PALE,
			Enum.Material.Marble
		).CanCollide =
			true
	end

	-- Sarcophagi around the edge: what he is actually guarding.
	for index = 0, 5 do
		local angle = index / 6 * math.pi * 2 + 0.9
		local at = ARENA + V(math.cos(angle) * (HALL_RADIUS - 34), 0, math.sin(angle) * (HALL_RADIUS - 34))
		local box = slab(
			`Sarcophagus{index}`,
			V(11, 4.5, 5.5),
			at + V(0, FLOOR + 2.2, 0),
			Detail.vary(STONE_DARK, rng, 0.03),
			Enum.Material.Rock
		)
		box.CFrame = CFrame.new(box.Position) * CFrame.Angles(0, -angle, 0)
		box.CanCollide = true

		local lid =
			slab(`SarcophagusLid{index}`, V(12, 1.2, 6.5), at + V(0, FLOOR + 5, 0), STONE_PALE, Enum.Material.Marble)
		-- One lid is off, slid half onto the floor. It is the detail that makes
		-- the other five read as closed rather than as decoration.
		if index == 2 then
			lid.CFrame = CFrame.new(at + V(4, FLOOR + 5.2, 3)) * CFrame.Angles(0, -angle + 0.5, math.rad(6))
		else
			lid.CFrame = CFrame.new(lid.Position) * CFrame.Angles(0, -angle, 0)
		end
	end

	-- Rubble where the ceiling has come down, and shafts of light through it.
	for index = 1, 7 do
		local angle = rng:NextNumber(0, math.pi * 2)
		local distance = rng:NextNumber(HALL_RADIUS * 0.5, HALL_RADIUS * 0.85)
		local at = ARENA + V(math.cos(angle) * distance, 0, math.sin(angle) * distance)
		local chunk = slab(
			`Rubble{index}`,
			V(rng:NextNumber(3, 7), rng:NextNumber(2, 5), rng:NextNumber(3, 7)),
			at + V(0, FLOOR + 2, 0),
			Detail.vary(STONE_DARK, rng, 0.05),
			Enum.Material.Rock
		)
		chunk.CFrame = CFrame.new(chunk.Position)
			* CFrame.Angles(rng:NextNumber(-0.4, 0.4), rng:NextNumber(0, 6), rng:NextNumber(-0.4, 0.4))
	end

	--[[
		Two collapsed holes in the ceiling with daylight coming through.

		The only natural light in the tomb, and the only thing that tells you
		how deep you are. Modelled as a bright translucent column rather than a
		real beam because a visible shaft reads at any camera angle and costs
		one part.
	]]
	for _, offset in { V(-30, 0, -22), V(26, 0, 30) } do
		local at = ARENA + offset
		slab(
			"CeilingHole",
			V(16, 4, 16),
			at + V(0, FLOOR + CEILING, 0),
			Color3.fromRGB(20, 20, 22),
			Enum.Material.Slate
		)
		local beam = slab(
			"Shaft",
			V(13, CEILING, 13),
			at + V(0, FLOOR + CEILING / 2, 0),
			Color3.fromRGB(216, 232, 240),
			Enum.Material.Neon
		)
		beam.Transparency = 0.88
		beam.CanCollide = false
		Create("PointLight", { Brightness = 1.4, Range = 60, Color = Color3.fromRGB(214, 230, 244), Parent = beam })
	end
end

--------------------------------------------------------------------------------

local function build()
	local existing = Workspace:FindFirstChild(FOLDER)
	if existing then
		existing:Destroy()
	end
	folder = Create("Folder", { Name = FOLDER, Parent = Workspace })

	--[[
		Carve the hill out first.

		The tomb sits inside generated terrain, so without this every room is
		packed solid with rock and the player walks into a hillside. Cleared as
		two blocks — the hall, and the corridor-and-stair run reaching back to
		the surface — with generous margins so no voxel edge pokes through a
		wall.
	]]
	local mouthX = ARENA.X - HALL_RADIUS
	local runLength = CORRIDOR_LENGTH + DESCENT_LENGTH + 60
	Terrain:FillBlock(
		CFrame.new(ARENA.X, FLOOR + CEILING / 2 - 6, ARENA.Z),
		V(HALL_RADIUS * 2 + 44, CEILING + 42, HALL_RADIUS * 2 + 44),
		Enum.Material.Air
	)
	Terrain:FillBlock(
		CFrame.new(mouthX - runLength / 2 + 10, FLOOR + 14, ARENA.Z),
		V(runLength, 78, CORRIDOR_WIDTH + 26),
		Enum.Material.Air
	)

	local rng = Random.new(40404)
	buildHall(rng)
	buildApproach(rng)

	-- A sign at the forecourt, since a hole in a hillside explains nothing.
	local mouth = ARENA - V(HALL_RADIUS, 0, 0)
	local courtX = mouth.X - CORRIDOR_LENGTH - DESCENT_LENGTH - 22
	local surfaceY = World.groundHeight(courtX, ARENA.Z)
	local board = slab("TombSign", V(1, 6, 20), V(courtX - 18, surfaceY + 9, ARENA.Z), STONE_DARK, Enum.Material.Rock)
	slab("TombSignPost", V(1.6, 9, 1.6), V(courtX - 18, surfaceY + 4.5, ARENA.Z), STONE_DARK, Enum.Material.Rock)
	Create("SurfaceGui", {
		Face = Enum.NormalId.Left,
		PixelsPerStud = 24,
		LightInfluence = 0,
		Parent = board,

		Create("TextLabel", {
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = "THE TOMB",
			TextScaled = true,
			TextColor3 = TEAL,
		}),
	})

	print(`[Tomb] built around {ARENA} — floor y={FLOOR}, hall r={HALL_RADIUS}`)
end

build()
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="87">
        <Properties>
          <string name="Name">Town</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Town — Firstia, built and inhabited.

	This is the town the player was previously only allowed to look at. It
	builds the street, the plaza and the fountain, hollows out five enterable
	buildings, stands eight people in them, and gives every one of those people
	somewhere else to be at nine in the evening.

	WHAT MAKES A TOWN FEEL ALIVE, in the order it actually matters:

	1. People are somewhere *specific*, and it changes. The smith is at his
	   anvil at ten and walking home at seven. You can arrive at the wrong hour
	   and find a shut shop. That single fact does more than any amount of
	   idle animation.
	2. Lights come on. The lanterns lit at dusk are the cheapest possible
	   signal that the place is maintained by somebody.
	3. Interiors are real. A door you can walk through into a room with a
	   counter in it beats a beautifully-textured facade every time, because
	   the facade is a lie you discover by walking at it.

	Everything here is driven from Config/Town, so moving a shop or rewriting
	a shopkeeper's day is a data edit.

	Geometry convention matches the rest of the world: -Z is north, floors sit
	at y = 0.
]]

local Lighting = game:GetService("Lighting")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Build = require(ReplicatedStorage.Shared.Util.Build)
local Crafting = require(ReplicatedStorage.Shared.Config.Crafting)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Scale = require(ReplicatedStorage.Shared.Config.Scale)
local Detail = require(ReplicatedStorage.Shared.World.Detail)
local Render = require(ReplicatedStorage.Shared.Util.Render)
local Network = require(ReplicatedStorage.Shared.Network)
local Mobs = require(ReplicatedStorage.Shared.Mobs)
local Sky = require(ReplicatedStorage.Shared.Config.Sky)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Town = require(ReplicatedStorage.Shared.Config.Town)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)

local Profile = require(script.Parent.Profile)

local V = Vector3.new

local FOLDER = "FirstiaTown"

local STONE = Color3.fromRGB(150, 143, 128)
local STONE_DARK = Color3.fromRGB(112, 106, 94)
local COBBLE = Color3.fromRGB(126, 121, 112)
local PLASTER = Color3.fromRGB(206, 196, 176)
local TIMBER = Color3.fromRGB(88, 64, 44)
--- Rendering the town showed it reading almost entirely red — two tints were
--- close enough to the third to be the same colour at distance. Wider spread,
--- with the slate blue and the weathered green pulling hard away from the
--- terracotta so a roofline has actual variety in it.
local ROOFS = {
	Color3.fromRGB(142, 76, 56),
	Color3.fromRGB(84, 96, 112),
	Color3.fromRGB(96, 104, 82),
	Color3.fromRGB(120, 92, 72),
	Color3.fromRGB(158, 116, 74),
}

--- How close you must be for G to reach someone.
local TALK_RANGE = 14
--- NPC walk speed, studs per second. Slower than the player on purpose: you
--- should always be able to catch the smith on his way home.
local WALK_SPEED = 7

local folder: Folder
local lanterns: { { light: PointLight, bulb: BasePart } } = {}
--- Hearth and forge lights, breathed rather than switched.
local fires: { PointLight } = {}
local residents: { [string]: any } = {}

--------------------------------------------------------------------------------
-- Geometry
--------------------------------------------------------------------------------

local function slab(
	name: string,
	size: Vector3,
	at: Vector3,
	color: Color3,
	material: Enum.Material?,
	parent: Instance?
)
	local part = Create("Part", {
		Name = name,
		Anchored = true,
		Size = size,
		Position = at,
		Color = color,
		Material = material or Enum.Material.Slate,
		TopSurface = Enum.SurfaceType.Smooth,
		BottomSurface = Enum.SurfaceType.Smooth,
		Parent = parent or folder,
	})
	-- Shadows come from mass; trim and clutter stop paying for them.
	Render.applyShadow(part)
	return part
end

--[[
	One building: four walls with a gap for the door, a floor, a roof, a lit
	sign, and a counter.

	The doorway is cut by splitting the wall it sits in into two panels with a
	lintel over the gap, rather than by leaving the wall out — a hole with a
	header over it reads as architecture; a missing wall reads as an unfinished
	model.
]]
local function buildBuilding(spec)
	local model = Create("Model", { Name = spec.id, Parent = folder })
	local center = spec.center
	local half = spec.size * 0.5
	local height = spec.size.Y
	local wall = 2

	--[[
		The floor sits just *under* the threshold, not on top of it.

		It used to be centred at +0.5 with a height of 1, so its top face was a
		full stud above the plaza outside — a kerb at every shop door, with the
		bottom stud of the doorway opening blocked by the slab's own edge, and
		7.5 studs of `Scale.DOORWAY` reduced to 6.5 of actual headroom because
		the floor had eaten the difference. Walls start at y = 0, so the floor
		was also standing a stud proud of its own walls.

		0.15 is the same threshold the causeway uses: enough that the interior
		never z-fights the plaza slab beneath it (top face 0.0), far too little
		to read or trip on.
	]]
	slab("Floor", V(spec.size.X, 1, spec.size.Z), center + V(0, -0.35, 0), STONE_DARK, Enum.Material.Slate, model)

	-- Which axis the door is on decides which wall gets split.
	local doorOnX = math.abs(spec.door.X) > 0.5
	--- A door a person walks through, not a cart entrance.
	local doorWidth = Scale.DOOR_WIDTH

	local function wallPanel(name: string, size: Vector3, offset: Vector3)
		local part = slab(name, size, center + offset + V(0, height / 2, 0), PLASTER, Enum.Material.Concrete, model)
		part.CanCollide = true
		return part
	end

	if doorOnX then
		local side = math.sign(spec.door.X)
		-- Solid: the three walls without a door in them.
		wallPanel("WallBack", V(wall, height, spec.size.Z), V(-side * half.X, 0, 0))
		wallPanel("WallA", V(spec.size.X, height, wall), V(0, 0, -half.Z))
		wallPanel("WallB", V(spec.size.X, height, wall), V(0, 0, half.Z))
		-- Front, split either side of the doorway.
		local panel = (spec.size.Z - doorWidth) / 2
		for index = -1, 1, 2 do
			wallPanel(`WallFront{index}`, V(wall, height, panel), V(side * half.X, 0, index * (doorWidth + panel) / 2))
		end
		local lintelHeight = height - Scale.DOORWAY
		wallPanel(
			"Lintel",
			V(wall, lintelHeight, doorWidth),
			V(side * half.X, Scale.DOORWAY + lintelHeight / 2 - height / 2, 0)
		)
	else
		local side = math.sign(spec.door.Z)
		wallPanel("WallBack", V(spec.size.X, height, wall), V(0, 0, -side * half.Z))
		wallPanel("WallA", V(wall, height, spec.size.Z), V(-half.X, 0, 0))
		wallPanel("WallB", V(wall, height, spec.size.Z), V(half.X, 0, 0))
		local panel = (spec.size.X - doorWidth) / 2
		for index = -1, 1, 2 do
			wallPanel(`WallFront{index}`, V(panel, height, wall), V(index * (doorWidth + panel) / 2, 0, side * half.Z))
		end
		local lintelHeight = height - Scale.DOORWAY
		wallPanel(
			"Lintel",
			V(doorWidth, lintelHeight, wall),
			V(0, Scale.DOORWAY + lintelHeight / 2 - height / 2, side * half.Z)
		)
	end

	-- Timber framing: two horizontal bands. Costs four parts and does most of
	-- the work of making plaster read as a building rather than a box.
	for _, y in { height * 0.34, height * 0.72 } do
		slab(
			"Band",
			V(spec.size.X + 0.4, 1.1, spec.size.Z + 0.4),
			center + V(0, y, 0),
			TIMBER,
			Enum.Material.Wood,
			model
		)
	end

	-- Roof.
	local roofColor = ROOFS[1]
	if spec.roof == "hip" then
		slab(
			"RoofSlab",
			V(spec.size.X + 6, 2, spec.size.Z + 6),
			center + V(0, height + 1, 0),
			roofColor,
			Enum.Material.Slate,
			model
		)
		for step = 1, 3 do
			local shrink = step * 6
			slab(
				`RoofStep{step}`,
				V(spec.size.X + 6 - shrink, 3, spec.size.Z + 6 - shrink),
				center + V(0, height + 1 + step * 3, 0),
				roofColor,
				Enum.Material.Slate,
				model
			)
		end
	else
		-- Gable: two wedges meeting at a ridge, aligned across the short axis.
		local ridgeAlongX = spec.size.X >= spec.size.Z
		local span = if ridgeAlongX then spec.size.Z else spec.size.X
		local run = if ridgeAlongX then spec.size.X else spec.size.Z
		for index = -1, 1, 2 do
			local part = Create("WedgePart", {
				Name = `Roof{index}`,
				Anchored = true,
				Size = V(run + 5, span * 0.45, span / 2 + 3),
				Color = roofColor,
				Material = Enum.Material.Slate,
				Parent = model,
			})
			local offset = if ridgeAlongX
				then V(0, height + span * 0.225 + 0.5, index * (span / 4 + 1.5))
				else V(index * (span / 4 + 1.5), height + span * 0.225 + 0.5, 0)
			part.CFrame = CFrame.new(center + offset)
				* CFrame.Angles(0, math.rad(if ridgeAlongX then 0 else 90), 0)
				* CFrame.Angles(0, math.rad(if index < 0 then 180 else 0), 0)
		end
	end

	-- Hanging sign on the door wall, lit at night by its own small light.
	local outward = spec.door
	local signAt = center
		+ outward * (half.X * math.abs(outward.X) + half.Z * math.abs(outward.Z) + 3)
		+ V(0, Scale.STOREY * 1.3, 0)
	local board = slab(
		"Sign",
		V(if doorOnX then 1 else 16, 5.5, if doorOnX then 16 else 1),
		signAt,
		TIMBER,
		Enum.Material.Wood,
		model
	)

	Create("SurfaceGui", {
		Face = if doorOnX
			then (if outward.X > 0 then Enum.NormalId.Right else Enum.NormalId.Left)
			else (if outward.Z > 0 then Enum.NormalId.Front else Enum.NormalId.Back),
		PixelsPerStud = 26,
		LightInfluence = 0,
		Parent = board,

		Create("TextLabel", {
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = spec.sign,
			TextScaled = true,
			TextColor3 = spec.accent,
		}),
	})

	Create("PointLight", { Brightness = 1.2, Range = 22, Color = Color3.fromRGB(255, 214, 150), Parent = board })

	-- Counter inside, where the shopkeeper stands.
	if spec.counter then
		local counterAt = center + spec.counter + V(0, Scale.COUNTER / 2, 0)
		local counterSize = if doorOnX then V(2.4, Scale.COUNTER, 10) else V(10, Scale.COUNTER, 2.4)
		local counter = slab("Counter", counterSize, counterAt, TIMBER, Enum.Material.WoodPlanks, model)
		counter.CanCollide = true
	end

	return model
end

local function buildStreet()
	local street = Town.Street
	local length = street.fromZ - street.toZ

	slab(
		"Street",
		V(street.halfWidth * 2, 1, length),
		V(0, Town.Floor - 0.5, (street.fromZ + street.toZ) / 2),
		COBBLE,
		Enum.Material.Cobblestone
	)

	-- Kerbs, so the street has an edge instead of dissolving into the grass.
	for side = -1, 1, 2 do
		slab(
			`Kerb{side}`,
			V(2, 1.4, length),
			V(side * street.halfWidth, Town.Floor + 0.2, (street.fromZ + street.toZ) / 2),
			STONE,
			Enum.Material.Slate
		)
	end
end

local function buildPlaza()
	local plaza = Town.Plaza
	slab("Plaza", V(plaza.width, 1, plaza.depth), plaza.center + V(0, -0.5, 0), COBBLE, Enum.Material.Cobblestone)

	-- Fountain: three stepped rings, water, and a spout. The one place in town
	-- that makes noise and moves on its own.
	local at = Town.Fountain
	slab("FountainBase", V(17, 1, 17), at + V(0, 0.5, 0), STONE, Enum.Material.Marble)
	local rim = slab("FountainRim", V(14.5, 1.6, 14.5), at + V(0, 1.4, 0), STONE, Enum.Material.Marble)
	rim.CanCollide = true

	local water =
		slab("FountainWater", V(13, 1.3, 13), at + V(0, 1.45, 0), Color3.fromRGB(88, 158, 178), Enum.Material.Glass)
	water.Transparency = 0.4
	water.Reflectance = 0.25

	slab("FountainStem", V(2.6, 5, 2.6), at + V(0, 4, 0), STONE, Enum.Material.Marble)
	slab("FountainBowl", V(7.4, 1, 7.4), at + V(0, 6.8, 0), STONE, Enum.Material.Marble)
	slab("FountainBowlLip", V(8.2, 0.5, 8.2), at + V(0, 7.4, 0), STONE_DARK, Enum.Material.Marble)

	-- Upper stage and a figure on top. At twelve studs the fountain was lost
	-- among the market stalls; it has to be the tallest thing on the plaza or
	-- it isn't a centrepiece, it's a bollard.
	slab("FountainStem2", V(1.7, 4.5, 1.7), at + V(0, 9.9, 0), STONE, Enum.Material.Marble)
	slab("FountainBowl2", V(4.5, 0.8, 4.5), at + V(0, 12.4, 0), STONE, Enum.Material.Marble)

	local figure =
		slab("FountainFigure", V(1.5, 2.8, 1), at + V(0, 14.2, 0), Color3.fromRGB(206, 200, 184), Enum.Material.Marble)
	figure.CFrame = CFrame.new(figure.Position) * CFrame.Angles(0, math.rad(28), 0)
	slab("FountainHead", V(0.85, 0.9, 0.85), at + V(0, 16.1, 0), Color3.fromRGB(206, 200, 184), Enum.Material.Marble)
	for side = -1, 1, 2 do
		local arm = slab(
			`FountainArm{side}`,
			V(0.5, 2.4, 0.5),
			at + V(side * 0.9, 14.9, 0),
			Color3.fromRGB(206, 200, 184),
			Enum.Material.Marble
		)
		arm.CFrame = CFrame.new(arm.Position) * CFrame.Angles(0, 0, math.rad(side * 34))
	end

	-- Four falls from the upper bowl into the basin.
	for index = 0, 3 do
		local angle = index / 4 * math.pi * 2 + math.pi / 4
		local fall = slab(
			`Fall{index}`,
			V(0.6, 5, 0.6),
			at + V(math.cos(angle) * 2.5, 4.2, math.sin(angle) * 2.5),
			Color3.fromRGB(178, 220, 232),
			Enum.Material.Glass
		)
		fall.Transparency = 0.55
	end

	-- Mist off the bowl and a fall from the spout. The plaza's centrepiece is
	-- the one thing in town that is genuinely in motion, so it earns particles.
	Create("ParticleEmitter", {
		Name = "Mist",
		Texture = "rbxasset://textures/particles/smoke_main.dds",
		Rate = 22,
		Lifetime = NumberRange.new(1.4, 2.4),
		Speed = NumberRange.new(1, 3),
		SpreadAngle = Vector2.new(60, 60),
		Size = NumberSequence.new({ NumberSequenceKeypoint.new(0, 1.4), NumberSequenceKeypoint.new(1, 5) }),
		Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 0.75),
			NumberSequenceKeypoint.new(1, 1),
		}),
		Color = ColorSequence.new(Color3.fromRGB(214, 236, 244)),
		Acceleration = Vector3.new(0, 1.5, 0),
		LightEmission = 0.6,
		Parent = water,
	})

	Create("Sound", {
		Name = "Fountain",
		SoundId = "rbxasset://sounds/impact_water.mp3",
		Volume = 0.35,
		PlaybackSpeed = 0.55,
		Looped = true,
		RollOffMaxDistance = 120,
		RollOffMode = Enum.RollOffMode.InverseTapered,
		Parent = water,
	}):Play()

	-- Stalls around it.
	for index, stall in Town.Stalls do
		local angle = math.rad(stall.angle)
		local stallAt = at + V(math.cos(angle) * stall.radius, 0, math.sin(angle) * stall.radius)

		slab(`StallTable{index}`, V(6, 2.6, 3.8), stallAt + V(0, 1.3, 0), TIMBER, Enum.Material.WoodPlanks)
		for side = -1, 1, 2 do
			slab(`StallPost{index}_{side}`, V(0.4, 6, 0.4), stallAt + V(side * 2.7, 3, 0), TIMBER, Enum.Material.Wood)
		end
		local canopy =
			slab(`StallCanopy{index}`, V(7.4, 0.35, 5.2), stallAt + V(0, 6.2, 0), stall.canopy, Enum.Material.Fabric)
		canopy.CFrame = CFrame.new(canopy.Position) * CFrame.Angles(math.rad(-12), 0, 0)

		-- Goods on the table: a few small blocks, deterministic per stall.
		local rng = Random.new(index * 77)
		for crate = 1, 4 do
			slab(
				`StallGoods{index}_{crate}`,
				V(rng:NextNumber(0.6, 1.2), rng:NextNumber(0.5, 1.1), rng:NextNumber(0.6, 1.2)),
				stallAt + V(rng:NextNumber(-2.2, 2.2), 3, rng:NextNumber(-1.1, 1.1)),
				stall.canopy:Lerp(Color3.new(1, 1, 1), 0.35),
				Enum.Material.SmoothPlastic
			)
		end
	end
end

--[[
	The bell tower.

	Rendering the town from the gate showed a completely flat skyline — twenty
	buildings all one storey, nothing to look at and nothing to walk toward.
	A town needs one thing taller than everything else: it gives the silhouette
	a peak, it tells you which way the centre is from anywhere inside the
	walls, and it is the difference between a place and a housing estate.
]]
local function buildTower()
	local at = V(48, 0, -706)
	local rng = Random.new(777)

	-- Battered base: three shrinking stages, so it tapers instead of being a
	-- 70-stud column dropped on the grass.
	-- Slimmer than the first pass: at 22 studs across it read as a factory
	-- chimney beside twenty-stud houses. A bell tower is tall, not fat.
	local stages = { { 13, 16 }, { 11, 14 }, { 9, 12 } }
	local y = 0
	for index, stage in stages do
		local width, height = stage[1], stage[2]
		local part = slab(
			`TowerStage{index}`,
			V(width, height, width),
			at + V(0, y + height / 2, 0),
			Detail.vary(STONE, rng, 0.03),
			Enum.Material.Brick
		)
		part.CanCollide = true
		-- String course between stages: a shadow line every 20-odd studs is
		-- what stops a tall thing reading as one extruded block.
		slab(
			`TowerCourse{index}`,
			V(width + 1.4, 0.9, width + 1.4),
			at + V(0, y + height, 0),
			STONE_DARK,
			Enum.Material.Slate
		)
		y += height
	end

	-- Belfry: open arches on all four sides.
	for side = -1, 1, 2 do
		slab(`TowerPierX{side}`, V(2, 9, 2), at + V(side * 3.6, y + 4.5, -3.6), STONE, Enum.Material.Brick)
		slab(`TowerPierZ{side}`, V(2, 9, 2), at + V(side * 3.6, y + 4.5, 3.6), STONE, Enum.Material.Brick)
	end
	slab("BelfryFloor", V(10.5, 0.9, 10.5), at + V(0, y + 0.45, 0), STONE_DARK, Enum.Material.Slate)
	slab("BelfryCap", V(11.8, 1.4, 11.8), at + V(0, y + 9.6, 0), STONE_DARK, Enum.Material.Slate)

	-- The bell itself, and a light in the belfry so the tower reads at night.
	local bell = slab("Bell", V(3, 3.6, 3), at + V(0, y + 5.6, 0), Color3.fromRGB(150, 118, 62), Enum.Material.Metal)
	bell.Shape = Enum.PartType.Ball
	Create("PointLight", {
		Brightness = 0,
		Range = 44,
		Color = Color3.fromRGB(255, 208, 150),
		Parent = bell,
	})
	table.insert(lanterns, { light = bell:FindFirstChildOfClass("PointLight"), bulb = bell })

	-- Spire: stacked shrinking blocks read as a taper at this scale and cost
	-- eight parts instead of a mesh.
	local spireBase = y + 10.4
	for index = 0, 7 do
		local t = index / 8
		slab(
			`Spire{index}`,
			V(10.5 * (1 - t) + 0.8, 2.6, 10.5 * (1 - t) + 0.8),
			at + V(0, spireBase + index * 2.6 + 1.3, 0),
			ROOFS[1],
			Enum.Material.Slate
		)
	end
	slab("SpireTip", V(0.9, 4.4, 0.9), at + V(0, spireBase + 22, 0), Color3.fromRGB(198, 168, 96), Enum.Material.Metal)

	-- Clock face on the side that looks down the street.
	local face = slab(
		"ClockFace",
		V(0.8, 5.4, 5.4),
		at + V(-6.8, spireBase - 10, 0),
		Color3.fromRGB(232, 226, 208),
		Enum.Material.Marble
	)
	Create("PointLight", { Brightness = 0, Range = 20, Color = Color3.fromRGB(255, 226, 180), Parent = face })
	table.insert(lanterns, { light = face:FindFirstChildOfClass("PointLight"), bulb = face })
end

local function buildHouses()
	for index, house in Town.Houses do
		local color = if index % 2 == 0 then PLASTER else STONE
		-- Sunk half a stud so the base meets the grass plate rather than
		-- hovering over it. Houses stand on the ground plate, not on paving, so
		-- dropping that plate to -0.35 left every one of them floating.
		local body = slab(
			`House{index}`,
			V(house.size.X, house.size.Y, house.size.Z),
			house.at + V(0, house.size.Y / 2 - 0.5, 0),
			color,
			Enum.Material.Concrete
		)
		body.CanCollide = true

		slab(
			`HouseBand{index}`,
			V(house.size.X + 0.3, 1, house.size.Z + 0.3),
			house.at + V(0, house.size.Y * 0.55, 0),
			TIMBER,
			Enum.Material.Wood
		)

		local roof = Create("WedgePart", {
			Name = `HouseRoof{index}`,
			Anchored = true,
			Size = V(house.size.X + 4, house.size.Z * 0.5, house.size.Z + 4),
			Color = ROOFS[house.tint],
			Material = Enum.Material.Slate,
			Parent = folder,
		})
		roof.CFrame = CFrame.new(house.at + V(0, house.size.Y + house.size.Z * 0.25, 0)) * CFrame.Angles(0, 0, 0)

		local roofBack = roof:Clone()
		roofBack.CFrame = roof.CFrame * CFrame.Angles(0, math.rad(180), 0)
		roofBack.Parent = folder

		-- Framed windows with shutters and a flower box, rather than a dark
		-- rectangle stuck to the wall. Same part budget, entirely different read.
		local rng = Random.new(index * 4099)
		for side = -1, 1, 2 do
			local at = house.at + V(side * house.size.X * 0.26, house.size.Y * 0.6, house.size.Z / 2)
			local _, light = Detail.window(CFrame.lookAt(at, at + V(0, 0, 1)), rng, {
				parent = folder,
				width = 3.6,
				height = 4.6,
				shutters = true,
				shutterColor = ROOFS[house.tint],
			})
			table.insert(lanterns, { light = light, bulb = light.Parent })

			local boxAt = at - V(0, 3.4, 0) + V(0, 0, 1.4)
			Detail.flowerBox(CFrame.lookAt(boxAt, boxAt + V(0, 0, 1)), rng, folder)
		end

		Detail.chimney(
			CFrame.new(house.at + V(house.size.X * 0.28, house.size.Y + 1, -house.size.Z * 0.2)),
			rng,
			folder
		)
	end
end

local function buildLanterns()
	for index, at in Town.Lanterns do
		slab(`LanternPost{index}`, V(0.5, 8, 0.5), at + V(0, 4, 0), Color3.fromRGB(52, 50, 48), Enum.Material.Metal)
		slab(
			`LanternArm{index}`,
			V(1.4, 0.35, 0.35),
			at + V(0, 7.8, 0),
			Color3.fromRGB(52, 50, 48),
			Enum.Material.Metal
		)

		local bulb = slab(
			`Lantern{index}`,
			V(2.4, 3, 2.4),
			at + V(0, 12.6, 0),
			Color3.fromRGB(255, 226, 176),
			Enum.Material.Neon
		)
		bulb.Transparency = 0.15

		local light = Create("PointLight", {
			Brightness = 0,
			Range = 34,
			Color = Color3.fromRGB(255, 206, 150),
			Parent = bulb,
		})
		table.insert(lanterns, { light = light, bulb = bulb })
	end
end

local function buildWalls()
	-- Outer wall around the whole town, so the place is enclosed rather than
	-- five buildings on an infinite plain. Gap at the south, where the
	-- courtyard arch already is.
	local minX, maxX = -160, 160
	local minZ, maxZ = -770, -505
	local height = 30

	-- East wall is solid. The west wall opens for the Colosseum causeway, so
	-- the arena is somewhere you walk out to rather than somewhere you appear.
	local east = slab(
		"TownWallE",
		V(5, height, maxZ - minZ),
		V(maxX, height / 2, (minZ + maxZ) / 2),
		STONE_DARK,
		Enum.Material.Brick
	)
	east.CanCollide = true

	local gate = Town.Colosseum
	for side = -1, 1, 2 do
		local edge = if side < 0 then minZ else maxZ
		local panelDepth = math.abs(edge - (gate.gateZ + side * gate.gateWidth / 2))
		local part = slab(
			`TownWallW{side}`,
			V(5, height, panelDepth),
			V(minX, height / 2, (edge + gate.gateZ + side * gate.gateWidth / 2) / 2),
			STONE_DARK,
			Enum.Material.Brick
		)
		part.CanCollide = true
	end
	slab("WestGateLintel", V(5, 7, gate.gateWidth + 4), V(minX, height - 3.5, gate.gateZ), STONE, Enum.Material.Brick)

	local north =
		slab("TownWallN", V(maxX - minX + 5, height, 5), V(0, height / 2, minZ), STONE_DARK, Enum.Material.Brick)
	north.CanCollide = true

	-- South wall, split around the street so the courtyard arch leads through.
	local gap = Town.Street.halfWidth * 2 + 6
	for side = -1, 1, 2 do
		local panelWidth = (maxX - minX - gap) / 2
		local part = slab(
			`TownWallS{side}`,
			V(panelWidth, height, 5),
			V(side * (gap + panelWidth) / 2, height / 2, maxZ),
			STONE_DARK,
			Enum.Material.Brick
		)
		part.CanCollide = true
	end

	--[[
		Ground plate under the whole town, so nothing sits on generated terrain.

		ITS TOP FACE IS BELOW THE PAVING, and that is the entire point. It used
		to sit at y = 0 — exactly the height of the plaza slab, the street slab
		and the courtyard floor, all of which lie directly on top of it. Four
		coplanar surfaces covering every square stud anyone walks on in Firstia,
		which is around twenty thousand studs of z-fighting: the grass and the
		cobbles flickering against each other across the whole town, changing
		with the camera angle. It is the single ugliest thing in the game and it
		is exactly what rule 6 in the handoff was written about.

		0.35 below is the same relationship the handoff describes — paved plaza
		above grass — and it is small enough to read as a kerb rather than a
		step.
	]]
	slab(
		"TownGround",
		V(maxX - minX + 40, 2, maxZ - minZ + 40),
		V(0, -1.35, (minZ + maxZ) / 2),
		Color3.fromRGB(96, 104, 76),
		Enum.Material.Grass
	)
end

--------------------------------------------------------------------------------
-- Interiors
--------------------------------------------------------------------------------

--[[
	What is actually inside each shop.

	A room with a counter is a room. A room with a lit forge, an anvil, a rack
	of blades and three barrels is a smithy — and the difference is entirely
	props, because the shell is the same six boxes either way. Offsets are
	relative to the building centre; the buildings are axis-aligned so no
	rotation is needed.

	Each entry is a list of {kind, at, rot?, extra?}. Fires register their light
	so the night pass can pulse them.
]]
local INTERIORS = {
	smithy = {
		{ kind = "fire", at = V(-9.2, 0, -8.6), scale = 1.6 },
		{ kind = "anvil", at = V(-1.3, 0, -7.9) },
		{ kind = "weaponRack", at = V(0.0, 0, 9.9), face = V(0, 0, -1) },
		{ kind = "barrel", at = V(-9.9, 0, 9.2) },
		{ kind = "barrel", at = V(-7.3, 0, 10.6) },
		{ kind = "crate", at = V(1.3, 0, 9.9) },
		{ kind = "shelf", at = V(-11.9, 0, 2.6), face = V(1, 0, 0), jar = Color3.fromRGB(150, 150, 158) },
	},
	apothecary = {
		{ kind = "shelf", at = V(9.9, 0, -7.3), face = V(-1, 0, 0), jar = Color3.fromRGB(120, 192, 140) },
		{ kind = "shelf", at = V(9.9, 0, 4.0), face = V(-1, 0, 0), jar = Color3.fromRGB(196, 148, 208) },
		{ kind = "shelf", at = V(0.0, 0, -8.6), face = V(0, 0, 1), jar = Color3.fromRGB(226, 184, 110) },
		{ kind = "fire", at = V(8.6, 0, 7.9), scale = 0.7 },
		{ kind = "sack", at = V(2.6, 0, 8.6) },
		{ kind = "sack", at = V(4.6, 0, 9.2) },
	},
	general = {
		{ kind = "shelf", at = V(-10.6, 0, -7.9), face = V(1, 0, 0), jar = Color3.fromRGB(180, 160, 120) },
		{ kind = "crate", at = V(-9.2, 0, 7.9) },
		{ kind = "crate", at = V(-5.9, 0, 9.2) },
		{ kind = "crate", at = V(-8.6, 0, 10.6) },
		{ kind = "sack", at = V(1.3, 0, 8.6) },
		{ kind = "sack", at = V(3.3, 0, 9.9) },
		{ kind = "barrel", at = V(-10.6, 0, 5.3) },
		{ kind = "cart", at = V(4.0, 0, -7.9) },
	},
	guild = {
		{ kind = "questBoard", at = V(0.0, 0, -11.9), face = V(0, 0, 1) },
		{ kind = "table", at = V(-5.9, 0, 1.3) },
		{ kind = "table", at = V(-5.9, 0, 9.2) },
		{ kind = "fire", at = V(10.6, 0, 9.9), scale = 1.1 },
		{ kind = "shelf", at = V(13.2, 0, -5.3), face = V(-1, 0, 0), jar = Color3.fromRGB(140, 168, 200) },
		{ kind = "barrel", at = V(9.2, 0, -10.6) },
	},
	tavern = {
		{ kind = "fire", at = V(-13.9, 0, -6.6), scale = 1.3 },
		{ kind = "table", at = V(-7.9, 0, 4.0) },
		{ kind = "table", at = V(7.9, 0, 4.0) },
		{ kind = "table", at = V(0.0, 0, -4.0) },
		{ kind = "shelf", at = V(13.2, 0, -7.3), face = V(-1, 0, 0), jar = Color3.fromRGB(198, 148, 92) },
		{ kind = "barrel", at = V(11.9, 0, 6.6) },
		{ kind = "barrel", at = V(14.5, 0, 4.6) },
	},
}

--- Builds one interior. `rng` is seeded per building so the dressing is the
--- same every run — a shop that rearranges itself on each server start is
--- disorienting in a way nobody can name.
local function dressInterior(spec, model: Model)
	local list = INTERIORS[spec.id]
	if not list then
		return
	end

	local rng = Random.new(spec.center.X * 31 + spec.center.Z)

	for _, prop in list do
		local at = spec.center + prop.at
		local face = prop.face or V(0, 0, -1)
		local origin = CFrame.lookAt(at, at + face)

		if prop.kind == "fire" then
			local _, light = Detail.fire(origin, rng, { scale = prop.scale, parent = model })
			-- Registered like a lantern so the night pass can breathe it.
			table.insert(fires, light)
		elseif prop.kind == "shelf" then
			Detail.shelf(origin, rng, { parent = model, jarColor = prop.jar })
		elseif prop.kind == "anvil" then
			Detail.anvil(origin, rng, model)
		elseif prop.kind == "weaponRack" then
			Detail.weaponRack(origin, rng, model)
		elseif prop.kind == "questBoard" then
			Detail.questBoard(origin, rng, model)
		elseif prop.kind == "table" then
			Detail.table(origin, rng, model)
		elseif prop.kind == "barrel" then
			Detail.barrel(origin, rng, model)
		elseif prop.kind == "crate" then
			Detail.crate(origin, rng, model)
		elseif prop.kind == "sack" then
			Detail.sack(origin, rng, model)
		elseif prop.kind == "cart" then
			Detail.cart(origin, rng, model)
		end
	end
end

--[[
	Exterior dressing: the doorway, its windows, and a smoking chimney.

	Windows go on the two walls perpendicular to the door, never on the door
	wall itself — a facade with a door and two windows crammed into the same
	twelve studs reads as a dollhouse. The chimney is what sells the roofline
	from across the plaza, because its smoke is the only thing up there that
	moves.
]]
local function dressExterior(spec, model: Model)
	local rng = Random.new(spec.center.X * 17 + spec.center.Z * 3)
	local half = spec.size * 0.5
	local doorOnX = math.abs(spec.door.X) > 0.5

	local doorAt = spec.center + V(spec.door.X * half.X, 0, spec.door.Z * half.Z)
	local _, doorLight = Detail.doorframe(CFrame.lookAt(doorAt, doorAt + spec.door), rng, {
		parent = model,
		width = Scale.DOOR_WIDTH,
		height = Scale.DOORWAY,
	})
	-- The bulb is always the light's own parent: for a doorway that's the neon
	-- lamp, for a window it's the glass pane. Naming the part separately is how
	-- you end up tinting a signboard instead of a window.
	table.insert(lanterns, { light = doorLight, bulb = doorLight.Parent })

	-- Windows on the two walls the door isn't in.
	for side = -1, 1, 2 do
		local normal = if doorOnX then V(0, 0, side) else V(side, 0, 0)
		local wallAt = spec.center + V(normal.X * half.X, 0, normal.Z * half.Z)
		local along = if doorOnX then V(1, 0, 0) else V(0, 0, 1)
		local span = if doorOnX then spec.size.X else spec.size.Z

		for slot = -1, 1, 2 do
			local at = wallAt + along * (slot * span * 0.24) + V(0, spec.size.Y * 0.56, 0)
			local _, light = Detail.window(CFrame.lookAt(at, at + normal), rng, {
				parent = model,
				shutters = true,
				shutterColor = spec.accent,
			})
			table.insert(lanterns, { light = light, bulb = light.Parent })

			-- Flower box under the ground-floor windows, on the street side only.
			if slot == 1 then
				local boxAt = at - V(0, spec.size.Y * 0.52 - 4.2, 0) + normal * 1.4
				Detail.flowerBox(CFrame.lookAt(boxAt, boxAt + normal), rng, model)
			end
		end
	end

	Detail.chimney(CFrame.new(spec.center + V(half.X * 0.45, spec.size.Y + 1, half.Z * 0.4)), rng, model)
end

--[[
	Street furniture.

	Deliberately clustered rather than spread: goods pile up beside the door
	they came out of, carts stop where there is room to turn, and a fence only
	exists where there is something to keep out. Evenly-distributed props read
	as decoration; clustered ones read as consequences.
]]
local function dressStreets()
	local rng = Random.new(90210)

	-- Goods stacked outside each shop door.
	for _, spec in Town.Buildings do
		local half = spec.size * 0.5
		local outside = spec.center + V(spec.door.X * (half.X + 9), 0, spec.door.Z * (half.Z + 9))
		local along = if math.abs(spec.door.X) > 0.5 then V(0, 0, 1) else V(1, 0, 0)

		for index = 1, rng:NextInteger(3, 5) do
			local at = outside + along * rng:NextNumber(-11, 11) + V(rng:NextNumber(-2, 2), 0, rng:NextNumber(-2, 2))
			local roll = rng:NextNumber()
			if roll < 0.4 then
				Detail.barrel(CFrame.new(at), rng, folder)
			elseif roll < 0.75 then
				Detail.crate(CFrame.new(at), rng, folder)
			else
				Detail.sack(CFrame.new(at), rng, folder)
			end
			if index == 1 then
				-- One crate gets stacked on another. Free verticality.
				Detail.crate(CFrame.new(at + V(0.4, 3.4, 0.3)), rng, folder)
			end
		end
	end

	-- Banners flanking the street, in the town's blue.
	for _, z in { -534, -580 } do
		for side = -1, 1, 2 do
			Detail.banner(
				CFrame.lookAt(V(side * 26, 0, z), V(side * 26, 0, z) + V(-side, 0, 0)),
				rng,
				Color3.fromRGB(62, 120, 148),
				folder
			)
		end
	end

	-- Carts parked around the plaza.
	for _, at in { V(-46, 0, -614), V(40, 0, -620), V(-52, 0, -712) } do
		Detail.cart(CFrame.new(at), rng, folder)
	end

	-- Fences enclosing the two back gardens.
	Detail.fence(CFrame.new(V(-104, 0, -578)), rng, 34, folder)
	Detail.fence(CFrame.lookAt(V(122, 0, -668), V(123, 0, -668)), rng, 30, folder)
end

--------------------------------------------------------------------------------
-- People
--------------------------------------------------------------------------------

local function buildFigure(npc): Model
	local ctx = Build.begin(npc.id, CFrame.new())
	--[[
		FIGURE_SCALE brings the hand-authored 9-stud proportions down to a
		Roblox humanoid. The offsets below stay as authored so the figure's
		proportions are still readable in the source; only the ruler changes.
	]]
	local s = (npc.scale or 1) * Scale.fromOldFigure(1)
	local cloth, skin, hair = npc.cloth, npc.skin, npc.hair

	local function sv(x: number, y: number, z: number)
		return V(x * s, y * s, z * s)
	end

	ctx:pair("block", { name = "Boot", size = sv(1.1, 0.9, 1.6), at = sv(0.62, 0.45, -0.1), color = TIMBER })
	ctx:pair(
		"block",
		{ name = "Leg", size = sv(1.0, 2.4, 1.05), at = sv(0.62, 2.0, 0), color = cloth:Lerp(Color3.new(), 0.3) }
	)
	ctx:block({ name = "Hips", size = sv(2.5, 1.0, 1.4), at = sv(0, 3.5, 0), color = cloth:Lerp(Color3.new(), 0.3) })
	ctx:block({ name = "Belt", size = sv(2.7, 0.5, 1.5), at = sv(0, 4.0, 0), color = TIMBER })
	ctx:block({ name = "Torso", size = sv(2.8, 2.7, 1.55), at = sv(0, 5.5, 0), color = cloth })
	ctx:block({
		name = "Collar",
		size = sv(3.0, 0.45, 1.75),
		at = sv(0, 6.8, 0),
		color = cloth:Lerp(Color3.new(), 0.25),
	})
	ctx:pair("block", { name = "Arm", size = sv(0.9, 2.5, 0.95), at = sv(1.82, 5.4, 0), color = cloth })
	ctx:pair("block", { name = "Hand", size = sv(0.95, 0.85, 1.0), at = sv(1.84, 3.95, 0), color = skin })
	ctx:block({ name = "Neck", size = sv(0.85, 0.55, 0.85), at = sv(0, 7.2, 0), color = skin })
	ctx:block({ name = "Head", size = sv(1.5, 1.6, 1.5), at = sv(0, 8.15, 0), color = skin })
	ctx:block({ name = "Hair", size = sv(1.62, 0.8, 1.62), at = sv(0, 8.8, 0), color = hair })
	ctx:block({ name = "HairBack", size = sv(1.55, 1.1, 0.5), at = sv(0, 8.2, 0.6), color = hair })
	ctx:pair(
		"block",
		{ name = "Eye", size = sv(0.18, 0.2, 0.12), at = sv(0.34, 8.25, -0.76), color = Color3.fromRGB(32, 30, 34) }
	)

	return ctx:finishRigged()
end

local function nameplate(model: Model, npc)
	Create("BillboardGui", {
		Name = "Nameplate",
		Size = UDim2.fromOffset(240, 44),
		StudsOffsetWorldSpace = V(0, 6.4 * (npc.scale or 1), 0),
		MaxDistance = 110,
		LightInfluence = 0,
		Parent = model.PrimaryPart,

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 22),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = npc.name,
			TextSize = 16,
			TextColor3 = Theme.Color.Text,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.3,
		}),
		Create("TextLabel", {
			Name = "Title",
			Position = UDim2.fromOffset(0, 22),
			Size = UDim2.new(1, 0, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = npc.title,
			TextSize = 12,
			TextColor3 = if npc.role == "quest" then Theme.Color.Unique else Theme.Color.TextMuted,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.45,
		}),
	})
end

local function spawnResidents()
	for _, npc in Town.Npcs do
		local entry = Town.scheduleAt(npc, Lighting.ClockTime)
		local at = entry.at + V(0, Town.Floor, 0)

		-- Most residents are the shared human figure; `build` opts one out to a
		-- model from the Mobs catalogue instead.
		local model = if npc.build then Mobs.build(npc.build, CFrame.new()) else buildFigure(npc)
		if not model then
			warn(`[Town] {npc.id} names an unknown model "{npc.build}"; skipped`)
			continue
		end
		model.Name = npc.id
		model.Parent = folder
		model:SetAttribute("NpcId", npc.id)
		model:SetAttribute("NpcName", npc.name)
		model:SetAttribute("NpcTitle", npc.title)
		model:SetAttribute("Interactable", true)

		nameplate(model, npc)

		--[[
			No rig bound here. The client owns posing; the server owns where
			this person is considered to be and what they will say.

			The model is still placed once at spawn so it is not sitting at the
			origin for the frame before a client picks it up.
		]]
		local resident = {
			npc = npc,
			model = model,
			position = at,
			lineIndex = 0,
			state = entry.state,
		}
		model:PivotTo(CFrame.lookAt(at, at + entry.facing))
		residents[npc.id] = resident
	end
end

--------------------------------------------------------------------------------
-- The day
--------------------------------------------------------------------------------

--[[
	Where each resident is, for range checks only.

	The server no longer poses anyone — Controllers/Townsfolk does that on
	every client at frame rate, because anchored-part CFrames do not
	interpolate across the network and posing fifteen rigs server-side made the
	whole town judder at the replication rate.

	What the server still needs is an authoritative answer to "is the player
	close enough to talk to this person", and that comes from the same pure
	`Town.scheduleAt` the clients use. Both halves therefore agree without
	anything being sent.

	Walking is integrated rather than snapped so the server's idea of a
	resident tracks the client's during the walk home, not just at the ends.
]]
local function driveResidents(dt: number)
	local hour = Lighting.ClockTime

	for _, resident in residents do
		local entry = Town.scheduleAt(resident.npc, hour)
		local target = entry.at + V(0, Town.Floor, 0)

		local delta = target - resident.position
		local distance = delta.Magnitude

		if distance > 1.2 then
			resident.position += delta.Unit * math.min(WALK_SPEED * dt, distance)
		else
			resident.position = target
		end

		resident.state = entry.state
	end
end

--- Lanterns and windows come up as the sun goes down. Eased rather than
--- switched: a town that snaps to full brightness on one frame reads as a
--- light switch, not a lamplighter.
local function driveLights(dt: number)
	local hour = Lighting.ClockTime
	local dark = Sky.isNight(hour) or hour < 6.2 or hour > 18.4
	local target = if dark then 1 else 0

	-- Hearths breathe rather than burn flat. A fire whose light does not move
	-- reads as a lamp painted orange.
	local flicker = math.sin(os.clock() * 5.3) * 0.22 + math.sin(os.clock() * 11.7) * 0.11
	for _, fire in fires do
		fire.Brightness = 2.6 + flicker
	end

	for _, lamp in lanterns do
		if not lamp.bulb then
			continue
		end
		local current = lamp.light:GetAttribute("Level") or 0
		local next = current + (target - current) * math.clamp(dt * 1.5, 0, 1)
		lamp.light:SetAttribute("Level", next)
		lamp.light.Brightness = next * 2.2
		if lamp.bulb.Material == Enum.Material.Neon then
			lamp.bulb.Transparency = 0.15 + (1 - next) * 0.5
		else
			lamp.bulb.Color = Color3.fromRGB(48, 44, 40):Lerp(Color3.fromRGB(255, 210, 150), next)
		end
	end
end

--------------------------------------------------------------------------------
-- Interaction
--------------------------------------------------------------------------------

local function rootOf(player: Player): BasePart?
	local character = player.Character
	return character and character:FindFirstChild("HumanoidRootPart") :: BasePart?
end

local function inRange(player: Player, resident): boolean
	local root = rootOf(player)
	if not root then
		return false
	end
	return (root.Position - resident.position).Magnitude <= TALK_RANGE + 6
end

--- The line this NPC says right now: time-of-day band first, then rotating
--- through that band's lines so a second press isn't the same sentence.
local function lineFor(resident): string
	local band = Town.band(Lighting.ClockTime)
	local lines = resident.npc.lines[band] or resident.npc.lines.day
	resident.lineIndex += 1
	return lines[(resident.lineIndex - 1) % #lines + 1]
end

--- Everything the player can be offered by this person, as buttons.
local function optionsFor(player: Player, resident)
	local npc = resident.npc
	local options = {}

	if npc.role == "shop" then
		local open = resident.state == "work"
		table.insert(options, {
			id = "shop",
			label = if open then "Browse wares" else "Shop is closed",
			enabled = open,
		})
	elseif npc.role == "rest" then
		table.insert(options, { id = "rest", label = "Rest (20g)", enabled = true })
	elseif npc.role == "heal" then
		table.insert(options, { id = "heal", label = "Ask for healing", enabled = true })
	elseif npc.role == "craft" then
		table.insert(options, { id = "craft", label = "Use the bench", enabled = resident.state == "work" })
	elseif npc.role == "arena" then
		table.insert(options, { id = "arena", label = "Enter the Colosseum", enabled = true, highlight = true })
	end

	-- Quests they own, in whatever state you have them.
	for _, quest in Town.questsFrom(npc.id) do
		local state = Profile.questState(player, quest.id)
		if state == "active" then
			local complete = Profile.isComplete(player, quest)
			table.insert(options, {
				id = if complete then `turnin:{quest.id}` else `about:{quest.id}`,
				label = if complete then `Hand in: {quest.name}` else `About: {quest.name}`,
				enabled = true,
				highlight = complete,
			})
		elseif state == "available" and Profile.canAccept(player, quest) then
			table.insert(options, {
				id = `offer:{quest.id}`,
				label = `New work: {quest.name}`,
				enabled = true,
				highlight = true,
			})
		end
	end

	table.insert(options, { id = "close", label = "Leave", enabled = true })
	return options
end

local function say(player: Player, resident, text: string, options)
	Network.Dialogue:FireClient(player, {
		npcId = resident.npc.id,
		name = resident.npc.name,
		title = resident.npc.title,
		text = text,
		options = options or optionsFor(player, resident),
	})
end

local function openShop(player: Player, resident)
	local shop = Town.Shops[resident.npc.shop]
	if not shop then
		return
	end

	local data = Profile.get(player)
	local sellable = {}
	if data then
		for id, count in data.materials do
			local material = Town.Materials[id]
			if material and count > 0 then
				table.insert(sellable, {
					id = id,
					name = material.name,
					rarity = material.rarity,
					count = count,
					price = Town.SellValues[id] or 10,
				})
			end
		end
	end

	Network.Shop:FireClient(player, {
		npcId = resident.npc.id,
		name = resident.npc.name,
		shopName = Town.findBuilding(resident.npc.shop) and Town.findBuilding(resident.npc.shop).name or shop.greeting,
		greeting = shop.greeting,
		stock = shop.stock,
		sellable = sellable,
		gold = if data then data.gold else 0,
	})
end

local ACTIONS = {}

function ACTIONS.talk(player: Player, resident)
	Audio.playAt("uiSelect", resident.position)
	say(player, resident, lineFor(resident))
end

function ACTIONS.shop(player: Player, resident)
	if resident.state ~= "work" then
		say(player, resident, "Come back when I'm open.")
		return
	end
	openShop(player, resident)
end

function ACTIONS.rest(player: Player, resident)
	if not Profile.spendGold(player, 20) then
		say(player, resident, "Twenty gold. I know, I know.")
		return
	end
	Network.Dialogue:FireClient(player, {
		npcId = resident.npc.id,
		name = resident.npc.name,
		title = resident.npc.title,
		text = "Upstairs, second on the left. Don't bleed on the sheets.",
		effect = "rest",
		options = { { id = "close", label = "Sleep", enabled = true } },
	})
end

function ACTIONS.heal(player: Player, resident)
	Network.Dialogue:FireClient(player, {
		npcId = resident.npc.id,
		name = resident.npc.name,
		title = resident.npc.title,
		text = "Hold still. There — the water does the work, I only ask it nicely.",
		effect = "heal",
		options = { { id = "close", label = "Thank her", enabled = true } },
	})
	Vfx.sparks(resident.position + V(0, 5, 0), 18, Theme.Color.Heal)
	Audio.playAt("levelUp", resident.position)
end

function ACTIONS.buy(player: Player, resident, itemId: string)
	local shop = Town.Shops[resident.npc.shop]
	if not (shop and resident.state == "work") then
		return
	end

	for _, item in shop.stock do
		if item.id == itemId then
			if not Profile.spendGold(player, item.price) then
				Network.Dialogue:FireClient(player, {
					npcId = resident.npc.id,
					name = resident.npc.name,
					title = resident.npc.title,
					text = "Not for that, you don't.",
					options = { { id = "close", label = "Right", enabled = true } },
				})
				return
			end
			Profile.addItem(player, item.id, 1)
			Network.Loot:FireClient(player, {
				id = item.id,
				name = item.name,
				rarity = item.rarity,
				slot = item.slot,
				description = item.description,
				stats = item.stats,
				quantity = 1,
			})
			Audio.playAt("loot", resident.position)
			openShop(player, resident)
			return
		end
	end
end

function ACTIONS.sell(player: Player, resident, materialId: string)
	local value = Town.SellValues[materialId]
	if not (value and resident.state == "work") then
		return
	end
	if Profile.takeMaterial(player, materialId, 1) then
		Profile.addGold(player, value)
		Audio.playAt("uiSelect", resident.position)
		openShop(player, resident)
	end
end

function ACTIONS.offer(player: Player, resident, questId: string)
	local quest = Town.findQuest(questId)
	if not quest then
		return
	end
	say(player, resident, quest.summary, {
		{ id = `accept:{quest.id}`, label = "Take the job", enabled = true, highlight = true },
		{ id = "talk", label = "Not right now", enabled = true },
	})
end

function ACTIONS.accept(player: Player, resident, questId: string)
	local quest = Town.findQuest(questId)
	if not (quest and Profile.accept(player, quest)) then
		return
	end
	Audio.playAt("unique", resident.position)
	say(player, resident, quest.accept, { { id = "close", label = "On it", enabled = true } })
end

function ACTIONS.about(player: Player, resident, questId: string)
	local quest = Town.findQuest(questId)
	if not quest then
		return
	end
	local have = Profile.progressOf(player, quest)
	say(player, resident, `{quest.progress}  ({have}/{quest.objective.count})`, {
		{ id = "talk", label = "Understood", enabled = true },
		{ id = "close", label = "Leave", enabled = true },
	})
end

function ACTIONS.turnin(player: Player, resident, questId: string)
	local quest = Town.findQuest(questId)
	if not quest then
		return
	end

	local reward = Profile.turnIn(player, quest)
	if not reward then
		return
	end

	Audio.playAt("levelUp", resident.position)
	Vfx.sparks(resident.position + V(0, 6, 0), 26, Theme.Color.Unique)

	if reward.item then
		Network.Loot:FireClient(player, reward.item)
	end
	if reward.xp then
		Network.EnemyDefeated:FireClient(player, "quest", reward.xp)
	end

	say(player, resident, quest.complete, {
		{ id = "close", label = `Take {reward.gold} gold`, enabled = true, highlight = true },
	})
end

--[[
	The crafting bench.

	Sends the whole recipe list every time, with each entry already resolved
	against what the player is carrying — `have` per ingredient, and a single
	`ready` flag. Working that out on the server rather than the client means
	the panel can never offer a craft the server will then refuse, which is the
	specific failure that makes a crafting UI feel broken.
]]
local function openBench(player: Player, resident)
	local data = Profile.get(player)
	if not data then
		return
	end

	local list = {}
	for _, recipe in Crafting.Recipes do
		local unlocked = not recipe.requires or data.quests[recipe.requires] == "done"
		local needs, ready = {}, unlocked

		for _, ingredient in recipe.needs do
			local have = data.materials[ingredient.id] or 0
			local material = Town.Materials[ingredient.id]
			table.insert(needs, {
				id = ingredient.id,
				name = material and material.name or ingredient.id,
				need = ingredient.count,
				have = have,
			})
			if have < ingredient.count then
				ready = false
			end
		end

		if data.gold < recipe.fee then
			ready = false
		end

		table.insert(list, {
			id = recipe.id,
			name = recipe.name,
			rarity = recipe.rarity,
			slot = recipe.slot,
			description = recipe.description,
			stats = recipe.stats,
			fee = recipe.fee,
			needs = needs,
			unlocked = unlocked,
			lockedBy = recipe.lockedBy,
			ready = ready,
		})
	end

	Network.Craft:FireClient(player, {
		npcId = resident.npc.id,
		name = resident.npc.name,
		greeting = "Put it on the bench. I'll tell you what it wants.",
		recipes = list,
		gold = data.gold,
	})
end

function ACTIONS.craft(player: Player, resident)
	if resident.state ~= "work" then
		say(player, resident, "Bench is cold. Come back when I'm at it.")
		return
	end
	openBench(player, resident)
end

--[[
	Makes one item.

	Charges the fee first and refunds it if any material turns out to be
	missing. Taking the gold and then discovering the bag is short is how a
	player loses a purchase to a race with their own inventory.
]]
function ACTIONS.make(player: Player, resident, recipeId: string)
	local recipe = Crafting.find(recipeId)
	local data = Profile.get(player)
	if not (recipe and data and resident.state == "work") then
		return
	end

	if recipe.requires and data.quests[recipe.requires] ~= "done" then
		say(player, resident, recipe.lockedBy or "Not yet.")
		return
	end

	for _, ingredient in recipe.needs do
		if (data.materials[ingredient.id] or 0) < ingredient.count then
			say(player, resident, "You're short. Look at the list, not at me.")
			return
		end
	end

	if not Profile.spendGold(player, recipe.fee) then
		say(player, resident, `The bench costs {recipe.fee}. I don't set the rate.`)
		return
	end

	for _, ingredient in recipe.needs do
		if not Profile.takeMaterial(player, ingredient.id, ingredient.count) then
			-- Should be unreachable given the check above; refund rather than
			-- silently eat the fee if it ever isn't.
			Profile.addGold(player, recipe.fee)
			return
		end
	end

	Profile.addItem(player, recipe.id, 1)
	Network.Loot:FireClient(player, {
		id = recipe.id,
		name = recipe.name,
		rarity = recipe.rarity,
		slot = recipe.slot,
		description = recipe.description,
		stats = recipe.stats,
		quantity = 1,
	})

	Audio.playAt(if recipe.rarity == "Unique" then "unique" else "levelUp", resident.position)
	Vfx.sparks(resident.position + V(0, 5, 0), 22, (Theme.Rarity[recipe.rarity] or Theme.Rarity.Common).Color)
	openBench(player, resident)
end

--- The arena gate. The Colosseum script owns the run; this only points at it.
function ACTIONS.arena(player: Player, resident)
	Network.Dialogue:FireClient(player, {
		npcId = resident.npc.id,
		name = resident.npc.name,
		title = resident.npc.title,
		text = "Five waves, one floor. Walk west down the causeway and step onto the sand — "
			.. "it starts when you're on it. Nothing stops you leaving, but leaving ends the run.",
		options = { { id = "close", label = "Understood", enabled = true } },
	})
end

function ACTIONS.close(player: Player)
	Network.Dialogue:FireClient(player, nil)
	Network.Shop:FireClient(player, nil)
	Network.Craft:FireClient(player, nil)
end

Network.Interact.OnServerEvent:Connect(function(player, npcId, action, arg)
	if typeof(action) ~= "string" then
		return
	end

	if action == "close" then
		ACTIONS.close(player)
		return
	end

	local resident = residents[npcId]
	if not resident then
		return
	end

	-- Range is re-checked on every action, not just the first: a shop panel
	-- left open while you walk away must not still be able to buy.
	if not inRange(player, resident) then
		ACTIONS.close(player)
		return
	end

	-- "offer:quest_id" style actions carry their argument in the id.
	local verb, payload = string.match(action, "^(%w+):(.+)$")
	local handler = ACTIONS[verb or action]
	if handler then
		handler(player, resident, payload or arg)
	end
end)

--------------------------------------------------------------------------------

local function build()
	local existing = Workspace:FindFirstChild(FOLDER)
	if existing then
		existing:Destroy()
	end
	table.clear(lanterns)
	table.clear(residents)

	folder = Create("Folder", { Name = FOLDER, Parent = Workspace })

	buildWalls()
	buildStreet()
	buildPlaza()
	for _, spec in Town.Buildings do
		local model = buildBuilding(spec)
		dressExterior(spec, model)
		dressInterior(spec, model)
	end
	buildTower()
	buildHouses()
	buildLanterns()
	dressStreets()
	spawnResidents()

	print(
		`[Town] Firstia built: {#Town.Buildings} buildings, {#Town.Npcs} residents, `
			.. `{#lanterns} lights, {#fires} fires, {#folder:GetDescendants()} parts`
	)
end

build()

RunService.Heartbeat:Connect(function(dt)
	driveResidents(dt)
	driveLights(dt)
end)

-- Closing whatever was open when someone leaves keeps no stale panel state.
Players.PlayerRemoving:Connect(function(player)
	ACTIONS.close(player)
end)
]]></string>
        </Properties>
      </Item>
      <Item class="Script" referent="88">
        <Properties>
          <string name="Name">World</string>
          <token name="RunContext">0</token>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	World — generates the Rampant Forest.

	Runs on server start and rebuilds from scratch, so editing Foliage or the
	World config and reconnecting Rojo reshapes the map immediately.

	Order matters: terrain first, then the road cut into it, then scatter, then
	the town. Everything vertical is placed using World.groundHeight, the same
	function that drove the terrain, which is why nothing floats.

	All randomness comes from one seeded Random. The same seed always produces
	the same forest — a world you can't reproduce is a world you can't debug.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local Create = require(ReplicatedStorage.Shared.Util.Create)
local Firstia = require(ReplicatedStorage.Shared.World.Firstia)
local Foliage = require(ReplicatedStorage.Shared.World.Foliage)
local World = require(ReplicatedStorage.Shared.Config.World)

local Terrain = Workspace.Terrain

local FOLDER_NAME = "RampantForest"

--- Terrain voxel resolution. 12 keeps the fill under ~2000 calls for the whole
--- region while staying fine enough that hills read as smooth.
local CELL = 12
local FILL_DEPTH = 60

--- Spacing of the scatter grid. Each point is jittered and then accepted or
--- rejected, so the result is clustered rather than gridded.
local SCATTER_STEP = 20

local function clearPrevious()
	local existing = Workspace:FindFirstChild(FOLDER_NAME)
	if existing then
		existing:Destroy()
	end

	local forest = World.Forest
	Terrain:FillBlock(
		CFrame.new((forest.MinX + forest.MaxX) / 2, 0, (forest.MinZ + forest.MaxZ) / 2 - 60),
		Vector3.new(forest.MaxX - forest.MinX + 260, 400, forest.MaxZ - forest.MinZ + 260),
		Enum.Material.Air
	)
end

--[[
	Fills the region column by column.

	Two things here do most of the visual work:

	SLOPE-AWARE MATERIALS. Rock on anything steep, dirt on shoulders, sand at
	the waterline, slate on the high ridges, grass everywhere else. Flat green
	across an entire map is the single strongest signal that terrain was
	generated rather than designed.

	WATER. Every column below the waterline gets filled to it, so the river
	carved by groundHeight actually holds water — a reflective surface, a
	landmark, and somewhere for the light to do something.
]]
local function fillTerrain()
	local forest = World.Forest
	local water = World.WaterLevel

	for x = forest.MinX, forest.MaxX, CELL do
		for z = forest.MinZ, forest.MaxZ, CELL do
			local top = World.groundHeight(x, z)
			local material = World.surfaceMaterial(x, z)

			Terrain:FillBlock(
				CFrame.new(x, top - FILL_DEPTH / 2, z),
				Vector3.new(CELL + 1, FILL_DEPTH, CELL + 1),
				material
			)

			-- Water sits on top of whatever the bed turned out to be.
			if top < water then
				local depth = water - top
				Terrain:FillBlock(
					CFrame.new(x, top + depth / 2, z),
					Vector3.new(CELL + 1, depth, CELL + 1),
					Enum.Material.Water
				)
			end

			-- Cut the road as a shallow layer of packed dirt on top.
			if math.abs(x) < World.RoadHalfWidth then
				Terrain:FillBlock(CFrame.new(x, top - 1.2, z), Vector3.new(CELL + 1, 4, CELL + 1), Enum.Material.Ground)
			end
		end
	end
end

local function scatter(folder: Folder)
	local rng = Random.new(World.Seed)
	local forest = World.Forest
	local placed = 0

	for x = forest.MinX + 20, forest.MaxX - 20, SCATTER_STEP do
		for z = forest.MinZ + 30, forest.MaxZ - 20, SCATTER_STEP do
			local px = x + rng:NextNumber(-11, 11)
			local pz = z + rng:NextNumber(-11, 11)

			-- Keep the road and the spawn clearing open.
			if World.isOnRoad(px, pz) then
				continue
			end

			-- Nothing grows in the river or off a cliff.
			if not World.isPlantable(px, pz) then
				continue
			end
			if (Vector3.new(px, 0, pz) - Vector3.new(World.Spawn.X, 0, World.Spawn.Z)).Magnitude < 42 then
				continue
			end

			-- The bunny arena stays open: a telegraph you can't see behind a
			-- tree trunk is not a telegraph.
			local toArena = Vector3.new(px, 0, pz) - Vector3.new(World.BunnyArena.X, 0, World.BunnyArena.Z)
			if toArena.Magnitude < World.BunnyArenaRadius then
				continue
			end

			-- Density varies across the map so there are thickets and glades
			-- rather than an even lawn of trees.
			local density = math.noise(px / 240, pz / 240, World.Seed + 3) + 0.5
			local roll = rng:NextNumber()

			local y = World.groundHeight(px, pz)
			local origin = CFrame.new(px, y - 0.6, pz) * CFrame.Angles(0, rng:NextNumber(0, math.pi * 2), 0)

			local model
			if roll < density * 0.78 then
				model = if rng:NextNumber() < 0.22 then Foliage.pine(origin, rng) else Foliage.tree(origin, rng)
			elseif roll < 0.78 then
				model = Foliage.bush(origin, rng)
			elseif roll < 0.9 then
				model = Foliage.grass(origin, rng)
			elseif roll < 0.97 then
				model = Foliage.rock(origin, rng)
			end

			if model then
				model.Parent = folder
				placed += 1
			end
		end
	end

	return placed
end

--- Grass and stones along the roadside, so the road has edges instead of just
--- being a different colour of ground.
local function dressRoad(folder: Folder)
	local rng = Random.new(World.Seed + 99)
	local forest = World.Forest

	for z = forest.MinZ + 40, forest.MaxZ - 10, 16 do
		for side = -1, 1, 2 do
			if rng:NextNumber() > 0.6 then
				continue
			end
			local px = side * (World.RoadHalfWidth + rng:NextNumber(0.5, 3.5))
			local pz = z + rng:NextNumber(-6, 6)
			local origin = CFrame.new(px, World.groundHeight(px, pz) - 0.3, pz)
				* CFrame.Angles(0, rng:NextNumber(0, math.pi * 2), 0)

			local model = if rng:NextNumber() < 0.3
				then Foliage.rock(origin, rng, rng:NextNumber(0.3, 0.6))
				else Foliage.grass(origin, rng)
			model.Parent = folder
		end
	end
end

local function placeSpawn(folder: Folder)
	-- Retire any spawn from the template place, or players keep appearing in
	-- whatever grey void the baseplate left behind.
	for _, descendant in Workspace:GetDescendants() do
		if descendant:IsA("SpawnLocation") and not descendant:IsDescendantOf(folder) then
			descendant:Destroy()
		end
	end

	local y = World.groundHeight(World.Spawn.X, World.Spawn.Z)

	Create("SpawnLocation", {
		Name = "ForestSpawn",
		Anchored = true,
		Size = Vector3.new(16, 1, 16),
		-- Facing north, up the road toward Firstia.
		CFrame = CFrame.new(World.Spawn.X, y + 0.5, World.Spawn.Z) * CFrame.Angles(0, math.pi, 0),
		Color = Color3.fromRGB(96, 92, 82),
		Material = Enum.Material.Slate,
		TopSurface = Enum.SurfaceType.Smooth,
		Duration = 0,
		Parent = folder,
	})
end

local function build()
	local started = os.clock()

	clearPrevious()
	local folder = Create("Folder", { Name = FOLDER_NAME, Parent = Workspace })

	fillTerrain()
	local placed = scatter(folder)
	dressRoad(folder)
	placeSpawn(folder)

	local town = Firstia(CFrame.new(World.FirstiaPosition) * CFrame.Angles(0, math.pi, 0))
	town.Parent = folder

	print(
		string.format(
			"[World] Rampant Forest built in %.1fs — %d props, road north to Firstia.",
			os.clock() - started,
			placed
		)
	)
end

build()
]]></string>
        </Properties>
      </Item>
    </Item>
  </Item>
  <Item class="StarterPlayer" referent="89">
    <Properties>
      <string name="Name">StarterPlayer</string>
    </Properties>
    <Item class="StarterPlayerScripts" referent="90">
      <Properties>
        <string name="Name">StarterPlayerScripts</string>
      </Properties>
      <Item class="Folder" referent="91">
        <Properties>
          <string name="Name">Client</string>
        </Properties>
        <Item class="ModuleScript" referent="92">
          <Properties>
            <string name="Name">Admin</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Admin — chat commands for driving the UI with no gameplay attached.

	Replaces the old keybind harness. Keybinds are for things a player does
	constantly (move, sprint, open the inventory); everything else is a typed
	command, which costs nothing to add, needs no on-screen legend, and gets
	autocomplete in the chat bar for free.

	Type `/` in chat to see them all, or `/help` for the list with arguments.

	AUTHORISATION: Studio-only by default. These write straight to the client
	store, so on a live server they'd let anyone heal themselves. Before
	shipping, this has to move behind a server-side check -- the UserId list
	below is a stopgap for playtesting, not security.
]]

local Lighting = game:GetService("Lighting")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TextChatService = game:GetService("TextChatService")

local Skills = require(game:GetService("ReplicatedStorage").Shared.Config.Skills)
local World = require(game:GetService("ReplicatedStorage").Shared.Config.World)

local Leveling = require(script.Parent.Controllers.Leveling)
local UI = require(script.Parent.UI)
local UIState = UI.State

--- Add your UserId here to use commands outside Studio during playtests.
local ALLOWED_USER_IDS: { number } = {}

local Admin = {}

--------------------------------------------------------------------------------
-- Demo content
--------------------------------------------------------------------------------

local DEMO_ITEMS = {
	{
		id = "bird_helm",
		name = "Nightslayer's Beak",
		rarity = "Cursed",
		slot = "Head",
		description = "A beaked helm taken from something that hunted at night. It does not come off.",
		stats = {
			{ label = "Attack", value = "+128" },
			{ label = "Move Speed", value = "+12%" },
		},
		drawbacks = {
			"Cannot be unequipped outside a safe zone",
			"Defence reduced by 40%",
			"NPC reactions worsened",
		},
	},
	{
		id = "vorpal_fang",
		name = "Vorpal Fang",
		rarity = "Unique",
		slot = "Weapon",
		description = "Dropped by something small and far too fast.",
		stats = {
			{ label = "Attack", value = "+96" },
			{ label = "Crit Rate", value = "+18%" },
		},
	},
	{
		id = "traveler_coat",
		name = "Traveler's Coat",
		rarity = "Rare",
		slot = "Body",
		description = "Unremarkable, reliable, and warm.",
		stats = { { label = "Defence", value = "+34" } },
	},
	{ id = "rabbit_pelt", name = "Rabbit Pelt", rarity = "Common", quantity = 12, description = "Crafting material." },
	{ id = "moon_shard", name = "Moon Shard", rarity = "Epic", quantity = 3, description = "Cold to the touch." },
	{ id = "iron_ore", name = "Iron Ore", rarity = "Common", quantity = 47 },
	{ id = "phoenix_down", name = "Phoenix Ember", rarity = "Legendary", quantity = 1, description = "One more try." },
	{ id = "green_herb", name = "Green Herb", rarity = "Uncommon", quantity = 8 },
}

local BOSSES = {
	{ name = "Vorpal Bunny", title = "Unique Scenario", maxHealth = 12000, maxPhase = 1, unique = true },
	{ name = "Lycagon the Nightslayer", title = "Unique Scenario", maxHealth = 480000, maxPhase = 7, unique = true },
	{ name = "Wethermon the Tombguard", title = "Unique Scenario", maxHealth = 260000, maxPhase = 3, unique = true },
}

--------------------------------------------------------------------------------
-- Plumbing
--------------------------------------------------------------------------------

local registered: { { alias: string, args: string?, help: string } } = {}

local function systemMessage(text: string)
	local channels = TextChatService:FindFirstChild("TextChannels")
	local system = channels and channels:FindFirstChild("RBXSystem")
	if system then
		system:DisplaySystemMessage(text)
	else
		warn(`[Admin] {text}`)
	end
end

--- Splits "/damage 250" into { "250" }.
local function parseArgs(text: string): { string }
	local args = {}
	for word in string.gmatch(text, "%S+") do
		table.insert(args, word)
	end
	table.remove(args, 1) -- drop the alias itself
	return args
end

type CommandSpec = {
	alias: string,
	secondary: string?,
	args: string?,
	help: string,
	run: (args: { string }) -> (),
}

local function register(spec: CommandSpec)
	local command = Instance.new("TextChatCommand")
	command.Name = spec.alias
	command.PrimaryAlias = `/{spec.alias}`
	if spec.secondary then
		command.SecondaryAlias = `/{spec.secondary}`
	end
	command.AutocompleteVisible = true
	command.Parent = TextChatService

	command.Triggered:Connect(function(_source, text)
		local ok, err = pcall(spec.run, parseArgs(text))
		if not ok then
			systemMessage(`/{spec.alias} failed: {err}`)
		end
	end)

	table.insert(registered, { alias = spec.alias, args = spec.args, help = spec.help })
end

--------------------------------------------------------------------------------
-- Shared actions
--------------------------------------------------------------------------------

local bossIndex = 0

local function damagePlayer(amount: number)
	if UIState.get("dead") then
		return
	end
	local health = math.max(UIState.get("health") - amount, 0)
	UIState.set("health", health)
	UI.DamageNumbers.show(amount, "taken")

	if health <= 0 then
		UIState.patch({ dead = true, boss = nil })
	end
end

local function damageBoss(amount: number, crit: boolean)
	local boss = UIState.get("boss")
	if not boss then
		return
	end

	boss.health = math.max(boss.health - amount, 0)

	if boss.maxPhase and boss.maxPhase > 1 then
		boss.phase = math.max(math.ceil(boss.health / boss.maxHealth * boss.maxPhase), 1)
	end

	UIState.set("boss", boss)
	UI.DamageNumbers.show(amount, if crit then "crit" else "normal")

	if boss.health <= 0 then
		UI.Toast.push({ title = `{boss.name} defeated`, subtitle = "Encounter cleared", kind = "quest" })
		task.delay(1.2, function()
			if UIState.get("boss") == boss then
				UIState.set("boss", nil)
			end
		end)
	end
end

local function engageBoss(which: number?)
	local existing = UIState.get("boss")

	-- No argument and a boss already up: treat it as "hit it".
	if not which and existing and existing.health > 0 then
		local crit = math.random() < 0.25
		damageBoss(math.random(2000, 6000) * (if crit then 3 else 1), crit)
		return
	end

	if which then
		bossIndex = math.clamp(which, 1, #BOSSES)
	else
		bossIndex = bossIndex % #BOSSES + 1
	end

	local template = BOSSES[bossIndex]
	UIState.set("boss", {
		name = template.name,
		title = template.title,
		health = template.maxHealth,
		maxHealth = template.maxHealth,
		phase = template.maxPhase,
		maxPhase = template.maxPhase,
		unique = template.unique,
	})
	systemMessage(`Engaged {template.name}. /boss again to damage it.`)
end

--------------------------------------------------------------------------------
-- Commands
--------------------------------------------------------------------------------

local function registerAll()
	register({
		alias = "damage",
		secondary = "dmg",
		args = "[amount]",
		help = "Take damage. Random 40-120 with no amount.",
		run = function(args)
			damagePlayer(tonumber(args[1]) or math.random(40, 120))
		end,
	})

	register({
		alias = "heal",
		args = "[amount]",
		help = "Heal. Restores everything with no amount.",
		run = function(args)
			local maxHealth = UIState.get("maxHealth")
			local amount = tonumber(args[1])
			local health = if amount then math.min(UIState.get("health") + amount, maxHealth) else maxHealth
			local gained = health - UIState.get("health")

			UIState.set("health", health)
			if gained > 0 then
				UI.DamageNumbers.show(gained, "heal")
			end
		end,
	})

	register({
		alias = "boss",
		args = "[1-3]",
		help = "Engage a boss, or damage the current one.",
		run = function(args)
			engageBoss(tonumber(args[1]))
		end,
	})

	register({
		alias = "scenario",
		args = "[name]",
		help = "Play the Unique Scenario banner.",
		run = function(args)
			local name = if #args > 0 then table.concat(args, " ") else "Vorpal Bunny"
			UI.ScenarioBanner.play({
				kicker = "Unique Scenario",
				name = name,
				note = "First clear across all servers",
			})
		end,
	})

	register({
		alias = "toast",
		help = "Show a random notification.",
		run = function()
			local samples = {
				{ title = "Vorpal Fang", subtitle = "Unique weapon", kind = "loot" },
				{ title = "Level up", subtitle = "Reached level 12", kind = "levelup" },
				{ title = "Curse applied", subtitle = "Nightslayer's Beak", kind = "curse" },
				{ title = "Party member down", subtitle = "Pencilgon", kind = "warning" },
			}
			UI.Toast.push(samples[math.random(#samples)])
		end,
	})

	register({
		alias = "status",
		args = "[buff|debuff|curse]",
		help = "Apply a timed status effect.",
		run = function(args)
			local kinds = { "buff", "debuff", "curse" }
			local labels = { "Haste", "Bleed", "Cursed Mark", "Fortified", "Poison" }
			local kind = args[1]
			if not table.find(kinds, kind) then
				kind = kinds[math.random(#kinds)]
			end

			local statuses = UIState.get("statuses")
			table.insert(statuses, {
				id = `status_{os.clock()}`,
				label = labels[math.random(#labels)],
				kind = kind,
				expiresAt = os.clock() + math.random(5, 14),
			})
			UIState.set("statuses", statuses)
		end,
	})

	register({
		alias = "level",
		args = "[amount]",
		help = "Gain levels, with the stat growth and skill points that follow.",
		run = function(args)
			for _ = 1, math.clamp(tonumber(args[1]) or 1, 1, 100) do
				Leveling.levelUp()
			end
		end,
	})

	register({
		alias = "xp",
		args = "[amount]",
		help = "Grant experience, rolling levels over as it goes.",
		run = function(args)
			Leveling.addExperience(math.max(tonumber(args[1]) or 50, 1))
		end,
	})

	register({
		alias = "demo",
		help = "Fill every menu with sample progression, for UI work.",
		run = function()
			UIState.patch({
				inventory = DEMO_ITEMS,
				equipment = { Head = "bird_helm", Weapon = "vorpal_fang", Body = "traveler_coat" },
				selectedItem = "bird_helm",
				skillPoints = 5,
				unlockedSkills = { "step", "read", "secondwind" },
				loadout = { "step" },
				earnedTitles = { "kusoge_hunter", "bird_brain" },
				equippedTitle = "kusoge_hunter",
				parryRank = 2,
				scenarioStates = {
					vorpal_bunny = "cleared",
					lycagon = "active",
					rustclad = "taken",
				},
			})
			for _ = 1, 10 do
				Leveling.levelUp()
			end
			systemMessage("Demo state applied.")
		end,
	})

	register({
		alias = "setscenario",
		args = "<id> <available|active|cleared|taken>",
		help = "Force a scenario's state.",
		run = function(args)
			local id, newState = args[1], args[2]
			if not (id and newState) then
				systemMessage("Usage: /setscenario <id> <available|active|cleared|taken>")
				return
			end
			local states = UIState.get("scenarioStates") or {}
			states[id] = newState
			UIState.set("scenarioStates", states)
			systemMessage(`{id} -> {newState}`)
		end,
	})

	register({
		alias = "kill",
		help = "Die, and open the death screen.",
		run = function()
			UIState.patch({ health = 0, dead = true, boss = nil })
		end,
	})

	register({
		alias = "revive",
		help = "Clear the death state and restore vitals.",
		run = function()
			UIState.patch({
				dead = false,
				health = UIState.get("maxHealth"),
				stamina = UIState.get("maxStamina"),
			})
		end,
	})

	register({
		alias = "dive",
		help = "Replay the dive sequence from the top.",
		run = function()
			UI.Flow.replayBoot()
		end,
	})

	register({
		alias = "skip",
		help = "Skip the front-end straight into gameplay.",
		run = function()
			UI.Flow.skipToPlaying({ name = "Sunraku", origin = "traveler", race = "human", style = "unarmed" })
		end,
	})

	register({
		alias = "give",
		help = "Reseed the demo inventory and equipment.",
		run = function()
			UIState.patch({
				inventory = DEMO_ITEMS,
				equipment = { Head = "bird_helm", Weapon = "vorpal_fang", Body = "traveler_coat" },
				selectedItem = "bird_helm",
			})
			systemMessage(`Gave {#DEMO_ITEMS} demo items.`)
		end,
	})

	register({
		alias = "points",
		args = "[amount]",
		help = "Grant skill points. Three with no amount.",
		run = function(args)
			local gain = math.max(tonumber(args[1]) or 3, 1)
			UIState.set("skillPoints", (UIState.get("skillPoints") or 0) + gain)
			systemMessage(`Skill points: {UIState.get("skillPoints")}`)
		end,
	})

	register({
		alias = "gallery",
		help = "Teleport to the creature lineup.",
		run = function()
			local character = Players.LocalPlayer.Character
			local root = character and character:FindFirstChild("HumanoidRootPart")
			if not root then
				systemMessage("No character to move.")
				return
			end
			-- Positioned looking at the line rather than dropped beside it.
			root.CFrame = CFrame.new(World.GalleryView, World.GalleryCenter + Vector3.new(0, 5, 0))
		end,
	})

	register({
		alias = "time",
		args = "[0-24]",
		help = "Set the time of day. Reports it with no argument.",
		run = function(args)
			local hour = tonumber(args[1])
			if not hour then
				systemMessage(`ClockTime is {string.format("%.1f", Lighting.ClockTime)}`)
				return
			end
			Lighting.ClockTime = math.clamp(hour, 0, 24)
			systemMessage(`ClockTime {string.format("%.1f", Lighting.ClockTime)}`)
		end,
	})

	register({
		alias = "bright",
		args = "[value]",
		help = "Set Lighting.Brightness. Reports it with no argument.",
		run = function(args)
			local value = tonumber(args[1])
			if not value then
				systemMessage(
					`Brightness {string.format("%.2f", Lighting.Brightness)}, `
						.. `exposure {string.format("%.2f", Lighting.ExposureCompensation)}`
				)
				return
			end
			Lighting.Brightness = math.clamp(value, 0, 10)
			systemMessage(`Brightness {string.format("%.2f", Lighting.Brightness)}`)
		end,
	})

	register({
		alias = "exposure",
		args = "[value]",
		help = "Set exposure compensation. Negative is darker.",
		run = function(args)
			local value = tonumber(args[1])
			if not value then
				systemMessage(`Exposure {string.format("%.2f", Lighting.ExposureCompensation)}`)
				return
			end
			Lighting.ExposureCompensation = math.clamp(value, -5, 5)
			systemMessage(`Exposure {string.format("%.2f", Lighting.ExposureCompensation)}`)
		end,
	})

	register({
		alias = "parry",
		args = "[0-3]",
		help = "Set your parry rank. 0 locks it, 3 is max.",
		run = function(args)
			local Parry = require(game:GetService("ReplicatedStorage").Shared.Config.Parry)
			local rank = math.clamp(math.floor(tonumber(args[1]) or Parry.maxRank()), 0, Parry.maxRank())
			UIState.set("parryRank", rank)
			local tier = Parry.forRank(rank)
			systemMessage(if tier then `Parry rank {rank} — {tier.summary}` else "Parry locked.")
		end,
	})

	register({
		alias = "helper",
		args = "[off|adaptive|always]",
		help = "Set the parry helper mode.",
		run = function(args)
			local mode = string.lower(args[1] or "")
			if mode ~= "off" and mode ~= "adaptive" and mode ~= "always" then
				systemMessage(`Parry helper is "{UIState.get("parryHelper")}". Usage: /helper off|adaptive|always`)
				return
			end
			UIState.set("parryHelper", mode)
			systemMessage(`Parry helper: {mode}`)
		end,
	})

	register({
		alias = "unlockall",
		help = "Unlock every skill for the current style.",
		run = function()
			local character = UIState.get("character")
			local unlocked = UIState.get("unlockedSkills") or {}
			for _, skill in Skills.forStyle(character and character.style) do
				if not table.find(unlocked, skill.id) then
					table.insert(unlocked, skill.id)
				end
			end
			UIState.set("unlockedSkills", unlocked)
			systemMessage(`Unlocked {#unlocked} skills.`)
		end,
	})

	register({
		alias = "cooldowns",
		secondary = "cd",
		help = "Clear all ability cooldowns.",
		run = function()
			UIState.set("cooldowns", {})
			systemMessage("Cooldowns cleared.")
		end,
	})

	register({
		alias = "menu",
		args = "[tab]",
		help = "Open the character menu, optionally on a named tab.",
		run = function(args)
			local tabs = { "status", "inventory", "skills", "scenarios", "titles" }
			local tab = string.lower(args[1] or "")
			if table.find(tabs, tab) then
				UIState.set("menuTab", tab)
			end
			UIState.set("menuOpen", "menu")
		end,
	})

	register({
		alias = "help",
		help = "List every admin command.",
		run = function()
			systemMessage("— Admin commands —")
			for _, entry in registered do
				local usage = if entry.args then `/{entry.alias} {entry.args}` else `/{entry.alias}`
				systemMessage(`{usage}  ·  {entry.help}`)
			end
		end,
	})
end

--------------------------------------------------------------------------------

local function authorised(): boolean
	if RunService:IsStudio() then
		return true
	end
	return table.find(ALLOWED_USER_IDS, Players.LocalPlayer.UserId) ~= nil
end

function Admin.start()
	-- Nothing is seeded. A character starts at level 1 with an empty bag, no
	-- unlocks and no titles; /demo exists for when you want the menus full.

	if not authorised() then
		return
	end

	if TextChatService.ChatVersion ~= Enum.ChatVersion.TextChatService then
		warn("[Admin] Commands need TextChatService; legacy chat is enabled, so they are unavailable.")
		return
	end

	registerAll()
end

return Admin
]]></string>
          </Properties>
        </Item>
        <Item class="Folder" referent="93">
          <Properties>
            <string name="Name">Controllers</string>
          </Properties>
          <Item class="ModuleScript" referent="94">
            <Properties>
              <string name="Name">Abilities</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Abilities — the Q/E/R loadout, and casting what's in it.

	A cast has to clear four gates in order, and each failure is reported
	distinctly so the HUD can say *why* nothing happened. "Nothing happened" is
	the worst possible feedback in a combat game -- a greyed slot, a stamina
	flash and a cooldown timer are three different problems and the player
	needs to tell them apart at a glance.

	    locked    -> your level is too low for this slot
	    empty     -> no skill assigned
	    cooldown  -> still recharging
	    stamina   -> can't afford it

	Effects live in EFFECTS, keyed by skill id, with a generic fallback. Real
	combat lands later; the framework and its costs are what matter now.
]]

local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Bindings = require(ReplicatedStorage.Shared.Config.Bindings)
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Network = require(ReplicatedStorage.Shared.Network)
local Skills = require(ReplicatedStorage.Shared.Config.Skills)

local Events = require(script.Parent.Parent.UI.Core.Events)
local Loadout = require(script.Parent.Parent.UI.Core.Loadout)
local UI = require(script.Parent.Parent.UI)
local UIState = UI.State

local DASH_SPEED = 62

local Abilities = {}

-- Signals live on the Events bus so the HUD can listen without requiring this
-- module back. See Events.luau.
Abilities.Cast = Events.AbilityCast
Abilities.Failed = Events.AbilityFailed

--------------------------------------------------------------------------------
-- Effects
--------------------------------------------------------------------------------

local function dash()
	local character = Players.LocalPlayer.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not (humanoid and root) then
		return
	end

	local direction = humanoid.MoveDirection
	if direction.Magnitude < 0.1 then
		direction = root.CFrame.LookVector
	end

	-- Flatten so a dash never launches you off a slope, and guard the Unit
	-- call -- a zero vector normalises to NaN and would fling the character.
	direction = Vector3.new(direction.X, 0, direction.Z)
	if direction.Magnitude < 0.01 then
		return
	end

	root.AssemblyLinearVelocity = direction.Unit * DASH_SPEED + Vector3.new(0, root.AssemblyLinearVelocity.Y, 0)
end

local EFFECTS = {
	step = function()
		dash()
		-- The i-frames are the actual skill; the dash is just how you leave.
		-- Told to the server because only the server decides whether a lunge
		-- connected.
		Network.Invulnerable:FireServer(Encounters.DodgeInvulnerability)
	end,

	secondwind = function()
		local max = UIState.get("maxStamina")
		UIState.set("stamina", math.min(UIState.get("stamina") + max * 0.3, max))
		UI.DamageNumbers.show(math.floor(max * 0.3), "heal")
	end,
}

--- Everything without a bespoke effect still reads as *something happening*.
local function genericEffect(skill)
	UI.Toast.push({ title = skill.name, subtitle = "Used", kind = "system", duration = 1.6 })
end

--------------------------------------------------------------------------------

Abilities.assign = Loadout.assign
Abilities.remainingCooldown = Loadout.remainingCooldown

function Abilities.cast(slotIndex: number)
	if UIState.get("phase") ~= "playing" or UIState.get("dead") then
		return
	end

	if not Loadout.isSlotUnlocked(slotIndex) then
		Abilities.Failed:Fire(slotIndex, "locked")
		return
	end

	local skill = Skills.find(Loadout.get(slotIndex))
	if not skill then
		Abilities.Failed:Fire(slotIndex, "empty")
		return
	end

	if Loadout.remainingCooldown(skill.id) > 0 then
		Abilities.Failed:Fire(slotIndex, "cooldown")
		return
	end

	local cost = skill.staminaCost or 0
	if UIState.get("stamina") < cost then
		Abilities.Failed:Fire(slotIndex, "stamina")
		return
	end

	if cost > 0 then
		UIState.set("stamina", UIState.get("stamina") - cost)
	end

	if skill.cooldown and skill.cooldown > 0 then
		local cooldowns = UIState.get("cooldowns") or {}
		cooldowns[skill.id] = os.clock() + skill.cooldown
		UIState.set("cooldowns", cooldowns)
	end

	local effect = EFFECTS[skill.id]
	if effect then
		effect(skill)
	else
		genericEffect(skill)
	end

	Abilities.Cast:Fire(slotIndex, skill)
end

function Abilities.start()
	-- Slot order comes from Skills, the action names from Bindings, so a slot
	-- gains its gamepad button by existing rather than by being listed twice.
	local actions = {}
	for action in Bindings.SkillSlots do
		table.insert(actions, action)
	end

	UserInputService.InputBegan:Connect(function(input, processed)
		if processed then
			return
		end
		local action = Bindings.resolve(input, actions)
		if action then
			Abilities.cast(Bindings.SkillSlots[action])
		end
	end)

	-- Losing a level must not leave a skill sitting in a slot you can no
	-- longer use.
	UIState.observe("level", function()
		Loadout.prune()
	end)
end

return Abilities
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="95">
            <Properties>
              <string name="Name">Acoustics</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Acoustics — the world's reverb, retuned as you move through it.

	One `ReverbSoundEffect` on the effects bus, cross-faded between the presets
	in `Config/Mix`. That is the whole mechanism, and it is the cheapest "this is
	a place" signal available: the same footstep sample in Wethermon's tomb and
	on the forest road becomes two different sounds, and players read the
	difference without ever noticing they are reading it.

	WHY THE EFFECT IS BUILT HERE AND NOT ON THE SERVER. Reverb is a property of
	where *you* are standing. The bus itself has to be server-owned so that
	replicated sounds can point at it, but the effect hanging off that bus is
	client-local — created here, so it can never leak one player's location into
	another player's mix.

	WHY THE ZONES ARE HERE AND NOT IN Mix. `Mix.Spaces` describes what a tomb
	sounds like; this file knows where the tomb is. Keeping the acoustic presets
	free of world coordinates means they can be auditioned and retuned without
	touching geography, and a new building needs a line here rather than an
	edit to the mixer.

	BLENDING, not switching. Every zone contributes a weight that falls off over
	`Mix.SpaceFalloff` studs, the strongest wins, and the effect tweens toward
	it. Snapping between presets at a hard boundary is instantly audible as a
	glitch; a doorway should read as a threshold you cross.
]]

local Players = game:GetService("Players")
local TweenService = game:GetService("TweenService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Mix = require(ReplicatedStorage.Shared.Config.Mix)
local Town = require(ReplicatedStorage.Shared.Config.Town)
local World = require(ReplicatedStorage.Shared.Config.World)

local Acoustics = {}

local player = Players.LocalPlayer

--[[
	Where the spaces are, most enclosed first — ties resolve to whichever is
	declared earlier, so the tomb beats the forest that sits on top of it.

	`radius` is a circle in XZ; `half` is a box half-extent. `below` restricts a
	zone to positions under a height, which is what separates Wethermon's hall
	from the woodland directly above it — the two overlap perfectly in plan and
	are only distinguishable by depth.
]]
local ZONES = {
	{
		space = "tomb",
		center = Vector3.new(214, 0, 62),
		radius = 62,
		--- The hall floor is at -26 and its ceiling at 8. Anything above that is
		--- standing on the ground outside, not inside the tomb.
		below = -4,
	},
	{
		space = "arena",
		center = Town.Colosseum.center,
		radius = Town.Colosseum.radius + 12,
	},
	{
		space = "town",
		--- Firstia proper plus its courtyard, which run together.
		center = Vector3.new(0, 0, -390),
		half = Vector3.new(105, 0, 105),
	},
	{
		space = "town",
		center = World.Rabituza.center,
		half = Vector3.new(World.Rabituza.width / 2, 0, World.Rabituza.depth / 2),
	},
	{
		space = "forest",
		center = Vector3.new(
			(World.Forest.MinX + World.Forest.MaxX) / 2,
			0,
			(World.Forest.MinZ + World.Forest.MaxZ) / 2
		),
		half = Vector3.new((World.Forest.MaxX - World.Forest.MinX) / 2, 0, (World.Forest.MaxZ - World.Forest.MinZ) / 2),
	},
}

--- 1 well inside the zone, 0 well outside, ramping across the falloff band.
local function strengthOf(zone, at: Vector3): number
	if zone.below and at.Y > zone.below then
		return 0
	end

	local flat = Vector3.new(at.X - zone.center.X, 0, at.Z - zone.center.Z)
	local outside
	if zone.radius then
		outside = flat.Magnitude - zone.radius
	else
		-- Box distance: how far past the nearest face, on whichever axis is worst.
		outside = math.max(math.abs(flat.X) - zone.half.X, math.abs(flat.Z) - zone.half.Z)
	end

	return math.clamp(1 - outside / Mix.SpaceFalloff, 0, 1)
end

local function spaceAt(at: Vector3): string
	local best, bestStrength = Mix.DefaultSpace, 0
	for _, zone in ZONES do
		local strength = strengthOf(zone, at)
		if strength > bestStrength then
			best, bestStrength = zone.space, strength
		end
	end
	return best
end

--------------------------------------------------------------------------------

local effect: ReverbSoundEffect? = nil
local current = Mix.DefaultSpace
local tween: Tween? = nil

local function applySpace(id: string)
	local space = Mix.Spaces[id]
	if not (effect and space) then
		return
	end

	if tween then
		tween:Cancel()
	end
	tween = TweenService:Create(effect, TweenInfo.new(Mix.SpaceBlend), {
		DecayTime = space.decay,
		Density = space.density / 100,
		Diffusion = space.diffusion / 100,
		WetLevel = space.wet,
		DryLevel = space.dry,
	})
	tween:Play()
end

function Acoustics.start()
	task.spawn(function()
		-- The bus tree is built by the server, so it arrives a moment after the
		-- client does. Poll rather than block: reverb turning up late is
		-- unnoticeable, a controller that never starts is not.
		local group
		for _ = 1, 100 do
			group = Audio.group("Sfx")
			if group then
				break
			end
			task.wait(0.1)
		end
		if not group then
			warn("[Acoustics] effects bus never arrived; running dry")
			return
		end

		local space = Mix.Spaces[Mix.DefaultSpace]
		effect = Instance.new("ReverbSoundEffect")
		effect.Name = "Space"
		effect.DecayTime = space.decay
		effect.Density = space.density / 100
		effect.Diffusion = space.diffusion / 100
		effect.WetLevel = space.wet
		effect.DryLevel = space.dry
		effect.Parent = group
	end)

	-- Quarter-second cadence. The blend is nearly a second long, so sampling
	-- any faster costs frames to decide something the ear cannot resolve.
	task.spawn(function()
		while true do
			task.wait(0.25)

			local character = player.Character
			local root = character and character:FindFirstChild("HumanoidRootPart")
			if root and effect then
				local found = spaceAt(root.Position)
				if found ~= current then
					current = found
					applySpace(found)
				end
			end
		end
	end)
end

--- Which space the player is currently in. Exposed for the admin readout and
--- for anything that wants to react to being indoors.
function Acoustics.space(): string
	return current
end

return Acoustics
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="96">
            <Properties>
              <string name="Name">Ambience</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Ambience — the world moving without being asked.

	Wind that shifts, canopies that sway with it, leaves that come loose, gusts
	that pass through. Nothing here reacts to the player at all, which is the
	entire point: a world that only moves when you touch it is a set, and a set
	is the thing that makes a game feel like a tech demo.

	CLIENT-SIDE AND DISTANCE-CULLED, deliberately. Swaying 226 trees on the
	server would mean pushing thousands of CFrames a frame to every client for
	scenery. Instead each client sways only the canopies it can actually see,
	rebuilt as you move. Nobody can tell the difference and it costs almost
	nothing.

	Only canopies move. Trunks staying rigid while foliage moves is what real
	trees do, and moving the trunk makes the whole thing read as rubber.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Network = require(ReplicatedStorage.Shared.Network)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)

local player = Players.LocalPlayer

--- How far out foliage is animated. Beyond this, sway is invisible anyway.
local SWAY_RANGE = 130
--- How often the nearby-foliage list is rebuilt.
local REBUILD_INTERVAL = 1.5
--- Cap, so a dense thicket can't tank the frame rate.
local MAX_SWAYING = 220

local Ambience = {}

type Swayer = { part: BasePart, rest: CFrame, phase: number, amount: number }

local swaying: { Swayer } = {}
local rebuildTimer = 0

--- Wind, as a direction and a strength that both drift over minutes.
local windAngle = 0
local windStrength = 0.5
local gustUntil = 0
local gustStrength = 0

local leafTimer = 0
local clock = 0

--------------------------------------------------------------------------------

local function characterPosition(): Vector3?
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	return root and root.Position
end

--[[
	Collects canopy parts near the player.

	Only foliage tagged by the world generator, and only the leafy parts — a
	trunk that sways reads as rubber. Rest CFrames are captured here rather
	than read live, so sway is always relative to where the tree actually
	stands and can never accumulate drift.
]]
local function rebuild(around: Vector3)
	table.clear(swaying)

	local forest = Workspace:FindFirstChild("RampantForest")
	if not forest then
		return
	end

	for _, model in forest:GetChildren() do
		if #swaying >= MAX_SWAYING then
			break
		end
		if not model:IsA("Model") or not model:GetAttribute("Foliage") then
			continue
		end

		local pivot = model:GetPivot().Position
		local distance = (Vector3.new(pivot.X, 0, pivot.Z) - Vector3.new(around.X, 0, around.Z)).Magnitude
		if distance > SWAY_RANGE then
			continue
		end

		-- Nearer trees sway more visibly; far ones would just shimmer.
		local falloff = 1 - (distance / SWAY_RANGE) ^ 2

		for _, part in model:GetChildren() do
			if not part:IsA("BasePart") then
				continue
			end
			local name = part.Name
			if not (string.find(name, "Canopy") or string.find(name, "Tier") or string.find(name, "Clump")) then
				continue
			end

			table.insert(swaying, {
				part = part,
				rest = part.CFrame,
				-- Per-part phase, so a tree ripples rather than moving as a slab.
				phase = (pivot.X + pivot.Z + part.Position.Y) * 0.35,
				amount = falloff * (0.6 + math.random() * 0.7),
			})

			if #swaying >= MAX_SWAYING then
				break
			end
		end
	end
end

--------------------------------------------------------------------------------

local function step(dt: number)
	clock += dt

	local position = characterPosition()
	if not position then
		return
	end

	------------------------------------------------------------------------
	-- Wind. Direction and strength both wander; gusts arrive on their own.
	------------------------------------------------------------------------

	windAngle += dt * 0.05
	windStrength = 0.45 + math.sin(clock * 0.07) * 0.3

	if os.clock() > gustUntil and math.random() < dt * 0.06 then
		gustUntil = os.clock() + 1.6 + math.random() * 2.2
		gustStrength = 1.2 + math.random() * 1.4
		Audio.playAt("windRush", position)
		-- A gust shakes leaves loose ahead of it, which is what actually sells
		-- it — you see the wind arrive before you feel it.
		Vfx.leaves(position + Vector3.new(math.random(-40, 40), 0, math.random(-40, 40)), 8)
	end

	local gust = if os.clock() < gustUntil then gustStrength else 0
	local strength = windStrength + gust
	local windX = math.cos(windAngle)
	local windZ = math.sin(windAngle)

	------------------------------------------------------------------------
	-- Canopy sway
	------------------------------------------------------------------------

	rebuildTimer -= dt
	if rebuildTimer <= 0 then
		rebuildTimer = REBUILD_INTERVAL
		rebuild(position)
	end

	for _, entry in swaying do
		if not entry.part.Parent then
			continue
		end
		local wave = math.sin(clock * 1.35 + entry.phase)
		local lift = math.sin(clock * 0.9 + entry.phase * 1.4) * 0.25
		local push = wave * entry.amount * strength

		entry.part.CFrame = entry.rest
			* CFrame.new(windX * push * 0.5, lift * entry.amount, windZ * push * 0.5)
			* CFrame.Angles(math.rad(windZ * push * 2.2), 0, math.rad(-windX * push * 2.2))
	end

	------------------------------------------------------------------------
	-- Loose leaves, more often in wind
	------------------------------------------------------------------------

	leafTimer -= dt * (0.6 + strength)
	if leafTimer <= 0 then
		leafTimer = 2.5 + math.random() * 3.5
		Vfx.leaves(position + Vector3.new(math.random(-45, 45), 0, math.random(-45, 45)), 3)
	end
end

--------------------------------------------------------------------------------
-- Air
--------------------------------------------------------------------------------

--[[
	Motes by day, fireflies by night.

	One emitter, parented to a part that rides along with the player, rather
	than thousands of particles scattered across the map. The player only ever
	sees the volume they are standing in, so simulating anything further out is
	paying for atmosphere nobody can look at.

	This is the cheapest large upgrade available to a scene built from flat
	primitives: particles in the air give the light something to catch, and a
	still frame stops reading as a diagram of a place and starts reading as
	being in one.
]]
local airPart: BasePart
local airEmitter: ParticleEmitter
local isNight = false

local function buildAir()
	airPart = Instance.new("Part")
	airPart.Name = "Air"
	-- A wide, shallow box: particles spawn all around the player and drift, so
	-- they never appear to be emitted *by* them.
	airPart.Size = Vector3.new(90, 26, 90)
	airPart.Transparency = 1
	airPart.Anchored = true
	airPart.CanCollide = false
	airPart.CanQuery = false
	airPart.CanTouch = false
	airPart.Parent = Workspace

	airEmitter = Instance.new("ParticleEmitter")
	airEmitter.Texture = "rbxasset://textures/particles/sparkles_main.dds"
	airEmitter.Rate = 26
	airEmitter.Lifetime = NumberRange.new(4, 8)
	airEmitter.Speed = NumberRange.new(0.4, 1.6)
	airEmitter.SpreadAngle = Vector2.new(180, 180)
	airEmitter.Drag = 1.4
	-- Emitted through a wide box around the player, so they drift past rather
	-- than streaming out of a point at their feet.
	airEmitter.Shape = Enum.ParticleEmitterShape.Box
	airEmitter.ShapeStyle = Enum.ParticleEmitterShapeStyle.Volume
	airEmitter.Size = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 0),
		NumberSequenceKeypoint.new(0.25, 0.3),
		NumberSequenceKeypoint.new(0.75, 0.3),
		NumberSequenceKeypoint.new(1, 0),
	})
	airEmitter.Parent = airPart
end

--- Motes are pale, slow and nearly invisible; fireflies are warm, bright and
--- few. Swapping the emitter's look rather than its existence keeps one object
--- doing both jobs.
local function setAirMood(night: boolean)
	if night then
		airEmitter.Rate = 10
		airEmitter.Color = ColorSequence.new(Color3.fromRGB(196, 255, 140), Color3.fromRGB(255, 226, 128))
		airEmitter.LightEmission = 1
		airEmitter.LightInfluence = 0
		airEmitter.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1),
			NumberSequenceKeypoint.new(0.2, 0.1),
			NumberSequenceKeypoint.new(0.5, 0.55),
			NumberSequenceKeypoint.new(0.8, 0.1),
			NumberSequenceKeypoint.new(1, 1),
		})
	else
		airEmitter.Rate = 26
		airEmitter.Color = ColorSequence.new(Color3.fromRGB(255, 248, 226))
		airEmitter.LightEmission = 0.35
		airEmitter.LightInfluence = 1
		airEmitter.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, 1),
			NumberSequenceKeypoint.new(0.3, 0.82),
			NumberSequenceKeypoint.new(0.7, 0.82),
			NumberSequenceKeypoint.new(1, 1),
		})
	end
end

local function stepAir()
	local at = characterPosition()
	if not at then
		airEmitter.Enabled = false
		return
	end
	airEmitter.Enabled = true
	airPart.CFrame = CFrame.new(at + Vector3.new(0, 6, 0))
end

--------------------------------------------------------------------------------

function Ambience.start()
	buildAir()
	setAirMood(false)

	Network.TimeOfDay.OnClientEvent:Connect(function(_hour, night)
		if night ~= isNight then
			isNight = night
			setAirMood(night)
		end
	end)

	RunService.Heartbeat:Connect(function(dt)
		local ok, err = pcall(step, dt)
		if not ok then
			warn(`[Ambience] {err}`)
		end
		stepAir()
	end)
end

return Ambience
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="97">
            <Properties>
              <string name="Name">BossIntro</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	BossIntro — the camera half of the Vorpal Bunny reveal.

	The server drives the creature (grazing, turning, glaring) and fires beats;
	this takes the camera, frames the thing, and hands control back.

	The joke has to land visually: the camera treats a small rabbit eating
	grass with the gravity of a boss reveal, and the player is meant to feel
	faintly silly right up until it looks at them. So the framing is
	deliberately over-serious — slow orbit, long lens push, and the name card
	arrives with the same weight a real boss would get.

	Camera control is released on any failure path. A cinematic that can strand
	the player in a locked camera is worse than no cinematic.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.UI.Core.Layer)
local ScreenFx = require(script.Parent.Parent.UI.Overlays.ScreenFx)

local player = Players.LocalPlayer

local BossIntro = {}

local active = false
local beat = "idle"
local elapsed = 0
local card: Frame
local nameLabel: TextLabel
local kickerLabel: TextLabel

--- Local copy so this client module doesn't reach into the server's Motor.
local function flatten(v: Vector3): Vector3
	local flat = Vector3.new(v.X, 0, v.Z)
	return if flat.Magnitude < 0.001 then Vector3.new(0, 0, -1) else flat.Unit
end

local function findBunny(): BasePart?
	local encounters = Workspace:FindFirstChild("Encounters")
	local model = encounters and encounters:FindFirstChild("VorpalBunny")
	return model and model.PrimaryPart
end

local function release()
	active = false
	beat = "idle"
	ScreenFx.surrenderCamera(false)
	local camera = Workspace.CurrentCamera
	if camera then
		camera.CameraType = Enum.CameraType.Custom
		camera.CameraSubject = player.Character and player.Character:FindFirstChildOfClass("Humanoid")
	end
end

local function buildCard()
	local root = Layer.root("Screen", "BossCard")

	card = Create("Frame", {
		Name = "Card",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.72),
		Size = UDim2.fromOffset(700, 90),
		BackgroundTransparency = 1,
		Visible = false,
		Parent = root,
	})

	kickerLabel = Create("TextLabel", {
		Name = "Kicker",
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "U N I Q U E   S C E N A R I O",
		TextSize = 14,
		TextColor3 = Theme.Color.Unique,
		TextTransparency = 1,
		Parent = card,
	})

	nameLabel = Create("TextLabel", {
		Name = "Name",
		Position = UDim2.fromOffset(0, 24),
		Size = UDim2.new(1, 0, 0, 52),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "VORPAL BUNNY",
		TextSize = 44,
		TextColor3 = Theme.Color.Text,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.4,
		TextTransparency = 1,
		Parent = card,
	})

	Create("Frame", {
		Name = "Rule",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 80),
		Size = UDim2.fromOffset(260, 1),
		BackgroundColor3 = Theme.Color.Health,
		BorderSizePixel = 0,
		Parent = card,

		Create("UIGradient", {
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 1),
				NumberSequenceKeypoint.new(0.5, 0),
				NumberSequenceKeypoint.new(1, 1),
			}),
		}),
	})
end

local function showCard()
	card.Visible = true
	for _, label in { kickerLabel, nameLabel } do
		label.TextTransparency = 1
	end

	TweenService:Create(kickerLabel, TweenInfo.new(0.5), { TextTransparency = 0 }):Play()
	TweenService:Create(nameLabel, TweenInfo.new(0.7, Enum.EasingStyle.Quint, Enum.EasingDirection.Out), {
		TextTransparency = 0,
	}):Play()

	task.delay(2.6, function()
		for _, label in { kickerLabel, nameLabel } do
			TweenService:Create(label, TweenInfo.new(0.5), { TextTransparency = 1 }):Play()
		end
		task.delay(0.6, function()
			card.Visible = false
		end)
	end)
end

--------------------------------------------------------------------------------

function BossIntro.start()
	buildCard()

	Network.BossIntro.OnClientEvent:Connect(function(nextBeat)
		beat = nextBeat
		elapsed = 0

		if nextBeat == "start" then
			active = true
			local camera = Workspace.CurrentCamera
			if camera then
				camera.CameraType = Enum.CameraType.Scriptable
			end
			-- Feel effects step aside for the duration; a breathing sway
			-- under a cinematic reads as a bug.
			ScreenFx.surrenderCamera(true)
			-- Safety net: never leave the camera locked, whatever happens.
			task.delay(8, function()
				if active then
					release()
				end
			end)
		elseif nextBeat == "silence" then
			ScreenFx.push(-4)
		elseif nextBeat == "glare" then
			ScreenFx.push(-10)
			ScreenFx.flash(Color3.fromRGB(255, 60, 60), 0.18)
		elseif nextBeat == "reveal" then
			-- The longest duck in the game. The roar has to own the room, and
			-- the music coming back up underneath it is the fight starting.
			Audio.duck("reveal")
			showCard()
			ScreenFx.flash(Color3.new(1, 1, 1), 0.4)
			task.delay(1.0, release)
		end
	end)

	--[[
		Bound after the camera update, not on RenderStepped.

		RenderStepped fires BEFORE Roblox updates its own camera, so a CFrame
		written there is overwritten by the default camera script the same
		frame. The cinematic and the follow camera were fighting for every
		frame of every boss intro, which reads as the camera vibrating.

		Camera + 1 is the same priority ScreenFx uses, and ScreenFx surrenders
		the camera for the duration (see surrenderCamera), so exactly one thing
		is writing it at a time.
	]]
	RunService:BindToRenderStep("ShangriLaBossIntro", Enum.RenderPriority.Camera.Value + 1, function(dt)
		if not active then
			return
		end

		local camera = Workspace.CurrentCamera
		local subject = findBunny()
		local character = player.Character
		local root = character and character:FindFirstChild("HumanoidRootPart")
		if not (camera and subject and root) then
			return
		end

		elapsed += dt

		-- Slow orbit that tightens as the beats progress. The push-in is what
		-- turns "a rabbit" into "the boss".
		local focus = subject.Position + Vector3.new(0, 1.6, 0)
		local distance, height, speed

		if beat == "start" then
			distance, height, speed = 22, 7, 0.22
		elseif beat == "silence" then
			distance, height, speed = 15, 4.5, 0.16
		elseif beat == "glare" then
			distance, height, speed = 8.5, 2.4, 0.1
		else
			distance, height, speed = 11, 3.4, 0.14
		end

		-- Orbit anchored to the player's side of the creature, so the reveal
		-- always ends looking back toward where the player is standing.
		local base = flatten(root.Position - subject.Position)
		local angle = elapsed * speed
		local offset = Vector3.new(
			base.X * math.cos(angle) - base.Z * math.sin(angle),
			0,
			base.X * math.sin(angle) + base.Z * math.cos(angle)
		) * distance

		local goal = CFrame.lookAt(focus + offset + Vector3.new(0, height, 0), focus)
		camera.CFrame = camera.CFrame:Lerp(goal, math.min(dt * 3.2, 1))
	end)
end

return BossIntro
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="98">
            <Properties>
              <string name="Name">Combat</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Combat — the player's half of a fight.

	A combo chain with input buffering, a guard that can parry, an arm that
	actually swings, and hitstop on impact. The previous version was one swing
	on a cooldown with no animation and no feedback: you clicked, and a number
	appeared next to an enemy that never reacted. That is the deadest possible
	combat loop, and no amount of tuning fixes it.

	Four things carry the feel here, in order of how much they matter:

	  1. Input buffering. A press during a swing is remembered and fires the
	     next link the moment the window opens. Dropping those presses is the
	     most common reason Roblox melee feels unresponsive — the player pressed
	     the button, nothing happened, and they concluded the game is broken.
	  2. Hitstop. A few frames of freeze on contact. Cheapest large win there is.
	  3. A visible swing. The arm moves, and each link moves differently.
	  4. Camera shake, scaled to the weight of the hit.

	The server still decides all damage. This module decides how it *feels*.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Bindings = require(ReplicatedStorage.Shared.Config.Bindings)
local Encounters = require(ReplicatedStorage.Shared.Config.Encounters)
local Network = require(ReplicatedStorage.Shared.Network)
local Progression = require(ReplicatedStorage.Shared.Config.Progression)
local World = require(ReplicatedStorage.Shared.Config.World)

local Haptics = require(script.Parent.Haptics)
local Leveling = require(script.Parent.Leveling)
local FocusFx = require(script.Parent.Parent.UI.Overlays.FocusFx)
local ScreenFx = require(script.Parent.Parent.UI.Overlays.ScreenFx)
local UI = require(script.Parent.Parent.UI)
local UIState = UI.State

local ATTACK = Encounters.PlayerAttack
local HITSTOP = Encounters.Hitstop
local COMBO = Encounters.Combo

local REST_ANGLES = Vector3.new(0, 0, 0)

local player = Players.LocalPlayer

local Combat = {}

--------------------------------------------------------------------------------
-- Swing state
--------------------------------------------------------------------------------

local comboIndex = 0
local lastSwingEndedAt = 0

--- nil when idle, otherwise { step, index, startedAt, phase }
local swing = nil
--- Timestamp of a press made mid-swing, honoured when the window opens.
local bufferedAt = 0

local blocking = false
--- Predicted parry pose window; replaced by the server verdict.
local parryPredictedUntil = 0
local freezeUntil = 0

--- Motor6D driving the right arm, plus its untouched rest pose.
local shoulder: Motor6D? = nil
local shoulderRest: CFrame? = nil

--------------------------------------------------------------------------------
-- Rig access
--------------------------------------------------------------------------------

local function bindArm(character: Model)
	shoulder, shoulderRest = nil, nil

	-- R15 keeps the joint on the limb; R6 keeps it on the torso.
	local upper = character:FindFirstChild("RightUpperArm")
	local joint = upper and upper:FindFirstChild("RightShoulder")
	if not joint then
		local torso = character:FindFirstChild("Torso")
		joint = torso and torso:FindFirstChild("Right Shoulder")
	end

	if joint and joint:IsA("Motor6D") then
		shoulder = joint
		shoulderRest = joint.C0
	end
end

--------------------------------------------------------------------------------
-- Swinging
--------------------------------------------------------------------------------

local function canAct(): boolean
	return UIState.get("phase") == "playing" and not UIState.get("dead") and UIState.get("menuOpen") == nil
end

local function beginSwing()
	local now = os.clock()

	-- The chain lapses if you stop attacking, so mashing from neutral always
	-- opens on the fast, cheap link rather than the slow finisher.
	if now - lastSwingEndedAt > ATTACK.comboReset then
		comboIndex = 0
	end

	comboIndex = comboIndex % #COMBO + 1
	local step = COMBO[comboIndex]

	local stamina = UIState.get("stamina")
	if stamina < step.stamina then
		comboIndex = math.max(comboIndex - 1, 0)
		return
	end
	UIState.set("stamina", stamina - step.stamina)

	swing = { step = step, index = comboIndex, startedAt = now }
	Network.Attack:FireServer(comboIndex)
end

local function requestSwing()
	if not canAct() or blocking then
		return
	end

	if not swing then
		beginSwing()
		return
	end

	-- Mid-swing: remember it. Fired the instant recovery opens.
	bufferedAt = os.clock()
end

--------------------------------------------------------------------------------
-- Parry
--------------------------------------------------------------------------------

--[[
	Pressing F asks the server to open a window. It does not assert a parry.

	The visuals fire immediately anyway — a defensive input that waits a round
	trip before showing anything feels broken even when it works. The server's
	verdict arrives a moment later and decides what actually happened; if it
	refuses (cooldown, not unlocked) the predicted flash is simply replaced by
	the refusal cue.
]]
local function requestParry()
	if not canAct() then
		return
	end

	-- Never fail silently. Pressing a key and getting nothing at all is
	-- indistinguishable from a broken game, and this was exactly that: anyone
	-- who hadn't finished the tutorial pressed F forever with no response.
	if (UIState.get("parryRank") or 0) <= 0 then
		UI.Toast.push({
			title = "Parry not unlocked",
			subtitle = "Menu (B) → Skills → Core",
			kind = "warning",
			duration = 3.5,
		})
		ScreenFx.flash(Color3.fromRGB(120, 130, 150), 0.14)
		return
	end

	if os.clock() < (UIState.get("parryReadyAt") or 0) then
		ScreenFx.flash(Color3.fromRGB(120, 130, 150), 0.12)
		return
	end

	-- Predicted: guard snaps up now, server confirms in a moment.
	parryPredictedUntil = os.clock() + 0.2
	-- The rank rides along, because the periodic vitals report can be stale by
	-- up to 250ms and the server would otherwise refuse a parry you own.
	Network.Parry:FireServer(UIState.get("parryRank") or 0)
end

local function onParryResult(accepted: boolean, reason: string, cooldown: number, window: number)
	local now = os.clock()

	if accepted then
		UIState.patch({
			parryReadyAt = now + cooldown,
			parryWindowUntil = now + window,
		})
		return
	end

	-- Refused. Drop the prediction so the arm doesn't hold a pose the server
	-- never granted, and say why.
	parryPredictedUntil = 0

	if reason == "locked" then
		UI.Toast.push({
			title = "Parry not unlocked",
			subtitle = "Menu (B) → Skills → Core",
			kind = "warning",
			duration = 3.5,
		})
	elseif cooldown and cooldown > 0 then
		UIState.set("parryReadyAt", now + cooldown)
	end
end

--------------------------------------------------------------------------------
-- Guard
--------------------------------------------------------------------------------

--[[
	Block is held on C, not the right mouse button.

	Right mouse is Roblox's own camera control — holding it turns the camera,
	and every player on the platform already knows that. Binding block to it
	meant you could not look around while guarding, which is the one moment you
	most need to. C is unbound, reachable with the left hand while WASD is held,
	and does not fight anything the engine already owns.

	The key itself lives in `Config/Bindings` along with its gamepad trigger.
]]
local function setBlocking(held: boolean)
	if blocking == held then
		return
	end
	if held and not canAct() then
		return
	end

	blocking = held
	UIState.set("blocking", held)
	Network.Block:FireServer(held)
end

--------------------------------------------------------------------------------
-- Feel
--------------------------------------------------------------------------------

local function onImpact(kind: string, data)
	if kind == "parry" then
		Haptics.play("parry")
		-- The fight's best moment gets the loudest response: freeze, white
		-- pop, camera punch-in, hard shake, and the biggest hole the mix ever
		-- opens. The assist drops out on the same frame, so time snapping back
		-- is part of the reward.
		Audio.duck("parry")
		FocusFx.stop()
		UIState.set("focusing", false)
		freezeUntil = os.clock() + HITSTOP.parry
		ScreenFx.shake(2.6)
		ScreenFx.parry()
		UI.Toast.push({ title = "Parry", subtitle = "Counter now", kind = "levelup", duration = 1.4 })
	elseif kind == "heavy" then
		Haptics.play("heavy")
		Audio.duck("impact")
		freezeUntil = os.clock() + HITSTOP.heavy
		ScreenFx.shake(1.7)
		ScreenFx.flash(Color3.fromRGB(255, 236, 190), 0.22)
	elseif kind == "blocked" then
		Haptics.play("hit")
		freezeUntil = os.clock() + HITSTOP.light
		ScreenFx.shake(1.2)
		ScreenFx.flash(Color3.fromRGB(190, 220, 255), 0.3)
	elseif kind == "hurt" then
		Haptics.play("hurt")
		ScreenFx.shake(3.4)
		ScreenFx.hurt(0.7)

		-- Knocked back by the hit. Applied client-side because the client owns
		-- its own character's physics; the server only says which way.
		if data and data.direction then
			local character = player.Character
			local root = character and character:FindFirstChild("HumanoidRootPart")
			if root then
				root.AssemblyLinearVelocity = data.direction * (data.power or 40) + Vector3.new(0, 18, 0)
			end
		end
	elseif kind == "guardBreak" then
		Haptics.play("guardBreak")
		-- Your guard failed rather than held, so it must not look like a block.
		-- Red where a block is blue, a heavier freeze, and the same knockback a
		-- clean hit gives, because that is what it was.
		Audio.duck("impact")
		freezeUntil = os.clock() + HITSTOP.heavy
		ScreenFx.shake(4.0)
		ScreenFx.flash(Color3.fromRGB(255, 90, 70), 0.45)
		ScreenFx.hurt(0.9)
		UIState.set("blocking", false)
		blocking = false

		if data and data.direction then
			local character = player.Character
			local root = character and character:FindFirstChild("HumanoidRootPart")
			if root then
				root.AssemblyLinearVelocity = data.direction * (data.power or 60) + Vector3.new(0, 22, 0)
			end
		end

		UI.Toast.push({ title = "Guard broken", subtitle = "Out of stamina", kind = "warning", duration = 1.6 })
	elseif kind == "parryWhiff" then
		-- Missed. No damage cue, but the lockout needs to be legible.
		parryPredictedUntil = 0
		ScreenFx.flash(Color3.fromRGB(90, 100, 120), 0.16)
	elseif kind == "unparryable" then
		-- Timed correctly, but this attack cannot be answered. Amber, not
		-- white: you did the right thing to the wrong attack.
		freezeUntil = os.clock() + HITSTOP.light
		ScreenFx.shake(1.8)
		ScreenFx.flash(Color3.fromRGB(255, 170, 80), 0.4)
		UI.Toast.push({ title = "Unparryable", subtitle = "Move, do not guard", kind = "warning", duration = 1.6 })
	elseif kind == "introStart" then
		ScreenFx.push(-6)
	else
		freezeUntil = os.clock() + HITSTOP.light
		ScreenFx.shake(0.8)
	end
end

--- Per-link arm poses. Alternating directions so a chain reads as a sequence
--- of different swings rather than the same one four times.
local SWINGS = {
	{ raise = Vector3.new(-70, 40, 0), strike = Vector3.new(-20, -55, 0) },
	{ raise = Vector3.new(-70, -50, 0), strike = Vector3.new(-25, 50, 0) },
	{ raise = Vector3.new(-110, 25, 0), strike = Vector3.new(-10, -40, 0) },
	{ raise = Vector3.new(-160, 0, 0), strike = Vector3.new(10, 0, 0) },
}

local function poseArm()
	if not (shoulder and shoulderRest) then
		return
	end

	if not swing then
		local parrying = os.clock() < parryPredictedUntil
		local target = if parrying
			then shoulderRest * CFrame.Angles(math.rad(-115), math.rad(-20), 0)
			elseif blocking then shoulderRest * CFrame.Angles(math.rad(-75), math.rad(30), 0)
			else shoulderRest
		-- Snappier into a parry than a block: it is a flick, not a brace.
		shoulder.C0 = shoulder.C0:Lerp(target, if parrying then 0.6 else 0.25)
		return
	end

	local step = swing.step
	local elapsed = os.clock() - swing.startedAt
	local shape = SWINGS[swing.index] or SWINGS[1]

	local angles
	if elapsed < step.windup then
		-- Wind back.
		local t = elapsed / step.windup
		angles = REST_ANGLES:Lerp(shape.raise, t * t)
	elseif elapsed < step.windup + step.active then
		-- Strike, fast and linear so it reads as a snap.
		local t = (elapsed - step.windup) / step.active
		angles = shape.raise:Lerp(shape.strike, t)
	else
		-- Settle back to rest.
		local total = step.windup + step.active + step.recovery
		local t = math.clamp((elapsed - step.windup - step.active) / step.recovery, 0, 1)
		angles = shape.strike:Lerp(REST_ANGLES, t)
		if elapsed >= total then
			swing = nil
			lastSwingEndedAt = os.clock()
		end
	end

	shoulder.C0 = shoulderRest * CFrame.Angles(math.rad(angles.X), math.rad(angles.Y), math.rad(angles.Z))
end

--------------------------------------------------------------------------------
-- Server messages
--------------------------------------------------------------------------------

local function onPlayerDamaged(amount: number)
	if UIState.get("dead") then
		return
	end

	local health = math.max(UIState.get("health") - amount, 0)
	UIState.set("health", health)
	UI.DamageNumbers.show(amount, "taken")

	if health <= 0 then
		UIState.patch({ dead = true, boss = nil })
	end
end

local function onGuardResult(staminaCost: number, parried: boolean)
	if parried then
		-- Higher parry ranks hand stamina back, arriving as a negative cost.
		if staminaCost < 0 then
			UIState.set("stamina", math.min(UIState.get("stamina") - staminaCost, UIState.get("maxStamina")))
		end
		parryPredictedUntil = 0
		return
	end
	-- Blocking is a resource, not a wall: it holds until your stamina doesn't.
	UIState.set("stamina", math.max(UIState.get("stamina") - staminaCost, 0))
end

local function onEncounterCleared(encounterId: string, rewardXp: number)
	local scenario = Progression.findScenario(encounterId)
	local states = UIState.get("scenarioStates") or {}

	local alreadyCleared = states[encounterId] == "cleared"
	states[encounterId] = "cleared"
	UIState.set("scenarioStates", states)

	if not alreadyCleared and scenario then
		UI.ScenarioBanner.play({ kicker = scenario.kicker, name = scenario.name, note = "First clear" })

		local titles = UIState.get("earnedTitles") or {}
		if not table.find(titles, "first_blood") then
			table.insert(titles, "first_blood")
			UIState.set("earnedTitles", titles)
			task.delay(4.5, function()
				UI.Toast.push({ title = "Title earned", subtitle = "First Encounter", kind = "quest" })
			end)
		end
	else
		UI.Toast.push({ title = "Vorpal Bunny defeated", kind = "quest" })
	end

	task.delay(if alreadyCleared then 0.2 else 4.2, function()
		Leveling.addExperience(rewardXp)
	end)
end

--------------------------------------------------------------------------------

function Combat.start()
	if player.Character then
		task.spawn(bindArm, player.Character)
	end
	player.CharacterAdded:Connect(function(character)
		task.wait(0.3)
		bindArm(character)
	end)

	Network.BossState.OnClientEvent:Connect(function(payload)
		UIState.set("boss", payload)
	end)
	Network.PlayerDamaged.OnClientEvent:Connect(onPlayerDamaged)
	Network.BossDamaged.OnClientEvent:Connect(function(amount, kind, worldPosition)
		UI.DamageNumbers.show(amount, kind, worldPosition)
	end)
	Network.Impact.OnClientEvent:Connect(onImpact)
	Network.ParryResult.OnClientEvent:Connect(onParryResult)

	--[[
		An assisted attack is inbound. The ring closes exactly on impact, so
		"press when the ring lands" trains the same motor skill as "press when
		the sword lands" — just larger and slower. It never presses for you.
	]]
	Network.ParryPrompt.OnClientEvent:Connect(function(profile)
		if not profile then
			FocusFx.stop()
			UIState.set("focusing", false)
			return
		end
		FocusFx.show(profile.realLead, profile.visibility, profile.ring, profile.prompt)
		UIState.set("focusing", true)

		task.delay(profile.realLead + 0.05, function()
			if not FocusFx.isActive() then
				UIState.set("focusing", false)
			end
		end)
	end)

	-- Push the chosen setting up whenever it changes, and once on start.
	UIState.observe("parryHelper", function(mode)
		Network.AssistMode:FireServer(mode)
	end)

	-- And the rank, the moment it changes, rather than waiting for the next
	-- vitals tick.
	UIState.observe("parryRank", function()
		local max = UIState.get("maxStamina")
		if max and max > 0 then
			Network.Vitals:FireServer(UIState.get("stamina") / max, UIState.get("parryRank"))
		end
	end)

	-- The AI reads your stamina to decide when to press. Reported a few times
	-- a second rather than every frame; it only drives flavour, not authority.
	task.spawn(function()
		while true do
			task.wait(0.25)
			local max = UIState.get("maxStamina")
			if max and max > 0 then
				Network.Vitals:FireServer(UIState.get("stamina") / max, UIState.get("parryRank"))
			end
		end
	end)
	Network.GuardResult.OnClientEvent:Connect(onGuardResult)
	Network.EncounterCleared.OnClientEvent:Connect(onEncounterCleared)

	--[[
		Loot. Stacks with anything already carried, and always announces
		itself — a drop the player doesn't notice may as well not have
		happened, and "why did I walk over there" is the question every
		open world has to keep answering.
	]]
	Network.Loot.OnClientEvent:Connect(function(item)
		if typeof(item) ~= "table" or not item.id then
			return
		end

		local inventory = UIState.get("inventory") or {}
		local stacked = false
		for _, held in inventory do
			if held.id == item.id then
				held.quantity = (held.quantity or 1) + (item.quantity or 1)
				stacked = true
				break
			end
		end
		if not stacked then
			table.insert(inventory, item)
		end
		UIState.set("inventory", inventory)

		UI.Toast.push({
			title = item.name,
			subtitle = if stacked then "Added to your bag" else "New item",
			kind = if item.rarity == "Common" then "loot" else "quest",
			duration = 3,
		})
	end)

	Network.EnemyDefeated.OnClientEvent:Connect(function(_id, rewardXp)
		Leveling.addExperience(rewardXp or 0)
	end)

	UserInputService.InputBegan:Connect(function(input, processed)
		if processed then
			return
		end
		if Bindings.matches("attack", input) then
			requestSwing()
		elseif Bindings.matches("block", input) then
			setBlocking(true)
		elseif Bindings.matches("parry", input) then
			requestParry()
		end
	end)

	UserInputService.InputEnded:Connect(function(input)
		if Bindings.matches("block", input) then
			setBlocking(false)
		end
	end)

	RunService.RenderStepped:Connect(function()
		-- Hitstop: hold the arm exactly where it was. The pause is the point.
		if os.clock() >= freezeUntil then
			poseArm()

			-- Recovery is cancellable. A buffered press fires the moment the
			-- strike has landed rather than waiting for the arm to finish
			-- returning — that gap is the difference between a chain that
			-- feels snappy and one that feels like wading.
			local chainable = swing == nil
			if swing then
				local s = swing.step
				chainable = os.clock() - swing.startedAt >= s.windup + s.active
			end

			if chainable and bufferedAt > 0 then
				local fresh = os.clock() - bufferedAt <= ATTACK.bufferWindow
				bufferedAt = 0
				if fresh then
					beginSwing()
				end
			end
		end
	end)

	UIState.observe("dead", function(dead)
		if not dead then
			return
		end
		setBlocking(false)
		swing, comboIndex, bufferedAt = nil, 0, 0

		task.delay(1.2, function()
			local character = player.Character
			local root = character and character:FindFirstChild("HumanoidRootPart")
			if root then
				local y = World.groundHeight(World.Spawn.X, World.Spawn.Z)
				root.CFrame = CFrame.new(World.Spawn.X, y + 5, World.Spawn.Z) * CFrame.Angles(0, math.pi, 0)
			end
		end)
	end)
end

return Combat
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="99">
            <Properties>
              <string name="Name">Feel</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Feel — what moving through the world is like.

	Landing impact, surface-aware footsteps, body lean, camera reaction, sprint
	sensation. No new verbs; the same actions, made to mean something.

	WHY THIS IS ONE MODULE. Game feel is not a list of independent effects — it
	is one coherent response to the player's state, and splitting it across five
	controllers guarantees they fight each other over the camera and the rig.
	Everything here reads the same movement state once per frame and writes one
	answer.

	WHAT IT DELIBERATELY DOES NOT DO. It never changes speed, damage, reach or
	timing. If a value in here altered what a player *can* do, it would belong
	in a gameplay module where it can be balanced. This is presentation only,
	which is also why it can be this aggressive without risk.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Config = require(ReplicatedStorage.Shared.Config.Feel)
local Vfx = require(ReplicatedStorage.Shared.Combat.Vfx)

local ScreenFx = require(script.Parent.Parent.UI.Overlays.ScreenFx)
local UIState = require(script.Parent.Parent.UI.Core.UIState)

local player = Players.LocalPlayer

local Feel = {}

--------------------------------------------------------------------------------
-- State
--------------------------------------------------------------------------------

local humanoid: Humanoid? = nil
local root: BasePart? = nil
--- R15's waist joint. Absent on R6, in which case leaning is skipped rather
--- than faked badly.
local waist: Motor6D? = nil
local waistRest: CFrame? = nil

--- Highest point reached since leaving the ground, for measuring the fall.
local airborneFrom: number? = nil
local wasAirborne = false

--- Distance accumulator driving footfalls.
local strideCarry = 0
local lastPosition: Vector3? = nil

--- Smoothed lean, in degrees.
local leanRoll = 0
local leanPitch = 0
local lastFacing: Vector3? = nil

--- Camera contributions this module owns.
local dip = 0
local dipHold = 0
local swayClock = 0

--------------------------------------------------------------------------------
-- Rig
--------------------------------------------------------------------------------

local function bind(character: Model)
	humanoid = character:WaitForChild("Humanoid", 10)
	root = character:WaitForChild("HumanoidRootPart", 10)
	waist, waistRest = nil, nil

	local upper = character:FindFirstChild("UpperTorso")
	local joint = upper and upper:FindFirstChild("Waist")
	if joint and joint:IsA("Motor6D") then
		waist = joint
		waistRest = joint.C0
	end

	airborneFrom = nil
	wasAirborne = false
	strideCarry = 0
	lastPosition = nil
	leanRoll, leanPitch = 0, 0
end

--------------------------------------------------------------------------------
-- Surfaces
--------------------------------------------------------------------------------

local groundParams = RaycastParams.new()
groundParams.FilterType = Enum.RaycastFilterType.Exclude

--- What are we standing on? Drives both the footstep cue and the dust colour,
--- so crossing from road to grass is audible and visible.
local function surfaceUnder(): (any, Vector3?)
	if not root then
		return Config.Surfaces.Default, nil
	end

	groundParams.FilterDescendantsInstances = { player.Character }
	local hit = Workspace:Raycast(root.Position, Vector3.new(0, -8, 0), groundParams)
	if not hit then
		return Config.Surfaces.Default, nil
	end

	local name = hit.Material.Name
	return Config.Surfaces[name] or Config.Surfaces.Default, hit.Position
end

--------------------------------------------------------------------------------
-- Landing
--------------------------------------------------------------------------------

--[[
	Scaled entirely by how far you actually fell. A hop off a kerb and a drop
	from a rooftop producing the same effect is how landings stop meaning
	anything, so below `soft` this is just a footstep.
]]
local function land(fallDistance: number)
	local surface, at = surfaceUnder()
	if not at then
		return
	end

	if fallDistance < Config.Landing.soft then
		Audio.playAt(surface.cue, at)
		return
	end

	local force = math.clamp((fallDistance - Config.Landing.soft) / (Config.Landing.hard - Config.Landing.soft), 0, 1)

	Vfx.dust(at, (0.5 + force * 1.4) * surface.dustiness)
	if force > 0.35 then
		Vfx.ring(at + Vector3.new(0, 0.12, 0), 2 + force * 5, surface.dust, 0.3)
	end
	if force > 0.7 then
		-- Only a genuinely bad landing throws debris and holds the camera low.
		Vfx.landing(at, force * 1.4)
		dipHold = Config.Landing.stagger * force
	end

	dip = Config.Landing.dip * force
	ScreenFx.shake(Config.Landing.shake * force)
	Audio.playAt(if force > 0.6 then "heavyLand" else "land", at)
end

--------------------------------------------------------------------------------
-- Footsteps
--------------------------------------------------------------------------------

local function footstep(sprinting: boolean)
	local surface, at = surfaceUnder()
	if not at then
		return
	end

	Audio.playAt(surface.cue, at)

	local scale = (if sprinting then Config.Steps.sprintDustScale else Config.Steps.dustScale) * surface.dustiness
	if scale > 0.08 then
		Vfx.dust(at, scale)
	end
end

--------------------------------------------------------------------------------
-- Per-frame
--------------------------------------------------------------------------------

local function step(dt: number)
	if not (humanoid and root and humanoid.Health > 0) then
		return
	end

	local position = root.Position
	local velocity = root.AssemblyLinearVelocity
	local planar = Vector3.new(velocity.X, 0, velocity.Z).Magnitude
	local sprinting = UIState.get("sprinting") == true
	local grounded = humanoid.FloorMaterial ~= Enum.Material.Air

	------------------------------------------------------------------------
	-- Airtime and landing
	------------------------------------------------------------------------

	if not grounded then
		airborneFrom = math.max(airborneFrom or position.Y, position.Y)
		wasAirborne = true
	elseif wasAirborne then
		wasAirborne = false
		land(math.max((airborneFrom or position.Y) - position.Y, 0))
		airborneFrom = nil
		strideCarry = 0
	end

	------------------------------------------------------------------------
	-- Footsteps, by distance rather than by timer
	------------------------------------------------------------------------

	if lastPosition and grounded then
		local travelled = (Vector3.new(position.X, 0, position.Z) - Vector3.new(lastPosition.X, 0, lastPosition.Z)).Magnitude
		-- Ignore teleports; they are not walking.
		if travelled < 8 then
			strideCarry += travelled
		end

		local stride = if sprinting then Config.Steps.sprintStride else Config.Steps.stride
		if strideCarry >= stride and planar > 1 then
			strideCarry -= stride
			footstep(sprinting)
		end
	end
	lastPosition = position

	------------------------------------------------------------------------
	-- Lean. The cheapest fix for "sliding statue".
	------------------------------------------------------------------------

	if waist and waistRest then
		local facing = root.CFrame.LookVector
		facing = Vector3.new(facing.X, 0, facing.Z)

		local targetRoll, targetPitch = 0, 0
		if lastFacing and facing.Magnitude > 0.01 and planar > 1.5 then
			-- Signed turn rate: which way, and how hard.
			local cross = lastFacing.X * facing.Z - lastFacing.Z * facing.X
			local rate = math.clamp((cross / dt) / Config.Lean.maxTurnRate, -1, 1)
			targetRoll = -rate * Config.Lean.turn
			targetPitch = (planar / 26) * Config.Lean.sprint
		end
		if UIState.get("blocking") then
			targetPitch = Config.Lean.guard
		end
		lastFacing = if facing.Magnitude > 0.01 then facing.Unit else lastFacing

		local alpha = 1 - (1 - 0.12) ^ (dt * 60 * Config.Lean.responsiveness / 7)
		leanRoll += (targetRoll - leanRoll) * alpha
		leanPitch += (targetPitch - leanPitch) * alpha

		waist.C0 = waistRest * CFrame.Angles(math.rad(leanPitch), 0, math.rad(leanRoll))
	end

	------------------------------------------------------------------------
	-- Camera
	------------------------------------------------------------------------

	-- Sprint FOV: speed you can feel rather than measure.
	ScreenFx.setSustainedFov(if sprinting then Config.Camera.sprintFov else 0)

	if dipHold > 0 then
		dipHold -= dt
	elseif dip > 0.001 then
		dip = math.max(dip - dt * Config.Landing.dipRecover, 0)
	end

	swayClock += dt * (1 + planar / 20)
	local sway = Vector3.new(
		math.sin(swayClock * Config.Camera.idleRate * 0.7) * Config.Camera.moveSway * math.min(planar / 16, 1),
		math.sin(swayClock * Config.Camera.idleRate) * Config.Camera.idleSway - dip,
		0
	)
	ScreenFx.setCameraOffset(sway)

	ScreenFx.setSpeedLines(if sprinting then Config.SpeedLines.opacity else 0)
end

--------------------------------------------------------------------------------

function Feel.start()
	if player.Character then
		task.spawn(bind, player.Character)
	end
	player.CharacterAdded:Connect(bind)

	RunService.RenderStepped:Connect(function(dt)
		local ok, err = pcall(step, dt)
		if not ok then
			-- Presentation must never take the game down with it.
			warn(`[Feel] {err}`)
		end
	end)
end

return Feel
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="100">
            <Properties>
              <string name="Name">Haptics</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Haptics — controller rumble, on the same events the screen already reacts to.

	Every cue here is deliberately short. Rumble is the one feedback channel a
	player cannot look away from, and a motor that is still running a second
	after the hit stops reading as impact and starts reading as a fault in the
	pad. Nothing below lasts longer than a quarter second.

	TWO MOTORS, USED FOR DIFFERENT THINGS. `Small` is the high-frequency motor:
	it reads as texture and detail, so it carries the light, fast events. `Large`
	is the low-frequency one: it reads as weight, so it carries the ones that
	are supposed to hurt. Firing both at full for everything is what makes
	cheap rumble feel like a phone on a table.

	IT IS NOT A SUBSTITUTE FOR ANYTHING. Every event here is already carried by
	sound and by the screen. A player with rumble off, or on a keyboard, loses
	no information — which is the test any haptic layer has to pass.
]]

local HapticService = game:GetService("HapticService")
local UserInputService = game:GetService("UserInputService")

local Haptics = {}

--- `large` and `small` are 0-1 motor amplitudes; `duration` is seconds.
local CUES = {
	--- Landing a hit. Light, quick, and the most frequent thing here — it has
	--- to stay under the threshold where repetition becomes irritating.
	hit = { large = 0, small = 0.35, duration = 0.06 },
	--- A heavy hit or a finisher.
	heavy = { large = 0.5, small = 0.5, duration = 0.12 },
	--- The parry. The strongest cue in the game, because it is the best moment
	--- in the game, and it is the one place both motors go at once.
	parry = { large = 0.85, small = 0.9, duration = 0.16 },
	--- Taking damage. Low and dull rather than sharp: this is not an impact you
	--- delivered, it is one that happened to you.
	hurt = { large = 0.7, small = 0.15, duration = 0.18 },
	--- Guard break. The longest and heaviest, and the only one that is meant to
	--- be unpleasant.
	guardBreak = { large = 1.0, small = 0.3, duration = 0.24 },
	--- Something landed near you: a boss slam, a heavy landing.
	rumble = { large = 0.45, small = 0, duration = 0.2 },
}

local enabled = true
local motor = Enum.VibrationMotor
local active = 0

local function supported(): boolean
	if not enabled then
		return false
	end
	-- Re-checked per fire rather than cached at boot: a pad can be plugged in
	-- at any point, and a player who connects one mid-session should not have
	-- to rejoin to get rumble.
	return UserInputService.GamepadEnabled
		and HapticService:IsVibrationSupported(Enum.UserInputType.Gamepad1)
		and HapticService:IsMotorSupported(Enum.UserInputType.Gamepad1, motor.Small)
end

--- Fires a named cue. Unknown names are ignored rather than erroring, so a call
--- site can name a cue before it has been tuned.
function Haptics.play(name: string)
	local cue = CUES[name]
	if not cue or not supported() then
		return
	end

	local token = active + 1
	active = token

	pcall(function()
		HapticService:SetMotor(Enum.UserInputType.Gamepad1, motor.Small, cue.small)
		HapticService:SetMotor(Enum.UserInputType.Gamepad1, motor.Large, cue.large)
	end)

	task.delay(cue.duration, function()
		-- Only the most recent cue is allowed to stop the motors. Without the
		-- token, a light hit fired 30ms after a parry would cut the parry's
		-- rumble short at its own much shorter duration.
		if active ~= token then
			return
		end
		pcall(function()
			HapticService:SetMotor(Enum.UserInputType.Gamepad1, motor.Small, 0)
			HapticService:SetMotor(Enum.UserInputType.Gamepad1, motor.Large, 0)
		end)
	end)
end

--- Stops everything immediately. Called on death and on teleport, because a
--- motor left running through a loading screen is the classic way to ship a
--- pad that buzzes forever.
function Haptics.stop()
	active += 1
	pcall(function()
		HapticService:SetMotor(Enum.UserInputType.Gamepad1, motor.Small, 0)
		HapticService:SetMotor(Enum.UserInputType.Gamepad1, motor.Large, 0)
	end)
end

function Haptics.setEnabled(value: boolean)
	enabled = value
	if not value then
		Haptics.stop()
	end
end

function Haptics.isEnabled(): boolean
	return enabled
end

return Haptics
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="101">
            <Properties>
              <string name="Name">Interact</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Interact — walking up to people and pressing a key.

	Finds the nearest thing in the world tagged `Interactable`, floats a prompt
	over it, and routes the key press to the server. The server owns whether
	the conversation may happen at all; this only ever asks.

	WHY G AND NOT E. E is the universal interact key and it is already spoken
	for — Q/E/R are the three ability slots, and a game where standing near a
	shopkeeper silently eats your second skill is worse than a game with an
	unusual interact key. G is unbound, adjacent to the movement hand, and the
	prompt says which key it is every time it appears, so nobody has to guess.

	Prompts are world-space, on the person, rather than a corner of the screen:
	the thing you're being told about is the thing you're looking at.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local Workspace = game:GetService("Workspace")

local Bindings = require(ReplicatedStorage.Shared.Config.Bindings)
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Town = require(ReplicatedStorage.Shared.Config.Town)

local Dialogue = require(script.Parent.Parent.UI.Screens.Dialogue)
local FishingHud = require(script.Parent.Parent.UI.Screens.FishingHud)
local Craft = require(script.Parent.Parent.UI.Screens.Craft)
local Shop = require(script.Parent.Parent.UI.Screens.Shop)
local Toast = require(script.Parent.Parent.UI.Overlays.Toast)
local Waypoint = require(script.Parent.Parent.UI.Overlays.Waypoint)
local UIState = require(script.Parent.Parent.UI.Core.UIState)

local RANGE = 14
--- Folders that hold Interactable models. Both are built at runtime by
--- their own server scripts.
local INTERACT_FOLDERS = { "FirstiaTown", "FishingSpots" }
--- Rescanning every frame is wasted work for something that changes when a town
--- gets built, i.e. once.
local SCAN_INTERVAL = 0.25

local Interact = {}

local player = Players.LocalPlayer
local prompt: BillboardGui
local promptLabel: TextLabel
local targets: { Model } = {}
local current: Model? = nil
local nextScan = 0

local function rootPosition(): Vector3?
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	return root and root.Position
end

--[[
	Collects the interactable models.

	Scoped to the folders that hold them, not Workspace:GetDescendants().
	Once terrain, forest and town are all up, Workspace holds tens of thousands
	of instances, and walking all of them four times a second to find eight
	NPCs is a frame-time cost for nothing. Residents are direct children of the
	folder by construction, so a shallow scan of one folder is both correct and
	free.
]]
local function rescan()
	table.clear(targets)

	for _, name in INTERACT_FOLDERS do
		local host = Workspace:FindFirstChild(name)
		if host then
			for _, child in host:GetChildren() do
				if child:IsA("Model") and child:GetAttribute("Interactable") then
					table.insert(targets, child)
				end
			end
		end
	end
end

local function setTarget(model: Model?)
	-- A jetty's label changes with the cast state, so it is re-read every time
	-- rather than only on target change.
	local isSpot = model and model:GetAttribute("NpcId") and string.match(model:GetAttribute("NpcId"), "^fish") ~= nil
	if current == model and not isSpot then
		return
	end
	current = model

	if not model or not model.PrimaryPart then
		prompt.Enabled = false
		prompt.Adornee = nil
		return
	end

	local who = model:GetAttribute("NpcName") or "them"
	promptLabel.Text = if model:GetAttribute("NpcId") and string.match(model:GetAttribute("NpcId"), "^fish")
		then (if FishingHud.isCasting then "[G]  Strike" else "[G]  Cast a line")
		else `[G]  Talk to {who}`
	prompt.Adornee = model.PrimaryPart
	prompt.Enabled = true
	Audio.play("uiHover")
end

local function tick()
	if os.clock() >= nextScan then
		nextScan = os.clock() + SCAN_INTERVAL
		rescan()
	end

	-- A conversation already open owns the prompt; suppressing it stops the
	-- hint flickering behind the panel you're reading.
	if Dialogue.isOpen or Shop.isOpen or Craft.isOpen then
		setTarget(nil)
		return
	end

	local from = rootPosition()
	if not from then
		setTarget(nil)
		return
	end

	local best, bestDistance = nil, RANGE
	for _, model in targets do
		local part = model.PrimaryPart
		if part then
			local distance = (part.Position - from).Magnitude
			if distance < bestDistance then
				best, bestDistance = model, distance
			end
		end
	end

	setTarget(best)
end

--------------------------------------------------------------------------------
-- Server-issued effects
--------------------------------------------------------------------------------

--[[
	Health and stamina still live in the client store (see Network.luau's
	header — the whole vitals path is client-owned for the slice). So a heal
	the server authorised is applied here, on its instruction, rather than
	being pushed as a value. Same trust level as everything else in that path;
	it moves server-side with the rest of it.
]]
local function applyEffect(effect: string?)
	if effect == "heal" then
		UIState.set("health", UIState.get("maxHealth"))
		Toast.push({ title = "Fully healed", kind = "levelup" })
	elseif effect == "rest" then
		UIState.patch({
			health = UIState.get("maxHealth"),
			stamina = UIState.get("maxStamina"),
		})
		Toast.push({ title = "Rested", subtitle = "Health and stamina restored", kind = "levelup" })
	end
end

--------------------------------------------------------------------------------
-- Guidance
--------------------------------------------------------------------------------

--[[
	Picks the one thing the player should be walking toward.

	Priority is strict and deliberate: a quest ready to hand in beats a quest
	in progress, which beats a quest waiting to be offered. Only ever one
	marker — two arrows is the same as none, because the player has to stop
	and decide which to believe.

	Resolves the giver's live model rather than their scheduled position, so
	the arrow follows the smith home at seven rather than pointing at an empty
	anvil.
]]
local GUIDANCE_INTERVAL = 0.4
local nextGuidance = 0

local function npcModel(npcId: string): Model?
	for _, model in targets do
		if model:GetAttribute("NpcId") == npcId then
			return model
		end
	end
	return nil
end

local function updateGuidance()
	if os.clock() < nextGuidance then
		return
	end
	nextGuidance = os.clock() + GUIDANCE_INTERVAL

	-- A conversation is open: the player is already where they need to be.
	if Dialogue.isOpen or Shop.isOpen or Craft.isOpen then
		Waypoint.clear()
		return
	end

	local states = UIState.get("quests") or {}
	local progress = UIState.get("questProgress") or {}

	local best, bestKind
	for _, quest in Town.Quests do
		local state = states[quest.id]
		if state == "active" and (progress[quest.id] or 0) >= quest.objective.count then
			best, bestKind = quest, "turnin"
			break
		end
	end
	if not best then
		for _, quest in Town.Quests do
			if not states[quest.id] and (not quest.requires or states[quest.requires] == "done") then
				best, bestKind = quest, "quest"
				break
			end
		end
	end

	if not best then
		Waypoint.clear()
		return
	end

	local model = npcModel(best.giver)
	local part = model and model.PrimaryPart
	if not part then
		-- The giver is indoors or out of range; nothing useful to point at.
		Waypoint.clear()
		return
	end

	local npc = Town.findNpc(best.giver)
	Waypoint.set(
		part.Position,
		if bestKind == "turnin" then `Hand in: {best.name}` else `{(npc and npc.name) or "?"} — {best.name}`,
		bestKind
	)
end

--------------------------------------------------------------------------------
-- The Colosseum floor
--------------------------------------------------------------------------------

--[[
	Stepping onto the sand starts a run.

	No confirmation dialog. OiKatzo already explained the rules at the gate,
	the causeway is a long deliberate walk, and a "are you sure?" box in front
	of an arena floor is the game asking a question the player answered by
	walking there. Leaving the floor ends the run, which is the same contract
	stated the other way round.
]]
local arenaInside = false

local function watchArena()
	local at = rootPosition()
	if not at then
		return
	end

	local flat = Vector3.new(at.X - Town.Colosseum.center.X, 0, at.Z - Town.Colosseum.center.Z)
	local inside = flat.Magnitude < Town.Colosseum.radius - 6

	if inside ~= arenaInside then
		arenaInside = inside
		Network.Arena:FireServer(if inside then "start" else "leave")
	end
end

--------------------------------------------------------------------------------

function Interact.start()
	prompt = Create("BillboardGui", {
		Name = "InteractPrompt",
		Size = UDim2.fromOffset(230, 32),
		StudsOffsetWorldSpace = Vector3.new(0, 12.4, 0),
		AlwaysOnTop = true,
		LightInfluence = 0,
		Enabled = false,
		Parent = player:WaitForChild("PlayerGui"),

		Create("Frame", {
			Name = "Backing",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = Theme.Color.Panel,
			BackgroundTransparency = 0.15,
			BorderSizePixel = 0,

			Create("UICorner", { CornerRadius = Theme.Radius.md }),
			Create("UIStroke", { Color = Theme.Color.Accent, Thickness = 1, Transparency = 0.35 }),
		}),
	})

	-- Inside the backing rather than beside it: siblings at equal ZIndex draw
	-- in creation order, which is not a thing to stake a prompt on.
	promptLabel = Create("TextLabel", {
		Name = "Label",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 14,
		TextColor3 = Theme.Color.Text,
		Parent = prompt.Backing,
	})

	RunService.Heartbeat:Connect(tick)
	RunService.Heartbeat:Connect(watchArena)
	RunService.Heartbeat:Connect(updateGuidance)

	UserInputService.InputBegan:Connect(function(input, processed)
		if processed or not Bindings.matches("interact", input) then
			return
		end
		if UIState.get("phase") ~= "playing" then
			return
		end
		-- Not through an open menu. On a gamepad this is load-bearing: interact
		-- and cancel are both B, and without the guard closing the inventory
		-- would also start a conversation with whoever you happened to be
		-- standing next to. It is right on a keyboard too, where talking to a
		-- shopkeeper from behind your own inventory screen was merely strange.
		if UIState.get("menuOpen") then
			return
		end
		local model = current
		if model then
			Network.Interact:FireServer(model:GetAttribute("NpcId"), "talk")
		end
	end)

	----------------------------------------------------------------------
	-- Server state
	----------------------------------------------------------------------

	Network.Profile.OnClientEvent:Connect(function(profile)
		UIState.patch({
			gold = profile.gold,
			materials = profile.materials,
			ownedItems = profile.items,
			quests = profile.quests,
			questProgress = profile.questProgress,
			kills = profile.kills,
		})
	end)

	Network.Dialogue.OnClientEvent:Connect(function(payload)
		if payload and payload.effect then
			applyEffect(payload.effect)
		end
	end)

	Network.QuestUpdate.OnClientEvent:Connect(function(questId, state, progress)
		local quest = Town.findQuest(questId)
		if not quest then
			return
		end

		if state == "accepted" then
			Toast.push({ title = "Quest accepted", subtitle = quest.name, kind = "quest", duration = 5 })
		elseif state == "progress" then
			Toast.push({
				title = quest.name,
				subtitle = `{progress} / {quest.objective.count}`,
				kind = "system",
				duration = 3,
			})
		elseif state == "ready" then
			Toast.push({
				title = "Objective complete",
				subtitle = `{quest.name} — return to {(Town.findNpc(quest.giver) or {}).name or "town"}`,
				kind = "quest",
				duration = 6,
			})
		elseif state == "done" then
			Toast.push({ title = "Quest complete", subtitle = quest.name, kind = "levelup", duration = 6 })
		end
	end)
end

return Interact
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="102">
            <Properties>
              <string name="Name">Leveling</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Leveling — experience, levels, and the stat growth that follows.

	Named Leveling rather than Progression so it doesn't read as the same thing
	as Config/Progression, which holds scenarios and titles.

	One skill point per level, which is what makes the Skills tab a real
	decision: you cannot have everything, and the tier 3 unlocks cost enough
	that rushing one means going without two cheap ones for a long time.

	Client-side for now. When the server owns progression this module keeps its
	shape and just stops being the authority.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Skills = require(ReplicatedStorage.Shared.Config.Skills)

local UI = require(script.Parent.Parent.UI)
local Theme = require(game:GetService("ReplicatedStorage").Shared.Config.Theme)
local Loadout = require(script.Parent.Parent.UI.Core.Loadout)
local UIState = UI.State

--[[
	Experience needed to leave a given level.

	The first few levels are deliberately cheap. Roblox's onboarding guidance
	is explicit that early thresholds should be low so a new player "feels the
	fun of progression immediately", and at a flat 100/1.32^n the first level
	took three or four goblins — long enough that a player who bounced at four
	minutes never saw one at all.

	EARLY is a hand-authored ramp for the levels that matter for retention;
	past it the curve takes over unchanged, so nothing about the long game
	moves.
]]
local EARLY = { 35, 70, 120, 190, 280 }
local BASE_XP = 100
local XP_GROWTH = 1.32

--- Per-level stat gains.
local HEALTH_PER_LEVEL = 12
local STAMINA_PER_LEVEL = 4
local POISE_PER_LEVEL = 2

local Leveling = {}

function Leveling.requiredFor(level: number): number
	local early = EARLY[level]
	if early then
		return early
	end
	return math.floor(BASE_XP * XP_GROWTH ^ (level - 1))
end

--- Raises the level by one, granting a point and stat growth. Vitals are
--- topped up to their new maximum, which is the traditional level-up payoff
--- and doubles as a small mercy.
function Leveling.levelUp()
	local level = (UIState.get("level") or 1) + 1

	local maxHealth = UIState.get("maxHealth") + HEALTH_PER_LEVEL
	local maxStamina = UIState.get("maxStamina") + STAMINA_PER_LEVEL
	local maxPoise = UIState.get("maxPoise") + POISE_PER_LEVEL

	UIState.patch({
		level = level,
		experience = 0,
		experienceToNext = Leveling.requiredFor(level),
		skillPoints = (UIState.get("skillPoints") or 0) + 1,
		maxHealth = maxHealth,
		maxStamina = maxStamina,
		maxPoise = maxPoise,
		health = maxHealth,
		stamina = maxStamina,
		poise = maxPoise,
	})

	--[[
		The celebration, not a toast.

		This is the most repeated reward in the game and it used to be a corner
		notification with the same weight as picking up a goblin ear. The gains
		are passed in already formatted so the overlay never has to know what a
		stat is.
	]]
	UI.LevelUp.play(level, {
		{ label = "Skill point", value = "+1", color = Theme.Color.Unique },
		{ label = "Health", value = `+{HEALTH_PER_LEVEL}`, color = Theme.Color.Health },
		{ label = "Stamina", value = `+{STAMINA_PER_LEVEL}`, color = Theme.Color.Stamina },
		{ label = "Poise", value = `+{POISE_PER_LEVEL}`, color = Theme.Color.Poise },
	})

	-- A new level can open a loadout slot.
	Loadout.prune()
end

--- Adds experience, rolling over as many levels as the amount covers.
function Leveling.addExperience(amount: number)
	if amount <= 0 then
		return
	end

	local experience = (UIState.get("experience") or 0) + amount
	local required = UIState.get("experienceToNext") or Leveling.requiredFor(UIState.get("level") or 1)

	-- Guard the loop: a huge grant shouldn't be able to spin forever if the
	-- curve is ever misconfigured to return zero.
	local safety = 0
	while experience >= required and safety < 200 do
		experience -= required
		Leveling.levelUp()
		required = UIState.get("experienceToNext")
		safety += 1
	end

	UIState.set("experience", experience)
end

--- Total points a character should have earned by their current level, used to
--- sanity-check saves later.
function Leveling.pointsEarnedBy(level: number): number
	return level
end

function Leveling.start()
	-- Make sure a fresh character's requirement matches the curve rather than
	-- whatever placeholder the store shipped with.
	UIState.set("experienceToNext", Leveling.requiredFor(UIState.get("level") or 1))

	-- Unlocking a skill spends a point; nothing else should be able to leave
	-- the loadout holding something that is no longer owned.
	UIState.observe("unlockedSkills", function()
		local loadout = UIState.get("loadout") or {}
		local changed = false
		for index = 1, #Skills.Slots do
			local id = loadout[index]
			if id and not Loadout.isUnlocked(id) then
				loadout[index] = nil
				changed = true
			end
		end
		if changed then
			UIState.set("loadout", loadout)
		end
	end)
end

return Leveling
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="103">
            <Properties>
              <string name="Name">MobRender</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	MobRender — draws every mob, smoothly, on this machine.

	The server publishes each mob's state a few times a second (see Mobs/Net).
	This runs the animation locally at frame rate and interpolates the root
	between those updates, which is what turns 20Hz replication into 144Hz
	motion.

	TWO SEPARATE SMOOTHINGS, and conflating them was the whole bug:

	  ROOT   eased toward the published CFrame with a frame-rate-independent
	         alpha whose rate scales with how wrong it currently is. This is the
	         one that removes the teleporting, and the one that has to stay
	         honest about where a fast boss actually is — see FOLLOW below.
	  POSE   handed to Rig:blend exactly as before. Untouched, because the
	         animation blending was never the problem.

	IT NEVER OWNS TRUTH. Everything here decides what a model looks like
	between two states the server already committed to. Hitboxes, damage and
	timing all still resolve server-side against the authoritative position, so
	the worst a wrong frame here can do is look slightly off.

	Distance culling matters more than it looks: fourteen roamers, a boss and
	nine arena fighters is twenty-four rigs, and posing the ones behind you is
	pure waste.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Anim = require(ReplicatedStorage.Shared.Mobs.Anim)
local Net = require(ReplicatedStorage.Shared.Mobs.Net)
local Rig = require(ReplicatedStorage.Shared.Mobs.Rig)

local MobRender = {}

local player = Players.LocalPlayer

--[[
	How fast the rendered root chases the published one.

	THIS IS NOT ONE NUMBER, and treating it as one broke the Vorpal Bunny.

	A flat 0.35 per 60th is right for a townsperson at 7 studs/s — it trails by
	a fifth of a stud, which hides the 20Hz replication stagger and costs
	nothing. The same rate against the bunny's 150 studs/s lunge trails by 4.6
	studs. The player's attack range is 11. So you swung at where you *saw* the
	creature and the server had it nearly half a reach away, and the fight
	became unwinnable for a reason nothing on screen explained.

	Smoothing exists to hide network stagger, and network stagger is a small
	distance. Once the gap grows past that it is no longer stagger — it is the
	renderer being wrong — and accuracy has to win. So the rate scales with the
	error itself: gentle while the gap is small, hard once it isn't.

	Squared rather than linear so ordinary drift stays smooth and only real
	divergence pulls hard. Capped below 1 so even a lunge keeps a frame of
	smoothing instead of visibly stepping at the replication rate.
]]
local FOLLOW = 0.35
local MAX_FOLLOW = 0.9
--- Gap at which the follow rate is fully wound up, in studs. Set well under
--- the player's 11-stud attack range: by the time the error could cost you a
--- hit, it is already being corrected at full speed.
local ERROR_FULL = 5
--- Beyond this it is a teleport, a respawn or a leash — not drift. Snap.
local SNAP = 30
--- Beyond this nothing is posed. Positions still track so nobody pops.
local RENDER_RANGE = 260
local SCAN_INTERVAL = 1

local tracked: { [Model]: any } = {}
local nextScan = 0

local function viewerPosition(): Vector3?
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	return root and root.Position
end

local function bind(model: Model)
	local cframe = model:GetAttribute("NetCFrame")
	if typeof(cframe) ~= "CFrame" then
		return
	end

	local entry = {
		model = model,
		rig = Rig.bind(model),
		animName = model:GetAttribute("NetAnim") or "goblin",
		scale = model:GetAttribute("NetScale") or 1,
		-- Starts exactly on the published pose, so a mob never slides in from
		-- wherever the last one stood.
		current = cframe,
	}
	entry.rig:apply(cframe)
	tracked[model] = entry
end

local function rescan()
	for _, descendant in Workspace:GetDescendants() do
		if descendant:IsA("Model") and descendant:GetAttribute(Net.TAG) and not tracked[descendant] then
			bind(descendant)
		end
	end

	for model in tracked do
		if not model.Parent then
			tracked[model] = nil
		end
	end
end

local function step(dt: number)
	if os.clock() >= nextScan then
		nextScan = os.clock() + SCAN_INTERVAL
		rescan()
	end

	local viewer = viewerPosition()
	local clock = os.clock()

	for model, entry in tracked do
		local published = model:GetAttribute("NetCFrame")
		if typeof(published) ~= "CFrame" then
			continue
		end

		--[[
			Error-scaled, frame-rate-independent easing.

			`1 - (1 - rate)^(dt*60)` is the same form the Rig uses, and is what
			makes a 30fps and a 144fps client converge at the same wall-clock
			speed rather than the same number of frames.
		]]
		local gap = (published.Position - entry.current.Position).Magnitude

		if gap > SNAP then
			-- Teleport, respawn or leash. Interpolating across it would drag
			-- the model through the world in a straight line.
			entry.current = published
		else
			local urgency = math.clamp(gap / ERROR_FULL, 0, 1)
			local rate = FOLLOW + (MAX_FOLLOW - FOLLOW) * urgency * urgency
			local alpha = 1 - (1 - rate) ^ (dt * 60)
			entry.current = entry.current:Lerp(published, math.clamp(alpha, 0, 1))
		end

		if viewer and (entry.current.Position - viewer).Magnitude > RENDER_RANGE then
			continue
		end

		local animate = Anim[entry.animName] or Anim.goblin
		local state = model:GetAttribute("NetState") or "idle"
		local phase = model:GetAttribute("NetPhase") or 0

		local rootOffset, parts, bodyScale, rate = animate(state, phase, clock, Net.signals(model))

		local size = entry.scale
		local scaled = if bodyScale then bodyScale * size else Vector3.new(size, size, size)

		entry.rig:blend(entry.current * (rootOffset or CFrame.identity), parts, scaled, rate or 0.3, dt)
	end
end

function MobRender.start()
	RunService.RenderStepped:Connect(function(dt)
		local ok, err = pcall(step, dt)
		if not ok then
			warn(`[MobRender] {err}`)
		end
	end)
end

return MobRender
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="104">
            <Properties>
              <string name="Name">Quality</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Quality — keeps the frame rate by giving up the right things first.

	THE PROBLEM. The lighting in this game is expensive on purpose: Future
	technology, real shadows, atmosphere, bloom, colour correction, depth of
	field and sun rays. On a desktop that looks like the game is worth playing.
	On the phones and low-end laptops that are most of Roblox it is a slideshow,
	and the player's only conclusion is that the game is broken.

	THE ORDER MATTERS MORE THAN THE THRESHOLDS. Effects are surrendered from
	least informative to most, so a struggling device loses prettiness long
	before it loses anything it needs to play:

	  1. Depth of field  — pure decoration, and the first thing that makes a
	                       cheap GPU cry. Nobody has ever missed it.
	  2. Sun rays        — likewise, and doubly expensive at low frame rates
	                       because it is screen-space.
	  3. Bloom           — starts to matter, because eye glow and the parry
	                       flash read partly through it. Dimmed before dropped.
	  4. Colour grade    — the whole palette lives here. Kept until last.
	  5. Shadows         — never dropped automatically. They are how you read
	                       depth and where a creature is standing; a game with
	                       no shadows is harder to *play*, not just uglier.

	HYSTERESIS, because the alternative is unbearable. A scaler that reacts
	immediately oscillates: it drops an effect, the frame rate recovers, it
	restores the effect, the frame rate falls. Both directions are therefore
	measured over a sustained window and recovery is slower and stricter than
	degradation — a device has to prove it can hold the higher tier for three
	times as long as it took to lose it.
]]

local Lighting = game:GetService("Lighting")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

local Quality = {}

--- Highest first. `tier` is what the rest of the game can read.
local TIERS = {
	{ tier = 3, name = "High", depthOfField = true, sunRays = true, bloom = 1.0, grade = 1.0 },
	{ tier = 2, name = "Medium", depthOfField = false, sunRays = true, bloom = 0.7, grade = 1.0 },
	{ tier = 1, name = "Low", depthOfField = false, sunRays = false, bloom = 0.45, grade = 1.0 },
	{ tier = 0, name = "Minimum", depthOfField = false, sunRays = false, bloom = 0, grade = 0.5 },
}

--- Below this for `DROP_AFTER` seconds and a tier is surrendered.
local DROP_BELOW = 32
--- Above this for `RAISE_AFTER` seconds and one is taken back.
local RAISE_ABOVE = 55
local DROP_AFTER = 2.5
local RAISE_AFTER = 7.5

--- Nothing is touched for this long after joining. The first seconds of any
--- Roblox session are streaming and compiling, and judging a device on them
--- would put every player on Minimum forever.
local SETTLE = 6

local current = #TIERS
local badFor, goodFor = 0, 0
local started = false
--- Set once a player picks a tier by hand. The scaler stops guessing after
--- that: overriding someone's explicit choice is worse than running slowly.
local pinned = false
--- Smoothed frame rate. A single frame's `dt` is far too noisy to make a
--- decision on — one garbage collection would drop a tier.
local smoothedFps = 60

local base = {}

local function effect(className: string, name: string)
	local found = Lighting:FindFirstChild(name)
	return if found and found:IsA(className) then found else nil
end

local function apply(index: number)
	local preset = TIERS[#TIERS - index + 1]
	if not preset then
		return
	end

	local dof = effect("DepthOfFieldEffect", "Depth")
	if dof then
		dof.Enabled = preset.depthOfField
	end

	local rays = effect("SunRaysEffect", "Rays")
	if rays then
		rays.Enabled = preset.sunRays
	end

	local bloom = effect("BloomEffect", "Glow")
	if bloom then
		bloom.Enabled = preset.bloom > 0
		bloom.Intensity = base.bloom * preset.bloom
	end

	local grade = effect("ColorCorrectionEffect", "Grade")
	if grade then
		-- Never disabled, only pulled toward neutral. Switching the grade off
		-- outright changes every colour in the game at once, which reads as a
		-- rendering bug rather than as a quality setting.
		grade.Saturation = base.saturation * preset.grade
		grade.Contrast = base.contrast * preset.grade
	end
end

--- Index 1 is the lowest tier, #TIERS the highest — the inverse of the table's
--- order, which is written best-first for readability.
local function presetFor(index: number)
	return TIERS[#TIERS - index + 1]
end

function Quality.start()
	if started then
		return
	end
	started = true

	local bloom = effect("BloomEffect", "Glow")
	local grade = effect("ColorCorrectionEffect", "Grade")
	base.bloom = bloom and bloom.Intensity or 0.5
	base.saturation = grade and grade.Saturation or -0.1
	base.contrast = grade and grade.Contrast or 0.14

	-- Touch devices start one tier down rather than discovering it the hard
	-- way over the first ten seconds of play.
	if UserInputService.TouchEnabled and not UserInputService.MouseEnabled then
		current = math.max(#TIERS - 1, 1)
		apply(current)
	end

	local elapsed = 0
	RunService.Heartbeat:Connect(function(dt)
		elapsed += dt
		if elapsed < SETTLE or dt <= 0 or pinned then
			return
		end

		smoothedFps += (1 / dt - smoothedFps) * math.min(dt * 2, 1)
		local fps = smoothedFps

		if fps < DROP_BELOW then
			badFor += dt
			goodFor = 0
		elseif fps > RAISE_ABOVE then
			goodFor += dt
			badFor = 0
		else
			-- In between is neither evidence for nor against; decay both so a
			-- single stutter inside an otherwise fine minute counts for nothing.
			badFor = math.max(badFor - dt, 0)
			goodFor = math.max(goodFor - dt, 0)
		end

		if badFor >= DROP_AFTER and current > 1 then
			current -= 1
			badFor, goodFor = 0, 0
			apply(current)
		elseif goodFor >= RAISE_AFTER and current < #TIERS then
			current += 1
			badFor, goodFor = 0, 0
			apply(current)
		end
	end)
end

--- Current tier name, for the settings readout and `/quality`.
function Quality.name(): string
	local preset = presetFor(current)
	return if preset then preset.name else "High"
end

function Quality.tier(): number
	return current
end

--- Pin to a tier, for players who would rather choose. Stops the automatic
--- scaler from second-guessing them.
function Quality.set(tier: number)
	pinned = true
	current = math.clamp(math.floor(tier), 1, #TIERS)
	badFor, goodFor = 0, 0
	apply(current)
end

--- Hand control back to the scaler.
function Quality.auto()
	pinned = false
	badFor, goodFor = 0, 0
end

function Quality.isPinned(): boolean
	return pinned
end

return Quality
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="105">
            <Properties>
              <string name="Name">Sprint</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Sprint — hold Shift while moving to run, at the cost of stamina.

	This is what makes the stamina bar mean anything. Stamina only drains while
	you are actually *moving* under Shift; holding Shift standing still costs
	nothing, so the bar tracks distance covered rather than a held key.

	Running dry sets an exhausted state that does not clear the instant a drop
	of stamina returns -- you have to recover past RECOVERY_THRESHOLD before
	sprinting again. Without that, a player at zero stamina can stutter-sprint
	every frame as regen ticks in, which reads as broken and removes the cost.

	Stamina is written every frame while it changes, so the HUD renders it
	without tweening (see HUD.luau). Tweening a per-frame value would queue 60
	tweens a second and lag behind the input.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

local Assist = require(game:GetService("ReplicatedStorage").Shared.Config.Assist)
local Bindings = require(game:GetService("ReplicatedStorage").Shared.Config.Bindings)
local Encounters = require(game:GetService("ReplicatedStorage").Shared.Config.Encounters)
local UIState = require(script.Parent.Parent.UI.Core.UIState)

local WALK_SPEED = 16
local SPRINT_SPEED = 26

local DRAIN_PER_SECOND = 26
local REGEN_PER_SECOND = 22

--- Pause before stamina starts coming back, so a sprint has a real recovery
--- window rather than refilling the moment you release Shift.
local REGEN_DELAY = 0.55

--- Stamina you must recover to before sprinting is available again.
local RECOVERY_THRESHOLD = 30

--- MoveDirection magnitude above which the character counts as moving.
local MOVE_EPSILON = 0.1

local Sprint = {}

Sprint.IsSprinting = false

local humanoid: Humanoid? = nil
local sprintHeld = false
local exhausted = false
local lastDrainAt = 0
--- The rig's own jump value, captured at bind so the freeze can put it back.
local jumpRest = 7.2
--- Latched so the jump property is only written on an actual change.
local frozen: boolean? = nil

local function bindCharacter(character: Model)
	local found = character:WaitForChild("Humanoid", 10)
	humanoid = found
	if found then
		found.WalkSpeed = WALK_SPEED
		-- Captured rather than assumed: R6 and R15 disagree about which jump
		-- property is live, and hardcoding 50 or 7.2 would silently change the
		-- jump for whichever rig this isn't.
		jumpRest = if found.UseJumpPower then found.JumpPower else found.JumpHeight
	end
	-- A fresh body starts rested, and re-arms the freeze latch: the new humanoid
	-- carries default speeds, so a stale `true` would leave it walkable through
	-- a front-end that thinks it has already frozen the last one.
	exhausted = false
	Sprint.IsSprinting = false
	frozen = nil
end

--[[
	Holds the body still while the front-end owns the screen.

	The character exists and Roblox's control scripts are live from the moment
	the client loads, so through the whole dive sequence, the title screen and
	character creation the player could walk — behind an opaque boot overlay,
	before they had chosen who they were. In practice that meant wandering out
	of the courtyard during creation and then being teleported by the spawn
	routing the instant Begin was pressed, which looks exactly like the game
	losing track of you.

	Speed rather than anchoring: an anchored root stops the idle animation and
	makes the avatar look dead on the title screen, and it fights the spawn
	routing that runs at the end of creation.
]]
local function applyFreeze(hold: boolean)
	if not humanoid then
		return
	end
	if hold then
		humanoid.WalkSpeed = 0
		if humanoid.UseJumpPower then
			humanoid.JumpPower = 0
		else
			humanoid.JumpHeight = 0
		end
	elseif humanoid.UseJumpPower then
		humanoid.JumpPower = jumpRest
	else
		humanoid.JumpHeight = jumpRest
	end
end

function Sprint.start()
	local player = Players.LocalPlayer

	if player.Character then
		task.spawn(bindCharacter, player.Character)
	end
	player.CharacterAdded:Connect(bindCharacter)

	UserInputService.InputBegan:Connect(function(input, processed)
		if not processed and Bindings.matches("sprint", input) then
			sprintHeld = true
		end
	end)

	-- Not gated on `processed`: if the key went down before a text box took
	-- focus, we still need the release, or sprint would latch on forever.
	UserInputService.InputEnded:Connect(function(input)
		if Bindings.matches("sprint", input) then
			sprintHeld = false
		end
	end)

	RunService.Heartbeat:Connect(function(dt)
		local stamina = UIState.get("stamina")
		local maxStamina = UIState.get("maxStamina")
		local previous = stamina

		local playing = UIState.get("phase") == "playing" and not UIState.get("dead")
		local moving = humanoid ~= nil and humanoid.MoveDirection.Magnitude > MOVE_EPSILON
		local guarding = UIState.get("blocking") == true
		-- No sprinting behind a raised guard. Committing to defence should
		-- cost mobility, or blocking would be strictly free.
		local sprinting = playing and moving and sprintHeld and not guarding and not exhausted and stamina > 0

		if sprinting then
			stamina = math.max(stamina - DRAIN_PER_SECOND * dt, 0)
			lastDrainAt = os.clock()
			if stamina <= 0 then
				exhausted = true
				sprinting = false
			end
		elseif stamina < maxStamina and os.clock() - lastDrainAt >= REGEN_DELAY then
			stamina = math.min(stamina + REGEN_PER_SECOND * dt, maxStamina)
		end

		if exhausted and stamina >= RECOVERY_THRESHOLD then
			exhausted = false
		end

		if Sprint.IsSprinting ~= sprinting then
			UIState.set("sprinting", sprinting)
		end
		Sprint.IsSprinting = sprinting

		-- The front-end holds the body still. Latched, so the jump property is
		-- only touched when the state actually flips.
		if frozen ~= not playing then
			frozen = not playing
			applyFreeze(frozen)
		end

		if humanoid and playing then
			local target = if sprinting then SPRINT_SPEED elseif guarding then Encounters.Block.moveSpeed else WALK_SPEED
			-- Focus slows you a little. Enough to sell the concentration, not
			-- enough to make dodging out of the way feel broken.
			if UIState.get("focusing") then
				target *= Assist.MoveScale
			end
			if humanoid.WalkSpeed ~= target then
				humanoid.WalkSpeed = target
			end
		end

		-- Skip the write when idle at full stamina, so observers aren't woken
		-- 60 times a second for nothing.
		if math.abs(stamina - previous) > 0.001 then
			UIState.set("stamina", stamina)
		end
	end)
end

return Sprint
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="106">
            <Properties>
              <string name="Name">Status</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Status — expires timed status effects.

	Runs at 4Hz rather than every frame: the HUD reads `expiresAt` directly to
	draw its countdown, so this only has to remove entries once they're spent.

	Server-authoritative later; for now it's the only thing aging them out.
]]

local RunService = game:GetService("RunService")

local UIState = require(script.Parent.Parent.UI.Core.UIState)

local TICK = 0.25

local Status = {}

function Status.start()
	local accumulator = 0

	RunService.Heartbeat:Connect(function(dt)
		accumulator += dt
		if accumulator < TICK then
			return
		end
		accumulator = 0

		local statuses = UIState.get("statuses")
		if not statuses or #statuses == 0 then
			return
		end

		local now = os.clock()
		local removed = false
		for index = #statuses, 1, -1 do
			local expiresAt = statuses[index].expiresAt
			if expiresAt and expiresAt <= now then
				table.remove(statuses, index)
				removed = true
			end
		end

		if removed then
			UIState.set("statuses", statuses)
		end
	end)
end

return Status
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="107">
            <Properties>
              <string name="Name">Townsfolk</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Townsfolk — the residents, animated on the client.

	WHY THIS EXISTS. The town's people used to be posed on the server: fifteen
	rigs, twenty parts each, three hundred CFrames written every Heartbeat and
	replicated to every client. Roblox does not interpolate anchored-part
	CFrame changes — they arrive as batched property updates at roughly 20Hz —
	so what the player actually saw was three hundred parts teleporting five
	times a frame. That is the juddering. It is not frame rate and it cannot be
	fixed by making the server faster; the ceiling is the replication rate.

	The fix is that none of it needed to be on the server. A townsperson is
	pure scenery: their entire position is a function of the clock and a
	schedule that both halves already have. So every client runs the same
	function locally at its own frame rate and gets perfectly smooth motion for
	free, and the network carries nothing at all.

	DETERMINISM is what makes this safe. `Town.scheduleAt` is pure, the clock
	is replicated by Lighting, and walking is a lerp toward a fixed point — so
	every client independently arrives at the same position, and the server's
	own range checks (which use the same schedule) still agree with what the
	player sees.

	The server keeps: spawning the models, owning the conversation, and
	deciding whether you are close enough to talk. It just stops moving them.
]]

local Lighting = game:GetService("Lighting")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Rig = require(ReplicatedStorage.Shared.Mobs.Rig)
local Town = require(ReplicatedStorage.Shared.Config.Town)

local V = Vector3.new

local TOWN_FOLDER = "FirstiaTown"
--- Must match the server's, or a resident drifts from where you can talk to it.
local WALK_SPEED = 7
--- Beyond this the rig is left alone entirely. Nobody can see a townsperson
--- breathe from ninety studs, and fifteen rigs is worth culling.
local ANIMATE_RANGE = 150
local SCAN_INTERVAL = 2

local Townsfolk = {}

local player = Players.LocalPlayer
local residents: { [string]: any } = {}
local nextScan = 0

local function characterPosition(): Vector3?
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	return root and root.Position
end

--- Binds any resident model that has appeared since the last scan.
local function rescan()
	local host = Workspace:FindFirstChild(TOWN_FOLDER)
	if not host then
		return
	end

	for _, model in host:GetChildren() do
		local id = model:IsA("Model") and model:GetAttribute("NpcId")
		if not id or residents[id] then
			continue
		end
		local npc = Town.findNpc(id)
		if not (npc and model.PrimaryPart) then
			continue
		end

		local entry = Town.scheduleAt(npc, Lighting.ClockTime)
		local at = entry.at + V(0, Town.Floor, 0)

		residents[id] = {
			npc = npc,
			model = model,
			rig = Rig.bind(model),
			position = at,
			cframe = CFrame.lookAt(at, at + entry.facing),
			phase = math.random() * 6,
			hidden = false,
		}
		residents[id].rig:apply(residents[id].cframe)
	end

	-- Drop anything the server removed.
	for id, entry in residents do
		if not entry.model.Parent then
			residents[id] = nil
		end
	end
end

--[[
	Poses one resident. Same shapes the server used to run, at frame rate.

	Kept identical to the old server-side poser on purpose: this is a move, not
	a redesign, and any difference here would show up as the townsfolk walking
	differently from every other rig in the game.
]]
local function pose(entry, dt: number, moving: boolean)
	entry.phase += dt * (if moving then 7.5 else 1.6)

	local swing = math.sin(entry.phase)
	local offsets = {}

	if entry.npc.build then
		-- Catalogue models (Emul and the warren) hop rather than walk.
		local hop = if moving then math.abs(math.sin(entry.phase)) else 0
		entry.rig:blend(
			entry.cframe * CFrame.new(0, hop * 1.7, 0),
			nil,
			V(1 + hop * 0.1, 1 - hop * 0.16, 1 + hop * 0.1),
			if moving then 0.5 else 0.14,
			dt
		)
		return
	end

	if moving then
		local amount = 26
		offsets.LegL = Rig.hinge(2.4, swing * amount)
		offsets.LegR = Rig.hinge(2.4, -swing * amount)
		offsets.BootL = Rig.hinge(4.0, swing * amount)
		offsets.BootR = Rig.hinge(4.0, -swing * amount)
		offsets.ArmL = Rig.hinge(2.5, -swing * amount * 0.7)
		offsets.ArmR = Rig.hinge(2.5, swing * amount * 0.7)
		offsets.HandL = Rig.hinge(4.0, -swing * amount * 0.7)
		offsets.HandR = Rig.hinge(4.0, swing * amount * 0.7)
		offsets.Torso = CFrame.new(0, math.abs(swing) * 0.18, 0) * CFrame.Angles(math.rad(-4), 0, 0)
	else
		local breath = math.sin(entry.phase) * 0.06
		offsets.Torso = CFrame.new(0, breath, 0)
		offsets.Head = CFrame.new(0, breath, 0) * CFrame.Angles(0, math.sin(entry.phase * 0.4) * 0.12, 0)
		offsets.ArmL = Rig.hinge(2.5, 3)
		offsets.ArmR = Rig.hinge(2.5, 3)
	end

	entry.rig:blend(entry.cframe, offsets, nil, if moving then 0.4 else 0.12, dt)
end

local function setHidden(entry, hidden: boolean)
	if entry.hidden == hidden then
		return
	end
	entry.hidden = hidden

	for _, part in entry.model:GetDescendants() do
		if part:IsA("BasePart") and part.Name ~= "Root" then
			part.LocalTransparencyModifier = if hidden then 1 else 0
		end
	end
	local plate = entry.model.PrimaryPart and entry.model.PrimaryPart:FindFirstChild("Nameplate")
	if plate then
		plate.Enabled = not hidden
	end
end

local function step(dt: number)
	if os.clock() >= nextScan then
		nextScan = os.clock() + SCAN_INTERVAL
		rescan()
	end

	local hour = Lighting.ClockTime
	local viewer = characterPosition()

	for _, entry in residents do
		local scheduled = Town.scheduleAt(entry.npc, hour)
		local target = scheduled.at + V(0, Town.Floor, 0)

		local delta = target - entry.position
		local distance = delta.Magnitude
		local moving = distance > 1.2

		if moving then
			entry.position += delta.Unit * math.min(WALK_SPEED * dt, distance)
			entry.cframe = CFrame.lookAt(entry.position, entry.position + delta.Unit)
		else
			entry.position = target
			entry.cframe = CFrame.lookAt(entry.position, entry.position + scheduled.facing)
		end

		--[[
			Hiding is client-side now, via LocalTransparencyModifier rather than
			Transparency. The server no longer touches these parts at all, so
			using the replicated property would be a client writing state the
			server believes it owns — and it would fight the moment anything
			else replicated.
		]]
		setHidden(entry, (scheduled.state == "home" or scheduled.state == "sleep") and not moving)

		-- Cull posing by distance. Position still updates so nobody teleports
		-- when they come back into range.
		if viewer and (entry.position - viewer).Magnitude > ANIMATE_RANGE then
			continue
		end
		pose(entry, dt, moving)
	end
end

function Townsfolk.start()
	RunService.RenderStepped:Connect(function(dt)
		local ok, err = pcall(step, dt)
		if not ok then
			warn(`[Townsfolk] {err}`)
		end
	end)
end

return Townsfolk
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="108">
            <Properties>
              <string name="Name">Tutorial</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Tutorial — the guided start, driven from the client.

	Runs the step machine from Config/Tutorial: says a line, sets an objective,
	watches for the player to actually do the thing, then moves on. The server
	owns the physical yard (instructor, dummies, practice enemy); this owns
	whether you have learned anything.

	It lives on the client because that is where every signal already is —
	movement, jumps, sprint, blocking, menu state, ability casts. Routing all of
	that to the server to be graded and routed back would add latency to a
	tutorial and buy nothing: there is no exploit in claiming you jumped.

	Steps advance on *doing*, never on a Next button. A player who already knows
	how to walk can complete three steps before the instructor finishes her
	first sentence, and that is the intended experience for anyone replaying.
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Characters = require(ReplicatedStorage.Shared.Config.Characters)
local Config = require(ReplicatedStorage.Shared.Config.Tutorial)
local Network = require(ReplicatedStorage.Shared.Network)
local Skills = require(ReplicatedStorage.Shared.Config.Skills)

local Leveling = require(script.Parent.Leveling)
local UI = require(script.Parent.Parent.UI)
local UIState = UI.State

local player = Players.LocalPlayer

local Tutorial = {}

local active = false
local stepIndex = 0
local step = nil
local progress = 0
local advancing = false

--- Per-frame trackers.
local lastPosition: Vector3? = nil
local blockHeld = 0
local sprintHeld = 0

--------------------------------------------------------------------------------

local function objectiveDone()
	return step and progress >= (step.goal.count or 1)
end

local function setProgress(value: number)
	if not step or advancing then
		return
	end
	progress = value
	UI.Objective.setProgress(progress / (step.goal.count or 1))
end

local function bump(amount: number?)
	setProgress(progress + (amount or 1))
end

local function finish()
	active = false
	step = nil
	Network.TutorialDrill:FireServer(nil)
	UI.Objective.setObjective(nil)
	UI.Objective.hideDialogue()

	local reward = Config.Reward
	local titles = UIState.get("earnedTitles") or {}
	if reward.title and not table.find(titles, reward.title) then
		table.insert(titles, reward.title)
	end

	UIState.patch({
		tutorialDone = true,
		earnedTitles = titles,
		skillPoints = (UIState.get("skillPoints") or 0) + reward.skillPoints,
		-- Parry is *taught* here, so it is granted rather than bought.
		parryRank = math.max(UIState.get("parryRank") or 0, reward.parryRank),
	})

	UI.Toast.push({ title = "Training complete", subtitle = "Parry unlocked", kind = "levelup", duration = 6 })
	task.delay(0.6, function()
		Leveling.addExperience(reward.experience)
	end)
end

--- Hands over whatever this step is about to teach. A lesson cannot teach a
--- tool the player does not have.
local function applyGrant(grant)
	if not grant then
		return
	end

	if grant.skill then
		local unlocked = UIState.get("unlockedSkills") or {}
		if not table.find(unlocked, grant.skill) then
			table.insert(unlocked, grant.skill)
			UIState.set("unlockedSkills", unlocked)
		end
		if grant.slot then
			local loadout = UIState.get("loadout") or {}
			-- Only fills an empty slot: never overwrite a returning player's
			-- own choice with the tutorial's.
			if not loadout[grant.slot] then
				loadout[grant.slot] = grant.skill
				UIState.set("loadout", loadout)
			end
		end
	end

	if grant.parryRank then
		UIState.set("parryRank", math.max(UIState.get("parryRank") or 0, grant.parryRank))
	end
end

local function beginStep(index: number)
	-- Whatever drill the previous step was running, stop it.
	Network.TutorialDrill:FireServer(nil)

	stepIndex = index
	step = Config.Steps[index]
	progress = 0
	advancing = false

	if not step then
		finish()
		return
	end

	applyGrant(step.grant)
	if step.drill then
		Network.TutorialDrill:FireServer(step.drill)
	end

	UI.Objective.say(Config.Instructor.name, step.say)
	UI.Objective.setObjective(step.objective, index, #Config.Steps)
	UI.Objective.setProgress(0)

	-- Reset per-step trackers.
	lastPosition = nil
	blockHeld = 0
	sprintHeld = 0
end

local function advance()
	if advancing or not step then
		return
	end
	advancing = true

	local closing = step.done
	UI.Objective.setProgress(1)
	UI.Objective.say(Config.Instructor.name, closing)

	task.delay(2.4, function()
		if active then
			beginStep(stepIndex + 1)
		end
	end)
end

--------------------------------------------------------------------------------
-- Signals
--------------------------------------------------------------------------------

local function onKind(kind: string, amount: number?)
	if not step or advancing or step.goal.kind ~= kind then
		return
	end
	bump(amount)
	if objectiveDone() then
		advance()
	end
end

function Tutorial.start()
	-- Only guided origins run this. Traveler gets nothing and likes it.
	UIState.observe("character", function(character)
		local origin = Characters.findOrigin(character and character.origin)
		if not origin or not origin.tutorial then
			return
		end
		if active or UIState.get("tutorialDone") then
			return
		end
		active = true
		task.delay(1.2, function()
			beginStep(1)
		end)
	end)

	-- Anything at all skips the typewriter; specific things advance steps.
	UserInputService.InputBegan:Connect(function(input, processed)
		UI.Objective.skipTyping()
		if processed or not active then
			return
		end

		if input.KeyCode == Enum.KeyCode.Space then
			onKind("jump")
		elseif
			input.UserInputType == Enum.UserInputType.MouseButton1
			or input.KeyCode == Enum.KeyCode.E
			or input.KeyCode == Enum.KeyCode.Return
		then
			-- Click, E or Enter all acknowledge her. Whichever you reach for
			-- first is the right one.
			onKind("talk")
		end
	end)

	-- Server-side training-yard events.
	Network.TutorialHit.OnClientEvent:Connect(function(kind)
		if kind == "enemyDown" then
			onKind("enemy")
		else
			onKind(kind)
		end
	end)

	-- Parry confirmations come through the impact channel.
	Network.Impact.OnClientEvent:Connect(function(kind)
		if kind == "parry" then
			onKind("parry")
		end
	end)

	-- Dashing is an ability cast; only Quickstep counts.
	UI.Events.AbilityCast:Connect(function(_slot, skill)
		if skill and skill.id == "step" then
			onKind("dodge")
		end
	end)

	UIState.observe("menuOpen", function(mode)
		if mode == "menu" then
			onKind("menu")
		end
	end)

	RunService.Heartbeat:Connect(function(dt)
		if not active or not step or advancing then
			return
		end

		local character = player.Character
		local root = character and character:FindFirstChild("HumanoidRootPart")
		if not root then
			return
		end

		local kind = step.goal.kind

		if kind == "move" then
			if lastPosition then
				local moved = (root.Position - lastPosition).Magnitude
				-- Ignore teleports and falls; this should measure walking.
				if moved < 6 then
					bump(moved)
				end
			end
			lastPosition = root.Position
			if objectiveDone() then
				advance()
			end
		elseif kind == "sprint" then
			if UIState.get("sprinting") then
				sprintHeld += dt
				setProgress(sprintHeld)
				if objectiveDone() then
					advance()
				end
			end
		elseif kind == "block" then
			if UIState.get("blocking") then
				blockHeld += dt
				setProgress(blockHeld)
				if objectiveDone() then
					advance()
				end
			end
		end
	end)

	-- Skills.Core is referenced so the tutorial's parry step and the menu's
	-- core-skill entry can never drift apart silently.
	assert(Skills.findCore("parry"), "Tutorial expects a core parry skill")
end

return Tutorial
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="LocalScript" referent="109">
          <Properties>
            <string name="Name">Main</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	Main — client entry point.

	Boots the interface, starts the gameplay controllers, and registers the
	admin commands.

	Real keybinds are deliberately few:
	    Shift (held, while moving)  sprint
	    Q / E / R                   cast loadout slots 1-3
	    F                           parry (core skill)
	    G                           talk to whoever you are standing next to
	    C (held)                    block
	    B                           character menu
	    Escape                      close the open menu

	Menu keys toggle: the key that opens a screen closes it again. Add new
	menus to MENU_KEYS and that behaviour comes for free.

	Q/E/R are owned by Controllers/Abilities and defined in Skills.Slots, so
	rebinding a slot is a config change, not a code change.

	Everything else a developer needs is a chat command -- see Admin.luau, or
	type /help in game.

	Not Tab: Roblox reserves it for CoreGui selection navigation, so it moves
	focus to the player list and arrives with gameProcessedEvent already true.
	B follows the backpack convention and is unclaimed.

	Reserved for gameplay, so don't bind them here: WASD (move), Space (jump),
	Shift (sprint), Q/E/R (ability slots), F (parry), C (block), G (interact),
	1-9 (hotbar).

	Right mouse is NOT ours. Roblox owns it for camera rotation and every player
	already knows that; block used to live there and stopped you looking around
	while guarding.

	G rather than the conventional E for interaction, because E is already an
	ability slot and silently eating a skill cast near a shopkeeper is worse
	than an unusual key the prompt spells out every time.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarterGui = game:GetService("StarterGui")
local UserInputService = game:GetService("UserInputService")

local Bindings = require(ReplicatedStorage.Shared.Config.Bindings)

local Abilities = require(script.Parent.Controllers.Abilities)
local Acoustics = require(script.Parent.Controllers.Acoustics)
local Admin = require(script.Parent.Admin)
local BossIntro = require(script.Parent.Controllers.BossIntro)
local Ambience = require(script.Parent.Controllers.Ambience)
local Combat = require(script.Parent.Controllers.Combat)
local Feel = require(script.Parent.Controllers.Feel)
local Interact = require(script.Parent.Controllers.Interact)
local MobRender = require(script.Parent.Controllers.MobRender)
local Leveling = require(script.Parent.Controllers.Leveling)
local Quality = require(script.Parent.Controllers.Quality)
local Sprint = require(script.Parent.Controllers.Sprint)
local Townsfolk = require(script.Parent.Controllers.Townsfolk)
local Status = require(script.Parent.Controllers.Status)
local Tutorial = require(script.Parent.Controllers.Tutorial)
local UI = require(script.Parent.UI)

-- Roblox's backpack hotbar duplicates our inventory and sits over the HUD.
-- SetCoreGuiEnabled throws if CoreGui isn't up yet, hence the retry.
task.spawn(function()
	for _ = 1, 10 do
		local ok = pcall(function()
			StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false)
		end)
		if ok then
			return
		end
		task.wait(0.25)
	end
end)

-- First, so a struggling device has already shed post-processing before the
-- dive sequence and the first fight rather than during them.
Quality.start()

UI.start()
Leveling.start()
Sprint.start()
Status.start()
Abilities.start()
Combat.start()
Feel.start()
Interact.start()
Townsfolk.start()
MobRender.start()
Ambience.start()
Acoustics.start()
BossIntro.start()
Tutorial.start()
Admin.start()

--[[
	Which screen each menu action opens. One entry gets a key, a gamepad button
	and a row on the on-screen panel, because all three come from `Bindings`.
]]
local MENU_ACTIONS = {
	menu = "menu",
}

UserInputService.InputBegan:Connect(function(input, processed)
	if processed then
		return
	end

	-- Menus belong to gameplay; the front-end owns its own input.
	if UI.State.get("phase") ~= "playing" then
		return
	end

	--[[
		Cancel first, and it has to be, because on a gamepad `cancel` and
		`interact` are both B. With a menu open B closes it; with nothing open
		the press falls through to Interact's own handler and talks to whoever
		you are standing next to. That contextual split is the console
		convention, and getting the order wrong here would make it impossible to
		shut a menu with a controller.
	]]
	if Bindings.matches("cancel", input) then
		if UI.State.get("menuOpen") then
			UI.State.set("menuOpen", nil)
			return
		end
	end

	for action, screen in MENU_ACTIONS do
		if Bindings.matches(action, input) then
			UI.State.set("menuOpen", if UI.State.get("menuOpen") == screen then nil else screen)
			return
		end
	end
end)
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="110">
          <Properties>
            <string name="Name">UI</string>
            <string name="Source"><![CDATA[--!nonstrict
--[[
	UI — boot order for the whole interface.

	Order matters only in that Responsive must be running before any Layer is
	created, since layers read the current scale at construction time.
]]

local Core = script.Core
local Components = script.Components

local Events = require(Core.Events)
local Responsive = require(Core.Responsive)
local UIState = require(Core.UIState)

local DamageNumbers = require(script.Overlays.DamageNumbers)
local ScenarioBanner = require(script.Overlays.ScenarioBanner)
local FocusFx = require(script.Overlays.FocusFx)
local ScreenFx = require(script.Overlays.ScreenFx)
local Toast = require(script.Overlays.Toast)
local LevelUp = require(script.Overlays.LevelUp)
local Waypoint = require(script.Overlays.Waypoint)

local AbilityBar = require(script.Screens.AbilityBar)
local BossBar = require(script.Screens.BossBar)
local DeathScreen = require(script.Screens.DeathScreen)
local ArenaHud = require(script.Screens.ArenaHud)
local Controls = require(script.Screens.Controls)
local Craft = require(script.Screens.Craft)
local FishingHud = require(script.Screens.FishingHud)
local Dialogue = require(script.Screens.Dialogue)
local Shop = require(script.Screens.Shop)
local HUD = require(script.Screens.HUD)
local Menu = require(script.Screens.Menu)
local Objective = require(script.Screens.Objective)

local Flow = require(script.Flow)

local UI = {}

-- Re-exported so gameplay code has one require target.
UI.State = UIState
UI.Events = Events
UI.Objective = Objective
UI.Responsive = Responsive
UI.Flow = Flow
UI.DamageNumbers = DamageNumbers
UI.ScenarioBanner = ScenarioBanner
UI.Toast = Toast
UI.Waypoint = Waypoint
UI.LevelUp = LevelUp
UI.Controls = Controls
UI.Dialogue = Dialogue
UI.Shop = Shop
UI.Craft = Craft
UI.Bar = require(Components.Bar)
UI.Button = require(Components.Button)
UI.Choice = require(Components.Choice)
UI.Meter = require(Components.Meter)
UI.Panel = require(Components.Panel)
UI.TextField = require(Components.TextField)

local started = false

function UI.start()
	if started then
		return
	end
	started = true

	Responsive.start()

	DamageNumbers.start()
	Toast.start()
	Waypoint.start()
	LevelUp.start()
	ScreenFx.start()
	FocusFx.start()
	ScenarioBanner.start()

	HUD.start()
	AbilityBar.start()
	BossBar.start()
	Menu.start()
	Objective.start()
	DeathScreen.start()
	Dialogue.start()
	Shop.start()
	Craft.start()
	ArenaHud.start()
	FishingHud.start()
	Controls.start()

	-- Last: builds the front-end screens and kicks off the dive sequence, so
	-- everything it transitions into already exists.
	Flow.start()
end

return UI
]]></string>
          </Properties>
          <Item class="Folder" referent="111">
            <Properties>
              <string name="Name">Components</string>
            </Properties>
            <Item class="ModuleScript" referent="112">
              <Properties>
                <string name="Name">Bar</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Bar — an animated resource meter with a chip-damage trail.

	The trail is the whole point. When you take a hit, the coloured fill snaps
	down immediately (so the number is honest) while a pale "chip" layer hangs
	at the old value for a beat and then drains after it. That lag is what
	makes damage *read* -- without it, a bar just quietly gets shorter and hits
	feel weightless.

	Healing runs the reverse: the chip jumps ahead instantly and the fill
	catches up, so gaining resource feels like it fills rather than snaps.

	    local hp = Bar.new({
	        Parent = root,
	        Size = UDim2.fromOffset(460, 26),
	        Color = Theme.Color.Health,
	        ChipColor = Theme.Color.HealthChip,
	        ShowText = true,
	    })
	    hp:setValue(340, 500)
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Bar = {}
Bar.__index = Bar

export type Config = {
	Parent: Instance,
	Size: UDim2?,
	Position: UDim2?,
	AnchorPoint: Vector2?,
	Color: Color3?,
	ChipColor: Color3?,
	Background: Color3?,
	Radius: UDim?,
	ShowText: boolean?,
	TextSize: number?,
	--- Fraction below which the bar pulses. 0 disables.
	LowThreshold: number?,
	--- Draws N divider ticks across the bar (segmented stamina, phase pips).
	Segments: number?,
	LayoutOrder: number?,
	ZIndex: number?,
}

function Bar.new(config: Config)
	local self = setmetatable({}, Bar)

	self._trove = Trove.new()
	self._config = config
	self._fraction = 1
	self._chipFraction = 1
	self._chipToken = 0
	self._chipTween = nil
	self._pulseTween = nil
	self._color = config.Color or Theme.Color.Health

	local radius = config.Radius or Theme.Radius.sm

	self.Root = self._trove:add(Create("Frame", {
		Name = "Bar",
		Size = config.Size or UDim2.fromOffset(400, 24),
		Position = config.Position,
		AnchorPoint = config.AnchorPoint,
		LayoutOrder = config.LayoutOrder,
		ZIndex = config.ZIndex or 1,
		BackgroundColor3 = config.Background or Theme.Color.Void,
		BackgroundTransparency = 0.15,
		BorderSizePixel = 0,
		ClipsDescendants = true,
		Parent = config.Parent,

		Create("UICorner", { CornerRadius = radius }),
		Create("UIStroke", {
			Color = Theme.Color.Stroke,
			Thickness = 1,
			Transparency = 0.35,
			ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
		}),
	}))

	-- Chip layer sits behind the fill and lags it.
	self._chip = Create("Frame", {
		Name = "Chip",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = config.ChipColor or Theme.Color.HealthChip,
		BackgroundTransparency = 0.45,
		BorderSizePixel = 0,
		ZIndex = 2,
		Parent = self.Root,
	})

	self._fill = Create("Frame", {
		Name = "Fill",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = self._color,
		BorderSizePixel = 0,
		ZIndex = 3,
		Parent = self.Root,

		-- Slight vertical shading so the bar isn't a flat slab.
		Create("UIGradient", {
			Rotation = 90,
			Color = ColorSequence.new({
				ColorSequenceKeypoint.new(0, Color3.new(1, 1, 1)),
				ColorSequenceKeypoint.new(1, Color3.fromRGB(178, 178, 178)),
			}),
		}),
	})

	-- Bright leading edge, so the eye can find the current value fast.
	self._edge = Create("Frame", {
		Name = "Edge",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.fromScale(1, 0),
		Size = UDim2.new(0, 2, 1, 0),
		BackgroundColor3 = Color3.new(1, 1, 1),
		BackgroundTransparency = 0.4,
		BorderSizePixel = 0,
		ZIndex = 4,
		Parent = self._fill,
	})

	-- Full-bar white overlay used only for the low-resource pulse.
	self._pulse = Create("Frame", {
		Name = "Pulse",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ZIndex = 5,
		Parent = self.Root,
	})

	if config.Segments and config.Segments > 1 then
		self:_buildSegments(config.Segments)
	end

	if config.ShowText then
		self._label = Create("TextLabel", {
			Name = "Value",
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			TextSize = config.TextSize or 13,
			TextColor3 = Theme.Color.Text,
			TextStrokeColor3 = Theme.Color.Void,
			TextStrokeTransparency = 0.4,
			TextXAlignment = Enum.TextXAlignment.Right,
			Text = "",
			ZIndex = 6,
			Parent = self.Root,

			Create("UIPadding", { PaddingRight = UDim.new(0, 8) }),
		})
	end

	return self
end

function Bar:_buildSegments(count: number)
	for index = 1, count - 1 do
		Create("Frame", {
			Name = `Tick{index}`,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(index / count, 0),
			Size = UDim2.new(0, 1, 1, 0),
			BackgroundColor3 = Theme.Color.Void,
			BackgroundTransparency = 0.35,
			BorderSizePixel = 0,
			ZIndex = 6,
			Parent = self.Root,
		})
	end
end

function Bar:_updatePulse()
	local threshold = self._config.LowThreshold
	if not threshold or threshold <= 0 then
		return
	end

	local shouldPulse = self._fraction <= threshold and self._fraction > 0

	if shouldPulse and not self._pulseTween then
		self._pulse.BackgroundTransparency = 0.9
		self._pulseTween = Motion.pulse(self._pulse, 0.5, { BackgroundTransparency = 0.7 })
	elseif not shouldPulse and self._pulseTween then
		self._pulseTween:Cancel()
		self._pulseTween = nil
		self._pulse.BackgroundTransparency = 1
	end
end

--- Sets the bar to `current` out of `max`.
--- `instant` skips all animation (use on respawn / first paint).
function Bar:setValue(current: number, max: number?, instant: boolean?)
	local maximum = max or self._max or 1
	self._max = maximum
	self._current = current

	local fraction = if maximum > 0 then math.clamp(current / maximum, 0, 1) else 0
	local previous = self._fraction
	self._fraction = fraction

	if self._label then
		self._label.Text = `{math.floor(current + 0.5)} / {math.floor(maximum + 0.5)}`
	end

	-- Any in-flight chip drain is now stale.
	if self._chipTween then
		self._chipTween:Cancel()
		self._chipTween = nil
	end
	self._chipToken += 1
	local token = self._chipToken

	if instant then
		self._fill.Size = UDim2.fromScale(fraction, 1)
		self._chip.Size = UDim2.fromScale(fraction, 1)
		self._chipFraction = fraction
		self:_updatePulse()
		return
	end

	if fraction < previous then
		-- Damage: fill drops now, chip hangs then drains after it.
		Motion.to(self._fill, Theme.Time.Fast, { Size = UDim2.fromScale(fraction, 1) })

		task.delay(Theme.Time.ChipHold, function()
			if token ~= self._chipToken or not self._chip.Parent then
				return
			end
			self._chipFraction = fraction
			self._chipTween =
				Motion.to(self._chip, Theme.Time.ChipDrain, { Size = UDim2.fromScale(fraction, 1) }, Theme.Ease.Exit)
		end)
	else
		-- Gain: chip leads, fill catches up.
		self._chip.Size = UDim2.fromScale(fraction, 1)
		self._chipFraction = fraction
		Motion.to(self._fill, Theme.Time.Base, { Size = UDim2.fromScale(fraction, 1) })
	end

	self:_updatePulse()
end

function Bar:setColor(color: Color3, instant: boolean?)
	self._color = color
	if instant then
		self._fill.BackgroundColor3 = color
	else
		Motion.to(self._fill, Theme.Time.Base, { BackgroundColor3 = color })
	end
end

function Bar:setVisible(visible: boolean)
	self.Root.Visible = visible
end

function Bar:getFraction(): number
	return self._fraction
end

function Bar:destroy()
	if self._pulseTween then
		self._pulseTween:Cancel()
	end
	if self._chipTween then
		self._chipTween:Cancel()
	end
	self._chipToken += 1
	self._trove:clean()
end

return Bar
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="113">
              <Properties>
                <string name="Name">Button</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Button — the standard interactive control.

	Handles hover, press, disabled and touch sizing in one place. Minimum
	target is 44px in reference units, which is the accessibility floor for
	touch; on phones Responsive scales that up further.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local MIN_TOUCH_TARGET = 44

local Button = {}
Button.__index = Button

export type Config = {
	Parent: Instance,
	Text: string,
	Size: UDim2?,
	Position: UDim2?,
	AnchorPoint: Vector2?,
	LayoutOrder: number?,
	ZIndex: number?,
	--- "primary" fills with accent, "ghost" is text-only with a stroke.
	Variant: string?,
	AccentColor: Color3?,
	OnClick: (() -> ())?,
}

function Button.new(config: Config)
	local self = setmetatable({}, Button)

	self._trove = Trove.new()
	self._enabled = true
	self._variant = config.Variant or "ghost"
	self._accent = config.AccentColor or Theme.Color.Accent

	local size = config.Size or UDim2.fromOffset(200, MIN_TOUCH_TARGET)
	-- Enforce the touch floor without overriding an intentionally bigger button.
	if size.Y.Scale == 0 and size.Y.Offset < MIN_TOUCH_TARGET then
		size = UDim2.new(size.X.Scale, size.X.Offset, 0, MIN_TOUCH_TARGET)
	end

	local isPrimary = self._variant == "primary"

	self.Root = self._trove:add(Create("TextButton", {
		Name = "Button",
		Size = size,
		Position = config.Position,
		AnchorPoint = config.AnchorPoint,
		LayoutOrder = config.LayoutOrder,
		ZIndex = config.ZIndex or 1,
		BackgroundColor3 = if isPrimary then self._accent else Theme.Color.PanelRaised,
		BackgroundTransparency = if isPrimary then 0 else 0.25,
		AutoButtonColor = false,
		BorderSizePixel = 0,
		FontFace = Theme.Font.SemiBold,
		Text = string.upper(config.Text),
		TextSize = 14,
		TextColor3 = if isPrimary then Theme.Color.TextInverse else Theme.Color.Text,
		Parent = config.Parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
	}))

	self._stroke = Create("UIStroke", {
		Color = if isPrimary then self._accent else Theme.Color.Stroke,
		Thickness = 1,
		Transparency = if isPrimary then 1 else 0.3,
		Parent = self.Root,
	})

	self._trove:connect(self.Root.MouseEnter, function()
		if self._enabled then
			Audio.play("uiHover")
		end
		self:_setHover(true)
	end)
	self._trove:connect(self.Root.MouseLeave, function()
		self:_setHover(false)
		self:_setPressed(false)
	end)
	self._trove:connect(self.Root.MouseButton1Down, function()
		self:_setPressed(true)
	end)
	self._trove:connect(self.Root.MouseButton1Up, function()
		self:_setPressed(false)
	end)

	self._trove:connect(self.Root.Activated, function()
		if self._enabled then
			Audio.play("uiSelect")
		end
		if self._enabled and config.OnClick then
			config.OnClick()
		end
	end)

	return self
end

function Button:_setHover(hovering: boolean)
	if not self._enabled then
		return
	end

	if self._variant == "primary" then
		Motion.to(self.Root, Theme.Time.Fast, {
			BackgroundTransparency = if hovering then 0.1 else 0,
		})
	else
		Motion.to(self.Root, Theme.Time.Fast, {
			BackgroundTransparency = if hovering then 0.05 else 0.25,
		})
		Motion.to(self._stroke, Theme.Time.Fast, {
			Transparency = if hovering then 0 else 0.3,
			Color = if hovering then self._accent else Theme.Color.Stroke,
		})
	end
end

function Button:_setPressed(pressed: boolean)
	if not self._enabled then
		return
	end
	-- Small inward nudge; reads as tactile without moving layout.
	Motion.to(self.Root, Theme.Time.Instant, {
		TextSize = if pressed then 13 else 14,
	})
end

function Button:setEnabled(enabled: boolean)
	self._enabled = enabled
	self.Root.Active = enabled
	self.Root.AutoButtonColor = false

	Motion.to(self.Root, Theme.Time.Fast, {
		TextTransparency = if enabled then 0 else 0.55,
		BackgroundTransparency = if enabled then (if self._variant == "primary" then 0 else 0.25) else 0.7,
	})
end

function Button:setText(text: string)
	self.Root.Text = string.upper(text)
end

function Button:destroy()
	self._trove:clean()
end

return Button
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="114">
              <Properties>
                <string name="Name">Choice</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Choice — a selectable card, in row or tile form.

	Used for race and combat-style pickers. Selection is expressed with an
	accent stroke plus a filled left edge (row) or top edge (tile), never with
	colour alone, so the selected state survives a colour-blind player and a
	dim monitor.

	    Choice.new({
	        Parent = list,
	        Variant = "row",
	        Title = "Beastfolk",
	        Subtitle = "Move first",
	        OnSelect = function() ... end,
	    })
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Choice = {}
Choice.__index = Choice

export type Config = {
	Parent: Instance,
	Title: string,
	Subtitle: string?,
	Glyph: string?,
	--- "row" (wide, left-aligned) or "tile" (square-ish, centred).
	Variant: string?,
	AccentColor: Color3?,
	Size: UDim2?,
	LayoutOrder: number?,
	OnSelect: (() -> ())?,
}

function Choice.new(config: Config)
	local self = setmetatable({}, Choice)

	self._trove = Trove.new()
	self._selected = false
	self._hovered = false
	self._accent = config.AccentColor or Theme.Color.Accent
	self._variant = config.Variant or "row"

	local isTile = self._variant == "tile"
	local defaultSize = if isTile then UDim2.fromOffset(150, 104) else UDim2.new(1, 0, 0, 58)

	self.Root = self._trove:add(Create("TextButton", {
		Name = config.Title,
		Size = config.Size or defaultSize,
		LayoutOrder = config.LayoutOrder,
		BackgroundColor3 = Theme.Color.PanelRaised,
		BackgroundTransparency = 0.45,
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Text = "",
		Parent = config.Parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
	}))

	self._stroke = Create("UIStroke", {
		Color = Theme.Color.Stroke,
		Thickness = 1,
		Transparency = 0.5,
		Parent = self.Root,
	})

	-- The non-colour selection cue.
	self._marker = Create("Frame", {
		Name = "Marker",
		Size = if isTile then UDim2.new(0, 0, 0, 2) else UDim2.new(0, 2, 0, 0),
		Position = UDim2.fromScale(0, 0),
		BackgroundColor3 = self._accent,
		BorderSizePixel = 0,
		Parent = self.Root,
	})

	if config.Glyph then
		self._glyph = Create("TextLabel", {
			Name = "Glyph",
			Size = if isTile then UDim2.new(1, 0, 0, 40) else UDim2.fromOffset(48, 58),
			Position = if isTile then UDim2.fromOffset(0, 14) else UDim2.fromOffset(4, 0),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = config.Glyph,
			TextSize = if isTile then 28 else 22,
			TextColor3 = Theme.Color.TextMuted,
			Parent = self.Root,
		})
	end

	local textLeft = if isTile then 0 else (if config.Glyph then 54 else 14)
	local textTop = if isTile then 56 else 10

	self._title = Create("TextLabel", {
		Name = "Title",
		Position = UDim2.fromOffset(textLeft, textTop),
		Size = UDim2.new(1, -textLeft - 10, 0, 19),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = config.Title,
		TextSize = 15,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = if isTile then Enum.TextXAlignment.Center else Enum.TextXAlignment.Left,
		TextTruncate = Enum.TextTruncate.AtEnd,
		Parent = self.Root,
	})

	if config.Subtitle then
		self._subtitle = Create("TextLabel", {
			Name = "Subtitle",
			Position = UDim2.fromOffset(textLeft, textTop + 19),
			Size = UDim2.new(1, -textLeft - 10, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = config.Subtitle,
			TextSize = 12,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = if isTile then Enum.TextXAlignment.Center else Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
			Parent = self.Root,
		})
	end

	self._trove:connect(self.Root.MouseEnter, function()
		Audio.play("uiHover")
		self._hovered = true
		self:_render()
	end)
	self._trove:connect(self.Root.MouseLeave, function()
		self._hovered = false
		self:_render()
	end)
	-- Press state. Button has had one since it was written; Choice did not, so
	-- the two most-clicked controls in the game responded differently to the
	-- same click — one acknowledged the press, the other only the release.
	-- A press that produces nothing until you let go reads as latency.
	self._trove:connect(self.Root.MouseButton1Down, function()
		Motion.to(self.Root, Theme.Time.Instant, { BackgroundTransparency = 0.05 })
	end)
	self._trove:connect(self.Root.MouseButton1Up, function()
		self:_render()
	end)

	self._trove:connect(self.Root.Activated, function()
		Audio.play("uiSelect")
		if config.OnSelect then
			config.OnSelect()
		end
	end)

	self:_render(true)
	return self
end

function Choice:_render(instant: boolean?)
	local selected, hovered = self._selected, self._hovered
	local duration = if instant then 0 else Theme.Time.Fast
	local isTile = self._variant == "tile"

	local background = if selected then 0.1 elseif hovered then 0.25 else 0.45
	local strokeTransparency = if selected then 0 elseif hovered then 0.2 else 0.5

	if instant then
		self.Root.BackgroundTransparency = background
		self._stroke.Transparency = strokeTransparency
		self._stroke.Color = if selected then self._accent else Theme.Color.Stroke
	else
		Motion.to(self.Root, duration, { BackgroundTransparency = background })
		Motion.to(self._stroke, duration, {
			Transparency = strokeTransparency,
			Color = if selected then self._accent else Theme.Color.Stroke,
		})
	end

	-- Marker grows along the edge rather than fading, so it reads as a state
	-- change even in a screenshot.
	local markerSize = if isTile
		then UDim2.new(if selected then 1 else 0, 0, 0, 2)
		else UDim2.new(0, 2, if selected then 1 else 0, 0)

	if instant then
		self._marker.Size = markerSize
	else
		Motion.to(self._marker, duration, { Size = markerSize })
	end

	if self._glyph then
		local glyphColor = if selected then self._accent else Theme.Color.TextMuted
		if instant then
			self._glyph.TextColor3 = glyphColor
		else
			Motion.to(self._glyph, duration, { TextColor3 = glyphColor })
		end
	end
end

function Choice:setSelected(selected: boolean)
	if self._selected == selected then
		return
	end
	self._selected = selected
	self:_render()
end

function Choice:isSelected(): boolean
	return self._selected
end

function Choice:destroy()
	self._trove:clean()
end

return Choice
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="115">
              <Properties>
                <string name="Name">Meter</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Meter — a labelled 0-1 rating bar.

	Used for style aptitudes in character creation and the status tab. Distinct
	from Bar: no chip trail, no live value, no text readout by default. This is
	for comparing static ratings at a glance, not tracking a resource.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Meter = {}

export type Config = {
	Parent: Instance,
	Label: string,
	--- 0-1.
	Value: number,
	--- Right-aligned readout, e.g. "128" or "+12%".
	ValueText: string?,
	Color: Color3?,
	LayoutOrder: number?,
	Animate: boolean?,
}

function Meter.new(config: Config): Frame
	local color = config.Color or Theme.Color.Accent
	local value = math.clamp(config.Value, 0, 1)

	local row = Create("Frame", {
		Name = config.Label,
		Size = UDim2.new(1, 0, 0, 26),
		BackgroundTransparency = 1,
		LayoutOrder = config.LayoutOrder,
		Parent = config.Parent,
	})

	Create("TextLabel", {
		Name = "Label",
		Size = UDim2.new(0.6, 0, 0, 13),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = config.Label,
		TextSize = 11,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = row,
	})

	if config.ValueText then
		Create("TextLabel", {
			Name = "Value",
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.fromScale(1, 0),
			Size = UDim2.new(0.4, 0, 0, 13),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = config.ValueText,
			TextSize = 11,
			TextColor3 = Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Right,
			Parent = row,
		})
	end

	local track = Create("Frame", {
		Name = "Track",
		Position = UDim2.fromOffset(0, 17),
		Size = UDim2.new(1, 0, 0, 3),
		BackgroundColor3 = Theme.Color.Stroke,
		BackgroundTransparency = 0.5,
		BorderSizePixel = 0,
		Parent = row,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	local fill = Create("Frame", {
		Name = "Fill",
		Size = UDim2.fromScale(if config.Animate == false then value else 0, 1),
		BackgroundColor3 = color,
		BorderSizePixel = 0,
		Parent = track,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	if config.Animate ~= false then
		Motion.to(fill, Theme.Time.Slow, { Size = UDim2.fromScale(value, 1) }, Theme.Ease.Enter)
	end

	return row
end

return Meter
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="116">
              <Properties>
                <string name="Name">Panel</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Panel — the standard framed surface for menus and dialogs.

	Handles the shared chrome (corner, stroke, optional title bar, optional
	dimming scrim) and the open/close transition, so screens only write their
	own contents into `panel.Content`.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Panel = {}
Panel.__index = Panel

export type Config = {
	Parent: Instance,
	Size: UDim2,
	Position: UDim2?,
	AnchorPoint: Vector2?,
	Title: string?,
	Subtitle: string?,
	AccentColor: Color3?,
	--- Darkens everything behind the panel while it's open.
	Scrim: boolean?,
	--- Called when the scrim or close button is clicked.
	OnClose: (() -> ())?,
	ZIndex: number?,
}

function Panel.new(config: Config)
	local self = setmetatable({}, Panel)

	self._trove = Trove.new()
	self._config = config
	self._open = false
	self._accent = config.AccentColor or Theme.Color.Accent

	self.Root = self._trove:add(Create("Frame", {
		Name = "PanelRoot",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		Visible = false,
		ZIndex = config.ZIndex or 1,
		Parent = config.Parent,
	}))

	if config.Scrim then
		self._scrim = Create("TextButton", {
			Name = "Scrim",
			Size = UDim2.fromScale(1, 1),
			BackgroundColor3 = Theme.Color.Scrim,
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			AutoButtonColor = false,
			Text = "",
			ZIndex = 1,
			Parent = self.Root,

			Activated = function()
				if config.OnClose then
					config.OnClose()
				end
			end,
		})
	end

	self.Window = Create("Frame", {
		Name = "Window",
		Size = config.Size,
		Position = config.Position or UDim2.fromScale(0.5, 0.5),
		AnchorPoint = config.AnchorPoint or Vector2.new(0.5, 0.5),
		BackgroundColor3 = Theme.Color.Panel,
		BorderSizePixel = 0,
		ZIndex = 2,
		Parent = self.Root,

		Create("UICorner", { CornerRadius = Theme.Radius.lg }),
		Create("UIStroke", {
			Color = Theme.Color.Stroke,
			Thickness = 1,
			Transparency = 0.2,
		}),
	})

	-- Thin accent rule along the top edge; the panel's only real colour.
	Create("Frame", {
		Name = "AccentRule",
		Size = UDim2.new(1, 0, 0, 2),
		BackgroundColor3 = self._accent,
		BorderSizePixel = 0,
		ZIndex = 4,
		Parent = self.Window,

		Create("UIGradient", {
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 1),
				NumberSequenceKeypoint.new(0.5, 0),
				NumberSequenceKeypoint.new(1, 1),
			}),
		}),
	})

	local contentTop = Theme.Space.lg

	if config.Title then
		contentTop = 74

		Create("TextLabel", {
			Name = "Title",
			Position = UDim2.fromOffset(Theme.Space.lg, Theme.Space.lg),
			Size = UDim2.new(1, -Theme.Space.lg * 2, 0, 24),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = string.upper(config.Title),
			TextSize = 19,
			TextColor3 = Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Left,
			ZIndex = 3,
			Parent = self.Window,
		})

		if config.Subtitle then
			Create("TextLabel", {
				Name = "Subtitle",
				Position = UDim2.fromOffset(Theme.Space.lg, Theme.Space.lg + 26),
				Size = UDim2.new(1, -Theme.Space.lg * 2, 0, 16),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Medium,
				Text = config.Subtitle,
				TextSize = 13,
				TextColor3 = Theme.Color.TextMuted,
				TextXAlignment = Enum.TextXAlignment.Left,
				ZIndex = 3,
				Parent = self.Window,
			})
		end

		if config.OnClose then
			-- Built then wired, rather than via Create's event sugar: MouseEnter
			-- passes cursor coordinates, so a handler can't reach the button
			-- through its arguments.
			local closeButton = Create("TextButton", {
				Name = "Close",
				AnchorPoint = Vector2.new(1, 0),
				Position = UDim2.new(1, -Theme.Space.md, 0, Theme.Space.md),
				Size = UDim2.fromOffset(34, 34),
				BackgroundColor3 = Theme.Color.PanelRaised,
				BackgroundTransparency = 1,
				AutoButtonColor = false,
				FontFace = Theme.Font.Bold,
				Text = "✕",
				TextSize = 15,
				TextColor3 = Theme.Color.TextMuted,
				ZIndex = 5,
				Parent = self.Window,

				Create("UICorner", { CornerRadius = Theme.Radius.md }),
			})

			self._trove:connect(closeButton.Activated, config.OnClose)
			self._trove:connect(closeButton.MouseEnter, function()
				Motion.to(closeButton, Theme.Time.Fast, {
					BackgroundTransparency = 0,
					TextColor3 = Theme.Color.Text,
				})
			end)
			self._trove:connect(closeButton.MouseLeave, function()
				Motion.to(closeButton, Theme.Time.Fast, {
					BackgroundTransparency = 1,
					TextColor3 = Theme.Color.TextMuted,
				})
			end)
		end
	end

	self.Content = Create("Frame", {
		Name = "Content",
		Position = UDim2.fromOffset(0, contentTop),
		Size = UDim2.new(1, 0, 1, -contentTop),
		BackgroundTransparency = 1,
		ZIndex = 3,
		Parent = self.Window,
	})

	return self
end

function Panel:isOpen(): boolean
	return self._open
end

--- Title and subtitle are usually fixed, but menus that show live character
--- details need to rewrite them after construction.
function Panel:setTitle(text: string)
	local label = self.Window:FindFirstChild("Title")
	if label then
		label.Text = string.upper(text)
	end
end

function Panel:setSubtitle(text: string)
	local label = self.Window:FindFirstChild("Subtitle")
	if label then
		label.Text = text
	end
end

function Panel:open()
	if self._open then
		return
	end
	self._open = true

	Audio.play("uiOpen")
	self.Root.Visible = true
	self.Window.Size = UDim2.new(
		self._config.Size.X.Scale,
		self._config.Size.X.Offset,
		self._config.Size.Y.Scale * 0.94,
		self._config.Size.Y.Offset * 0.94
	)

	Motion.pop(self.Window, Theme.Time.Slow, { Size = self._config.Size })

	if self._scrim then
		self._scrim.BackgroundTransparency = 1
		Motion.to(self._scrim, Theme.Time.Base, { BackgroundTransparency = 0.45 })
	end
end

function Panel:close()
	if not self._open then
		return
	end
	self._open = false
	Audio.play("uiClose")

	if self._scrim then
		Motion.to(self._scrim, Theme.Time.Fast, { BackgroundTransparency = 1 })
	end

	local shrink = UDim2.new(
		self._config.Size.X.Scale,
		self._config.Size.X.Offset,
		self._config.Size.Y.Scale * 0.96,
		self._config.Size.Y.Offset * 0.96
	)
	local tween = Motion.to(self.Window, Theme.Time.Fast, { Size = shrink }, Theme.Ease.Exit)

	tween.Completed:Once(function()
		if not self._open then
			self.Root.Visible = false
		end
	end)
end

function Panel:toggle()
	if self._open then
		self:close()
	else
		self:open()
	end
end

function Panel:destroy()
	self._trove:clean()
end

return Panel
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="117">
              <Properties>
                <string name="Name">Slider</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Slider — a continuous value, dragged.

	Built for the audio mixer, which is the first thing in this game that needed
	a value rather than a choice. Kept general because the next one won't be.

	    Slider.new({
	        Parent = list,
	        Label = "Music",
	        Value = 0.8,
	        OnChanged = function(v) ... end,
	    })

	DRAGGING IS TRACKED GLOBALLY, not on the widget. A slider that only follows
	the pointer while it stays inside its own 20-pixel-tall hit box is the
	single most common way this control is got wrong: you grab the handle, move
	naturally, drift a few pixels vertically and the value stops following your
	hand. Once the press lands, input is followed until it is released, wherever
	it goes.

	IT MAKES A SOUND, AND THE SOUND IS THE POINT. An audio slider that is silent
	while you drag it is asking you to guess. Every step fires the interface
	tick, throttled so a fast drag is a run of clicks rather than a buzz — and
	because it plays through the bus being adjusted, you hear the change in the
	thing you are changing.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Slider = {}
Slider.__index = Slider

export type Config = {
	Parent: Instance,
	Label: string,
	Summary: string?,
	--- 0-1. The only range anything needs so far; a min/max pair can be added
	--- when something actually wants one.
	Value: number?,
	--- Quantisation. 0.05 gives twenty stops, which is fine enough to feel
	--- continuous and coarse enough that the same drag twice lands on the same
	--- number.
	Step: number?,
	AccentColor: Color3?,
	LayoutOrder: number?,
	OnChanged: ((value: number) -> ())?,
}

local TRACK_HEIGHT = 4
local HANDLE = 14
local ROW_HEIGHT = 46

function Slider.new(config: Config)
	local self = setmetatable({}, Slider)

	self._trove = Trove.new()
	self._accent = config.AccentColor or Theme.Color.Accent
	self._step = config.Step or 0.05
	self._value = math.clamp(config.Value or 1, 0, 1)
	self._dragging = false
	self._lastTickAt = 0
	self._onChanged = config.OnChanged

	self.Root = self._trove:add(Create("Frame", {
		Name = config.Label,
		Size = UDim2.new(1, 0, 0, if config.Summary then ROW_HEIGHT + 14 else ROW_HEIGHT),
		BackgroundTransparency = 1,
		LayoutOrder = config.LayoutOrder,
		Parent = config.Parent,
	}))

	Create("TextLabel", {
		Name = "Label",
		Size = UDim2.new(1, -56, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = config.Label,
		TextSize = 13,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = self.Root,
	})

	-- The readout. Percentages rather than decibels: this is a preference, not
	-- a mixing desk, and nobody sets their music to -6 dB by choice.
	self._readout = Create("TextLabel", {
		Name = "Readout",
		Size = UDim2.new(0, 52, 0, 18),
		Position = UDim2.new(1, -52, 0, 0),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "100%",
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Right,
		Parent = self.Root,
	})

	if config.Summary then
		Create("TextLabel", {
			Name = "Summary",
			Position = UDim2.fromOffset(0, 18),
			Size = UDim2.new(1, 0, 0, 14),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = config.Summary,
			TextSize = 11,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
			Parent = self.Root,
		})
	end

	-- A tall transparent button over a thin visible track, so the grab target
	-- is a comfortable 24px while the track stays a 4px line.
	self._hit = Create("TextButton", {
		Name = "Hit",
		Size = UDim2.new(1, 0, 0, 24),
		Position = UDim2.new(0, 0, 1, -24),
		BackgroundTransparency = 1,
		AutoButtonColor = false,
		Text = "",
		Parent = self.Root,
	})

	self._track = Create("Frame", {
		Name = "Track",
		Size = UDim2.new(1, 0, 0, TRACK_HEIGHT),
		Position = UDim2.new(0, 0, 0.5, -TRACK_HEIGHT / 2),
		BackgroundColor3 = Theme.Color.PanelHover,
		BorderSizePixel = 0,
		Parent = self._hit,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	self._fill = Create("Frame", {
		Name = "Fill",
		Size = UDim2.fromScale(self._value, 1),
		BackgroundColor3 = self._accent,
		BorderSizePixel = 0,
		Parent = self._track,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	self._handle = Create("Frame", {
		Name = "Handle",
		Size = UDim2.fromOffset(HANDLE, HANDLE),
		Position = UDim2.new(self._value, -HANDLE / 2, 0.5, -HANDLE / 2),
		BackgroundColor3 = Theme.Color.Text,
		BorderSizePixel = 0,
		Parent = self._hit,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	self._trove:connect(self._hit.MouseEnter, function()
		Motion.to(self._handle, Theme.Time.Fast, { Size = UDim2.fromOffset(HANDLE + 3, HANDLE + 3) })
		self:_reposition()
	end)
	self._trove:connect(self._hit.MouseLeave, function()
		if not self._dragging then
			Motion.to(self._handle, Theme.Time.Fast, { Size = UDim2.fromOffset(HANDLE, HANDLE) })
			self:_reposition()
		end
	end)

	self._trove:connect(self._hit.InputBegan, function(input)
		if
			input.UserInputType == Enum.UserInputType.MouseButton1
			or input.UserInputType == Enum.UserInputType.Touch
		then
			self._dragging = true
			self:_setFromInput(input.Position.X)
		end
	end)

	-- Global, deliberately: see the header. The press is owned until it ends,
	-- no matter where the pointer wanders.
	self._trove:connect(UserInputService.InputChanged, function(input)
		if not self._dragging then
			return
		end
		if
			input.UserInputType == Enum.UserInputType.MouseMovement
			or input.UserInputType == Enum.UserInputType.Touch
		then
			self:_setFromInput(input.Position.X)
		end
	end)

	self._trove:connect(UserInputService.InputEnded, function(input)
		if not self._dragging then
			return
		end
		if
			input.UserInputType == Enum.UserInputType.MouseButton1
			or input.UserInputType == Enum.UserInputType.Touch
		then
			self._dragging = false
			Motion.to(self._handle, Theme.Time.Fast, { Size = UDim2.fromOffset(HANDLE, HANDLE) })
			self:_reposition()
		end
	end)

	self:_render(true)
	return self
end

--- Handle position depends on its current size, so this is shared by every path
--- that changes either.
function Slider:_reposition()
	local size = self._handle.Size.X.Offset
	self._handle.Position = UDim2.new(self._value, -size / 2, 0.5, -size / 2)
end

function Slider:_setFromInput(x: number)
	local left = self._track.AbsolutePosition.X
	local width = self._track.AbsoluteSize.X
	if width <= 0 then
		return
	end

	local raw = math.clamp((x - left) / width, 0, 1)
	local stepped = math.round(raw / self._step) * self._step
	self:setValue(stepped, true)
end

function Slider:_render(instant: boolean?)
	local duration = if instant then 0 else Theme.Time.Instant

	if instant then
		self._fill.Size = UDim2.fromScale(self._value, 1)
	else
		Motion.to(self._fill, duration, { Size = UDim2.fromScale(self._value, 1) })
	end
	self:_reposition()

	self._readout.Text = `{math.round(self._value * 100)}%`
	self._readout.TextColor3 = if self._value <= 0 then Theme.Color.TextDim else Theme.Color.TextMuted
end

--[[
	`fromUser` separates a drag from a programmatic set: only a drag makes a
	sound and reports back. Without it, restoring saved settings at boot would
	fire the change handler for every slider and click five times.
]]
function Slider:setValue(value: number, fromUser: boolean?)
	local clamped = math.clamp(value, 0, 1)
	if math.abs(clamped - self._value) < 1e-4 then
		return
	end
	self._value = clamped
	self:_render()

	if not fromUser then
		return
	end

	-- Throttled, so dragging across the whole track is a run of ticks and not
	-- a solid buzz.
	local now = os.clock()
	if now - self._lastTickAt > 0.04 then
		self._lastTickAt = now
		Audio.play("uiHover")
	end

	if self._onChanged then
		self._onChanged(clamped)
	end
end

function Slider:value(): number
	return self._value
end

function Slider:destroy()
	self._trove:clean()
end

return Slider
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="118">
              <Properties>
                <string name="Name">TextField</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	TextField — labelled text input with live validation.

	Validation runs on every keystroke and surfaces beneath the field, so you
	learn a name is taken or too short while typing it rather than after
	committing to it.

	    local field = TextField.new({
	        Parent = column,
	        Label = "Character name",
	        Placeholder = "Enter a name",
	        MaxLength = 16,
	        Validate = function(text) return #text >= 3, "At least 3 characters" end,
	        OnChanged = function(text, valid) ... end,
	    })
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local TextField = {}
TextField.__index = TextField

export type Config = {
	Parent: Instance,
	Label: string?,
	Placeholder: string?,
	Text: string?,
	MaxLength: number?,
	Size: UDim2?,
	Position: UDim2?,
	LayoutOrder: number?,
	--- Returns (isValid, message). Message shows in red when invalid, and as
	--- a muted hint when valid.
	Validate: ((text: string) -> (boolean, string?))?,
	OnChanged: ((text: string, valid: boolean) -> ())?,
	OnSubmit: ((text: string) -> ())?,
}

function TextField.new(config: Config)
	local self = setmetatable({}, TextField)

	self._trove = Trove.new()
	self._config = config
	self.Valid = false

	local width = config.Size or UDim2.new(1, 0, 0, 74)

	self.Root = self._trove:add(Create("Frame", {
		Name = "TextField",
		Size = width,
		Position = config.Position,
		LayoutOrder = config.LayoutOrder,
		BackgroundTransparency = 1,
		Parent = config.Parent,
	}))

	if config.Label then
		Create("TextLabel", {
			Name = "Label",
			Size = UDim2.new(1, 0, 0, 15),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = string.upper(config.Label),
			TextSize = 11,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			Parent = self.Root,
		})
	end

	local box = Create("Frame", {
		Name = "Box",
		Position = UDim2.fromOffset(0, if config.Label then 19 else 0),
		Size = UDim2.new(1, 0, 0, 40),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 0.25,
		BorderSizePixel = 0,
		Parent = self.Root,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
	})

	self._stroke = Create("UIStroke", {
		Color = Theme.Color.Stroke,
		Thickness = 1,
		Transparency = 0.35,
		Parent = box,
	})

	self.Input = Create("TextBox", {
		Name = "Input",
		Size = UDim2.new(1, -24, 1, 0),
		Position = UDim2.fromOffset(12, 0),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = config.Text or "",
		PlaceholderText = config.Placeholder or "",
		PlaceholderColor3 = Theme.Color.TextDim,
		TextSize = 15,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		ClearTextOnFocus = false,
		Parent = box,
	})

	self._hint = Create("TextLabel", {
		Name = "Hint",
		AnchorPoint = Vector2.new(0, 1),
		Position = UDim2.new(0, 0, 1, 0),
		Size = UDim2.new(1, 0, 0, 14),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 11,
		TextColor3 = Theme.Color.TextDim,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = self.Root,
	})

	if config.MaxLength then
		self._counter = Create("TextLabel", {
			Name = "Counter",
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, 0, 1, 0),
			Size = UDim2.fromOffset(60, 14),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = "",
			TextSize = 11,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Right,
			Parent = self.Root,
		})
	end

	self._trove:connect(self.Input:GetPropertyChangedSignal("Text"), function()
		self:_onTextChanged()
	end)

	self._trove:connect(self.Input.Focused, function()
		Motion.to(self._stroke, Theme.Time.Fast, { Color = Theme.Color.Accent, Transparency = 0 })
	end)

	self._trove:connect(self.Input.FocusLost, function(enterPressed)
		Motion.to(self._stroke, Theme.Time.Fast, {
			Color = if self.Valid then Theme.Color.Stroke else Theme.Color.Danger,
			Transparency = 0.35,
		})
		if enterPressed and config.OnSubmit then
			config.OnSubmit(self.Input.Text)
		end
	end)

	self:_onTextChanged()
	return self
end

function TextField:_onTextChanged()
	local config = self._config
	local text = self.Input.Text

	-- Enforce the cap by rewriting, which re-enters this handler once and
	-- then settles.
	if config.MaxLength and #text > config.MaxLength then
		self.Input.Text = string.sub(text, 1, config.MaxLength)
		return
	end

	local valid, message = true, nil
	if config.Validate then
		valid, message = config.Validate(text)
	end
	self.Valid = valid

	self._hint.Text = message or ""
	self._hint.TextColor3 = if valid then Theme.Color.TextDim else Theme.Color.Danger

	if self._counter then
		self._counter.Text = `{#text}/{config.MaxLength}`
	end

	if config.OnChanged then
		config.OnChanged(text, valid)
	end
end

function TextField:getText(): string
	return self.Input.Text
end

function TextField:setText(text: string)
	self.Input.Text = text
end

function TextField:focus()
	self.Input:CaptureFocus()
end

function TextField:destroy()
	self._trove:clean()
end

return TextField
]]></string>
              </Properties>
            </Item>
          </Item>
          <Item class="Folder" referent="119">
            <Properties>
              <string name="Name">Core</string>
            </Properties>
            <Item class="ModuleScript" referent="120">
              <Properties>
                <string name="Name">Events</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Events — client-side signal bus.

	Exists to break a require cycle. Controllers need the UI (toasts, damage
	numbers), and UI screens need to hear about gameplay events; if they
	required each other directly, `UI -> AbilityBar -> Abilities -> UI` would
	be a cycle and Luau would refuse to load it.

	Both sides require this instead. Nothing in here requires anything but
	Signal, so it can never take part in a cycle.
]]

local Signal = require(game:GetService("ReplicatedStorage").Shared.Util.Signal)

local Events = {}

--- (slotIndex: number, skill: Skill)
Events.AbilityCast = Signal.new()

--- (slotIndex: number, reason: "locked" | "empty" | "cooldown" | "stamina")
Events.AbilityFailed = Signal.new()

return Events
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="121">
              <Properties>
                <string name="Name">Layer</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Layer — named, z-ordered, responsive canvases.

	Every screen draws into a Layer instead of making its own ScreenGui, which
	keeps depth ordering in one place (Theme.Layer) and guarantees each one is
	scaled and survives respawns.

	Structure per layer:
	    ScreenGui (DisplayOrder from Theme.Layer)
	      └─ Canvas : Frame        <- centred, counter-sized, holds the UIScale
	           └─ ...your UI

	Because the Canvas is counter-sized to 1/scale, children can be authored in
	reference pixels (1920x1080) and land correctly on any screen.
]]

local Players = game:GetService("Players")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Responsive = require(script.Parent.Responsive)

local playerGui = Players.LocalPlayer:WaitForChild("PlayerGui")

local Layer = {}
local cache: { [string]: Frame } = {}

--- Returns (creating on first call) the scaled canvas for a named layer.
--- `name` must be a key in Theme.Layer. Parent your UI to this.
function Layer.get(name: string): Frame
	local existing = cache[name]
	if existing and existing.Parent then
		return existing
	end

	local order = Theme.Layer[name]
	assert(order, `Layer.get: unknown layer "{name}" -- add it to Theme.Layer`)

	local screen = Create("ScreenGui", {
		Name = `Layer_{name}`,
		DisplayOrder = order,
		ResetOnSpawn = false,
		IgnoreGuiInset = true,
		ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
		ScreenInsets = Enum.ScreenInsets.CoreUISafeInsets,
		Parent = playerGui,
	})

	-- Centred + anchored so the counter-sizing expands symmetrically.
	local canvas = Create("Frame", {
		Name = "Canvas",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(1, 1), -- Responsive overwrites this immediately
		BackgroundTransparency = 1,
		Parent = screen,
	})

	Responsive.register(Create("UIScale", { Parent = canvas }), canvas)

	cache[name] = canvas
	return canvas
end

--- Convenience: a full-bleed transparent frame inside a layer, which is what
--- most screens want as their own root.
function Layer.root(layerName: string, rootName: string): Frame
	return Create("Frame", {
		Name = rootName,
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		Parent = Layer.get(layerName),
	})
end

function Layer.clear(name: string)
	local canvas = cache[name]
	if canvas then
		local screen = canvas:FindFirstAncestorWhichIsA("ScreenGui")
		if screen then
			screen:Destroy()
		end
		cache[name] = nil
	end
end

return Layer
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="122">
              <Properties>
                <string name="Name">Loadout</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Loadout — the rules for what can go in the Q/E/R slots.

	Pure state manipulation over UIState plus the Skills config. Lives in Core
	so both the Abilities controller (which casts) and SkillsTab (which
	assigns) can use it without requiring each other -- see Events.luau for the
	cycle this avoids.

	assign() returns (ok, reason) so callers can explain a refusal instead of
	silently doing nothing.
]]

local Skills = require(game:GetService("ReplicatedStorage").Shared.Config.Skills)

local UIState = require(script.Parent.UIState)

local Loadout = {}

function Loadout.isSlotUnlocked(slotIndex: number): boolean
	return Skills.isSlotUnlocked(slotIndex, UIState.get("level") or 1)
end

function Loadout.isUnlocked(skillId: string): boolean
	return table.find(UIState.get("unlockedSkills") or {}, skillId) ~= nil
end

function Loadout.get(slotIndex: number): string?
	return (UIState.get("loadout") or {})[slotIndex]
end

--- Which slot a skill currently occupies, if any.
function Loadout.slotOf(skillId: string?): number?
	if not skillId then
		return nil
	end
	local loadout = UIState.get("loadout") or {}
	for index = 1, #Skills.Slots do
		if loadout[index] == skillId then
			return index
		end
	end
	return nil
end

--- Assigns `skillId` (or nil to clear) to a slot.
--- Returns (ok, reason) where reason is "locked" | "passive" | "unowned".
function Loadout.assign(slotIndex: number, skillId: string?): (boolean, string?)
	if not Loadout.isSlotUnlocked(slotIndex) then
		return false, "locked"
	end

	if skillId then
		local skill = Skills.find(skillId)
		if not Skills.isSlottable(skill) then
			return false, "passive"
		end
		if not Loadout.isUnlocked(skillId) then
			return false, "unowned"
		end
	end

	local loadout = UIState.get("loadout") or {}

	-- A skill occupies one slot at a time; assigning it elsewhere moves it
	-- rather than duplicating it.
	if skillId then
		for index = 1, #Skills.Slots do
			if loadout[index] == skillId then
				loadout[index] = nil
			end
		end
	end

	loadout[slotIndex] = skillId
	UIState.set("loadout", loadout)
	return true, nil
end

function Loadout.clear(slotIndex: number)
	local loadout = UIState.get("loadout") or {}
	loadout[slotIndex] = nil
	UIState.set("loadout", loadout)
end

function Loadout.remainingCooldown(skillId: string?): number
	if not skillId then
		return 0
	end
	local endsAt = (UIState.get("cooldowns") or {})[skillId]
	if not endsAt then
		return 0
	end
	return math.max(endsAt - os.clock(), 0)
end

--- Drops any slotted skill whose slot is no longer unlocked. Called when the
--- level changes, so a de-level can't leave an uncastable skill on the bar.
function Loadout.prune()
	local loadout = UIState.get("loadout") or {}
	local changed = false

	for index = 1, #Skills.Slots do
		if loadout[index] and not Loadout.isSlotUnlocked(index) then
			loadout[index] = nil
			changed = true
		end
	end

	if changed then
		UIState.set("loadout", loadout)
	end
end

return Loadout
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="123">
              <Properties>
                <string name="Name">Responsive</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Responsive — one scale factor, applied everywhere.

	All UI is authored against a 1920x1080 reference. This module computes a
	single multiplier from the real viewport and pushes it into every
	registered canvas, so layout code never branches on device.

	Why a global UIScale rather than per-element Scale sizing: sizing purely in
	Scale stretches elements on non-16:9 screens (10% of width is a different
	pixel count than 10% of height). Authoring in reference pixels and scaling
	uniformly keeps every proportion intact on 4:3, ultrawide and phone
	portrait alike.

	Two Roblox gotchas this works around:
	  1. A UIScale parented to a ScreenGui does nothing -- UIScale only affects
	     a GuiObject parent. So each layer needs a real Frame to hold it.
	  2. UIScale multiplies the size of the frame it lives on, not just the
	     descendants. A full-bleed canvas at Scale(1,1) would therefore shrink
	     to `scale` of the screen. We counter-size the canvas to 1/scale so the
	     product is exactly one screen.
]]

local UserInputService = game:GetService("UserInputService")
local GuiService = game:GetService("GuiService")
local Workspace = game:GetService("Workspace")

local Signal = require(game:GetService("ReplicatedStorage").Shared.Util.Signal)

local REFERENCE = Vector2.new(1920, 1080)

-- Phones get a relative bump: fingers need bigger targets than a cursor.
local TOUCH_MULTIPLIER = 1.3

-- Below this the UI would be unreadable; we'd rather it overflow slightly.
local MIN_SCALE = 0.5
local MAX_SCALE = 1.6

local Responsive = {}

Responsive.Changed = Signal.new()
Responsive.Scale = 1
Responsive.Viewport = REFERENCE
Responsive.IsTouch = UserInputService.TouchEnabled and not UserInputService.MouseEnabled
Responsive.IsPortrait = false

type Entry = { uiScale: UIScale, canvas: Frame? }
local registered: { Entry } = {}

local function compute(): number
	local camera = Workspace.CurrentCamera
	local viewport = camera and camera.ViewportSize or REFERENCE
	if viewport.X < 1 or viewport.Y < 1 then
		return Responsive.Scale
	end

	Responsive.Viewport = viewport
	Responsive.IsPortrait = viewport.Y > viewport.X

	-- min() of both axes: guarantees the reference frame always fits.
	local scale = math.min(viewport.X / REFERENCE.X, viewport.Y / REFERENCE.Y)

	if Responsive.IsTouch then
		scale *= TOUCH_MULTIPLIER
	end

	return math.clamp(scale, MIN_SCALE, MAX_SCALE)
end

local function applyEntry(entry: Entry, scale: number)
	entry.uiScale.Scale = scale
	if entry.canvas then
		-- Counter-size so canvas * scale == exactly one viewport.
		entry.canvas.Size = UDim2.fromScale(1 / scale, 1 / scale)
	end
end

local function apply(force: boolean?)
	local scale = compute()
	if not force and math.abs(scale - Responsive.Scale) < 0.001 then
		return
	end

	Responsive.Scale = scale
	for index = #registered, 1, -1 do
		local entry = registered[index]
		if entry.uiScale.Parent then
			applyEntry(entry, scale)
		else
			table.remove(registered, index) -- layer was destroyed
		end
	end

	Responsive.Changed:Fire(scale)
end

--- Registers a UIScale (and optionally the canvas frame holding it) to be
--- driven by this module.
function Responsive.register(uiScale: UIScale, canvas: Frame?)
	local entry: Entry = { uiScale = uiScale, canvas = canvas }
	table.insert(registered, entry)
	applyEntry(entry, Responsive.Scale)
end

--- The inset the mobile controls occupy. Keep interactive UI out of the
--- bottom-left (thumbstick) and bottom-right (jump button).
function Responsive.controlSafeInset(): number
	return if Responsive.IsTouch then 190 else 0
end

function Responsive.start()
	Responsive.Scale = compute()

	local function watchCamera()
		local camera = Workspace.CurrentCamera
		if camera then
			camera:GetPropertyChangedSignal("ViewportSize"):Connect(function()
				apply()
			end)
		end
	end

	watchCamera()
	Workspace:GetPropertyChangedSignal("CurrentCamera"):Connect(function()
		watchCamera()
		apply()
	end)

	-- Rotating a phone changes the usable area without always firing
	-- ViewportSize on the same frame.
	GuiService:GetPropertyChangedSignal("TopbarInset"):Connect(function()
		apply()
	end)
	UserInputService:GetPropertyChangedSignal("TouchEnabled"):Connect(function()
		Responsive.IsTouch = UserInputService.TouchEnabled and not UserInputService.MouseEnabled
		apply(true)
	end)

	apply(true)
end

return Responsive
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="124">
              <Properties>
                <string name="Name">UIState</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	UIState — the single client-side store the UI renders from.

	Screens never talk to the server or to each other; they observe keys here.
	Networking later writes into this store and every bar, banner and counter
	updates for free.

	    UIState.observe("health", function(hp) bar:setValue(hp, UIState.get("maxHealth")) end)
	    UIState.patch({ health = 340, maxHealth = 500 })
]]

local Signal = require(game:GetService("ReplicatedStorage").Shared.Util.Signal)

local UIState = {}

--- Fires (key, newValue, oldValue) for any change.
UIState.Changed = Signal.new()

-- A new character starts with nothing. No free gear, no pre-unlocked skills,
-- no titles, no cleared scenarios. Everything below is what level 1 actually
-- looks like; anything else has to be earned or granted by an admin command.
local state: { [string]: any } = {
	-- Vitals
	health = 100,
	maxHealth = 100,
	stamina = 100,
	maxStamina = 100,
	poise = 50,
	maxPoise = 50,

	-- Identity
	displayName = "",
	level = 1,
	experience = 0,
	experienceToNext = 100,

	-- Boss / target. `boss` is nil when nothing is engaged.
	boss = nil, -- { name, title, health, maxHealth, phase, maxPhase, unique }

	-- Status effects: array of { id, label, duration, expiresAt, kind }
	statuses = {},

	-- Town. Server-owned, mirrored here for the panels to read. Never written
	-- by the client except by the Profile push that arrives from the server.
	gold = 0,
	materials = {},
	ownedItems = {},
	quests = {},
	questProgress = {},
	kills = {},

	-- Items: array of { id, name, rarity, slot, stats, drawbacks, description }
	inventory = {},
	-- Map of slot name -> item id
	equipment = {},
	selectedItem = nil,

	-- Progression. One point at level 1 so a new character has exactly one
	-- meaningful choice to make, and one more per level after that.
	skillPoints = 1,
	--- Array of unlocked skill ids.
	unlockedSkills = {},
	selectedSkill = nil,
	--- Three loadout slots, keyed Q/E/R. Holds skill ids or nil.
	loadout = { nil, nil, nil },
	--- Map of skill id -> os.clock() timestamp the cooldown ends.
	cooldowns = {},
	--- Map of scenario id -> state ("available" | "active" | "cleared" | "taken").
	scenarioStates = {},
	--- Array of earned title ids.
	earnedTitles = {},
	equippedTitle = nil,

	-- Front-end flow: "boot" | "title" | "create" | "playing".
	-- Gameplay UI (HUD, boss bar, menus) only appears in "playing".
	phase = "boot",
	--- { name, race, style } once created.
	character = nil,

	--- Guard raised. Read by Sprint so blocking slows you down.
	blocking = false,
	--- Sprinting right now. Read by the tutorial.
	sprinting = false,
	--- Guided start finished. Traveler origins never set this.
	tutorialDone = false,

	--- Gameplay setting: "off" | "adaptive" | "always".
	parryHelper = "adaptive",
	--- True while the focus assist is showing. Read by Sprint.
	focusing = false,

	--- Parry is a permanent, upgradeable core skill. 0 means not yet unlocked.
	parryRank = 0,
	--- os.clock() the parry comes off cooldown, and when its window closes.
	parryReadyAt = 0,
	parryWindowUntil = 0,

	-- UI mode
	menuOpen = nil, -- nil | "menu"
	menuTab = "inventory", -- status | inventory | skills | scenarios | titles
	dead = false,
	deaths = 0,
}

local watchers: { [string]: { (value: any, old: any) -> () } } = {}

function UIState.get(key: string): any
	return state[key]
end

--- Returns a shallow copy so callers can't mutate the store by accident.
function UIState.snapshot(): { [string]: any }
	return table.clone(state)
end

function UIState.set(key: string, value: any)
	local old = state[key]

	-- Tables always re-fire. Callers routinely mutate a table in place (boss
	-- health, status list) and hand back the same reference; comparing by
	-- identity would silently swallow those updates.
	if old == value and type(value) ~= "table" then
		return
	end

	state[key] = value

	local list = watchers[key]
	if list then
		for _, fn in list do
			task.spawn(fn, value, old)
		end
	end
	UIState.Changed:Fire(key, value, old)
end

--- Sets several keys at once.
function UIState.patch(values: { [string]: any })
	for key, value in values do
		UIState.set(key, value)
	end
end

--- Calls `fn` immediately with the current value, then on every change.
--- Returns a disconnect function.
function UIState.observe(key: string, fn: (value: any, old: any) -> ()): () -> ()
	local list = watchers[key]
	if not list then
		list = {}
		watchers[key] = list
	end
	table.insert(list, fn)

	task.spawn(fn, state[key], nil)

	return function()
		local current = watchers[key]
		if not current then
			return
		end
		local index = table.find(current, fn)
		if index then
			table.remove(current, index)
		end
	end
end

--- Observes several keys with one handler. Handler receives no arguments and
--- is expected to read what it needs via UIState.get.
function UIState.observeMany(keys: { string }, fn: () -> ()): () -> ()
	local disconnects = {}
	for _, key in keys do
		table.insert(disconnects, UIState.observe(key, fn))
	end
	return function()
		for _, disconnect in disconnects do
			disconnect()
		end
	end
end

return UIState
]]></string>
              </Properties>
            </Item>
          </Item>
          <Item class="ModuleScript" referent="125">
            <Properties>
              <string name="Name">Flow</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Flow — the front-end state machine.

	    boot -> title -> create -> playing

	Owns the transitions between them and nothing else. Each screen exposes
	show/hide and knows nothing about what comes before or after it, so the
	order can be changed here alone -- e.g. skipping straight to `playing` for
	a returning player once profiles exist.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Items = require(ReplicatedStorage.Shared.Config.Items)
local Network = require(ReplicatedStorage.Shared.Network)

local UIState = require(script.Parent.Core.UIState)

local BootSequence = require(script.Parent.Screens.BootSequence)
local CharacterCreate = require(script.Parent.Screens.CharacterCreate)
local TitleScreen = require(script.Parent.Screens.TitleScreen)
local Toast = require(script.Parent.Overlays.Toast)

local Flow = {}

local function enter(phase: string)
	UIState.set("phase", phase)
end

--- Applies the created character to the store and drops into gameplay.
--- Everyone starts armed: a style that leaves you punching air through your
--- first fight isn't a choice, it's a punishment.
local function beginPlaying(character)
	local inventory = Items.startingInventory(character.style)
	local weapon = Items.starterFor(character.style)

	UIState.patch({
		character = character,
		displayName = character.name,
		inventory = inventory,
		equipment = { Weapon = weapon.id },
		selectedItem = weapon.id,
	})

	-- The server builds the matching model and welds it on, so other players
	-- see it too, and routes the spawn point from the chosen origin.
	Network.CharacterChosen:FireServer(character)
	Network.OriginChosen:FireServer(character.origin)

	CharacterCreate.hide()
	enter("playing")

	task.delay(0.4, function()
		Toast.push({
			title = `Welcome, {character.name}`,
			subtitle = "Shift to sprint · B for the character menu",
			kind = "system",
			duration = 6,
		})
	end)
end

function Flow.start()
	BootSequence.start()

	TitleScreen.start(function()
		TitleScreen.hide()
		enter("create")
		CharacterCreate.show()
	end)

	CharacterCreate.start(beginPlaying)

	enter("boot")
	BootSequence.play(function()
		enter("title")
		TitleScreen.show()
	end)
end

--- Replays the dive sequence from any phase and lands back on the title.
--- Only the sequence re-runs -- the screens themselves are already built, and
--- calling Flow.start() again would construct a second set of them.
function Flow.replayBoot()
	TitleScreen.hide()
	CharacterCreate.hide()

	UIState.patch({ phase = "boot", menuOpen = nil, dead = false })

	BootSequence.play(function()
		enter("title")
		TitleScreen.show()
	end)
end

--- Jumps straight to gameplay with a stock character. Used by the sandbox so
--- UI work doesn't mean sitting through the dive sequence on every reload.
function Flow.skipToPlaying(character)
	TitleScreen.hide()
	CharacterCreate.hide()

	local weapon = Items.starterFor(character.style)

	UIState.patch({
		character = character,
		displayName = character.name,
		phase = "playing",
		-- Skipping creation still has to arm you, or the fight is unwinnable
		-- for reasons that have nothing to do with the fight.
		inventory = Items.startingInventory(character.style),
		equipment = { Weapon = weapon.id },
		selectedItem = weapon.id,
	})

	Network.CharacterChosen:FireServer(character)
	Network.OriginChosen:FireServer(character.origin)
end

return Flow
]]></string>
            </Properties>
          </Item>
          <Item class="Folder" referent="126">
            <Properties>
              <string name="Name">Overlays</string>
            </Properties>
            <Item class="ModuleScript" referent="127">
              <Properties>
                <string name="Name">DamageNumbers</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	DamageNumbers — floating combat text.

	Numbers spawn in screen space. If a world position is supplied they're
	projected once at spawn and then drift in 2D, which is the right tradeoff:
	re-projecting every frame makes text swim when the camera moves, and the
	number only needs to live for half a second anyway.

	    DamageNumbers.show(482, "crit", hitPosition)
	    DamageNumbers.show(120, "taken")

	Coordinate note: the layer canvas is counter-sized to 1/scale, so a
	viewport pixel maps to canvasPixel = viewportPixel / Responsive.Scale.
]]

local Workspace = game:GetService("Workspace")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)
local Responsive = require(script.Parent.Parent.Core.Responsive)

local KINDS = {
	normal = { color = Theme.Color.DamageNormal, size = 26, font = Theme.Font.Bold },
	crit = { color = Theme.Color.DamageCrit, size = 40, font = Theme.Font.Black, prefix = "" },
	weak = { color = Theme.Color.DamageWeak, size = 20, font = Theme.Font.SemiBold },
	taken = { color = Theme.Color.DamageTaken, size = 28, font = Theme.Font.Bold },
	heal = { color = Theme.Color.Heal, size = 24, font = Theme.Font.Bold, prefix = "+" },
	block = { color = Theme.Color.Poise, size = 20, font = Theme.Font.SemiBold },
}

local RISE = 90
local DRIFT = 46
local LIFETIME = 0.85

local DamageNumbers = {}
local root: Frame

local function canvasPosition(worldPosition: Vector3?): Vector2
	local viewportSize = Responsive.Viewport
	local scale = Responsive.Scale

	if worldPosition then
		local camera = Workspace.CurrentCamera
		if camera then
			local point, onScreen = camera:WorldToViewportPoint(worldPosition)
			if onScreen then
				return Vector2.new(point.X / scale, point.Y / scale)
			end
		end
	end

	-- No world anchor (or off-screen): scatter near the centre, biased above
	-- the crosshair so numbers don't sit on top of the reticle.
	local center = Vector2.new(viewportSize.X / scale / 2, viewportSize.Y / scale / 2)
	return center + Vector2.new(math.random(-130, 130), math.random(-110, -30))
end

--- Spawns one floating number.
--- `kind` is a key of KINDS; unknown kinds fall back to "normal".
function DamageNumbers.show(amount: number, kind: string?, worldPosition: Vector3?)
	if not root then
		return
	end

	local style = KINDS[kind or "normal"] or KINDS.normal
	local origin = canvasPosition(worldPosition)

	local label = Create("TextLabel", {
		Name = "Damage",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromOffset(origin.X, origin.Y),
		Size = UDim2.fromOffset(200, 44),
		BackgroundTransparency = 1,
		FontFace = style.font,
		Text = `{style.prefix or ""}{math.floor(math.abs(amount) + 0.5)}`,
		TextSize = style.size,
		TextColor3 = style.color,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.25,
		Parent = root,
	})

	-- Crits land with a punch instead of just appearing.
	if kind == "crit" then
		label.TextSize = style.size * 1.6
		Motion.pop(label, Theme.Time.Base, { TextSize = style.size })
	end

	local drift = math.random(-DRIFT, DRIFT)
	local target = UDim2.fromOffset(origin.X + drift, origin.Y - RISE)

	Motion.to(label, LIFETIME, { Position = target }, Enum.EasingStyle.Quad)

	-- Fade held back so the number is fully legible for most of its life.
	Motion.to(label, LIFETIME * 0.45, {
		TextTransparency = 1,
		TextStrokeTransparency = 1,
	}, Enum.EasingStyle.Linear, LIFETIME * 0.55)

	task.delay(LIFETIME + 0.05, function()
		label:Destroy()
	end)
end

function DamageNumbers.start()
	root = Layer.root("Combat", "DamageNumbers")
end

return DamageNumbers
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="128">
              <Properties>
                <string name="Name">FocusFx</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	FocusFx — the parry assist's "heightened concentration" effect.

	THREE STAGES, and which one you get is the server's decision — this module
	draws exactly what it is told to and nothing more:

	  tier 1   wash, vignette, heartbeat. Time stretches and that is all. You
	           are still reading the attack itself, just with longer to do it.
	  tier 2   + the timing ring, closing on the parry window.
	  tier 3   + the "F" prompt, inside the window only.

	The escalation matters more than any of the visuals. A player shown the key
	first learns to obey a prompt, and the prompt is not there in the real
	fight — so they have learned nothing they can take with them. A player who
	finds it at tier 1 owns it outright, and most do.

	THE RING IS THE BRIDGE between those. It shrinks from wide to nothing over
	exactly the window you have, so pressing "when the ring lands" is the same
	motor skill as pressing "when the sword lands" — larger and slower. Take it
	away later and the habit is already trained.

	It never presses for you. It only tells you when.

	Deliberately not styled like a tutorial popup — no panel, no border, no
	instructional copy. It should read as the character's perception sharpening.
]]

local RunService = game:GetService("RunService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local FOCUS_BLUE = Color3.fromRGB(96, 176, 255)

local FocusFx = {}

local root: Frame
local vignette: Frame
local ring: Frame
local ringStroke: UIStroke
local promptLabel: TextLabel
local desaturate: Frame

local active = false
local startedAt = 0
local duration = 0
local visibility = 0
--- What this stage is permitted to draw.
local showRing = false
local showPrompt = false

--------------------------------------------------------------------------------

function FocusFx.start()
	root = Layer.root("Screen", "FocusFx")
	root.Visible = false

	-- Cool wash. Stands in for desaturation, which would need a real
	-- post-process pass we can't add per-player.
	desaturate = Create("Frame", {
		Name = "Wash",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.fromRGB(70, 96, 130),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Parent = root,
	})

	vignette = Create("Frame", {
		Name = "Vignette",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = FOCUS_BLUE,
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Parent = root,

		Create("UIGradient", {
			Rotation = 90,
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 0.15),
				NumberSequenceKeypoint.new(0.5, 1),
				NumberSequenceKeypoint.new(1, 0.15),
			}),
		}),
	})

	-- The ring, centred on the player's position on screen (which is the
	-- middle, in a third-person game).
	ring = Create("Frame", {
		Name = "Ring",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.52),
		Size = UDim2.fromOffset(320, 320),
		BackgroundTransparency = 1,
		Parent = root,

		Create("UICorner", { CornerRadius = UDim.new(1, 0) }),
	})

	ringStroke = Create("UIStroke", {
		Color = FOCUS_BLUE,
		Thickness = 3,
		Transparency = 1,
		Parent = ring,
	})

	promptLabel = Create("TextLabel", {
		Name = "Prompt",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.52),
		Size = UDim2.fromOffset(70, 70),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "F",
		TextSize = 44,
		TextColor3 = Color3.new(1, 1, 1),
		TextStrokeColor3 = FOCUS_BLUE,
		TextStrokeTransparency = 0.2,
		TextTransparency = 1,
		Parent = root,
	})

	RunService.RenderStepped:Connect(function()
		if not active then
			return
		end

		local elapsed = os.clock() - startedAt
		local progress = math.clamp(elapsed / duration, 0, 1)

		-- Tier 2+. Closes on impact; the cue the player is actually learning.
		if showRing then
			local size = 320 - 260 * progress
			ring.Size = UDim2.fromOffset(size, size)
			ringStroke.Transparency = (1 - visibility) + progress * 0.25 * visibility
			ringStroke.Thickness = 3 + progress * 2
		else
			ringStroke.Transparency = 1
		end

		-- Tier 3 only, and a beat after the ring, so the ring reads as the
		-- timing and the letter reads as the instruction.
		if showPrompt then
			local promptIn = math.clamp((progress - 0.12) / 0.25, 0, 1)
			promptLabel.TextTransparency = 1 - promptIn * visibility
			promptLabel.TextSize = 44 + 8 * (1 - promptIn)
		else
			promptLabel.TextTransparency = 1
		end

		desaturate.BackgroundTransparency = 1 - 0.16 * visibility
		vignette.BackgroundTransparency = 1 - 0.34 * visibility

		if progress >= 1 then
			FocusFx.stop()
		end
	end)
end

--------------------------------------------------------------------------------

--- Shows the effect for `seconds`. `ring` and `prompt` come from the server's
--- stage decision; this module never escalates itself.
function FocusFx.show(seconds: number, strength: number, ring_: boolean?, prompt_: boolean?)
	if seconds <= 0 then
		return
	end

	active = true
	startedAt = os.clock()
	duration = seconds
	visibility = math.clamp(strength, 0, 1)
	showRing = ring_ == true
	showPrompt = prompt_ == true

	ringStroke.Transparency = 1
	promptLabel.TextTransparency = 1

	root.Visible = true
	Audio.play("heartbeat")
end

function FocusFx.stop()
	if not active then
		return
	end
	active = false
	root.Visible = false
	ringStroke.Transparency = 1
	promptLabel.TextTransparency = 1
	desaturate.BackgroundTransparency = 1
	vignette.BackgroundTransparency = 1
end

function FocusFx.isActive(): boolean
	return active
end

return FocusFx
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="129">
              <Properties>
                <string name="Name">LevelUp</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	LevelUp — the celebration.

	Roblox's onboarding guidance asks for progression milestones to end in "an
	intentionally designed moment of joy". Levelling here was a four-second
	toast in the corner, identical in weight to picking up a goblin ear. The
	single most repeated reward in the game had the least presentation of
	anything in it.

	FOUR BEATS, in this order, because the order is the whole effect:

	  1. FLASH    a fast white-gold wash. Punctuation — it says *something
	              happened* before the eye has read anything.
	  2. RINGS    two rings expanding from the centre at different speeds.
	              Motion outward reads as growth; a single ring reads as a hit.
	  3. NUMBER   the level itself, large, overshooting its size and settling.
	  4. GAINS    what actually improved, listed. The joy is the fanfare; the
	              *reason to care* is the numbers, and they arrive last so they
	              are read rather than flashed past.

	Deliberately not modal and deliberately short. It plays over combat without
	stealing a frame of input, because levelling mid-fight is common and a
	celebration that gets you killed is not a celebration.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local LevelUp = {}

local root: Frame
local flash: Frame
local rings: { Frame } = {}
local numberLabel: TextLabel
local wordLabel: TextLabel
local gainsHolder: Frame

local token = 0

--------------------------------------------------------------------------------

local function clearGains()
	for _, child in gainsHolder:GetChildren() do
		if child:IsA("TextLabel") then
			child:Destroy()
		end
	end
end

--- Fires the celebration. `gains` is a list of { label, value } already
--- formatted by the caller, so this never has to know what a stat is.
function LevelUp.play(level: number, gains)
	if not root then
		return
	end

	token += 1
	local mine = token
	root.Visible = true

	Audio.play("levelUp")

	------------------------------------------------------------------
	-- 1. Flash
	------------------------------------------------------------------
	flash.BackgroundTransparency = 0.55
	Motion.to(flash, Theme.Time.Slow, { BackgroundTransparency = 1 }, Theme.Ease.Exit)

	------------------------------------------------------------------
	-- 2. Rings
	------------------------------------------------------------------
	for index, ring in rings do
		ring.Size = UDim2.fromOffset(60, 60)
		ring.BackgroundTransparency = 1
		local stroke = ring:FindFirstChildOfClass("UIStroke")
		stroke.Transparency = 0.1
		stroke.Thickness = 4

		-- Different speeds and end sizes, so the two rings never read as one
		-- thick ring.
		local size = 420 + index * 190
		local duration = Theme.Time.Slow * (1 + index * 0.45)
		Motion.to(ring, duration, { Size = UDim2.fromOffset(size, size) }, Theme.Ease.Exit)
		Motion.to(stroke, duration, { Transparency = 1, Thickness = 1 })
	end

	------------------------------------------------------------------
	-- 3. The number
	------------------------------------------------------------------
	numberLabel.Text = tostring(level)
	numberLabel.TextTransparency = 1
	numberLabel.TextSize = 40
	wordLabel.TextTransparency = 1

	Motion.to(numberLabel, Theme.Time.Fast, { TextTransparency = 0 })
	-- Overshoot then settle: the size curve is what makes it feel like an
	-- arrival rather than a fade-in.
	Motion.to(numberLabel, Theme.Time.Base, { TextSize = 108 }, Theme.Ease.Enter)
	task.delay(Theme.Time.Base, function()
		if token == mine then
			Motion.to(numberLabel, Theme.Time.Base, { TextSize = 92 })
		end
	end)
	Motion.to(wordLabel, Theme.Time.Base, { TextTransparency = 0.1 })

	------------------------------------------------------------------
	-- 4. What improved
	------------------------------------------------------------------
	clearGains()
	for index, gain in gains or {} do
		local line = Create("TextLabel", {
			Name = gain.label,
			Size = UDim2.new(1, 0, 0, 22),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = `{gain.label}  {gain.value}`,
			TextSize = 17,
			TextColor3 = gain.color or Theme.Color.Success,
			TextTransparency = 1,
			LayoutOrder = index,
			Parent = gainsHolder,
		})
		-- Staggered, so they read as a list arriving rather than a block.
		task.delay(0.18 + index * 0.09, function()
			if token == mine then
				Motion.to(line, Theme.Time.Base, { TextTransparency = 0.05 })
			end
		end)
	end

	------------------------------------------------------------------

	task.delay(2.6, function()
		if token ~= mine then
			return
		end
		Motion.to(numberLabel, Theme.Time.Slow, { TextTransparency = 1 })
		Motion.to(wordLabel, Theme.Time.Slow, { TextTransparency = 1 })
		for _, child in gainsHolder:GetChildren() do
			if child:IsA("TextLabel") then
				Motion.to(child, Theme.Time.Slow, { TextTransparency = 1 })
			end
		end
		task.delay(Theme.Time.Slow, function()
			if token == mine then
				root.Visible = false
			end
		end)
	end)
end

--------------------------------------------------------------------------------

function LevelUp.start()
	root = Layer.root("Overlay", "LevelUp")
	root.Visible = false

	flash = Create("Frame", {
		Name = "Flash",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.fromRGB(255, 246, 214),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ZIndex = 1,
		Parent = root,
	})

	for index = 1, 2 do
		table.insert(
			rings,
			Create("Frame", {
				Name = `Ring{index}`,
				AnchorPoint = Vector2.new(0.5, 0.5),
				Position = UDim2.fromScale(0.5, 0.46),
				Size = UDim2.fromOffset(60, 60),
				BackgroundTransparency = 1,
				BorderSizePixel = 0,
				ZIndex = 2,
				Parent = root,

				Create("UICorner", { CornerRadius = UDim.new(1, 0) }),
				Create("UIStroke", {
					Color = if index == 1 then Theme.Color.Unique else Theme.Color.Accent,
					Thickness = 4,
					Transparency = 1,
				}),
			})
		)
	end

	wordLabel = Create("TextLabel", {
		Name = "Word",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.fromScale(0.5, 0.36),
		Size = UDim2.fromOffset(400, 26),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "LEVEL UP",
		TextSize = 24,
		TextColor3 = Theme.Color.Unique,
		TextTransparency = 1,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.4,
		ZIndex = 3,
		Parent = root,
	})

	numberLabel = Create("TextLabel", {
		Name = "Level",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.46),
		Size = UDim2.fromOffset(400, 130),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "",
		TextSize = 92,
		TextColor3 = Theme.Color.Text,
		TextTransparency = 1,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.35,
		ZIndex = 3,
		Parent = root,
	})

	gainsHolder = Create("Frame", {
		Name = "Gains",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.fromScale(0.5, 0.55),
		Size = UDim2.fromOffset(300, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		ZIndex = 3,
		Parent = root,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			Padding = UDim.new(0, 3),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})
end

return LevelUp
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="130">
              <Properties>
                <string name="Name">ScenarioBanner</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	ScenarioBanner — the Unique Scenario reveal.

	This is the single loudest thing the UI ever does, and it should stay that
	way: gold is used nowhere else, and the banner is the only element allowed
	to dim the whole screen for a non-menu reason. A player might see this
	twice in a play session. It has to feel like an event.

	Sequence (roughly 4s end to end):
	    vignette in -> rules sweep out from centre -> kicker fades up
	    -> name rises into place -> hold -> everything retreats

	    ScenarioBanner.play({
	        kicker = "Unique Scenario",
	        name = "Vorpal Bunny",
	        note = "First clear across all servers",
	    })
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local RULE_WIDTH = 560
local HOLD = 2.1

local ScenarioBanner = {}
local root: Frame
local playToken = 0

export type Options = {
	kicker: string?,
	name: string,
	note: string?,
	color: Color3?,
	hold: number?,
}

--- Letterspacing Roblox won't do for us. Cheap, and the kicker is short.
local function spaced(text: string): string
	return table.concat(string.split(string.upper(text), ""), " ")
end

function ScenarioBanner.play(options: Options)
	if not root then
		return
	end

	playToken += 1
	local token = playToken
	local accent = options.color or Theme.Color.Unique

	-- A fresh subtree per play; simpler than resetting a dozen properties and
	-- guarantees an interrupted banner can't leak state into the next one.
	root:ClearAllChildren()

	local vignette = Create("Frame", {
		Name = "Vignette",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.Color.Scrim,
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Parent = root,
	})

	local group = Create("Frame", {
		Name = "Group",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.42),
		Size = UDim2.fromOffset(RULE_WIDTH, 200),
		BackgroundTransparency = 1,
		Parent = root,
	})

	local function rule(yScale: number): Frame
		return Create("Frame", {
			Name = "Rule",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, yScale),
			Size = UDim2.fromOffset(0, 1),
			BackgroundColor3 = accent,
			BorderSizePixel = 0,
			Parent = group,

			Create("UIGradient", {
				Transparency = NumberSequence.new({
					NumberSequenceKeypoint.new(0, 1),
					NumberSequenceKeypoint.new(0.5, 0),
					NumberSequenceKeypoint.new(1, 1),
				}),
			}),
		})
	end

	local topRule = rule(0.16)
	local bottomRule = rule(0.84)

	local kicker = Create("TextLabel", {
		Name = "Kicker",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.34),
		Size = UDim2.fromScale(1, 0.16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = spaced(options.kicker or "Unique Scenario"),
		TextSize = 15,
		TextColor3 = accent,
		TextTransparency = 1,
		Parent = group,
	})

	local nameLabel = Create("TextLabel", {
		Name = "ScenarioName",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.56),
		Size = UDim2.fromScale(1, 0.32),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = string.upper(options.name),
		TextSize = 52,
		TextColor3 = Theme.Color.Text,
		TextTransparency = 1,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.6,
		Parent = group,
	})

	local note = if options.note
		then Create("TextLabel", {
			Name = "Note",
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.95),
			Size = UDim2.fromScale(1, 0.14),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = options.note,
			TextSize = 14,
			TextColor3 = Theme.Color.TextMuted,
			TextTransparency = 1,
			Parent = group,
		})
		else nil

	local function stillPlaying(): boolean
		return token == playToken and root.Parent ~= nil
	end

	-- 1. Dim
	Motion.to(vignette, Theme.Time.Slow, { BackgroundTransparency = 0.55 })

	-- 2. Rules sweep outward
	Motion.to(topRule, Theme.Time.Deliberate, { Size = UDim2.fromOffset(RULE_WIDTH, 1) }, Theme.Ease.Enter)
	Motion.to(bottomRule, Theme.Time.Deliberate, { Size = UDim2.fromOffset(RULE_WIDTH, 1) }, Theme.Ease.Enter)

	-- 3. Kicker
	Motion.to(kicker, Theme.Time.Slow, { TextTransparency = 0 }, Theme.Ease.Standard, 0.22)

	-- 4. Name rises in
	task.delay(0.4, function()
		if not stillPlaying() then
			return
		end
		nameLabel.Position = UDim2.fromScale(0.5, 0.62)
		Motion.to(nameLabel, Theme.Time.Deliberate, {
			TextTransparency = 0,
			Position = UDim2.fromScale(0.5, 0.56),
		}, Theme.Ease.Enter)

		if note then
			Motion.to(note, Theme.Time.Slow, { TextTransparency = 0 }, Theme.Ease.Standard, 0.3)
		end
	end)

	-- 5. Hold, then retreat
	task.delay(0.4 + (options.hold or HOLD), function()
		if not stillPlaying() then
			return
		end

		Motion.to(vignette, Theme.Time.Deliberate, { BackgroundTransparency = 1 })
		Motion.to(topRule, Theme.Time.Slow, { Size = UDim2.fromOffset(0, 1) }, Theme.Ease.Exit)
		Motion.to(bottomRule, Theme.Time.Slow, { Size = UDim2.fromOffset(0, 1) }, Theme.Ease.Exit)

		for _, label in { kicker, nameLabel, note } do
			if label then
				Motion.to(label, Theme.Time.Slow, { TextTransparency = 1 })
			end
		end

		task.delay(Theme.Time.Deliberate + 0.1, function()
			if token == playToken then
				root:ClearAllChildren()
			end
		end)
	end)
end

function ScenarioBanner.start()
	root = Layer.root("Overlay", "ScenarioBanner")
end

return ScenarioBanner
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="131">
              <Properties>
                <string name="Name">ScreenFx</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	ScreenFx — full-screen feedback, and sole owner of the camera.

	EVERYTHING that moves the camera goes through here. Combat wants to shake
	it, landings want to dip it, sprinting wants to widen it, the parry wants to
	punch it in, and the boss intro wants to take it entirely. Five modules
	writing `camera.CFrame` on the same frame is how you get a camera that
	jitters, drifts, or silently snaps back — so instead they each own a named
	channel here and this module composes them once.

	The composite is applied on BindToRenderStep at Camera + 1, not on
	RenderStepped, because RenderStepped runs *before* Roblox updates the
	camera and anything written there is overwritten the same frame.

	On slow motion: Roblox has no global time scale, and building one would mean
	rescaling every tween and physics step in the game. What reads as slow
	motion is the world stopping, the camera pushing in, and colour draining —
	so that is what this does, while the server freezes the creature for the
	same window. Convincing, and it costs nothing.
]]

local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local ScreenFx = {}

local flash: Frame
local vignette: Frame
local speedLines: Frame
local streaks: { Frame } = {}

local flashAlpha = 0
local flashColor = Color3.new(1, 1, 1)
local vignetteAlpha = 0
local speedAlpha = 0
local speedTarget = 0

--------------------------------------------------------------------------------
-- Camera channels
--------------------------------------------------------------------------------

local baseFov: number? = nil
--- Transient punch, decays on its own. Parries, impacts.
local fovPunch = 0
--- Held offset. Sprint.
local fovSustained = 0
--- Positional offset in camera space. Landing dip, breathing sway.
local cameraOffset = Vector3.zero
--- Shake magnitude, decays.
local shake = 0
--- Set while a cinematic owns the camera outright.
local surrendered = false

function ScreenFx.shake(amount: number)
	shake = math.max(shake, amount)
end

function ScreenFx.setSustainedFov(offset: number)
	fovSustained = offset
end

function ScreenFx.setCameraOffset(offset: Vector3)
	cameraOffset = offset
end

--- Cinematics call this so per-frame feel effects stop fighting them.
function ScreenFx.surrenderCamera(yes: boolean)
	surrendered = yes
end

function ScreenFx.setSpeedLines(opacity: number)
	speedTarget = math.clamp(opacity, 0, 1)
end

--------------------------------------------------------------------------------

function ScreenFx.start()
	local root = Layer.root("Screen", "ScreenFx")

	flash = Create("Frame", {
		Name = "Flash",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Parent = root,
	})

	vignette = Create("Frame", {
		Name = "Vignette",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.fromRGB(120, 0, 0),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Parent = root,

		Create("UIGradient", {
			Rotation = 90,
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 0),
				NumberSequenceKeypoint.new(0.45, 1),
				NumberSequenceKeypoint.new(0.55, 1),
				NumberSequenceKeypoint.new(1, 0),
			}),
		}),
	})

	-- Sprint streaks. Edge-only, so the middle of the screen stays readable.
	speedLines = Create("Frame", {
		Name = "SpeedLines",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		Parent = root,
	})

	for index = 1, 14 do
		local left = index % 2 == 0
		local streak = Create("Frame", {
			Name = `Streak{index}`,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(
				if left then 0.04 + math.random() * 0.13 else 0.83 + math.random() * 0.13,
				math.random()
			),
			Size = UDim2.fromOffset(2, 70 + math.random(0, 130)),
			BackgroundColor3 = Color3.new(1, 1, 1),
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			Rotation = if left then -8 else 8,
			Parent = speedLines,
		})
		table.insert(streaks, streak)
	end

	--------------------------------------------------------------------------
	-- Overlays: safe on RenderStepped, they touch no camera state.
	--------------------------------------------------------------------------

	RunService.RenderStepped:Connect(function(dt)
		if flashAlpha > 0.001 then
			flash.BackgroundColor3 = flashColor
			flash.BackgroundTransparency = 1 - flashAlpha
			flashAlpha = math.max(flashAlpha - dt * 4.2, 0)
		elseif flash.BackgroundTransparency < 1 then
			flash.BackgroundTransparency = 1
		end

		if vignetteAlpha > 0.001 then
			vignette.BackgroundTransparency = 1 - vignetteAlpha
			vignetteAlpha = math.max(vignetteAlpha - dt * 1.6, 0)
		elseif vignette.BackgroundTransparency < 1 then
			vignette.BackgroundTransparency = 1
		end

		-- Eased, so a brief sprint doesn't strobe them on and off.
		speedAlpha += (speedTarget - speedAlpha) * math.min(dt * 6, 1)
		if speedAlpha > 0.005 then
			for index, streak in streaks do
				streak.BackgroundTransparency = 1 - speedAlpha * (0.6 + (index % 3) * 0.2)
			end
		else
			for _, streak in streaks do
				streak.BackgroundTransparency = 1
			end
		end
	end)

	--------------------------------------------------------------------------
	-- Camera: after Roblox has placed it, or everything here is discarded.
	--------------------------------------------------------------------------

	RunService:BindToRenderStep("ShangriLaCamera", Enum.RenderPriority.Camera.Value + 1, function(dt)
		local camera = Workspace.CurrentCamera
		if not camera then
			return
		end

		if not baseFov then
			baseFov = camera.FieldOfView
		end

		if fovPunch ~= 0 then
			fovPunch = fovPunch * (1 - math.min(dt * 5, 1))
			if math.abs(fovPunch) < 0.05 then
				fovPunch = 0
			end
		end

		camera.FieldOfView = (baseFov or 70) + fovPunch + fovSustained

		if surrendered then
			return
		end

		-- Offsets are applied in camera space so a dip is always "down the
		-- screen", regardless of which way the player is looking.
		if cameraOffset.Magnitude > 0.001 then
			camera.CFrame = camera.CFrame * CFrame.new(cameraOffset.X, cameraOffset.Y, cameraOffset.Z)
		end

		if shake > 0.01 then
			local magnitude = shake * 0.12
			camera.CFrame = camera.CFrame
				* CFrame.new((math.random() - 0.5) * magnitude, (math.random() - 0.5) * magnitude, 0)
			shake = math.max(shake - dt * 9, 0)
		end
	end)
end

--------------------------------------------------------------------------------

function ScreenFx.flash(color: Color3, strength: number)
	flashColor = color
	flashAlpha = math.max(flashAlpha, math.clamp(strength, 0, 1))
end

function ScreenFx.hurt(strength: number)
	vignetteAlpha = math.max(vignetteAlpha, math.clamp(strength, 0, 0.85))
	ScreenFx.flash(Color3.fromRGB(180, 20, 20), 0.35)
end

--- The parry: white pop, camera punches in, world holds still.
function ScreenFx.parry()
	ScreenFx.flash(Color3.new(1, 1, 1), 0.9)
	fovPunch = -14
end

function ScreenFx.push(amount: number)
	fovPunch = amount
end

ScreenFx.Theme = Theme

return ScreenFx
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="132">
              <Properties>
                <string name="Name">Toast</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Toast — transient right-hand notifications.

	Loot, level-ups, quest state, system messages. Stacks downward, oldest at
	the top, and self-expires. Kept deliberately quiet: the scenario banner is
	the loud channel, this one shouldn't compete with it.

	    Toast.push({ title = "Vorpal Bunny Fang", subtitle = "Rare material", kind = "loot" })
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local WIDTH = 300
local LIFETIME = 4
local MAX_VISIBLE = 5

local KIND_COLORS = {
	loot = Theme.Color.Accent,
	levelup = Theme.Color.Success,
	quest = Theme.Color.Unique,
	curse = Theme.Color.Cursed,
	system = Theme.Color.TextMuted,
	warning = Theme.Color.Danger,
}

local Toast = {}
local root: Frame
local stack: Frame
local active: { Frame } = {}

export type Options = {
	title: string,
	subtitle: string?,
	kind: string?,
	duration: number?,
}

local function dismiss(card: Frame)
	local index = table.find(active, card)
	if index then
		table.remove(active, index)
	end

	local tween = Motion.to(card, Theme.Time.Base, {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(30, 0),
	}, Theme.Ease.Exit)

	for _, descendant in card:GetDescendants() do
		if descendant:IsA("TextLabel") then
			Motion.to(descendant, Theme.Time.Base, { TextTransparency = 1 })
		elseif descendant:IsA("UIStroke") or descendant:IsA("Frame") then
			Motion.to(descendant, Theme.Time.Base, { Transparency = 1 })
		end
	end

	tween.Completed:Once(function()
		card:Destroy()
	end)
end

function Toast.push(options: Options)
	if not root then
		return
	end

	local accent = KIND_COLORS[options.kind or "system"] or Theme.Color.Accent

	local card = Create("Frame", {
		Name = "Toast",
		Size = UDim2.fromOffset(WIDTH, if options.subtitle then 58 else 42),
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.08,
		BorderSizePixel = 0,
		Position = UDim2.fromOffset(30, 0),
		Parent = stack,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.4 }),
		Create("UIPadding", {
			PaddingLeft = UDim.new(0, 14),
			PaddingRight = UDim.new(0, 12),
			PaddingTop = UDim.new(0, 8),
		}),

		-- Left accent stripe carries the category colour.
		Create("Frame", {
			Name = "Stripe",
			Size = UDim2.new(0, 3, 1, 0),
			Position = UDim2.fromOffset(-14, 0),
			BackgroundColor3 = accent,
			BorderSizePixel = 0,
		}),

		Create("TextLabel", {
			Name = "Title",
			Size = UDim2.new(1, 0, 0, 17),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = options.title,
			TextSize = 14,
			TextColor3 = Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
		}),
	})

	if options.subtitle then
		Create("TextLabel", {
			Name = "Subtitle",
			Position = UDim2.fromOffset(0, 20),
			Size = UDim2.new(1, 0, 0, 15),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = options.subtitle,
			TextSize = 12,
			TextColor3 = accent,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
			Parent = card,
		})
	end

	-- Each category gets its own sting, so a level-up and a loot drop are
	-- distinguishable without looking away from the fight.
	Audio.play(
		if options.kind == "levelup"
			then "levelUp"
			elseif options.kind == "quest" or options.kind == "curse" then "unique"
			elseif options.kind == "loot" then "loot"
			else "uiSelect"
	)

	table.insert(active, card)
	card.LayoutOrder = os.clock() * 1000 // 1

	Motion.to(card, Theme.Time.Base, { Position = UDim2.fromOffset(0, 0) }, Theme.Ease.Enter)

	-- Retire the oldest if we're over budget.
	while #active > MAX_VISIBLE do
		dismiss(active[1])
	end

	task.delay(options.duration or LIFETIME, function()
		if card.Parent then
			dismiss(card)
		end
	end)
end

function Toast.start()
	root = Layer.root("Overlay", "Toasts")

	stack = Create("Frame", {
		Name = "Stack",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, -26, 0, 150),
		Size = UDim2.fromOffset(WIDTH, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		Parent = root,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			HorizontalAlignment = Enum.HorizontalAlignment.Right,
			Padding = UDim.new(0, 8),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})
end

return Toast
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="133">
              <Properties>
                <string name="Name">Waypoint</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Waypoint — the arrow that tells you where to go.

	The single most common way a new player bounces off a game is not knowing
	what to do next, and the fix is never more text. It is a marker in the
	world and an arrow at the edge of the screen when that marker is behind
	you. Every successful Roblox game does exactly this and none of them
	explain it, because it needs no explaining.

	THREE PIECES, and each covers a case the others can't:

	  BEAM      a column of light at the destination. Visible over rooftops
	            and through a crowd; tells you *where* from a distance.
	  MARKER    a bobbing arrow with a label directly above the target. Tells
	            you *which one* when you are close and there are six NPCs.
	  CHEVRON   a screen-edge pointer with a distance readout, shown only when
	            the target is off-screen. Tells you *which way to turn*, which
	            is the case a world-space marker fundamentally cannot cover.

	The chevron is the important one. A player who cannot see the objective
	assumes there isn't one.

	    Waypoint.set(position, "Rowan", "quest")
	    Waypoint.clear()
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Create = require(ReplicatedStorage.Shared.Util.Create)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local Waypoint = {}

local player = Players.LocalPlayer
local camera = Workspace.CurrentCamera

local folder: Folder
local beam: BasePart
local marker: BillboardGui
local markerLabel: TextLabel
local markerArrow: TextLabel

local root: Frame
local chevron: Frame
local chevronArrow: TextLabel
local chevronLabel: TextLabel

local target: Vector3? = nil
local tone = Theme.Color.Unique

--- Off-screen chevrons ride this far inside the screen edge.
local EDGE_INSET = 78

local KIND_COLOR = {
	quest = Theme.Color.Unique,
	turnin = Theme.Color.Success,
	tutorial = Theme.Color.Accent,
	danger = Theme.Color.Danger,
}

--------------------------------------------------------------------------------

function Waypoint.set(position: Vector3, label: string, kind: string?)
	target = position
	tone = KIND_COLOR[kind or "quest"] or Theme.Color.Unique

	beam.Position = position + Vector3.new(0, 24, 0)
	beam.Color = tone
	beam.Parent = folder

	markerLabel.Text = label
	markerLabel.TextColor3 = tone
	markerArrow.TextColor3 = tone
	marker.Adornee = beam
	marker.Enabled = true

	chevronArrow.TextColor3 = tone
	chevronLabel.TextColor3 = tone
end

function Waypoint.clear()
	target = nil
	beam.Parent = nil
	marker.Enabled = false
	chevron.Visible = false
end

--------------------------------------------------------------------------------

local function step()
	if not target then
		return
	end

	local character = player.Character
	local characterRoot = character and character:FindFirstChild("HumanoidRootPart")
	if not characterRoot then
		chevron.Visible = false
		return
	end

	-- Bob the marker so it reads as a live prompt rather than a decal.
	local bob = math.sin(os.clock() * 2.6) * 1.2
	beam.Position = target + Vector3.new(0, 24 + bob, 0)

	local point, onScreen = camera:WorldToViewportPoint(target + Vector3.new(0, 4, 0))
	local distance = (characterRoot.Position - target).Magnitude

	-- Close enough that the marker itself is unmissable: drop the chevron.
	if onScreen and point.Z > 0 then
		chevron.Visible = false
		return
	end

	--[[
		Project the target onto the screen edge.

		WorldToViewportPoint gives a mirrored point when the target is behind
		the camera (Z < 0), so that case is flipped explicitly. Getting this
		wrong points the arrow at exactly the opposite of the objective, which
		is worse than having no arrow at all.
	]]
	local viewport = camera.ViewportSize
	local centre = Vector2.new(viewport.X / 2, viewport.Y / 2)
	local screen = Vector2.new(point.X, point.Y)
	if point.Z < 0 then
		screen = centre - (screen - centre)
	end

	local direction = screen - centre
	if direction.Magnitude < 1 then
		direction = Vector2.new(0, 1)
	end
	direction = direction.Unit

	-- Push out to the edge of an inset rectangle, whichever axis hits first.
	local halfX = centre.X - EDGE_INSET
	local halfY = centre.Y - EDGE_INSET
	local scale = math.min(
		if math.abs(direction.X) > 1e-4 then halfX / math.abs(direction.X) else math.huge,
		if math.abs(direction.Y) > 1e-4 then halfY / math.abs(direction.Y) else math.huge
	)
	local at = centre + direction * scale

	chevron.Visible = true
	chevron.Position = UDim2.fromOffset(at.X, at.Y)
	chevronArrow.Rotation = math.deg(math.atan2(direction.Y, direction.X)) + 90
	chevronLabel.Text = `{math.floor(distance)}m`
end

--------------------------------------------------------------------------------

function Waypoint.start()
	folder = Create("Folder", { Name = "Waypoint", Parent = Workspace })

	--[[
		The beam.

		A tall, thin, unlit neon column. Deliberately not a particle effect —
		it has to be visible through fog, at any time of day, from the far side
		of a town, and a solid emissive part is the only thing that reliably is.
	]]
	beam = Create("Part", {
		Name = "Beam",
		Anchored = true,
		CanCollide = false,
		CanQuery = false,
		CanTouch = false,
		Size = Vector3.new(1.6, 48, 1.6),
		Material = Enum.Material.Neon,
		Transparency = 0.55,
		Color = Theme.Color.Unique,
	})

	marker = Create("BillboardGui", {
		Name = "Marker",
		Size = UDim2.fromOffset(220, 62),
		StudsOffsetWorldSpace = Vector3.new(0, 27, 0),
		AlwaysOnTop = true,
		LightInfluence = 0,
		Enabled = false,
		Parent = player:WaitForChild("PlayerGui"),
	})

	markerArrow = Create("TextLabel", {
		Name = "Arrow",
		Size = UDim2.new(1, 0, 0, 34),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "▼",
		TextSize = 34,
		TextColor3 = Theme.Color.Unique,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.35,
		Parent = marker,
	})

	markerLabel = Create("TextLabel", {
		Name = "Label",
		Position = UDim2.fromOffset(0, 34),
		Size = UDim2.new(1, 0, 0, 24),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "",
		TextSize = 20,
		TextColor3 = Theme.Color.Unique,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.35,
		Parent = marker,
	})

	----------------------------------------------------------------------
	-- Screen-edge chevron
	----------------------------------------------------------------------

	root = Layer.root("HUD", "Waypoint")

	chevron = Create("Frame", {
		Name = "Chevron",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Size = UDim2.fromOffset(74, 74),
		BackgroundTransparency = 1,
		Visible = false,
		Parent = root,
	})

	chevronArrow = Create("TextLabel", {
		Name = "Arrow",
		Size = UDim2.new(1, 0, 0, 46),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "➤",
		TextSize = 40,
		TextColor3 = Theme.Color.Unique,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.3,
		Parent = chevron,
	})

	chevronLabel = Create("TextLabel", {
		Name = "Distance",
		Position = UDim2.fromOffset(0, 46),
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "",
		TextSize = 17,
		TextColor3 = Theme.Color.Unique,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.35,
		Parent = chevron,
	})

	RunService.RenderStepped:Connect(function()
		camera = Workspace.CurrentCamera
		local ok, err = pcall(step)
		if not ok then
			warn(`[Waypoint] {err}`)
		end
	end)
end

return Waypoint
]]></string>
              </Properties>
            </Item>
          </Item>
          <Item class="Folder" referent="134">
            <Properties>
              <string name="Name">Screens</string>
            </Properties>
            <Item class="ModuleScript" referent="135">
              <Properties>
                <string name="Name">AbilityBar</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	AbilityBar — the three Q/E/R loadout slots, bottom centre.

	Each slot shows its key, the assigned skill, its stamina cost, and a
	cooldown sweep that drains downward with the seconds remaining over it.

	Failure states get distinct colours, because "nothing happened" is the
	worst feedback a combat game can give:
	    cooldown -> accent flash, you just have to wait
	    stamina  -> amber flash, matching the stamina bar's low colour
	    empty    -> muted flash, nothing is assigned
	    locked   -> no flash; the slot already reads as locked

	Cooldowns render on RenderStepped straight from UIState rather than through
	observers -- they're a continuous countdown, and firing an observer 60
	times a second to move one frame's height would be pure overhead.
]]

local RunService = game:GetService("RunService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Skills = require(ReplicatedStorage.Shared.Config.Skills)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Parry = require(ReplicatedStorage.Shared.Config.Parry)

local Events = require(script.Parent.Parent.Core.Events)
local Layer = require(script.Parent.Parent.Core.Layer)
local UIState = require(script.Parent.Parent.Core.UIState)

local SLOT = 66
local GAP = 10
local BOTTOM_MARGIN = 28

local FAIL_COLORS = {
	cooldown = Theme.Color.Accent,
	stamina = Theme.Color.StaminaEmpty,
	empty = Theme.Color.TextMuted,
}

local AbilityBar = {}
local trove = Trove.new()

--- The parry pip sits apart from the Q/E/R slots on purpose: it is a core
--- skill, not a loadout choice, and it should never look like something you
--- could have swapped out.
local parryPip: Frame? = nil

local root: Frame
local slots: { [number]: Frame } = {}

local function buildSlot(slot, parent: Instance): Frame
	local frame = Create("Frame", {
		Name = `Slot{slot.index}`,
		Size = UDim2.fromOffset(SLOT, SLOT),
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.12,
		BorderSizePixel = 0,
		ClipsDescendants = true,
		LayoutOrder = slot.index,
		Parent = parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),

		-- Skill initial, standing in for art.
		Create("TextLabel", {
			Name = "Glyph",
			Size = UDim2.fromScale(1, 1),
			Position = UDim2.fromOffset(0, -4),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = "",
			TextSize = 24,
			TextColor3 = Theme.Color.Text,
		}),

		-- Level requirement, shown only while the slot is locked.
		Create("TextLabel", {
			Name = "Requirement",
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = "",
			TextSize = 12,
			TextColor3 = Theme.Color.TextDim,
		}),

		-- Cooldown veil: full height at cast, drains to nothing.
		Create("Frame", {
			Name = "Cooldown",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.fromScale(0, 1),
			Size = UDim2.fromScale(1, 0),
			BackgroundColor3 = Theme.Color.Void,
			BackgroundTransparency = 0.35,
			BorderSizePixel = 0,
			ZIndex = 3,
		}),

		Create("TextLabel", {
			Name = "Timer",
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = "",
			TextSize = 18,
			TextColor3 = Theme.Color.Text,
			ZIndex = 4,
		}),

		-- Key cap, bottom-left.
		Create("TextLabel", {
			Name = "Key",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, 6, 1, -3),
			Size = UDim2.fromOffset(16, 15),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Black,
			Text = slot.label,
			TextSize = 12,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			ZIndex = 5,
		}),

		-- Stamina cost, bottom-right.
		Create("TextLabel", {
			Name = "Cost",
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -6, 1, -3),
			Size = UDim2.fromOffset(30, 15),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = "",
			TextSize = 11,
			TextColor3 = Theme.Color.Stamina,
			TextXAlignment = Enum.TextXAlignment.Right,
			ZIndex = 5,
		}),
	})

	frame:SetAttribute("SlotIndex", slot.index)

	Create("UIStroke", {
		Name = "Edge",
		Color = Theme.Color.Stroke,
		Thickness = 1,
		Transparency = 0.35,
		Parent = frame,
	})

	return frame
end

local function syncSlots()
	local level = UIState.get("level") or 1
	local loadout = UIState.get("loadout") or {}

	for index, frame in slots do
		local unlocked = Skills.isSlotUnlocked(index, level)
		local skill = Skills.find(loadout[index])
		local slotConfig = Skills.slot(index)

		frame.Requirement.Text = if unlocked then "" else `Lv {slotConfig.levelRequired}`
		frame.Key.TextTransparency = if unlocked then 0 else 0.6

		if not unlocked then
			frame.Glyph.Text = ""
			frame.Cost.Text = ""
			frame.BackgroundTransparency = 0.55
			frame.Edge.Transparency = 0.7
		elseif skill then
			frame.Glyph.Text = string.sub(skill.name, 1, 1)
			frame.Glyph.TextColor3 = Theme.Color.Text
			frame.Cost.Text = if (skill.staminaCost or 0) > 0 then tostring(skill.staminaCost) else ""
			frame.BackgroundTransparency = 0.12
			frame.Edge.Transparency = 0.35
			frame.Edge.Color = Theme.Color.Stroke
		else
			frame.Glyph.Text = ""
			frame.Cost.Text = ""
			frame.BackgroundTransparency = 0.45
			frame.Edge.Transparency = 0.6
			frame.Edge.Color = Theme.Color.Stroke
		end
	end
end

local function flash(slotIndex: number, reason: string)
	local frame = slots[slotIndex]
	local color = FAIL_COLORS[reason]
	if not frame or not color then
		return
	end

	frame.Edge.Color = color
	frame.Edge.Transparency = 0
	Motion.to(frame.Edge, Theme.Time.Slow, { Transparency = 0.35 })
	task.delay(Theme.Time.Slow, function()
		if frame.Parent then
			frame.Edge.Color = Theme.Color.Stroke
		end
	end)
end

local function pop(slotIndex: number)
	local frame = slots[slotIndex]
	if not frame then
		return
	end
	frame.Glyph.TextSize = 30
	Motion.to(frame.Glyph, Theme.Time.Base, { TextSize = 24 })
end

function AbilityBar.start()
	root = Layer.root("HUD", "AbilityBar")
	trove:add(root)

	local strip = Create("Frame", {
		Name = "Strip",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -BOTTOM_MARGIN),
		Size = UDim2.fromOffset(SLOT * #Skills.Slots + GAP * (#Skills.Slots - 1), SLOT),
		BackgroundTransparency = 1,
		Parent = root,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			Padding = UDim.new(0, GAP),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for _, slot in Skills.Slots do
		slots[slot.index] = buildSlot(slot, strip)
	end

	parryPip = Create("Frame", {
		Name = "Parry",
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(0.5, -(SLOT * #Skills.Slots + GAP * #Skills.Slots) / 2 - 14, 1, -BOTTOM_MARGIN),
		Size = UDim2.fromOffset(SLOT, SLOT),
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.12,
		BorderSizePixel = 0,
		ClipsDescendants = true,
		Parent = root,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Name = "Edge", Color = Theme.Color.Unique, Thickness = 1, Transparency = 0.4 }),

		Create("TextLabel", {
			Name = "Glyph",
			Size = UDim2.fromScale(1, 1),
			Position = UDim2.fromOffset(0, -4),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = "⟠",
			TextSize = 26,
			TextColor3 = Theme.Color.Unique,
		}),

		Create("Frame", {
			Name = "Cooldown",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.fromScale(0, 1),
			Size = UDim2.fromScale(1, 0),
			BackgroundColor3 = Theme.Color.Void,
			BackgroundTransparency = 0.35,
			BorderSizePixel = 0,
			ZIndex = 3,
		}),

		Create("TextLabel", {
			Name = "Key",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, 6, 1, -3),
			Size = UDim2.fromOffset(16, 15),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Black,
			Text = "F",
			TextSize = 12,
			TextColor3 = Theme.Color.TextMuted,
			ZIndex = 5,
		}),

		Create("TextLabel", {
			Name = "Rank",
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -6, 1, -3),
			Size = UDim2.fromOffset(30, 15),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = "",
			TextSize = 11,
			TextColor3 = Theme.Color.Unique,
			TextXAlignment = Enum.TextXAlignment.Right,
			ZIndex = 5,
		}),
	})

	trove:add(UIState.observeMany({ "loadout", "level" }, syncSlots))

	trove:add(UIState.observeMany({ "dead", "phase" }, function()
		root.Visible = UIState.get("phase") == "playing" and not UIState.get("dead")
	end))

	trove:connect(Events.AbilityFailed, function(slotIndex, reason)
		flash(slotIndex, reason)
	end)

	trove:connect(Events.AbilityCast, function(slotIndex)
		pop(slotIndex)
	end)

	-- Cooldown sweep. Reads UIState directly each frame; see the header note.
	trove:connect(RunService.RenderStepped, function()
		if not root.Visible then
			return
		end

		local cooldowns = UIState.get("cooldowns") or {}
		local loadout = UIState.get("loadout") or {}
		local now = os.clock()

		if parryPip then
			local rank = UIState.get("parryRank") or 0
			local locked = rank <= 0
			parryPip.Rank.Text = if locked then "" else `R{rank}`
			parryPip.Glyph.TextTransparency = if locked then 0.65 else 0
			parryPip.Edge.Transparency = if locked then 0.8 else 0.4

			local tier = Parry.forRank(rank)
			local readyAt = UIState.get("parryReadyAt") or 0
			local remaining = math.max(readyAt - now, 0)

			-- The active window flashes bright; the cooldown drains downward.
			if now < (UIState.get("parryWindowUntil") or 0) then
				parryPip.Edge.Color = Color3.fromRGB(255, 255, 255)
				parryPip.Cooldown.Size = UDim2.fromScale(1, 0)
			else
				parryPip.Edge.Color = Theme.Color.Unique
				parryPip.Cooldown.Size = if tier and remaining > 0
					then UDim2.fromScale(1, math.clamp(remaining / tier.cooldown, 0, 1))
					else UDim2.fromScale(1, 0)
			end
		end

		for index, frame in slots do
			local skill = Skills.find(loadout[index])
			local remaining = 0
			local total = 0

			if skill then
				local endsAt = cooldowns[skill.id]
				if endsAt then
					remaining = math.max(endsAt - now, 0)
					total = skill.cooldown or 1
				end
			end

			if remaining > 0 and total > 0 then
				frame.Cooldown.Size = UDim2.fromScale(1, math.clamp(remaining / total, 0, 1))
				frame.Timer.Text = if remaining >= 10
					then tostring(math.ceil(remaining))
					else string.format("%.1f", remaining)
			elseif frame.Timer.Text ~= "" then
				frame.Cooldown.Size = UDim2.fromScale(1, 0)
				frame.Timer.Text = ""
			end
		end
	end)
end

function AbilityBar.stop()
	trove:clean()
	table.clear(slots)
end

return AbilityBar
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="136">
              <Properties>
                <string name="Name">ArenaHud</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	ArenaHud — the wave counter, and the banner between waves.

	Two elements, no more. A wave counter that is permanently visible while a
	run is live, and a banner that appears for the moments that matter: a wave
	starting, a wave clearing, the run ending.

	It sits top-centre rather than in a corner because during a Colosseum run
	the player's eyes are on the middle of the screen and nowhere else, and a
	counter you have to look away from a fight to read is a counter nobody
	reads.

	Pips rather than "3/5": five dots you can count without parsing is faster
	than a fraction you have to read, and speed is the only thing this element
	is for.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local ArenaHud = {}

local root: Frame
local tracker: Frame
local waveLabel: TextLabel
local remainingLabel: TextLabel
local pipRow: Frame
local pips: { Frame } = {}

local banner: Frame
local bannerTitle: TextLabel
local bannerSub: TextLabel
local bannerToken = 0

local TOTAL_PIPS = 5

local function showBanner(title: string, sub: string?)
	bannerToken += 1
	local token = bannerToken

	bannerTitle.Text = title
	bannerSub.Text = sub or ""

	banner.Visible = true
	banner.Position = UDim2.new(0.5, 0, 0, 118)
	bannerTitle.TextTransparency = 1
	bannerSub.TextTransparency = 1

	Motion.to(banner, Theme.Time.Base, { Position = UDim2.new(0.5, 0, 0, 132) }, Theme.Ease.Enter)
	Motion.to(bannerTitle, Theme.Time.Base, { TextTransparency = 0 })
	Motion.to(bannerSub, Theme.Time.Base, { TextTransparency = 0.25 })

	task.delay(2.6, function()
		-- Guarded by a token so a fast second wave doesn't get its banner
		-- faded out by the previous one's timer.
		if token ~= bannerToken then
			return
		end
		Motion.to(bannerTitle, Theme.Time.Base, { TextTransparency = 1 })
		local tween = Motion.to(bannerSub, Theme.Time.Base, { TextTransparency = 1 })
		tween.Completed:Once(function()
			if token == bannerToken then
				banner.Visible = false
			end
		end)
	end)
end

local function setWave(wave: number, total: number, remaining: number)
	waveLabel.Text = `WAVE {wave} / {total}`
	remainingLabel.Text = if remaining > 0 then `{remaining} left` else "clear"
	remainingLabel.TextColor3 = if remaining > 0 then Theme.Color.TextMuted else Theme.Color.Success

	for index, pip in pips do
		local done = index < wave
		local active = index == wave
		Motion.to(pip, Theme.Time.Fast, {
			BackgroundColor3 = if done
				then Theme.Color.Success
				elseif active then Theme.Color.Unique
				else Theme.Color.Stroke,
			BackgroundTransparency = if index <= wave then 0 else 0.55,
			Size = if active then UDim2.fromOffset(26, 6) else UDim2.fromOffset(18, 4),
		})
		pip.Visible = index <= total
	end
end

function ArenaHud.start()
	root = Layer.root("HUD", "Arena")
	root.Visible = false

	------------------------------------------------------------------
	-- Tracker
	------------------------------------------------------------------

	tracker = Create("Frame", {
		Name = "Tracker",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 22),
		Size = UDim2.fromOffset(220, 54),
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.2,
		BorderSizePixel = 0,
		Parent = root,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Unique, Thickness = 1, Transparency = 0.55 }),
	})

	waveLabel = Create("TextLabel", {
		Name = "Wave",
		Position = UDim2.fromOffset(0, 7),
		Size = UDim2.new(1, 0, 0, 17),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "",
		TextSize = 14,
		TextColor3 = Theme.Color.Text,
		Parent = tracker,
	})

	remainingLabel = Create("TextLabel", {
		Name = "Remaining",
		Position = UDim2.fromOffset(0, 23),
		Size = UDim2.new(1, 0, 0, 14),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 11,
		TextColor3 = Theme.Color.TextMuted,
		Parent = tracker,
	})

	pipRow = Create("Frame", {
		Name = "Pips",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -8),
		Size = UDim2.new(1, -20, 0, 6),
		BackgroundTransparency = 1,
		Parent = tracker,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			VerticalAlignment = Enum.VerticalAlignment.Center,
			Padding = UDim.new(0, 5),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index = 1, TOTAL_PIPS do
		table.insert(
			pips,
			Create("Frame", {
				Name = `Pip{index}`,
				Size = UDim2.fromOffset(18, 4),
				BackgroundColor3 = Theme.Color.Stroke,
				BackgroundTransparency = 0.55,
				BorderSizePixel = 0,
				LayoutOrder = index,
				Parent = pipRow,

				Create("UICorner", { CornerRadius = UDim.new(1, 0) }),
			})
		)
	end

	------------------------------------------------------------------
	-- Banner
	------------------------------------------------------------------

	banner = Create("Frame", {
		Name = "Banner",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 132),
		Size = UDim2.fromOffset(460, 62),
		BackgroundTransparency = 1,
		Visible = false,
		Parent = root,
	})

	bannerTitle = Create("TextLabel", {
		Name = "Title",
		Size = UDim2.new(1, 0, 0, 34),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "",
		TextSize = 32,
		TextColor3 = Theme.Color.Unique,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.4,
		Parent = banner,
	})

	bannerSub = Create("TextLabel", {
		Name = "Sub",
		Position = UDim2.fromOffset(0, 34),
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 14,
		TextColor3 = Theme.Color.Text,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.55,
		Parent = banner,
	})

	------------------------------------------------------------------

	local lastWave = 0

	Network.ArenaState.OnClientEvent:Connect(function(payload)
		if not payload then
			root.Visible = false
			lastWave = 0
			return
		end

		root.Visible = true
		setWave(payload.wave, payload.totalWaves or TOTAL_PIPS, payload.remaining or 0)

		if payload.banner then
			showBanner(payload.banner, payload.sub)
			-- Only sting on an actual wave change, not on every enemy killed.
			if payload.wave ~= lastWave then
				lastWave = payload.wave
				Audio.play("unique")
			end
		end
	end)
end

return ArenaHud
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="137">
              <Properties>
                <string name="Name">BootSequence</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	BootSequence — the full-dive login animation.

	Plays once on join, above everything, before any other UI exists. Roughly
	six seconds, built as a staged sequence rather than one long tween so each
	beat can be retimed without touching the others.

	    black
	     -> crosshair sweeps open across the screen
	     -> perspective grid resolves underneath
	     -> rings pulse outward from centre
	     -> neural link sync bar fills
	     -> title condenses out of letterspacing
	     -> white flash, dissolve

	The title's reveal is a letterspacing collapse: the text starts spread
	wide and the gaps close as it fades up. TextLabels can't animate tracking,
	so the string is rebuilt each frame via Motion.drive. It's cheap (one
	short label) and it's the moment the whole sequence is built around.

	Call BootSequence.play() and await the returned signal, or pass onDone.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local TITLE = "SHANGRI-LA FRONTIER"
local MAX_TRACKING = 5 -- spaces between characters at the start of the collapse

local BootSequence = {}
local root: Frame

--- Rebuilds `text` with `gaps` spaces between each character.
local function tracked(text: string, gaps: number): string
	local spacer = string.rep(" ", math.max(math.floor(gaps), 0))
	return table.concat(string.split(text, ""), spacer)
end

local function buildGrid(parent: Instance)
	local grid = Create("Frame", {
		Name = "Grid",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		Parent = parent,
	})

	for index = 1, 15 do
		Create("Frame", {
			Name = `V{index}`,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(index / 16, 0),
			Size = UDim2.new(0, 1, 1, 0),
			BackgroundColor3 = Theme.Color.Accent,
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			Parent = grid,
		})
	end

	for index = 1, 8 do
		Create("Frame", {
			Name = `H{index}`,
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromScale(0, index / 9),
			Size = UDim2.new(1, 0, 0, 1),
			BackgroundColor3 = Theme.Color.Accent,
			BackgroundTransparency = 1,
			BorderSizePixel = 0,
			Parent = grid,
		})
	end

	return grid
end

--[[
	Runs the dive. Pass `onDone` to be told when it has finished.

	THE TOKEN IS NOT OPTIONAL. The sequence is a straight-line coroutine holding
	direct references to the parts it built, and its last act is to hide the
	layer and call `onDone`. A second `play()` — the admin replay command, a
	double-fired boot — used to leave the first one running: it would reach
	`root:ClearAllChildren()` and destroy the *new* sequence's elements
	mid-animation, then fire `onDone` a second time and shove the player back to
	the title screen from wherever they had already got to, which on a slow
	machine could be halfway through character creation.

	Now every run takes a token, and any run that is no longer the current one
	stops at its next beat and touches nothing.
]]
local runToken = 0

--- Returns a signal-ish: pass onDone, or ignore it and rely on timing.
function BootSequence.play(onDone: (() -> ())?)
	if not root then
		return
	end

	runToken += 1
	local token = runToken

	--- True while this run is still the one that should be drawing.
	local function current(): boolean
		return token == runToken
	end

	root.Visible = true
	root:ClearAllChildren()

	local scrim = Create("Frame", {
		Name = "Scrim",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.Color.Void,
		BorderSizePixel = 0,
		Parent = root,
	})

	local grid = buildGrid(scrim)

	-- Centre crosshair
	local hLine = Create("Frame", {
		Name = "HLine",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(0, 1),
		BackgroundColor3 = Theme.Color.Accent,
		BorderSizePixel = 0,
		Parent = scrim,
	})

	local vLine = Create("Frame", {
		Name = "VLine",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(1, 0),
		BackgroundColor3 = Theme.Color.Accent,
		BackgroundTransparency = 0.5,
		BorderSizePixel = 0,
		Parent = scrim,
	})

	local titleLabel = Create("TextLabel", {
		Name = "Title",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.46),
		Size = UDim2.fromScale(0.9, 0.12),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "",
		TextSize = 46,
		TextColor3 = Theme.Color.Text,
		TextTransparency = 1,
		Parent = scrim,
	})

	local syncLabel = Create("TextLabel", {
		Name = "Sync",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.58),
		Size = UDim2.fromScale(0.6, 0.04),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = tracked("ESTABLISHING NEURAL LINK", 1),
		TextSize = 13,
		TextColor3 = Theme.Color.AccentDim,
		TextTransparency = 1,
		Parent = scrim,
	})

	local track = Create("Frame", {
		Name = "SyncTrack",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.63),
		Size = UDim2.fromOffset(360, 2),
		BackgroundColor3 = Theme.Color.Stroke,
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Parent = scrim,
	})

	local fill = Create("Frame", {
		Name = "SyncFill",
		Size = UDim2.fromScale(0, 1),
		BackgroundColor3 = Theme.Color.Accent,
		BorderSizePixel = 0,
		Parent = track,
	})

	local flash = Create("Frame", {
		Name = "Flash",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ZIndex = 20,
		Parent = scrim,
	})

	task.spawn(function()
		-- 1. Crosshair opens.
		task.wait(0.35)
		if not current() then
			return
		end
		Motion.to(hLine, 0.7, { Size = UDim2.fromScale(1, 0) }, Theme.Ease.Enter)
		task.wait(0.18)
		Motion.to(vLine, 0.7, { Size = UDim2.new(0, 1, 1, 0) }, Theme.Ease.Enter)

		-- 2. Grid resolves faintly underneath.
		task.wait(0.25)
		if not current() then
			return
		end
		for _, line in grid:GetChildren() do
			Motion.to(line, 0.9, { BackgroundTransparency = 0.93 }, Theme.Ease.Standard, math.random() * 0.35)
		end

		-- 3. Rings pulse outward.
		task.wait(0.15)
		for index = 1, 4 do
			task.delay((index - 1) * 0.16, function()
				if not current() or not root.Visible then
					return
				end
				local ring = Create("Frame", {
					Name = `Ring{index}`,
					AnchorPoint = Vector2.new(0.5, 0.5),
					Position = UDim2.fromScale(0.5, 0.5),
					Size = UDim2.fromOffset(0, 0),
					BackgroundTransparency = 1,
					Parent = scrim,

					Create("UICorner", { CornerRadius = UDim.new(1, 0) }),
					Create("UIStroke", { Color = Theme.Color.Accent, Thickness = 1, Transparency = 0.25 }),
				})
				Motion.to(ring, 1.5, { Size = UDim2.fromOffset(920, 920) }, Theme.Ease.Exit)
				Motion.to(ring.UIStroke, 1.5, { Transparency = 1 }, Enum.EasingStyle.Quad)
				task.delay(1.6, function()
					ring:Destroy()
				end)
			end)
		end

		-- 4. Sync bar fills.
		task.wait(0.3)
		if not current() then
			return
		end
		Motion.to(syncLabel, 0.4, { TextTransparency = 0 })
		Motion.to(track, 0.4, { BackgroundTransparency = 0.6 })
		Motion.to(fill, 1.5, { Size = UDim2.fromScale(1, 1) }, Enum.EasingStyle.Quad)

		task.wait(1.6)
		if not current() then
			return
		end
		syncLabel.Text = tracked("LINK ESTABLISHED", 1)
		syncLabel.TextColor3 = Theme.Color.Accent

		-- 5. Title condenses out of letterspacing.
		task.wait(0.25)
		if not current() then
			return
		end
		titleLabel.TextTransparency = 1
		Motion.to(titleLabel, 0.9, { TextTransparency = 0 }, Theme.Ease.Standard)
		Motion.drive(1.0, function(alpha)
			if not current() then
				return
			end
			-- Ease the tracking so most of the collapse happens early and it
			-- settles gently, matching the fade.
			local eased = 1 - (1 - alpha) ^ 3
			titleLabel.Text = tracked(TITLE, (1 - eased) * MAX_TRACKING)
		end, function()
			titleLabel.Text = TITLE
		end)

		-- Crosshair retreats as the title takes over.
		Motion.to(hLine, 0.8, { BackgroundTransparency = 1 })
		Motion.to(vLine, 0.8, { BackgroundTransparency = 1 })
		Motion.to(syncLabel, 0.6, { TextTransparency = 0.4 }, Theme.Ease.Standard, 0.5)

		-- 6. Flash and dissolve.
		task.wait(1.5)
		if not current() then
			return
		end
		Motion.to(flash, 0.12, { BackgroundTransparency = 0.15 }, Enum.EasingStyle.Quad)
		task.wait(0.14)
		Motion.to(flash, 0.5, { BackgroundTransparency = 1 }, Enum.EasingStyle.Quad)
		Motion.to(scrim, 0.5, { BackgroundTransparency = 1 })
		Motion.to(titleLabel, 0.35, { TextTransparency = 1 })
		Motion.to(syncLabel, 0.35, { TextTransparency = 1 })
		for _, line in grid:GetChildren() do
			Motion.to(line, 0.35, { BackgroundTransparency = 1 })
		end

		task.wait(0.55)
		if not current() then
			return
		end

		root.Visible = false
		root:ClearAllChildren()

		if onDone then
			onDone()
		end
	end)
end

function BootSequence.start()
	root = Layer.root("Boot", "BootSequence")
	root.Visible = false
end

return BootSequence
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="138">
              <Properties>
                <string name="Name">BossBar</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	BossBar — top-centre encounter readout.

	Appears when UIState.boss is set, hides when it's cleared. Unique bosses
	get gold treatment; regular ones get the standard stroke.

	Phase pips sit above the bar rather than dividing it, so the bar itself
	stays a clean unbroken read of remaining health.

	Boss shape:
	    { name, title, health, maxHealth, phase, maxPhase, unique }
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Bar = require(script.Parent.Parent.Components.Bar)
local Layer = require(script.Parent.Parent.Core.Layer)
local UIState = require(script.Parent.Parent.Core.UIState)

local WIDTH = 720
local TOP_MARGIN = 46

local BossBar = {}
local trove = Trove.new()

local container: Frame
local nameLabel: TextLabel
local titleLabel: TextLabel
local healthBar
local pipRow: Frame
local pips: { Frame } = {}
local visible = false

local function setPips(count: number, current: number, color: Color3)
	for _, pip in pips do
		pip:Destroy()
	end
	table.clear(pips)

	if count <= 1 then
		return
	end

	for index = 1, count do
		local spent = index > current
		local pip = Create("Frame", {
			Name = `Pip{index}`,
			Size = UDim2.fromOffset(26, 3),
			BackgroundColor3 = if spent then Theme.Color.TextDim else color,
			BackgroundTransparency = if spent then 0.4 else 0,
			BorderSizePixel = 0,
			LayoutOrder = index,
			Parent = pipRow,

			Create("UICorner", { CornerRadius = Theme.Radius.pill }),
		})
		table.insert(pips, pip)
	end
end

local function show()
	if visible then
		return
	end
	visible = true

	container.Visible = true
	container.Position = UDim2.new(0.5, 0, 0, TOP_MARGIN - 18)
	Motion.to(container, Theme.Time.Slow, {
		Position = UDim2.new(0.5, 0, 0, TOP_MARGIN),
	}, Theme.Ease.Enter)

	for _, descendant in container:GetDescendants() do
		if descendant:IsA("TextLabel") then
			descendant.TextTransparency = 1
			Motion.to(descendant, Theme.Time.Slow, { TextTransparency = 0 })
		end
	end
end

local function hide()
	if not visible then
		return
	end
	visible = false

	local tween = Motion.to(container, Theme.Time.Base, {
		Position = UDim2.new(0.5, 0, 0, TOP_MARGIN - 14),
	}, Theme.Ease.Exit)

	for _, descendant in container:GetDescendants() do
		if descendant:IsA("TextLabel") then
			Motion.to(descendant, Theme.Time.Base, { TextTransparency = 1 })
		end
	end

	tween.Completed:Once(function()
		if not visible then
			container.Visible = false
		end
	end)
end

function BossBar.start()
	local root = Layer.root("HUD", "BossBar")
	trove:add(root)

	container = Create("Frame", {
		Name = "Container",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, TOP_MARGIN),
		Size = UDim2.fromOffset(WIDTH, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		Visible = false,
		Parent = root,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			Padding = UDim.new(0, 5),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	titleLabel = Create("TextLabel", {
		Name = "Title",
		Size = UDim2.new(1, 0, 0, 14),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.4,
		LayoutOrder = 1,
		Parent = container,
	})

	nameLabel = Create("TextLabel", {
		Name = "BossName",
		Size = UDim2.new(1, 0, 0, 30),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "",
		TextSize = 25,
		TextColor3 = Theme.Color.Text,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.3,
		LayoutOrder = 2,
		Parent = container,
	})

	pipRow = Create("Frame", {
		Name = "Phases",
		Size = UDim2.new(1, 0, 0, 6),
		BackgroundTransparency = 1,
		LayoutOrder = 3,
		Parent = container,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			VerticalAlignment = Enum.VerticalAlignment.Center,
			Padding = UDim.new(0, 5),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	healthBar = Bar.new({
		Parent = container,
		Size = UDim2.fromOffset(WIDTH, 18),
		Color = Theme.Color.Health,
		ChipColor = Theme.Color.HealthChip,
		Radius = Theme.Radius.sm,
		LayoutOrder = 4,
	})
	trove:add(healthBar)

	trove:add(UIState.observe("boss", function(boss)
		if not boss then
			hide()
			return
		end

		local accent = if boss.unique then Theme.Color.Unique else Theme.Color.Text

		nameLabel.Text = string.upper(boss.name or "UNKNOWN")
		nameLabel.TextColor3 = accent

		titleLabel.Text = if boss.title then string.upper(boss.title) else ""
		titleLabel.Visible = boss.title ~= nil
		titleLabel.TextColor3 = if boss.unique then Theme.Color.Unique else Theme.Color.TextMuted

		setPips(boss.maxPhase or 1, boss.phase or 1, accent)
		healthBar:setColor(if boss.unique then Theme.Color.Unique else Theme.Color.Health, not visible)

		local wasHidden = not visible
		show()
		healthBar:setValue(boss.health or 0, boss.maxHealth or 1, wasHidden)
	end))
end

function BossBar.stop()
	trove:clean()
	table.clear(pips)
	visible = false
end

return BossBar
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="139">
              <Properties>
                <string name="Name">CharacterCreate</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	CharacterCreate — name, race, combat style, and what you look like.

	Three columns: identity on the left, styles in the middle, and a detail
	pane on the right that reacts to whatever is highlighted. Nothing is
	hidden behind a confirm step, so the consequences of a pick are visible
	while you're still making it.

	Style is the load-bearing choice -- it decides how the game plays -- so it
	gets the largest, most central real estate, and its difficulty is stated
	outright rather than implied by stat bars.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Characters = require(ReplicatedStorage.Shared.Config.Characters)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Button = require(script.Parent.Parent.Components.Button)
local Choice = require(script.Parent.Parent.Components.Choice)
local Layer = require(script.Parent.Parent.Core.Layer)
local TextField = require(script.Parent.Parent.Components.TextField)

local DIFFICULTY_COLORS = {
	Standard = Theme.Color.Success,
	Demanding = Theme.Color.StaminaEmpty,
	Punishing = Theme.Color.Danger,
}

local NAME_PATTERN = "^[%w_]+$"

local CharacterCreate = {}
local trove = Trove.new()

local root: Frame
local group: Frame
local originChoices: { [string]: any } = {}
local Appearance = require(ReplicatedStorage.Shared.Config.Appearance)

local raceChoices: { [string]: any } = {}
local styleChoices: { [string]: any } = {}
local detailPane: Frame
local beginButton
local nameField

local selectedOrigin = Characters.Origins[1].id
local selectedRace = Characters.Races[1].id
local selectedStyle = Characters.Styles[2].id -- Longsword: the sane default

--[[
	Appearance.

	Four swatch rows rather than a colour wheel: a short curated palette all
	reads well against this game's dim blue-grey world, and a free picker
	mostly produces mud. Skin follows the chosen race until the player touches
	it, after which it stays put — a default that keeps overriding you is worse
	than no default.
]]
local selectedLook = { skin = nil, hair = "black", outfit = "traveller", build = "average" }
local skinTouched = false
local swatchButtons: { [string]: { [string]: TextButton } } = { skin = {}, hair = {}, outfit = {} }
local buildButtons: { [string]: TextButton } = {}
--- Forward declaration: selectRace re-syncs the skin default and is defined
--- above the swatch helpers.
local syncSwatches: () -> ()
local shown = false
--- Latched by Begin, so the character can only ever be submitted once.
local begun = false

--------------------------------------------------------------------------------

local function validateName(text: string): (boolean, string?)
	if #text == 0 then
		return false, "Required"
	end
	if #text < 3 then
		return false, "At least 3 characters"
	end
	if not string.match(text, NAME_PATTERN) then
		return false, "Letters, numbers and underscores only"
	end
	return true, "This is the name other players will see"
end

local function refreshBeginState()
	if beginButton then
		beginButton:setEnabled(nameField ~= nil and nameField.Valid)
	end
end

--------------------------------------------------------------------------------
-- Detail pane
--------------------------------------------------------------------------------

local function meter(parent: Instance, label: string, value: number, order: number)
	local row = Create("Frame", {
		Name = label,
		Size = UDim2.new(1, 0, 0, 26),
		BackgroundTransparency = 1,
		LayoutOrder = order,
		Parent = parent,
	})

	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 13),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = label,
		TextSize = 11,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = row,
	})

	local track = Create("Frame", {
		Position = UDim2.fromOffset(0, 17),
		Size = UDim2.new(1, 0, 0, 3),
		BackgroundColor3 = Theme.Color.Stroke,
		BackgroundTransparency = 0.5,
		BorderSizePixel = 0,
		Parent = row,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	local fill = Create("Frame", {
		Size = UDim2.fromScale(0, 1),
		BackgroundColor3 = Theme.Color.Accent,
		BorderSizePixel = 0,
		Parent = track,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	Motion.to(fill, Theme.Time.Slow, { Size = UDim2.fromScale(value, 1) }, Theme.Ease.Enter)
end

local function renderDetail()
	for _, child in detailPane:GetChildren() do
		if not child:IsA("UIListLayout") and not child:IsA("UIPadding") then
			child:Destroy()
		end
	end

	local style = Characters.findStyle(selectedStyle)
	local race = Characters.findRace(selectedRace)
	local origin = Characters.findOrigin(selectedOrigin)
	local order = 0
	local function nextOrder(): number
		order += 1
		return order
	end

	-- Origin leads: it is the only choice here that changes the first hour
	-- rather than a number.
	if origin then
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 22),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = string.upper(origin.name),
			TextSize = 16,
			TextColor3 = if origin.start == "forest" then Theme.Color.Health else Theme.Color.Accent,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detailPane,
		})

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Regular,
			Text = origin.description,
			TextSize = 12,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextWrapped = true,
			LayoutOrder = nextOrder(),
			Parent = detailPane,
		})

		for _, note in origin.notes do
			Create("TextLabel", {
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Medium,
				Text = `— {note}`,
				TextSize = 11,
				TextColor3 = if origin.tutorial then Theme.Color.TextDim else Theme.Color.StaminaEmpty,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextWrapped = true,
				LayoutOrder = nextOrder(),
				Parent = detailPane,
			})
		end

		Create("Frame", {
			Size = UDim2.new(1, 0, 0, 1),
			BackgroundColor3 = Theme.Color.Stroke,
			BackgroundTransparency = 0.5,
			BorderSizePixel = 0,
			LayoutOrder = nextOrder(),
			Parent = detailPane,
		})
	end

	if style then
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 24),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = string.upper(style.name),
			TextSize = 18,
			TextColor3 = Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detailPane,
		})

		local difficultyColor = DIFFICULTY_COLORS[style.difficulty] or Theme.Color.TextMuted
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = string.upper(style.difficulty),
			TextSize = 11,
			TextColor3 = difficultyColor,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detailPane,
		})

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Regular,
			Text = style.description,
			TextSize = 12,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextWrapped = true,
			LayoutOrder = nextOrder(),
			Parent = detailPane,
		})

		for _, rating in style.ratings do
			meter(detailPane, rating.label, rating.value, nextOrder())
		end
	end

	if race then
		Create("Frame", {
			Size = UDim2.new(1, 0, 0, 1),
			BackgroundColor3 = Theme.Color.Stroke,
			BackgroundTransparency = 0.5,
			BorderSizePixel = 0,
			LayoutOrder = nextOrder(),
			Parent = detailPane,
		})

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = string.upper(race.name),
			TextSize = 13,
			TextColor3 = Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detailPane,
		})

		for _, trait in race.traits do
			Create("TextLabel", {
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Medium,
				Text = `— {trait}`,
				TextSize = 11,
				TextColor3 = Theme.Color.TextDim,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextWrapped = true,
				LayoutOrder = nextOrder(),
				Parent = detailPane,
			})
		end
	end
end

local function selectOrigin(id: string)
	selectedOrigin = id
	for originId, choice in originChoices do
		choice:setSelected(originId == id)
	end
	renderDetail()
end

local function selectRace(id: string)
	selectedRace = id
	for raceId, choice in raceChoices do
		choice:setSelected(raceId == id)
	end
	-- Race suggests a skin tone; it stops suggesting once you have chosen one.
	if not skinTouched then
		selectedLook.skin = Appearance.RaceSkin[id] or Appearance.Skin[1].id
		syncSwatches()
	end
	renderDetail()
end

--- One row of colour swatches. `kind` keys both the palette and the state.
local function swatchRow(parent: Instance, kind: string, palette, order: number)
	local row = Create("Frame", {
		Name = kind,
		Size = UDim2.new(1, 0, 0, 30),
		BackgroundTransparency = 1,
		LayoutOrder = order,
		Parent = parent,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			Padding = UDim.new(0, 5),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index, swatch in palette do
		local button = Create("TextButton", {
			Name = swatch.id,
			Size = UDim2.fromOffset(30, 30),
			BackgroundColor3 = swatch.color,
			AutoButtonColor = false,
			BorderSizePixel = 0,
			Text = "",
			LayoutOrder = index,
			Parent = row,

			Create("UICorner", { CornerRadius = Theme.Radius.sm }),
			Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 2, Transparency = 0.5 }),
		})

		button.Activated:Connect(function()
			if kind == "skin" then
				skinTouched = true
			end
			selectedLook[kind] = swatch.id
			-- Selection is a bright ring, never a colour change: the swatch
			-- has to keep showing the colour it applies.
			for id, other in swatchButtons[kind] do
				local stroke = other:FindFirstChildOfClass("UIStroke")
				stroke.Color = if id == swatch.id then Theme.Color.Unique else Theme.Color.Stroke
				stroke.Transparency = if id == swatch.id then 0 else 0.5
			end
		end)

		swatchButtons[kind][swatch.id] = button
	end

	return row
end

--- Highlights the swatch matching current state. Used on first build and
--- whenever race changes the skin default.
function syncSwatches()
	for kind, buttons in swatchButtons do
		for id, button in buttons do
			local stroke = button:FindFirstChildOfClass("UIStroke")
			local active = selectedLook[kind] == id
			stroke.Color = if active then Theme.Color.Unique else Theme.Color.Stroke
			stroke.Transparency = if active then 0 else 0.5
		end
	end
	for id, button in buildButtons do
		local stroke = button:FindFirstChildOfClass("UIStroke")
		local active = selectedLook.build == id
		stroke.Color = if active then Theme.Color.Unique else Theme.Color.Stroke
		stroke.Transparency = if active then 0 else 0.5
		button.TextColor3 = if active then Theme.Color.Text else Theme.Color.TextMuted
	end
end

local function selectStyle(id: string)
	selectedStyle = id
	for styleId, choice in styleChoices do
		choice:setSelected(styleId == id)
	end
	renderDetail()
end

--------------------------------------------------------------------------------

function CharacterCreate.start(onComplete: (character: any) -> ())
	root = Layer.root("Frontend", "CharacterCreate")
	root.Visible = false
	trove:add(root)

	Create("Frame", {
		Name = "Backdrop",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 0.06,
		BorderSizePixel = 0,
		Parent = root,
	})

	group = Create("Frame", {
		Name = "Group",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(1140, 600),
		BackgroundTransparency = 1,
		Parent = root,
	})

	-- Header
	Create("TextLabel", {
		Name = "Heading",
		Size = UDim2.new(1, 0, 0, 28),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "CREATE YOUR AVATAR",
		TextSize = 22,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = group,
	})

	Create("TextLabel", {
		Name = "Subheading",
		Position = UDim2.fromOffset(0, 30),
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "Origin decides where you wake up. Style decides how the game plays.",
		TextSize = 13,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = group,
	})

	local body = Create("Frame", {
		Name = "Body",
		Position = UDim2.fromOffset(0, 70),
		Size = UDim2.new(1, 0, 1, -140),
		BackgroundTransparency = 1,
		Parent = group,
	})

	-- Left: identity
	local identity = Create("ScrollingFrame", {
		Name = "Identity",
		Size = UDim2.new(0, 300, 1, 0),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = body,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 8),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	nameField = TextField.new({
		Parent = identity,
		Label = "Character name",
		Placeholder = "Sunraku",
		MaxLength = 16,
		LayoutOrder = 1,
		Validate = validateName,
		OnChanged = function()
			refreshBeginState()
		end,
	})
	trove:add(nameField)

	----------------------------------------------------------------------
	-- Appearance
	----------------------------------------------------------------------

	local function heading(text: string, order: number)
		Create("TextLabel", {
			Name = text,
			Size = UDim2.new(1, 0, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = text,
			TextSize = 11,
			TextColor3 = Theme.Color.Unique,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = order,
			Parent = identity,
		})
	end

	heading("SKIN", 100)
	swatchRow(identity, "skin", Appearance.Skin, 101)
	heading("HAIR", 102)
	swatchRow(identity, "hair", Appearance.Hair, 103)
	heading("OUTFIT", 104)
	swatchRow(identity, "outfit", Appearance.Outfit, 105)
	heading("BUILD", 106)

	local buildRow = Create("Frame", {
		Name = "Build",
		Size = UDim2.new(1, 0, 0, 32),
		BackgroundTransparency = 1,
		LayoutOrder = 107,
		Parent = identity,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			Padding = UDim.new(0, 5),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index, build in Appearance.Build do
		local button = Create("TextButton", {
			Name = build.id,
			Size = UDim2.fromOffset(94, 32),
			BackgroundColor3 = Theme.Color.PanelRaised,
			BackgroundTransparency = 0.35,
			AutoButtonColor = false,
			BorderSizePixel = 0,
			FontFace = Theme.Font.SemiBold,
			Text = build.name,
			TextSize = 13,
			TextColor3 = Theme.Color.TextMuted,
			LayoutOrder = index,
			Parent = buildRow,

			Create("UICorner", { CornerRadius = Theme.Radius.sm }),
			Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 2, Transparency = 0.5 }),
		})
		button.Activated:Connect(function()
			selectedLook.build = build.id
			syncSwatches()
		end)
		buildButtons[build.id] = button
	end

	-- Race has already been defaulted by now; paint the initial selection.
	selectedLook.skin = selectedLook.skin or Appearance.RaceSkin[selectedRace] or Appearance.Skin[1].id
	syncSwatches()

	Create("TextLabel", {
		Name = "OriginHeading",
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "ORIGIN",
		TextSize = 11,
		TextColor3 = Theme.Color.Unique,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = 2,
		Parent = identity,
	})

	for index, origin in Characters.Origins do
		local choice = Choice.new({
			Parent = identity,
			Variant = "row",
			Title = origin.name,
			Subtitle = origin.tagline,
			AccentColor = if origin.start == "forest" then Theme.Color.Health else Theme.Color.Accent,
			LayoutOrder = 2 + index,
			OnSelect = function()
				selectOrigin(origin.id)
			end,
		})
		originChoices[origin.id] = choice
		trove:add(choice)
	end

	Create("TextLabel", {
		Name = "RaceHeading",
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "RACE",
		TextSize = 11,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = 20,
		Parent = identity,
	})

	for index, race in Characters.Races do
		local choice = Choice.new({
			Parent = identity,
			Variant = "row",
			Title = race.name,
			Subtitle = race.tagline,
			LayoutOrder = 20 + index,
			OnSelect = function()
				selectRace(race.id)
			end,
		})
		raceChoices[race.id] = choice
		trove:add(choice)
	end

	-- Middle: styles
	local styleColumn = Create("Frame", {
		Name = "Styles",
		Position = UDim2.fromOffset(320, 0),
		Size = UDim2.new(0, 490, 1, 0),
		BackgroundTransparency = 1,
		Parent = body,
	})

	Create("TextLabel", {
		Name = "StyleHeading",
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "COMBAT STYLE",
		TextSize = 11,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = styleColumn,
	})

	local styleGrid = Create("Frame", {
		Name = "Grid",
		Position = UDim2.fromOffset(0, 24),
		Size = UDim2.new(1, 0, 1, -24),
		BackgroundTransparency = 1,
		Parent = styleColumn,

		Create("UIGridLayout", {
			CellSize = UDim2.fromOffset(152, 106),
			CellPadding = UDim2.fromOffset(10, 10),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index, style in Characters.Styles do
		local choice = Choice.new({
			Parent = styleGrid,
			Variant = "tile",
			Title = style.name,
			Subtitle = style.tagline,
			Glyph = style.glyph,
			LayoutOrder = index,
			OnSelect = function()
				selectStyle(style.id)
			end,
		})
		styleChoices[style.id] = choice
		trove:add(choice)
	end

	-- Right: detail
	local detailFrame = Create("Frame", {
		Name = "Detail",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.fromScale(1, 0),
		Size = UDim2.new(0, 296, 1, 0),
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.2,
		BorderSizePixel = 0,
		Parent = body,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.5 }),
	})

	detailPane = Create("ScrollingFrame", {
		Name = "DetailContent",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 2,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = detailFrame,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 7),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", {
			PaddingTop = UDim.new(0, 16),
			PaddingLeft = UDim.new(0, 16),
			PaddingRight = UDim.new(0, 16),
			PaddingBottom = UDim.new(0, 16),
		}),
	})

	-- Footer
	beginButton = Button.new({
		Parent = group,
		Text = "Begin",
		Variant = "primary",
		Size = UDim2.fromOffset(240, 50),
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.fromScale(1, 1),
		OnClick = function()
			if not nameField.Valid then
				return
			end
			--[[
				Debounced, and this one matters most in the whole front-end.

				`onComplete` fires `CharacterChosen` and `OriginChosen` at the
				server, which builds an avatar model, welds it to the character
				and routes the spawn. A second click before the hide tween
				finishes did all of that twice — two avatar models welded to one
				character, and the spawn routed twice — which is exactly the
				kind of thing that looks like the game is broken from the very
				first second of play.
			]]
			if begun then
				return
			end
			begun = true
			onComplete({
				name = nameField:getText(),
				origin = selectedOrigin,
				race = selectedRace,
				style = selectedStyle,
				appearance = {
					skin = selectedLook.skin,
					hair = selectedLook.hair,
					outfit = selectedLook.outfit,
					build = selectedLook.build,
				},
			})
		end,
	})
	trove:add(beginButton)

	Create("TextLabel", {
		Name = "Footnote",
		AnchorPoint = Vector2.new(0, 1),
		Position = UDim2.fromScale(0, 1),
		Size = UDim2.fromOffset(600, 40),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "Style can be respecced later. Race cannot.",
		TextSize = 12,
		TextColor3 = Theme.Color.TextDim,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextYAlignment = Enum.TextYAlignment.Bottom,
		Parent = group,
	})

	selectOrigin(selectedOrigin)
	selectRace(selectedRace)
	selectStyle(selectedStyle)
	refreshBeginState()
end

function CharacterCreate.show()
	if shown then
		return
	end
	shown = true
	-- Re-armed on every entry, so the admin replay can create again.
	begun = false

	root.Visible = true
	group.Position = UDim2.fromScale(0.5, 0.52)
	Motion.to(group, Theme.Time.Deliberate, { Position = UDim2.fromScale(0.5, 0.5) }, Theme.Ease.Enter)

	task.delay(0.35, function()
		if shown and nameField then
			nameField:focus()
		end
	end)
end

function CharacterCreate.hide()
	if not shown then
		return
	end
	shown = false

	local tween = Motion.to(group, Theme.Time.Slow, { Position = UDim2.fromScale(0.5, 0.47) }, Theme.Ease.Exit)
	tween.Completed:Once(function()
		if not shown then
			root.Visible = false
		end
	end)
end

function CharacterCreate.stop()
	trove:clean()
	table.clear(originChoices)
	table.clear(raceChoices)
	table.clear(styleChoices)
	shown = false
end

return CharacterCreate
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="140">
              <Properties>
                <string name="Name">Controls</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Controls — the keys, on screen, where you can see them.

	Roblox's own onboarding guidance leads with "display navigation and
	interaction controls prominently on-screen", and this game had no such
	display at all: the only place a control was ever named was one line of
	tutorial dialogue that scrolled past once. A player who forgets which key
	blocks has no way to find out, and the honest outcome of that is they stop
	blocking.

	IT TEACHES ITSELF OUT OF THE WAY. Each key starts lit; using it once dims
	that row permanently. Once every key has been used the whole panel fades to
	a corner hint, because a control list that is still shouting at hour three
	is clutter. Nothing here is modal, nothing pauses, and it can be toggled
	back with H at any time.

	The keys are read from one table so this panel and the actual bindings can
	never disagree — the previous version of this information lived in a
	comment, and the comment was wrong the moment block moved off right mouse.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")

local Bindings = require(ReplicatedStorage.Shared.Config.Bindings)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)
local UIState = require(script.Parent.Parent.Core.UIState)

local Controls = {}

--[[
	The rows come from `Config/Bindings`, which is also what the game actually
	binds. They used to be a hand-written table here, and the whole point of
	this panel is that it cannot be wrong — a list of controls that has drifted
	from the controls is worse than no list, because a player who tries the
	printed key and gets nothing concludes the mechanic is broken rather than
	the label. It had already drifted once, when block moved off right mouse.

	GLYPHS FOLLOW THE DEVICE. Pick up a controller mid-session and every chip
	on this panel becomes its gamepad button on the next input; touch the
	keyboard and they change back. The panel is the only place most players
	will ever learn the pad layout, so it has to be honest about which device
	is in their hands right now.
]]
local ACTIONS = Bindings.panel()

--- True while the last input came from a gamepad.
local gamepad = false

local root: Frame
local panel: Frame
local rows: { [string]: { holder: Frame, key: TextLabel, label: TextLabel } } = {}
local used: { [string]: boolean } = {}
local visible = true
local collapsed = false

--------------------------------------------------------------------------------

local function dim(entry)
	local row = rows[entry.id]
	if not row or used[entry.id] then
		return
	end
	used[entry.id] = true

	-- Learned keys recede rather than vanish: still legible if you look, no
	-- longer competing for attention.
	Motion.to(row.key, Theme.Time.Base, { TextTransparency = 0.55, BackgroundTransparency = 0.85 })
	Motion.to(row.label, Theme.Time.Base, { TextTransparency = 0.6 })

	-- Everything learned: shrink to a single reminder line.
	local total, learned = 0, 0
	for _, action in ACTIONS do
		total += 1
		if used[action.id] then
			learned += 1
		end
	end
	if learned >= total - 1 and not collapsed then
		collapsed = true
		task.delay(3, function()
			if collapsed then
				Motion.to(panel, Theme.Time.Slow, { BackgroundTransparency = 1 })
				for _, r in rows do
					Motion.to(r.key, Theme.Time.Slow, { TextTransparency = 1, BackgroundTransparency = 1 })
					Motion.to(r.label, Theme.Time.Slow, { TextTransparency = 1 })
				end
			end
		end)
	end
end

function Controls.setVisible(state: boolean)
	visible = state
	collapsed = false
	root.Visible = state

	if state then
		panel.BackgroundTransparency = 0.15
		for key, row in rows do
			local learned = used[key]
			row.key.TextTransparency = if learned then 0.55 else 0
			row.key.BackgroundTransparency = if learned then 0.85 else 0.25
			row.label.TextTransparency = if learned then 0.6 else 0.15
		end

		-- Rows arrive in order rather than the whole block landing at once.
		-- This panel is toggled often enough that a hard cut is noticeable, and
		-- a sequence reads as a list being offered rather than dumped.
		Motion.stagger(Motion.rows(panel))
	end
end

--------------------------------------------------------------------------------

function Controls.start()
	root = Layer.root("HUD", "Controls")

	panel = Create("Frame", {
		Name = "Panel",
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(1, -26, 1, -26),
		Size = UDim2.fromOffset(216, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.15,
		BorderSizePixel = 0,
		Parent = root,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.35 }),
		Create("UIPadding", {
			PaddingLeft = UDim.new(0, 12),
			PaddingRight = UDim.new(0, 12),
			PaddingTop = UDim.new(0, 10),
			PaddingBottom = UDim.new(0, 10),
		}),
		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 5),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	Create("TextLabel", {
		Name = "Heading",
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "CONTROLS",
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = 0,
		Parent = panel,
	})

	for index, action in ACTIONS do
		local holder = Create("Frame", {
			Name = action.id,
			Size = UDim2.new(1, 0, 0, 22),
			BackgroundTransparency = 1,
			LayoutOrder = index,
			Parent = panel,
		})

		-- The key gets a chip; the action gets plain text. A player scanning
		-- for "how do I block" is looking for the word, then the chip beside it.
		local keyLabel = Create("TextLabel", {
			Name = "Key",
			Size = UDim2.fromOffset(74, 22),
			BackgroundColor3 = if action.accent then Theme.Color.AccentDim else Theme.Color.PanelRaised,
			BackgroundTransparency = 0.25,
			BorderSizePixel = 0,
			FontFace = Theme.Font.Bold,
			Text = Bindings.glyph(action.id, gamepad),
			TextSize = 13,
			TextColor3 = if action.accent then Theme.Color.Accent else Theme.Color.Text,
			Parent = holder,

			Create("UICorner", { CornerRadius = Theme.Radius.sm }),
		})

		local textLabel = Create("TextLabel", {
			Name = "Label",
			Position = UDim2.fromOffset(82, 0),
			Size = UDim2.new(1, -82, 1, 0),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = action.text,
			TextSize = 14,
			TextColor3 = Theme.Color.Text,
			TextTransparency = 0.15,
			TextXAlignment = Enum.TextXAlignment.Left,
			Parent = holder,
		})

		rows[action.id] = { holder = holder, key = keyLabel, label = textLabel }
	end

	----------------------------------------------------------------------

	UserInputService.InputBegan:Connect(function(input, processed)
		if Bindings.matches("controls", input) and not processed then
			Controls.setVisible(not visible)
			return
		end
		if processed or not visible then
			return
		end

		-- One matcher for both devices, so learning a control on a pad dims the
		-- same row that learning it on a keyboard would.
		for _, action in ACTIONS do
			if Bindings.matches(action.id, input) then
				dim(action)
			end
		end
	end)

	----------------------------------------------------------------------

	--[[
		Repaint the chips when the device changes.

		Debounced through a stored flag rather than repainting on every input,
		because `LastInputTypeChanged` also fires for mouse movement and this
		would otherwise rewrite ten TextLabels several times a second.
	]]
	UserInputService.LastInputTypeChanged:Connect(function(inputType)
		local isPad = inputType.Name:sub(1, 7) == "Gamepad"
		local isKeyboardOrMouse = inputType == Enum.UserInputType.Keyboard
			or inputType == Enum.UserInputType.MouseButton1
			or inputType == Enum.UserInputType.MouseButton2

		if isPad == gamepad or not (isPad or isKeyboardOrMouse) then
			return
		end
		gamepad = isPad

		for _, action in ACTIONS do
			local row = rows[action.id]
			if row then
				row.key.Text = Bindings.glyph(action.id, gamepad)
			end
		end
	end)

	-- Hidden while a menu or the front-end owns the screen.
	UIState.observe("phase", function(phase)
		root.Visible = visible and phase == "playing"
	end)
	UIState.observe("menuOpen", function(open)
		root.Visible = visible and not open and UIState.get("phase") == "playing"
	end)
end

return Controls
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="141">
              <Properties>
                <string name="Name">Craft</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Craft — Pencilgon's bench.

	One list. Each row shows what the recipe wants, how much of it you have,
	and what the bench charges. Ingredients you're short of go red *on the
	ingredient*, not just on the row — "you can't make this" is useless
	information compared to "you need three more hides".

	Locked recipes stay visible with their unlock condition shown. A crafting
	list that hides everything you can't make yet is a list that never tells
	you why any material is worth keeping, which is the entire job it has.

	Every row is rendered from what the server sent. The client never decides
	whether a craft is possible.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)
local Panel = require(script.Parent.Parent.Components.Panel)

local Craft = {}

local panel
local goldLabel: TextLabel
local list: ScrollingFrame
local current: any = nil

Craft.isOpen = false

local function clear()
	for _, child in list:GetChildren() do
		if child:IsA("GuiObject") then
			child:Destroy()
		end
	end
end

local function recipeRow(recipe, order: number, npcId: string)
	local rarity = Theme.Rarity[recipe.rarity] or Theme.Rarity.Common
	local locked = not recipe.unlocked

	local row = Create("TextButton", {
		Name = recipe.id,
		Size = UDim2.new(1, -8, 0, 92),
		BackgroundColor3 = Theme.Color.PanelRaised,
		BackgroundTransparency = if locked then 0.72 else 0.38,
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Text = "",
		LayoutOrder = order,
		Parent = list,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", {
			Color = if recipe.ready then rarity.Color else Theme.Color.Stroke,
			Thickness = 1,
			Transparency = if recipe.ready then 0.1 else 0.6,
		}),

		Create("Frame", {
			Name = "Marker",
			Size = UDim2.new(0, 3, 1, 0),
			BackgroundColor3 = rarity.Color,
			BackgroundTransparency = if locked then 0.6 else 0,
			BorderSizePixel = 0,
		}),

		Create("TextLabel", {
			Name = "Name",
			Position = UDim2.fromOffset(14, 8),
			Size = UDim2.new(1, -150, 0, 19),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = recipe.name,
			TextSize = 16,
			TextColor3 = if locked then Theme.Color.TextDim else Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
		}),

		Create("TextLabel", {
			Name = "Kind",
			Position = UDim2.fromOffset(14, 27),
			Size = UDim2.new(1, -150, 0, 15),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = `{rarity.Label}  ·  {recipe.slot}`,
			TextSize = 11,
			TextColor3 = rarity.Color,
			TextXAlignment = Enum.TextXAlignment.Left,
		}),

		Create("TextLabel", {
			Name = "Fee",
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, -14, 0, 8),
			Size = UDim2.fromOffset(120, 19),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = `{recipe.fee} G`,
			TextSize = 15,
			TextColor3 = if recipe.ready then Theme.Color.Unique else Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Right,
		}),

		Create("TextLabel", {
			Name = "Body",
			Position = UDim2.fromOffset(14, 44),
			Size = UDim2.new(1, -28, 0, 15),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Regular,
			Text = if locked then (recipe.lockedBy or "Locked") else recipe.description,
			TextSize = 11,
			TextColor3 = if locked then Theme.Color.Cursed else Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
		}),
	})

	-- Ingredients, each coloured by whether you have enough of that one.
	local strip = Create("Frame", {
		Name = "Needs",
		Position = UDim2.fromOffset(14, 63),
		Size = UDim2.new(1, -28, 0, 20),
		BackgroundTransparency = 1,
		Parent = row,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			VerticalAlignment = Enum.VerticalAlignment.Center,
			Padding = UDim.new(0, 8),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index, need in recipe.needs do
		local enough = need.have >= need.need
		Create("TextLabel", {
			Name = need.id,
			Size = UDim2.fromOffset(0, 18),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundColor3 = if enough then Theme.Color.Success else Theme.Color.Danger,
			BackgroundTransparency = 0.82,
			BorderSizePixel = 0,
			FontFace = Theme.Font.Medium,
			Text = ` {need.name}  {need.have}/{need.need} `,
			TextSize = 11,
			TextColor3 = if enough then Theme.Color.Success else Theme.Color.Danger,
			LayoutOrder = index,
			Parent = strip,

			Create("UICorner", { CornerRadius = Theme.Radius.sm }),
		})
	end

	row.MouseEnter:Connect(function()
		if recipe.ready then
			Audio.play("uiHover")
		end
		row.BackgroundTransparency = if locked then 0.72 else 0.18
	end)
	row.MouseLeave:Connect(function()
		row.BackgroundTransparency = if locked then 0.72 else 0.38
	end)
	row.Activated:Connect(function()
		if recipe.ready then
			Network.Interact:FireServer(npcId, "make", recipe.id)
		else
			Audio.play("uiClose")
		end
	end)
end

local function render(payload)
	current = payload
	goldLabel.Text = `{payload.gold} G`
	clear()

	-- Craftable first, then locked. The list is a to-do list, so what you can
	-- act on now belongs at the top.
	local order = 0
	for _, pass in { true, false } do
		for _, recipe in payload.recipes do
			if recipe.ready == pass then
				order += 1
				recipeRow(recipe, order, payload.npcId)
			end
		end
	end

	list.CanvasSize = UDim2.fromOffset(0, list.UIListLayout.AbsoluteContentSize.Y + 10)
end

function Craft.close()
	if not Craft.isOpen then
		return
	end
	Craft.isOpen = false
	panel:close()
	if current then
		Network.Interact:FireServer(current.npcId, "close")
	end
	current = nil
end

function Craft.start()
	local root = Layer.root("Menu", "Craft")

	panel = Panel.new({
		Parent = root,
		Size = UDim2.fromOffset(660, 500),
		Title = "Bench",
		Subtitle = "",
		Scrim = true,
		AccentColor = Theme.Color.Unique,
		OnClose = function()
			Craft.close()
		end,
	})

	goldLabel = Create("TextLabel", {
		Name = "Gold",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, -54, 0, Theme.Space.lg),
		Size = UDim2.fromOffset(130, 24),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "0 G",
		TextSize = 18,
		TextColor3 = Theme.Color.Unique,
		TextXAlignment = Enum.TextXAlignment.Right,
		ZIndex = 4,
		Parent = panel.Window,
	})

	list = Create("ScrollingFrame", {
		Name = "Recipes",
		Position = UDim2.fromOffset(Theme.Space.lg, 0),
		Size = UDim2.new(1, -Theme.Space.lg * 2, 1, -Theme.Space.lg),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		Parent = panel.Content,

		Create("UIListLayout", {
			Padding = UDim.new(0, 7),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	Network.Craft.OnClientEvent:Connect(function(payload)
		if not payload then
			if Craft.isOpen then
				Craft.isOpen = false
				current = nil
				panel:close()
			end
			return
		end

		panel:setTitle(payload.name)
		panel:setSubtitle(payload.greeting or "")
		render(payload)

		if not Craft.isOpen then
			Craft.isOpen = true
			panel:open()
		end
	end)
end

return Craft
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="142">
              <Properties>
                <string name="Name">DeathScreen</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	DeathScreen — full-bleed death state.

	Deliberately unhurried and un-punishing in tone. In a game built around
	learning a boss's pattern you will die constantly, and a screen that
	scolds you gets old by the tenth attempt. So: a running death count worn
	as a badge rather than a scoreboard, and a respawn button that's ready
	fast.

	Driven by UIState.dead.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Button = require(script.Parent.Parent.Components.Button)
local Layer = require(script.Parent.Parent.Core.Layer)
local UIState = require(script.Parent.Parent.Core.UIState)

-- Shown under the death count. Index by how many times you've died.
local FLAVOUR = {
	[0] = "Shake it off.",
	[3] = "You're reading it now.",
	[10] = "Attempt ten. The pattern is there.",
	[25] = "Twenty-five. Most players quit at six.",
	[50] = "Fifty deaths. This is a lifestyle.",
	[100] = "One hundred. Respect.",
}

local DeathScreen = {}
local trove = Trove.new()

local root: Frame
local scrim: Frame
local headline: TextLabel
local countLabel: TextLabel
local flavourLabel: TextLabel
local respawnButton
local shown = false

local function flavourFor(deaths: number): string
	local best = FLAVOUR[0]
	local bestKey = 0
	for key, text in FLAVOUR do
		if deaths >= key and key >= bestKey then
			best, bestKey = text, key
		end
	end
	return best
end

local function setShown(visible: boolean)
	if shown == visible then
		return
	end
	shown = visible

	if visible then
		local deaths = (UIState.get("deaths") or 0) + 1
		UIState.set("deaths", deaths)

		countLabel.Text = `DEATH {deaths}`
		flavourLabel.Text = flavourFor(deaths)

		root.Visible = true
		scrim.BackgroundTransparency = 1
		headline.TextTransparency = 1
		headline.TextSize = 76

		Motion.to(scrim, Theme.Time.Deliberate, { BackgroundTransparency = 0.25 })
		Motion.to(headline, 1.1, { TextTransparency = 0, TextSize = 64 }, Theme.Ease.Exit)

		for _, label in { countLabel, flavourLabel } do
			label.TextTransparency = 1
			Motion.to(label, Theme.Time.Deliberate, { TextTransparency = 0 }, Theme.Ease.Standard, 0.5)
		end

		respawnButton.Root.Visible = false
		task.delay(1.1, function()
			if shown then
				respawnButton.Root.Visible = true
				respawnButton.Root.BackgroundTransparency = 1
				Motion.to(respawnButton.Root, Theme.Time.Slow, { BackgroundTransparency = 0 })
			end
		end)
	else
		local tween = Motion.to(scrim, Theme.Time.Slow, { BackgroundTransparency = 1 })
		Motion.to(headline, Theme.Time.Base, { TextTransparency = 1 })
		Motion.to(countLabel, Theme.Time.Base, { TextTransparency = 1 })
		Motion.to(flavourLabel, Theme.Time.Base, { TextTransparency = 1 })

		tween.Completed:Once(function()
			if not shown then
				root.Visible = false
			end
		end)
	end
end

function DeathScreen.start()
	root = Layer.root("Fullscreen", "DeathScreen")
	root.Visible = false
	trove:add(root)

	scrim = Create("Frame", {
		Name = "Scrim",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Parent = root,
	})

	local column = Create("Frame", {
		Name = "Column",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(620, 260),
		BackgroundTransparency = 1,
		Parent = root,
	})

	headline = Create("TextLabel", {
		Name = "Headline",
		Position = UDim2.fromScale(0, 0),
		Size = UDim2.new(1, 0, 0, 80),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "YOU DIED",
		TextSize = 64,
		TextColor3 = Theme.Color.Health,
		TextTransparency = 1,
		Parent = column,
	})

	countLabel = Create("TextLabel", {
		Name = "Count",
		Position = UDim2.fromOffset(0, 90),
		Size = UDim2.new(1, 0, 0, 22),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 16,
		TextColor3 = Theme.Color.TextMuted,
		TextTransparency = 1,
		Parent = column,
	})

	flavourLabel = Create("TextLabel", {
		Name = "Flavour",
		Position = UDim2.fromOffset(0, 116),
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 14,
		TextColor3 = Theme.Color.TextDim,
		TextTransparency = 1,
		Parent = column,
	})

	respawnButton = Button.new({
		Parent = column,
		Text = "Respawn",
		Variant = "primary",
		AccentColor = Theme.Color.Accent,
		Size = UDim2.fromOffset(220, 46),
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 172),
		OnClick = function()
			UIState.patch({
				dead = false,
				health = UIState.get("maxHealth"),
				stamina = UIState.get("maxStamina"),
				boss = nil,
			})
		end,
	})
	trove:add(respawnButton)

	trove:add(UIState.observe("dead", function(dead)
		setShown(dead == true)
	end))
end

function DeathScreen.stop()
	trove:clean()
	shown = false
end

return DeathScreen
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="143">
              <Properties>
                <string name="Name">Dialogue</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Dialogue — the conversation panel.

	Sits along the bottom of the screen rather than in the middle, so the
	person you're talking to stays visible while they talk. Options are real
	buttons; quest offers and hand-ins are highlighted in the Unique colour,
	which is the same signal the game uses for anything that matters.

	Everything shown is exactly what the server sent — the client never decides
	who will talk to it or what they'll offer.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Button = require(script.Parent.Parent.Components.Button)
local Layer = require(script.Parent.Parent.Core.Layer)

local WIDTH = 700
local HEIGHT = 214

local Dialogue = {}

local root: Frame
local card: Frame
local speaker: TextLabel
local role: TextLabel
local body: TextLabel
local optionRow: Frame
local buttons: { any } = {}

Dialogue.isOpen = false

local function clearOptions()
	for _, button in buttons do
		button:destroy()
	end
	table.clear(buttons)
end

function Dialogue.close()
	if not Dialogue.isOpen then
		return
	end
	Dialogue.isOpen = false
	clearOptions()

	local tween = Motion.to(card, Theme.Time.Fast, {
		Position = UDim2.new(0.5, 0, 1, 40),
	}, Theme.Ease.Exit)
	tween.Completed:Once(function()
		if not Dialogue.isOpen then
			root.Visible = false
		end
	end)
end

function Dialogue.show(payload)
	if not root then
		return
	end

	clearOptions()

	speaker.Text = payload.name
	role.Text = payload.title or ""
	body.Text = payload.text or ""

	for index, option in payload.options or {} do
		local button = Button.new({
			Parent = optionRow,
			Text = option.label,
			Variant = if option.highlight then "primary" else "ghost",
			AccentColor = if option.highlight then Theme.Color.Unique else Theme.Color.Accent,
			LayoutOrder = index,
			Size = UDim2.fromOffset(0, 40),
			OnClick = function()
				if option.id == "close" then
					Dialogue.close()
					Network.Interact:FireServer(payload.npcId, "close")
				else
					Network.Interact:FireServer(payload.npcId, option.id)
				end
			end,
		})
		-- Width follows the label so a row of three short options doesn't
		-- stretch into three identical slabs.
		button.Root.AutomaticSize = Enum.AutomaticSize.X
		Create("UIPadding", {
			PaddingLeft = UDim.new(0, Theme.Space.lg),
			PaddingRight = UDim.new(0, Theme.Space.lg),
			Parent = button.Root,
		})
		if not option.enabled then
			button:setEnabled(false)
		end
		table.insert(buttons, button)
	end

	if not Dialogue.isOpen then
		Dialogue.isOpen = true
		root.Visible = true
		card.Position = UDim2.new(0.5, 0, 1, 40)
		Motion.to(card, Theme.Time.Base, {
			Position = UDim2.new(0.5, 0, 1, -Theme.Space.xl),
		}, Theme.Ease.Enter)
	end
end

function Dialogue.start()
	root = Layer.root("Menu", "Dialogue")
	root.Visible = false

	card = Create("Frame", {
		Name = "Card",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -Theme.Space.xl),
		Size = UDim2.fromOffset(WIDTH, HEIGHT),
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.04,
		BorderSizePixel = 0,
		Parent = root,

		Create("UICorner", { CornerRadius = Theme.Radius.lg }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.15 }),
		Create("UIPadding", {
			PaddingLeft = UDim.new(0, Theme.Space.lg),
			PaddingRight = UDim.new(0, Theme.Space.lg),
			PaddingTop = UDim.new(0, Theme.Space.md),
			PaddingBottom = UDim.new(0, Theme.Space.md),
		}),
	})

	Create("Frame", {
		Name = "AccentRule",
		Size = UDim2.new(1, Theme.Space.lg * 2, 0, 2),
		Position = UDim2.fromOffset(-Theme.Space.lg, -Theme.Space.md),
		BackgroundColor3 = Theme.Color.Accent,
		BorderSizePixel = 0,
		Parent = card,

		Create("UIGradient", {
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 1),
				NumberSequenceKeypoint.new(0.5, 0),
				NumberSequenceKeypoint.new(1, 1),
			}),
		}),
	})

	speaker = Create("TextLabel", {
		Name = "Speaker",
		Size = UDim2.new(1, 0, 0, 24),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "",
		TextSize = 20,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = card,
	})

	role = Create("TextLabel", {
		Name = "Role",
		Position = UDim2.fromOffset(0, 24),
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 12,
		TextColor3 = Theme.Color.Accent,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = card,
	})

	body = Create("TextLabel", {
		Name = "Body",
		Position = UDim2.fromOffset(0, 50),
		Size = UDim2.new(1, 0, 0, 78),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Regular,
		Text = "",
		TextSize = 16,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextYAlignment = Enum.TextYAlignment.Top,
		TextWrapped = true,
		Parent = card,
	})

	optionRow = Create("Frame", {
		Name = "Options",
		AnchorPoint = Vector2.new(0, 1),
		Position = UDim2.new(0, 0, 1, 0),
		Size = UDim2.new(1, 0, 0, 44),
		BackgroundTransparency = 1,
		Parent = card,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			VerticalAlignment = Enum.VerticalAlignment.Center,
			Padding = UDim.new(0, Theme.Space.sm),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	Network.Dialogue.OnClientEvent:Connect(function(payload)
		if payload then
			Dialogue.show(payload)
		else
			Dialogue.close()
		end
	end)
end

return Dialogue
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="144">
              <Properties>
                <string name="Name">FishingHud</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	FishingHud — the line, the bite, and the strike.

	Three states and nothing else on screen. Waiting is a quiet line of text.
	The bite is a bar with a marker sweeping across it and a green band in the
	middle. The catch is a card that names what you landed and how cleanly.

	The bar is the whole design. A "press G when it says bite" prompt is a
	reaction test with one correct answer; a sweeping marker is a *timing* test
	where being early and being late look different, feel different, and pay
	differently. That is what makes the tenth cast interesting.

	The sweep runs on the client for smoothness, but it is only a picture: the
	server graded the strike from when it arrived, and the number shown here is
	the number it sent back.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Create = require(ReplicatedStorage.Shared.Util.Create)
local Fishing = require(ReplicatedStorage.Shared.Config.Fishing)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Layer = require(script.Parent.Parent.Core.Layer)

local FishingHud = {}

local root: Frame
local hint: TextLabel
local bar: Frame
local marker: Frame
local card: Frame
local cardName: TextLabel
local cardGrade: TextLabel

local biteAt = 0
local sweeping = false

FishingHud.isCasting = false

local function setHint(text: string, color: Color3?)
	hint.Text = text
	hint.TextColor3 = color or Theme.Color.TextMuted
	hint.Visible = text ~= ""
end

local function showCard(fish, grade: string, quality: number)
	local rarity = Theme.Rarity[fish.rarity] or Theme.Rarity.Common

	cardName.Text = fish.name
	cardName.TextColor3 = rarity.Color
	cardGrade.Text = `{grade}  ·  {math.floor(quality * 100)}%  ·  worth {fish.value} G`
	cardGrade.TextColor3 = if quality >= 1 - Fishing.PerfectBand then Theme.Color.Unique else Theme.Color.TextMuted

	card.Visible = true
	card.Position = UDim2.new(0.5, 0, 1, -196)
	Motion.to(card, Theme.Time.Base, { Position = UDim2.new(0.5, 0, 1, -214) }, Theme.Ease.Enter)

	task.delay(3.2, function()
		local tween = Motion.to(card, Theme.Time.Base, { Position = UDim2.new(0.5, 0, 1, -196) }, Theme.Ease.Exit)
		tween.Completed:Once(function()
			card.Visible = false
		end)
	end)
end

local function stop()
	FishingHud.isCasting = false
	sweeping = false
	bar.Visible = false
	setHint("")
end

function FishingHud.start()
	root = Layer.root("HUD", "Fishing")

	hint = Create("TextLabel", {
		Name = "Hint",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -150),
		Size = UDim2.fromOffset(420, 22),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 15,
		TextColor3 = Theme.Color.TextMuted,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.4,
		Visible = false,
		Parent = root,
	})

	------------------------------------------------------------------
	-- The strike bar
	------------------------------------------------------------------

	bar = Create("Frame", {
		Name = "Bar",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -118),
		Size = UDim2.fromOffset(340, 22),
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.15,
		BorderSizePixel = 0,
		Visible = false,
		Parent = root,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.3 }),
	})

	-- The band you are aiming for. Sized from the same constant the server
	-- grades against, so what you see is what it scores.
	Create("Frame", {
		Name = "Band",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.new(Fishing.PerfectBand, 0, 1, -6),
		BackgroundColor3 = Theme.Color.Success,
		BackgroundTransparency = 0.45,
		BorderSizePixel = 0,
		Parent = bar,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	--[[
		Amber shoulder: "good but not perfect", so the bar isn't hit-or-miss.

		0.4 is not a look-nice number. The server grades GOOD at quality >= 0.6,
		quality is 1 - |offset|, and offset is 2t-1 across the window — which
		works out to t in [0.3, 0.7], a band exactly 0.4 wide. It was 0.55,
		which promised a grade over a sixth of the bar that the server would
		not actually award. A meter that lies about its own scoring is worse
		than no meter.
	]]
	Create("Frame", {
		Name = "Shoulder",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.new(0.4, 0, 1, -6),
		BackgroundColor3 = Theme.Color.StaminaEmpty,
		BackgroundTransparency = 0.82,
		BorderSizePixel = 0,
		ZIndex = 0,
		Parent = bar,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	marker = Create("Frame", {
		Name = "Marker",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0, 0.5),
		Size = UDim2.fromOffset(4, 30),
		BackgroundColor3 = Theme.Color.Text,
		BorderSizePixel = 0,
		ZIndex = 3,
		Parent = bar,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	------------------------------------------------------------------
	-- The catch card
	------------------------------------------------------------------

	card = Create("Frame", {
		Name = "Catch",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -214),
		Size = UDim2.fromOffset(300, 52),
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.08,
		BorderSizePixel = 0,
		Visible = false,
		Parent = root,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.25 }),
	})

	cardName = Create("TextLabel", {
		Name = "Name",
		Position = UDim2.fromOffset(0, 8),
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "",
		TextSize = 17,
		TextColor3 = Theme.Color.Text,
		Parent = card,
	})

	cardGrade = Create("TextLabel", {
		Name = "Grade",
		Position = UDim2.fromOffset(0, 28),
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		Parent = card,
	})

	------------------------------------------------------------------

	RunService.RenderStepped:Connect(function()
		if not sweeping then
			return
		end
		-- Sweeps once across the window, so early reads as left and late as
		-- right. Past the end the marker parks; the server has already called it.
		local t = math.clamp((os.clock() - biteAt) / Fishing.HookWindow, 0, 1)
		marker.Position = UDim2.fromScale(t, 0.5)
	end)

	Network.Fishing.OnClientEvent:Connect(function(payload)
		if payload.state == "cast" then
			FishingHud.isCasting = true
			bar.Visible = false
			setHint("Line's out. Wait for it — G to strike.")
		elseif payload.state == "bite" then
			biteAt = os.clock()
			sweeping = true
			bar.Visible = true
			marker.Position = UDim2.fromScale(0, 0.5)
			setHint("STRIKE", Theme.Color.Unique)
		elseif payload.state == "caught" then
			stop()
			showCard(payload.fish, payload.grade, payload.quality)
		elseif payload.state == "missed" then
			stop()
			setHint("It's gone.", Theme.Color.TextDim)
			task.delay(1.6, function()
				if not FishingHud.isCasting then
					setHint("")
				end
			end)
		elseif payload.state == "early" then
			stop()
			setHint("Too early — the float went under.", Theme.Color.Danger)
			task.delay(1.6, function()
				if not FishingHud.isCasting then
					setHint("")
				end
			end)
		end
	end)
end

return FishingHud
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="145">
              <Properties>
                <string name="Name">HUD</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	HUD — persistent combat readout: vitals, statuses, level.

	Anchored top-left rather than the more familiar bottom-left because the
	bottom corners belong to the mobile thumbstick and jump button. Top-left is
	free on every platform, so there is one layout instead of two.

	Reads exclusively from UIState -- nothing here knows about networking.
]]

local RunService = game:GetService("RunService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Bar = require(script.Parent.Parent.Components.Bar)
local Layer = require(script.Parent.Parent.Core.Layer)
local UIState = require(script.Parent.Parent.Core.UIState)

local BAR_WIDTH = 340
local MARGIN = 26

local STATUS_COLORS = {
	buff = Theme.Color.Success,
	debuff = Theme.Color.Danger,
	curse = Theme.Color.Cursed,
	unique = Theme.Color.Unique,
}

local HUD = {}
local trove = Trove.new()
local statusChips: { [string]: Frame } = {}
local statusList: Frame
local root: Frame

local function buildStatusChip(status): Frame
	local color = STATUS_COLORS[status.kind] or Theme.Color.Accent

	local chip = Create("Frame", {
		Name = status.id,
		Size = UDim2.fromOffset(0, 24),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundColor3 = Theme.Color.Panel,
		BackgroundTransparency = 0.15,
		BorderSizePixel = 0,

		Create("UICorner", { CornerRadius = Theme.Radius.sm }),
		Create("UIStroke", { Color = color, Thickness = 1, Transparency = 0.4 }),
		Create("UIPadding", {
			PaddingLeft = UDim.new(0, 8),
			PaddingRight = UDim.new(0, 8),
		}),
		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			VerticalAlignment = Enum.VerticalAlignment.Center,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),

		Create("TextLabel", {
			Name = "Label",
			Size = UDim2.fromOffset(0, 24),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = status.label,
			TextSize = 12,
			TextColor3 = color,
			LayoutOrder = 1,
		}),

		Create("TextLabel", {
			Name = "Timer",
			Size = UDim2.fromOffset(0, 24),
			AutomaticSize = Enum.AutomaticSize.X,
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = "",
			TextSize = 12,
			TextColor3 = Theme.Color.TextMuted,
			LayoutOrder = 2,
		}),
	})

	return chip
end

local function syncStatuses()
	local statuses = UIState.get("statuses") or {}
	local seen = {}

	for index, status in statuses do
		seen[status.id] = true
		local chip = statusChips[status.id]
		if not chip then
			chip = buildStatusChip(status)
			chip.Parent = statusList
			statusChips[status.id] = chip
		end
		chip.LayoutOrder = index
	end

	for id, chip in statusChips do
		if not seen[id] then
			chip:Destroy()
			statusChips[id] = nil
		end
	end
end

function HUD.start()
	root = Layer.root("HUD", "HUD")

	local column = Create("Frame", {
		Name = "Vitals",
		Position = UDim2.fromOffset(MARGIN, MARGIN),
		Size = UDim2.fromOffset(BAR_WIDTH, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		Parent = root,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})
	trove:add(column)

	-- Name + level line
	local header = Create("Frame", {
		Name = "Header",
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		LayoutOrder = 1,
		Parent = column,
	})

	local nameLabel = Create("TextLabel", {
		Name = "PlayerName",
		Size = UDim2.fromScale(0.7, 1),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "",
		TextSize = 15,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.5,
		Parent = header,
	})

	local levelLabel = Create("TextLabel", {
		Name = "Level",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.fromScale(1, 0),
		Size = UDim2.fromScale(0.3, 1),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 13,
		TextColor3 = Theme.Color.Accent,
		TextXAlignment = Enum.TextXAlignment.Right,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.5,
		Parent = header,
	})

	local healthBar = Bar.new({
		Parent = column,
		Size = UDim2.fromOffset(BAR_WIDTH, 26),
		Color = Theme.Color.Health,
		ChipColor = Theme.Color.HealthChip,
		ShowText = true,
		LowThreshold = 0.3,
		LayoutOrder = 2,
	})
	trove:add(healthBar)

	local staminaBar = Bar.new({
		Parent = column,
		Size = UDim2.fromOffset(BAR_WIDTH * 0.82, 12),
		Color = Theme.Color.Stamina,
		ChipColor = Theme.Color.StaminaChip,
		LayoutOrder = 3,
	})
	trove:add(staminaBar)

	-- Thin XP rail under the vitals.
	local xpBar = Bar.new({
		Parent = column,
		Size = UDim2.fromOffset(BAR_WIDTH * 0.6, 4),
		Color = Theme.Color.AccentDim,
		ChipColor = Theme.Color.Accent,
		Radius = Theme.Radius.pill,
		LayoutOrder = 4,
	})
	trove:add(xpBar)

	--[[
		Purse.

		Under the XP rail rather than in a corner of its own: gold changes at
		the same moments experience does — a kill, a hand-in, a purchase — so
		the two belong in the same glance. It stays hidden at zero, which keeps
		the HUD honest before the player has been to town.
	]]
	local goldLabel = Create("TextLabel", {
		Name = "Gold",
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 13,
		TextColor3 = Theme.Color.Unique,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.5,
		Visible = false,
		LayoutOrder = 5,
		Parent = column,
	})

	trove:add(UIState.observe("gold", function(gold)
		goldLabel.Visible = (gold or 0) > 0
		goldLabel.Text = `{gold or 0} G`
	end))

	statusList = Create("Frame", {
		Name = "Statuses",
		Size = UDim2.new(1, 0, 0, 24),
		AutomaticSize = Enum.AutomaticSize.X,
		BackgroundTransparency = 1,
		LayoutOrder = 6,
		Parent = column,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	-- Bindings
	trove:add(UIState.observeMany({ "health", "maxHealth" }, function()
		healthBar:setValue(UIState.get("health"), UIState.get("maxHealth"))
	end))

	-- Sprint writes stamina every frame, so this renders instantly rather than
	-- tweening: a tween per frame would queue 60 a second and trail the input.
	local staminaLow = false
	trove:add(UIState.observeMany({ "stamina", "maxStamina" }, function()
		local current, max = UIState.get("stamina"), UIState.get("maxStamina")
		staminaBar:setValue(current, max, true)

		-- Low stamina turns amber: you're about to lose your sprint, and you
		-- need to know that faster than you can read a number.
		local low = max > 0 and (current / max) < 0.2
		if low ~= staminaLow then
			staminaLow = low
			staminaBar:setColor(if low then Theme.Color.StaminaEmpty else Theme.Color.Stamina)
		end
	end))

	trove:add(UIState.observeMany({ "experience", "experienceToNext" }, function()
		xpBar:setValue(UIState.get("experience"), UIState.get("experienceToNext"))
	end))

	trove:add(UIState.observe("displayName", function(value)
		nameLabel.Text = value or ""
	end))

	trove:add(UIState.observe("level", function(value)
		levelLabel.Text = `Lv {value or 1}`
	end))

	trove:add(UIState.observe("statuses", syncStatuses))

	-- Status countdowns tick locally; the server only sends expiry times.
	trove:connect(RunService.Heartbeat, function()
		local now = os.clock()
		local statuses = UIState.get("statuses")
		if not statuses then
			return
		end
		for _, status in statuses do
			local chip = statusChips[status.id]
			if chip and status.expiresAt then
				local remaining = math.max(status.expiresAt - now, 0)
				chip.Timer.Text = if remaining > 0 then string.format("%.1fs", remaining) else ""
			end
		end
	end)

	-- The HUD exists only during gameplay, and the death screen owns the frame
	-- while dead.
	trove:add(UIState.observeMany({ "dead", "phase" }, function()
		root.Visible = UIState.get("phase") == "playing" and not UIState.get("dead")
	end))
end

function HUD.stop()
	trove:clean()
	table.clear(statusChips)
end

return HUD
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="146">
              <Properties>
                <string name="Name">Menu</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Menu — the character screen shell.

	One panel, a tab rail down the left, and one content frame per tab. Every
	tab is built once at start and then just shown or hidden; they're small
	enough that rebuilding on each switch would cost more than it saves, and
	keeping them alive means scroll position and selection survive tabbing
	away and back.

	Opened with B. The rule for every menu key: the key that opens a screen
	closes it, and Escape closes whatever is open.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Characters = require(ReplicatedStorage.Shared.Config.Characters)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Choice = require(script.Parent.Parent.Components.Choice)
local Layer = require(script.Parent.Parent.Core.Layer)
local Panel = require(script.Parent.Parent.Components.Panel)
local UIState = require(script.Parent.Parent.Core.UIState)

local PANEL_SIZE = UDim2.fromOffset(1130, 650)
local RAIL_WIDTH = 176

local TABS = {
	{ id = "status", label = "Status", hint = "Who you are" },
	{ id = "inventory", label = "Inventory", hint = "Items and gear" },
	{ id = "skills", label = "Skills", hint = "Unlocks" },
	{ id = "quests", label = "Quests", hint = "Work from town" },
	{ id = "scenarios", label = "Scenarios", hint = "Unique quests" },
	{ id = "titles", label = "Titles", hint = "Proof" },
	{ id = "settings", label = "Settings", hint = "Gameplay options" },
}

local TAB_MODULES = {
	status = require(script.StatusTab),
	inventory = require(script.InventoryTab),
	skills = require(script.SkillsTab),
	quests = require(script.QuestsTab),
	scenarios = require(script.ScenariosTab),
	titles = require(script.TitlesTab),
	settings = require(script.SettingsTab),
}

local Menu = {}
local trove = Trove.new()

local panel
local tabChoices: { [string]: any } = {}
local tabFrames: { [string]: Frame } = {}

--[[
	Switching tabs used to be a hard cut: one wall of content vanished and
	another appeared on the same frame, with nothing to tell the eye that
	anything had been replaced rather than rearranged. The incoming tab's rows
	now arrive in sequence, which costs a fifth of a second and is the single
	clearest signal that this is a different screen.

	Only on an actual change — re-running `syncTab` for an unrelated state write
	would replay the animation over a tab the player is already reading.
]]
local shown: string? = nil

local function syncTab()
	local active = UIState.get("menuTab")

	for id, choice in tabChoices do
		choice:setSelected(id == active)
	end
	for id, frame in tabFrames do
		frame.Visible = id == active
	end

	if active and active ~= shown then
		shown = active
		local frame = tabFrames[active]
		if frame then
			Motion.stagger(Motion.rows(frame))
		end
	end
end

local function syncHeader()
	local character = UIState.get("character")
	local level = UIState.get("level") or 1

	panel:setTitle(UIState.get("displayName") or "Adventurer")

	if character then
		local race = Characters.findRace(character.race)
		local style = Characters.findStyle(character.style)
		panel:setSubtitle(`{race and race.name or "—"}  ·  {style and style.name or "—"}  ·  Level {level}`)
	else
		panel:setSubtitle(`Level {level}`)
	end
end

function Menu.start()
	local root = Layer.root("Menu", "Menu")
	trove:add(root)

	panel = Panel.new({
		Parent = root,
		Size = PANEL_SIZE,
		Title = "Adventurer",
		Subtitle = "",
		Scrim = true,
		OnClose = function()
			UIState.set("menuOpen", nil)
		end,
	})
	trove:add(panel)

	local body = Create("Frame", {
		Name = "Body",
		Position = UDim2.fromOffset(Theme.Space.lg, 0),
		Size = UDim2.new(1, -Theme.Space.lg * 2, 1, -Theme.Space.lg),
		BackgroundTransparency = 1,
		Parent = panel.Content,
	})

	-- Tab rail
	local rail = Create("Frame", {
		Name = "Rail",
		Size = UDim2.new(0, RAIL_WIDTH, 1, 0),
		BackgroundTransparency = 1,
		Parent = body,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	local content = Create("Frame", {
		Name = "Content",
		Position = UDim2.fromOffset(RAIL_WIDTH + Theme.Space.lg, 0),
		Size = UDim2.new(1, -RAIL_WIDTH - Theme.Space.lg, 1, 0),
		BackgroundTransparency = 1,
		Parent = body,
	})

	for index, tab in TABS do
		local choice = Choice.new({
			Parent = rail,
			Variant = "row",
			Title = tab.label,
			Subtitle = tab.hint,
			LayoutOrder = index,
			OnSelect = function()
				UIState.set("menuTab", tab.id)
			end,
		})
		tabChoices[tab.id] = choice
		trove:add(choice)

		local frame = Create("Frame", {
			Name = tab.label,
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			Visible = false,
			Parent = content,
		})
		tabFrames[tab.id] = frame

		TAB_MODULES[tab.id].build(frame, trove)
	end

	trove:add(UIState.observe("menuTab", syncTab))
	trove:add(UIState.observeMany({ "displayName", "character", "level" }, syncHeader))

	trove:add(UIState.observe("menuOpen", function(mode)
		if mode == "menu" then
			panel:open()
		else
			panel:close()
		end
	end))
end

function Menu.stop()
	trove:clean()
	table.clear(tabChoices)
	table.clear(tabFrames)
end

return Menu
]]></string>
              </Properties>
              <Item class="ModuleScript" referent="147">
                <Properties>
                  <string name="Name">InventoryTab</string>
                  <string name="Source"><![CDATA[--!nonstrict
--[[
	InventoryTab — equipment slots, item grid, and item detail.

	The detail pane gives drawbacks equal billing with stats, in danger red
	directly beneath them. Cursed gear is the interesting decision in this
	game -- big numbers bought with a real cost -- and that decision only
	works if the cost is as legible as the payoff. Never bury it in a
	tooltip footnote.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local UIState = require(script.Parent.Parent.Parent.Core.UIState)

local EQUIP_SLOTS = { "Head", "Body", "Hands", "Legs", "Feet", "Weapon", "Offhand", "Accessory" }
local CELL = 62

local InventoryTab = {}

local grid: ScrollingFrame
local detail: ScrollingFrame
local slotFrames: { [string]: Frame } = {}
local cells: { [string]: TextButton } = {}

local function rarityOf(item)
	return Theme.Rarity[item and item.rarity or "Common"] or Theme.Rarity.Common
end

local function findItem(id: string?)
	if not id then
		return nil
	end
	for _, item in UIState.get("inventory") or {} do
		if item.id == id then
			return item
		end
	end
	return nil
end

--------------------------------------------------------------------------------

local function renderDetail(item)
	for _, child in detail:GetChildren() do
		if not child:IsA("UIListLayout") and not child:IsA("UIPadding") then
			child:Destroy()
		end
	end

	if not item then
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 40),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = "Select an item",
			TextSize = 13,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = 1,
			Parent = detail,
		})
		return
	end

	local rarity = rarityOf(item)
	local order = 0
	local function nextOrder(): number
		order += 1
		return order
	end

	Create("TextLabel", {
		Name = "Name",
		Size = UDim2.new(1, 0, 0, 26),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = item.name,
		TextSize = 19,
		TextColor3 = rarity.Color,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextWrapped = true,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	Create("TextLabel", {
		Name = "Meta",
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = `{rarity.Label}  ·  {item.slot or "Material"}`,
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	if item.description then
		Create("TextLabel", {
			Name = "Description",
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Regular,
			Text = item.description,
			TextSize = 13,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextWrapped = true,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})
	end

	for _, stat in item.stats or {} do
		local row = Create("Frame", {
			Size = UDim2.new(1, 0, 0, 20),
			BackgroundTransparency = 1,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})
		Create("TextLabel", {
			Size = UDim2.fromScale(0.6, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = stat.label,
			TextSize = 13,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			Parent = row,
		})
		Create("TextLabel", {
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.fromScale(1, 0),
			Size = UDim2.fromScale(0.4, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = stat.value,
			TextSize = 13,
			TextColor3 = Theme.Color.Success,
			TextXAlignment = Enum.TextXAlignment.Right,
			Parent = row,
		})
	end

	if item.drawbacks and #item.drawbacks > 0 then
		Create("Frame", {
			Name = "Divider",
			Size = UDim2.new(1, 0, 0, 1),
			BackgroundColor3 = Theme.Color.Stroke,
			BackgroundTransparency = 0.4,
			BorderSizePixel = 0,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})

		Create("TextLabel", {
			Name = "CurseHeading",
			Size = UDim2.new(1, 0, 0, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = "CURSE",
			TextSize = 12,
			TextColor3 = Theme.Color.Cursed,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})

		for _, drawback in item.drawbacks do
			Create("TextLabel", {
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Medium,
				Text = `— {drawback}`,
				TextSize = 12,
				TextColor3 = Theme.Color.Danger,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextWrapped = true,
				LayoutOrder = nextOrder(),
				Parent = detail,
			})
		end
	end
end

--------------------------------------------------------------------------------

local function buildCell(item): TextButton
	local rarity = rarityOf(item)

	local cell = Create("TextButton", {
		Name = item.id,
		Size = UDim2.fromOffset(CELL, CELL),
		BackgroundColor3 = Theme.Color.PanelRaised,
		BackgroundTransparency = 0.2,
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Text = "",

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Name = "Rarity", Color = rarity.Color, Thickness = 1, Transparency = 0.35 }),

		-- Placeholder glyph until real item art exists.
		Create("TextLabel", {
			Name = "Glyph",
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = string.sub(item.name, 1, 1),
			TextSize = 22,
			TextColor3 = rarity.Color,
		}),
	})

	-- Corner tick marks cursed gear even before you read the name.
	if item.rarity == "Cursed" then
		Create("Frame", {
			Name = "CurseMark",
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.fromScale(1, 1),
			Size = UDim2.fromOffset(8, 8),
			BackgroundColor3 = Theme.Color.Cursed,
			BorderSizePixel = 0,
			Parent = cell,

			Create("UICorner", { CornerRadius = Theme.Radius.pill }),
		})
	end

	if item.quantity and item.quantity > 1 then
		Create("TextLabel", {
			Name = "Quantity",
			AnchorPoint = Vector2.new(1, 1),
			Position = UDim2.new(1, -4, 1, -2),
			Size = UDim2.fromOffset(24, 14),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = tostring(item.quantity),
			TextSize = 11,
			TextColor3 = Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Right,
			Parent = cell,
		})
	end

	cell.MouseEnter:Connect(function()
		Motion.to(cell, Theme.Time.Fast, { BackgroundTransparency = 0 })
	end)
	cell.MouseLeave:Connect(function()
		Motion.to(cell, Theme.Time.Fast, { BackgroundTransparency = 0.2 })
	end)
	cell.Activated:Connect(function()
		UIState.set("selectedItem", item.id)
	end)

	return cell
end

local function syncGrid()
	for _, cell in cells do
		cell:Destroy()
	end
	table.clear(cells)

	for index, item in UIState.get("inventory") or {} do
		local cell = buildCell(item)
		cell.LayoutOrder = index
		cell.Parent = grid
		cells[item.id] = cell
	end
end

local function syncSelection()
	local selectedId = UIState.get("selectedItem")

	for id, cell in cells do
		local stroke = cell:FindFirstChild("Rarity")
		if stroke then
			stroke.Transparency = if id == selectedId then 0 else 0.35
			stroke.Thickness = if id == selectedId then 2 else 1
		end
	end

	renderDetail(findItem(selectedId))
end

local function syncEquipment()
	local equipment = UIState.get("equipment") or {}

	for slotName, frame in slotFrames do
		local item = findItem(equipment[slotName])
		local rarity = rarityOf(item)

		frame.Glyph.Text = if item then string.sub(item.name, 1, 1) else ""
		frame.Glyph.TextColor3 = rarity.Color
		frame.SlotName.TextColor3 = if item then Theme.Color.TextMuted else Theme.Color.TextDim
		frame.Rarity.Color = if item then rarity.Color else Theme.Color.Stroke
		frame.Rarity.Transparency = if item then 0.25 else 0.6
	end
end

--------------------------------------------------------------------------------

function InventoryTab.build(parent: Frame, trove)
	-- Equipment column
	local equipColumn = Create("Frame", {
		Name = "Equipment",
		Size = UDim2.new(0, 170, 1, 0),
		BackgroundTransparency = 1,
		Parent = parent,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index, slotName in EQUIP_SLOTS do
		slotFrames[slotName] = Create("Frame", {
			Name = slotName,
			Size = UDim2.new(1, 0, 0, 44),
			BackgroundColor3 = Theme.Color.PanelRaised,
			BackgroundTransparency = 0.35,
			BorderSizePixel = 0,
			LayoutOrder = index,
			Parent = equipColumn,

			Create("UICorner", { CornerRadius = Theme.Radius.md }),
			Create("UIStroke", { Name = "Rarity", Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.6 }),

			Create("TextLabel", {
				Name = "Glyph",
				Size = UDim2.fromOffset(44, 44),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Bold,
				Text = "",
				TextSize = 18,
				TextColor3 = Theme.Color.TextDim,
			}),

			Create("TextLabel", {
				Name = "SlotName",
				Position = UDim2.fromOffset(48, 0),
				Size = UDim2.new(1, -52, 1, 0),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Medium,
				Text = slotName,
				TextSize = 12,
				TextColor3 = Theme.Color.TextDim,
				TextXAlignment = Enum.TextXAlignment.Left,
			}),
		})
	end

	-- Item grid
	grid = Create("ScrollingFrame", {
		Name = "Grid",
		Position = UDim2.fromOffset(190, 0),
		Size = UDim2.new(1, -190 - 280, 1, 0),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = parent,

		Create("UIGridLayout", {
			CellSize = UDim2.fromOffset(CELL, CELL),
			CellPadding = UDim2.fromOffset(8, 8),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", { PaddingRight = UDim.new(0, 10) }),
	})

	-- Detail pane
	local detailFrame = Create("Frame", {
		Name = "Detail",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.fromScale(1, 0),
		Size = UDim2.new(0, 268, 1, 0),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 0.35,
		BorderSizePixel = 0,
		Parent = parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.5 }),
	})

	detail = Create("ScrollingFrame", {
		Name = "DetailContent",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 2,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = detailFrame,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 7),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", {
			PaddingTop = UDim.new(0, 14),
			PaddingLeft = UDim.new(0, 14),
			PaddingRight = UDim.new(0, 14),
			PaddingBottom = UDim.new(0, 14),
		}),
	})

	trove:add(UIState.observe("inventory", function()
		syncGrid()
		syncSelection()
		syncEquipment()
	end))
	trove:add(UIState.observe("selectedItem", syncSelection))
	trove:add(UIState.observe("equipment", syncEquipment))
end

return InventoryTab
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="148">
                <Properties>
                  <string name="Name">QuestsTab</string>
                  <string name="Source"><![CDATA[--!nonstrict
--[[
	QuestsTab — the job board.

	Three groups in one list: what you're doing, what's ready to hand in, and
	what you've finished. Ready-to-hand-in sorts to the top and is the only
	group given the Unique colour, because "go back and collect" is the one
	piece of information a quest log exists to deliver — everything else the
	player already knows.

	Quests you haven't taken are not listed. A log that shows work you were
	never offered is a spoiler list, and it makes the town's NPCs redundant.
	The one exception is a quest whose prerequisite you've finished: that gets
	a single line telling you who to go and see, because otherwise a chain
	quest is invisible until you happen to walk past its giver again.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Town = require(ReplicatedStorage.Shared.Config.Town)

local UIState = require(script.Parent.Parent.Parent.Core.UIState)

local QuestsTab = {}

local list: ScrollingFrame
local summary: TextLabel

local GROUPS = {
	{ key = "ready", label = "READY TO HAND IN", color = Theme.Color.Unique },
	{ key = "active", label = "IN PROGRESS", color = Theme.Color.Accent },
	{ key = "lead", label = "AVAILABLE IN TOWN", color = Theme.Color.TextMuted },
	{ key = "done", label = "COMPLETED", color = Theme.Color.Success },
}

local function card(order: number, quest, group, progress: number)
	local giver = Town.findNpc(quest.giver)
	local done = group.key == "done"

	local holder = Create("Frame", {
		Name = quest.id,
		Size = UDim2.new(1, -10, 0, if group.key == "lead" then 50 else 72),
		BackgroundColor3 = Theme.Color.PanelRaised,
		BackgroundTransparency = if done then 0.7 else 0.4,
		BorderSizePixel = 0,
		LayoutOrder = order,
		Parent = list,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = group.color, Thickness = 1, Transparency = if done then 0.8 else 0.55 }),

		Create("Frame", {
			Name = "Marker",
			Size = UDim2.new(0, 3, 1, 0),
			BackgroundColor3 = group.color,
			BorderSizePixel = 0,
		}),

		Create("TextLabel", {
			Name = "Name",
			Position = UDim2.fromOffset(14, 8),
			Size = UDim2.new(1, -140, 0, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = quest.name,
			TextSize = 15,
			TextColor3 = if done then Theme.Color.TextDim else Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
		}),

		Create("TextLabel", {
			Name = "Giver",
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, -12, 0, 8),
			Size = UDim2.fromOffset(124, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = if giver then `{giver.name} · {giver.title}` else "",
			TextSize = 12,
			TextColor3 = group.color,
			TextXAlignment = Enum.TextXAlignment.Right,
			TextTruncate = Enum.TextTruncate.AtEnd,
		}),
	})

	if group.key == "lead" then
		Create("TextLabel", {
			Name = "Hint",
			Position = UDim2.fromOffset(14, 27),
			Size = UDim2.new(1, -26, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Regular,
			Text = `Ask {if giver then giver.name else "in town"} about this.`,
			TextSize = 12,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			Parent = holder,
		})
		return
	end

	Create("TextLabel", {
		Name = "Summary",
		Position = UDim2.fromOffset(14, 27),
		Size = UDim2.new(1, -26, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Regular,
		Text = quest.summary,
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextTruncate = Enum.TextTruncate.AtEnd,
		Parent = holder,
	})

	-- Objective line, with a filled bar underneath. The bar is what makes
	-- "3 of 5" readable without reading.
	local target = quest.objective.count
	local verb = if quest.objective.kind == "collect" then "Collect" else "Defeat"

	Create("TextLabel", {
		Name = "Objective",
		Position = UDim2.fromOffset(14, 45),
		Size = UDim2.new(1, -26, 0, 15),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = if done
			then `Reward claimed · {quest.reward.gold} gold`
			else `{verb} {quest.objective.target}  —  {progress} / {target}`,
		TextSize = 12,
		TextColor3 = group.color,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = holder,
	})

	if not done then
		local track = Create("Frame", {
			Name = "Track",
			AnchorPoint = Vector2.new(0, 1),
			Position = UDim2.new(0, 14, 1, -7),
			Size = UDim2.new(1, -28, 0, 3),
			BackgroundColor3 = Theme.Color.Stroke,
			BackgroundTransparency = 0.4,
			BorderSizePixel = 0,
			Parent = holder,

			Create("UICorner", { CornerRadius = UDim.new(1, 0) }),
		})

		Create("Frame", {
			Name = "Fill",
			Size = UDim2.fromScale(math.clamp(progress / target, 0, 1), 1),
			BackgroundColor3 = group.color,
			BorderSizePixel = 0,
			Parent = track,

			Create("UICorner", { CornerRadius = UDim.new(1, 0) }),
		})
	end
end

local function sync()
	for _, child in list:GetChildren() do
		if child:IsA("Frame") then
			child:Destroy()
		end
	end

	local states = UIState.get("quests") or {}
	local progresses = UIState.get("questProgress") or {}

	local buckets = { ready = {}, active = {}, lead = {}, done = {} }

	for _, quest in Town.Quests do
		local state = states[quest.id]
		local progress = progresses[quest.id] or 0

		if state == "done" then
			table.insert(buckets.done, { quest = quest, progress = progress })
		elseif state == "active" then
			local key = if progress >= quest.objective.count then "ready" else "active"
			table.insert(buckets[key], { quest = quest, progress = progress })
		elseif not quest.requires or states[quest.requires] == "done" then
			-- Unlocked by the chain but not yet taken: worth one line so the
			-- next step in a chain isn't invisible.
			table.insert(buckets.lead, { quest = quest, progress = 0 })
		end
	end

	local order = 0
	for _, group in GROUPS do
		local entries = buckets[group.key]
		if #entries == 0 then
			continue
		end

		order += 1
		Create("Frame", {
			Name = `Header{group.key}`,
			Size = UDim2.new(1, -10, 0, 24),
			BackgroundTransparency = 1,
			LayoutOrder = order,
			Parent = list,

			Create("TextLabel", {
				AnchorPoint = Vector2.new(0, 1),
				Position = UDim2.new(0, 2, 1, -4),
				Size = UDim2.new(1, -4, 0, 14),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Bold,
				Text = `{group.label}  ({#entries})`,
				TextSize = 11,
				TextColor3 = group.color,
				TextXAlignment = Enum.TextXAlignment.Left,
			}),
		})

		for _, entry in entries do
			order += 1
			card(order, entry.quest, group, entry.progress)
		end
	end

	local active = #buckets.active + #buckets.ready
	summary.Text = if active == 0
		then "Nothing on. Talk to anyone in Firstia with a gold title."
		else `{active} job{if active == 1 then "" else "s"} in hand · {#buckets.done} finished`

	list.CanvasSize = UDim2.fromOffset(0, list.UIListLayout.AbsoluteContentSize.Y + 10)
end

function QuestsTab.build(parent: Frame, trove)
	summary = Create("TextLabel", {
		Name = "Summary",
		Position = UDim2.fromOffset(0, 0),
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 13,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = parent,
	})

	list = Create("ScrollingFrame", {
		Name = "Quests",
		Position = UDim2.fromOffset(0, 26),
		Size = UDim2.new(1, 0, 1, -26),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		Parent = parent,

		Create("UIListLayout", {
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	trove:add(UIState.observeMany({ "quests", "questProgress" }, sync))
end

return QuestsTab
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="149">
                <Properties>
                  <string name="Name">ScenariosTab</string>
                  <string name="Source"><![CDATA[--!nonstrict
--[[
	ScenariosTab — the Unique Scenario log.

	The state that matters here is `taken`: another player somewhere in the
	world claimed that scenario first and it is gone for everyone else,
	permanently. It stays listed, greyed, and explicitly labelled rather than
	being hidden — a scenario you can never have is only meaningful if you can
	see that you missed it.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Progression = require(ReplicatedStorage.Shared.Config.Progression)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local UIState = require(script.Parent.Parent.Parent.Core.UIState)

local TONE_COLORS = {
	accent = Theme.Color.Accent,
	unique = Theme.Color.Unique,
	success = Theme.Color.Success,
	dim = Theme.Color.TextDim,
}

local ScenariosTab = {}

local list: ScrollingFrame
local detail: ScrollingFrame
local rows: { [string]: TextButton } = {}
local selectedId: string? = nil

local function stateOf(id: string): string
	local states = UIState.get("scenarioStates") or {}
	local scenario = Progression.findScenario(id)
	return states[id] or (scenario and scenario.defaultState) or "available"
end

local function renderDetail()
	for _, child in detail:GetChildren() do
		if not child:IsA("UIListLayout") and not child:IsA("UIPadding") then
			child:Destroy()
		end
	end

	local scenario = Progression.findScenario(selectedId)
	if not scenario then
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 40),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = "Select a scenario",
			TextSize = 13,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			Parent = detail,
		})
		return
	end

	local state = stateOf(scenario.id)
	local meta = Progression.ScenarioStates[state]
	local tone = TONE_COLORS[meta.tone] or Theme.Color.Accent
	local order = 0
	local function nextOrder(): number
		order += 1
		return order
	end

	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = string.upper(scenario.kicker),
		TextSize = 10,
		TextColor3 = Theme.Color.Unique,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = scenario.name,
		TextSize = 19,
		TextColor3 = if state == "taken" then Theme.Color.TextDim else Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextWrapped = true,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = string.upper(meta.label),
		TextSize = 11,
		TextColor3 = tone,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextWrapped = true,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Regular,
		Text = scenario.description,
		TextSize = 13,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextWrapped = true,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	if scenario.reward then
		Create("Frame", {
			Size = UDim2.new(1, 0, 0, 1),
			BackgroundColor3 = Theme.Color.Stroke,
			BackgroundTransparency = 0.4,
			BorderSizePixel = 0,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = "REWARD",
			TextSize = 10,
			TextColor3 = Theme.Color.AccentDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = scenario.reward,
			TextSize = 12,
			TextColor3 = if state == "cleared" then Theme.Color.Unique else Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextWrapped = true,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})
	end
end

local function syncRows()
	for id, row in rows do
		local state = stateOf(id)
		local meta = Progression.ScenarioStates[state]
		local tone = TONE_COLORS[meta.tone] or Theme.Color.Accent
		local taken = state == "taken"

		row.Title.TextColor3 = if taken then Theme.Color.TextDim else Theme.Color.Text
		row.State.Text = meta.label
		row.State.TextColor3 = tone

		local edge = row:FindFirstChild("Edge")
		if edge then
			edge.Transparency = if id == selectedId then 0 else 0.55
			edge.Color = if id == selectedId then Theme.Color.Accent else Theme.Color.Stroke
		end
	end

	renderDetail()
end

function ScenariosTab.build(parent: Frame, trove)
	list = Create("ScrollingFrame", {
		Name = "List",
		Size = UDim2.new(1, -290, 1, 0),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = parent,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index, scenario in Progression.Scenarios do
		local row = Create("TextButton", {
			Name = scenario.id,
			Size = UDim2.new(1, -10, 0, 54),
			BackgroundColor3 = Theme.Color.PanelRaised,
			BackgroundTransparency = 0.4,
			AutoButtonColor = false,
			BorderSizePixel = 0,
			Text = "",
			LayoutOrder = index,
			Parent = list,

			Create("UICorner", { CornerRadius = Theme.Radius.md }),
			Create("UIStroke", { Name = "Edge", Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.55 }),

			Create("TextLabel", {
				Name = "Title",
				Position = UDim2.fromOffset(14, 9),
				Size = UDim2.new(1, -28, 0, 19),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.SemiBold,
				Text = scenario.name,
				TextSize = 14,
				TextColor3 = Theme.Color.Text,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextTruncate = Enum.TextTruncate.AtEnd,
			}),

			Create("TextLabel", {
				Name = "State",
				Position = UDim2.fromOffset(14, 28),
				Size = UDim2.new(1, -28, 0, 16),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Medium,
				Text = "",
				TextSize = 11,
				TextColor3 = Theme.Color.TextDim,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextTruncate = Enum.TextTruncate.AtEnd,
			}),
		})

		row.MouseEnter:Connect(function()
			Motion.to(row, Theme.Time.Fast, { BackgroundTransparency = 0.2 })
		end)
		row.MouseLeave:Connect(function()
			Motion.to(row, Theme.Time.Fast, { BackgroundTransparency = 0.4 })
		end)
		row.Activated:Connect(function()
			selectedId = scenario.id
			syncRows()
		end)

		rows[scenario.id] = row
	end

	local detailFrame = Create("Frame", {
		Name = "Detail",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.fromScale(1, 0),
		Size = UDim2.new(0, 278, 1, 0),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 0.35,
		BorderSizePixel = 0,
		Parent = parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.5 }),
	})

	detail = Create("ScrollingFrame", {
		Name = "DetailContent",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 2,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = detailFrame,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 7),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", {
			PaddingTop = UDim.new(0, 14),
			PaddingLeft = UDim.new(0, 14),
			PaddingRight = UDim.new(0, 14),
			PaddingBottom = UDim.new(0, 14),
		}),
	})

	trove:add(UIState.observe("scenarioStates", syncRows))
end

return ScenariosTab
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="150">
                <Properties>
                  <string name="Name">SettingsTab</string>
                  <string name="Source"><![CDATA[--!nonstrict
--[[
	SettingsTab — gameplay and audio options.

	The Parry Helper is
	presented without any framing that suggests picking assistance is the wrong
	answer: no "recommended", no difficulty language, no warning icons. A player
	who wants Always On should not be made to feel they have opted out of the
	real game, and a player who turns it Off should not feel they have opted
	into a chore.

	Each option states plainly what it does, including the one thing all three
	share: you always press the key yourself.

	The audio section is one slider per bus in `Config/Mix`, so adding a bus
	adds a slider and nothing here has to know about it. Levels apply live and
	are local to this player.

	NOT YET PERSISTED. Every setting on this tab, the parry helper included,
	resets on rejoin — there is no client-settings store, and there never was.
	That wants a `settings` blob on the profile and one remote to push it.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Assist = require(ReplicatedStorage.Shared.Config.Assist)
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Mix = require(ReplicatedStorage.Shared.Config.Mix)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Choice = require(script.Parent.Parent.Parent.Components.Choice)
local Slider = require(script.Parent.Parent.Parent.Components.Slider)
local UIState = require(script.Parent.Parent.Parent.Core.UIState)

local SettingsTab = {}

local modeChoices: { [string]: any } = {}
local detail: ScrollingFrame

local function heading(parent: Instance, text: string, order: number)
	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = string.upper(text),
		TextSize = 11,
		TextColor3 = Theme.Color.AccentDim,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = order,
		Parent = parent,
	})
end

local function body(parent: Instance, text: string, order: number, color: Color3?)
	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = text,
		TextSize = 12,
		TextColor3 = color or Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextWrapped = true,
		LayoutOrder = order,
		Parent = parent,
	})
end

local function renderDetail()
	for _, child in detail:GetChildren() do
		if not child:IsA("UIListLayout") and not child:IsA("UIPadding") then
			child:Destroy()
		end
	end

	local current = UIState.get("parryHelper")
	local order = 0
	local function nextOrder(): number
		order += 1
		return order
	end

	for _, mode in Assist.Modes do
		local selected = mode.id == current
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 20),
			BackgroundTransparency = 1,
			FontFace = if selected then Theme.Font.Bold else Theme.Font.SemiBold,
			Text = mode.name,
			TextSize = 14,
			TextColor3 = if selected then Theme.Color.Accent else Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})
		body(detail, mode.summary, nextOrder(), if selected then Theme.Color.TextMuted else Theme.Color.TextDim)
	end

	Create("Frame", {
		Size = UDim2.new(1, 0, 0, 1),
		BackgroundColor3 = Theme.Color.Stroke,
		BackgroundTransparency = 0.5,
		BorderSizePixel = 0,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	heading(detail, "What the helper does", nextOrder())
	body(
		detail,
		"It never parries for you. The attack's approach is stretched so you have longer to react. "
			.. "You still press F yourself, and pressing too early or too late still fails.",
		nextOrder()
	)

	if current == "adaptive" then
		heading(detail, "It escalates in three stages", nextOrder())
		body(
			detail,
			"Each stage is a different kind of help, not a louder version of the last. "
				.. "You only reach the next one if the one before wasn't enough.",
			nextOrder(),
			Theme.Color.TextDim
		)

		for _, stage in Assist.Stages do
			Create("TextLabel", {
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				BackgroundTransparency = 1,
				FontFace = Theme.Font.SemiBold,
				Text = `{stage.failures} misses — {stage.name}`,
				TextSize = 11,
				TextColor3 = Theme.Color.Accent,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextWrapped = true,
				LayoutOrder = nextOrder(),
				Parent = detail,
			})
			body(detail, stage.summary, nextOrder(), Theme.Color.TextDim)
		end

		body(
			detail,
			`{Assist.SuccessesBeforeOff} clean parries in a row clears all of it.`,
			nextOrder(),
			Theme.Color.TextMuted
		)
	elseif current == "always" then
		heading(detail, "Note", nextOrder())
		body(
			detail,
			"Always On stops at the timing ring. The key prompt is only ever shown to a player "
				.. "who is genuinely stuck, so it isn't available as a setting.",
			nextOrder(),
			Theme.Color.TextDim
		)
	end
end

--------------------------------------------------------------------------------

--[[
	The left side scrolls now.

	It held one setting and could be laid out by hand at fixed offsets. With the
	mixer it holds nine, which is already more than fits, and the next one added
	would have silently run off the bottom of the panel with nothing to say so.
	A list layout inside a scroller means sections can be added without anyone
	having to do arithmetic.
]]
local function section(parent: Instance, title: string, order: number): Frame
	Create("TextLabel", {
		Name = title,
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = string.upper(title),
		TextSize = 11,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = order,
		Parent = parent,
	})

	return Create("Frame", {
		Name = `{title}Body`,
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		LayoutOrder = order + 1,
		Parent = parent,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 8),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})
end

function SettingsTab.build(parent: Frame, trove)
	local column = Create("ScrollingFrame", {
		Name = "Options",
		Size = UDim2.new(1, -300, 1, 0),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 2,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = parent,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 10),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", { PaddingRight = UDim.new(0, 10), PaddingBottom = UDim.new(0, 20) }),
	})

	local modes = section(column, "Parry helper", 10)
	for index, mode in Assist.Modes do
		local choice = Choice.new({
			Parent = modes,
			Variant = "row",
			Title = mode.name,
			Subtitle = mode.summary,
			LayoutOrder = index,
			OnSelect = function()
				UIState.set("parryHelper", mode.id)
			end,
		})
		modeChoices[mode.id] = choice
		trove:add(choice)
	end

	--[[
		The mixer.

		Levels are applied the instant the handle moves, not on some Apply
		button, because the only way to set an audio level is to hear it. They
		are held on the local `SoundGroup` tree, so this is one player's mix and
		nobody else's.
	]]
	local levels = section(column, "Audio", 20)
	for index, bus in Mix.adjustable() do
		trove:add(Slider.new({
			Parent = levels,
			Label = bus.label,
			Summary = bus.summary,
			Value = Audio.level(bus.id),
			LayoutOrder = index,
			AccentColor = if bus.id == Mix.Root then Theme.Color.Accent else Theme.Color.AccentDim,
			OnChanged = function(value)
				Audio.setLevel(bus.id, value)
			end,
		}))
	end

	local detailFrame = Create("Frame", {
		Name = "Detail",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.fromScale(1, 0),
		Size = UDim2.new(0, 278, 1, 0),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 0.35,
		BorderSizePixel = 0,
		Parent = parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.5 }),
	})

	detail = Create("ScrollingFrame", {
		Name = "DetailContent",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 2,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = detailFrame,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 7),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", {
			PaddingTop = UDim.new(0, 14),
			PaddingLeft = UDim.new(0, 14),
			PaddingRight = UDim.new(0, 14),
			PaddingBottom = UDim.new(0, 14),
		}),
	})

	trove:add(UIState.observe("parryHelper", function(mode)
		for id, choice in modeChoices do
			choice:setSelected(id == mode)
		end
		renderDetail()
	end))
end

return SettingsTab
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="151">
                <Properties>
                  <string name="Name">SkillsTab</string>
                  <string name="Source"><![CDATA[--!nonstrict
--[[
	SkillsTab — unlock abilities, then assign them to Q/E/R.

	Two jobs, stacked. The loadout strip at the top is what you actually press
	in a fight, so it sits above the list rather than buried in it; the list
	below is the shopping. Clicking a filled slot clears it.

	The list is filtered by the style chosen at creation, so it reads
	differently per build rather than showing a wall of greyed-out entries for
	weapons you'll never hold. Universal skills always appear.

	Passives are never slottable -- they apply on unlock -- and the detail pane
	says so explicitly instead of just hiding the equip row, which would look
	like a bug.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Parry = require(ReplicatedStorage.Shared.Config.Parry)
local Skills = require(ReplicatedStorage.Shared.Config.Skills)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Button = require(script.Parent.Parent.Parent.Components.Button)
local Loadout = require(script.Parent.Parent.Parent.Core.Loadout)
local UIState = require(script.Parent.Parent.Parent.Core.UIState)

local STRIP_TOP = 28
local STRIP_HEIGHT = 62
local LIST_TOP = STRIP_TOP + STRIP_HEIGHT + 14

local SkillsTab = {}

local list: ScrollingFrame
local detail: ScrollingFrame
local pointsLabel: TextLabel
local unlockButton
local rows: { [string]: TextButton } = {}
local slotCards: { [number]: TextButton } = {}
local equipButtons: { [number]: TextButton } = {}

--- Core skills and tree skills share the list, so every lookup has to check
--- both. Returns the skill and whether it is a core (ranked) one.
local function lookup(id: string?)
	local core = Skills.findCore(id)
	if core then
		return core, true
	end
	return Skills.find(id), false
end

local function isUnlocked(id: string): boolean
	return Loadout.isUnlocked(id)
end

--------------------------------------------------------------------------------
-- Loadout strip
--------------------------------------------------------------------------------

local function syncStrip()
	for index, card in slotCards do
		local slot = Skills.slot(index)
		local unlocked = Loadout.isSlotUnlocked(index)
		local skill = Skills.find(Loadout.get(index))

		card.Key.TextTransparency = if unlocked then 0 else 0.55

		if not unlocked then
			card.SkillName.Text = `Requires level {slot.levelRequired}`
			card.SkillName.TextColor3 = Theme.Color.TextDim
			card.Hint.Text = "Locked"
		elseif skill then
			card.SkillName.Text = skill.name
			card.SkillName.TextColor3 = Theme.Color.Text
			card.Hint.Text = if (skill.staminaCost or 0) > 0
				then `{skill.staminaCost} stamina · {skill.cooldown or 0}s`
				else `{skill.cooldown or 0}s cooldown`
		else
			card.SkillName.Text = "Empty"
			card.SkillName.TextColor3 = Theme.Color.TextDim
			card.Hint.Text = "Select a skill to assign"
		end

		local edge = card:FindFirstChild("Edge")
		if edge then
			edge.Color = if skill and unlocked then Theme.Color.Accent else Theme.Color.Stroke
			edge.Transparency = if not unlocked then 0.75 elseif skill then 0.25 else 0.55
		end
	end
end

local function buildStrip(parent: Frame)
	local strip = Create("Frame", {
		Name = "Loadout",
		Position = UDim2.fromOffset(0, STRIP_TOP),
		Size = UDim2.new(1, 0, 0, STRIP_HEIGHT),
		BackgroundTransparency = 1,
		Parent = parent,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			Padding = UDim.new(0, 10),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for _, slot in Skills.Slots do
		local card = Create("TextButton", {
			Name = `Slot{slot.index}`,
			Size = UDim2.fromOffset(232, STRIP_HEIGHT),
			BackgroundColor3 = Theme.Color.PanelRaised,
			BackgroundTransparency = 0.4,
			AutoButtonColor = false,
			BorderSizePixel = 0,
			Text = "",
			LayoutOrder = slot.index,
			Parent = strip,

			Create("UICorner", { CornerRadius = Theme.Radius.md }),
			Create("UIStroke", { Name = "Edge", Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.55 }),

			Create("TextLabel", {
				Name = "Key",
				Position = UDim2.fromOffset(14, 0),
				Size = UDim2.fromOffset(26, STRIP_HEIGHT),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Black,
				Text = slot.label,
				TextSize = 20,
				TextColor3 = Theme.Color.Accent,
				TextXAlignment = Enum.TextXAlignment.Left,
			}),

			Create("TextLabel", {
				Name = "SkillName",
				Position = UDim2.fromOffset(48, 13),
				Size = UDim2.new(1, -60, 0, 18),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.SemiBold,
				Text = "",
				TextSize = 14,
				TextColor3 = Theme.Color.Text,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextTruncate = Enum.TextTruncate.AtEnd,
			}),

			Create("TextLabel", {
				Name = "Hint",
				Position = UDim2.fromOffset(48, 31),
				Size = UDim2.new(1, -60, 0, 16),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Medium,
				Text = "",
				TextSize = 11,
				TextColor3 = Theme.Color.TextDim,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextTruncate = Enum.TextTruncate.AtEnd,
			}),
		})

		card.MouseEnter:Connect(function()
			Motion.to(card, Theme.Time.Fast, { BackgroundTransparency = 0.2 })
		end)
		card.MouseLeave:Connect(function()
			Motion.to(card, Theme.Time.Fast, { BackgroundTransparency = 0.4 })
		end)
		card.Activated:Connect(function()
			Loadout.clear(slot.index)
		end)

		slotCards[slot.index] = card
	end
end

--------------------------------------------------------------------------------
-- Detail pane
--------------------------------------------------------------------------------

local function syncEquipRow()
	local skill = Skills.find(UIState.get("selectedSkill"))
	local current = Loadout.slotOf(skill and skill.id)

	for index, button in equipButtons do
		local slotUnlocked = Loadout.isSlotUnlocked(index)
		local usable = skill ~= nil and Skills.isSlottable(skill) and isUnlocked(skill.id) and slotUnlocked
		local occupied = current == index

		button.AutoButtonColor = false
		button.BackgroundColor3 = if occupied then Theme.Color.Accent else Theme.Color.PanelRaised
		button.BackgroundTransparency = if usable or occupied then 0.1 else 0.6
		button.TextColor3 = if occupied
			then Theme.Color.TextInverse
			elseif usable then Theme.Color.Text
			else Theme.Color.TextDim
	end
end

local function renderDetail()
	for _, child in detail:GetChildren() do
		if not child:IsA("UIListLayout") and not child:IsA("UIPadding") then
			child:Destroy()
		end
	end
	table.clear(equipButtons)

	local skill = lookup(UIState.get("selectedSkill"))
	if not skill then
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 40),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = "Select a skill",
			TextSize = 13,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			Parent = detail,
		})
		unlockButton.Root.Visible = false
		return
	end

	local unlocked = isUnlocked(skill.id)
	local order = 0
	local function nextOrder(): number
		order += 1
		return order
	end

	-- Core skills are ranked, not slotted. Show the whole ladder so the player
	-- can see what the next point actually buys before spending it.
	if skill.core then
		local rank = UIState.get("parryRank") or 0

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 26),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = skill.name,
			TextSize = 19,
			TextColor3 = if rank > 0 then Theme.Color.Accent else Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = if rank > 0
				then `Rank {rank} / {Parry.maxRank()}  ·  key {skill.key}`
				else `Locked  ·  key {skill.key}`,
			TextSize = 12,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})

		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Regular,
			Text = skill.description,
			TextSize = 13,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextWrapped = true,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})

		for _, tier in Parry.Ranks do
			local owned = rank >= tier.rank
			local isNext = rank + 1 == tier.rank
			Create("TextLabel", {
				Size = UDim2.new(1, 0, 0, 0),
				AutomaticSize = Enum.AutomaticSize.Y,
				BackgroundTransparency = 1,
				FontFace = if isNext then Theme.Font.SemiBold else Theme.Font.Medium,
				Text = `{if owned then "✓" else "—"}  Rank {tier.rank}: {tier.summary}`,
				TextSize = 11,
				TextColor3 = if owned
					then Theme.Color.Success
					elseif isNext then Theme.Color.Text
					else Theme.Color.TextDim,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextWrapped = true,
				LayoutOrder = nextOrder(),
				Parent = detail,
			})
		end

		local cost = Parry.upgradeCost(rank)
		unlockButton.Root.Visible = cost ~= nil
		if cost then
			unlockButton:setEnabled((UIState.get("skillPoints") or 0) >= cost)
			unlockButton:setText(if rank == 0 then `Unlock — {cost}` else `Upgrade — {cost}`)
		end
		return
	end

	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 26),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = skill.name,
		TextSize = 19,
		TextColor3 = if unlocked then Theme.Color.Accent else Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextWrapped = true,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	local meta = if skill.passive
		then `Tier {skill.tier}  ·  Passive`
		else `Tier {skill.tier}  ·  {skill.cooldown or 0}s  ·  {skill.staminaCost or 0} stamina`

	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = meta,
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Regular,
		Text = skill.description,
		TextSize = 13,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextWrapped = true,
		LayoutOrder = nextOrder(),
		Parent = detail,
	})

	if not unlocked then
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = `LOCKED — {skill.cost} POINT{if skill.cost == 1 then "" else "S"}`,
			TextSize = 11,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})
	elseif skill.passive then
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = "PASSIVE — always active, no slot needed",
			TextSize = 11,
			TextColor3 = Theme.Color.Success,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextWrapped = true,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})
	else
		Create("TextLabel", {
			Size = UDim2.new(1, 0, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = "ASSIGN TO SLOT",
			TextSize = 10,
			TextColor3 = Theme.Color.AccentDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			LayoutOrder = nextOrder(),
			Parent = detail,
		})

		local row = Create("Frame", {
			Size = UDim2.new(1, 0, 0, 40),
			BackgroundTransparency = 1,
			LayoutOrder = nextOrder(),
			Parent = detail,

			Create("UIListLayout", {
				FillDirection = Enum.FillDirection.Horizontal,
				Padding = UDim.new(0, 8),
				SortOrder = Enum.SortOrder.LayoutOrder,
			}),
		})

		for _, slot in Skills.Slots do
			local button = Create("TextButton", {
				Name = slot.label,
				Size = UDim2.fromOffset(72, 40),
				BackgroundColor3 = Theme.Color.PanelRaised,
				BackgroundTransparency = 0.6,
				AutoButtonColor = false,
				BorderSizePixel = 0,
				FontFace = Theme.Font.Bold,
				Text = slot.label,
				TextSize = 15,
				TextColor3 = Theme.Color.TextDim,
				LayoutOrder = slot.index,
				Parent = row,

				Create("UICorner", { CornerRadius = Theme.Radius.md }),
			})

			button.Activated:Connect(function()
				local selected = UIState.get("selectedSkill")
				if Loadout.slotOf(selected) == slot.index then
					Loadout.clear(slot.index)
				else
					Loadout.assign(slot.index, selected)
				end
			end)

			equipButtons[slot.index] = button
		end

		syncEquipRow()
	end

	unlockButton.Root.Visible = not unlocked
	unlockButton:setEnabled((UIState.get("skillPoints") or 0) >= skill.cost)
	unlockButton:setText(`Unlock — {skill.cost}`)
end

--------------------------------------------------------------------------------
-- Skill list
--------------------------------------------------------------------------------

local function syncRows()
	local selected = UIState.get("selectedSkill")

	for id, row in rows do
		local skill, isCore = lookup(id)
		if not skill then
			continue
		end

		-- Core skills show a rank instead of a cost and a key instead of a slot.
		if isCore then
			local rank = UIState.get("parryRank") or 0
			row.Name1.TextColor3 = if rank > 0 then Theme.Color.Accent else Theme.Color.Text
			row.Sub.Text = if rank > 0 then `Rank {rank} / {Parry.maxRank()}` else "Locked"
			row.Cost.Text = if rank > 0 then tostring(rank) else "—"
			row.Cost.TextColor3 = if rank > 0 then Theme.Color.Unique else Theme.Color.TextMuted

			local coreEdge = row:FindFirstChild("Edge")
			if coreEdge then
				coreEdge.Transparency = if id == selected then 0 else 0.55
				coreEdge.Color = if id == selected then Theme.Color.Unique else Theme.Color.Stroke
			end
			continue
		end

		local unlocked = isUnlocked(id)
		local slotIndex = Loadout.slotOf(id)

		row.Name1.TextColor3 = if unlocked then Theme.Color.Accent else Theme.Color.Text
		row.Cost.Text = if unlocked then "✓" else tostring(skill.cost)
		row.Cost.TextColor3 = if unlocked then Theme.Color.Success else Theme.Color.TextMuted

		-- The slot badge is why this list is scannable: at a glance you can
		-- see which of your unlocks are actually bound to a key.
		if slotIndex then
			row.Slot.Text = Skills.slot(slotIndex).label
			row.Slot.TextTransparency = 0
		else
			row.Slot.Text = ""
			row.Slot.TextTransparency = 1
		end

		row.Sub.Text = if skill.passive then "Passive" else `{skill.cooldown or 0}s · {skill.staminaCost or 0} stamina`

		local edge = row:FindFirstChild("Edge")
		if edge then
			edge.Transparency = if id == selected then 0 else 0.55
			edge.Color = if id == selected then Theme.Color.Accent else Theme.Color.Stroke
		end
	end
end

--- One list row. Core skills get the same shape so the list reads as one
--- thing, but they carry a key cap instead of a loadout slot badge.
local function buildRow(skill, isCore: boolean?): TextButton
	local row = Create("TextButton", {
		Name = skill.id,
		Size = UDim2.new(1, -10, 0, 46),
		BackgroundColor3 = Theme.Color.PanelRaised,
		BackgroundTransparency = 0.4,
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Text = "",

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Name = "Edge", Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.55 }),

		Create("TextLabel", {
			Name = "Name1",
			Position = UDim2.fromOffset(14, 6),
			Size = UDim2.new(1, -90, 0, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = skill.name,
			TextSize = 14,
			TextColor3 = Theme.Color.Text,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
		}),

		Create("TextLabel", {
			Name = "Sub",
			Position = UDim2.fromOffset(14, 24),
			Size = UDim2.new(1, -90, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = "",
			TextSize = 11,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
		}),

		Create("TextLabel", {
			Name = "Slot",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -44, 0.5, 0),
			Size = UDim2.fromOffset(22, 22),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Black,
			Text = if isCore then skill.key else "",
			TextSize = 13,
			TextColor3 = if isCore then Theme.Color.Unique else Theme.Color.Accent,
			TextTransparency = if isCore then 0 else 1,
		}),

		Create("TextLabel", {
			Name = "Cost",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -14, 0.5, 0),
			Size = UDim2.fromOffset(26, 20),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = "",
			TextSize = 14,
			TextColor3 = Theme.Color.TextMuted,
			TextXAlignment = Enum.TextXAlignment.Right,
		}),
	})

	row.MouseEnter:Connect(function()
		Motion.to(row, Theme.Time.Fast, { BackgroundTransparency = 0.2 })
	end)
	row.MouseLeave:Connect(function()
		Motion.to(row, Theme.Time.Fast, { BackgroundTransparency = 0.4 })
	end)
	row.Activated:Connect(function()
		UIState.set("selectedSkill", skill.id)
	end)

	return row
end

local function rebuildList()
	for _, row in rows do
		row:Destroy()
	end
	table.clear(rows)

	local character = UIState.get("character")
	local available = Skills.forStyle(character and character.style)
	local lastTier = nil
	local order = 0

	-- Core skills sit above the tree: permanent, keybound, and not competing
	-- with any build choice.
	Create("TextLabel", {
		Name = "CoreHeading",
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "CORE",
		TextSize = 11,
		TextColor3 = Theme.Color.Unique,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = 0,
		Parent = list,
	})

	for _, core in Skills.Core do
		local row = buildRow(core, true)
		row.LayoutOrder = 1
		row.Parent = list
		rows[core.id] = row
	end

	for _, skill in available do
		order += 1

		if skill.tier ~= lastTier then
			lastTier = skill.tier
			Create("TextLabel", {
				Name = `TierHeading{skill.tier}`,
				Size = UDim2.new(1, 0, 0, 20),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.SemiBold,
				Text = `TIER {skill.tier}`,
				TextSize = 11,
				TextColor3 = Theme.Color.AccentDim,
				TextXAlignment = Enum.TextXAlignment.Left,
				LayoutOrder = order,
				Parent = list,
			})
			order += 1
		end

		local row = buildRow(skill)
		row.LayoutOrder = order
		row.Parent = list
		rows[skill.id] = row
	end

	syncRows()
	renderDetail()
end

--------------------------------------------------------------------------------

function SkillsTab.build(parent: Frame, trove)
	pointsLabel = Create("TextLabel", {
		Name = "Points",
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 13,
		TextColor3 = Theme.Color.Accent,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = parent,
	})

	buildStrip(parent)

	list = Create("ScrollingFrame", {
		Name = "List",
		Position = UDim2.fromOffset(0, LIST_TOP),
		Size = UDim2.new(1, -290, 1, -LIST_TOP),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = parent,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	local detailFrame = Create("Frame", {
		Name = "Detail",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, 0, 0, LIST_TOP),
		Size = UDim2.new(0, 278, 1, -LIST_TOP),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 0.35,
		BorderSizePixel = 0,
		Parent = parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.5 }),
	})

	detail = Create("ScrollingFrame", {
		Name = "DetailContent",
		Size = UDim2.new(1, 0, 1, -62),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 2,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = detailFrame,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 7),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", {
			PaddingTop = UDim.new(0, 14),
			PaddingLeft = UDim.new(0, 14),
			PaddingRight = UDim.new(0, 14),
		}),
	})

	unlockButton = Button.new({
		Parent = detailFrame,
		Text = "Unlock",
		Variant = "primary",
		Size = UDim2.new(1, -28, 0, 44),
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -14),
		OnClick = function()
			local skill, isCore = lookup(UIState.get("selectedSkill"))
			local points = UIState.get("skillPoints") or 0
			if not skill then
				return
			end

			if isCore then
				local rank = UIState.get("parryRank") or 0
				local cost = Parry.upgradeCost(rank)
				if not cost or points < cost then
					return
				end
				UIState.patch({ parryRank = rank + 1, skillPoints = points - cost })
				return
			end

			if isUnlocked(skill.id) or points < skill.cost then
				return
			end

			local unlocked = UIState.get("unlockedSkills")
			table.insert(unlocked, skill.id)
			UIState.patch({ unlockedSkills = unlocked, skillPoints = points - skill.cost })
		end,
	})
	trove:add(unlockButton)

	-- The list depends on the chosen style, so it rebuilds with the character.
	trove:add(UIState.observe("character", rebuildList))

	trove:add(UIState.observe("skillPoints", function(points)
		pointsLabel.Text = `{points or 0} skill point{if points == 1 then "" else "s"} available`
		renderDetail()
	end))

	trove:add(UIState.observeMany({ "unlockedSkills", "selectedSkill", "parryRank" }, function()
		syncRows()
		renderDetail()
	end))

	trove:add(UIState.observeMany({ "loadout", "level" }, function()
		syncStrip()
		syncRows()
		syncEquipRow()
	end))
end

return SkillsTab
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="152">
                <Properties>
                  <string name="Name">StatusTab</string>
                  <string name="Source"><![CDATA[--!nonstrict
--[[
	StatusTab — who you are.

	Everything here is real state, not invented numbers: vitals come from
	UIState, aptitudes are the style's own ratings, traits are the race's.
	When real stat derivation exists it slots into the Vitals column without
	changing the layout.

	Split into two halves on purpose. Vitals are built once and updated in
	place, because stamina changes every frame while sprinting and rebuilding
	the section on each tick would burn a whole frame's budget for three
	strings. The aptitude and trait sections only rebuild when the character
	itself changes, which is approximately never.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Characters = require(ReplicatedStorage.Shared.Config.Characters)
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Progression = require(ReplicatedStorage.Shared.Config.Progression)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local Bar = require(script.Parent.Parent.Parent.Components.Bar)
local Meter = require(script.Parent.Parent.Parent.Components.Meter)
local UIState = require(script.Parent.Parent.Parent.Core.UIState)

local StatusTab = {}

local function heading(parent: Instance, text: string, order: number): TextLabel
	return Create("TextLabel", {
		Name = "Heading",
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = string.upper(text),
		TextSize = 11,
		TextColor3 = Theme.Color.AccentDim,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = order,
		Parent = parent,
	})
end

--- Returns the value label so callers can update it without a rebuild.
local function statRow(parent: Instance, label: string, order: number, color: Color3?): TextLabel
	local row = Create("Frame", {
		Name = label,
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		LayoutOrder = order,
		Parent = parent,
	})

	Create("TextLabel", {
		Size = UDim2.fromScale(0.6, 1),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = label,
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = row,
	})

	return Create("TextLabel", {
		Name = "Value",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.fromScale(1, 0),
		Size = UDim2.fromScale(0.4, 1),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 12,
		TextColor3 = color or Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Right,
		Parent = row,
	})
end

function StatusTab.build(parent: Frame, trove)
	--------------------------------------------------------------------------
	-- Left: identity
	--------------------------------------------------------------------------

	local identity = Create("Frame", {
		Name = "Identity",
		Size = UDim2.new(0, 330, 1, 0),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 0.4,
		BorderSizePixel = 0,
		Parent = parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.55 }),
		Create("UIPadding", {
			PaddingTop = UDim.new(0, 20),
			PaddingLeft = UDim.new(0, 20),
			PaddingRight = UDim.new(0, 20),
		}),
		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	local nameLabel = Create("TextLabel", {
		Name = "Name",
		Size = UDim2.new(1, 0, 0, 30),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "",
		TextSize = 25,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextTruncate = Enum.TextTruncate.AtEnd,
		LayoutOrder = 1,
		Parent = identity,
	})

	local titleLabel = Create("TextLabel", {
		Name = "EquippedTitle",
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 13,
		TextColor3 = Theme.Color.Unique,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = 2,
		Parent = identity,
	})

	local buildLabel = Create("TextLabel", {
		Name = "Build",
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 13,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = 3,
		Parent = identity,
	})

	Create("Frame", {
		Name = "Spacer",
		Size = UDim2.new(1, 0, 0, 12),
		BackgroundTransparency = 1,
		LayoutOrder = 4,
		Parent = identity,
	})

	heading(identity, "Experience", 5)

	local xpLabel = Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 13,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = 6,
		Parent = identity,
	})

	local xpBar = Bar.new({
		Parent = identity,
		Size = UDim2.new(1, 0, 0, 8),
		Color = Theme.Color.Accent,
		ChipColor = Theme.Color.Text,
		Radius = Theme.Radius.pill,
		LayoutOrder = 7,
	})
	trove:add(xpBar)

	local deathsLabel = Create("TextLabel", {
		Size = UDim2.new(1, 0, 0, 34),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 12,
		TextColor3 = Theme.Color.TextDim,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextYAlignment = Enum.TextYAlignment.Bottom,
		LayoutOrder = 8,
		Parent = identity,
	})

	--------------------------------------------------------------------------
	-- Right: vitals (static structure) + aptitude/traits (rebuilt)
	--------------------------------------------------------------------------

	local detail = Create("ScrollingFrame", {
		Name = "Detail",
		Position = UDim2.fromOffset(350, 0),
		Size = UDim2.new(1, -350, 1, 0),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 2,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = parent,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 8),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", { PaddingRight = UDim.new(0, 12) }),
	})

	heading(detail, "Vitals", 1)
	local healthValue = statRow(detail, "Health", 2)
	local staminaValue = statRow(detail, "Stamina", 3)
	local poiseValue = statRow(detail, "Poise", 4)

	-- Everything from here down is rebuilt when the character changes.
	local dynamic = Create("Frame", {
		Name = "Dynamic",
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		LayoutOrder = 20,
		Parent = detail,

		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 8),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	local function rebuildDynamic()
		for _, child in dynamic:GetChildren() do
			if not child:IsA("UIListLayout") then
				child:Destroy()
			end
		end

		local character = UIState.get("character")
		local race = Characters.findRace(character and character.race)
		local style = Characters.findStyle(character and character.style)
		local order = 0
		local function nextOrder(): number
			order += 1
			return order
		end

		if style then
			heading(dynamic, `Aptitude — {style.name}`, nextOrder())
			for _, rating in style.ratings do
				Meter.new({
					Parent = dynamic,
					Label = rating.label,
					Value = rating.value,
					LayoutOrder = nextOrder(),
					Animate = false,
				})
			end
			statRow(dynamic, "Difficulty", nextOrder(), Theme.Color.StaminaEmpty).Text = style.difficulty
		end

		if race then
			heading(dynamic, `Traits — {race.name}`, nextOrder())
			for _, trait in race.traits do
				Create("TextLabel", {
					Size = UDim2.new(1, 0, 0, 0),
					AutomaticSize = Enum.AutomaticSize.Y,
					BackgroundTransparency = 1,
					FontFace = Theme.Font.Medium,
					Text = `— {trait}`,
					TextSize = 12,
					TextColor3 = Theme.Color.TextMuted,
					TextXAlignment = Enum.TextXAlignment.Left,
					TextWrapped = true,
					LayoutOrder = nextOrder(),
					Parent = dynamic,
				})
			end
		end
	end

	--------------------------------------------------------------------------
	-- Bindings
	--------------------------------------------------------------------------

	trove:add(UIState.observeMany({ "displayName", "character", "equippedTitle", "deaths" }, function()
		local character = UIState.get("character")
		local race = Characters.findRace(character and character.race)
		local style = Characters.findStyle(character and character.style)
		local title = Progression.findTitle(UIState.get("equippedTitle"))

		nameLabel.Text = UIState.get("displayName") or "Adventurer"
		titleLabel.Text = if title then title.name else "No title equipped"
		titleLabel.TextColor3 = if title then Theme.Color.Unique else Theme.Color.TextDim
		buildLabel.Text = `{race and race.name or "—"}  ·  {style and style.name or "—"}`
		deathsLabel.Text = `Deaths: {UIState.get("deaths") or 0}`

		rebuildDynamic()
	end))

	trove:add(UIState.observeMany({ "experience", "experienceToNext", "level" }, function()
		local current, target = UIState.get("experience"), UIState.get("experienceToNext")
		xpLabel.Text = `Level {UIState.get("level")}  ·  {current} / {target}`
		xpBar:setValue(current, target, true)
	end))

	-- Text-only updates, and skipped entirely while the menu is shut, so
	-- sprinting doesn't touch this at all.
	trove:add(UIState.observeMany({ "health", "stamina", "poise" }, function()
		if UIState.get("menuOpen") ~= "menu" then
			return
		end
		healthValue.Text = `{math.floor(UIState.get("health"))} / {UIState.get("maxHealth")}`
		staminaValue.Text = `{math.floor(UIState.get("stamina"))} / {UIState.get("maxStamina")}`
		poiseValue.Text = `{UIState.get("poise")} / {UIState.get("maxPoise")}`
	end))

	-- Repaint once on open, since the above skips while closed.
	trove:add(UIState.observe("menuOpen", function(mode)
		if mode ~= "menu" then
			return
		end
		healthValue.Text = `{math.floor(UIState.get("health"))} / {UIState.get("maxHealth")}`
		staminaValue.Text = `{math.floor(UIState.get("stamina"))} / {UIState.get("maxStamina")}`
		poiseValue.Text = `{UIState.get("poise")} / {UIState.get("maxPoise")}`
	end))
end

return StatusTab
]]></string>
                </Properties>
              </Item>
              <Item class="ModuleScript" referent="153">
                <Properties>
                  <string name="Name">TitlesTab</string>
                  <string name="Source"><![CDATA[--!nonstrict
--[[
	TitlesTab — earned titles, one equipped at a time.

	Unearned titles stay listed with their requirement hidden behind a locked
	state rather than being removed, so the list doubles as a goal board.
	Clicking an earned title equips it; clicking the equipped one clears it.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Progression = require(ReplicatedStorage.Shared.Config.Progression)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)

local UIState = require(script.Parent.Parent.Parent.Core.UIState)

local TitlesTab = {}

local cards: { [string]: TextButton } = {}
local hint: TextLabel

local function isEarned(id: string): boolean
	return table.find(UIState.get("earnedTitles") or {}, id) ~= nil
end

local function sync()
	local equipped = UIState.get("equippedTitle")

	for id, card in cards do
		local title = Progression.findTitle(id)
		local earned = isEarned(id)
		local rarity = Theme.Rarity[title.rarity] or Theme.Rarity.Common
		local isEquipped = id == equipped

		card.TitleName.Text = if earned then title.name else "— Locked —"
		card.TitleName.TextColor3 = if not earned
			then Theme.Color.TextDim
			elseif isEquipped then rarity.Color
			else Theme.Color.Text

		card.Detail.Text = if earned then title.description else "Requirement not yet met"
		card.Detail.TextColor3 = if earned then Theme.Color.TextMuted else Theme.Color.TextDim

		card.Badge.Text = if isEquipped then "EQUIPPED" else (if earned then rarity.Label else "LOCKED")
		card.Badge.TextColor3 = if isEquipped then Theme.Color.Success else rarity.Color
		card.Badge.TextTransparency = if earned then 0 else 0.5

		local edge = card:FindFirstChild("Edge")
		if edge then
			edge.Color = if isEquipped then Theme.Color.Success else rarity.Color
			edge.Transparency = if isEquipped then 0 elseif earned then 0.45 else 0.75
		end

		card.Marker.Size = UDim2.new(0, 2, if isEquipped then 1 else 0, 0)
	end

	local title = Progression.findTitle(equipped)
	hint.Text = if title
		then `Equipped: {title.name}. Click it again to remove it.`
		else "No title equipped. Click an earned title to wear it."
end

function TitlesTab.build(parent: Frame, trove)
	hint = Create("TextLabel", {
		Name = "Hint",
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 12,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = parent,
	})

	local list = Create("ScrollingFrame", {
		Name = "List",
		Position = UDim2.fromOffset(0, 30),
		Size = UDim2.new(1, 0, 1, -30),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		Parent = parent,

		Create("UIGridLayout", {
			CellSize = UDim2.fromOffset(370, 78),
			CellPadding = UDim2.fromOffset(10, 10),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index, title in Progression.Titles do
		local card = Create("TextButton", {
			Name = title.id,
			BackgroundColor3 = Theme.Color.PanelRaised,
			BackgroundTransparency = 0.4,
			AutoButtonColor = false,
			BorderSizePixel = 0,
			Text = "",
			LayoutOrder = index,
			Parent = list,

			Create("UICorner", { CornerRadius = Theme.Radius.md }),
			Create("UIStroke", { Name = "Edge", Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.55 }),

			Create("Frame", {
				Name = "Marker",
				Size = UDim2.new(0, 2, 0, 0),
				BackgroundColor3 = Theme.Color.Success,
				BorderSizePixel = 0,
			}),

			Create("TextLabel", {
				Name = "TitleName",
				Position = UDim2.fromOffset(16, 12),
				Size = UDim2.new(1, -110, 0, 20),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Bold,
				Text = "",
				TextSize = 15,
				TextColor3 = Theme.Color.Text,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextTruncate = Enum.TextTruncate.AtEnd,
			}),

			Create("TextLabel", {
				Name = "Badge",
				AnchorPoint = Vector2.new(1, 0),
				Position = UDim2.new(1, -14, 0, 14),
				Size = UDim2.fromOffset(96, 16),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.SemiBold,
				Text = "",
				TextSize = 10,
				TextColor3 = Theme.Color.TextMuted,
				TextXAlignment = Enum.TextXAlignment.Right,
			}),

			Create("TextLabel", {
				Name = "Detail",
				Position = UDim2.fromOffset(16, 36),
				Size = UDim2.new(1, -32, 0, 32),
				BackgroundTransparency = 1,
				FontFace = Theme.Font.Medium,
				Text = "",
				TextSize = 12,
				TextColor3 = Theme.Color.TextMuted,
				TextXAlignment = Enum.TextXAlignment.Left,
				TextYAlignment = Enum.TextYAlignment.Top,
				TextWrapped = true,
			}),
		})

		card.MouseEnter:Connect(function()
			Motion.to(card, Theme.Time.Fast, { BackgroundTransparency = 0.2 })
		end)
		card.MouseLeave:Connect(function()
			Motion.to(card, Theme.Time.Fast, { BackgroundTransparency = 0.4 })
		end)
		card.Activated:Connect(function()
			if not isEarned(title.id) then
				return
			end
			local equipped = UIState.get("equippedTitle")
			UIState.set("equippedTitle", if equipped == title.id then nil else title.id)
		end)

		cards[title.id] = card
	end

	trove:add(UIState.observeMany({ "earnedTitles", "equippedTitle" }, sync))
end

return TitlesTab
]]></string>
                </Properties>
              </Item>
            </Item>
            <Item class="ModuleScript" referent="154">
              <Properties>
                <string name="Name">Objective</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Objective — the tutorial's dialogue box and objective tracker.

	Two pieces: the instructor speaks along the bottom, and the current
	objective sits under the top-left vitals with a progress bar.

	TEXT IS DELIBERATELY LARGE. The first pass ran 10-15px, which is fine on a
	monitor two feet away and unreadable on a phone or a TV — and this is the
	one screen a new player *must* read. Tutorial copy gets roughly 1.5x the
	size of ordinary interface text, and the speaker line is the biggest text
	in the game outside a boss banner.

	Dialogue types out rather than appearing, and can be skipped by pressing
	anything — the point is that a player who already knows how to walk should
	never be held hostage by someone explaining walking.
]]

local RunService = game:GetService("RunService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Layer = require(script.Parent.Parent.Core.Layer)

local Objective = {}
local trove = Trove.new()

local dialogue: Frame
local speaker: TextLabel
local line: TextLabel
local prompt: TextLabel

local tracker: Frame
local objectiveLabel: TextLabel
local progressFill: Frame
local stepLabel: TextLabel

local fullText = ""
local shown = 0
local typing = false

--------------------------------------------------------------------------------

function Objective.start()
	local root = Layer.root("HUD", "Objective")

	--------------------------------------------------------------------------
	-- Dialogue, bottom centre and above the ability bar
	--------------------------------------------------------------------------

	dialogue = Create("Frame", {
		Name = "Dialogue",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -112),
		Size = UDim2.fromOffset(760, 108),
		BackgroundColor3 = Theme.Color.Void,
		BackgroundTransparency = 0.12,
		BorderSizePixel = 0,
		Visible = false,
		Parent = root,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.35 }),
		Create("UIPadding", {
			PaddingTop = UDim.new(0, 12),
			PaddingLeft = UDim.new(0, 18),
			PaddingRight = UDim.new(0, 18),
		}),
	})

	speaker = Create("TextLabel", {
		Name = "Speaker",
		Size = UDim2.new(1, 0, 0, 17),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "",
		TextSize = 19,
		TextColor3 = Theme.Color.Accent,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = dialogue,
	})

	line = Create("TextLabel", {
		Name = "Line",
		Position = UDim2.fromOffset(0, 22),
		Size = UDim2.new(1, 0, 0, 58),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 24,
		TextColor3 = Theme.Color.Text,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextYAlignment = Enum.TextYAlignment.Top,
		TextWrapped = true,
		Parent = dialogue,
	})

	prompt = Create("TextLabel", {
		Name = "Prompt",
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(1, 0, 1, -6),
		Size = UDim2.fromOffset(160, 16),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "",
		TextSize = 16,
		TextColor3 = Theme.Color.TextDim,
		TextXAlignment = Enum.TextXAlignment.Right,
		Parent = dialogue,
	})

	--------------------------------------------------------------------------
	-- Objective tracker, under the vitals
	--------------------------------------------------------------------------

	tracker = Create("Frame", {
		Name = "Tracker",
		Position = UDim2.fromOffset(26, 190),
		Size = UDim2.fromOffset(320, 44),
		BackgroundTransparency = 1,
		Visible = false,
		Parent = root,
	})

	stepLabel = Create("TextLabel", {
		Name = "Step",
		Size = UDim2.new(1, 0, 0, 14),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 15,
		TextColor3 = Theme.Color.AccentDim,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = tracker,
	})

	objectiveLabel = Create("TextLabel", {
		Name = "Objective",
		Position = UDim2.fromOffset(0, 15),
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.SemiBold,
		Text = "",
		TextSize = 20,
		TextColor3 = Theme.Color.Text,
		TextStrokeColor3 = Theme.Color.Void,
		TextStrokeTransparency = 0.5,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = tracker,
	})

	local track = Create("Frame", {
		Name = "Track",
		Position = UDim2.fromOffset(0, 37),
		Size = UDim2.fromOffset(220, 3),
		BackgroundColor3 = Theme.Color.Stroke,
		BackgroundTransparency = 0.4,
		BorderSizePixel = 0,
		Parent = tracker,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	progressFill = Create("Frame", {
		Size = UDim2.fromScale(0, 1),
		BackgroundColor3 = Theme.Color.Accent,
		BorderSizePixel = 0,
		Parent = track,

		Create("UICorner", { CornerRadius = Theme.Radius.pill }),
	})

	-- Typewriter.
	local accumulator = 0
	trove:connect(RunService.RenderStepped, function(dt)
		if not typing then
			return
		end
		accumulator += dt
		while accumulator > 0.018 and shown < #fullText do
			shown += 1
			accumulator -= 0.018
		end
		line.Text = string.sub(fullText, 1, shown)
		if shown >= #fullText then
			typing = false
			prompt.Text = ""
		end
	end)
end

--------------------------------------------------------------------------------

function Objective.say(who: string, text: string)
	dialogue.Visible = true
	speaker.Text = string.upper(who)
	fullText = text
	shown = 0
	typing = true
	prompt.Text = "any key to skip"

	dialogue.BackgroundTransparency = 1
	Motion.to(dialogue, Theme.Time.Base, { BackgroundTransparency = 0.12 })
end

--- Dumps the rest of the line instantly. Nobody should be held hostage by
--- someone explaining how to walk.
function Objective.skipTyping()
	if typing then
		shown = #fullText
	end
end

function Objective.hideDialogue()
	local tween = Motion.to(dialogue, Theme.Time.Base, { BackgroundTransparency = 1 })
	typing = false
	tween.Completed:Once(function()
		dialogue.Visible = false
		line.Text = ""
		speaker.Text = ""
	end)
end

function Objective.setObjective(text: string?, index: number?, total: number?)
	if not text then
		tracker.Visible = false
		return
	end

	tracker.Visible = true
	objectiveLabel.Text = text
	stepLabel.Text = if index and total then `STEP {index} OF {total}` else ""
	progressFill.Size = UDim2.fromScale(0, 1)
end

function Objective.setProgress(fraction: number)
	Motion.to(progressFill, Theme.Time.Fast, { Size = UDim2.fromScale(math.clamp(fraction, 0, 1), 1) })
end

function Objective.stop()
	trove:clean()
end

return Objective
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="155">
              <Properties>
                <string name="Name">Shop</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	Shop — buying and selling.

	Two columns: their stock on the left, your sellable materials on the right.
	Both are rebuilt whole whenever the server re-sends the shop, which it does
	after every transaction — the client never predicts a purchase, so there is
	no state here that can disagree with the server's.

	Affordability is shown on the row itself (price greys out when you can't
	pay) rather than only failing on click, because a shop that lets you press
	a button it knows will fail is a shop that wastes your time.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Network = require(ReplicatedStorage.Shared.Network)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Audio = require(ReplicatedStorage.Shared.Combat.Audio)

local Layer = require(script.Parent.Parent.Core.Layer)
local Panel = require(script.Parent.Parent.Components.Panel)

local Shop = {}

local panel
local goldLabel: TextLabel
local buyList: ScrollingFrame
local sellList: ScrollingFrame
local current: any = nil

Shop.isOpen = false

local function clear(list: Instance)
	for _, child in list:GetChildren() do
		if child:IsA("GuiButton") or child:IsA("Frame") then
			child:Destroy()
		end
	end
end

--- One purchasable or sellable line.
local function row(parent: Instance, options)
	local rarity = Theme.Rarity[options.rarity] or Theme.Rarity.Common
	local affordable = options.affordable ~= false

	local button = Create("TextButton", {
		Name = options.name,
		Size = UDim2.new(1, -8, 0, if options.description then 62 else 46),
		BackgroundColor3 = Theme.Color.PanelRaised,
		BackgroundTransparency = 0.4,
		AutoButtonColor = false,
		BorderSizePixel = 0,
		Text = "",
		LayoutOrder = options.order,
		Parent = parent,

		Create("UICorner", { CornerRadius = Theme.Radius.md }),
		Create("UIStroke", { Color = Theme.Color.Stroke, Thickness = 1, Transparency = 0.55 }),

		Create("Frame", {
			Name = "Stripe",
			Size = UDim2.new(0, 3, 1, 0),
			BackgroundColor3 = rarity.Color,
			BorderSizePixel = 0,
		}),

		Create("TextLabel", {
			Name = "Name",
			Position = UDim2.fromOffset(14, 7),
			Size = UDim2.new(1, -110, 0, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.SemiBold,
			Text = options.name,
			TextSize = 15,
			TextColor3 = if affordable then Theme.Color.Text else Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
		}),

		Create("TextLabel", {
			Name = "Price",
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, -12, 0, 7),
			Size = UDim2.fromOffset(92, 18),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Bold,
			Text = options.price,
			TextSize = 15,
			TextColor3 = if affordable then Theme.Color.Unique else Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Right,
		}),

		Create("TextLabel", {
			Name = "Sub",
			Position = UDim2.fromOffset(14, 25),
			Size = UDim2.new(1, -26, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = options.subtitle or rarity.Label,
			TextSize = 12,
			TextColor3 = rarity.Color,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
		}),
	})

	if options.description then
		Create("TextLabel", {
			Name = "Description",
			Position = UDim2.fromOffset(14, 41),
			Size = UDim2.new(1, -26, 0, 16),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Regular,
			Text = options.description,
			TextSize = 11,
			TextColor3 = Theme.Color.TextDim,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextTruncate = Enum.TextTruncate.AtEnd,
			Parent = button,
		})
	end

	button.MouseEnter:Connect(function()
		Audio.play("uiHover")
		button.BackgroundTransparency = 0.15
	end)
	button.MouseLeave:Connect(function()
		button.BackgroundTransparency = 0.4
	end)
	button.Activated:Connect(options.onClick)

	return button
end

local function render(payload)
	current = payload
	goldLabel.Text = `{payload.gold} G`

	clear(buyList)
	clear(sellList)

	for index, item in payload.stock do
		local affordable = payload.gold >= item.price
		row(buyList, {
			order = index,
			name = item.name,
			rarity = item.rarity,
			price = `{item.price} G`,
			affordable = affordable,
			subtitle = `{(Theme.Rarity[item.rarity] or Theme.Rarity.Common).Label}  ·  {item.slot or "Item"}`,
			description = item.description,
			onClick = function()
				if affordable then
					Network.Interact:FireServer(payload.npcId, "buy", item.id)
				else
					Audio.play("uiClose")
				end
			end,
		})
	end

	if #payload.sellable == 0 then
		Create("TextLabel", {
			Name = "Empty",
			Size = UDim2.new(1, -8, 0, 60),
			BackgroundTransparency = 1,
			FontFace = Theme.Font.Medium,
			Text = "Nothing they want.",
			TextSize = 13,
			TextColor3 = Theme.Color.TextDim,
			Parent = sellList,
		})
	else
		for index, material in payload.sellable do
			row(sellList, {
				order = index,
				name = `{material.name}  ×{material.count}`,
				rarity = material.rarity,
				price = `+{material.price} G`,
				subtitle = "Click to sell one",
				onClick = function()
					Network.Interact:FireServer(payload.npcId, "sell", material.id)
				end,
			})
		end
	end

	buyList.CanvasSize = UDim2.fromOffset(0, buyList.UIListLayout.AbsoluteContentSize.Y + 8)
	sellList.CanvasSize = UDim2.fromOffset(0, sellList.UIListLayout.AbsoluteContentSize.Y + 8)
end

function Shop.close()
	if not Shop.isOpen then
		return
	end
	Shop.isOpen = false
	panel:close()
	if current then
		Network.Interact:FireServer(current.npcId, "close")
	end
	current = nil
end

local function column(parent: Instance, title: string, xScale: number): ScrollingFrame
	local holder = Create("Frame", {
		Name = title,
		Position = UDim2.fromScale(xScale, 0),
		Size = UDim2.new(0.5, -Theme.Space.md, 1, 0),
		BackgroundTransparency = 1,
		Parent = parent,
	})

	Create("TextLabel", {
		Name = "Header",
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = string.upper(title),
		TextSize = 13,
		TextColor3 = Theme.Color.TextMuted,
		TextXAlignment = Enum.TextXAlignment.Left,
		Parent = holder,
	})

	local list = Create("ScrollingFrame", {
		Name = "List",
		Position = UDim2.fromOffset(0, 26),
		Size = UDim2.new(1, 0, 1, -26),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		CanvasSize = UDim2.new(),
		Parent = holder,

		Create("UIListLayout", {
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	return list
end

function Shop.start()
	local root = Layer.root("Menu", "Shop")

	panel = Panel.new({
		Parent = root,
		Size = UDim2.fromOffset(760, 470),
		Title = "Shop",
		Subtitle = "",
		Scrim = true,
		OnClose = function()
			Shop.close()
		end,
	})

	goldLabel = Create("TextLabel", {
		Name = "Gold",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, -54, 0, Theme.Space.lg),
		Size = UDim2.fromOffset(130, 24),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Bold,
		Text = "0 G",
		TextSize = 18,
		TextColor3 = Theme.Color.Unique,
		TextXAlignment = Enum.TextXAlignment.Right,
		ZIndex = 4,
		Parent = panel.Window,
	})

	local content = Create("Frame", {
		Name = "Columns",
		Size = UDim2.new(1, -Theme.Space.lg * 2, 1, -Theme.Space.lg),
		Position = UDim2.fromOffset(Theme.Space.lg, 0),
		BackgroundTransparency = 1,
		Parent = panel.Content,
	})

	buyList = column(content, "For sale", 0)
	sellList = column(content, "They'll buy", 0.5)

	Network.Shop.OnClientEvent:Connect(function(payload)
		if not payload then
			if Shop.isOpen then
				Shop.isOpen = false
				current = nil
				panel:close()
			end
			return
		end

		panel:setTitle(payload.name)
		panel:setSubtitle(payload.greeting or "")
		render(payload)

		if not Shop.isOpen then
			Shop.isOpen = true
			panel:open()
		end
	end)
end

return Shop
]]></string>
              </Properties>
            </Item>
            <Item class="ModuleScript" referent="156">
              <Properties>
                <string name="Name">TitleScreen</string>
                <string name="Source"><![CDATA[--!nonstrict
--[[
	TitleScreen — logo, tagline, and the way in.

	Deliberately sparse. The boot sequence just spent six seconds being loud,
	so this beat is quiet: a slow drifting grid, the title holding still, and
	one obvious button.
]]

local RunService = game:GetService("RunService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Create = require(ReplicatedStorage.Shared.Util.Create)
local Motion = require(ReplicatedStorage.Shared.Util.Motion)
local Theme = require(ReplicatedStorage.Shared.Config.Theme)
local Trove = require(ReplicatedStorage.Shared.Util.Trove)

local Button = require(script.Parent.Parent.Components.Button)
local Layer = require(script.Parent.Parent.Core.Layer)

local TitleScreen = {}
local trove = Trove.new()

local root: Frame
local group: Frame
local shown = false

function TitleScreen.start(onDive: () -> ())
	root = Layer.root("Frontend", "TitleScreen")
	root.Visible = false
	trove:add(root)

	Create("Frame", {
		Name = "Backdrop",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.Color.Void,
		BorderSizePixel = 0,
		Parent = root,
	})

	-- Slow drifting rule pattern; just enough motion to feel alive.
	local drift = Create("Frame", {
		Name = "Drift",
		Size = UDim2.fromScale(1, 1.4),
		Position = UDim2.fromScale(0, -0.2),
		BackgroundTransparency = 1,
		Parent = root,
	})

	for index = 1, 10 do
		Create("Frame", {
			Name = `Rule{index}`,
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.fromScale(0, index / 11),
			Size = UDim2.new(1, 0, 0, 1),
			BackgroundColor3 = Theme.Color.Accent,
			BackgroundTransparency = 0.94,
			BorderSizePixel = 0,
			Parent = drift,
		})
	end

	local offset = 0
	trove:connect(RunService.RenderStepped, function(dt)
		if not root.Visible then
			return
		end
		offset = (offset + dt * 0.008) % 0.0909
		drift.Position = UDim2.fromScale(0, -0.2 + offset)
	end)

	group = Create("Frame", {
		Name = "Group",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(760, 340),
		BackgroundTransparency = 1,
		Parent = root,
	})

	Create("TextLabel", {
		Name = "Title",
		Position = UDim2.fromScale(0, 0.1),
		Size = UDim2.new(1, 0, 0, 60),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Black,
		Text = "SHANGRI-LA FRONTIER",
		TextSize = 46,
		TextColor3 = Theme.Color.Text,
		Parent = group,
	})

	Create("Frame", {
		Name = "Rule",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 76),
		Size = UDim2.fromOffset(300, 1),
		BackgroundColor3 = Theme.Color.Accent,
		BorderSizePixel = 0,
		Parent = group,

		Create("UIGradient", {
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 1),
				NumberSequenceKeypoint.new(0.5, 0),
				NumberSequenceKeypoint.new(1, 1),
			}),
		}),
	})

	Create("TextLabel", {
		Name = "Tagline",
		Position = UDim2.fromOffset(0, 92),
		Size = UDim2.new(1, 0, 0, 22),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "A god-tier game, played by someone who only plays trash",
		TextSize = 14,
		TextColor3 = Theme.Color.TextMuted,
		Parent = group,
	})

	-- Debounced. `onDive` advances the whole front-end a phase, and the button
	-- stays on screen through the 0.34s hide tween — long enough for a second
	-- click to land and push the player through the transition twice.
	local dived = false
	local diveButton = Button.new({
		Parent = group,
		Text = "Dive In",
		Variant = "primary",
		Size = UDim2.fromOffset(260, 52),
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 172),
		OnClick = function()
			if dived then
				return
			end
			dived = true
			onDive()
		end,
	})
	trove:add(diveButton)

	-- Re-armed whenever the screen comes back, so the admin replay works.
	TitleScreen._rearm = function()
		dived = false
	end

	Create("TextLabel", {
		Name = "Version",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.fromScale(0.5, 1),
		Size = UDim2.new(1, 0, 0, 18),
		BackgroundTransparency = 1,
		FontFace = Theme.Font.Medium,
		Text = "v0.1.0 — pre-alpha",
		TextSize = 11,
		TextColor3 = Theme.Color.TextDim,
		Parent = group,
	})
end

function TitleScreen.show()
	if shown then
		return
	end
	shown = true

	if TitleScreen._rearm then
		TitleScreen._rearm()
	end

	root.Visible = true
	group.Position = UDim2.fromScale(0.5, 0.53)

	Motion.to(group, Theme.Time.Deliberate, { Position = UDim2.fromScale(0.5, 0.5) }, Theme.Ease.Enter)

	for _, descendant in group:GetDescendants() do
		if descendant:IsA("TextLabel") then
			descendant.TextTransparency = 1
			Motion.to(descendant, Theme.Time.Deliberate, { TextTransparency = 0 }, Theme.Ease.Standard, 0.1)
		end
	end
end

function TitleScreen.hide()
	if not shown then
		return
	end
	shown = false

	local tween = Motion.to(group, Theme.Time.Slow, { Position = UDim2.fromScale(0.5, 0.47) }, Theme.Ease.Exit)

	for _, descendant in group:GetDescendants() do
		if descendant:IsA("TextLabel") then
			Motion.to(descendant, Theme.Time.Slow, { TextTransparency = 1 })
		end
	end

	tween.Completed:Once(function()
		if not shown then
			root.Visible = false
		end
	end)
end

function TitleScreen.stop()
	trove:clean()
	shown = false
end

return TitleScreen
]]></string>
              </Properties>
            </Item>
          </Item>
        </Item>
      </Item>
    </Item>
  </Item>
  <Item class="Workspace" referent="157">
    <Properties>
      <string name="Name">Workspace</string>
      <token name="ModelStreamingBehavior">2</token>
      <bool name="NeedsPivotMigration">false</bool>
      <bool name="StreamingEnabled">true</bool>
      <token name="StreamingIntegrityMode">2</token>
      <int name="StreamingMinRadius">128</int>
      <int name="StreamingTargetRadius">640</int>
    </Properties>
  </Item>
</roblox>