@defold-typescript/transpiler 0.46.0 → 0.48.0

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/dist/hsm/index.ts CHANGED
@@ -168,6 +168,22 @@ export type AlwaysSpec<Ctx, To extends string = string> =
168
168
  | AlwaysConfig<Ctx, To>
169
169
  | RuleList<AlwaysConfig<Ctx, To>>;
170
170
 
171
+ /**
172
+ * One entry of the list form of `after`: a `delay` and the `to` its timer leads to.
173
+ * @noSelf
174
+ */
175
+ export interface AfterConfig<Ctx, To extends string = string> {
176
+ /**
177
+ * The delay in seconds of game time: a number, or a function of the context that returns one.
178
+ * A function is read once each time the state is entered, after its `enter` hook, and again on
179
+ * a hot reload, so the delay is fixed for that stay. A result that is not a non-negative number
180
+ * throws, naming the state.
181
+ */
182
+ readonly delay: number | ((ctx: Ctx) => number);
183
+ /** The full path of the state to move to when the delay has passed. */
184
+ readonly to: To;
185
+ }
186
+
171
187
  /**
172
188
  * A state's rules by event `type`. Only the machine's event types are keys, and each rule's
173
189
  * `when` and `run` see the event variant its key names.
@@ -304,11 +320,15 @@ interface StateBase<Ctx, E extends EventObject, T, Self extends string, To exten
304
320
  */
305
321
  readonly on?: NoInfer<OnConfig<Ctx, E, To>>;
306
322
  /**
307
- * Targets by delay in seconds of game time. The delay is counted by `update(dt)` while the
308
- * state is active, from zero on each entry. One `update` call fires at most one timer, and
309
- * none when an `update` hook moved the machine.
323
+ * Timers, in one of two forms: targets by delay in seconds of game time, such as
324
+ * `{ 2: "/idle" }`, or a list of `AfterConfig` entries, such as
325
+ * `[{ delay: (ctx) => ctx.patrolFor, to: "/rest" }]`, whose `delay` is a number or a function
326
+ * of the context. A delay function is read once each time the state is entered, after its
327
+ * `enter` hook, and again on a hot reload. The delay is counted by `update(dt)` while the state
328
+ * is active, from zero on each entry. One `update` call fires at most one timer, and none when
329
+ * an `update` hook moved the machine.
310
330
  */
311
- readonly after?: NoInfer<{ readonly [seconds: number]: To }>;
331
+ readonly after?: NoInfer<{ readonly [seconds: number]: To } | readonly AfterConfig<Ctx, To>[]>;
312
332
  /**
313
333
  * Targets taken right after any move that leaves this state active; the first whose `when` is
314
334
  * missing or passes wins. It is checked after a move only: not after a rule with no `to`, and
@@ -415,6 +435,8 @@ interface Transition<Ctx, E extends EventObject> {
415
435
  readonly run: readonly MoveAction<Ctx, E>[];
416
436
  }
417
437
 
438
+ type Delay<Ctx> = number | ((ctx: Ctx) => number);
439
+
418
440
  interface Compiled<Ctx, E extends EventObject> {
419
441
  count: number;
420
442
  readonly configs: StateConfig<Ctx, E>[];
@@ -431,6 +453,10 @@ interface Compiled<Ctx, E extends EventObject> {
431
453
  readonly transitions: Transition<Ctx, E>[][];
432
454
  readonly afterDelays: number[][];
433
455
  readonly afterTargets: number[][];
456
+ // Set only for a state whose after list holds a delay function: its entries in list order,
457
+ // for each instance to sort once the function is read.
458
+ readonly afterListDelays: (Delay<Ctx>[] | undefined)[];
459
+ readonly afterListTargets: (number[] | undefined)[];
434
460
  readonly always: Transition<Ctx, E>[][];
435
461
  readonly pathIndex: { [path: string]: number };
436
462
  }
@@ -763,6 +789,25 @@ function parseDelay(key: string | number): number {
763
789
  return delay;
764
790
  }
765
791
 
792
+ // NaN fails the comparison, so it is rejected with the negative numbers.
793
+ function checkDelay(path: string, delay: unknown, written: unknown): void {
794
+ if (typeof delay !== "number" || !(delay >= 0)) {
795
+ throw `hsm: state "${describePath(path)}" has an after delay "${written}" that is not a non-negative number`;
796
+ }
797
+ }
798
+
799
+ // Keeps the delays ascending; timers with one delay stay in the order they were added.
800
+ function insertTimer(delays: number[], targets: number[], delay: number, target: number): void {
801
+ let slot = delays.length;
802
+ while (slot > 0 && (delays[slot - 1] as number) > delay) {
803
+ delays[slot] = delays[slot - 1] as number;
804
+ targets[slot] = targets[slot - 1] as number;
805
+ slot--;
806
+ }
807
+ delays[slot] = delay;
808
+ targets[slot] = target;
809
+ }
810
+
766
811
  function compileAfter<Ctx, E extends EventObject>(compiled: Compiled<Ctx, E>, index: number): void {
767
812
  const delays: number[] = [];
768
813
  const targets: number[] = [];
@@ -773,20 +818,39 @@ function compileAfter<Ctx, E extends EventObject>(compiled: Compiled<Ctx, E>, in
773
818
  return;
774
819
  }
775
820
  const path = compiled.paths[index] as string;
776
- for (const key in after) {
777
- const delay = parseDelay(key);
778
- if (!(delay >= 0)) {
779
- throw `hsm: state "${describePath(path)}" has an after delay "${key}" that is not a non-negative number`;
821
+ // Read as an array type: the transpiler shifts an index to Lua's base 1 only on an array.
822
+ const list = after as readonly AfterConfig<Ctx>[];
823
+ if (typeof list[0] !== "object") {
824
+ const byDelay = after as { readonly [seconds: number]: string };
825
+ for (const key in byDelay) {
826
+ const delay = parseDelay(key);
827
+ checkDelay(path, delay, key);
828
+ const target = resolveTarget(compiled, index, byDelay[key as unknown as number] as string);
829
+ insertTimer(delays, targets, delay, target);
780
830
  }
781
- const target = resolveTarget(compiled, index, after[key as unknown as number] as string);
782
- let slot = delays.length;
783
- while (slot > 0 && (delays[slot - 1] as number) > delay) {
784
- delays[slot] = delays[slot - 1] as number;
785
- targets[slot] = targets[slot - 1] as number;
786
- slot--;
831
+ return;
832
+ }
833
+ const listDelays: Delay<Ctx>[] = [];
834
+ const listTargets: number[] = [];
835
+ let perInstance = false;
836
+ for (let i = 0; i < list.length; i++) {
837
+ const entry = list[i] as AfterConfig<Ctx>;
838
+ const delay = entry.delay;
839
+ if (typeof delay === "function") {
840
+ perInstance = true;
841
+ } else {
842
+ checkDelay(path, delay, delay);
787
843
  }
788
- delays[slot] = delay;
789
- targets[slot] = target;
844
+ listDelays[i] = delay;
845
+ listTargets[i] = resolveTarget(compiled, index, entry.to);
846
+ }
847
+ if (perInstance) {
848
+ compiled.afterListDelays[index] = listDelays;
849
+ compiled.afterListTargets[index] = listTargets;
850
+ return;
851
+ }
852
+ for (let i = 0; i < listDelays.length; i++) {
853
+ insertTimer(delays, targets, listDelays[i] as number, listTargets[i] as number);
790
854
  }
791
855
  }
792
856
 
@@ -807,6 +871,8 @@ function compile<Ctx, E extends EventObject>(config: MachineConfig<Ctx, E>): Com
807
871
  transitions: [],
808
872
  afterDelays: [],
809
873
  afterTargets: [],
874
+ afterListDelays: [],
875
+ afterListTargets: [],
810
876
  always: [],
811
877
  pathIndex: {},
812
878
  };
@@ -865,6 +931,23 @@ interface Registered<Ctx, E extends EventObject> {
865
931
 
866
932
  const registered: { [key: string]: Registered<unknown, EventObject> } = {};
867
933
 
934
+ // Adds the fields the root's privateCtx returns to ctx; the factory receives target. With
935
+ // onlyMissing, a field ctx already has keeps its value: a hot reload adds new private fields and
936
+ // leaves the old ones alone.
937
+ function fillPrivate(
938
+ target: { [name: string]: unknown },
939
+ root: unknown,
940
+ onlyMissing: boolean,
941
+ ): void {
942
+ const factory = (root as PrivateCtxConfig<unknown, object>).privateCtx;
943
+ const fields = (factory?.(target) || {}) as { [name: string]: unknown };
944
+ for (const name in fields) {
945
+ if (!onlyMissing || target[name] === undefined) {
946
+ target[name] = fields[name];
947
+ }
948
+ }
949
+ }
950
+
868
951
  function createMachine<Ctx, E extends EventObject>(
869
952
  definition: Definition<Ctx, E>,
870
953
  ): Machine<Ctx, E> {
@@ -883,11 +966,16 @@ function createMachine<Ctx, E extends EventObject>(
883
966
  let transitions = compiled.transitions;
884
967
  let afterDelays = compiled.afterDelays;
885
968
  let afterTargets = compiled.afterTargets;
969
+ let afterListDelays = compiled.afterListDelays;
970
+ let afterListTargets = compiled.afterListTargets;
886
971
  let always = compiled.always;
887
972
  const activeChild: number[] = [];
888
973
  const isActive: boolean[] = [];
889
974
  const elapsed: number[] = [];
890
975
  const fired: number[] = [];
976
+ // The sorted timers of a state whose after list holds a delay function, read on each entry.
977
+ const ownDelays: (number[] | undefined)[] = [];
978
+ const ownTargets: (number[] | undefined)[] = [];
891
979
  const entryId: number[] = [];
892
980
  const cleanup: ((() => void) | undefined)[] = [];
893
981
  // Keyed by path, not index, so a remembered child outlives a rebind's recompilation.
@@ -921,10 +1009,32 @@ function createMachine<Ctx, E extends EventObject>(
921
1009
  isActive[state] = false;
922
1010
  elapsed[state] = 0;
923
1011
  fired[state] = 0;
1012
+ ownDelays[state] = undefined;
1013
+ ownTargets[state] = undefined;
924
1014
  entryId[state] = 0;
925
1015
  cleanup[state] = undefined;
926
1016
  }
927
1017
 
1018
+ function readDelays(state: number): void {
1019
+ const list = afterListDelays[state];
1020
+ if (list === undefined) {
1021
+ return;
1022
+ }
1023
+ const listTargets = afterListTargets[state] as number[];
1024
+ const delays: number[] = [];
1025
+ const targets: number[] = [];
1026
+ for (let i = 0; i < list.length; i++) {
1027
+ let delay = list[i] as Delay<Ctx>;
1028
+ if (typeof delay === "function") {
1029
+ delay = delay(ctx);
1030
+ checkDelay(paths[state] as string, delay, delay);
1031
+ }
1032
+ insertTimer(delays, targets, delay, listTargets[i] as number);
1033
+ }
1034
+ ownDelays[state] = delays;
1035
+ ownTargets[state] = targets;
1036
+ }
1037
+
928
1038
  function growSlots(): void {
929
1039
  while (slotCount < compiled.count) {
930
1040
  clearSlot(slotCount);
@@ -979,7 +1089,10 @@ function createMachine<Ctx, E extends EventObject>(
979
1089
  transitions = compiled.transitions;
980
1090
  afterDelays = compiled.afterDelays;
981
1091
  afterTargets = compiled.afterTargets;
1092
+ afterListDelays = compiled.afterListDelays;
1093
+ afterListTargets = compiled.afterListTargets;
982
1094
  always = compiled.always;
1095
+ fillPrivate(ctx as never, configs[ROOT], true);
983
1096
  growSlots();
984
1097
  const dropped: number[] = [];
985
1098
  for (let i = 0; i < order.length; i++) {
@@ -1002,6 +1115,7 @@ function createMachine<Ctx, E extends EventObject>(
1002
1115
  fired[state] = keptFired[i] as number;
1003
1116
  entryId[state] = keptEntryId[i] as number;
1004
1117
  cleanup[state] = keptCleanup[i];
1118
+ readDelays(state);
1005
1119
  }
1006
1120
  for (let i = dropped.length - 1; i >= 0; i--) {
1007
1121
  const run = keptCleanup[dropped[i] as number];
@@ -1104,6 +1218,7 @@ function createMachine<Ctx, E extends EventObject>(
1104
1218
  if (hook !== undefined) {
1105
1219
  hook(ctx, instance);
1106
1220
  }
1221
+ readDelays(state);
1107
1222
  const task = config.task;
1108
1223
  if (task !== undefined) {
1109
1224
  // A path, not an index, so a finish held across a rebind still finds its entry.
@@ -1501,17 +1616,12 @@ function createMachine<Ctx, E extends EventObject>(
1501
1616
  if (child !== NO_STATE && fireTimers(child)) {
1502
1617
  return true;
1503
1618
  }
1504
- const delays = afterDelays[state] as number[];
1619
+ const delays = ownDelays[state] || (afterDelays[state] as number[]);
1505
1620
  const next = fired[state] as number;
1506
1621
  if (next < delays.length && (delays[next] as number) <= (elapsed[state] as number)) {
1507
1622
  fired[state] = next + 1;
1508
- transition(
1509
- state,
1510
- (afterTargets[state] as number[])[next] as number,
1511
- undefined,
1512
- undefined,
1513
- "after",
1514
- );
1623
+ const targets = ownTargets[state] || (afterTargets[state] as number[]);
1624
+ transition(state, targets[next] as number, undefined, undefined, "after");
1515
1625
  return true;
1516
1626
  }
1517
1627
  return false;
@@ -1553,6 +1663,7 @@ function createMachine<Ctx, E extends EventObject>(
1553
1663
  }
1554
1664
 
1555
1665
  busy = true;
1666
+ fillPrivate(ctx as never, configs[ROOT], false);
1556
1667
  enterState(ROOT);
1557
1668
  enterDefaults(ROOT, 0);
1558
1669
  refreshLeaves();
@@ -1585,6 +1696,11 @@ function define<Ctx, E extends EventObject>(
1585
1696
  return machine;
1586
1697
  }
1587
1698
 
1699
+ // Ctx for a config without privateCtx, Ctx & Priv for one with it. The tuple test keeps the
1700
+ // context whole while Priv has no inference yet, so a generic helper in a slot, such as
1701
+ // `sequence`, still takes its ctx from the slot.
1702
+ type WithPrivate<Ctx, Priv> = [Priv] extends [never] ? Ctx : Ctx & Priv;
1703
+
1588
1704
  // Ctx and E are given explicitly and the config is inferred, so the config takes a second call.
1589
1705
  // A key makes a later definition under it (a hot-reloaded module re-running) rebind live instances.
1590
1706
  /**
@@ -1593,9 +1709,117 @@ function define<Ctx, E extends EventObject>(
1593
1709
  * when that call runs, and a mistake throws, naming the state path. With a key, a later
1594
1710
  * definition under it puts its states into the machine already built, so every running instance
1595
1711
  * sees the edit; without one, each call builds a separate machine.
1712
+ *
1713
+ * The root config may hold `privateCtx`, for a machine that keeps fields of its own, such as a
1714
+ * counter, a heat level or a list it builds up. Every hook, `when`, `run`, `task` and `update`
1715
+ * then sees those fields beside `Ctx`, while `start` takes only `Ctx` and the instance it returns
1716
+ * shows only `Ctx` in its `ctx`. A machine without fields of its own leaves `privateCtx` out.
1596
1717
  */
1597
1718
  export function defineMachine<Ctx, E extends EventObject>(
1598
1719
  key?: string,
1599
- ): <T>(config: RootState<Ctx, E, T>) => Machine<Ctx, E, TreePath<T>> {
1720
+ ): <T, Priv extends object = never>(
1721
+ config: RootState<WithPrivate<Ctx, Priv>, E, T> & PrivateCtxConfig<Ctx, Priv>,
1722
+ ) => Machine<Ctx, E, TreePath<T>> {
1600
1723
  return ((config: MachineConfig<Ctx, E>) => define(key, config)) as never;
1601
1724
  }
1725
+
1726
+ /**
1727
+ * The optional key a `defineMachine` root config takes beside `initial` and `states`.
1728
+ * @noSelf
1729
+ */
1730
+ export interface PrivateCtxConfig<Ctx, Priv> {
1731
+ /**
1732
+ * Builds the fields only the machine uses. It runs once per `start`, before the root's `enter`
1733
+ * hook, so every instance gets fresh fields of its own; a value written straight into the
1734
+ * config would be one array or table shared by every instance. It receives the object passed
1735
+ * to `start`, typed `Ctx`, so a private field can start from a field of `Ctx`:
1736
+ * `privateCtx: (ctx: TurretOptions) => ({ heat: ctx.startHeat })`. Give `ctx` its type; without
1737
+ * one TypeScript can type a hook before it knows the private fields, and that hook reports the
1738
+ * field as missing.
1739
+ *
1740
+ * The fields are added to the object passed to `start`, which stays the instance's `ctx`, so
1741
+ * restarting with `start(old.ctx)` keeps the public fields and builds the private ones fresh.
1742
+ * A hot reload under the machine's key adds the fields a new factory introduces and leaves
1743
+ * every field that already holds a value alone. A private field may not share a name with a
1744
+ * field of `Ctx`.
1745
+ *
1746
+ * The fields are hidden from callers by type only. At run time `ctx` is one table, and hooks,
1747
+ * `hsm-view` and a debugger see every field.
1748
+ *
1749
+ * There are two ways to type the fields.
1750
+ *
1751
+ * @example
1752
+ * **Inferred.** For a few fields, write them inline and let TypeScript infer their types. Add
1753
+ * `as` where the inferred type is wrong: an empty array is `never[]` until it reads
1754
+ * `[] as Hash[]`, and a field holding one of several strings is any `string` until it reads
1755
+ * `"cool" as "cool" | "hot"`.
1756
+ *
1757
+ * ```ts
1758
+ * type TurretEvent = { type: "SPOTTED" } | { type: "LOST" };
1759
+ *
1760
+ * export const turret = defineMachine<{ readonly fireRate: number }, TurretEvent>("turret")({
1761
+ * privateCtx: () => ({ heat: 0, targets: [] as Hash[] }),
1762
+ * initial: "/idle",
1763
+ * states: {
1764
+ * idle: { on: { SPOTTED: "/firing" } },
1765
+ * firing: {
1766
+ * enter: (ctx) => {
1767
+ * ctx.heat += 1 / ctx.fireRate;
1768
+ * },
1769
+ * on: { LOST: "/idle" },
1770
+ * },
1771
+ * },
1772
+ * });
1773
+ *
1774
+ * const gun = turret.start({ fireRate: 2 }); // gun.ctx shows fireRate only
1775
+ * ```
1776
+ *
1777
+ * @example
1778
+ * **A named type.** For more fields, or when a helper outside the config needs the whole
1779
+ * context, declare the fields as an interface and make it the factory's return type. The
1780
+ * fields need no `as`, a misspelled or unknown field is a compile error, and the whole context
1781
+ * is `TurretOptions & TurretPrivate`. `satisfies TurretPrivate` does not do this: it checks the
1782
+ * object but keeps its narrow types, so an empty array stays `never[]`.
1783
+ *
1784
+ * ```ts
1785
+ * interface TurretOptions {
1786
+ * readonly fireRate: number;
1787
+ * }
1788
+ *
1789
+ * interface TurretPrivate {
1790
+ * heat: number;
1791
+ * mode: "cool" | "hot";
1792
+ * }
1793
+ *
1794
+ * type TurretEvent = { type: "SPOTTED" } | { type: "LOST" };
1795
+ *
1796
+ * const overheated = (ctx: TurretOptions & TurretPrivate) => ctx.heat > ctx.fireRate;
1797
+ *
1798
+ * export const turret = defineMachine<TurretOptions, TurretEvent>("turret")({
1799
+ * privateCtx: (): TurretPrivate => ({ heat: 0, mode: "cool" }),
1800
+ * initial: "/idle",
1801
+ * states: {
1802
+ * idle: { on: { SPOTTED: "/firing" } },
1803
+ * firing: {
1804
+ * enter: (ctx) => {
1805
+ * ctx.heat += 1 / ctx.fireRate;
1806
+ * },
1807
+ * always: { to: "/cooling", when: overheated },
1808
+ * on: { LOST: "/idle" },
1809
+ * },
1810
+ * cooling: {
1811
+ * enter: (ctx) => {
1812
+ * ctx.mode = "hot";
1813
+ * },
1814
+ * exit: (ctx) => {
1815
+ * ctx.heat = 0;
1816
+ * ctx.mode = "cool";
1817
+ * },
1818
+ * after: { 2: "/idle" },
1819
+ * },
1820
+ * },
1821
+ * });
1822
+ * ```
1823
+ */
1824
+ readonly privateCtx?: (ctx: Ctx) => Priv & { readonly [K in keyof Ctx & keyof Priv]: never };
1825
+ }
package/dist/index.js CHANGED
@@ -3666,6 +3666,7 @@ function transpileVirtual(files, companionEmit, overrides = {}) {
3666
3666
  sourceMap: true,
3667
3667
  lib: ["lib.es2022.d.ts"],
3668
3668
  skipLibCheck: true,
3669
+ resolvePackageJsonImports: false,
3669
3670
  noImplicitSelf: true,
3670
3671
  luaPlugins: [
3671
3672
  { plugin: lifecycleErasurePlugin },
@@ -3745,6 +3746,7 @@ function compilerOptions(companionEmit) {
3745
3746
  sourceMap: true,
3746
3747
  lib: ["lib.es2022.d.ts"],
3747
3748
  skipLibCheck: true,
3749
+ resolvePackageJsonImports: false,
3748
3750
  noImplicitSelf: true,
3749
3751
  luaPlugins: [
3750
3752
  { plugin: lifecycleErasurePlugin },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/transpiler",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "TypeScript-to-Lua build pipeline tuned for Defold's runtime.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@defold-typescript/types": "0.46.0",
38
+ "@defold-typescript/types": "0.48.0",
39
39
  "@typescript-to-lua/language-extensions": "1.19.0",
40
40
  "typescript": "6.0.2",
41
41
  "typescript-to-lua": "^1.36.0"
package/src/session.ts CHANGED
@@ -35,6 +35,13 @@ function compilerOptions(companionEmit: CompanionEmitter): tstl.CompilerOptions
35
35
  // Don't cross-check the seeded ambient .d.ts surface against itself; only user
36
36
  // files matter (mirrors transpileProject and the editor's skipLibCheck).
37
37
  skipLibCheck: true,
38
+ // TypeScript turns this on under its default `bundler` resolution. With it on,
39
+ // an error that has to name a module-declared type the file does not import
40
+ // (`Url`, `Hash`, `Vector3`) throws inside the checker, which builds a module
41
+ // specifier from an undefined base path and the virtual host's empty current
42
+ // directory. The virtual host reads every `package.json` as empty, so no `#`
43
+ // import resolves with the option on or off.
44
+ resolvePackageJsonImports: false,
38
45
  // Defold scripts are not OO: free helper functions never receive a context,
39
46
  // so suppress TSTL's implicit `self` parameter and the `_G` call-site filler.
40
47
  noImplicitSelf: true,
package/src/transpile.ts CHANGED
@@ -341,6 +341,13 @@ function transpileVirtual(
341
341
  // Don't cross-check the seeded ambient .d.ts surface against itself; we only
342
342
  // care about diagnostics on user files (mirrors the editor's skipLibCheck).
343
343
  skipLibCheck: true,
344
+ // TypeScript turns this on under its default `bundler` resolution. With it on,
345
+ // an error that has to name a module-declared type the file does not import
346
+ // (`Url`, `Hash`, `Vector3`) throws inside the checker, which builds a module
347
+ // specifier from an undefined base path and the virtual host's empty current
348
+ // directory. The virtual host reads every `package.json` as empty, so no `#`
349
+ // import resolves with the option on or off.
350
+ resolvePackageJsonImports: false,
344
351
  // Defold scripts are not OO: free helper functions never receive a context,
345
352
  // so suppress TSTL's implicit `self` parameter and the `_G` call-site filler.
346
353
  noImplicitSelf: true,