Skip to content
507 changes: 507 additions & 0 deletions .codex/superpowers/plans/2026-07-26-skill-legality-sdc-foundation.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ Character identity is source-validated: Human is currently the sole playable
species, while known but unimplemented identities remain unavailable rather than
silently accepted.

The builder persists source choices—O.C.C. slots, O.C.C.-Related and independent
Secondary Skill picks, repeatable labels, category routes, and Hand-to-Hand
selection—rather than client-authored bonuses. The shared rules engine assembles
those choices from the complete printed skill catalog (RUE pp.304–330), validates
prerequisites and O.C.C. legality (pp.299–300), and derives the same result in the
preview, backend, and saved sheet. Pre-existing flattened characters remain
readable, but new and fully edited characters use the authoritative selection
contract.

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.

## Development

Tooling is [Vite+](https://viteplus.dev) (`vp`) over a pnpm workspace.
Expand Down
26 changes: 19 additions & 7 deletions apps/web/src/builder/steps/occ-skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,29 @@ export function OccSkillsStep(props: { store: BuilderStore }) {

const setLabel = (slot: OccSkillChoice, index: number, label: string) => {
const next = [...picks(slot)];
if (label.trim() === "") {
next.splice(index, 1);
} else if (index >= next.length) {
const trimmed = label.trim();
if (index >= next.length) {
if (trimmed === "") return;
// Append instead of writing past the end: filling input #2 before #1
// must not leave a hole that skill assembly would crash on.
next.push({ skillId: slot.options[0]!.id, label: label.trim() });
next.push({ skillId: slot.options[0]!.id, label: trimmed });
} else {
next[index] = { skillId: slot.options[0]!.id, label: label.trim() };
next[index] = {
skillId: slot.options[0]!.id,
...(trimmed !== "" ? { label: trimmed } : {}),
};
}
setPicks(slot, next);
};

const commitLabel = (slot: OccSkillChoice, index: number) => {
const next = [...picks(slot)];
if (next[index]?.label === undefined) {
next.splice(index, 1);
setPicks(slot, next);
}
};

const setPickLabel = (slot: OccSkillChoice, skillId: string, label: string) =>
setPicks(
slot,
Expand Down Expand Up @@ -122,7 +133,7 @@ export function OccSkillsStep(props: { store: BuilderStore }) {
value={
picks(slot).find((p) => p.skillId === option.id)?.label ?? ""
}
onChange={(e) =>
onInput={(e) =>
setPickLabel(slot, option.id, e.currentTarget.value)
}
/>
Expand All @@ -141,7 +152,8 @@ export function OccSkillsStep(props: { store: BuilderStore }) {
<TextInput
class="px-2 py-1 text-[12px]"
value={picks(slot)[index]?.label ?? ""}
onChange={(e) => setLabel(slot, index, e.currentTarget.value)}
onInput={(e) => setLabel(slot, index, e.currentTarget.value)}
onBlur={() => commitLabel(slot, index)}
/>
</label>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
Expand Down
Loading
Loading