@patterkit/runtime 0.1.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [34429f0]
8
+ - Updated dependencies [34429f0]
9
+ - Updated dependencies [c61c146]
10
+ - @patterkit/model@0.2.0
11
+ - @patterkit/dialect@0.1.3
12
+
3
13
  All notable changes to `@patterkit/runtime` (Patterplay JS) are documented here. The
4
14
  Patterplay runtimes - JS, Unity, Unreal, and Godot - are versioned in lockstep: the same
5
15
  version number always means the same runtime behaviour. This package is versioned by
@@ -7,9 +17,20 @@ version number always means the same runtime behaviour. This package is versione
7
17
 
8
18
  ## [Unreleased]
9
19
 
10
- ## [0.1.0] - Unreleased
20
+ ## [0.2.0] - 2026-07-07
21
+
22
+ ### Added
23
+
24
+ - **Best match** selection (a new `sequence` order, `specificity`): among the eligible children,
25
+ play the one whose condition most specifically fits the current state; equally-specific ties break
26
+ by the seeded shuffle, and a condition-less child is the filler that wins only when nothing more
27
+ specific applies. Composes with the exhaust axis (re-pickable, or graceful degradation to the
28
+ filler). Locked by the conformance corpus, so all four runtimes agree.
29
+
30
+ ## [0.1.0] - 2026-07-04
11
31
 
12
32
  ### Added
33
+
13
34
  - The Patter runtime in JS/TS: `Engine` + `Flow` over a compiled `.patterc` bundle - scenes,
14
35
  blocks, run/choice/branch/sequence selectors, sticky/fallback options, call-return jumps,
15
36
  conditions + effects, visit counts, `{@ref}` interpolation, game events, tags, gameData
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ module.exports = __toCommonJS(index_exports);
31
31
 
32
32
  // src/engine.ts
33
33
  var import_expr = require("@wildwinter/expr");
34
+ var import_expr_specificity = require("@wildwinter/expr-specificity");
34
35
  var import_scoperegistry = require("@wildwinter/scoperegistry");
35
36
  var import_dialect = require("@patterkit/dialect");
36
37
  var import_model = require("@patterkit/model");
@@ -933,7 +934,7 @@ var Flow = class {
933
934
  case "sequence": {
934
935
  const order = group.options?.order ?? "sequential";
935
936
  const exhaust = group.options?.exhaust ?? "once";
936
- return order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st);
937
+ return order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : order === "specificity" ? this.pickSpecificity(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st);
937
938
  }
938
939
  case "run":
939
940
  case "choice":
@@ -980,6 +981,65 @@ var Flow = class {
980
981
  st.last = id;
981
982
  return eligible.find((c) => c.id === id);
982
983
  }
984
+ /**
985
+ * `sequence` with `order: "specificity"` - **Best match**: score every eligible child by how
986
+ * specifically its condition fits the CURRENT state (`matchedSpec`), keep the top-scoring tier,
987
+ * and break ties with the seeded shuffle (no immediate repeat). A child with no condition scores
988
+ * 0, so it is the filler that wins only when nothing more specific is eligible.
989
+ *
990
+ * `exhaust` composes as it does for the other orders: `repeat` re-scores the full eligible set
991
+ * every draw (re-pickable - the character keeps preferring the on-topic line); `once` uses each
992
+ * pick up (a bag of remaining ids), so as specific lines are consumed the group slides down to
993
+ * less-specific ones and finally the filler, then yields null; `stick` degrades like `once` but
994
+ * holds the final pick forever instead of drying up.
995
+ */
996
+ pickSpecificity(eligible, exhaust, st) {
997
+ let pool = eligible;
998
+ if (exhaust !== "repeat") {
999
+ if (st.bag === void 0) st.bag = eligible.map((c) => c.id);
1000
+ const remaining = new Set(st.bag);
1001
+ pool = eligible.filter((c) => remaining.has(c.id));
1002
+ if (pool.length === 0) {
1003
+ return exhaust === "stick" && st.last !== void 0 ? eligible.find((c) => c.id === st.last) ?? null : null;
1004
+ }
1005
+ }
1006
+ let best = -1;
1007
+ const scored = pool.map((c) => {
1008
+ const s = this.specScore(c);
1009
+ if (s > best) best = s;
1010
+ return { c, s };
1011
+ });
1012
+ const tier = scored.filter((x) => x.s === best).map((x) => x.c);
1013
+ let pick;
1014
+ if (tier.length === 1) {
1015
+ pick = tier[0];
1016
+ } else {
1017
+ const p = st.last !== void 0 ? tier.findIndex((c) => c.id === st.last) : -1;
1018
+ let i = Math.floor(this.rng() * (p >= 0 ? tier.length - 1 : tier.length));
1019
+ if (p >= 0 && i >= p) i++;
1020
+ pick = tier[i];
1021
+ }
1022
+ if (exhaust !== "repeat") st.bag = st.bag.filter((id) => id !== pick.id);
1023
+ st.last = pick.id;
1024
+ return pick;
1025
+ }
1026
+ /** A child's Best-match score against the current state: 0 when it has no condition (the filler
1027
+ * tier), else the specificity of its (already-passing) condition. */
1028
+ specScore(node) {
1029
+ return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
1030
+ }
1031
+ /**
1032
+ * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
1033
+ * holding this condition TRUE against the live state. Evaluation-aware, not a static clause count -
1034
+ * it walks the tree with a De-Morgan polarity flag so `or` and `not` score the branch that is
1035
+ * actually carrying the truth. `want` = "does this subtree need to be true for the whole condition
1036
+ * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
1037
+ * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1038
+ */
1039
+ matchedSpec(node, want) {
1040
+ const evalTruthy = (n) => truthy((0, import_expr.evaluate)(n, this.evalCtx, import_dialect.patterDialect));
1041
+ return (0, import_expr_specificity.matchedSpecificity)(node, evalTruthy, { want });
1042
+ }
983
1043
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
984
1044
  selectorState(group) {
985
1045
  const map = group.shared ? this.host.sharedSelectors : this.selectors;
@@ -1001,12 +1061,17 @@ var Flow = class {
1001
1061
  return truthy(this.evalExpr(node.condition));
1002
1062
  }
1003
1063
  evalExpr(expr) {
1064
+ return (0, import_expr.evaluate)(this.conditionAst(expr), this.evalCtx, import_dialect.patterDialect);
1065
+ }
1066
+ /** The deserialised (in-memory) AST for an expression, cached per Expression. Shared by the
1067
+ * evaluator and the Best-match specificity walker so both work off one parse. */
1068
+ conditionAst(expr) {
1004
1069
  let ast = astCache.get(expr);
1005
1070
  if (!ast) {
1006
1071
  ast = (0, import_expr.deserialiseAst)(expr.ast);
1007
1072
  astCache.set(expr, ast);
1008
1073
  }
1009
- return (0, import_expr.evaluate)(ast, this.evalCtx, import_dialect.patterDialect);
1074
+ return ast;
1010
1075
  }
1011
1076
  /** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
1012
1077
  enter(id) {