Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ Physical S.D.C. is additive: the p.287 `2D6+12` base and every applicable
O.C.C./Physical-skill formula are exposed as a sourced breakdown, combined into
the preview range, and rolled server-side from that same contribution list.

Starting loadouts follow the same authority boundary. The builder submits only
the player's page-stamped O.C.C. choice-group selections; one backend mutation
reloads the O.C.C. content, validates those choices, rolls grant quantities and
per-suit armor capacity, validates the full character, and atomically stores the
result as ordinary one-entry-per-instance inventory. The client cannot author a
starting manifest, and request-only selections are not persisted. Characters
created before structured loadouts receive no retroactive grants or migration,
while later inventory changes remain ordinary mutable character state and do not
reapply creation-time loadout legality.

## Development

Tooling is [Vite+](https://viteplus.dev) (`vp`) over a pnpm workspace.
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/builder/loadout-summary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { For, Show } from "solid-js";
import { MonoLabel, Panel, SectionTitle } from "../components/ui.tsx";
import type { BuilderStore } from "./store.ts";

const ROLLED_ON_FORGE = "ROLLED ON FORGE";

function range(min: number, max: number): string {
return min === max ? `${min}` : `${min}-${max}`;
}

/** Unrolled grants and checklist guidance shared by the loadout and review steps. */
export function LoadoutSummary(props: { store: BuilderStore }) {
const grants = () => props.store.loadoutResolution()?.grants ?? [];
const checklist = () => props.store.loadoutPlan()?.checklist ?? [];

return (
<div class="space-y-3">
<Panel class="p-4">
<SectionTitle>AUTHORITATIVE STARTING MANIFEST</SectionTitle>
<div class="mt-2 divide-y divide-line/60">
<For each={grants()}>
{(grant) => (
<div class="flex flex-col gap-1 py-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="font-display text-[16px] tracking-[0.04em]">{grant.item.name}</p>
<p class="font-mono text-[10.5px] tracking-[0.08em] text-muted">
GRANT RUE P.{grant.source.page} // ITEM RUE P.{grant.item.page}
</p>
</div>
<div class="text-left font-mono text-[11px] tracking-[0.08em] text-amber sm:text-right">
<p>
QTY {grant.quantity.formula}
<Show when={grant.quantity.min !== grant.quantity.max}>
{" "}
// {range(grant.quantity.min, grant.quantity.max)}
</Show>
</p>
<Show when={grant.armorCapacity}>
{(armor) => (
<p>
M.D.C. {armor().formula} // {range(armor().min, armor().max)} //{" "}
{ROLLED_ON_FORGE}
</p>
)}
</Show>
</div>
</div>
)}
</For>
</div>
</Panel>

<Panel class="p-4">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<SectionTitle>CHECKLIST // NOT YET MODELED</SectionTitle>
<MonoLabel>GUIDANCE ONLY // NOT INVENTORY</MonoLabel>
</div>
<ul class="mt-2 space-y-2">
<For each={checklist()}>
{(entry) => (
<li class="border-l-2 border-muted/50 pl-3 text-[13px] text-muted">
{entry.text}{" "}
<span class="font-mono text-[10.5px] tracking-[0.08em] text-dead">
RUE P.{entry.source.page}
</span>
</li>
)}
</For>
</ul>
</Panel>
</div>
);
}
107 changes: 107 additions & 0 deletions apps/web/src/builder/steps/loadout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { For, Show } from "solid-js";
import { Alert, MonoLabel, Panel } from "../../components/ui.tsx";
import { LoadoutSummary } from "../loadout-summary.tsx";
import type { BuilderStore } from "../store.ts";

/** Step 9 (RUE p.116): select only player-owned loadout choices. */
export function LoadoutStep(props: { store: BuilderStore }) {
const selected = (key: string) => props.store.draft.loadoutSelections.choices[key] ?? [];

const toggle = (key: string, itemId: string, checked: boolean) => {
const current = selected(key);
props.store.setLoadoutChoice(
key,
checked ? [...current, itemId] : current.filter((id) => id !== itemId),
);
};

return (
<section class="space-y-3" aria-labelledby="loadout-heading">
<Panel class="space-y-4 p-5">
<div>
<MonoLabel>PLAYER SELECTIONS // SERVER PROVISIONING</MonoLabel>
<h2 id="loadout-heading" class="font-display text-2xl tracking-[0.03em]">
STARTING LOADOUT
</h2>
<p class="mt-1 max-w-[70ch] text-[13px] text-muted">
Choose the printed options. The server reloads this O.C.C., rolls quantities and armor,
and creates the concrete manifest when you forge.
</p>
</div>

<For each={props.store.loadoutPlan()?.choices ?? []}>
{(choice) => {
const valid = () =>
selected(choice.key).length === choice.choose &&
selected(choice.key).every((id) =>
choice.options.some((option) => option.item.id === id),
);
return (
<fieldset class="border border-line bg-inset p-4">
<legend class="px-2 font-display text-[17px] tracking-[0.04em]">
{choice.label}
</legend>
<p class="mb-2 font-mono text-[10.5px] tracking-[0.08em] text-muted">
CHOOSE EXACTLY {choice.choose} // GRANT RUE P.{choice.source.page}
</p>
<div class="grid gap-2 sm:grid-cols-2">
<For each={choice.options}>
{(option) => (
<label class="flex cursor-pointer gap-2 border border-line p-3 hover:border-muted">
<Show
when={choice.choose === 1}
fallback={
<input
type="checkbox"
class="mt-0.5 accent-amber"
checked={selected(choice.key).includes(option.item.id)}
onChange={(event) =>
toggle(choice.key, option.item.id, event.currentTarget.checked)
}
/>
}
>
<input
type="radio"
name={`loadout-${choice.key}`}
class="mt-0.5 accent-amber"
checked={selected(choice.key).includes(option.item.id)}
onChange={() =>
props.store.setLoadoutChoice(choice.key, [option.item.id])
}
/>
</Show>
<span>
<span class="block font-display tracking-[0.03em]">
{option.item.name}
</span>
<span class="font-mono text-[10.5px] tracking-[0.08em] text-muted">
RUE P.{option.item.page}
</span>
</span>
</label>
)}
</For>
</div>
<p
class="mt-2 font-mono text-[11px]"
classList={{ "text-ok": valid(), "text-blood": !valid() }}
>
{valid()
? "SELECTION VALID"
: `SELECTION REQUIRED — EXACTLY ${choice.choose} OPTION(S)`}
</p>
</fieldset>
);
}}
</For>

<For each={props.store.loadoutResolution()?.errors ?? []}>
{(error) => <Alert tone="danger">LOADOUT INVALID — {error}</Alert>}
</For>
</Panel>

<LoadoutSummary store={props.store} />
</section>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/builder/steps/occ.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function OccStep(props: { store: BuilderStore }) {
name="occ"
class="accent-amber"
checked={props.store.draft.occId === occ.id}
onChange={() => props.store.setDraft("occId", occ.id)}
onChange={() => props.store.selectOcc(occ.id)}
disabled={check(occ)?.ok !== true}
/>
<span class="font-display text-xl tracking-[0.03em]">{occ.name}</span>
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/builder/steps/review.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Show } from "solid-js";
import { SheetView } from "../../components/sheet-view.tsx";
import { Alert, MonoLabel } from "../../components/ui.tsx";
import { LoadoutSummary } from "../loadout-summary.tsx";
import type { BuilderStore } from "../store.ts";

/**
Expand All @@ -22,7 +23,17 @@ export function ReviewStep(props: { store: BuilderStore }) {
<MonoLabel>
// PRE-FORGE PREVIEW — vitals show ranges until rolled on the dossier
</MonoLabel>
<SheetView sheet={sheet()} />
<SheetView sheet={sheet()} showEquipment={false} />
<div class="space-y-1 border border-amber/40 bg-amber/5 p-3">
<MonoLabel>SERVER-PROVISIONED CATALOG ITEMS</MonoLabel>
<p class="font-mono text-[11px] tracking-[0.08em] text-amber">
QUANTITIES AND ARMOR CAPACITY REMAIN UNROLLED UNTIL FORGE
</p>
<p class="font-mono text-[11px] tracking-[0.08em] text-muted">
CHECKLIST GUIDANCE IS NOT STORED INVENTORY
</p>
</div>
<LoadoutSummary store={props.store} />
</>
)}
</Show>
Expand Down
58 changes: 54 additions & 4 deletions apps/web/src/builder/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
getOcc,
getSpecies,
occSkillPlan,
planStartingLoadout,
resolveStartingLoadout,
validateOccEligibility,
validateInitialSpells,
type AttributeCode,
Expand All @@ -15,6 +17,9 @@ import {
type PsionicsRoll,
type PsychicClass,
type SkillPick,
type StartingLoadoutPlan,
type StartingLoadoutResolution,
type StartingLoadoutSelections,
} from "@riftforge/rules";
import { createMemo, type Accessor } from "solid-js";
import { createStore, type SetStoreFunction } from "solid-js/store";
Expand All @@ -37,8 +42,14 @@ export interface Draft {
secondary: SkillPick[];
hthId?: string;
spellIds: string[];
loadoutSelections: StartingLoadoutSelections;
}

export type NewCharacterCreateInput = Omit<CharacterInput, "items"> & {
speciesId: string;
loadoutSelections: StartingLoadoutSelections;
};

export interface BuilderStore {
draft: Draft;
setDraft: SetStoreFunction<Draft>;
Expand All @@ -51,8 +62,15 @@ export interface BuilderStore {
/** Engine validation of every skill selection (empty errors = legal). */
assembled: Accessor<ReturnType<typeof assembleSkills> | undefined>;
spellErrors: Accessor<string[]>;
/** Unrolled catalog-backed grants and choices for the current O.C.C. */
loadoutPlan: Accessor<StartingLoadoutPlan | undefined>;
/** Shared-engine validation of the player's transient item choices. */
loadoutResolution: Accessor<StartingLoadoutResolution | undefined>;
/** Select an O.C.C. and clear loadout choices only when their owner changes. */
selectOcc: (occId: string) => void;
setLoadoutChoice: (key: string, itemIds: string[]) => void;
/** The final character, when every step is satisfied. */
characterInput: Accessor<(CharacterInput & { speciesId: string }) | undefined>;
characterInput: Accessor<NewCharacterCreateInput | undefined>;
/** The derived preview for the review step, or the error preventing it. */
preview: Accessor<{ sheet?: CharacterSheet; error?: string }>;
}
Expand All @@ -67,6 +85,7 @@ export function createBuilderStore(): BuilderStore {
related: [],
secondary: [],
spellIds: [],
loadoutSelections: { choices: {} },
});

const occ = createMemo(() => (draft.occId !== undefined ? getOcc(draft.occId) : undefined));
Expand Down Expand Up @@ -101,9 +120,32 @@ export function createBuilderStore(): BuilderStore {
return chosen ? validateInitialSpells(chosen, draft.spellIds) : [];
});

const characterInput = createMemo((): (CharacterInput & { speciesId: string }) | undefined => {
const loadoutPlan = createMemo(() => {
const chosen = occ();
return chosen ? planStartingLoadout(chosen) : undefined;
});

const loadoutResolution = createMemo(() => {
const chosen = occ();
return chosen ? resolveStartingLoadout(chosen, draft.loadoutSelections) : undefined;
});

const selectOcc = (occId: string) => {
if (draft.occId === occId) return;
setDraft("occId", occId);
setDraft("loadoutSelections", { choices: {} });
};

const setLoadoutChoice = (key: string, itemIds: string[]) => {
setDraft("loadoutSelections", "choices", key, itemIds);
};

const characterInput = createMemo((): NewCharacterCreateInput | undefined => {
const attributes = attributeTotals();
if (!draft.occId || !attributes || !assembled()) return undefined;
const loadout = loadoutResolution();
if (!draft.occId || !attributes || !assembled() || !loadout || loadout.errors.length > 0) {
return undefined;
}
const narrative = toNarrative(draft.narrative);
return {
name: draft.name.trim(),
Expand All @@ -120,6 +162,7 @@ export function createBuilderStore(): BuilderStore {
...(draft.hthId !== undefined ? { hthId: draft.hthId } : {}),
},
spellIds: draft.spellIds,
loadoutSelections: draft.loadoutSelections,
...(narrative !== undefined ? { narrative } : {}),
};
});
Expand All @@ -128,7 +171,9 @@ export function createBuilderStore(): BuilderStore {
const input = characterInput();
if (!input) return { error: "The build is incomplete." };
try {
return { sheet: deriveSheet(input) };
const { loadoutSelections: _requestOnly, ...character } = input;
void _requestOnly;
return { sheet: deriveSheet(character) };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
Expand All @@ -142,6 +187,10 @@ export function createBuilderStore(): BuilderStore {
attributeTotals,
assembled,
spellErrors,
loadoutPlan,
loadoutResolution,
selectOcc,
setLoadoutChoice,
characterInput,
preview,
};
Expand Down Expand Up @@ -185,6 +234,7 @@ export function stepValidity(store: BuilderStore) {
}),
skills: createMemo(() => (store.assembled()?.errors.length ?? 1) === 0),
spells: createMemo(() => store.spellErrors().length === 0),
loadout: createMemo(() => (store.loadoutResolution()?.errors.length ?? 1) === 0),
review: createMemo(() => store.preview().sheet !== undefined),
};
}
Loading
Loading