@arcforge/cognet 2.0.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -0
- package/src/air/air.ts +32 -0
- package/src/air/grammar.ts +103 -0
- package/src/air/index.ts +17 -0
- package/src/air/parse/index.ts +210 -0
- package/src/air/parse/scan.ts +35 -0
- package/src/air/render/blocks.ts +242 -0
- package/src/air/render/index.ts +51 -0
- package/src/air/render/output.ts +144 -0
- package/src/air/render/text.ts +85 -0
- package/src/air/repair/index.ts +1 -0
- package/src/air/repair/repair.ts +33 -0
- package/src/air/types.ts +71 -0
- package/src/clock.ts +118 -0
- package/src/define.ts +11 -0
- package/src/ecs/component.ts +78 -0
- package/src/ecs/ecs.ts +51 -0
- package/src/ecs/entity.ts +52 -0
- package/src/ecs/index.ts +13 -0
- package/src/ecs/state.ts +129 -0
- package/src/ecs/types.ts +46 -0
- package/src/host.ts +245 -0
- package/src/index.ts +21 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ComponentT } from "./component"
|
|
2
|
+
import type { StateT } from "./state"
|
|
3
|
+
import type { ComponentRegistry, ComponentType, EntityId } from "./types"
|
|
4
|
+
import type { EcsEmit } from "./ecs"
|
|
5
|
+
|
|
6
|
+
type EntityOpts = {
|
|
7
|
+
state: StateT
|
|
8
|
+
component: ComponentT
|
|
9
|
+
emit: EcsEmit
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Entity — membership in the world. Component writes delegate to Component()
|
|
14
|
+
* so telemetry and watchers fire through the single write path.
|
|
15
|
+
*/
|
|
16
|
+
export function Entity(opts: EntityOpts) {
|
|
17
|
+
const { state, component, emit } = opts
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
add<K extends ComponentType>({
|
|
21
|
+
entity,
|
|
22
|
+
components,
|
|
23
|
+
}: {
|
|
24
|
+
entity: EntityId
|
|
25
|
+
components?:
|
|
26
|
+
| { type: K; data: ComponentRegistry[K] }
|
|
27
|
+
| { type: K; data: ComponentRegistry[K] }[]
|
|
28
|
+
}) {
|
|
29
|
+
state.entities.add(entity)
|
|
30
|
+
void emit("cognet:entity:add", { ...state.stamp(), entity })
|
|
31
|
+
|
|
32
|
+
if (!components) return
|
|
33
|
+
|
|
34
|
+
const list = Array.isArray(components) ? components : [components]
|
|
35
|
+
for (const { type, data } of list) {
|
|
36
|
+
component.add({ entity, type, data })
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
remove({ entity }: { entity: EntityId }) {
|
|
41
|
+
// Remove components through the single write path so removal telemetry fires
|
|
42
|
+
for (const type of [...state.components.keys()]) {
|
|
43
|
+
component.remove({ entity, type })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
state.entities.delete(entity)
|
|
47
|
+
void emit("cognet:entity:remove", { ...state.stamp(), entity })
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type EntityT = ReturnType<typeof Entity>
|
package/src/ecs/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ecs — the wake-scoped world. Opt-in: a cognet that never queries an entity
|
|
2
|
+
// doesn't need this module. The world clock lives in ../clock.ts and is
|
|
3
|
+
// always present.
|
|
4
|
+
export { Ecs, type EcsT, type EcsOpts, type EcsEmit } from "./ecs"
|
|
5
|
+
export type { StateOpts } from "./state"
|
|
6
|
+
export type {
|
|
7
|
+
ComponentRegistry,
|
|
8
|
+
ComponentType,
|
|
9
|
+
ComponentWatcher,
|
|
10
|
+
EntityId,
|
|
11
|
+
QueryDescriptor,
|
|
12
|
+
WorldQueryResult,
|
|
13
|
+
} from "./types"
|
package/src/ecs/state.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentRegistry,
|
|
3
|
+
ComponentStore,
|
|
4
|
+
ComponentType,
|
|
5
|
+
ComponentWatcher,
|
|
6
|
+
EntityId,
|
|
7
|
+
QueryDescriptor,
|
|
8
|
+
WorldQueryResult,
|
|
9
|
+
} from "./types"
|
|
10
|
+
|
|
11
|
+
export type StateOpts = {
|
|
12
|
+
/** The clock's stamp — world mutations are attributed to a tick and phase. */
|
|
13
|
+
stamp(): { tick: number; phase: string | null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* State — the single owner of everything shared inside the world:
|
|
18
|
+
* entity set, component stores, and watchers. Component() and Entity() are
|
|
19
|
+
* views over this; nothing else holds the maps.
|
|
20
|
+
*
|
|
21
|
+
* The world clock does NOT live here. tick/phase belong to Clock(), which
|
|
22
|
+
* every cognet has whether or not it holds a world — see ../clock.ts. State
|
|
23
|
+
* receives a stamp function so world mutations can be attributed to the tick
|
|
24
|
+
* and phase they happened in without owning the counters.
|
|
25
|
+
*/
|
|
26
|
+
export function State(opts: StateOpts) {
|
|
27
|
+
const entities = new Set<EntityId>()
|
|
28
|
+
const components = new Map<ComponentType, ComponentStore>()
|
|
29
|
+
const watchers = new Map<ComponentType, Set<ComponentWatcher>>()
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
entities,
|
|
33
|
+
components,
|
|
34
|
+
|
|
35
|
+
/** tick/phase stamp merged into every world event payload. */
|
|
36
|
+
stamp: opts.stamp,
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Subscribe to writes on a specific component type.
|
|
40
|
+
* Called synchronously after every write of that component.
|
|
41
|
+
* Returns an unsubscribe function.
|
|
42
|
+
*/
|
|
43
|
+
watch(type: ComponentType, handler: ComponentWatcher): () => void {
|
|
44
|
+
let set = watchers.get(type)
|
|
45
|
+
if (!set) {
|
|
46
|
+
set = new Set()
|
|
47
|
+
watchers.set(type, set)
|
|
48
|
+
}
|
|
49
|
+
set.add(handler)
|
|
50
|
+
return () => {
|
|
51
|
+
watchers.get(type)?.delete(handler)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/** Fire watchers for a component write. A throwing watcher is a bug — it propagates. */
|
|
56
|
+
notify(type: ComponentType, entity: EntityId, data: unknown) {
|
|
57
|
+
const set = watchers.get(type)
|
|
58
|
+
if (!set) return
|
|
59
|
+
for (const watcher of set) watcher(entity, data)
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
query<
|
|
63
|
+
const W extends readonly string[] = [],
|
|
64
|
+
const WO extends readonly string[] = [],
|
|
65
|
+
Reg extends Record<string, any> = ComponentRegistry,
|
|
66
|
+
>({
|
|
67
|
+
with: withComponents = [] as unknown as W,
|
|
68
|
+
without: withoutComponents = [] as unknown as WO,
|
|
69
|
+
where,
|
|
70
|
+
filter,
|
|
71
|
+
}: QueryDescriptor<W, WO, Reg>): WorldQueryResult<W, Reg> {
|
|
72
|
+
const stores = components as Map<string, Map<EntityId, any>>
|
|
73
|
+
|
|
74
|
+
const withStores: Map<EntityId, any>[] = []
|
|
75
|
+
for (const c of withComponents) {
|
|
76
|
+
const store = stores.get(c)
|
|
77
|
+
if (!store) return [] as WorldQueryResult<W, Reg>
|
|
78
|
+
withStores.push(store)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Intersect starting from the smallest store
|
|
82
|
+
withStores.sort((a, b) => a.size - b.size)
|
|
83
|
+
|
|
84
|
+
let candidates: EntityId[] = []
|
|
85
|
+
if (withStores.length > 0) {
|
|
86
|
+
const [smallest, ...rest] = withStores
|
|
87
|
+
candidates = [...smallest.keys()].filter(e => rest.every(store => store.has(e)))
|
|
88
|
+
} else {
|
|
89
|
+
const all = new Set<EntityId>()
|
|
90
|
+
for (const store of stores.values()) {
|
|
91
|
+
for (const e of store.keys()) all.add(e)
|
|
92
|
+
}
|
|
93
|
+
candidates = [...all]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (withoutComponents.length > 0) {
|
|
97
|
+
candidates = candidates.filter(e =>
|
|
98
|
+
withoutComponents.every(c => !stores.get(c)?.has(e))
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (where) {
|
|
103
|
+
candidates = candidates.filter(e =>
|
|
104
|
+
Object.entries(where).every(([component, expected]) => {
|
|
105
|
+
const store = stores.get(component)
|
|
106
|
+
if (!store?.has(e)) return false
|
|
107
|
+
return store.get(e) === expected
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let results: WorldQueryResult<W, Reg> = candidates.map(e => {
|
|
113
|
+
const comps: Record<string, any> = {}
|
|
114
|
+
for (const c of withComponents) {
|
|
115
|
+
comps[c] = stores.get(c)!.get(e)
|
|
116
|
+
}
|
|
117
|
+
return { entity: e, components: comps as any }
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (filter) {
|
|
121
|
+
results = results.filter(entry => filter(entry as any))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return results
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type StateT = ReturnType<typeof State>
|
package/src/ecs/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Base ComponentRegistry interface.
|
|
4
|
+
*
|
|
5
|
+
* Kernel systems extend this via module augmentation:
|
|
6
|
+
*
|
|
7
|
+
* declare module "@axon/core" {
|
|
8
|
+
* interface ComponentRegistry {
|
|
9
|
+
* "my-component": { value: string }
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
export interface ComponentRegistry {
|
|
14
|
+
// Base components (empty — kernels extend via module augmentation)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type EntityId = string
|
|
18
|
+
export type ComponentType = keyof ComponentRegistry
|
|
19
|
+
export type ComponentStore<T = any> = Map<EntityId, T>
|
|
20
|
+
|
|
21
|
+
/** Fired synchronously after every component write of a watched type. */
|
|
22
|
+
export type ComponentWatcher = (entity: EntityId, data: unknown) => void
|
|
23
|
+
|
|
24
|
+
export type WorldQueryResult<
|
|
25
|
+
W extends readonly string[],
|
|
26
|
+
Reg extends Record<string, any> = ComponentRegistry,
|
|
27
|
+
> = {
|
|
28
|
+
entity: EntityId
|
|
29
|
+
components: {
|
|
30
|
+
[K in W[number]]: K extends keyof Reg ? Reg[K] : never
|
|
31
|
+
}
|
|
32
|
+
}[]
|
|
33
|
+
|
|
34
|
+
export type QueryDescriptor<
|
|
35
|
+
W extends readonly string[] = [],
|
|
36
|
+
WO extends readonly string[] = [],
|
|
37
|
+
Reg extends Record<string, any> = ComponentRegistry,
|
|
38
|
+
> = {
|
|
39
|
+
with?: W
|
|
40
|
+
without?: WO
|
|
41
|
+
where?: Partial<{ [K in keyof Reg]: Reg[K] }>
|
|
42
|
+
filter?: (entry: {
|
|
43
|
+
entity: EntityId
|
|
44
|
+
components: { [K in W[number]]: K extends keyof Reg ? Reg[K] : never }
|
|
45
|
+
}) => boolean
|
|
46
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { Hookable } from "hookable"
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
3
|
+
import { err } from "@axon/err"
|
|
4
|
+
import type { AxonRunResult, CognetConfig, CognetDefinition, CognetHooks, CognetPlugin, CognetWake, KernelAbi } from "@arcforge/types"
|
|
5
|
+
import type { AxonOutputEvent } from "@arcforge/types"
|
|
6
|
+
import { Clock } from "./clock"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CognetHost — the runtime half of the cognet authoring surface.
|
|
10
|
+
*
|
|
11
|
+
* A cognet is authored as two clutter-free files:
|
|
12
|
+
* cognet.config.ts — identity: `export default defineCognet({ name, ... })`
|
|
13
|
+
* src/main.ts — a RAW SCRIPT: `loop(async ({ stop, ... }) => { ... })`
|
|
14
|
+
* with typed ambient globals (kernel, loop, phase,
|
|
15
|
+
* system) and normal imports.
|
|
16
|
+
*
|
|
17
|
+
* The CLI compile step wraps main.ts in a callable (imports hoisted out,
|
|
18
|
+
* body deferred) and generates an entry that composes this host with the
|
|
19
|
+
* config — so authors never see defineCognet-with-lifecycle boilerplate,
|
|
20
|
+
* and the desugared form is an ordinary CognetDefinition against the ABI:
|
|
21
|
+
* there is no side channel around it.
|
|
22
|
+
*
|
|
23
|
+
* IMPORTING THIS MODULE INSTALLS THE GLOBALS (that's why it is not exported
|
|
24
|
+
* from the cognet index — only generated bundle entries import it, first,
|
|
25
|
+
* so config/main evaluate with the globals present). One brain per process;
|
|
26
|
+
* each bundle carries its own inlined copy of this module's state.
|
|
27
|
+
*
|
|
28
|
+
* Global rules (the contract from the sketch):
|
|
29
|
+
* - process-lifetime things are ambient (kernel, loop)
|
|
30
|
+
* - the cognet learns NOTHING about its environment: no blueprint, no config,
|
|
31
|
+
* no paths. A mind that never knew what kind of world it was in doesn't
|
|
32
|
+
* need porting when the world changes.
|
|
33
|
+
* - wake-scoped things arrive as loop-body ARGS (stop, stimuli, signal,
|
|
34
|
+
* push); phase/system are ambient sugar bound to the current wake's
|
|
35
|
+
* world through AsyncLocalStorage. Several Axon instances share one JS
|
|
36
|
+
* global object, so plain global assignment is not an isolation boundary.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
type LoopCtx = CognetWake & {
|
|
40
|
+
/** end the wake after this tick completes — the brain stays warm */
|
|
41
|
+
stop(): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type LoopBody = (ctx: LoopCtx) => Promise<void>
|
|
45
|
+
|
|
46
|
+
// ── host state (module-scoped: one copy per compiled bundle) ────────────────
|
|
47
|
+
|
|
48
|
+
let registered: LoopBody | null = null
|
|
49
|
+
let boundKernel: KernelAbi | null = null
|
|
50
|
+
let currentClock: ReturnType<typeof Clock> | null = null
|
|
51
|
+
let loaded = false
|
|
52
|
+
|
|
53
|
+
type CognetAmbientScope = {
|
|
54
|
+
kernel: KernelAbi
|
|
55
|
+
loop(body: LoopBody): void
|
|
56
|
+
phase<T>(name: string, fn: () => Promise<T>): Promise<T>
|
|
57
|
+
system<T>(name: string, fn: () => Promise<T>): Promise<T>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Every compiled cognet carries an inlined copy of this module, but all of
|
|
61
|
+
// those copies execute in the same process/globalThis. Symbol.for gives them
|
|
62
|
+
// one dispatcher while AsyncLocalStorage keeps nested and concurrent wakes
|
|
63
|
+
// bound to their own runtime across awaits.
|
|
64
|
+
const AMBIENT_SCOPE = Symbol.for("axon.cognet.ambient-scope")
|
|
65
|
+
const ambientStorage = (() => {
|
|
66
|
+
const shared = globalThis as typeof globalThis & { [AMBIENT_SCOPE]?: AsyncLocalStorage<CognetAmbientScope> }
|
|
67
|
+
return shared[AMBIENT_SCOPE] ??= new AsyncLocalStorage<CognetAmbientScope>()
|
|
68
|
+
})()
|
|
69
|
+
|
|
70
|
+
function ambientOrThrow(): CognetAmbientScope {
|
|
71
|
+
const scope = ambientStorage.getStore()
|
|
72
|
+
if (!scope) throw err("COGNET_ACCESSED_BEFORE_LOAD", { detail: "cognet globals are only available inside this brain's load, wake, and shutdown scope" })
|
|
73
|
+
return scope
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The cognet's own lifecycle hooks — a SEPARATE hookable from Axon's, one
|
|
77
|
+
// ring down. The kernel drives the brain's life through the host; the host
|
|
78
|
+
// fires these at the four fixed points. Awaited-to-completion, in order.
|
|
79
|
+
const hooks = new Hookable<CognetHooks>()
|
|
80
|
+
|
|
81
|
+
function kernelOrThrow(): KernelAbi {
|
|
82
|
+
if (!boundKernel) throw err("COGNET_ACCESSED_BEFORE_LOAD", { detail: "kernel is not available yet — it binds at load(); do work inside loop(), not at module top level" })
|
|
83
|
+
return boundKernel
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clockOrThrow(): ReturnType<typeof Clock> {
|
|
87
|
+
if (!currentClock) throw err("COGNET_ACCESSED_BEFORE_LOAD", { detail: "phase()/system() are wake-scoped — call them inside the loop body" })
|
|
88
|
+
return currentClock
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── ambient globals ──────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const globals = globalThis as Record<string, unknown>
|
|
94
|
+
|
|
95
|
+
globals.defineCognet = <T extends CognetConfig>(config: T): T => config
|
|
96
|
+
|
|
97
|
+
function registerLoop(body: LoopBody): void {
|
|
98
|
+
if (registered) throw err("COGNET_LOOP_ALREADY_DECLARED")
|
|
99
|
+
registered = body
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// definePlugin — plumbing wired to lifecycle hooks. Runs at import time
|
|
103
|
+
// (the compile step imports plugins/*.ts into the bundle after this module),
|
|
104
|
+
// so a plugin's hooks.on(...) registrations land before load() fires "boot".
|
|
105
|
+
globals.definePlugin = (plugin: CognetPlugin): CognetPlugin => {
|
|
106
|
+
// hookable's InferCallback can't distribute over the generic hook name,
|
|
107
|
+
// so the handler is passed through — CognetPluginContext already typed it
|
|
108
|
+
// correctly at the call site; this is a TS limitation, not a shape gap.
|
|
109
|
+
plugin({ hooks: { on: (name, fn) => hooks.hook(name, fn as never) } })
|
|
110
|
+
return plugin
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// run() is overloaded on input shape (string vs string[]) — a plain arrow
|
|
114
|
+
// can't carry two call signatures, so it's declared separately and spread in.
|
|
115
|
+
function run(code: string, opts?: { signal?: AbortSignal }): Promise<AxonRunResult>
|
|
116
|
+
function run(code: string[], opts?: { signal?: AbortSignal }): Promise<AxonRunResult[]>
|
|
117
|
+
function run(code: string | string[], opts?: { signal?: AbortSignal }) {
|
|
118
|
+
if (Array.isArray(code)) return kernelOrThrow().run(code, opts)
|
|
119
|
+
return kernelOrThrow().run(code, opts)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// the syscall table, delegating — live from load() onward
|
|
123
|
+
const localKernel = {
|
|
124
|
+
output: (type: keyof AxonOutputEvent, data: never) => kernelOrThrow().output(type, data),
|
|
125
|
+
stream: (req: never) => kernelOrThrow().stream(req),
|
|
126
|
+
run,
|
|
127
|
+
scope: () => kernelOrThrow().scope(),
|
|
128
|
+
base: () => kernelOrThrow().base(),
|
|
129
|
+
emit: (type: never, data: never) => kernelOrThrow().emit(type, data),
|
|
130
|
+
// store is a live sub-object on the bound ABI — delegate per call, same
|
|
131
|
+
// discipline as every other syscall (never captured before load())
|
|
132
|
+
store: {
|
|
133
|
+
session: {
|
|
134
|
+
get: (opts?: { after?: number }) => kernelOrThrow().store.session.get(opts),
|
|
135
|
+
},
|
|
136
|
+
get: (key: never) => kernelOrThrow().store.get(key),
|
|
137
|
+
set: (key: never, value: never) => kernelOrThrow().store.set(key, value),
|
|
138
|
+
},
|
|
139
|
+
} satisfies KernelAbi
|
|
140
|
+
|
|
141
|
+
const localScope: CognetAmbientScope = {
|
|
142
|
+
kernel: localKernel,
|
|
143
|
+
loop: registerLoop,
|
|
144
|
+
phase: <T>(name: string, fn: () => Promise<T>) => clockOrThrow().runPhase(name, fn),
|
|
145
|
+
system: <T>(name: string, fn: () => Promise<T>) => clockOrThrow().runSystem(name, fn),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Stable process-wide facades. They never capture one cognet instance; every
|
|
149
|
+
// operation resolves the current async scope at the moment it is performed.
|
|
150
|
+
globals.loop = (body: LoopBody): void => ambientOrThrow().loop(body)
|
|
151
|
+
globals.kernel = {
|
|
152
|
+
output: (type: keyof AxonOutputEvent, data: never) => ambientOrThrow().kernel.output(type, data),
|
|
153
|
+
stream: (req: never) => ambientOrThrow().kernel.stream(req),
|
|
154
|
+
run: ((code: string | string[], opts?: { signal?: AbortSignal }) => ambientOrThrow().kernel.run(code as never, opts)) as KernelAbi["run"],
|
|
155
|
+
scope: () => ambientOrThrow().kernel.scope(),
|
|
156
|
+
base: () => ambientOrThrow().kernel.base(),
|
|
157
|
+
emit: (type: never, data: never) => ambientOrThrow().kernel.emit(type, data),
|
|
158
|
+
store: {
|
|
159
|
+
session: { get: (opts?: { after?: number }) => ambientOrThrow().kernel.store.session.get(opts) },
|
|
160
|
+
get: (key: never) => ambientOrThrow().kernel.store.get(key),
|
|
161
|
+
set: (key: never, value: never) => ambientOrThrow().kernel.store.set(key, value),
|
|
162
|
+
},
|
|
163
|
+
} satisfies KernelAbi
|
|
164
|
+
|
|
165
|
+
globals.phase = <T>(name: string, fn: () => Promise<T>) => ambientOrThrow().phase(name, fn)
|
|
166
|
+
globals.system = <T>(name: string, fn: () => Promise<T>) => ambientOrThrow().system(name, fn)
|
|
167
|
+
|
|
168
|
+
// ── composition (what the generated entry calls) ────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* config + wrapped main → the definition the kernel loads. main() runs once
|
|
172
|
+
* at load(), with kernel already bound — its module/closure scope is the
|
|
173
|
+
* brain's resident RAM. It must declare exactly one loop().
|
|
174
|
+
*/
|
|
175
|
+
export function CognetHost(config: CognetConfig, main: () => Promise<void>): CognetDefinition {
|
|
176
|
+
return {
|
|
177
|
+
...config,
|
|
178
|
+
|
|
179
|
+
async load(abi) {
|
|
180
|
+
// A definition is a process-lifetime artifact. Reload plumbing may
|
|
181
|
+
// encounter the same hash-busted module instance again; exec is
|
|
182
|
+
// idempotent for that instance and must never register main twice.
|
|
183
|
+
if (loaded) {
|
|
184
|
+
if (boundKernel !== abi) throw err("COGNET_ALREADY_LOADED", { context: { name: config.name } })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
boundKernel = abi
|
|
188
|
+
try {
|
|
189
|
+
await ambientStorage.run(localScope, main) // declares loop(); plugins already registered at import time
|
|
190
|
+
if (!registered) {
|
|
191
|
+
throw err("COGNET_NO_LOOP", { detail: `${config.name} ran main() without declaring loop()`, context: { name: config.name } })
|
|
192
|
+
}
|
|
193
|
+
await ambientStorage.run(localScope, () => hooks.callHook("boot"))
|
|
194
|
+
loaded = true
|
|
195
|
+
} catch (error) {
|
|
196
|
+
// A failed exec is not a half-loaded brain. Permit a clean
|
|
197
|
+
// retry after the caller has recorded/recovered the failure.
|
|
198
|
+
registered = null
|
|
199
|
+
boundKernel = null
|
|
200
|
+
throw error
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async wake(wake) {
|
|
205
|
+
return ambientStorage.run(localScope, async () => {
|
|
206
|
+
const body = registered
|
|
207
|
+
if (!body) throw err("COGNET_NO_LOOP", { detail: `${config.name} woken before load()`, context: { name: config.name } })
|
|
208
|
+
|
|
209
|
+
const clock = Clock({ emit: (type, data) => kernelOrThrow().emit(type, data), signal: wake.signal })
|
|
210
|
+
currentClock = clock
|
|
211
|
+
|
|
212
|
+
await hooks.callHook("wake", wake)
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
let stopped = false
|
|
216
|
+
const ctx: LoopCtx = { ...wake, stop: () => { stopped = true } }
|
|
217
|
+
const maxTicks = config.maxTicksPerWake ?? Infinity
|
|
218
|
+
while (!stopped && !wake.signal.aborted) {
|
|
219
|
+
if (clock.tick >= maxTicks) {
|
|
220
|
+
throw err("COGNET_MAX_TICKS", {
|
|
221
|
+
detail: `${config.name} exceeded ${maxTicks} ticks in one wake`,
|
|
222
|
+
context: { name: config.name, maxTicks },
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
// tick hook is on the hot path — plugins here must stay cheap
|
|
226
|
+
await clock.runTick(async () => {
|
|
227
|
+
await hooks.callHook("tick", { tick: clock.tick })
|
|
228
|
+
await body(ctx)
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
currentClock = null
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async unload() {
|
|
238
|
+
await ambientStorage.run(localScope, () => hooks.callHook("shutdown"))
|
|
239
|
+
hooks.removeAllHooks()
|
|
240
|
+
registered = null
|
|
241
|
+
boundKernel = null
|
|
242
|
+
loaded = false
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @arcforge/cognet — the cognet runtime.
|
|
2
|
+
//
|
|
3
|
+
// Everything that executes INSIDE a compiled brain. The kernel that loads the
|
|
4
|
+
// brain lives in @axon/core and is private; this is the public half, and it is
|
|
5
|
+
// what the generated entry imports.
|
|
6
|
+
//
|
|
7
|
+
// Root export is deliberately minimal: a cognet cannot exist without the host,
|
|
8
|
+
// but the world (./ecs) and the grammar (./air) are opt-in, so a control loop
|
|
9
|
+
// bundles neither.
|
|
10
|
+
|
|
11
|
+
// Importing ./host INSTALLS the ambient globals (loop, kernel, phase, system,
|
|
12
|
+
// defineCognet, definePlugin) as a side effect — that is why the generated
|
|
13
|
+
// entry imports it first, before config and main evaluate.
|
|
14
|
+
//
|
|
15
|
+
// definePlugin is deliberately global-only: it registers lifecycle hooks when
|
|
16
|
+
// called, so it is a side-effecting declaration rather than an identity
|
|
17
|
+
// function, and exporting it would invite calling it outside a brain.
|
|
18
|
+
export { CognetHost } from "./host"
|
|
19
|
+
export { defineCognet } from "./define"
|
|
20
|
+
|
|
21
|
+
export { Clock, type ClockT, type ClockOpts, type ClockEmit } from "./clock"
|