{"id":"metricrb/skilltree","name":"skilltree","scope":"metricrb","platform":"roblox","description":"A reusable, declarative skill tree system for Roblox games with support for multiple tree topologies, multi-rank upgrades, exclusive choices, and flexible prerequisites.","version":"1.0.2","latest":"1.0.2","versions":["1.0.0","1.0.2"],"license":"MIT","licenseRating":"safe","licenseCaveats":["The package archive does not include its license text; the license is declared in its manifest metadata."],"licenseVerified":false,"dependencies":{"lm-loleris/profilestore":{"version":"^1.0.3","alias":"ProfileStore"}},"integrity":"2212f41eaa4548975dcc9f68983aa39677b0a3607bbb763e470dfa9d0a026071","likes":0,"downloads":0,"install":"forest install metricrb/skilltree","url":"https://forest.dev/p/roblox/metricrb/skilltree","files":"https://api.forest.dev/ai/package/roblox/metricrb/skilltree/files","readme":"# SkillTree\n\nA reusable, declarative skill tree system for Roblox games written in strict Luau. Fully typed, server-authoritative, and designed for multi-currency support and complex tree topologies.\n\n## Installation\n\nAdd to your project's `wally.toml` (this package is **server realm**; it pulls in [ProfileStore](https://github.com/MadStudioRoblox/ProfileStore) transitively):\n\n```toml\n[server-dependencies]\nSkillTree = \"metricrb/skilltree@1.0.0\"\n```\n\nThen run **`wally install`**. Server deps from **`[server-dependencies]`** (ProfileStore) install under **`ServerPackages/`**. In **this cloned repository**, **`[dev-dependencies]`** installs **`elttob/fusion@0.3.0`** under **`DevPackages/`** for the Fusion example — **`default.project.json`** maps **`SkillTree`** + **`DevPackages`** into **`ReplicatedStorage`** and **`ServerPackages`** into **`ServerScriptService`**.\n\n**Registry contents:** Only the library (`src/`) is published. Paths **`example/`** and **`tests/`** are listed under **`exclude`** in this repo’s `wally.toml`, so **they never ship with `wally install`**—they are only for cloning the repo (Rojo + Studio).\n\nFor your game, map `ServerPackages` under `ServerScriptService` like your Rojo/Wally defaults; **ProfileStore** resolves as `ServerScriptService.ServerPackages.ProfileStore`.\n\n**Tip:** To simplify imports, create a helper alias under `ServerScriptService`:\n```luau\n_G.SkillTree = require(script:WaitForChild(\"ServerPackages\"):WaitForChild(\"_Index\"):WaitForChild(\"metricrb_skilltree\"):WaitForChild(\"SkillTree\"))\n```\n\nThen use `_G.SkillTree.SkillTree.new()` throughout your game.\n\n## Repo layout (Rojo + Fusion demo — **clone only**)\n\nThis section is for **developers working in this repository**. It is **not** part of the Wally package (see **Installation** above).\n\nIn **this git repository**, Rojo maps the whole package to **`ReplicatedStorage.SkillTree`** (the `src/` folder), so server and client can `require` the same ModuleScripts:\n\n- **`ReplicatedStorage.SkillTreeDemo.ExampleConfig`** — shared demo `SkillTreeConfig`\n- **`ReplicatedStorage.DevPackages`** — **`wally`** dev-deps (**`Fusion`**) pulled by **`wally install`**, required for **`example/`**\n- **`ServerScriptService.SkillTreeDemoServer.Example`** — server script (`SkillTree.new`, `PlayerAdded`: `loadPlayer`, `addPoints`, `unloadPlayer`)\n- **`StarterPlayer.StarterPlayerScripts.SkillTreeDemoClient.ClientDemo`** — Fusion UI locally\n\nStudio **API access must be enabled** for ProfileStore. After **`wally install`**, use **`require(ReplicatedStorage.DevPackages.Fusion)`** in the demo (**`ClientDemo`** and **`Components`**).\n\nConsuming **`metricrb/skilltree` from the Registry** yields **`src/`** + **ProfileStore** transitively — not **Fusion**, which is **`[dev-dependencies]`** here only (`example/` is excluded from the published package anyway).\n\n## Quick Start\n\n### Server Setup\n\nImport from the installed Wally package:\n\n```luau\nlocal SkillTreePackage = require(game:GetService(\"ServerScriptService\"):WaitForChild(\"ServerPackages\"):WaitForChild(\"_Index\"):WaitForChild(\"metricrb_skilltree\"):WaitForChild(\"SkillTree\"))\nlocal SkillTree = SkillTreePackage.SkillTree\n\nlocal config = {\n  currencies = { \"points\", \"gold\" },\n  layout = \"branching\",\n  nodes = {\n    {\n      id = \"fireball\",\n      type = \"active\",\n      name = \"Fireball\",\n      maxRank = 1,\n      cost = { points = 20 },\n      prerequisites = nil,\n      position = { x = 0, y = 0 },\n      effectData = { damage = 50 },\n    },\n  },\n}\n\nlocal tree = SkillTree.new(config)\n\ngame:GetService(\"Players\").PlayerAdded:Connect(function(player)\n  local userId = tostring(player.UserId)\n  tree:loadPlayer(userId)\n  tree:addPoints(userId, \"points\", 100)\nend)\n\ngame:GetService(\"Players\").PlayerRemoving:Connect(function(player)\n  tree:unloadPlayer(tostring(player.UserId))\nend)\n\ntree.NodeUnlocked:connect(function(playerId, nodeId, rank)\n  print(playerId .. \" unlocked \" .. nodeId .. \" at rank \" .. rank)\nend)\n```\n\n### Client Usage\n\nThe published Wally artifact is **server realm**; `SkillTreeClient` and `Types` must be available to the client from **ReplicatedStorage** (or another replicated container) in your own Rojo layout. The example below assumes you map them under `ReplicatedStorage.SkillTree`.\n\n```luau\nlocal SkillTreeClient = require(game:GetService(\"ReplicatedStorage\"):WaitForChild(\"SkillTree\"):WaitForChild(\"SkillTreeClient\"))\n\nlocal remotes = {\n  unlockRequest = game:GetService(\"ReplicatedStorage\"):WaitForChild(\"SkillTreeUnlockRequest\"),\n  upgradeRequest = game:GetService(\"ReplicatedStorage\"):WaitForChild(\"SkillTreeUpgradeRequest\"),\n  stateUpdated = game:GetService(\"ReplicatedStorage\"):WaitForChild(\"SkillTreeStateUpdated\"),\n}\n\nlocal client = SkillTreeClient.new(remotes)\n\nclient:requestUnlock(\"fireball\")\nclient.StateUpdated:connect(function(state)\n  print(\"State updated:\", state)\nend)\n```\n\n## SkillTreeConfig Reference\n\n```luau\ntype SkillTreeConfig = {\n  categories: { CategoryConfig }?,      -- Top-level categories (radial layout)\n  nodes: { NodeConfig }?,               -- Flat node list (alternative to categories)\n  layout: \"linear\" | \"branching\" | \"web\" | \"radial\"?,  -- Layout topology\n  currencies: { string },               -- Currency names tracked per player\n}\n\ntype CategoryConfig = {\n  id: string,\n  name: string,\n  icon: string?,\n  description: string?,\n  position: { x: number, y: number },  -- Canvas position\n  nodes: { NodeConfig },                -- Category's skill nodes\n}\n\ntype NodeConfig = {\n  id: string,\n  type: \"passive\" | \"active\" | \"multirank\" | \"exclusive\",\n  name: string,\n  description: string?,\n  icon: string?,\n  maxRank: number,                      -- 1 for non-multirank\n  cost: { [string]: number },           -- Currency -> amount\n  prerequisites: Prerequisites?,        -- Unlock conditions\n  position: { x: number, y: number },   -- UI canvas position\n  effectData: { [string]: any }?,       -- Passed to effect handler\n  category: string?,                    -- Parent category ID\n  exclusiveGroup: string?,              -- For exclusive choice nodes\n}\n\ntype Prerequisites = {\n  level: { minLevel: number }?,\n  parents: { { nodeId: string } }?,\n  dependencies: { { nodeIds: { string } } }?,\n  custom: { { predicate: (playerId: string, state: PlayerSkillState) -> boolean } }?,\n}\n```\n\n## API Reference\n\n### Server-Side\n\n```luau\n-- Create tree from config\nlocal tree = SkillTree.new(config)\n\n-- Load player state from storage\ntree:loadPlayer(userId)\n\n-- Add currency points\ntree:addPoints(userId, \"points\", 50) -> boolean\n\n-- Unlock a node\ntree:unlock(userId, nodeId) -> { success: boolean, reason: string }\n\n-- Upgrade a multi-rank node\ntree:upgrade(userId, nodeId) -> { success: boolean, reason: string }\n\n-- Get player state\ntree:getState(userId) -> PlayerSkillState\n\n-- Get node config\ntree:getNode(nodeId) -> NodeConfig\n\n-- Full respec (refund all points, clear unlocks)\ntree:respec(userId) -> boolean\n\n-- Save and unload\ntree:savePlayer(userId) -> boolean\ntree:unloadPlayer(userId)\n\n-- Signal: fires when node unlocked\ntree.NodeUnlocked:connect(function(playerId, nodeId, rank) end)\n```\n\n### Client-Side\n\n```luau\nlocal client = SkillTreeClient.new(remoteConfig)\n\n-- Request unlock from server\nclient:requestUnlock(nodeId)\n\n-- Request upgrade from server\nclient:requestUpgrade(nodeId)\n\n-- Get current client state\nclient:getState() -> PlayerSkillState\n\n-- Listen for state updates from server\nclient.StateUpdated:connect(function(state) end)\n```\n\n## Example UI\n\n**(Repository only — not in the Wally package.)** A Fusion 0.3 demo lives under `example/`:\n\n- **`Example.server.luau`** — `require(ReplicatedStorage.SkillTree.SkillTree)` and drives live saves + remotes (`PlayerAdded`, `unloadPlayer`).\n- **`ClientDemo.client.luau`** — LocalScript Fusion UI wiring `SkillTreeClient` + the same **`ExampleConfig`** as the server (`ReplicatedStorage.SkillTreeDemo.ExampleConfig`), so visuals match server rules.\n- **`Components/`** — Radial + branch canvas HUD.\n\nDemonstrates:\n\n- **Split-view layout**: Radial category picker (left) + branching canvas (right)\n- **Pan and zoom**: Mouse drag for panning, scroll wheel for zoom\n- **Node tooltips**: Hover display with cost, rank, and prerequisites\n- **Animated unlocks**: Scale pulse and color transition on node unlock\n- **Live balance**: HUD showing current currency balance\n\nTo run it with Rojo: **`wally install`**, then **`rojo serve`**, sync so **`ReplicatedStorage.DevPackages`** (Fusion) and **`SkillTree`** are present; enable Studio datastore API access, hit Play — **Example** installs remotes **before** the client reads them; edit **`example/ExampleConfig.luau`** to change nodes or currencies once for both halves.\n\n## Testing\n\nRun tests with TestEZ (`tests/` Modules expect **`ReplicatedStorage.SkillTree`** per this repo’s **Rojo** tree):\n\n```bash\nrojo serve\n# In another terminal, or in-game:\n# Load the test suite and run\n```\n\nTest coverage includes:\n\n- **Validator**: Prerequisite evaluation (levels, parents, dependencies, custom predicates)\n- **Tree**: Unlock flow, multi-rank upgrades, exclusive node locking, respec\n- **Store**: Mad Studio ProfileStore integration and state serialization\n- **Currency**: Multi-currency balance tracking and spend logic\n\n## Code Standards\n\n- **--!strict** enforced on all modules\n- **Types module**: Central type definitions imported by all files\n- **Wally deps**: ProfileStore on the server (transitive for consumers). **Fusion** is **`[dev-dependencies]`** in this repo only — pulled into **`DevPackages/`** for the clone-only Fusion demo, not part of the published Registry bundle for games that only need the library.\n- **Moonwave docs**: Every public method and type fully documented\n- **Signal class**: Lightweight built-in event system (no GoodSignal dependency)\n\n## Moonwave Documentation Build\n\nGenerate HTML docs:\n\n```bash\nmoonwave install\nmoonwave build --out-dir site\n```\n\nStatic output is in **`site/`** (gitignored). Guides live in **`docs/*.md`**; the landing page is **`pages/index.md`**. See `.moonwave.toml` for GitHub Pages URL and base path.\n\n## Extending\n\n### Custom Effect Handlers\n\n```luau\nlocal tree = SkillTree.new(config)\n\ntree._effectHandler:register(\"fireball\", function(playerId, nodeId, rank, effectData)\n  -- Apply effect: give player ability, boost stat, etc.\n  print(\"Applied effect for \" .. nodeId .. \" rank \" .. rank)\nend)\n```\n\n### Custom Prerequisites\n\n```luau\nlocal nodeConfig = {\n  -- ...\n  prerequisites = {\n    custom = {\n      {\n        predicate = function(playerId, state)\n          return state.level >= 20\n        end,\n      },\n    },\n  },\n}\n```\n\n### Multiple Currencies\n\n```luau\nlocal config = {\n  currencies = { \"points\", \"gold\", \"essence\" },\n  -- ...\n  nodes = {\n    {\n      id = \"legendary_skill\",\n      cost = { points = 50, gold = 100, essence = 1 },\n      -- ...\n    },\n  },\n}\n```\n\n## License\n\nMIT\n","readmeTruncated":false}