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
16 changes: 16 additions & 0 deletions .changeset/quiet-states.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@typeonce/effect-machine": minor
---

Allow active states to omit `schema` when they own no data. Schema-less atomic, compound, parallel, and final states keep full control-flow semantics while exposing value-free `.from(...)` builders, `undefined` handler state, and snapshot-only query APIs.

```ts
const States = Machine.defineStates({
Form: {
initial: "Editing",
states: { Editing: {}, Saving }
}
})

States.initial.Form.from((form) => form.Editing.from())
```
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,27 @@ defaults, refinements, and tagged-class identity are therefore preserved, and
decode failures remain typed machine failures. Pass a value directly only when
it is already decoded, such as a value returned by `Machine.retag`.

Omit `schema` when a state represents control flow but owns no data:

```ts
const States = Machine.defineStates({
Form: {
initial: "Editing",
states: {
Editing: {},
Saving
}
}
})

States.initial.Form.from((form) => form.Editing.from())
```

Schema-less states remain active, targetable, matchable, and visible through
`getSnapshot`, but have no value to read. Their builders expose only `.from`,
their handler `state` is `undefined`, and `get` / `getWithParents` accept only
schema-backed paths. Add a schema later if the state starts owning data.

Put data on the narrowest state where it is valid. If sibling phases share
data, put it on their compound parent.

Expand Down
84 changes: 56 additions & 28 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Use this order so inference has all schemas available when handlers are
declared:

1. Domain schemas used by state and event fields.
2. Tagged state schemas.
2. Tagged schemas for states that own data.
3. Tagged public-event, internal-event, and emitted-event schemas.
4. `Machine.defineStates`.
5. `Machine.make`, including input, events, internal events, emits, and the
Expand Down Expand Up @@ -108,24 +108,63 @@ its extra control is required:

## Atomic, compound, parallel, and history states

An active state does not need a schema unless it owns data. Omit `schema` for
control-only atomic, compound, parallel, and final states:

```ts
const States = Machine.defineStates({
Idle: {},
Form: {
initial: "Editing",
states: {
Editing: {},
Saving: State.cases.Saving
}
}
})

States.initial.Idle.from()
States.initial.Form.from((form) => form.Editing.from())
```

Schema-less states have the same control semantics as schema-backed states:
they are active, targetable, matchable, receive lifecycle handlers, and appear
in snapshots. They do not have a state value:

```ts
Idle: {
on: {
Start: ({ state, target }) => {
// state: undefined
return target.full.Form.from((form) => form.Editing.from())
}
}
}

States.matches(snapshot, "Form") // allowed
States.getSnapshot(snapshot, "Form") // allowed
States.get(snapshot, "Form") // type error: no value schema
```

For a schema-less path, builders expose only `.from(...)`; the direct callable
form is reserved for already-decoded schema values. Structural ancestors are
also omitted from `parents`; an immediate structural parent is typed as
`undefined`. Add `schema` when a state begins to own data or needs runtime
validation and persistence for that data.

Use an atomic state when no child phase can be active beneath it.

Use a compound state when exactly one child phase is active. It must declare an
`initial` child:

```ts
const FormState = Schema.TaggedUnion({
Form: { draft: Schema.String },
Editing: {},
Saving: {}
})
const FormState = Schema.TaggedUnion({ Saving: { draft: Schema.String } })

const FormStates = Machine.defineStates({
Form: {
schema: FormState.cases.Form,
initial: "Editing",
states: {
Editing: FormState.cases.Editing,
Editing: {},
Saving: FormState.cases.Saving
}
}
Expand All @@ -135,35 +174,22 @@ const FormStates = Machine.defineStates({
Use a parallel state when every direct region is active:

```ts
const ParallelState = Schema.TaggedUnion({
Screen: {},
Network: {},
Online: {},
Offline: {},
Panel: {},
Closed: {},
Open: {}
})

const ParallelStates = Machine.defineStates({
Screen: {
schema: ParallelState.cases.Screen,
type: "parallel",
states: {
network: {
schema: ParallelState.cases.Network,
initial: "Online",
states: {
Online: ParallelState.cases.Online,
Offline: ParallelState.cases.Offline
Online: {},
Offline: {}
}
},
panel: {
schema: ParallelState.cases.Panel,
initial: "Closed",
states: {
Closed: ParallelState.cases.Closed,
Open: ParallelState.cases.Open
Closed: {},
Open: {}
}
}
}
Expand Down Expand Up @@ -366,9 +392,11 @@ const ready = Option.getOrThrow(States.getSnapshot(snapshot, "Route.Ready"))
States.matches(ready, "Route.Ready.Saving")
```

All paths are checked against the definition. `context.parent` is the immediate
typed parent (`undefined` at a root). Use `parents` when another ancestor is
needed:
All paths are checked against the definition. `get` and `getWithParents` accept
only schema-backed paths; use `matches` or `getSnapshot` for any active path.
`context.parent` is the immediate typed parent value (`undefined` at a root or
when that parent is schema-less). `parents` contains only valued ancestors. Use
its full paths when another ancestor value is needed:

```ts
parents["Route.Ready"]
Expand Down
88 changes: 38 additions & 50 deletions examples/platformer/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,14 @@ export type Axis = typeof Axis.Type
const JumpKind = Schema.Literals(["Ground", "Double", "Wall"])

const State = Schema.TaggedUnion({
Character: {},
Locomotion: {},
Playing: {},
Paused: { pausedAt: Schema.Number },
Grounded: {},
Standing: {},
Running: { startedAt: Schema.Number },
Ducking: { startedAt: Schema.Number },
Landing: { impact: Schema.Number, resumeAxis: Axis, landedAt: Schema.Number },
Airborne: { originY: Schema.Number },
Motion: {},
Jumping: { startedAt: Schema.Number, push: Axis, kind: JumpKind },
Falling: { apexY: Schema.Number },
Diving: { startedAt: Schema.Number },
AirJump: {},
AirJumpGroundLock: {},
AirJumpWallLock: {},
AirJumpReady: {},
AirJumpSpent: {},
WallContact: {},
NoWall: {},
LeftWall: {},
RightWall: {},
Facing: {},
Left: {},
Right: {}
Diving: { startedAt: Schema.Number }
})

// Inputs and physics facts are one runtime-decoded, statically typed protocol.
Expand Down Expand Up @@ -61,22 +43,18 @@ const awayFrom = (wall: Axis): Axis => (wall === -1 ? 1 : wall === 1 ? -1 : 0)

export const CharacterStates = Machine.defineStates({
Character: {
schema: State.cases.Character,
type: "parallel",
states: {
locomotion: {
schema: State.cases.Locomotion,
initial: "Playing",
states: {
Playing: {
schema: State.cases.Playing,
initial: "Grounded",
states: {
Grounded: {
schema: State.cases.Grounded,
initial: "Standing",
states: {
Standing: State.cases.Standing,
Standing: {},
Running: State.cases.Running,
Ducking: State.cases.Ducking,
Landing: State.cases.Landing
Expand All @@ -87,7 +65,6 @@ export const CharacterStates = Machine.defineStates({
type: "parallel",
states: {
motion: {
schema: State.cases.Motion,
initial: "Jumping",
states: {
Jumping: State.cases.Jumping,
Expand All @@ -96,13 +73,12 @@ export const CharacterStates = Machine.defineStates({
}
},
airJump: {
schema: State.cases.AirJump,
initial: "AirJumpGroundLock",
states: {
AirJumpGroundLock: State.cases.AirJumpGroundLock,
AirJumpWallLock: State.cases.AirJumpWallLock,
AirJumpReady: State.cases.AirJumpReady,
AirJumpSpent: State.cases.AirJumpSpent
AirJumpGroundLock: {},
AirJumpWallLock: {},
AirJumpReady: {},
AirJumpSpent: {}
}
}
}
Expand All @@ -117,20 +93,18 @@ export const CharacterStates = Machine.defineStates({
}
},
facing: {
schema: State.cases.Facing,
initial: "Right",
states: {
Left: State.cases.Left,
Right: State.cases.Right
Left: {},
Right: {}
}
},
contact: {
schema: State.cases.WallContact,
initial: "NoWall",
states: {
NoWall: State.cases.NoWall,
LeftWall: State.cases.LeftWall,
RightWall: State.cases.RightWall
NoWall: {},
LeftWall: {},
RightWall: {}
}
}
}
Expand Down Expand Up @@ -457,9 +431,12 @@ export const locomotionState = (snapshot: CharacterSnapshot) => {
}

const playing = locomotion.state
return playing.path === "Character.locomotion.Playing.Grounded"
? playing.state.value
: playing.states.motion.state.value
if (playing.path === "Character.locomotion.Playing.Airborne") {
return playing.states.motion.state.value
}
return playing.state.path === "Character.locomotion.Playing.Grounded.Standing"
? { _tag: "Standing" as const }
: playing.state.value
}

export type LocomotionMode = ReturnType<typeof locomotionState>["_tag"]
Expand All @@ -468,22 +445,32 @@ export const locomotionMode = (snapshot: CharacterSnapshot): LocomotionMode => l

export const locomotionBranch = (snapshot: CharacterSnapshot) => {
const locomotion = snapshot.states.locomotion.state
return locomotion.path === "Character.locomotion.Paused" ? locomotion.value._tag : locomotion.state.value._tag
if (locomotion.path === "Character.locomotion.Paused") return "Paused" as const
return locomotion.state.path === "Character.locomotion.Playing.Grounded" ? "Grounded" as const : "Airborne" as const
}

export const airJumpMode = (snapshot: CharacterSnapshot) => {
const locomotion = snapshot.states.locomotion.state
return locomotion.path === "Character.locomotion.Playing" &&
locomotion.state.path === "Character.locomotion.Playing.Airborne"
? locomotion.state.states.airJump.state.value._tag
: undefined
if (
locomotion.path !== "Character.locomotion.Playing" ||
locomotion.state.path !== "Character.locomotion.Playing.Airborne"
) return undefined

const path = locomotion.state.states.airJump.state.path
if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpGroundLock") return "AirJumpGroundLock" as const
if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpWallLock") return "AirJumpWallLock" as const
if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpReady") return "AirJumpReady" as const
return "AirJumpSpent" as const
}

export const wallContact = (snapshot: CharacterSnapshot) => {
return snapshot.states.contact.state.value._tag
const path = snapshot.states.contact.state.path
if (path === "Character.contact.NoWall") return "NoWall" as const
return path === "Character.contact.LeftWall" ? "LeftWall" as const : "RightWall" as const
}

export const facingDirection = (snapshot: CharacterSnapshot) => snapshot.states.facing.state.value._tag
export const facingDirection = (snapshot: CharacterSnapshot) =>
snapshot.states.facing.state.path === "Character.facing.Left" ? "Left" as const : "Right" as const

export const activeStateData = (snapshot: CharacterSnapshot) => {
const locomotion = snapshot.states.locomotion.state
Expand All @@ -493,15 +480,16 @@ export const activeStateData = (snapshot: CharacterSnapshot) => {
}

const playing = locomotion.state
const { _tag: _branch, ...branchData } = playing.value
if (playing.path === "Character.locomotion.Playing.Grounded") {
if (playing.state.path === "Character.locomotion.Playing.Grounded.Standing") return {}
const { _tag: _leaf, ...leafData } = playing.state.value
return { ...branchData, ...leafData }
return leafData
}
const { _tag: _branch, ...branchData } = playing.value
const { _tag: _motion, ...motionData } = playing.states.motion.state.value
return {
...branchData,
...motionData,
airJump: playing.states.airJump.state.value._tag
airJump: airJumpMode(snapshot)
}
}
Loading