Skip to content

Commit 3a97e69

Browse files
authored
Merge pull request #8 from nova-rey/codex/implement-spec-builder-mvp-features
feat: Phase 2 repair — add Spec Builder MVP (types, forms, normalize, autosave, map) and update docs [v0.3.0-phase2-baseline]
2 parents 351abfe + b119145 commit 3a97e69

9 files changed

Lines changed: 61 additions & 90 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ A modern, browser-based UI for **CrapsSim-Control (CSC)**.
3434
- Navigate to `/builder` to author a spec.
3535
- Use **Normalize** to validate/pretty-print via CSC.
3636
- Use **Import/Export** to move specs in/out.
37-
- Presets available: Molly, Contra (top-left).
37+
- Presets available: Molly, Contra (panel on the left).
3838

3939
## Repo Boundaries
4040

docs/ARCHITECTURE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
- Response shape is normalized to `{ ok, status, data }` or `{ ok:false, status, message }`.
2929
- Mock mode serves JSON from `/mock-data/*` when the API is unavailable.
3030
## Spec Builder (Phase 2)
31-
- Authoring types live in `src/spec/`.
31+
- Authoring types in `src/spec/`.
3232
- Conversion `authoring → draft` via `src/spec/convert.ts`.
33-
- `/builder` provides a navigator (spec/profiles/rules), editor forms, and an output panel with Normalize.
34-
- Persistence: localStorage autosave under a single workspace key.
35-
- Import/Export: JSON files for authoring or normalized output.
33+
- `/builder` provides navigator, editor forms, and an output panel with Normalize.
34+
- Persistence: localStorage autosave.
35+
- Import/Export: JSON for authoring or normalized output.
3636
- Visual map: read-only tree (profiles + rules).

src/components/builder/ProfileForm.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
1-
import { BaseBet, Profile } from "../../spec/authoringTypes";
1+
import { AuthoringSpec, BaseBet, Profile } from "../../spec/authoringTypes";
22

33
export default function ProfileForm({
4+
spec,
45
profile,
5-
onChange,
6-
}: {
7-
profile: Profile;
8-
onChange: (p: Profile) => void;
9-
}) {
6+
onChange
7+
}: { spec: AuthoringSpec; profile: Profile; onChange: (p: Profile) => void }) {
8+
void spec;
109
function updateBet(idx: number, patch: Partial<BaseBet>) {
1110
const copy = {
1211
...profile,
13-
base_bets: profile.base_bets.map((b, i) => (i === idx ? { ...b, ...patch } : b)),
12+
base_bets: profile.base_bets.map((b, i) => (i === idx ? { ...b, ...patch } : b))
1413
};
1514
onChange(copy);
1615
}
1716
function addBet() {
1817
const copy = {
1918
...profile,
20-
base_bets: [...profile.base_bets, { kind: "place", number: 6, amount: 6, working_on_comeout: false }],
19+
base_bets: [...profile.base_bets, { kind: "place", number: 6, amount: 6, working_on_comeout: false }]
2120
};
2221
onChange(copy);
2322
}
@@ -50,7 +49,7 @@ export default function ProfileForm({
5049
<select
5150
className="w-full border rounded px-2 py-1"
5251
value={b.kind}
53-
onChange={(e) => updateBet(idx, { kind: e.target.value as BaseBet["kind"] })}
52+
onChange={(e) => updateBet(idx, { kind: e.target.value as any })}
5453
>
5554
<option>place</option>
5655
<option>come</option>
@@ -67,7 +66,7 @@ export default function ProfileForm({
6766
value={b.number ?? ""}
6867
onChange={(e) =>
6968
updateBet(idx, {
70-
number: e.target.value ? (Number(e.target.value) as BaseBet["number"]) : undefined,
69+
number: e.target.value ? (Number(e.target.value) as any) : undefined
7170
})
7271
}
7372
>
Lines changed: 20 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,35 @@
11
import { Rule, RuleVerb } from "../../spec/authoringTypes";
22
const verbs: RuleVerb[] = ["switch_profile", "press", "regress", "apply_policy"];
33

4-
export default function RuleForm({ value: rule, onChange }: { value: Rule; onChange: (r: Rule) => void }) {
5-
function setArg(key: string, val: unknown) {
6-
const args: Record<string, unknown> = { ...(rule.then.args ?? {}) };
7-
if (val === undefined || val === "") {
8-
delete args[key];
9-
} else {
10-
args[key] = val;
11-
}
12-
onChange({ ...rule, then: { ...rule.then, args } });
4+
export default function RuleForm({ value, onChange }: { value: Rule; onChange: (r: Rule) => void }) {
5+
function setArg(k: string, v: any) {
6+
const args = { ...(value.then.args ?? {}), [k]: v };
7+
onChange({ ...value, then: { ...value.then, args } });
138
}
149
return (
1510
<div className="space-y-2 text-sm">
1611
<label className="block">
1712
<span className="block">Rule ID</span>
1813
<input
1914
className="w-full border rounded px-2 py-1"
20-
value={rule.id}
21-
onChange={(e) => onChange({ ...rule, id: e.target.value })}
15+
value={value.id}
16+
onChange={(e) => onChange({ ...value, id: e.target.value })}
2217
/>
2318
</label>
2419
<label className="block">
2520
<span className="block">When (expression)</span>
2621
<input
2722
className="w-full border rounded px-2 py-1"
28-
value={rule.when}
29-
onChange={(e) => onChange({ ...rule, when: e.target.value })}
23+
value={value.when}
24+
onChange={(e) => onChange({ ...value, when: e.target.value })}
3025
/>
3126
</label>
3227
<label className="block">
3328
<span className="block">Then (verb)</span>
3429
<select
3530
className="w-full border rounded px-2 py-1"
36-
value={rule.then.verb}
37-
onChange={(e) => onChange({ ...rule, then: { ...rule.then, verb: e.target.value as RuleVerb } })}
31+
value={value.then.verb}
32+
onChange={(e) => onChange({ ...value, then: { ...value.then, verb: e.target.value as any } })}
3833
>
3934
{verbs.map((v) => (
4035
<option key={v} value={v}>
@@ -48,26 +43,16 @@ export default function RuleForm({ value: rule, onChange }: { value: Rule; onCha
4843
<span className="block">Arg: target/policy</span>
4944
<input
5045
className="w-full border rounded px-2 py-1"
51-
value={String((rule.then.args ?? {}).target ?? (rule.then.args ?? {}).policy ?? "")}
46+
value={String((value.then.args ?? {}).target ?? (value.then.args ?? {}).policy ?? "")}
5247
onChange={(e) => setArg("target", e.target.value)}
5348
/>
5449
</label>
5550
<label className="block">
5651
<span className="block">Arg: delta/factor</span>
5752
<input
5853
className="w-full border rounded px-2 py-1"
59-
value={String((rule.then.args ?? {}).delta ?? (rule.then.args ?? {}).factor ?? "")}
60-
onChange={(e) => {
61-
const raw = e.target.value;
62-
const num = Number(raw);
63-
if (raw.trim() === "") {
64-
setArg("delta", undefined);
65-
} else if (Number.isFinite(num)) {
66-
setArg("delta", num);
67-
} else {
68-
setArg("delta", raw);
69-
}
70-
}}
54+
value={String((value.then.args ?? {}).delta ?? (value.then.args ?? {}).factor ?? "")}
55+
onChange={(e) => setArg("delta", Number(e.target.value) || e.target.value)}
7156
/>
7257
</label>
7358
</div>
@@ -77,16 +62,16 @@ export default function RuleForm({ value: rule, onChange }: { value: Rule; onCha
7762
<input
7863
type="number"
7964
className="w-full border rounded px-2 py-1"
80-
value={rule.cooldown ?? ""}
81-
onChange={(e) => onChange({ ...rule, cooldown: n(e.target.value) })}
65+
value={value.cooldown ?? ""}
66+
onChange={(e) => onChange({ ...value, cooldown: n(e.target.value) })}
8267
/>
8368
</label>
8469
<label className="block">
8570
<span className="block">Scope</span>
8671
<select
8772
className="w-full border rounded px-2 py-1"
88-
value={rule.scope ?? ""}
89-
onChange={(e) => onChange({ ...rule, scope: (e.target.value as Rule["scope"]) || undefined })}
73+
value={value.scope ?? ""}
74+
onChange={(e) => onChange({ ...value, scope: (e.target.value as any) || undefined })}
9075
>
9176
<option value=""></option>
9277
<option>roll</option>
@@ -98,8 +83,8 @@ export default function RuleForm({ value: rule, onChange }: { value: Rule; onCha
9883
<span className="block">Guards (csv)</span>
9984
<input
10085
className="w-full border rounded px-2 py-1"
101-
value={(rule.guards ?? []).join(",")}
102-
onChange={(e) => onChange({ ...rule, guards: csv(e.target.value) })}
86+
value={(value.guards ?? []).join(",")}
87+
onChange={(e) => onChange({ ...value, guards: csv(e.target.value) })}
10388
/>
10489
</label>
10590
</div>
@@ -111,8 +96,5 @@ function n(v: string) {
11196
return Number.isFinite(x) ? x : undefined;
11297
}
11398
function csv(v: string) {
114-
return v
115-
.split(",")
116-
.map((s) => s.trim())
117-
.filter(Boolean);
99+
return v.split(",").map((s) => s.trim()).filter(Boolean);
118100
}

src/components/builder/TableForm.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@ export default function TableForm({ value, onChange }: { value: TableSettings; o
2626
<select
2727
className="w-full border rounded px-2 py-1"
2828
value={value.odds_profile ?? "3-4-5x"}
29-
onChange={(e) =>
30-
onChange({ ...value, odds_profile: e.target.value as TableSettings["odds_profile"] })
31-
}
29+
onChange={(e) => onChange({ ...value, odds_profile: e.target.value as any })}
3230
>
3331
{profiles.map((p) => (
3432
<option key={p} value={p}>

src/routes/Builder.tsx

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, type ChangeEvent } from "react";
1+
import { useState } from "react";
22
import { useBuilderStore } from "../state/builderStore";
33
import Navigator from "../components/builder/Navigator";
44
import IdentityForm from "../components/builder/IdentityForm";
@@ -29,28 +29,21 @@ export default function Builder() {
2929
setNormalized(r.data.normalized);
3030
setWarnings(r.data.warnings ?? []);
3131
} else {
32-
const detailErrors = (r.details as { errors?: string[] } | undefined)?.errors ?? [];
33-
setErrors([`${r.status}: ${r.message}`, ...detailErrors]);
32+
setErrors([`${r.status}: ${r.message}`].concat((r as any).details?.errors ?? []));
3433
}
3534
}
3635

37-
function importJson(e: ChangeEvent<HTMLInputElement>) {
36+
function importJson(e: React.ChangeEvent<HTMLInputElement>) {
3837
const file = e.target.files?.[0];
3938
if (!file) return;
4039
const reader = new FileReader();
4140
reader.onload = () => {
4241
try {
4342
const obj = JSON.parse(String(reader.result));
44-
// Minimal shape check
45-
const candidate = obj as Partial<AuthoringSpec>;
46-
if (!candidate || typeof candidate !== "object") throw new Error("Invalid authoring spec shape.");
47-
if (!candidate.identity || !candidate.behavior?.rules || !candidate.profiles) {
48-
throw new Error("Invalid authoring spec shape.");
49-
}
50-
setSpec(candidate as AuthoringSpec);
51-
} catch (err: unknown) {
52-
const message = err instanceof Error ? err.message : String(err);
53-
setErrors([`Import failed: ${message}`]);
43+
if (!obj.identity || !obj.behavior?.rules || !obj.profiles) throw new Error("Invalid authoring spec shape.");
44+
setSpec(obj as AuthoringSpec);
45+
} catch (err: any) {
46+
setErrors([`Import failed: ${err.message}`]);
5447
}
5548
};
5649
reader.readAsText(file);
@@ -69,10 +62,7 @@ export default function Builder() {
6962

7063
function loadPreset(id: string) {
7164
const found = PRESETS.find((p) => p.id === id);
72-
if (found) {
73-
const copy = JSON.parse(JSON.stringify(found.spec)) as AuthoringSpec;
74-
setSpec(copy);
75-
}
65+
if (found) setSpec(JSON.parse(JSON.stringify(found.spec)));
7666
}
7767

7868
const currentProfile = selected.kind === "profile" ? spec.profiles.find((p) => p.id === selected.id) : undefined;
@@ -113,6 +103,7 @@ export default function Builder() {
113103
)}
114104
{selected.kind === "profile" && currentProfile && (
115105
<ProfileForm
106+
spec={spec}
116107
profile={currentProfile}
117108
onChange={(p) => setSpec((s) => ({ ...s, profiles: s.profiles.map((x) => (x.id === p.id ? p : x)) }))}
118109
/>
@@ -123,7 +114,10 @@ export default function Builder() {
123114
onChange={(r) =>
124115
setSpec((s) => ({
125116
...s,
126-
behavior: { ...s.behavior, rules: s.behavior.rules.map((x) => (x.id === r.id ? r : x)) },
117+
behavior: {
118+
...s.behavior,
119+
rules: s.behavior.rules.map((x) => (x.id === r.id ? r : x))
120+
}
127121
}))
128122
}
129123
/>

src/spec/authoringTypes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export type RuleVerb = "switch_profile" | "press" | "regress" | "apply_policy";
2929

3030
export interface Rule {
3131
id: ID;
32-
when: string; // simple expression string (evaluated by CSC)
32+
when: string;
3333
then: { verb: RuleVerb; args?: Record<string, unknown> };
3434
cooldown?: number;
3535
scope?: "roll" | "hand" | "session";

src/spec/convert.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
1-
import { AuthoringSpec, Rule } from "./authoringTypes";
1+
import { AuthoringSpec } from "./authoringTypes";
22

33
export function toDraft(spec: AuthoringSpec): Record<string, unknown> {
4-
// Minimal coercion: drop undefineds and keep keys CSC expects.
5-
const clean = JSON.parse(JSON.stringify(spec)) as AuthoringSpec;
6-
const rules: Rule[] = Array.isArray(clean.behavior?.rules) ? clean.behavior.rules : [];
4+
const clean = JSON.parse(JSON.stringify(spec));
75
return {
86
identity: clean.identity ?? {},
97
table: clean.table ?? {},
108
profiles: Array.isArray(clean.profiles) ? clean.profiles : [],
119
behavior: {
1210
schema_version: "1.0",
13-
rules: rules.map((rule) => ({
14-
id: rule.id,
15-
when: rule.when,
16-
then: rule.then,
17-
cooldown: rule.cooldown ?? undefined,
18-
scope: rule.scope ?? undefined,
19-
guards: rule.guards ?? undefined,
20-
})),
21-
},
11+
rules: (clean.behavior?.rules ?? []).map((r: any) => ({
12+
id: r.id,
13+
when: r.when,
14+
then: r.then,
15+
cooldown: r.cooldown ?? undefined,
16+
scope: r.scope ?? undefined,
17+
guards: r.guards ?? undefined
18+
}))
19+
}
2220
};
2321
}

src/state/builderStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function useBuilderStore() {
1414
}
1515
});
1616
const [selected, setSelected] = useState<{ kind: "identity" | "table" | "profile" | "rule"; id?: string }>({
17-
kind: "identity",
17+
kind: "identity"
1818
});
1919

2020
const first = useRef(true);

0 commit comments

Comments
 (0)