@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,381 @@
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.js';
18
+ import { PHASE_GAS } from '../species/species.js';
19
+ import { ContactSet } from './contacts.js';
20
+ import { ChemistryEvents, EVENT_EXTINGUISHED, EVENT_IGNITED, EVENT_SMOULDER_END, EVENT_SMOULDER_START, } from '../ignition/events.js';
21
+ import { extinguishes, ignites, ignitionProgress, smoulders, EXTINCT_NONE, } from '../ignition/criteria.js';
22
+ import { RadiativeSources, convectiveCoefficient } from './radiation.js';
23
+ const DEFAULT_EXCHANGE_RATE = 2;
24
+ /** Which of a parcel's gases can carry a flame. Steam and carbon dioxide cannot, and that matters. */
25
+ const COMBUSTIBLE = new Set(['CH4', 'CO', 'H2', 'C2H4', 'C2H6', 'NH3', 'HCN']);
26
+ /** Where there is no field to ask, the air is ordinary air. */
27
+ const AIR_OXYGEN = 0.2071;
28
+ /**
29
+ * Ticks the wake scan is spread over, and it must be a power of two so the phase is a mask.
30
+ *
31
+ * `§14`: "that scan is itself budgeted and round-robin across four ticks." A sleeping parcel is
32
+ * therefore woken within four ticks of a fire arriving — a sixteenth of a second at 60 Hz, which is
33
+ * far inside the tens of seconds anything takes to respond to one.
34
+ */
35
+ const WAKE_PHASES = 4;
36
+ export class ChemistryWorld {
37
+ parcels;
38
+ field;
39
+ /** Refilled by the caller each tick: what is radiating. */
40
+ sources = new RadiativeSources();
41
+ /** Refilled by the caller each tick: what is touching what. */
42
+ contacts = new ContactSet();
43
+ /** Drained by the caller each tick: what happened. Cleared at the top of every `simulate`. */
44
+ events = new ChemistryEvents();
45
+ /** Which slice of the sleeping set this tick's wake scan looks at. See `simulate`. */
46
+ scanPhase = 0;
47
+ /**
48
+ * Whether anything solid stands between two points.
49
+ *
50
+ * Absent, every source is visible — the cheap default, and wrong indoors. A consumer supplies a
51
+ * raycast; `§10` budgets and caches it, which is CH-6's work rather than this phase's.
52
+ */
53
+ occluded = null;
54
+ exchangeRate;
55
+ stillAir;
56
+ scratch = new Float64Array(ELEMENT_COUNT);
57
+ constructor(
58
+ /** The parcels this world steps. Readable, because `fingerprintChemistry` hashes them. */
59
+ parcels,
60
+ /** The air, or `null` for a world with none. Readable for the same reason. */
61
+ field = null, options = {}) {
62
+ this.parcels = parcels;
63
+ this.field = field;
64
+ this.exchangeRate = options.exchangeRate ?? DEFAULT_EXCHANGE_RATE;
65
+ this.stillAir = options.stillAir ?? 0;
66
+ }
67
+ /**
68
+ * One fixed step, in the order `§13` gives.
69
+ *
70
+ * **Sleeping and the tier cadence are both here rather than inside the terms they skip**, because
71
+ * both are decisions about *whether* a parcel is stepped at all and a term that decided for itself
72
+ * would be four places deciding the same thing four ways.
73
+ */
74
+ simulate(dt, windX = 0, windZ = 0) {
75
+ if (!(dt > 0))
76
+ return;
77
+ this.events.clear();
78
+ const speed = Math.sqrt(windX * windX + windZ * windZ) + this.stillAir;
79
+ const count = this.parcels.count;
80
+ /*
81
+ * The wake scan, round-robin across four ticks. `§14`: "the wake test is a coarse scan and it
82
+ * must be, or sleeping saves nothing." A sleeping parcel is tested against the source set's own
83
+ * ranges — which CH-5 already derives from source power, so a bonfire reaches further than a
84
+ * candle — and a thousand sleepers cost two hundred and fifty of those a tick.
85
+ */
86
+ this.scanPhase = (this.scanPhase + 1) & (WAKE_PHASES - 1);
87
+ for (let parcel = this.scanPhase; parcel < count; parcel += WAKE_PHASES) {
88
+ if (!this.parcels.alive(parcel) || !this.parcels.sleeping(parcel))
89
+ continue;
90
+ if (this.sources.reachesPoint(this.parcels.positionX(parcel), this.parcels.positionY(parcel), this.parcels.positionZ(parcel))) {
91
+ this.parcels.wake(parcel);
92
+ }
93
+ }
94
+ for (let parcel = 0; parcel < count; parcel++) {
95
+ if (!this.parcels.alive(parcel) || this.parcels.sleeping(parcel))
96
+ continue;
97
+ this.radiate(parcel, dt);
98
+ this.convect(parcel, dt, speed);
99
+ }
100
+ this.conductContacts(dt);
101
+ for (let parcel = 0; parcel < count; parcel++) {
102
+ if (!this.parcels.alive(parcel) || this.parcels.sleeping(parcel))
103
+ continue;
104
+ /* Its turn, or the seconds it is owed carried forward. Zero means it is not due this tick. */
105
+ const step = this.parcels.accrue(parcel, dt);
106
+ if (step === 0)
107
+ continue;
108
+ this.parcels.conduct(parcel, step);
109
+ /* Faster gas past a surface delivers reactants faster — `1 + √v`, so a breeze matters and a
110
+ gale does not matter proportionally more. This is what brightens an ember. */
111
+ this.parcels.react(parcel, step, 1 + Math.sqrt(speed));
112
+ this.exchange(parcel, step);
113
+ }
114
+ if (this.field !== null)
115
+ this.field.step(dt, windX, windZ);
116
+ for (let parcel = 0; parcel < count; parcel++) {
117
+ if (!this.parcels.alive(parcel) || this.parcels.sleeping(parcel))
118
+ continue;
119
+ this.criteria(parcel, speed);
120
+ this.parcels.settle(parcel);
121
+ }
122
+ }
123
+ /** What the fires can see of this parcel, into shell 0. */
124
+ radiate(parcel, dt) {
125
+ if (this.sources.count === 0)
126
+ return;
127
+ const area = this.parcels.areaOf(parcel);
128
+ if (!(area > 0))
129
+ return;
130
+ const flux = this.sources.fluxAt(this.parcels.positionX(parcel), this.parcels.positionY(parcel), this.parcels.positionZ(parcel), this.parcels.surfaceTemperatureOf(parcel), this.parcels.emissivityOf(parcel), this.occluded);
131
+ if (flux === 0)
132
+ return;
133
+ /*
134
+ * **Bounded by the source doing the heating**, because the term is explicit and joules into a
135
+ * small enough shell is an arbitrarily large temperature change. A copper coin on a hearth in
136
+ * `demo/dev/chemistry.ts` reached 4,000 K on its first step and 100 K a minute later, radiating
137
+ * the excess back as `T⁴` and ringing itself apart — a 2 g surface shell against a flux written
138
+ * for a log.
139
+ *
140
+ * Nothing can be heated past the thing heating it, so the step may reach that equilibrium and
141
+ * not exceed it. That is what an implicit solve would give, at the cost of one comparison.
142
+ */
143
+ this.parcels.addSurfaceHeat(parcel, this.bounded(parcel, flux * area * dt, this.sources.hottestAt(this.parcels.positionX(parcel), this.parcels.positionY(parcel), this.parcels.positionZ(parcel), this.occluded)));
144
+ }
145
+ /**
146
+ * Joules, clipped to what would bring the surface to `driving` and no further.
147
+ *
148
+ * Zero `driving` means nothing was found to be driving it, and then the joules pass through: a
149
+ * parcel radiating to a sky with no source in it is losing heat to the far field, and clipping
150
+ * that against zero kelvin would be clipping it against nothing.
151
+ */
152
+ bounded(parcel, joules, driving) {
153
+ if (joules === 0 || !(driving > 0))
154
+ return joules;
155
+ const capacity = this.parcels.surfaceHeatCapacityOf(parcel);
156
+ if (!(capacity > 0))
157
+ return joules;
158
+ const room = capacity * (driving - this.parcels.surfaceTemperatureOf(parcel));
159
+ if (joules > 0)
160
+ return room <= 0 ? 0 : joules < room ? joules : room;
161
+ return room >= 0 ? 0 : joules > room ? joules : room;
162
+ }
163
+ /** What the air around it is doing to it. */
164
+ convect(parcel, dt, speed) {
165
+ if (this.field === null)
166
+ return;
167
+ const area = this.parcels.areaOf(parcel);
168
+ if (!(area > 0))
169
+ return;
170
+ const x = this.parcels.positionX(parcel);
171
+ const y = this.parcels.positionY(parcel);
172
+ const z = this.parcels.positionZ(parcel);
173
+ const gas = this.field.temperatureAt(x, y, z);
174
+ const surface = this.parcels.surfaceTemperatureOf(parcel);
175
+ /* Bounded by the gas, for the reason `radiate` gives: a surface cannot be convected past the
176
+ air touching it, and an explicit step over a small shell otherwise sails through it. */
177
+ const joules = this.bounded(parcel, convectiveCoefficient(speed) * area * (gas - surface) * dt, gas);
178
+ if (joules === 0)
179
+ return;
180
+ /* Taken from the air rather than made: the cell cools by exactly what the parcel gained. */
181
+ this.parcels.addSurfaceHeat(parcel, joules);
182
+ this.field.addHeat(x, y, z, -joules);
183
+ }
184
+ /** Heat across the faces the caller says are touching. */
185
+ conductContacts(dt) {
186
+ for (let contact = 0; contact < this.contacts.count; contact++) {
187
+ const a = this.contacts.firstOf(contact);
188
+ const b = this.contacts.secondOf(contact);
189
+ if (!this.parcels.alive(a) || !this.parcels.alive(b))
190
+ continue;
191
+ const area = this.contacts.areaOf(contact);
192
+ if (!(area > 0))
193
+ continue;
194
+ const ta = this.parcels.surfaceTemperatureOf(a);
195
+ const tb = this.parcels.surfaceTemperatureOf(b);
196
+ const ka = this.parcels.conductivityOf(a, ta);
197
+ const kb = this.parcels.conductivityOf(b, tb);
198
+ const total = ka + kb;
199
+ if (!(total > 0))
200
+ continue;
201
+ /* The harmonic mean again, and for the same reason a shell boundary uses one: two materials
202
+ in series add reciprocals. The span is the two parcels' own surface shells. */
203
+ const effective = (2 * ka * kb) / total;
204
+ const span = (this.parcels.shellDepthOf(a, 0) + this.parcels.shellDepthOf(b, 0)) / 2;
205
+ if (!(span > 0))
206
+ continue;
207
+ const joules = (effective * area * (ta - tb) * dt) / span;
208
+ this.parcels.addSurfaceHeat(a, -joules);
209
+ this.parcels.addSurfaceHeat(b, joules);
210
+ }
211
+ }
212
+ /**
213
+ * The pore gas in a parcel relaxes toward the air around it.
214
+ *
215
+ * **One mechanism, both directions.** Volatiles a parcel's reactions made are above the cell's
216
+ * concentration and leave; oxygen its reactions consumed is below and comes in. Writing those as
217
+ * two rules would be two things to keep in step, and the second would be found wrong on the day a
218
+ * fire went out with oxygen still in the room.
219
+ *
220
+ * The target is what the pore volume would hold at the cell's own density, so a dense material
221
+ * with little void exchanges little — which is why a log smoulders and sawdust does not.
222
+ */
223
+ exchange(parcel, dt) {
224
+ const field = this.field;
225
+ if (field === null)
226
+ return;
227
+ const substance = this.parcels.substanceOf(parcel);
228
+ const porosity = this.parcels.porosityOf(parcel);
229
+ if (!(porosity > 0))
230
+ return;
231
+ const share = Math.min(1, this.exchangeRate * porosity * dt);
232
+ if (!(share > 0))
233
+ return;
234
+ const pore = this.parcels.volumeOf(parcel) * porosity;
235
+ const x = this.parcels.positionX(parcel);
236
+ const y = this.parcels.positionY(parcel);
237
+ const z = this.parcels.positionZ(parcel);
238
+ const cellVolume = field.cellVolume;
239
+ const set = this.parcels.speciesSetOf(substance);
240
+ const count = this.parcels.shellCount(parcel);
241
+ const area = this.parcels.areaOf(parcel);
242
+ let leaving = 0;
243
+ let leavingFuel = 0;
244
+ for (let i = 0; i < set.length; i++) {
245
+ const global = set[i];
246
+ if (this.parcels.phaseOf(global) !== PHASE_GAS)
247
+ continue;
248
+ if (field.slotOf(global) < 0)
249
+ continue;
250
+ const outside = (field.speciesMassAt(x, y, z, global) / cellVolume) * pore;
251
+ /* Only the surface shell is open to the air; deeper gas has to reach it first, which is what
252
+ `permeability` will govern when CH-6 needs it to. */
253
+ const inside = this.parcels.speciesMassOf(parcel, 0, global);
254
+ let moved = (inside - outside / count) * share;
255
+ if (moved === 0)
256
+ continue;
257
+ /*
258
+ * Clamped to what the donor actually has, **before** anything is applied.
259
+ *
260
+ * Both sides clamp a negative mass to zero on the way in, so an unclamped move that overdrew
261
+ * would be silently absorbed — and silently absorbed mass is exactly the thing element
262
+ * conservation exists to catch. It caught it: the drift was 5.6e-9 before this line.
263
+ */
264
+ if (moved > inside)
265
+ moved = inside;
266
+ const available = field.speciesMassAt(x, y, z, global);
267
+ if (-moved > available)
268
+ moved = -available;
269
+ if (moved === 0)
270
+ continue;
271
+ /*
272
+ * **Enthalpy travels with the mass**, and leaving it behind is an energy leak with a
273
+ * plausible-looking symptom: a parcel that quietly cools as it vents, or warms as it
274
+ * breathes, for no reason anybody can point at. The donor's own specific enthalpy is what
275
+ * moves, which is what makes the pair conserve rather than merely nearly.
276
+ */
277
+ if (moved > 0) {
278
+ const shellMass = this.parcels.massOf(parcel) / count;
279
+ const specific = this.parcels.shellEnthalpyOf(parcel, 0) / shellMass;
280
+ this.parcels.addSurfaceHeat(parcel, -specific * moved);
281
+ field.addHeat(x, y, z, specific * moved);
282
+ }
283
+ else {
284
+ const cellMass = field.massAt(x, y, z);
285
+ if (cellMass > 0) {
286
+ const specific = field.enthalpyAt(x, y, z) / cellMass;
287
+ field.addHeat(x, y, z, specific * moved);
288
+ this.parcels.addSurfaceHeat(parcel, -specific * moved);
289
+ }
290
+ }
291
+ this.parcels.addSpeciesMass(parcel, 0, global, -moved);
292
+ field.addSpecies(x, y, z, global, moved);
293
+ if (moved > 0) {
294
+ leaving += moved;
295
+ if (COMBUSTIBLE.has(this.parcels.idOfSpecies(global)))
296
+ leavingFuel += moved;
297
+ }
298
+ }
299
+ /*
300
+ * **The criterion ignition actually turns on**, kg/(m²·s). A surface can be well past its
301
+ * ignition temperature and still not be producing a flammable mixture — which is exactly what
302
+ * wet wood does, and why the temperature is the proxy and this is the thing.
303
+ */
304
+ const per = area > 0 && dt > 0 ? 1 / (area * dt) : 0;
305
+ this.parcels.setMassFlux(parcel, leaving * per, leavingFuel * per);
306
+ }
307
+ /**
308
+ * Whether this parcel catches, keeps burning, or stops — and why.
309
+ *
310
+ * **Nothing here starts a fire.** Five conditions decide it, a pilot is one of them, and the
311
+ * pilot a caller supplied is spent at the end of the tick whether or not it was enough. That is
312
+ * `§11`, and it is why holding a match to wet wood does exactly nothing.
313
+ */
314
+ criteria(parcel, gasSpeed) {
315
+ const substance = this.parcels.substanceOf(parcel);
316
+ const model = this.parcels.ignitionOf(substance);
317
+ if (model === null)
318
+ return;
319
+ const surface = this.parcels.surfaceTemperatureOf(parcel);
320
+ const flux = this.parcels.fuelFluxOf(parcel);
321
+ const total = this.parcels.massFluxOf(parcel);
322
+ const x = this.parcels.positionX(parcel);
323
+ const y = this.parcels.positionY(parcel);
324
+ const z = this.parcels.positionZ(parcel);
325
+ const oxygen = this.field === null ? AIR_OXYGEN : this.field.oxygenFractionAt(x, y, z);
326
+ const wasBurning = this.parcels.burning(parcel);
327
+ if (wasBurning) {
328
+ const why = extinguishes(model, flux, oxygen, surface, gasSpeed);
329
+ if (why !== EXTINCT_NONE) {
330
+ this.parcels.setBurning(parcel, false);
331
+ this.events.push(EVENT_EXTINGUISHED, parcel, -1, why);
332
+ }
333
+ }
334
+ else if (ignites(model, surface, flux, total, oxygen, gasSpeed, this.parcels.piloted(parcel))) {
335
+ this.parcels.setBurning(parcel, true);
336
+ this.events.push(EVENT_IGNITED, parcel, -1, surface);
337
+ }
338
+ const wasSmouldering = this.parcels.smouldering(parcel);
339
+ const glowing = smoulders(model, this.parcels.charFractionOf(parcel), oxygen, surface);
340
+ if (glowing !== wasSmouldering) {
341
+ this.parcels.setSmouldering(parcel, glowing);
342
+ this.events.push(glowing ? EVENT_SMOULDER_START : EVENT_SMOULDER_END, parcel, -1, surface);
343
+ }
344
+ /* A pilot lasts one tick. A caller holding a match holds it every tick. */
345
+ this.parcels.clearPilot(parcel);
346
+ }
347
+ /** How close a parcel is to catching, 0 to 1. A readout; nothing branches on it. */
348
+ ignitionProgressOf(parcel) {
349
+ const model = this.parcels.ignitionOf(this.parcels.substanceOf(parcel));
350
+ if (model === null)
351
+ return 0;
352
+ const x = this.parcels.positionX(parcel);
353
+ const y = this.parcels.positionY(parcel);
354
+ const z = this.parcels.positionZ(parcel);
355
+ return ignitionProgress(model, this.parcels.surfaceTemperatureOf(parcel), this.parcels.fuelFluxOf(parcel), this.field === null ? AIR_OXYGEN : this.field.oxygenFractionAt(x, y, z), this.parcels.piloted(parcel));
356
+ }
357
+ /** Moles of each element in the parcels and the air together. Writes into `out`. */
358
+ elementTotals(out) {
359
+ if (out.length !== ELEMENT_COUNT) {
360
+ throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
361
+ }
362
+ out.fill(0);
363
+ for (let parcel = 0; parcel < this.parcels.count; parcel++) {
364
+ if (!this.parcels.alive(parcel))
365
+ continue;
366
+ this.parcels.elementTotalsOf(parcel, this.scratch);
367
+ for (let e = 0; e < ELEMENT_COUNT; e++)
368
+ out[e] = out[e] + this.scratch[e];
369
+ }
370
+ if (this.field !== null) {
371
+ this.field.elementTotals(this.scratch);
372
+ for (let e = 0; e < ELEMENT_COUNT; e++)
373
+ out[e] = out[e] + this.scratch[e];
374
+ /* Plus what has crossed the boundary in either direction — which includes the air every new
375
+ chunk drew in, and is what makes this invariant while the live set is still growing. */
376
+ this.field.ventedElementTotals(this.scratch);
377
+ for (let e = 0; e < ELEMENT_COUNT; e++)
378
+ out[e] = out[e] + this.scratch[e];
379
+ }
380
+ }
381
+ }
@@ -0,0 +1,31 @@
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
+ export declare function fingerprintChemistry(world: ChemistryWorld): string;
@@ -0,0 +1,70 @@
1
+ import { ELEMENT_COUNT } from '../element/elements.js';
2
+ const OFFSET = 0x811c9dc5;
3
+ const PRIME = 0x01000193;
4
+ /* Scratch, module-level: this is called once a tick at most and allocating a view per double would
5
+ be the one place a diagnostic function cost more than what it measures. */
6
+ const scratch = new Float64Array(1);
7
+ const bytes = new Uint8Array(scratch.buffer);
8
+ const elements = new Float64Array(ELEMENT_COUNT);
9
+ /** FNV-1a over the eight bytes of a double, in both directions, into a pair of 32-bit lanes. */
10
+ function mix(state, value) {
11
+ scratch[0] = value;
12
+ for (let b = 0; b < 8; b++) {
13
+ state.low = Math.imul(state.low ^ bytes[b], PRIME) >>> 0;
14
+ state.high = Math.imul(state.high ^ bytes[7 - b], PRIME) >>> 0;
15
+ }
16
+ }
17
+ function hex(low, high) {
18
+ return `${(low >>> 0).toString(16).padStart(8, '0')}${(high >>> 0).toString(16).padStart(8, '0')}`;
19
+ }
20
+ export function fingerprintChemistry(world) {
21
+ const parcels = world.parcels;
22
+ const state = { low: OFFSET, high: OFFSET };
23
+ /*
24
+ * The registry, folded in order, because a registry *is* ordered: a substance is an index and two
25
+ * libraries installed the other way round genuinely are two different worlds by that index.
26
+ */
27
+ const substances = parcels.substanceCount;
28
+ mix(state, substances);
29
+ for (let s = 0; s < substances; s++) {
30
+ mix(state, parcels.substanceDigestOf(s));
31
+ }
32
+ /* Each parcel's own digest, added rather than folded. See the header. */
33
+ let low = 0;
34
+ let high = 0;
35
+ for (let parcel = 0; parcel < parcels.count; parcel++) {
36
+ if (!parcels.alive(parcel))
37
+ continue;
38
+ const own = { low: OFFSET, high: OFFSET };
39
+ /* What it is made of, rather than which handle it got. */
40
+ mix(own, parcels.substanceOf(parcel));
41
+ mix(own, parcels.massOf(parcel));
42
+ mix(own, parcels.positionX(parcel));
43
+ mix(own, parcels.positionY(parcel));
44
+ mix(own, parcels.positionZ(parcel));
45
+ const shells = parcels.shellCount(parcel);
46
+ for (let shell = 0; shell < shells; shell++) {
47
+ mix(own, parcels.shellEnthalpyOf(parcel, shell));
48
+ mix(own, parcels.shellMassOf(parcel, shell));
49
+ }
50
+ /* Composition as element totals: a species set is a property of the substance, so hashing raw
51
+ slots would make two identical parcels of differently-ordered registries disagree. */
52
+ parcels.elementTotalsOf(parcel, elements);
53
+ for (let e = 0; e < ELEMENT_COUNT; e++)
54
+ mix(own, elements[e]);
55
+ low = (low + own.low) >>> 0;
56
+ high = (high + own.high) >>> 0;
57
+ }
58
+ mix(state, low);
59
+ mix(state, high);
60
+ /* The air, as the one quantity that summarises every cell: moles of each element it holds. */
61
+ const field = world.field;
62
+ if (field !== null) {
63
+ field.elementTotals(elements);
64
+ for (let e = 0; e < ELEMENT_COUNT; e++)
65
+ mix(state, elements[e]);
66
+ mix(state, field.chunkCount);
67
+ mix(state, field.totalEnthalpy());
68
+ }
69
+ return hex(state.low, state.high);
70
+ }
package/package.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "@driftengine/chemistry",
3
+ "version": "3.61.0",
4
+ "description": "Thermochemistry of bulk matter: elements, species, substances and what they turn into",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "drift-source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./library/organic": {
16
+ "drift-source": "./src/library/organic.ts",
17
+ "types": "./dist/library/organic.d.ts",
18
+ "node": "./dist/library/organic.js",
19
+ "default": "./src/library/organic.ts"
20
+ },
21
+ "./library/food": {
22
+ "drift-source": "./src/library/food.ts",
23
+ "types": "./dist/library/food.d.ts",
24
+ "node": "./dist/library/food.js",
25
+ "default": "./src/library/food.ts"
26
+ },
27
+ "./library/fuel": {
28
+ "drift-source": "./src/library/fuel.ts",
29
+ "types": "./dist/library/fuel.d.ts",
30
+ "node": "./dist/library/fuel.js",
31
+ "default": "./src/library/fuel.ts"
32
+ },
33
+ "./library/polymer": {
34
+ "drift-source": "./src/library/polymer.ts",
35
+ "types": "./dist/library/polymer.d.ts",
36
+ "node": "./dist/library/polymer.js",
37
+ "default": "./src/library/polymer.ts"
38
+ },
39
+ "./library/mineral": {
40
+ "drift-source": "./src/library/mineral.ts",
41
+ "types": "./dist/library/mineral.d.ts",
42
+ "node": "./dist/library/mineral.js",
43
+ "default": "./src/library/mineral.ts"
44
+ },
45
+ "./library/metal": {
46
+ "drift-source": "./src/library/metal.ts",
47
+ "types": "./dist/library/metal.d.ts",
48
+ "node": "./dist/library/metal.js",
49
+ "default": "./src/library/metal.ts"
50
+ },
51
+ "./library/biological": {
52
+ "drift-source": "./src/library/biological.ts",
53
+ "types": "./dist/library/biological.d.ts",
54
+ "node": "./dist/library/biological.js",
55
+ "default": "./src/library/biological.ts"
56
+ },
57
+ "./present": {
58
+ "drift-source": "./src/present/index.ts",
59
+ "types": "./dist/present/index.d.ts",
60
+ "node": "./dist/present/index.js",
61
+ "default": "./src/present/index.ts"
62
+ }
63
+ },
64
+ "files": [
65
+ "dist",
66
+ "src",
67
+ "!src/**/*.test.ts",
68
+ "!src/**/*.test.mjs",
69
+ "!src/**/__snapshots__",
70
+ "README.md",
71
+ "LICENSE",
72
+ "NOTICE"
73
+ ],
74
+ "sideEffects": false,
75
+ "author": "Drift Technologies",
76
+ "repository": {
77
+ "type": "git",
78
+ "url": "git+https://github.com/drftrun/driftengine.git",
79
+ "directory": "packages/chemistry"
80
+ },
81
+ "homepage": "https://github.com/drftrun/driftengine#readme",
82
+ "bugs": "https://github.com/drftrun/driftengine/issues",
83
+ "keywords": [
84
+ "driftengine",
85
+ "3d",
86
+ "webgl",
87
+ "webgpu",
88
+ "typescript",
89
+ "thermochemistry",
90
+ "simulation",
91
+ "combustion",
92
+ "enthalpy"
93
+ ],
94
+ "engines": {
95
+ "node": ">=22.12.0"
96
+ },
97
+ "publishConfig": {
98
+ "access": "public"
99
+ }
100
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Moles of each element held by a set of species masses.
3
+ *
4
+ * **This is the law `§6` of the design calls the most valuable test in the track**, and it is one
5
+ * reduction: kilograms of a species become moles through its derived molar mass, and moles of a
6
+ * species become moles of its elements through the species-by-element matrix.
7
+ *
8
+ * A chemistry model that does not conserve elements invents or destroys matter, and every symptom
9
+ * of that is subtle — fires that will not go out, mass that quietly grows, smoke that never clears.
10
+ * One assertion catches all of them, which is why this function exists before there is anything to
11
+ * conserve through.
12
+ *
13
+ * Writes into a caller-owned vector and allocates nothing, so it is affordable on a tick rather
14
+ * than only in a test.
15
+ */
16
+ import { ELEMENT_COUNT } from './element/elements.ts';
17
+ import type { SpeciesRegistry } from './species/registry.ts';
18
+
19
+ /**
20
+ * Reduce kilograms per species into moles per element.
21
+ *
22
+ * `out` is **overwritten**, not accumulated into, so a caller may hold one vector for the life of a
23
+ * world and re-derive totals every tick without clearing it first. `speciesMass` is indexed by
24
+ * species, so it is the registry's width; a species with no mass costs one comparison.
25
+ */
26
+ export function elementTotals(
27
+ registry: SpeciesRegistry,
28
+ speciesMass: Float64Array,
29
+ out: Float64Array,
30
+ ): void {
31
+ if (speciesMass.length !== registry.count) {
32
+ throw new Error(`a mass vector is ${registry.count} wide, not ${speciesMass.length}`);
33
+ }
34
+ if (out.length !== ELEMENT_COUNT) {
35
+ throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
36
+ }
37
+
38
+ out.fill(0);
39
+ for (let species = 0; species < speciesMass.length; species++) {
40
+ const kilograms = speciesMass[species] as number;
41
+ if (kilograms === 0) continue;
42
+ registry.elements.addScaledRow(species, kilograms / registry.molarMass(species), out);
43
+ }
44
+ }