The Router Block
A Router launches every route whose condition is met, waits for the ones that block, then says whether all of them were.
It is the precondition dispatcher block: "launch everything that is in place, and tell me whether everything was."
Do not confuse it with a condition
Both read conditions, but they are not in the same line of work.
| CONDITION | ROUTER | |
|---|---|---|
| The question | "which one is true?" | "which ones are true?" |
| Cases evaluated | stops as soon as it has its answer | all of them, to the last |
| Exits taken | one only | one per true case, plus a continuation |
A condition switches. A Router dispatches.
The ports
| Port | How many | Taken when |
|---|---|---|
K1, K2, … | one per declared case | that case is true |
then | always 1 | all the cases were true |
catch | always 1 | at least one case was false |
thenandcatchare exclusive. One of the two is taken, never both, never neither.- Both exist from the moment the block is created, before the first case.
catchdoes not mean "error", it means "a condition was not met".
Example
┌─────────────────────────────┐
── the flow ────►│ ROUTER "the door" │
├─────────────────────────────┤
│ K1 items.gold_key > 0 ├──► ACTION a chime (isAsync)
│ K2 quest.guard == 2 ├──► DIALOG the guard nods (isAsync)
│ K3 purse.gold >= 50 ├──► ACTION take 50 gold (isAsync)
├─────────────────────────────┤
│ then ├──► DIALOG "the door opens"
│ catch ├──► DIALOG "something is missing"
└─────────────────────────────┘The key and the gold, but not the guard → K1 and K3 leave, 2 cases out of 3 → exit through catch.
All three → three routes leave, exit through then.
Leaving through catch cancels nothing
The chime rings, the gold is taken, and the player is told something is missing.
Execution
[[every isAsync route], [route 1], [route 2], …] then then | catch- Evaluate every case, in order, never stopping early.
- Launch all the
isAsyncroutes at once. - Run the others one at a time, each to its end.
- Continue through
thenif every case was true, otherwisecatch.
Recommended: isAsync on every target of your K* ports. The engine does not require it.
Example with non-async ports
Every party member present reacts. They should speak one after another, not three bubbles at once — so their routes carry no isAsync.
┌─────────────────────────────┐
── the flow ────►│ ROUTER "the reactions" │
├─────────────────────────────┤
│ K1 quest.door == 3 ├──► ACTION shut the door (isAsync)
│ K2 party.aria == true ├──► DIALOG Aria: "at last!"
│ K3 party.bram == true ├──► DIALOG Bram: "no way"
├─────────────────────────────┤
│ then ├──► DIALOG "the party is ready"
└─────────────────────────────┘All three cases are true:
time ───────────────────────────────────────────────►
K1 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ detached — lives its own life
K2 ▓▓▓▓▓▓▓ Aria speaks
K3 ▓▓▓▓▓▓▓ then Bram
then ▓▓▓▓▓▓[[K1],[K2],[K3]].then- K2 is played to its end — not just its first block, the whole chain wired behind it. K3 only starts afterwards.
thenwaits for K2 and K3. K1 may still be running.
With isAsync on K2 and K3: Aria and Bram speak at the same time, and then leaves immediately.
The cases
Same shape as the CONDITION block.
- A flat list:
dictionary.entry· operator · value. - Named operators —
equals,notEquals,lessThan,lessOrEqual,greaterThan,greaterOrEqual. The diagrams above use>and==for readability. - Every line after the first carries its own link:
andoror. - No evaluation precedence is exported. The reference engine evaluates left to right.
- A case with no comparison is always true.
On the game side
engine.onResolveCondition((test) => {
// test = { dict: "items", entry: "gold_key", op: "greaterThan", value: 0 }
return myGameState.answer(test);
});The engine decides which ports leave and picks then or catch. Nothing to route by hand.
There is no onRouter handler, and none is needed: by the time a handler could speak, every true case has launched its port and the continuation is picked — there is nothing left to answer. start() does not require one. To watch a router — log what matched, drive a debug view — use handle.onBlock(id): its context carries the pre-evaluated cases, ALL of them, and no resolve.
import type { RouterContext } from '@lsde/dialog-engine';
// A ROUTER has no handler of its own: the engine evaluates EVERY case, launches the port of each
// true one and picks `then` or `catch` before a handler could speak. To WATCH one, target it by id.
handle.onBlock('ROUTER-001', ({ block, context, next }) => {
const { cases } = context as RouterContext;
const launched = cases.filter((c) => c.result).map((c) => c.port);
console.log(`${block.id} launched:`, launched.join(', ') || 'nothing');
// Nothing to resolve: the exits are a tally, not a choice.
next();
});handle.OnBlock("ROUTER-001", args => {
var cases = ((IRouterContext)args.Context).Cases;
var launched = cases.Where(c => c.Result == true).Select(c => c.Port).ToList();
Console.WriteLine($"{args.Block.Id} launched: {string.Join(", ", launched)}");
// Nothing to resolve: the exits are a tally, not a choice.
args.Next();
return null;
});handle->onBlock("ROUTER-001", [](auto*, auto* block, IBaseBlockContext* ctx, auto next) -> CleanupFn {
auto* router = dynamic_cast<IRouterContext*>(ctx);
for (const auto& c : router->cases()) {
if (c.result.value_or(false)) std::cout << block->id << " launched " << c.port << "\n";
}
// Nothing to resolve: the exits are a tally, not a choice.
next();
return {};
});handle.on_block("ROUTER-001", func(args):
var ctx = args["context"] # an LsdeBlockContext.RouterContext
var launched = []
for c in ctx.cases:
if c["result"]:
launched.append(c["port"])
print("%s launched: %s" % [args["block"]["id"], ", ".join(launched)])
# Nothing to resolve: the exits are a tally, not a choice.
args["next"].call()
return Callable()
)A tally, not a choice
A condition asks which one and leaves by a single port. A router asks which ones: every case is evaluated — a false one in the middle hides nothing after it — each true case launches its port, and the flow then continues by then when they all held, by catch when any did not. No case at all is then, the way Promise.all([]) resolves.
The K* routes are walked like any other port: an isAsync target opens its own track, the others are this track's, in turn, and the continuation comes last — which is what keeps then/catch as the main flow when the routes are async. catch cancels nothing: the tracks of the true cases are already running.
Pitfalls
- The chosen port may be wired to nothing — the track ends there, like any terminal block. Not an error.
- Zero cases →
then. catchon an ACTION block means "the call failed" — a different thing.- "First one that holds wins" is not a Router, it is a CONDITION with
portPerCase.
What it does not do
- Wait for its
isAsyncroutes — the philosophy of TypeScript'sPromise.all. Deferred. - The
finallyconcept. Ruled out.
