@driftengine/chemistry 3.61.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.
Files changed (142) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +194 -0
  4. package/dist/conservation.d.ts +9 -0
  5. package/dist/conservation.js +38 -0
  6. package/dist/element/elements.d.ts +31 -0
  7. package/dist/element/elements.js +73 -0
  8. package/dist/element/matrix.d.ts +27 -0
  9. package/dist/element/matrix.js +80 -0
  10. package/dist/field/ambient.d.ts +49 -0
  11. package/dist/field/ambient.js +82 -0
  12. package/dist/field/atmosphere.d.ts +308 -0
  13. package/dist/field/atmosphere.js +1033 -0
  14. package/dist/ignition/criteria.d.ts +97 -0
  15. package/dist/ignition/criteria.js +135 -0
  16. package/dist/ignition/events.d.ts +56 -0
  17. package/dist/ignition/events.js +90 -0
  18. package/dist/index.d.ts +84 -0
  19. package/dist/index.js +66 -0
  20. package/dist/library/biological.d.ts +6 -0
  21. package/dist/library/biological.js +229 -0
  22. package/dist/library/common.d.ts +26 -0
  23. package/dist/library/common.js +208 -0
  24. package/dist/library/food.d.ts +9 -0
  25. package/dist/library/food.js +272 -0
  26. package/dist/library/fuel.d.ts +19 -0
  27. package/dist/library/fuel.js +290 -0
  28. package/dist/library/library.d.ts +45 -0
  29. package/dist/library/library.js +29 -0
  30. package/dist/library/metal.d.ts +6 -0
  31. package/dist/library/metal.js +181 -0
  32. package/dist/library/mineral.d.ts +4 -0
  33. package/dist/library/mineral.js +177 -0
  34. package/dist/library/organic.d.ts +12 -0
  35. package/dist/library/organic.js +489 -0
  36. package/dist/library/polymer.d.ts +4 -0
  37. package/dist/library/polymer.js +237 -0
  38. package/dist/parcel/conduction.d.ts +20 -0
  39. package/dist/parcel/conduction.js +160 -0
  40. package/dist/parcel/shells.d.ts +49 -0
  41. package/dist/parcel/shells.js +73 -0
  42. package/dist/parcel/store.d.ts +395 -0
  43. package/dist/parcel/store.js +1152 -0
  44. package/dist/parcel/tier.d.ts +48 -0
  45. package/dist/parcel/tier.js +51 -0
  46. package/dist/present/audio.d.ts +41 -0
  47. package/dist/present/audio.js +50 -0
  48. package/dist/present/blackbody.d.ts +55 -0
  49. package/dist/present/blackbody.js +112 -0
  50. package/dist/present/flame.d.ts +34 -0
  51. package/dist/present/flame.js +103 -0
  52. package/dist/present/index.d.ts +25 -0
  53. package/dist/present/index.js +22 -0
  54. package/dist/present/install.d.ts +87 -0
  55. package/dist/present/install.js +157 -0
  56. package/dist/present/smoke.d.ts +63 -0
  57. package/dist/present/smoke.js +85 -0
  58. package/dist/present/surface.d.ts +40 -0
  59. package/dist/present/surface.js +76 -0
  60. package/dist/reaction/localise.d.ts +16 -0
  61. package/dist/reaction/localise.js +95 -0
  62. package/dist/reaction/rates.d.ts +59 -0
  63. package/dist/reaction/rates.js +101 -0
  64. package/dist/reaction/reaction.d.ts +178 -0
  65. package/dist/reaction/reaction.js +104 -0
  66. package/dist/reaction/registry.d.ts +47 -0
  67. package/dist/reaction/registry.js +215 -0
  68. package/dist/reaction/solver.d.ts +51 -0
  69. package/dist/reaction/solver.js +277 -0
  70. package/dist/species/registry.d.ts +26 -0
  71. package/dist/species/registry.js +97 -0
  72. package/dist/species/species.d.ts +48 -0
  73. package/dist/species/species.js +20 -0
  74. package/dist/species/standard.d.ts +30 -0
  75. package/dist/species/standard.js +436 -0
  76. package/dist/species/thermal.d.ts +18 -0
  77. package/dist/species/thermal.js +72 -0
  78. package/dist/substance/boiling.d.ts +29 -0
  79. package/dist/substance/boiling.js +54 -0
  80. package/dist/substance/composition.d.ts +34 -0
  81. package/dist/substance/composition.js +53 -0
  82. package/dist/substance/registry.d.ts +86 -0
  83. package/dist/substance/registry.js +187 -0
  84. package/dist/substance/substance.d.ts +94 -0
  85. package/dist/substance/substance.js +48 -0
  86. package/dist/testing/drift.d.ts +15 -0
  87. package/dist/testing/drift.js +29 -0
  88. package/dist/transport/contacts.d.ts +22 -0
  89. package/dist/transport/contacts.js +50 -0
  90. package/dist/transport/radiation.d.ts +101 -0
  91. package/dist/transport/radiation.js +199 -0
  92. package/dist/transport/world.d.ts +92 -0
  93. package/dist/transport/world.js +381 -0
  94. package/dist/world/fingerprint.d.ts +31 -0
  95. package/dist/world/fingerprint.js +70 -0
  96. package/package.json +100 -0
  97. package/src/conservation.ts +44 -0
  98. package/src/element/elements.ts +80 -0
  99. package/src/element/matrix.ts +89 -0
  100. package/src/field/ambient.ts +111 -0
  101. package/src/field/atmosphere.ts +1159 -0
  102. package/src/ignition/criteria.ts +177 -0
  103. package/src/ignition/events.ts +100 -0
  104. package/src/index.ts +213 -0
  105. package/src/library/biological.ts +252 -0
  106. package/src/library/common.ts +224 -0
  107. package/src/library/food.ts +302 -0
  108. package/src/library/fuel.ts +299 -0
  109. package/src/library/library.ts +62 -0
  110. package/src/library/metal.ts +186 -0
  111. package/src/library/mineral.ts +194 -0
  112. package/src/library/organic.ts +545 -0
  113. package/src/library/polymer.ts +243 -0
  114. package/src/parcel/conduction.ts +188 -0
  115. package/src/parcel/shells.ts +100 -0
  116. package/src/parcel/store.ts +1270 -0
  117. package/src/parcel/tier.ts +60 -0
  118. package/src/present/audio.ts +54 -0
  119. package/src/present/blackbody.ts +118 -0
  120. package/src/present/flame.ts +111 -0
  121. package/src/present/index.ts +25 -0
  122. package/src/present/install.ts +209 -0
  123. package/src/present/smoke.ts +139 -0
  124. package/src/present/surface.ts +107 -0
  125. package/src/reaction/localise.ts +126 -0
  126. package/src/reaction/rates.ts +153 -0
  127. package/src/reaction/reaction.ts +235 -0
  128. package/src/reaction/registry.ts +257 -0
  129. package/src/reaction/solver.ts +326 -0
  130. package/src/species/registry.ts +117 -0
  131. package/src/species/species.ts +53 -0
  132. package/src/species/standard.ts +449 -0
  133. package/src/species/thermal.ts +74 -0
  134. package/src/substance/boiling.ts +64 -0
  135. package/src/substance/composition.ts +81 -0
  136. package/src/substance/registry.ts +223 -0
  137. package/src/substance/substance.ts +148 -0
  138. package/src/testing/drift.ts +28 -0
  139. package/src/transport/contacts.ts +56 -0
  140. package/src/transport/radiation.ts +238 -0
  141. package/src/transport/world.ts +437 -0
  142. package/src/world/fingerprint.ts +106 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * The tick: parcels, the air, and everything that crosses between them.
3
+ *
4
+ * `§13` gives the order and this is it. What it adds over the phases beneath is the thing that
5
+ * makes them one simulation rather than two — **a burning parcel eats the oxygen around it and
6
+ * pushes its smoke into the air**, and a parcel near a fire gets hot because of where it is rather
7
+ * than because a caller told it to.
8
+ *
9
+ * **Nothing here applies a flux.** A consumer declares what is radiating and what is touching what,
10
+ * and every parcel works out for itself what reaches it. That is the difference between a fire that
11
+ * behaves and a fire that is a set of numbers somebody tuned.
12
+ *
13
+ * **The field is optional.** Without one, radiation and contact still work and the parcels simply
14
+ * have no air to breathe: no convection, no gas exchange, no oxygen limit. A consumer who wants
15
+ * heat but not atmosphere pays for neither the memory nor the sweep.
16
+ */
17
+ import { ELEMENT_COUNT } from '../element/elements.ts';
18
+ import type { AtmosphereField } from '../field/atmosphere.ts';
19
+ import { PHASE_GAS } from '../species/species.ts';
20
+ import type { ParcelStore } from '../parcel/store.ts';
21
+ import { ContactSet } from './contacts.ts';
22
+ import {
23
+ ChemistryEvents,
24
+ EVENT_EXTINGUISHED,
25
+ EVENT_IGNITED,
26
+ EVENT_SMOULDER_END,
27
+ EVENT_SMOULDER_START,
28
+ } from '../ignition/events.ts';
29
+ import {
30
+ extinguishes,
31
+ ignites,
32
+ ignitionProgress,
33
+ smoulders,
34
+ EXTINCT_NONE,
35
+ } from '../ignition/criteria.ts';
36
+ import { type OcclusionTest, RadiativeSources, convectiveCoefficient } from './radiation.ts';
37
+
38
+ export interface ChemistryWorldOptions {
39
+ /**
40
+ * How fast a porous parcel equalises its pore gas with the cell around it, per second.
41
+ *
42
+ * Scaled by porosity, so a substance nobody described as porous exchanges nothing. What this
43
+ * governs in practice: how quickly volatiles get out to burn, and how quickly oxygen gets in to
44
+ * burn them.
45
+ */
46
+ readonly exchangeRate?: number;
47
+ /** Ambient gas speed where there is no field to ask, m/s. */
48
+ readonly stillAir?: number;
49
+ }
50
+
51
+ const DEFAULT_EXCHANGE_RATE = 2;
52
+
53
+ /** Which of a parcel's gases can carry a flame. Steam and carbon dioxide cannot, and that matters. */
54
+ const COMBUSTIBLE = new Set(['CH4', 'CO', 'H2', 'C2H4', 'C2H6', 'NH3', 'HCN']);
55
+
56
+ /** Where there is no field to ask, the air is ordinary air. */
57
+ const AIR_OXYGEN = 0.2071;
58
+
59
+ /**
60
+ * Ticks the wake scan is spread over, and it must be a power of two so the phase is a mask.
61
+ *
62
+ * `§14`: "that scan is itself budgeted and round-robin across four ticks." A sleeping parcel is
63
+ * therefore woken within four ticks of a fire arriving — a sixteenth of a second at 60 Hz, which is
64
+ * far inside the tens of seconds anything takes to respond to one.
65
+ */
66
+ const WAKE_PHASES = 4;
67
+
68
+ export class ChemistryWorld {
69
+ /** Refilled by the caller each tick: what is radiating. */
70
+ readonly sources = new RadiativeSources();
71
+ /** Refilled by the caller each tick: what is touching what. */
72
+ readonly contacts = new ContactSet();
73
+ /** Drained by the caller each tick: what happened. Cleared at the top of every `simulate`. */
74
+ readonly events = new ChemistryEvents();
75
+
76
+ /** Which slice of the sleeping set this tick's wake scan looks at. See `simulate`. */
77
+ private scanPhase = 0;
78
+ /**
79
+ * Whether anything solid stands between two points.
80
+ *
81
+ * Absent, every source is visible — the cheap default, and wrong indoors. A consumer supplies a
82
+ * raycast; `§10` budgets and caches it, which is CH-6's work rather than this phase's.
83
+ */
84
+ occluded: OcclusionTest | null = null;
85
+
86
+ private readonly exchangeRate: number;
87
+ private readonly stillAir: number;
88
+ private readonly scratch = new Float64Array(ELEMENT_COUNT);
89
+
90
+ constructor(
91
+ /** The parcels this world steps. Readable, because `fingerprintChemistry` hashes them. */
92
+ readonly parcels: ParcelStore,
93
+ /** The air, or `null` for a world with none. Readable for the same reason. */
94
+ readonly field: AtmosphereField | null = null,
95
+ options: ChemistryWorldOptions = {},
96
+ ) {
97
+ this.exchangeRate = options.exchangeRate ?? DEFAULT_EXCHANGE_RATE;
98
+ this.stillAir = options.stillAir ?? 0;
99
+ }
100
+
101
+ /**
102
+ * One fixed step, in the order `§13` gives.
103
+ *
104
+ * **Sleeping and the tier cadence are both here rather than inside the terms they skip**, because
105
+ * both are decisions about *whether* a parcel is stepped at all and a term that decided for itself
106
+ * would be four places deciding the same thing four ways.
107
+ */
108
+ simulate(dt: number, windX = 0, windZ = 0): void {
109
+ if (!(dt > 0)) return;
110
+ this.events.clear();
111
+ const speed = Math.sqrt(windX * windX + windZ * windZ) + this.stillAir;
112
+ const count = this.parcels.count;
113
+
114
+ /*
115
+ * The wake scan, round-robin across four ticks. `§14`: "the wake test is a coarse scan and it
116
+ * must be, or sleeping saves nothing." A sleeping parcel is tested against the source set's own
117
+ * ranges — which CH-5 already derives from source power, so a bonfire reaches further than a
118
+ * candle — and a thousand sleepers cost two hundred and fifty of those a tick.
119
+ */
120
+ this.scanPhase = (this.scanPhase + 1) & (WAKE_PHASES - 1);
121
+ for (let parcel = this.scanPhase; parcel < count; parcel += WAKE_PHASES) {
122
+ if (!this.parcels.alive(parcel) || !this.parcels.sleeping(parcel)) continue;
123
+ if (
124
+ this.sources.reachesPoint(
125
+ this.parcels.positionX(parcel),
126
+ this.parcels.positionY(parcel),
127
+ this.parcels.positionZ(parcel),
128
+ )
129
+ ) {
130
+ this.parcels.wake(parcel);
131
+ }
132
+ }
133
+
134
+ for (let parcel = 0; parcel < count; parcel++) {
135
+ if (!this.parcels.alive(parcel) || this.parcels.sleeping(parcel)) continue;
136
+ this.radiate(parcel, dt);
137
+ this.convect(parcel, dt, speed);
138
+ }
139
+ this.conductContacts(dt);
140
+ for (let parcel = 0; parcel < count; parcel++) {
141
+ if (!this.parcels.alive(parcel) || this.parcels.sleeping(parcel)) continue;
142
+ /* Its turn, or the seconds it is owed carried forward. Zero means it is not due this tick. */
143
+ const step = this.parcels.accrue(parcel, dt);
144
+ if (step === 0) continue;
145
+ this.parcels.conduct(parcel, step);
146
+ /* Faster gas past a surface delivers reactants faster — `1 + √v`, so a breeze matters and a
147
+ gale does not matter proportionally more. This is what brightens an ember. */
148
+ this.parcels.react(parcel, step, 1 + Math.sqrt(speed));
149
+ this.exchange(parcel, step);
150
+ }
151
+ if (this.field !== null) this.field.step(dt, windX, windZ);
152
+ for (let parcel = 0; parcel < count; parcel++) {
153
+ if (!this.parcels.alive(parcel) || this.parcels.sleeping(parcel)) continue;
154
+ this.criteria(parcel, speed);
155
+ this.parcels.settle(parcel);
156
+ }
157
+ }
158
+
159
+ /** What the fires can see of this parcel, into shell 0. */
160
+ private radiate(parcel: number, dt: number): void {
161
+ if (this.sources.count === 0) return;
162
+ const area = this.parcels.areaOf(parcel);
163
+ if (!(area > 0)) return;
164
+ const flux = this.sources.fluxAt(
165
+ this.parcels.positionX(parcel),
166
+ this.parcels.positionY(parcel),
167
+ this.parcels.positionZ(parcel),
168
+ this.parcels.surfaceTemperatureOf(parcel),
169
+ this.parcels.emissivityOf(parcel),
170
+ this.occluded,
171
+ );
172
+ if (flux === 0) return;
173
+ /*
174
+ * **Bounded by the source doing the heating**, because the term is explicit and joules into a
175
+ * small enough shell is an arbitrarily large temperature change. A copper coin on a hearth in
176
+ * `demo/dev/chemistry.ts` reached 4,000 K on its first step and 100 K a minute later, radiating
177
+ * the excess back as `T⁴` and ringing itself apart — a 2 g surface shell against a flux written
178
+ * for a log.
179
+ *
180
+ * Nothing can be heated past the thing heating it, so the step may reach that equilibrium and
181
+ * not exceed it. That is what an implicit solve would give, at the cost of one comparison.
182
+ */
183
+ this.parcels.addSurfaceHeat(
184
+ parcel,
185
+ this.bounded(
186
+ parcel,
187
+ flux * area * dt,
188
+ this.sources.hottestAt(
189
+ this.parcels.positionX(parcel),
190
+ this.parcels.positionY(parcel),
191
+ this.parcels.positionZ(parcel),
192
+ this.occluded,
193
+ ),
194
+ ),
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Joules, clipped to what would bring the surface to `driving` and no further.
200
+ *
201
+ * Zero `driving` means nothing was found to be driving it, and then the joules pass through: a
202
+ * parcel radiating to a sky with no source in it is losing heat to the far field, and clipping
203
+ * that against zero kelvin would be clipping it against nothing.
204
+ */
205
+ private bounded(parcel: number, joules: number, driving: number): number {
206
+ if (joules === 0 || !(driving > 0)) return joules;
207
+ const capacity = this.parcels.surfaceHeatCapacityOf(parcel);
208
+ if (!(capacity > 0)) return joules;
209
+ const room = capacity * (driving - this.parcels.surfaceTemperatureOf(parcel));
210
+ if (joules > 0) return room <= 0 ? 0 : joules < room ? joules : room;
211
+ return room >= 0 ? 0 : joules > room ? joules : room;
212
+ }
213
+
214
+ /** What the air around it is doing to it. */
215
+ private convect(parcel: number, dt: number, speed: number): void {
216
+ if (this.field === null) return;
217
+ const area = this.parcels.areaOf(parcel);
218
+ if (!(area > 0)) return;
219
+ const x = this.parcels.positionX(parcel);
220
+ const y = this.parcels.positionY(parcel);
221
+ const z = this.parcels.positionZ(parcel);
222
+ const gas = this.field.temperatureAt(x, y, z);
223
+ const surface = this.parcels.surfaceTemperatureOf(parcel);
224
+ /* Bounded by the gas, for the reason `radiate` gives: a surface cannot be convected past the
225
+ air touching it, and an explicit step over a small shell otherwise sails through it. */
226
+ const joules = this.bounded(
227
+ parcel,
228
+ convectiveCoefficient(speed) * area * (gas - surface) * dt,
229
+ gas,
230
+ );
231
+ if (joules === 0) return;
232
+ /* Taken from the air rather than made: the cell cools by exactly what the parcel gained. */
233
+ this.parcels.addSurfaceHeat(parcel, joules);
234
+ this.field.addHeat(x, y, z, -joules);
235
+ }
236
+
237
+ /** Heat across the faces the caller says are touching. */
238
+ private conductContacts(dt: number): void {
239
+ for (let contact = 0; contact < this.contacts.count; contact++) {
240
+ const a = this.contacts.firstOf(contact);
241
+ const b = this.contacts.secondOf(contact);
242
+ if (!this.parcels.alive(a) || !this.parcels.alive(b)) continue;
243
+ const area = this.contacts.areaOf(contact);
244
+ if (!(area > 0)) continue;
245
+ const ta = this.parcels.surfaceTemperatureOf(a);
246
+ const tb = this.parcels.surfaceTemperatureOf(b);
247
+ const ka = this.parcels.conductivityOf(a, ta);
248
+ const kb = this.parcels.conductivityOf(b, tb);
249
+ const total = ka + kb;
250
+ if (!(total > 0)) continue;
251
+ /* The harmonic mean again, and for the same reason a shell boundary uses one: two materials
252
+ in series add reciprocals. The span is the two parcels' own surface shells. */
253
+ const effective = (2 * ka * kb) / total;
254
+ const span = (this.parcels.shellDepthOf(a, 0) + this.parcels.shellDepthOf(b, 0)) / 2;
255
+ if (!(span > 0)) continue;
256
+ const joules = (effective * area * (ta - tb) * dt) / span;
257
+ this.parcels.addSurfaceHeat(a, -joules);
258
+ this.parcels.addSurfaceHeat(b, joules);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * The pore gas in a parcel relaxes toward the air around it.
264
+ *
265
+ * **One mechanism, both directions.** Volatiles a parcel's reactions made are above the cell's
266
+ * concentration and leave; oxygen its reactions consumed is below and comes in. Writing those as
267
+ * two rules would be two things to keep in step, and the second would be found wrong on the day a
268
+ * fire went out with oxygen still in the room.
269
+ *
270
+ * The target is what the pore volume would hold at the cell's own density, so a dense material
271
+ * with little void exchanges little — which is why a log smoulders and sawdust does not.
272
+ */
273
+ private exchange(parcel: number, dt: number): void {
274
+ const field = this.field;
275
+ if (field === null) return;
276
+ const substance = this.parcels.substanceOf(parcel);
277
+ const porosity = this.parcels.porosityOf(parcel);
278
+ if (!(porosity > 0)) return;
279
+ const share = Math.min(1, this.exchangeRate * porosity * dt);
280
+ if (!(share > 0)) return;
281
+
282
+ const pore = this.parcels.volumeOf(parcel) * porosity;
283
+ const x = this.parcels.positionX(parcel);
284
+ const y = this.parcels.positionY(parcel);
285
+ const z = this.parcels.positionZ(parcel);
286
+ const cellVolume = field.cellVolume;
287
+
288
+ const set = this.parcels.speciesSetOf(substance);
289
+ const count = this.parcels.shellCount(parcel);
290
+ const area = this.parcels.areaOf(parcel);
291
+ let leaving = 0;
292
+ let leavingFuel = 0;
293
+ for (let i = 0; i < set.length; i++) {
294
+ const global = set[i] as number;
295
+ if (this.parcels.phaseOf(global) !== PHASE_GAS) continue;
296
+ if (field.slotOf(global) < 0) continue;
297
+ const outside = (field.speciesMassAt(x, y, z, global) / cellVolume) * pore;
298
+ /* Only the surface shell is open to the air; deeper gas has to reach it first, which is what
299
+ `permeability` will govern when CH-6 needs it to. */
300
+ const inside = this.parcels.speciesMassOf(parcel, 0, global);
301
+ let moved = (inside - outside / count) * share;
302
+ if (moved === 0) continue;
303
+
304
+ /*
305
+ * Clamped to what the donor actually has, **before** anything is applied.
306
+ *
307
+ * Both sides clamp a negative mass to zero on the way in, so an unclamped move that overdrew
308
+ * would be silently absorbed — and silently absorbed mass is exactly the thing element
309
+ * conservation exists to catch. It caught it: the drift was 5.6e-9 before this line.
310
+ */
311
+ if (moved > inside) moved = inside;
312
+ const available = field.speciesMassAt(x, y, z, global);
313
+ if (-moved > available) moved = -available;
314
+ if (moved === 0) continue;
315
+
316
+ /*
317
+ * **Enthalpy travels with the mass**, and leaving it behind is an energy leak with a
318
+ * plausible-looking symptom: a parcel that quietly cools as it vents, or warms as it
319
+ * breathes, for no reason anybody can point at. The donor's own specific enthalpy is what
320
+ * moves, which is what makes the pair conserve rather than merely nearly.
321
+ */
322
+ if (moved > 0) {
323
+ const shellMass = this.parcels.massOf(parcel) / count;
324
+ const specific = this.parcels.shellEnthalpyOf(parcel, 0) / shellMass;
325
+ this.parcels.addSurfaceHeat(parcel, -specific * moved);
326
+ field.addHeat(x, y, z, specific * moved);
327
+ } else {
328
+ const cellMass = field.massAt(x, y, z);
329
+ if (cellMass > 0) {
330
+ const specific = field.enthalpyAt(x, y, z) / cellMass;
331
+ field.addHeat(x, y, z, specific * moved);
332
+ this.parcels.addSurfaceHeat(parcel, -specific * moved);
333
+ }
334
+ }
335
+
336
+ this.parcels.addSpeciesMass(parcel, 0, global, -moved);
337
+ field.addSpecies(x, y, z, global, moved);
338
+ if (moved > 0) {
339
+ leaving += moved;
340
+ if (COMBUSTIBLE.has(this.parcels.idOfSpecies(global))) leavingFuel += moved;
341
+ }
342
+ }
343
+
344
+ /*
345
+ * **The criterion ignition actually turns on**, kg/(m²·s). A surface can be well past its
346
+ * ignition temperature and still not be producing a flammable mixture — which is exactly what
347
+ * wet wood does, and why the temperature is the proxy and this is the thing.
348
+ */
349
+ const per = area > 0 && dt > 0 ? 1 / (area * dt) : 0;
350
+ this.parcels.setMassFlux(parcel, leaving * per, leavingFuel * per);
351
+ }
352
+
353
+ /**
354
+ * Whether this parcel catches, keeps burning, or stops — and why.
355
+ *
356
+ * **Nothing here starts a fire.** Five conditions decide it, a pilot is one of them, and the
357
+ * pilot a caller supplied is spent at the end of the tick whether or not it was enough. That is
358
+ * `§11`, and it is why holding a match to wet wood does exactly nothing.
359
+ */
360
+ private criteria(parcel: number, gasSpeed: number): void {
361
+ const substance = this.parcels.substanceOf(parcel);
362
+ const model = this.parcels.ignitionOf(substance);
363
+ if (model === null) return;
364
+
365
+ const surface = this.parcels.surfaceTemperatureOf(parcel);
366
+ const flux = this.parcels.fuelFluxOf(parcel);
367
+ const total = this.parcels.massFluxOf(parcel);
368
+ const x = this.parcels.positionX(parcel);
369
+ const y = this.parcels.positionY(parcel);
370
+ const z = this.parcels.positionZ(parcel);
371
+ const oxygen = this.field === null ? AIR_OXYGEN : this.field.oxygenFractionAt(x, y, z);
372
+
373
+ const wasBurning = this.parcels.burning(parcel);
374
+ if (wasBurning) {
375
+ const why = extinguishes(model, flux, oxygen, surface, gasSpeed);
376
+ if (why !== EXTINCT_NONE) {
377
+ this.parcels.setBurning(parcel, false);
378
+ this.events.push(EVENT_EXTINGUISHED, parcel, -1, why);
379
+ }
380
+ } else if (
381
+ ignites(model, surface, flux, total, oxygen, gasSpeed, this.parcels.piloted(parcel))
382
+ ) {
383
+ this.parcels.setBurning(parcel, true);
384
+ this.events.push(EVENT_IGNITED, parcel, -1, surface);
385
+ }
386
+
387
+ const wasSmouldering = this.parcels.smouldering(parcel);
388
+ const glowing = smoulders(model, this.parcels.charFractionOf(parcel), oxygen, surface);
389
+ if (glowing !== wasSmouldering) {
390
+ this.parcels.setSmouldering(parcel, glowing);
391
+ this.events.push(glowing ? EVENT_SMOULDER_START : EVENT_SMOULDER_END, parcel, -1, surface);
392
+ }
393
+
394
+ /* A pilot lasts one tick. A caller holding a match holds it every tick. */
395
+ this.parcels.clearPilot(parcel);
396
+ }
397
+
398
+ /** How close a parcel is to catching, 0 to 1. A readout; nothing branches on it. */
399
+ ignitionProgressOf(parcel: number): number {
400
+ const model = this.parcels.ignitionOf(this.parcels.substanceOf(parcel));
401
+ if (model === null) return 0;
402
+ const x = this.parcels.positionX(parcel);
403
+ const y = this.parcels.positionY(parcel);
404
+ const z = this.parcels.positionZ(parcel);
405
+ return ignitionProgress(
406
+ model,
407
+ this.parcels.surfaceTemperatureOf(parcel),
408
+ this.parcels.fuelFluxOf(parcel),
409
+ this.field === null ? AIR_OXYGEN : this.field.oxygenFractionAt(x, y, z),
410
+ this.parcels.piloted(parcel),
411
+ );
412
+ }
413
+
414
+ /** Moles of each element in the parcels and the air together. Writes into `out`. */
415
+ elementTotals(out: Float64Array): void {
416
+ if (out.length !== ELEMENT_COUNT) {
417
+ throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
418
+ }
419
+ out.fill(0);
420
+ for (let parcel = 0; parcel < this.parcels.count; parcel++) {
421
+ if (!this.parcels.alive(parcel)) continue;
422
+ this.parcels.elementTotalsOf(parcel, this.scratch);
423
+ for (let e = 0; e < ELEMENT_COUNT; e++)
424
+ out[e] = (out[e] as number) + (this.scratch[e] as number);
425
+ }
426
+ if (this.field !== null) {
427
+ this.field.elementTotals(this.scratch);
428
+ for (let e = 0; e < ELEMENT_COUNT; e++)
429
+ out[e] = (out[e] as number) + (this.scratch[e] as number);
430
+ /* Plus what has crossed the boundary in either direction — which includes the air every new
431
+ chunk drew in, and is what makes this invariant while the live set is still growing. */
432
+ this.field.ventedElementTotals(this.scratch);
433
+ for (let e = 0; e < ELEMENT_COUNT; e++)
434
+ out[e] = (out[e] as number) + (this.scratch[e] as number);
435
+ }
436
+ }
437
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * A 64-bit digest of everything a chemistry world holds, beside `fingerprintBodies`.
3
+ *
4
+ * **`§15` lists four things that make determinism a property rather than a hope, and three of them
5
+ * already held**: no transcendental on a tick, a reaction clamp that is proportional rather than
6
+ * first-come, and `Float64Array` for every piece of state. Without this function those three are
7
+ * claims about the code. With it they are assertions about its output, and a replay that diverges
8
+ * is caught at the tick it diverged rather than at the frame somebody noticed.
9
+ *
10
+ * ---
11
+ *
12
+ * ## Two departures from the physics one, and both are forced
13
+ *
14
+ * **Doubles, not floats.** `fingerprintBodies` narrows to `Float32Array` because that is what a body
15
+ * *is*. Chemistry's state is doubles by decision — enthalpy differences across a burn span twelve
16
+ * orders of magnitude — so narrowing here would make two genuinely different worlds agree, which is
17
+ * the one thing a fingerprint may not do.
18
+ *
19
+ * **Order-insensitive over parcels.** Each parcel's digest is combined by **addition** rather than
20
+ * by folding into a running state, because a parcel's handle *is* the order it was spawned in.
21
+ * `§25` asks for a test that shuffled registration gives an identical fingerprint, and a hash that
22
+ * folded handles in sequence would fail it while nothing was wrong. Addition costs the ability to
23
+ * distinguish two worlds that are permutations of each other — which is exactly the distinction
24
+ * this is asked not to make.
25
+ *
26
+ * **The substance registry is in it**, per `§15`: two worlds with different libraries are different
27
+ * worlds, and a mismatch should be reported as one rather than surface later as a divergence nobody
28
+ * can place.
29
+ */
30
+ import type { ChemistryWorld } from '../transport/world.ts';
31
+ import { ELEMENT_COUNT } from '../element/elements.ts';
32
+
33
+ const OFFSET = 0x811c9dc5;
34
+ const PRIME = 0x01000193;
35
+
36
+ /* Scratch, module-level: this is called once a tick at most and allocating a view per double would
37
+ be the one place a diagnostic function cost more than what it measures. */
38
+ const scratch = new Float64Array(1);
39
+ const bytes = new Uint8Array(scratch.buffer);
40
+ const elements = new Float64Array(ELEMENT_COUNT);
41
+
42
+ /** FNV-1a over the eight bytes of a double, in both directions, into a pair of 32-bit lanes. */
43
+ function mix(state: { low: number; high: number }, value: number): void {
44
+ scratch[0] = value;
45
+ for (let b = 0; b < 8; b++) {
46
+ state.low = Math.imul(state.low ^ (bytes[b] as number), PRIME) >>> 0;
47
+ state.high = Math.imul(state.high ^ (bytes[7 - b] as number), PRIME) >>> 0;
48
+ }
49
+ }
50
+
51
+ function hex(low: number, high: number): string {
52
+ return `${(low >>> 0).toString(16).padStart(8, '0')}${(high >>> 0).toString(16).padStart(8, '0')}`;
53
+ }
54
+
55
+ export function fingerprintChemistry(world: ChemistryWorld): string {
56
+ const parcels = world.parcels;
57
+ const state = { low: OFFSET, high: OFFSET };
58
+
59
+ /*
60
+ * The registry, folded in order, because a registry *is* ordered: a substance is an index and two
61
+ * libraries installed the other way round genuinely are two different worlds by that index.
62
+ */
63
+ const substances = parcels.substanceCount;
64
+ mix(state, substances);
65
+ for (let s = 0; s < substances; s++) {
66
+ mix(state, parcels.substanceDigestOf(s));
67
+ }
68
+
69
+ /* Each parcel's own digest, added rather than folded. See the header. */
70
+ let low = 0;
71
+ let high = 0;
72
+ for (let parcel = 0; parcel < parcels.count; parcel++) {
73
+ if (!parcels.alive(parcel)) continue;
74
+ const own = { low: OFFSET, high: OFFSET };
75
+ /* What it is made of, rather than which handle it got. */
76
+ mix(own, parcels.substanceOf(parcel));
77
+ mix(own, parcels.massOf(parcel));
78
+ mix(own, parcels.positionX(parcel));
79
+ mix(own, parcels.positionY(parcel));
80
+ mix(own, parcels.positionZ(parcel));
81
+ const shells = parcels.shellCount(parcel);
82
+ for (let shell = 0; shell < shells; shell++) {
83
+ mix(own, parcels.shellEnthalpyOf(parcel, shell));
84
+ mix(own, parcels.shellMassOf(parcel, shell));
85
+ }
86
+ /* Composition as element totals: a species set is a property of the substance, so hashing raw
87
+ slots would make two identical parcels of differently-ordered registries disagree. */
88
+ parcels.elementTotalsOf(parcel, elements);
89
+ for (let e = 0; e < ELEMENT_COUNT; e++) mix(own, elements[e] as number);
90
+ low = (low + own.low) >>> 0;
91
+ high = (high + own.high) >>> 0;
92
+ }
93
+ mix(state, low);
94
+ mix(state, high);
95
+
96
+ /* The air, as the one quantity that summarises every cell: moles of each element it holds. */
97
+ const field = world.field;
98
+ if (field !== null) {
99
+ field.elementTotals(elements);
100
+ for (let e = 0; e < ELEMENT_COUNT; e++) mix(state, elements[e] as number);
101
+ mix(state, field.chunkCount);
102
+ mix(state, field.totalEnthalpy());
103
+ }
104
+
105
+ return hex(state.low, state.high);
106
+ }