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
1,265 changes: 1,265 additions & 0 deletions .codex/superpowers/plans/2026-07-22-species-eligibility-foundation.md

Large diffs are not rendered by default.

475 changes: 475 additions & 0 deletions .codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ inventory and worn armor, take and recover damage, rest, draw P.P.E. from a ley
receive treatment, and cast spells through server-validated state changes, while
ephemeral sheet rolls use the shared rules package client-side.

Character identity is source-validated: Human is currently the sole playable
species, while known but unimplemented identities remain unavailable rather than
silently accepted.

## Development

Tooling is [Vite+](https://viteplus.dev) (`vp`) over a pnpm workspace.
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/builder/steps/identity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ export function IdentityStep(props: { store: BuilderStore }) {
/>
</label>

<div class="max-w-sm space-y-1">
<MonoLabel>SPECIES</MonoLabel>
<div
class="border border-line bg-inset px-3 py-2 font-mono text-[13px] tracking-[0.08em] text-amber"
aria-label="Species: Human, locked"
>
HUMAN // LOCKED
</div>
<p class="font-mono text-[11px] text-dead">
// sole playable species in the current rules catalog
</p>
</div>

<div class="border-t border-line pt-3">
<div class="flex items-baseline gap-3">
<h3 class="font-display text-[15px] tracking-[0.1em] text-muted">// PERSONNEL FILE</h3>
Expand Down
31 changes: 24 additions & 7 deletions apps/web/src/builder/steps/occ.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { meetsAttributeRequirements, occRegistry } from "@riftforge/rules";
import {
describeOccEligibilityFailure,
getSpecies,
occRegistry,
validateOccEligibility,
} from "@riftforge/rules";
import { For, Show } from "solid-js";
import { Alert, MonoLabel, Panel } from "../../components/ui.tsx";
import type { BuilderStore } from "../store.ts";
import { occEligibilityRecoveryGuidance, type BuilderStore } from "../store.ts";

/** Step 5 (RUE p.289): pick an O.C.C., gated on its attribute requirements. */
export function OccStep(props: { store: BuilderStore }) {
const occs = Object.values(occRegistry);
const check = (occ: (typeof occs)[number]) => {
const attrs = props.store.attributeTotals();
return attrs ? meetsAttributeRequirements(occ, attrs) : undefined;
return attrs ? validateOccEligibility(occ, props.store.draft.speciesId, attrs) : undefined;
};

const eligibleSpecies = (occ: (typeof occs)[number]) => {
if (occ.speciesEligibility.kind === "any") return "ANY PLAYABLE SPECIES";
return occ.speciesEligibility.speciesIds
.map((id) => {
const species = getSpecies(id);
return species ? `${species.name}${species.playable ? "" : " [DEFERRED]"}` : id;
})
.join(", ");
};

return (
Expand All @@ -30,6 +45,7 @@ export function OccStep(props: { store: BuilderStore }) {
class="accent-amber"
checked={props.store.draft.occId === occ.id}
onChange={() => props.store.setDraft("occId", occ.id)}
disabled={check(occ)?.ok !== true}
/>
<span class="font-display text-xl tracking-[0.03em]">{occ.name}</span>
<MonoLabel>{occ.category}</MonoLabel>
Expand All @@ -41,17 +57,18 @@ export function OccStep(props: { store: BuilderStore }) {
REQUIRES:{" "}
{occ.attributeRequirements.map((r) => `${r.code} ${r.min}+`).join(", ") || "NOTHING"}
</p>
<p class="font-mono text-[12px] text-muted">
SPECIES: {eligibleSpecies(occ).toUpperCase()}
</p>
<Show when={check(occ)}>
{(result) => (
<Show
when={result().ok}
fallback={
<Alert tone="danger" class="mt-2">
NOT QUALIFIED:{" "}
{result()
.failures.map((f) => `${f.code} ${f.actual} (NEEDS ${f.min})`)
.join(", ")}{" "}
— REROLL ATTRIBUTES TO QUALIFY
{result().failures.map(describeOccEligibilityFailure).join(" ").toUpperCase()}{" "}
— {occEligibilityRecoveryGuidance(result().failures)}
</Alert>
}
>
Expand Down
46 changes: 37 additions & 9 deletions apps/web/src/builder/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import {
assembleSkills,
deriveSheet,
getOcc,
meetsAttributeRequirements,
getSpecies,
occSkillPlan,
validateOccEligibility,
validateInitialSpells,
type AttributeCode,
type AttributeRoll,
type BuilderSelections,
type CharacterInput,
type CharacterSheet,
type OccEligibilityFailure,
type PsionicsRoll,
type PsychicClass,
type SkillPick,
Expand All @@ -21,6 +23,7 @@ import { emptyNarrativeForm, toNarrative, type NarrativeForm } from "../lib/narr
/** Everything the player has chosen so far. */
export interface Draft {
name: string;
speciesId: string;
/** Optional player-authored identity (raw form strings). */
narrative: NarrativeForm;
attributes?: Record<AttributeCode, AttributeRoll>;
Expand All @@ -41,20 +44,23 @@ export interface BuilderStore {
setDraft: SetStoreFunction<Draft>;
/** The chosen O.C.C., once picked. */
occ: Accessor<ReturnType<typeof getOcc>>;
/** The resolved source-owned species identity. */
species: Accessor<ReturnType<typeof getSpecies>>;
/** Rolled attribute totals, once rolled. */
attributeTotals: Accessor<Record<AttributeCode, number> | undefined>;
/** Engine validation of every skill selection (empty errors = legal). */
assembled: Accessor<ReturnType<typeof assembleSkills> | undefined>;
spellErrors: Accessor<string[]>;
/** The final character, when every step is satisfied. */
characterInput: Accessor<CharacterInput | undefined>;
characterInput: Accessor<(CharacterInput & { speciesId: string }) | undefined>;
/** The derived preview for the review step, or the error preventing it. */
preview: Accessor<{ sheet?: CharacterSheet; error?: string }>;
}

export function createBuilderStore(): BuilderStore {
const [draft, setDraft] = createStore<Draft>({
name: "",
speciesId: "human",
narrative: { ...emptyNarrativeForm },
psychicClass: "ordinary",
occChoices: {},
Expand All @@ -64,6 +70,7 @@ export function createBuilderStore(): BuilderStore {
});

const occ = createMemo(() => (draft.occId !== undefined ? getOcc(draft.occId) : undefined));
const species = createMemo(() => getSpecies(draft.speciesId));

const attributeTotals = createMemo(() => {
if (!draft.attributes) return undefined;
Expand Down Expand Up @@ -94,14 +101,15 @@ export function createBuilderStore(): BuilderStore {
return chosen ? validateInitialSpells(chosen, draft.spellIds) : [];
});

const characterInput = createMemo((): CharacterInput | undefined => {
const characterInput = createMemo((): (CharacterInput & { speciesId: string }) | undefined => {
const attributes = attributeTotals();
const skills = assembled();
if (!draft.occId || !attributes || !skills) return undefined;
const narrative = toNarrative(draft.narrative);
return {
name: draft.name.trim(),
occId: draft.occId,
speciesId: draft.speciesId,
...(draft.alignmentId !== undefined ? { alignmentId: draft.alignmentId } : {}),
level: 1,
attributes,
Expand All @@ -123,22 +131,42 @@ export function createBuilderStore(): BuilderStore {
}
});

return { draft, setDraft, occ, attributeTotals, assembled, spellErrors, characterInput, preview };
return {
draft,
setDraft,
occ,
species,
attributeTotals,
assembled,
spellErrors,
characterInput,
preview,
};
}

export function occEligibilityRecoveryGuidance(failures: readonly OccEligibilityFailure[]): string {
return failures.some((failure) => failure.kind !== "attribute")
? "IDENTITY/O.C.C. UNAVAILABLE"
: "REROLL ATTRIBUTES TO QUALIFY";
}

/** Per-step gating — a step is reachable when all before it are valid. */
export function stepValidity(store: BuilderStore) {
const requirements = createMemo(() => {
const eligibility = createMemo(() => {
const chosen = store.occ();
const attrs = store.attributeTotals();
return chosen && attrs ? meetsAttributeRequirements(chosen, attrs) : undefined;
return chosen && attrs
? validateOccEligibility(chosen, store.draft.speciesId, attrs)
: undefined;
});

return {
requirements,
identity: createMemo(() => store.draft.name.trim().length > 0),
eligibility,
identity: createMemo(
() => store.draft.name.trim().length > 0 && store.species()?.playable === true,
),
attributes: createMemo(() => store.draft.attributes !== undefined),
occ: createMemo(() => store.occ() !== undefined && requirements()?.ok === true),
occ: createMemo(() => store.occ() !== undefined && eligibility()?.ok === true),
alignment: createMemo(() => store.draft.alignmentId !== undefined),
psionics: createMemo(() => true),
// The O.C.C.-skills step only checks its own slots: related/secondary
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/sheet-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,8 @@ export function SheetView(props: {
<PortraitFrame />
<div class="min-w-0 flex-1">
<MonoLabel>
LEVEL {s().level} // {s().occ.name.toUpperCase()} // {s().occ.category.toUpperCase()}
LEVEL {s().level} // {s().species.name.toUpperCase()} // {s().occ.name.toUpperCase()} //{" "}
{s().occ.category.toUpperCase()}
</MonoLabel>
<h1 class="font-display text-5xl leading-none tracking-[0.02em]">{s().name}</h1>
<Show when={s().narrative?.epithet}>
Expand Down
92 changes: 92 additions & 0 deletions apps/web/tests/builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/// <reference types="node" />

import { existsSync, readFileSync } from "node:fs";
import type { AttributeCode, AttributeRoll, OccEligibilityFailure } from "@riftforge/rules";
import { createRoot } from "solid-js";
import { describe, expect, test } from "vite-plus/test";
import {
createBuilderStore,
occEligibilityRecoveryGuidance,
stepValidity,
} from "../src/builder/store.ts";

function source(relative: string): string {
const url = new URL(relative, import.meta.url);
if (!existsSync(url)) throw new Error(`Missing source under test: ${relative}`);
return readFileSync(url, "utf8");
}

const identitySource = source("../src/builder/steps/identity.tsx");

const rolled = (...dice: number[]): AttributeRoll => ({
total: dice.reduce((sum, die) => sum + die, 0),
dice,
exceptional: false,
});

const legalLeyLineWalkerAttributes: Record<AttributeCode, AttributeRoll> = {
IQ: rolled(3, 3, 4),
ME: rolled(4, 4, 4),
MA: rolled(4, 4, 4),
PS: rolled(4, 4, 4),
PP: rolled(4, 4, 4),
PE: rolled(4, 4, 4),
PB: rolled(4, 4, 4),
Spd: rolled(4, 4, 4),
};

describe("builder species identity", () => {
test("starts with the sole playable Human identity", () => {
createRoot((dispose) => {
const store = createBuilderStore();
expect(store.draft.speciesId).toBe("human");
expect(store.species()).toMatchObject({ id: "human", name: "Human", playable: true });
dispose();
});
});

test("derives legal and species-ineligible O.C.C. results through store accessors", () => {
createRoot((dispose) => {
const store = createBuilderStore();
const validity = stepValidity(store);
store.setDraft("attributes", legalLeyLineWalkerAttributes);
store.setDraft("occId", "ley-line-walker");

expect(validity.eligibility()).toMatchObject({
ok: true,
species: { id: "human", source: { book: "Rifts Ultimate Edition", page: 233 } },
failures: [],
});
expect(validity.occ()).toBe(true);

store.setDraft("speciesId", "psi-stalker");
const speciesIneligible = validity.eligibility();
expect(speciesIneligible).toMatchObject({
ok: false,
failures: [{ kind: "unavailableSpecies", speciesId: "psi-stalker", name: "Psi-Stalker" }],
});
expect(occEligibilityRecoveryGuidance(speciesIneligible?.failures ?? [])).toBe(
"IDENTITY/O.C.C. UNAVAILABLE",
);
expect(validity.occ()).toBe(false);
dispose();
});
});

test("uses identity guidance for species failures and reroll guidance for attributes", () => {
expect(
occEligibilityRecoveryGuidance([{ kind: "attribute", code: "PE", min: 12, actual: 11 }]),
).toBe("REROLL ATTRIBUTES TO QUALIFY");
const mixedFailures: OccEligibilityFailure[] = [
{ kind: "unavailableSpecies", speciesId: "psi-stalker", name: "Psi-Stalker" },
{ kind: "attribute", code: "PE", min: 12, actual: 11 },
];
expect(occEligibilityRecoveryGuidance(mixedFailures)).toBe("IDENTITY/O.C.C. UNAVAILABLE");
});

test("renders Human as locked identity rather than an editable species step", () => {
expect(identitySource).toContain("SPECIES");
expect(identitySource).toContain("HUMAN // LOCKED");
expect(identitySource).not.toContain('name="species"');
});
});
4 changes: 4 additions & 0 deletions apps/web/tests/character-sheet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ describe("application shell", () => {
});

describe("terminal sheet dossier", () => {
test("shows resolved species in the dossier identity line", () => {
expect(sheetViewSource).toContain("s().species.name.toUpperCase()");
});

test("retains the dossier while disabling every gameplay roll with the terminal reason", () => {
expect(characterSheetSource).toContain(TERMINAL_REASON);
expect(sheetViewSource).toContain("gameplayDisabledReason?: string;");
Expand Down
7 changes: 6 additions & 1 deletion packages/backend/convex/characters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ const characterInputFields = {
spellIds: v.optional(characterFields.spellIds),
};

const newCharacterInputFields = {
...characterInputFields,
speciesId: v.string(),
};

export const create = mutation({
args: characterInputFields,
args: newCharacterInputFields,
returns: v.id("characters"),
handler: async (ctx, args) => {
return await ctx.db.insert("characters", validateCharacter(args));
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { combatExchangeValidator } from "./combat_values";
export const characterFields = {
name: v.string(),
occId: v.string(),
/** Optional only for documents created before species identity existed. */
speciesId: v.optional(v.string()),
alignmentId: v.optional(v.string()),
level: v.number(),
attributes: v.object({
Expand Down
Loading
Loading