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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ A modern, browser-based UI for **CrapsSim-Control (CSC)**.
- Navigate to `/builder` to author a spec.
- Use **Normalize** to validate/pretty-print via CSC.
- Use **Import/Export** to move specs in/out.
- Presets available: Molly, Contra (top-left).
- Presets available: Molly, Contra (panel on the left).

## Repo Boundaries

Expand Down
8 changes: 4 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
- Response shape is normalized to `{ ok, status, data }` or `{ ok:false, status, message }`.
- Mock mode serves JSON from `/mock-data/*` when the API is unavailable.
## Spec Builder (Phase 2)
- Authoring types live in `src/spec/`.
- Authoring types in `src/spec/`.
- Conversion `authoring → draft` via `src/spec/convert.ts`.
- `/builder` provides a navigator (spec/profiles/rules), editor forms, and an output panel with Normalize.
- Persistence: localStorage autosave under a single workspace key.
- Import/Export: JSON files for authoring or normalized output.
- `/builder` provides navigator, editor forms, and an output panel with Normalize.
- Persistence: localStorage autosave.
- Import/Export: JSON for authoring or normalized output.
- Visual map: read-only tree (profiles + rules).
19 changes: 9 additions & 10 deletions src/components/builder/ProfileForm.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import { BaseBet, Profile } from "../../spec/authoringTypes";
import { AuthoringSpec, BaseBet, Profile } from "../../spec/authoringTypes";

export default function ProfileForm({
spec,
profile,
onChange,
}: {
profile: Profile;
onChange: (p: Profile) => void;
}) {
onChange
}: { spec: AuthoringSpec; profile: Profile; onChange: (p: Profile) => void }) {
void spec;
function updateBet(idx: number, patch: Partial<BaseBet>) {
const copy = {
...profile,
base_bets: profile.base_bets.map((b, i) => (i === idx ? { ...b, ...patch } : b)),
base_bets: profile.base_bets.map((b, i) => (i === idx ? { ...b, ...patch } : b))
};
onChange(copy);
}
function addBet() {
const copy = {
...profile,
base_bets: [...profile.base_bets, { kind: "place", number: 6, amount: 6, working_on_comeout: false }],
base_bets: [...profile.base_bets, { kind: "place", number: 6, amount: 6, working_on_comeout: false }]
};
onChange(copy);
}
Expand Down Expand Up @@ -50,7 +49,7 @@
<select
className="w-full border rounded px-2 py-1"
value={b.kind}
onChange={(e) => updateBet(idx, { kind: e.target.value as BaseBet["kind"] })}
onChange={(e) => updateBet(idx, { kind: e.target.value as any })}

Check failure on line 52 in src/components/builder/ProfileForm.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
>
<option>place</option>
<option>come</option>
Expand All @@ -67,7 +66,7 @@
value={b.number ?? ""}
onChange={(e) =>
updateBet(idx, {
number: e.target.value ? (Number(e.target.value) as BaseBet["number"]) : undefined,
number: e.target.value ? (Number(e.target.value) as any) : undefined

Check failure on line 69 in src/components/builder/ProfileForm.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
})
}
>
Expand Down
58 changes: 20 additions & 38 deletions src/components/builder/RuleForm.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,35 @@
import { Rule, RuleVerb } from "../../spec/authoringTypes";
const verbs: RuleVerb[] = ["switch_profile", "press", "regress", "apply_policy"];

export default function RuleForm({ value: rule, onChange }: { value: Rule; onChange: (r: Rule) => void }) {
function setArg(key: string, val: unknown) {
const args: Record<string, unknown> = { ...(rule.then.args ?? {}) };
if (val === undefined || val === "") {
delete args[key];
} else {
args[key] = val;
}
onChange({ ...rule, then: { ...rule.then, args } });
export default function RuleForm({ value, onChange }: { value: Rule; onChange: (r: Rule) => void }) {
function setArg(k: string, v: any) {

Check failure on line 5 in src/components/builder/RuleForm.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
const args = { ...(value.then.args ?? {}), [k]: v };
onChange({ ...value, then: { ...value.then, args } });
}
return (
<div className="space-y-2 text-sm">
<label className="block">
<span className="block">Rule ID</span>
<input
className="w-full border rounded px-2 py-1"
value={rule.id}
onChange={(e) => onChange({ ...rule, id: e.target.value })}
value={value.id}
onChange={(e) => onChange({ ...value, id: e.target.value })}
/>
</label>
<label className="block">
<span className="block">When (expression)</span>
<input
className="w-full border rounded px-2 py-1"
value={rule.when}
onChange={(e) => onChange({ ...rule, when: e.target.value })}
value={value.when}
onChange={(e) => onChange({ ...value, when: e.target.value })}
/>
</label>
<label className="block">
<span className="block">Then (verb)</span>
<select
className="w-full border rounded px-2 py-1"
value={rule.then.verb}
onChange={(e) => onChange({ ...rule, then: { ...rule.then, verb: e.target.value as RuleVerb } })}
value={value.then.verb}
onChange={(e) => onChange({ ...value, then: { ...value.then, verb: e.target.value as any } })}

Check failure on line 32 in src/components/builder/RuleForm.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
>
{verbs.map((v) => (
<option key={v} value={v}>
Expand All @@ -48,26 +43,16 @@
<span className="block">Arg: target/policy</span>
<input
className="w-full border rounded px-2 py-1"
value={String((rule.then.args ?? {}).target ?? (rule.then.args ?? {}).policy ?? "")}
value={String((value.then.args ?? {}).target ?? (value.then.args ?? {}).policy ?? "")}
onChange={(e) => setArg("target", e.target.value)}
/>
</label>
<label className="block">
<span className="block">Arg: delta/factor</span>
<input
className="w-full border rounded px-2 py-1"
value={String((rule.then.args ?? {}).delta ?? (rule.then.args ?? {}).factor ?? "")}
onChange={(e) => {
const raw = e.target.value;
const num = Number(raw);
if (raw.trim() === "") {
setArg("delta", undefined);
} else if (Number.isFinite(num)) {
setArg("delta", num);
} else {
setArg("delta", raw);
}
}}
value={String((value.then.args ?? {}).delta ?? (value.then.args ?? {}).factor ?? "")}
onChange={(e) => setArg("delta", Number(e.target.value) || e.target.value)}
/>
Comment on lines 51 to 56

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle empty and zero delta inputs without producing invalid args

The new onChange handler for the delta/factor field now calls setArg("delta", Number(e.target.value) || e.target.value). Because Number("") evaluates to 0, clearing the input stores an empty string instead of removing the argument, and entering 0 stores the string "0" instead of the number 0. The previous implementation trimmed the input and deleted the key when blank, ensuring only valid numeric values were sent. With the current code, normalizing a rule after clearing the field or setting delta to 0 will emit then.args.delta as a string, which CSC’s normalizer expects to be a number and therefore rejects. Consider restoring the explicit parsing/removal logic so blank values delete the argument and zero remains numeric.

Useful? React with 👍 / 👎.

</label>
</div>
Expand All @@ -77,16 +62,16 @@
<input
type="number"
className="w-full border rounded px-2 py-1"
value={rule.cooldown ?? ""}
onChange={(e) => onChange({ ...rule, cooldown: n(e.target.value) })}
value={value.cooldown ?? ""}
onChange={(e) => onChange({ ...value, cooldown: n(e.target.value) })}
/>
</label>
<label className="block">
<span className="block">Scope</span>
<select
className="w-full border rounded px-2 py-1"
value={rule.scope ?? ""}
onChange={(e) => onChange({ ...rule, scope: (e.target.value as Rule["scope"]) || undefined })}
value={value.scope ?? ""}
onChange={(e) => onChange({ ...value, scope: (e.target.value as any) || undefined })}

Check failure on line 74 in src/components/builder/RuleForm.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
>
<option value="">—</option>
<option>roll</option>
Expand All @@ -98,8 +83,8 @@
<span className="block">Guards (csv)</span>
<input
className="w-full border rounded px-2 py-1"
value={(rule.guards ?? []).join(",")}
onChange={(e) => onChange({ ...rule, guards: csv(e.target.value) })}
value={(value.guards ?? []).join(",")}
onChange={(e) => onChange({ ...value, guards: csv(e.target.value) })}
/>
</label>
</div>
Expand All @@ -111,8 +96,5 @@
return Number.isFinite(x) ? x : undefined;
}
function csv(v: string) {
return v
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return v.split(",").map((s) => s.trim()).filter(Boolean);
}
4 changes: 1 addition & 3 deletions src/components/builder/TableForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@
<select
className="w-full border rounded px-2 py-1"
value={value.odds_profile ?? "3-4-5x"}
onChange={(e) =>
onChange({ ...value, odds_profile: e.target.value as TableSettings["odds_profile"] })
}
onChange={(e) => onChange({ ...value, odds_profile: e.target.value as any })}

Check failure on line 29 in src/components/builder/TableForm.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
>
{profiles.map((p) => (
<option key={p} value={p}>
Expand Down
32 changes: 13 additions & 19 deletions src/routes/Builder.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, type ChangeEvent } from "react";
import { useState } from "react";
import { useBuilderStore } from "../state/builderStore";
import Navigator from "../components/builder/Navigator";
import IdentityForm from "../components/builder/IdentityForm";
Expand Down Expand Up @@ -29,28 +29,21 @@
setNormalized(r.data.normalized);
setWarnings(r.data.warnings ?? []);
} else {
const detailErrors = (r.details as { errors?: string[] } | undefined)?.errors ?? [];
setErrors([`${r.status}: ${r.message}`, ...detailErrors]);
setErrors([`${r.status}: ${r.message}`].concat((r as any).details?.errors ?? []));

Check failure on line 32 in src/routes/Builder.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
}
}

function importJson(e: ChangeEvent<HTMLInputElement>) {
function importJson(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const obj = JSON.parse(String(reader.result));
// Minimal shape check
const candidate = obj as Partial<AuthoringSpec>;
if (!candidate || typeof candidate !== "object") throw new Error("Invalid authoring spec shape.");
if (!candidate.identity || !candidate.behavior?.rules || !candidate.profiles) {
throw new Error("Invalid authoring spec shape.");
}
setSpec(candidate as AuthoringSpec);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setErrors([`Import failed: ${message}`]);
if (!obj.identity || !obj.behavior?.rules || !obj.profiles) throw new Error("Invalid authoring spec shape.");
setSpec(obj as AuthoringSpec);
} catch (err: any) {

Check failure on line 45 in src/routes/Builder.tsx

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
setErrors([`Import failed: ${err.message}`]);
}
};
reader.readAsText(file);
Expand All @@ -69,10 +62,7 @@

function loadPreset(id: string) {
const found = PRESETS.find((p) => p.id === id);
if (found) {
const copy = JSON.parse(JSON.stringify(found.spec)) as AuthoringSpec;
setSpec(copy);
}
if (found) setSpec(JSON.parse(JSON.stringify(found.spec)));
}

const currentProfile = selected.kind === "profile" ? spec.profiles.find((p) => p.id === selected.id) : undefined;
Expand Down Expand Up @@ -113,6 +103,7 @@
)}
{selected.kind === "profile" && currentProfile && (
<ProfileForm
spec={spec}
profile={currentProfile}
onChange={(p) => setSpec((s) => ({ ...s, profiles: s.profiles.map((x) => (x.id === p.id ? p : x)) }))}
/>
Expand All @@ -123,7 +114,10 @@
onChange={(r) =>
setSpec((s) => ({
...s,
behavior: { ...s.behavior, rules: s.behavior.rules.map((x) => (x.id === r.id ? r : x)) },
behavior: {
...s.behavior,
rules: s.behavior.rules.map((x) => (x.id === r.id ? r : x))
}
}))
}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/spec/authoringTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type RuleVerb = "switch_profile" | "press" | "regress" | "apply_policy";

export interface Rule {
id: ID;
when: string; // simple expression string (evaluated by CSC)
when: string;
then: { verb: RuleVerb; args?: Record<string, unknown> };
cooldown?: number;
scope?: "roll" | "hand" | "session";
Expand Down
24 changes: 11 additions & 13 deletions src/spec/convert.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import { AuthoringSpec, Rule } from "./authoringTypes";
import { AuthoringSpec } from "./authoringTypes";

export function toDraft(spec: AuthoringSpec): Record<string, unknown> {
// Minimal coercion: drop undefineds and keep keys CSC expects.
const clean = JSON.parse(JSON.stringify(spec)) as AuthoringSpec;
const rules: Rule[] = Array.isArray(clean.behavior?.rules) ? clean.behavior.rules : [];
const clean = JSON.parse(JSON.stringify(spec));
return {
identity: clean.identity ?? {},
table: clean.table ?? {},
profiles: Array.isArray(clean.profiles) ? clean.profiles : [],
behavior: {
schema_version: "1.0",
rules: rules.map((rule) => ({
id: rule.id,
when: rule.when,
then: rule.then,
cooldown: rule.cooldown ?? undefined,
scope: rule.scope ?? undefined,
guards: rule.guards ?? undefined,
})),
},
rules: (clean.behavior?.rules ?? []).map((r: any) => ({

Check failure on line 11 in src/spec/convert.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
id: r.id,
when: r.when,
then: r.then,
cooldown: r.cooldown ?? undefined,
scope: r.scope ?? undefined,
guards: r.guards ?? undefined
}))
}
};
}
2 changes: 1 addition & 1 deletion src/state/builderStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function useBuilderStore() {
}
});
const [selected, setSelected] = useState<{ kind: "identity" | "table" | "profile" | "rule"; id?: string }>({
kind: "identity",
kind: "identity"
});

const first = useRef(true);
Expand Down
Loading