From 50d609242dab94b9da95b002c41ce802da397cd8 Mon Sep 17 00:00:00 2001
From: Jeremiah <42397676+jlsnow301@users.noreply.github.com>
Date: Wed, 3 Sep 2025 01:01:44 -0700
Subject: [PATCH] [tgui] uselocalstate maintenance 2 (#92716)
## About The Pull Request
Refactors much of spellbook to rid it of some bad conventions and
improves style
Replaces use of uselocalstate -> useAtom
Moves spellbook to a folder to improve readability
## Why It's Good For The Game
useLocalState is deprecated
## Changelog
---
tgui/packages/tgui/interfaces/Spellbook.tsx | 792 ------------------
.../interfaces/Spellbook/CategoryDisplay.tsx | 51 ++
.../interfaces/Spellbook/EnscribedName.tsx | 24 +
.../tgui/interfaces/Spellbook/Loadouts.tsx | 113 +++
.../tgui/interfaces/Spellbook/Locked.tsx | 27 +
.../tgui/interfaces/Spellbook/Randomize.tsx | 51 ++
.../interfaces/Spellbook/SearchSpells.tsx | 60 ++
.../interfaces/Spellbook/SpellResults.tsx | 71 ++
.../interfaces/Spellbook/SpellTabDisplay.tsx | 122 +++
.../interfaces/Spellbook/TableOfContents.tsx | 101 +++
.../tgui/interfaces/Spellbook/constants.ts | 82 ++
.../tgui/interfaces/Spellbook/index.tsx | 102 +++
.../tgui/interfaces/Spellbook/types.ts | 71 ++
13 files changed, 875 insertions(+), 792 deletions(-)
delete mode 100644 tgui/packages/tgui/interfaces/Spellbook.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/CategoryDisplay.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/EnscribedName.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/Loadouts.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/Locked.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/Randomize.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/SearchSpells.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/SpellResults.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/SpellTabDisplay.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/TableOfContents.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/constants.ts
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/index.tsx
create mode 100644 tgui/packages/tgui/interfaces/Spellbook/types.ts
diff --git a/tgui/packages/tgui/interfaces/Spellbook.tsx b/tgui/packages/tgui/interfaces/Spellbook.tsx
deleted file mode 100644
index b349d36d1c4..00000000000
--- a/tgui/packages/tgui/interfaces/Spellbook.tsx
+++ /dev/null
@@ -1,792 +0,0 @@
-import type { ReactNode } from 'react';
-import {
- Box,
- Button,
- Dimmer,
- Divider,
- Icon,
- Input,
- NoticeBox,
- ProgressBar,
- Section,
- Stack,
-} from 'tgui-core/components';
-import type { BooleanLike } from 'tgui-core/react';
-
-import { useBackend, useLocalState } from '../backend';
-import { Window } from '../layouts';
-
-enum SpellCategory {
- Offensive = 'Offensive',
- Defensive = 'Defensive',
- Mobility = 'Mobility',
- Assistance = 'Assistance',
- Rituals = 'Rituals',
- Perks = 'Perks',
-}
-
-type byondRef = string;
-
-type SpellEntry = {
- // Name of the spell
- name: string;
- // Description of what the spell does
- desc: string;
- // Byond REF of the spell entry datum
- ref: byondRef;
- // Whether the spell requires wizard clothing to cast
- requires_wizard_garb: BooleanLike;
- // Spell points required to buy the spell
- cost: number;
- // How many times the spell has been bought
- times: number;
- // Cooldown length of the spell once cast once
- cooldown: number;
- // Category of the spell
- cat: SpellCategory;
- // Whether the spell is refundable
- refundable: BooleanLike;
- // The verb displayed when buying
- buyword: Buywords;
-};
-
-type Data = {
- owner: string;
- points: number;
- semi_random_bonus: number;
- full_random_bonus: number;
- entries: SpellEntry[];
-};
-
-type TabType = {
- title: string;
- blurb?: string;
- component?: () => ReactNode;
- locked?: boolean;
- scrollable?: boolean;
-};
-
-const TAB2NAME: TabType[] = [
- {
- title: 'Enscribed Name',
- blurb:
- "This book answers only to its owner, and of course, must have one. The permanence of the pact between a spellbook and its owner ensures such a powerful artifact cannot fall into enemy hands, or be used in ways that break the Federation's rules such as bartering spells.",
- component: () => ,
- },
- {
- title: 'Table of Contents',
- component: () => ,
- },
- {
- title: 'Offensive',
- blurb: 'Spells and items geared towards debilitating and destroying.',
- scrollable: true,
- },
- {
- title: 'Defensive',
- blurb:
- "Spells and items geared towards improving your survivability or reducing foes' ability to attack.",
- scrollable: true,
- },
- {
- title: 'Mobility',
- blurb:
- 'Spells and items geared towards improving your ability to move. It is a good idea to take at least one.',
- scrollable: true,
- },
- {
- title: 'Assistance',
- blurb:
- 'Spells and items geared towards bringing in outside forces to aid you or improving upon your other items and abilities.',
- scrollable: true,
- },
- {
- title: 'Challenges',
- blurb:
- 'The Wizard Federation is looking for shows of power. Arming the station against you will increase the danger, but will grant you more charges for your spellbook.',
- locked: true,
- scrollable: true,
- },
- {
- title: 'Rituals',
- blurb:
- 'These powerful spells change the very fabric of reality. Not always in your favour.',
- scrollable: true,
- },
- {
- title: 'Loadouts',
- blurb:
- 'The Wizard Federation accepts that sometimes, choosing is hard. You can choose from some approved wizard loadouts here.',
- component: () => ,
- },
- {
- title: 'Randomize',
- blurb:
- "If you didn't like the loadouts offered, you can embrace chaos. Not recommended for newer wizards.",
- component: () => ,
- },
- {
- title: 'Perks',
- blurb:
- 'Perks are useful (and not so useful) improvements to the soul and body collected from all corners of the universe.',
- scrollable: true,
- },
- {
- title: 'Table of Contents',
- component: () => ,
- },
-];
-
-enum Buywords {
- Learn = 'Learn',
- Summon = 'Summon',
- Cast = 'Cast',
-}
-
-const BUYWORD2ICON = {
- Learn: 'plus',
- Summon: 'hat-wizard',
- Cast: 'meteor',
-};
-
-const EnscribedName = (props) => {
- const { data } = useBackend();
- const { owner } = data;
- return (
- <>
-
- {owner}
-
-
- >
- );
-};
-
-const lineHeightToc = '30.6px';
-
-const TableOfContents = (props) => {
- const [tabIndex, setTabIndex] = useLocalState('tab-index', 1);
- return (
-
-
-
-
-
- );
-};
-
-const LockedPage = (props) => {
- const { act, data } = useBackend();
- const { owner } = data;
- return (
-
-
-
-
-
-
- The Wizard Federation has locked this page.
-
-
-
- );
-};
-
-const PointLocked = (props) => {
- return (
-
-
-
-
-
-
-
- You do not have enough points to use this page.
-
-
-
- );
-};
-
-type WizardLoadout = {
- loadoutId: string;
- loadoutColor: string;
- name: string;
- blurb: string;
- icon: string;
- author: string;
-};
-
-const SingleLoadout = (props: WizardLoadout) => {
- const { act } = useBackend();
- const { author, name, blurb, icon, loadoutId, loadoutColor } = props;
- return (
-
-
- {blurb}
-
-
- act('purchase_loadout', {
- id: loadoutId,
- })
- }
- />
-
- Added by {author}.
-
-
- );
-};
-
-const LoadoutWidth = 19.17;
-
-const Loadouts = (props) => {
- const { data } = useBackend();
- const { points } = data;
- // Future todo : Make these datums on the DM side
- return (
-
- {points < 10 && }
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-const lineHeightRandomize = 6;
-
-const Randomize = (props) => {
- const { act, data } = useBackend();
- const { points, semi_random_bonus, full_random_bonus } = data;
- return (
-
- {points < 10 && }
-
- Semi-Randomize will ensure you at least get some mobility and lethality.
- Guaranteed to have {semi_random_bonus} points worth of spells.
-
-
- act('semirandomize')}
- />
-
-
-
- Full Random will give you anything. There's no going back, either!
- Guaranteed to have {full_random_bonus} points worth of spells.
-
-
-
- act('randomize')}
- />
-
-
-
- );
-};
-
-const SearchSpells = (props) => {
- const { data } = useBackend();
- const [spellSearch] = useLocalState('spell-search', '');
- const { entries } = data;
-
- const filterEntryList = (entries: SpellEntry[]) => {
- const searchStatement = spellSearch.toLowerCase();
- if (searchStatement === 'robeless') {
- // Lets you just search for robeless spells, you're welcome mindswap-bros
- return entries.filter((entry) => !entry.requires_wizard_garb);
- }
-
- return entries.filter(
- (entry) =>
- entry.name.toLowerCase().includes(searchStatement) ||
- // Unsure about including description. Wizard spell descriptions
- // are painfully original and use the same verbiage often,
- // which may both be a benefit and a curse
- entry.desc.toLowerCase().includes(searchStatement) ||
- // Also opting to include category
- // so you can search "rituals" to see them all at once
- entry.cat.toLowerCase().includes(searchStatement),
- );
- };
-
- const filteredEntries = filterEntryList(entries);
-
- if (filteredEntries.length === 0) {
- return (
-
-
- {`No spells found!`}
-
-
-
- {`Search tip: Searching "Robeless" will only show you
- spells that don't require wizard garb!`}
-
-
-
- );
- }
- return (
-
- );
-};
-
-const SpellTabDisplay = (props: {
- TabSpells: SpellEntry[];
- CooldownOffset?: number;
- PointOffset?: number;
-}) => {
- const { act, data } = useBackend();
- const { points } = data;
- const { TabSpells, CooldownOffset, PointOffset } = props;
-
- const getTimeOrCat = (entry: SpellEntry) => {
- if (entry.cat === SpellCategory.Rituals) {
- if (entry.times) {
- return `Cast ${entry.times} times.`;
- } else {
- return 'Not cast yet.';
- }
- } else {
- if (entry.cooldown) {
- return `${entry.cooldown}s Cooldown`;
- } else {
- return '';
- }
- }
- };
-
- return (
-
- {TabSpells.sort((a, b) => a.name.localeCompare(b.name)).map((entry) => (
-
-
-
-
- {getTimeOrCat(entry)}
-
-
- {entry.cost} points
-
- {entry.buyword === Buywords.Learn && (
-
-
-
- )}
-
-
-
- {entry.desc}
-
-
-
-
- = entry.cost ? 'green' : 'bad'}
- disabled={points < entry.cost}
- width={7}
- icon={BUYWORD2ICON[entry.buyword]}
- content={entry.buyword}
- onClick={() =>
- act('purchase', {
- spellref: entry.ref,
- })
- }
- />
-
- {(!entry.refundable && No refunds.) || (
-
- act('refund', {
- spellref: entry.ref,
- })
- }
- />
- )}
-
-
-
-
- ))}
-
- );
-};
-
-const CategoryDisplay = (props: { ActiveCat: TabType }) => {
- const { data } = useBackend();
- const { entries } = data;
- const { ActiveCat } = props;
-
- const TabSpells = entries.filter((entry) => entry.cat === ActiveCat.title);
-
- return (
- <>
- {!!ActiveCat.locked && }
-
- {ActiveCat.blurb && (
-
-
- {ActiveCat.blurb}
-
-
- )}
-
- {ActiveCat.component?.() || (
-
- )}
-
-
- >
- );
-};
-
-const widthSection = '466px';
-const heightSection = '456px';
-
-export const Spellbook = (props) => {
- const { data } = useBackend();
- const { points } = data;
- const [tabIndex, setTabIndex] = useLocalState('tab-index', 1);
- const [spellSearch, setSpellSearch] = useLocalState('spell-search', '');
- const ActiveCat = TAB2NAME[tabIndex - 1];
- const ActiveNextCat = TAB2NAME[tabIndex];
-
- // Has a chance of selecting a random funny verb instead of "Searching"
- const SelectSearchVerb = () => {
- const found = Math.random();
- if (found <= 0.03) {
- return 'Seeking';
- }
- if (found <= 0.06) {
- return 'Contemplating';
- }
- if (found <= 0.09) {
- return 'Divining';
- }
- if (found <= 0.12) {
- return 'Scrying';
- }
- if (found <= 0.15) {
- return 'Peeking';
- }
- if (found <= 0.18) {
- return 'Pondering';
- }
- if (found <= 0.21) {
- return 'Divining';
- }
- if (found <= 0.24) {
- return 'Gazing';
- }
- if (found <= 0.27) {
- return 'Studying';
- }
- if (found <= 0.3) {
- return 'Reviewing';
- }
-
- return 'Searching';
- };
-
- const SelectedVerb = SelectSearchVerb();
-
- return (
-
-
-
-
-
- {spellSearch.length > 1 ? (
-
- setSpellSearch('')}
- />
- }
- >
-
-
-
- ) : (
- <>
-
-
- setTabIndex(tabIndex - 2)}
- />
-
- {tabIndex}
-
- >
- }
- >
-
-
-
-
-
- setTabIndex(tabIndex + 2)}
- />
-
- {tabIndex + 1}
-
- >
- }
- >
-
-
-
- >
- )}
-
-
-
-
-
-
-
-
- );
-};
diff --git a/tgui/packages/tgui/interfaces/Spellbook/CategoryDisplay.tsx b/tgui/packages/tgui/interfaces/Spellbook/CategoryDisplay.tsx
new file mode 100644
index 00000000000..b090c4cb087
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/CategoryDisplay.tsx
@@ -0,0 +1,51 @@
+import { Box, Dimmer, Icon, Stack } from 'tgui-core/components';
+import { useBackend } from '../../backend';
+import { SpellTabDisplay } from './SpellTabDisplay';
+import type { SpellbookData, TabType } from './types';
+
+type Props = {
+ activeCat: TabType;
+};
+
+export function CategoryDisplay(props: Props) {
+ const { data } = useBackend();
+ const { entries } = data;
+ const { activeCat } = props;
+
+ const tabSpells = entries.filter((entry) => entry.cat === activeCat.title);
+
+ return (
+ <>
+ {!!activeCat.locked && }
+
+ {activeCat.blurb && (
+
+
+ {activeCat.blurb}
+
+
+ )}
+
+ {activeCat.component?.() || (
+
+ )}
+
+
+ >
+ );
+}
+
+function LockedPage(props) {
+ return (
+
+
+
+
+
+
+ The Wizard Federation has locked this page.
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/EnscribedName.tsx b/tgui/packages/tgui/interfaces/Spellbook/EnscribedName.tsx
new file mode 100644
index 00000000000..b512678c0ad
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/EnscribedName.tsx
@@ -0,0 +1,24 @@
+import { Box, Divider } from 'tgui-core/components';
+import { useBackend } from '../../backend';
+import type { SpellbookData } from './types';
+
+export function EnscribedName(props) {
+ const { data } = useBackend();
+ const { owner } = data;
+
+ return (
+ <>
+
+ {owner}
+
+
+ >
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/Loadouts.tsx b/tgui/packages/tgui/interfaces/Spellbook/Loadouts.tsx
new file mode 100644
index 00000000000..a716d3a0f28
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/Loadouts.tsx
@@ -0,0 +1,113 @@
+import { Box, Button, Divider, Section, Stack } from 'tgui-core/components';
+import { useBackend } from '../../backend';
+import { PointLocked } from './Locked';
+import type { SpellbookData } from './types';
+
+export function Loadouts(props) {
+ const { data } = useBackend();
+ const { points } = data;
+ // Future todo : Make these datums on the DM side
+ return (
+
+ {points < 10 && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+type Props = {
+ author: string;
+ blurb: string;
+ icon: string;
+ loadoutColor: string;
+ loadoutId: string;
+ name: string;
+};
+
+function SingleLoadout(props: Props) {
+ const { act } = useBackend();
+ const { author, name, blurb, icon, loadoutId, loadoutColor } = props;
+
+ return (
+
+
+ {blurb}
+
+
+ act('purchase_loadout', {
+ id: loadoutId,
+ })
+ }
+ >
+ Purchase Loadout
+
+
+ Added by {author}.
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/Locked.tsx b/tgui/packages/tgui/interfaces/Spellbook/Locked.tsx
new file mode 100644
index 00000000000..de584a7718c
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/Locked.tsx
@@ -0,0 +1,27 @@
+import { Dimmer, Icon, Stack } from 'tgui-core/components';
+
+export function PointLocked(props) {
+ return (
+
+
+
+
+
+
+
+ You do not have enough points to use this page.
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/Randomize.tsx b/tgui/packages/tgui/interfaces/Spellbook/Randomize.tsx
new file mode 100644
index 00000000000..0f769c2c433
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/Randomize.tsx
@@ -0,0 +1,51 @@
+import { Button, Divider, NoticeBox, Stack } from 'tgui-core/components';
+import { useBackend } from '../../backend';
+import { PointLocked } from './Locked';
+import type { SpellbookData } from './types';
+
+export function Randomize(props) {
+ const { act, data } = useBackend();
+ const { points, semi_random_bonus, full_random_bonus } = data;
+
+ return (
+
+ {points < 10 && }
+
+ Semi-Randomize will ensure you at least get some mobility and lethality.
+ Guaranteed to have {semi_random_bonus} points worth of spells.
+
+
+ act('semirandomize')}
+ >
+ Semi-Randomize
+
+
+
+
+ Full Random will give you anything. There's no going back, either!
+ Guaranteed to have {full_random_bonus} points worth of spells.
+
+
+
+ act('randomize')}
+ >
+ Full Random
+
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/SearchSpells.tsx b/tgui/packages/tgui/interfaces/Spellbook/SearchSpells.tsx
new file mode 100644
index 00000000000..334ab1855a9
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/SearchSpells.tsx
@@ -0,0 +1,60 @@
+import { useAtom } from 'jotai';
+import { Box, NoticeBox, Stack } from 'tgui-core/components';
+import { useBackend } from '../../backend';
+import { spellSearchAtom } from '.';
+import { SpellTabDisplay } from './SpellTabDisplay';
+import type { SpellbookData, SpellEntry } from './types';
+
+export function SearchSpells(props) {
+ const { data } = useBackend();
+ const [spellSearch] = useAtom(spellSearchAtom);
+ const { entries } = data;
+
+ const searchStatement = spellSearch.toLowerCase();
+
+ let filteredEntries: SpellEntry[] = [];
+
+ if (searchStatement === 'robeless') {
+ // Lets you just search for robeless spells, you're welcome mindswap-bros
+ filteredEntries = entries.filter((entry) => !entry.requires_wizard_garb);
+ } else {
+ filteredEntries = entries.filter(
+ (entry) =>
+ entry.name.toLowerCase().includes(searchStatement) ||
+ // Unsure about including description. Wizard spell descriptions
+ // are painfully original and use the same verbiage often,
+ // which may both be a benefit and a curse
+ entry.desc
+ .toLowerCase()
+ .includes(searchStatement) ||
+ // Also opting to include category
+ // so you can search "rituals" to see them all at once
+ entry.cat
+ .toLowerCase()
+ .includes(searchStatement),
+ );
+ }
+
+ if (filteredEntries.length === 0) {
+ return (
+
+
+ No spells found!
+
+
+
+ Search tip: Searching "Robeless" will only show you spells that
+ don't require wizard garb!
+
+
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/SpellResults.tsx b/tgui/packages/tgui/interfaces/Spellbook/SpellResults.tsx
new file mode 100644
index 00000000000..424f96e458b
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/SpellResults.tsx
@@ -0,0 +1,71 @@
+import { useAtom } from 'jotai';
+import { Box, Button, Section, Stack } from 'tgui-core/components';
+import { heightSection, tabAtom, widthSection } from '.';
+import { CategoryDisplay } from './CategoryDisplay';
+import { TAB2NAME } from './constants';
+
+export function SpellResults() {
+ const [tabIndex, setTabIndex] = useAtom(tabAtom);
+
+ const activeCat = TAB2NAME[tabIndex - 1];
+ const activeNextCat = TAB2NAME[tabIndex];
+
+ return (
+ <>
+
+
+ setTabIndex(tabIndex - 2)}
+ >
+ Previous Page
+
+
+ {tabIndex}
+
+ >
+ }
+ >
+
+
+
+
+
+ setTabIndex(tabIndex + 2)}
+ >
+ Next Page
+
+
+ {tabIndex + 1}
+
+ >
+ }
+ >
+
+
+
+ >
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/SpellTabDisplay.tsx b/tgui/packages/tgui/interfaces/Spellbook/SpellTabDisplay.tsx
new file mode 100644
index 00000000000..c3ccd0b046a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/SpellTabDisplay.tsx
@@ -0,0 +1,122 @@
+import {
+ Button,
+ Divider,
+ NoticeBox,
+ Section,
+ Stack,
+} from 'tgui-core/components';
+import { useBackend } from '../../backend';
+import { BUYWORD2ICON } from './constants';
+import {
+ Buywords,
+ type SpellbookData,
+ SpellCategory,
+ type SpellEntry,
+} from './types';
+
+type Props = {
+ tabSpells: SpellEntry[];
+ cooldownOffset?: number;
+ pointOffset?: number;
+};
+
+function getTimeOrCat(entry: SpellEntry) {
+ if (entry.cat === SpellCategory.Rituals) {
+ if (entry.times) {
+ return `Cast ${entry.times} times.`;
+ } else {
+ return 'Not cast yet.';
+ }
+ } else {
+ if (entry.cooldown) {
+ return `${entry.cooldown}s Cooldown`;
+ } else {
+ return '';
+ }
+ }
+}
+
+export function SpellTabDisplay(props: Props) {
+ const { act, data } = useBackend();
+ const { points } = data;
+ const { tabSpells, cooldownOffset, pointOffset } = props;
+
+ return (
+
+ {tabSpells
+ .sort((a, b) => {
+ return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
+ })
+ .map((entry) => (
+
+
+
+
+ {getTimeOrCat(entry)}
+
+
+ {entry.cost} points
+
+ {entry.buyword === Buywords.Learn && (
+
+
+
+ )}
+
+
+
+ {entry.desc}
+
+
+
+
+ = entry.cost ? 'green' : 'bad'}
+ disabled={points < entry.cost}
+ width={7}
+ icon={BUYWORD2ICON[entry.buyword]}
+ onClick={() =>
+ act('purchase', {
+ spellref: entry.ref,
+ })
+ }
+ >
+ {entry.buyword}
+
+
+ {!entry.refundable ? (
+ No refunds.
+ ) : (
+
+ act('refund', {
+ spellref: entry.ref,
+ })
+ }
+ >
+ Refund
+
+ )}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/TableOfContents.tsx b/tgui/packages/tgui/interfaces/Spellbook/TableOfContents.tsx
new file mode 100644
index 00000000000..8ab6fb902fe
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/TableOfContents.tsx
@@ -0,0 +1,101 @@
+import { useAtom } from 'jotai';
+import { Box, Button, Divider } from 'tgui-core/components';
+import { tabAtom } from '.';
+import { Tab } from './types';
+
+export const lineHeightToc = '30.6px';
+
+export function TableOfContents(props) {
+ const [_tabIndex, setTabIndex] = useAtom(tabAtom);
+
+ return (
+
+
+ Name Enscription
+
+
+
+ Table of Contents
+
+
+
+ setTabIndex(Tab.Defensive)}
+ >
+ Deadly Evocations
+
+
+ setTabIndex(Tab.Defensive)}
+ >
+ Defensive Evocations
+
+
+ setTabIndex(Tab.Assistance)}
+ >
+ Magical Transportation
+
+ setTabIndex(Tab.Assistance)}
+ >
+ Assistance and Summoning
+
+
+ setTabIndex(Tab.Rituals)}
+ >
+ Challenges
+
+ setTabIndex(Tab.Rituals)}
+ >
+ Rituals
+
+
+ setTabIndex(Tab.Randomize)}
+ >
+ Wizard Approved Loadouts
+
+ setTabIndex(Tab.Randomize)}
+ >
+ Arcane Randomizer
+
+
+ setTabIndex(Tab.TableOfContents2)}
+ >
+ Perks
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/constants.ts b/tgui/packages/tgui/interfaces/Spellbook/constants.ts
new file mode 100644
index 00000000000..7c3760d8e69
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/constants.ts
@@ -0,0 +1,82 @@
+import { EnscribedName } from './EnscribedName';
+import { Loadouts } from './Loadouts';
+import { Randomize } from './Randomize';
+import { TableOfContents } from './TableOfContents';
+import type { TabType } from './types';
+
+export const TAB2NAME: TabType[] = [
+ {
+ title: 'Enscribed Name',
+ blurb:
+ "This book answers only to its owner, and of course, must have one. The permanence of the pact between a spellbook and its owner ensures such a powerful artifact cannot fall into enemy hands, or be used in ways that break the Federation's rules such as bartering spells.",
+ component: EnscribedName,
+ },
+ {
+ title: 'Table of Contents',
+ component: TableOfContents,
+ },
+ {
+ title: 'Offensive',
+ blurb: 'Spells and items geared towards debilitating and destroying.',
+ scrollable: true,
+ },
+ {
+ title: 'Defensive',
+ blurb:
+ "Spells and items geared towards improving your survivability or reducing foes' ability to attack.",
+ scrollable: true,
+ },
+ {
+ title: 'Mobility',
+ blurb:
+ 'Spells and items geared towards improving your ability to move. It is a good idea to take at least one.',
+ scrollable: true,
+ },
+ {
+ title: 'Assistance',
+ blurb:
+ 'Spells and items geared towards bringing in outside forces to aid you or improving upon your other items and abilities.',
+ scrollable: true,
+ },
+ {
+ title: 'Challenges',
+ blurb:
+ 'The Wizard Federation is looking for shows of power. Arming the station against you will increase the danger, but will grant you more charges for your spellbook.',
+ locked: true,
+ scrollable: true,
+ },
+ {
+ title: 'Rituals',
+ blurb:
+ 'These powerful spells change the very fabric of reality. Not always in your favour.',
+ scrollable: true,
+ },
+ {
+ title: 'Loadouts',
+ blurb:
+ 'The Wizard Federation accepts that sometimes, choosing is hard. You can choose from some approved wizard loadouts here.',
+ component: Loadouts,
+ },
+ {
+ title: 'Randomize',
+ blurb:
+ "If you didn't like the loadouts offered, you can embrace chaos. Not recommended for newer wizards.",
+ component: Randomize,
+ },
+ {
+ title: 'Perks',
+ blurb:
+ 'Perks are useful (and not so useful) improvements to the soul and body collected from all corners of the universe.',
+ scrollable: true,
+ },
+ {
+ title: 'Table of Contents',
+ component: TableOfContents,
+ },
+];
+
+export const BUYWORD2ICON = {
+ Learn: 'plus',
+ Summon: 'hat-wizard',
+ Cast: 'meteor',
+};
diff --git a/tgui/packages/tgui/interfaces/Spellbook/index.tsx b/tgui/packages/tgui/interfaces/Spellbook/index.tsx
new file mode 100644
index 00000000000..7df95693488
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/index.tsx
@@ -0,0 +1,102 @@
+import { sample } from 'es-toolkit';
+import { atom, useAtom } from 'jotai';
+import { useEffect, useState } from 'react';
+import {
+ Button,
+ Input,
+ ProgressBar,
+ Section,
+ Stack,
+} from 'tgui-core/components';
+import { useBackend } from '../../backend';
+import { Window } from '../../layouts';
+import { SearchSpells } from './SearchSpells';
+import { SpellResults } from './SpellResults';
+import { type SpellbookData, Tab } from './types';
+
+export const tabAtom = atom(Tab.TableOfContents);
+export const spellSearchAtom = atom('');
+
+export const widthSection = '466px';
+export const heightSection = '456px';
+
+const searchVerbs = [
+ 'Searching',
+ 'Seeking',
+ 'Contemplating',
+ 'Divining',
+ 'Scrying',
+ 'Peeking',
+ 'Pondering',
+ 'Gazing',
+ 'Studying',
+ 'Reviewing',
+];
+
+export function Spellbook(props) {
+ const { data } = useBackend();
+ const { points } = data;
+
+ const [selectedVerb, setSelectedVerb] = useState(searchVerbs[0]);
+ const [spellSearch, setSpellSearch] = useAtom(spellSearchAtom);
+
+ useEffect(() => {
+ // Ensures it only changes on reset
+ if (spellSearch === '') {
+ setSelectedVerb(sample(searchVerbs));
+ }
+ }, [spellSearch]);
+
+ return (
+
+
+
+
+
+ {spellSearch.length > 1 ? (
+
+ setSpellSearch('')}
+ >
+ Stop {selectedVerb}
+
+ }
+ >
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/Spellbook/types.ts b/tgui/packages/tgui/interfaces/Spellbook/types.ts
new file mode 100644
index 00000000000..ca480ee2701
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Spellbook/types.ts
@@ -0,0 +1,71 @@
+import type { BooleanLike } from 'tgui-core/react';
+
+export enum SpellCategory {
+ Offensive = 'Offensive',
+ Defensive = 'Defensive',
+ Mobility = 'Mobility',
+ Assistance = 'Assistance',
+ Rituals = 'Rituals',
+ Perks = 'Perks',
+}
+
+export type SpellEntry = {
+ // Name of the spell
+ name: string;
+ // Description of what the spell does
+ desc: string;
+ // Byond REF of the spell entry datum
+ ref: string;
+ // Whether the spell requires wizard clothing to cast
+ requires_wizard_garb: BooleanLike;
+ // Spell points required to buy the spell
+ cost: number;
+ // How many times the spell has been bought
+ times: number;
+ // Cooldown length of the spell once cast once
+ cooldown: number;
+ // Category of the spell
+ cat: SpellCategory;
+ // Whether the spell is refundable
+ refundable: BooleanLike;
+ // The verb displayed when buying
+ buyword: Buywords;
+};
+
+export type SpellbookData = {
+ owner: string;
+ points: number;
+ semi_random_bonus: number;
+ full_random_bonus: number;
+ entries: SpellEntry[];
+};
+
+export type TabType = {
+ title: string;
+} & Partial<{
+ blurb: string;
+ component: (props?: any) => React.JSX.Element;
+ locked: boolean;
+ scrollable: boolean;
+}>;
+
+export enum Buywords {
+ Learn = 'Learn',
+ Summon = 'Summon',
+ Cast = 'Cast',
+}
+
+export enum Tab {
+ EnscribedName = 0,
+ TableOfContents = 1,
+ Offensive = 2,
+ Defensive = 3,
+ Mobility = 4,
+ Assistance = 5,
+ Challenges = 6,
+ Rituals = 7,
+ Loadouts = 8,
+ Randomize = 9,
+ Perks = 10,
+ TableOfContents2 = 11,
+}