@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,1152 @@
1
+ /**
2
+ * Parcels: lumps of matter with an identity, in structure-of-arrays typed columns.
3
+ *
4
+ * A parcel is a log, a steak, a puddle, a wall panel, a barrel of oil. It is **not** a mesh, a body
5
+ * or an entity — it is bound to one by a handle the consumer keeps, and it knows nothing about what
6
+ * it is bound to. That is what lets the package import no other engine package.
7
+ *
8
+ * **Enthalpy in joules, mass in kilograms, and temperature is neither.** The store holds a total
9
+ * and the curve holds a specific value, so two kilograms at one temperature hold twice the enthalpy
10
+ * of one and `temperatureOf` divides before it looks up. Every column is `Float64Array`, per `§15`:
11
+ * enthalpy differences across a burn span twelve orders of magnitude and `Float32Array` loses the
12
+ * small ones, which is not an optimisation to revisit later.
13
+ *
14
+ * **One lump, one temperature, until CH-2.** The design's parcel has a depth stack of shells, and
15
+ * without it a thick log heats through as fast as a twig — which is the single most important thing
16
+ * a fire model has to get right. That is CH-2's, and this store is shaped to grow into it: a shell
17
+ * is another stride on the enthalpy column and nothing above it changes.
18
+ *
19
+ * **A destroyed handle is never reused.** Physics numbers bodies the same way, and reuse without a
20
+ * generation lets a stale handle address a live parcel. Compaction is a budget question and belongs
21
+ * with the rest of them in CH-6; until then a destroyed parcel costs its columns and nothing else.
22
+ */
23
+ import { sensibleEnthalpy, temperatureFromCapacity } from '../species/thermal.js';
24
+ import { conductShells } from './conduction.js';
25
+ import { MAX_SUBSTANCE_SPECIES, reactShell } from '../reaction/solver.js';
26
+ import { ELEMENT_COUNT } from '../element/elements.js';
27
+ import { MAX_SHELLS, shellGeometry } from './shells.js';
28
+ import { TIER_CADENCE, TIER_HERO, TIER_SHELLS, TIER_SUBSTEPS, foldShare, foldSpan, } from './tier.js';
29
+ import { PHASE_GAS, PHASE_LIQUID, PHASE_SOLID } from '../species/species.js';
30
+ /** Parcels held before the first growth. Doubles from here. */
31
+ const INITIAL_CAPACITY = 64;
32
+ /* Scratch for a fold, module-level so a tier change on a hot path allocates nothing. */
33
+ const foldScratch = new Float64Array(MAX_SHELLS * MAX_SUBSTANCE_SPECIES);
34
+ const foldEnthalpy = new Float64Array(MAX_SHELLS);
35
+ /**
36
+ * A parcel made of more than one phase at once, which is most of them.
37
+ *
38
+ * Not a `Phase`, because it is not one: wet wood is a solid and a liquid together, and the design's
39
+ * `§20.3` gives it its own value rather than making a consumer guess which half was reported.
40
+ */
41
+ export const PHASE_MIXED = 3;
42
+ /** Share of a parcel's mass one phase must hold before that phase is the answer. */
43
+ const PHASE_DOMINANT = 0.95;
44
+ /** Share of a shell's mass that must be char before the shell counts as charred through. */
45
+ const CHAR_SHELL_FRACTION = 0.5;
46
+ const FLAG_BURNING = 1;
47
+ const FLAG_SMOULDERING = 2;
48
+ const FLAG_PILOT = 4;
49
+ const FLAG_SLEEPING = 8;
50
+ /**
51
+ * Ticks a parcel must be still for before it sleeps, and how still "still" is.
52
+ *
53
+ * `§14`: "a parcel whose net flux is below a threshold, whose composition changed by less than a
54
+ * threshold last tick, and which has been that way for `N` ticks, sleeps." The counter is what stops
55
+ * a parcel that is merely between two events from being put to sleep and woken every other tick,
56
+ * which would cost more than never sleeping it.
57
+ *
58
+ * The flux threshold is three orders of magnitude below the critical mass flux at piloted ignition,
59
+ * so nothing anywhere near catching is ever asleep.
60
+ */
61
+ const QUIET_TICKS = 30;
62
+ const QUIET_FLUX = 1e-6;
63
+ /** Joules per second of reaction output below which a parcel counts as chemically still. */
64
+ const QUIET_RELEASE = 1e-3;
65
+ export class ParcelStore {
66
+ substances;
67
+ species;
68
+ substance = new Int32Array(INITIAL_CAPACITY);
69
+ mass = new Float64Array(INITIAL_CAPACITY);
70
+ /** Strided by `MAX_SHELLS`: shell `s` of parcel `p` is at `p * MAX_SHELLS + s`, in joules. */
71
+ enthalpy = new Float64Array(INITIAL_CAPACITY * MAX_SHELLS);
72
+ area = new Float64Array(INITIAL_CAPACITY);
73
+ x = new Float64Array(INITIAL_CAPACITY);
74
+ y = new Float64Array(INITIAL_CAPACITY);
75
+ z = new Float64Array(INITIAL_CAPACITY);
76
+ live = new Uint8Array(INITIAL_CAPACITY);
77
+ /** `FLAG_BURNING | FLAG_SMOULDERING | FLAG_PILOT`. */
78
+ flags = new Uint8Array(INITIAL_CAPACITY);
79
+ /** kg/(m²·s) of gas leaving the surface, and of the combustible part of it. */
80
+ flux = new Float64Array(INITIAL_CAPACITY);
81
+ fuelFlux = new Float64Array(INITIAL_CAPACITY);
82
+ /**
83
+ * Watts the reactions put into this parcel over the last `react`, and it is a *readout*.
84
+ *
85
+ * **A fire's size is its heat release rate**, not its temperature — every fire-engineering figure
86
+ * in the design is quoted in kilowatts — and it cannot be recovered from outside, because
87
+ * conduction and a radiative source change the same enthalpy in the same tick. So it is measured
88
+ * where only reactions are running, by summing the thermal enthalpy either side of them. That sum
89
+ * is one pass over at most sixteen shells, which is nothing against the reaction step it brackets.
90
+ */
91
+ release = new Float64Array(INITIAL_CAPACITY);
92
+ /** Which of `§14`'s four tiers each parcel is being simulated at. */
93
+ tier = new Uint8Array(INITIAL_CAPACITY);
94
+ /** Shells actually resolved right now, which a tier may reduce below what the substance declared. */
95
+ shells = new Int32Array(INITIAL_CAPACITY);
96
+ /** Seconds owed to a parcel its cadence has been skipping, handed over when its turn comes. */
97
+ owed = new Float64Array(INITIAL_CAPACITY);
98
+ /** Consecutive ticks a parcel has been still. At `QUIET_TICKS` it sleeps. */
99
+ quiet = new Int32Array(INITIAL_CAPACITY);
100
+ /**
101
+ * Where each parcel's composition block starts, and the block itself.
102
+ *
103
+ * A block is `shells × the substance's own species count` kilograms — **dense over the few
104
+ * species that substance can hold**, not a row over all sixty-eight. Blocks are appended and
105
+ * never moved, which is what makes an offset stable; compaction is a budget question and belongs
106
+ * with the rest of them in CH-6.
107
+ */
108
+ compositionOffset = new Int32Array(INITIAL_CAPACITY);
109
+ composition = new Float64Array(INITIAL_CAPACITY * 8);
110
+ compositionUsed = 0;
111
+ used = 0;
112
+ constructor(substances, species = substances.species) {
113
+ this.substances = substances;
114
+ this.species = species;
115
+ }
116
+ /** Handles ever issued, live or not. A destroyed parcel still occupies its index. */
117
+ get count() {
118
+ return this.used;
119
+ }
120
+ spawn(spec) {
121
+ const { substance, mass, temperature } = spec;
122
+ if (!(mass > 0) || !Number.isFinite(mass)) {
123
+ throw new Error(`a parcel was spawned with a mass of ${mass}`);
124
+ }
125
+ const area = spec.area ?? 0;
126
+ if (area < 0 || !Number.isFinite(area)) {
127
+ throw new Error(`a parcel was spawned with a surface area of ${spec.area}`);
128
+ }
129
+ /* Resolves the substance, which refuses an index this registry never issued. */
130
+ const thermal = this.substances.reactionsOf(substance);
131
+ if (this.used === this.substance.length)
132
+ this.grow();
133
+ const parcel = this.used++;
134
+ this.substance[parcel] = substance;
135
+ this.mass[parcel] = mass;
136
+ const count = this.substances.conductionOf(substance).geometry.count;
137
+ const shellMass = mass / count;
138
+ /* The composition block: every shell starts as the substance says it is made. */
139
+ const set = this.substances.speciesSetOf(substance);
140
+ const block = count * set.length;
141
+ while (this.compositionUsed + block > this.composition.length) {
142
+ const grown = new Float64Array(this.composition.length * 2);
143
+ grown.set(this.composition);
144
+ this.composition = grown;
145
+ }
146
+ this.compositionOffset[parcel] = this.compositionUsed;
147
+ this.composition.fill(0, this.compositionUsed, this.compositionUsed + block);
148
+ const source = this.substances.definitionOf(substance).composition;
149
+ for (let i = 0; i < source.species.length; i++) {
150
+ const local = this.substances.localSlotOf(substance, source.species[i]);
151
+ const kilograms = source.fraction[i] * shellMass;
152
+ for (let shell = 0; shell < count; shell++) {
153
+ this.composition[this.compositionUsed + shell * set.length + local] = kilograms;
154
+ }
155
+ }
156
+ /* Enthalpy from what the shell actually holds, at the temperature asked for. Written after the
157
+ composition rather than before, because with CH-6's model it is a function of it. */
158
+ for (let shell = 0; shell < count; shell++) {
159
+ const base = this.compositionUsed + shell * set.length;
160
+ let joules = 0;
161
+ for (let i = 0; i < set.length; i++) {
162
+ const kilograms = this.composition[base + i];
163
+ if (kilograms === 0)
164
+ continue;
165
+ joules +=
166
+ kilograms *
167
+ sensibleEnthalpy(thermal.cpA[i], thermal.cpB[i], temperature);
168
+ }
169
+ this.enthalpy[parcel * MAX_SHELLS + shell] = joules;
170
+ }
171
+ this.compositionUsed += block;
172
+ this.area[parcel] = area;
173
+ this.x[parcel] = spec.x ?? 0;
174
+ this.y[parcel] = spec.y ?? 0;
175
+ this.z[parcel] = spec.z ?? 0;
176
+ this.live[parcel] = 1;
177
+ this.flags[parcel] = 0;
178
+ this.flux[parcel] = 0;
179
+ this.fuelFlux[parcel] = 0;
180
+ this.release[parcel] = 0;
181
+ this.tier[parcel] = TIER_HERO;
182
+ this.shells[parcel] = count;
183
+ this.owed[parcel] = 0;
184
+ this.quiet[parcel] = 0;
185
+ return parcel;
186
+ }
187
+ /** m², what the environment touches. Zero means the caller has not said, and conduction refuses. */
188
+ areaOf(parcel) {
189
+ return this.area[this.check(parcel)];
190
+ }
191
+ destroy(parcel) {
192
+ this.live[this.check(parcel)] = 0;
193
+ }
194
+ alive(parcel) {
195
+ if (!Number.isInteger(parcel) || parcel < 0 || parcel >= this.used)
196
+ return false;
197
+ return this.live[parcel] === 1;
198
+ }
199
+ substanceOf(parcel) {
200
+ return this.substance[this.check(parcel)];
201
+ }
202
+ massOf(parcel) {
203
+ return this.mass[this.check(parcel)];
204
+ }
205
+ /** m³, from mass and the substance's density. Shrinks as mass leaves, which is why a log burns down. */
206
+ volumeOf(parcel) {
207
+ const at = this.check(parcel);
208
+ return this.mass[at] / this.substances.densityOf(this.substance[at]);
209
+ }
210
+ /** How many shells this parcel's substance divides it into. */
211
+ /**
212
+ * Shells resolved right now, which a level-of-detail tier may have reduced.
213
+ *
214
+ * Everything that walks a parcel's depth reads this rather than the substance's declared count, so
215
+ * a `Distant` log is one shell everywhere at once and nothing has to be told which tier it is at.
216
+ */
217
+ shellCount(parcel) {
218
+ return this.shells[this.check(parcel)];
219
+ }
220
+ /** What the substance asked for, which is the resolution `Hero` restores to. */
221
+ declaredShellsOf(parcel) {
222
+ return this.substances.conductionOf(this.substance[this.check(parcel)]).geometry
223
+ .count;
224
+ }
225
+ tierOf(parcel) {
226
+ return this.tier[this.check(parcel)];
227
+ }
228
+ /** Ticks between updates at this parcel's tier. `§14`'s cadence column. */
229
+ cadenceOf(parcel) {
230
+ return TIER_CADENCE[this.tier[this.check(parcel)]];
231
+ }
232
+ /** The most sub-steps a reaction solve will take at this parcel's tier. */
233
+ substepsOf(parcel) {
234
+ return TIER_SUBSTEPS[this.tier[this.check(parcel)]];
235
+ }
236
+ /**
237
+ * Move a parcel to a tier, **conserving mass, every element and enthalpy exactly**.
238
+ *
239
+ * `§14`: "a tier change does not lose or gain mass or energy... which is the property that makes
240
+ * the LOD safe to apply to a burning object mid-burn." Shells are equal-mass by construction, so a
241
+ * fold is a proportional redistribution — target `j` takes source shells `[j·n/m, (j+1)·n/m)`,
242
+ * splitting one where the ratio is not an integer — and the targets come out equal-mass too.
243
+ *
244
+ * **What it loses is the profile inside a fold**, which is the point of a tier: two shells at
245
+ * 400 K and 300 K become one at their mass-weighted mean, and going back up hands each of them the
246
+ * same mean. Resolution in depth is what a distant object gives away.
247
+ */
248
+ setTier(parcel, tier) {
249
+ const at = this.check(parcel);
250
+ const declared = this.declaredShellsOf(at);
251
+ const wanted = Math.min(declared, TIER_SHELLS[tier]);
252
+ this.tier[at] = tier;
253
+ const current = this.shells[at];
254
+ if (wanted === current)
255
+ return;
256
+ this.refold(at, current, wanted);
257
+ this.shells[at] = wanted;
258
+ }
259
+ /**
260
+ * Redistribute a parcel's `from` shells into `to`, exactly.
261
+ *
262
+ * One pass into scratch and one pass back, because a fold reads every source before it has written
263
+ * any target and doing it in place would read a slot it had already overwritten.
264
+ */
265
+ refold(at, from, to) {
266
+ const substance = this.substance[at];
267
+ const width = this.substances.speciesSetOf(substance).length;
268
+ const start = this.compositionOffset[at];
269
+ for (let j = 0; j < to; j++) {
270
+ const span = foldSpan(from, to, j);
271
+ let joules = 0;
272
+ for (let s = 0; s < width; s++)
273
+ foldScratch[j * MAX_SUBSTANCE_SPECIES + s] = 0;
274
+ for (let i = 0; i < from; i++) {
275
+ const share = foldShare(i, span.start, span.end);
276
+ if (share === 0)
277
+ continue;
278
+ for (let s = 0; s < width; s++) {
279
+ foldScratch[j * MAX_SUBSTANCE_SPECIES + s] =
280
+ foldScratch[j * MAX_SUBSTANCE_SPECIES + s] +
281
+ this.composition[start + i * width + s] * share;
282
+ }
283
+ joules += this.enthalpy[at * MAX_SHELLS + i] * share;
284
+ }
285
+ foldEnthalpy[j] = joules;
286
+ }
287
+ for (let j = 0; j < to; j++) {
288
+ for (let s = 0; s < width; s++) {
289
+ this.composition[start + j * width + s] = foldScratch[j * MAX_SUBSTANCE_SPECIES + s];
290
+ }
291
+ this.enthalpy[at * MAX_SHELLS + j] = foldEnthalpy[j];
292
+ }
293
+ /* Beyond the new count nothing is read, and leaving stale values there would show up the day
294
+ somebody added a loop over `MAX_SHELLS` instead of over `shellCount`. */
295
+ for (let j = to; j < from; j++) {
296
+ for (let s = 0; s < width; s++)
297
+ this.composition[start + j * width + s] = 0;
298
+ this.enthalpy[at * MAX_SHELLS + j] = 0;
299
+ }
300
+ }
301
+ /** Total joules held across every shell, not joules per kilogram. */
302
+ enthalpyOf(parcel) {
303
+ const at = this.check(parcel);
304
+ const count = this.shellCount(at);
305
+ let total = 0;
306
+ for (let i = 0; i < count; i++)
307
+ total += this.enthalpy[at * MAX_SHELLS + i];
308
+ return total;
309
+ }
310
+ shellEnthalpyOf(parcel, shell) {
311
+ return this.enthalpy[this.check(parcel) * MAX_SHELLS + this.checkShell(parcel, shell)];
312
+ }
313
+ /**
314
+ * The temperature of one shell, K. Shell 0 is the surface and the last is the core.
315
+ *
316
+ * **`surfaceTemperatureOf` is what decides ignition and `coreTemperatureOf` is what decides
317
+ * doneness**, and the gap between them is the whole reason shells exist.
318
+ */
319
+ shellTemperatureOf(parcel, shell) {
320
+ const at = this.check(parcel);
321
+ const index = this.checkShell(at, shell);
322
+ const substance = this.substance[at];
323
+ const thermal = this.substances.reactionsOf(substance);
324
+ const width = this.substances.speciesSetOf(substance).length;
325
+ const base = this.compositionOffset[at] + index * width;
326
+ let A = 0;
327
+ let B = 0;
328
+ for (let i = 0; i < width; i++) {
329
+ const kilograms = this.composition[base + i];
330
+ if (kilograms === 0)
331
+ continue;
332
+ A += kilograms * thermal.cpA[i];
333
+ B += kilograms * thermal.cpB[i];
334
+ }
335
+ return temperatureFromCapacity(A, B, this.enthalpy[at * MAX_SHELLS + index]);
336
+ }
337
+ surfaceTemperatureOf(parcel) {
338
+ return this.shellTemperatureOf(parcel, 0);
339
+ }
340
+ coreTemperatureOf(parcel) {
341
+ return this.shellTemperatureOf(parcel, this.shellCount(parcel) - 1);
342
+ }
343
+ positionX(parcel) {
344
+ return this.x[this.check(parcel)];
345
+ }
346
+ positionY(parcel) {
347
+ return this.y[this.check(parcel)];
348
+ }
349
+ positionZ(parcel) {
350
+ return this.z[this.check(parcel)];
351
+ }
352
+ move(parcel, x, y, z) {
353
+ const at = this.check(parcel);
354
+ this.x[at] = x;
355
+ this.y[at] = y;
356
+ this.z[at] = z;
357
+ }
358
+ /**
359
+ * The temperature this parcel would have if it were stirred, K.
360
+ *
361
+ * A read rather than a stored field, and that is the design. Nothing writes a temperature except
362
+ * `setTemperature`, which writes the enthalpy that produces one — so a parcel on a plateau simply
363
+ * does not move, and no code anywhere had to know a plateau exists.
364
+ *
365
+ * **The temperature of the total enthalpy**, not the mean of the shell temperatures. The two
366
+ * differ wherever the curve bends, and only this one has the property that matters: heat in,
367
+ * temperature out, with nothing lost between them.
368
+ */
369
+ temperatureOf(parcel) {
370
+ const at = this.check(parcel);
371
+ const substance = this.substance[at];
372
+ const thermal = this.substances.reactionsOf(substance);
373
+ const width = this.substances.speciesSetOf(substance).length;
374
+ const count = this.shellCount(at);
375
+ const start = this.compositionOffset[at];
376
+ let A = 0;
377
+ let B = 0;
378
+ for (let shell = 0; shell < count; shell++) {
379
+ const base = start + shell * width;
380
+ for (let i = 0; i < width; i++) {
381
+ const kilograms = this.composition[base + i];
382
+ if (kilograms === 0)
383
+ continue;
384
+ A += kilograms * thermal.cpA[i];
385
+ B += kilograms * thermal.cpB[i];
386
+ }
387
+ }
388
+ return temperatureFromCapacity(A, B, this.enthalpyOf(at));
389
+ }
390
+ /** Joules in, spread evenly through the depth. Negative cools. A microwave, or a stirred pot. */
391
+ addHeat(parcel, joules) {
392
+ const at = this.check(parcel);
393
+ this.wake(at);
394
+ const count = this.shellCount(at);
395
+ const share = joules / count;
396
+ for (let i = 0; i < count; i++) {
397
+ this.enthalpy[at * MAX_SHELLS + i] = this.enthalpy[at * MAX_SHELLS + i] + share;
398
+ }
399
+ }
400
+ /**
401
+ * Joules into shell 0 alone, which is what a flux from outside actually does.
402
+ *
403
+ * Every radiative, convective and contact term in CH-5 lands here. The difference between this
404
+ * and `addHeat` is the difference between a log in front of a fire and a log in an oven.
405
+ */
406
+ addSurfaceHeat(parcel, joules) {
407
+ const at = this.check(parcel);
408
+ this.wake(at);
409
+ this.enthalpy[at * MAX_SHELLS] = this.enthalpy[at * MAX_SHELLS] + joules;
410
+ }
411
+ /**
412
+ * Conduct heat inward one step of `dt` seconds.
413
+ *
414
+ * Conserves this parcel's total enthalpy exactly — the arithmetic is in `conduction.ts` and so is
415
+ * the reason.
416
+ */
417
+ conduct(parcel, dt) {
418
+ const at = this.check(parcel);
419
+ const substance = this.substance[at];
420
+ /* The geometry for the shells this parcel is *currently* resolved to, which its tier decides. */
421
+ const model = this.substances.conductionAt(substance, this.shells[at]);
422
+ if (model.geometry.count < 2)
423
+ return;
424
+ if (model.conductivity === null) {
425
+ throw new Error(`substance "${this.substances.idOf(substance)}" has no conductivity, so it cannot conduct`);
426
+ }
427
+ const area = this.area[at];
428
+ if (!(area > 0)) {
429
+ throw new Error(`parcel ${parcel} has no surface area, and a characteristic depth is volume over area`);
430
+ }
431
+ const volume = this.mass[at] / this.substances.densityOf(substance);
432
+ const depth = (model.geometry.depthFactor * volume) / area;
433
+ conductShells(this.enthalpy, at * MAX_SHELLS, model, this.substances.reactionsOf(substance), this.composition, this.compositionOffset[at], this.mass[at] / model.geometry.count, area, depth, dt);
434
+ }
435
+ /**
436
+ * Force a temperature by writing the enthalpy that produces it.
437
+ *
438
+ * For authoring and for tests. **Not for a tick**: it discards whatever enthalpy the parcel held,
439
+ * so a boiling parcel set to its own boiling point loses however much of the plateau it had
440
+ * crossed, and the energy budget does not close across the call.
441
+ */
442
+ setTemperature(parcel, temperature) {
443
+ const at = this.check(parcel);
444
+ const count = this.shellCount(at);
445
+ for (let i = 0; i < count; i++)
446
+ this.setShellTemperature(at, i, temperature);
447
+ }
448
+ /** Force one shell's temperature. Authoring and tests only, for the reason above. */
449
+ setShellTemperature(parcel, shell, temperature) {
450
+ const at = this.check(parcel);
451
+ const index = this.checkShell(at, shell);
452
+ const substance = this.substance[at];
453
+ const thermal = this.substances.reactionsOf(substance);
454
+ const width = this.substances.speciesSetOf(substance).length;
455
+ const base = this.compositionOffset[at] + index * width;
456
+ let joules = 0;
457
+ for (let i = 0; i < width; i++) {
458
+ const kilograms = this.composition[base + i];
459
+ if (kilograms === 0)
460
+ continue;
461
+ joules +=
462
+ kilograms *
463
+ sensibleEnthalpy(thermal.cpA[i], thermal.cpB[i], temperature);
464
+ }
465
+ this.enthalpy[at * MAX_SHELLS + index] = joules;
466
+ }
467
+ /** Kilograms of one species in one shell. Zero where the substance cannot hold it at all. */
468
+ speciesMassOf(parcel, shell, species) {
469
+ const at = this.check(parcel);
470
+ const index = this.checkShell(at, shell);
471
+ const substance = this.substance[at];
472
+ const local = this.substances.localSlotOf(substance, species);
473
+ if (local < 0)
474
+ return 0;
475
+ const width = this.substances.speciesSetOf(substance).length;
476
+ return this.composition[this.compositionOffset[at] + index * width + local];
477
+ }
478
+ /** The same, summed over every shell. */
479
+ parcelSpeciesMass(parcel, species) {
480
+ const at = this.check(parcel);
481
+ const count = this.shellCount(at);
482
+ let total = 0;
483
+ for (let shell = 0; shell < count; shell++)
484
+ total += this.speciesMassOf(at, shell, species);
485
+ return total;
486
+ }
487
+ /** Kilograms in one shell, summed over its species. Invariant under reaction. */
488
+ shellMassOf(parcel, shell) {
489
+ const at = this.check(parcel);
490
+ const index = this.checkShell(at, shell);
491
+ const substance = this.substance[at];
492
+ const width = this.substances.speciesSetOf(substance).length;
493
+ const start = this.compositionOffset[at] + index * width;
494
+ let total = 0;
495
+ for (let i = 0; i < width; i++)
496
+ total += this.composition[start + i];
497
+ return total;
498
+ }
499
+ /** Moles of each element this parcel holds. Writes into `out`, allocates nothing. */
500
+ elementTotalsOf(parcel, out) {
501
+ if (out.length !== ELEMENT_COUNT) {
502
+ throw new Error(`a totals vector is ${ELEMENT_COUNT} wide, not ${out.length}`);
503
+ }
504
+ const at = this.check(parcel);
505
+ const substance = this.substance[at];
506
+ const set = this.substances.speciesSetOf(substance);
507
+ const model = this.substances.reactionsOf(substance);
508
+ const count = this.shellCount(at);
509
+ const start = this.compositionOffset[at];
510
+ out.fill(0);
511
+ for (let shell = 0; shell < count; shell++) {
512
+ for (let i = 0; i < set.length; i++) {
513
+ const kilograms = this.composition[start + shell * set.length + i];
514
+ if (kilograms === 0)
515
+ continue;
516
+ this.species.elements.addScaledRow(set[i], kilograms / model.molarMass[i], out);
517
+ }
518
+ }
519
+ }
520
+ /**
521
+ * Energy locked in this parcel's chemical bonds, J.
522
+ *
523
+ * Neither this nor the thermal enthalpy is conserved on its own — a reaction moves energy between
524
+ * them. **Their sum is**, exactly, because the same number is added to one and taken from the
525
+ * other in `reactShell`.
526
+ */
527
+ chemicalEnergyOf(parcel) {
528
+ const at = this.check(parcel);
529
+ const substance = this.substance[at];
530
+ const set = this.substances.speciesSetOf(substance);
531
+ const model = this.substances.reactionsOf(substance);
532
+ const count = this.shellCount(at);
533
+ const start = this.compositionOffset[at];
534
+ let total = 0;
535
+ for (let shell = 0; shell < count; shell++) {
536
+ for (let i = 0; i < set.length; i++) {
537
+ const kilograms = this.composition[start + shell * set.length + i];
538
+ if (kilograms === 0)
539
+ continue;
540
+ total +=
541
+ (kilograms / model.molarMass[i]) *
542
+ this.species.formationEnthalpyOf(set[i]);
543
+ }
544
+ }
545
+ return total;
546
+ }
547
+ /** Run this parcel's reactions in every shell for `dt` seconds. */
548
+ react(parcel, dt, transportFactor = 1) {
549
+ const at = this.check(parcel);
550
+ const substance = this.substance[at];
551
+ const model = this.substances.reactionsOf(substance);
552
+ if (model.count === 0) {
553
+ this.release[at] = 0;
554
+ return;
555
+ }
556
+ const count = this.shellCount(at);
557
+ /* Bracketing the reaction step is the only place heat release can honestly be read: outside it,
558
+ conduction and a radiative source have moved the same joules in the same tick. */
559
+ const before = this.thermalEnthalpy(at, count);
560
+ const width = this.substances.speciesSetOf(substance).length;
561
+ const start = this.compositionOffset[at];
562
+ const shellMass = this.mass[at] / count;
563
+ for (let shell = 0; shell < count; shell++) {
564
+ reactShell(this.composition, start + shell * width, this.enthalpy, at * MAX_SHELLS + shell, model, shellMass, dt, transportFactor);
565
+ }
566
+ this.release[at] = dt > 0 ? (this.thermalEnthalpy(at, count) - before) / dt : 0;
567
+ }
568
+ /** Joules of sensible heat across a parcel's shells. One pass over at most sixteen doubles. */
569
+ thermalEnthalpy(at, count) {
570
+ let total = 0;
571
+ for (let shell = 0; shell < count; shell++) {
572
+ total += this.enthalpy[at * MAX_SHELLS + shell];
573
+ }
574
+ return total;
575
+ }
576
+ /**
577
+ * Whether this parcel is being skipped entirely.
578
+ *
579
+ * `§14`'s thermal quiescence, and the vocabulary is deliberately Track B's. A cold stone floor is a
580
+ * thousand of these and costs a bounded scan.
581
+ */
582
+ sleeping(parcel) {
583
+ return (this.flags[this.check(parcel)] & FLAG_SLEEPING) !== 0;
584
+ }
585
+ /**
586
+ * Wake it, and reset the count that would put it back.
587
+ *
588
+ * **Called by everything that could change what a parcel is**: heat arriving, water poured on,
589
+ * mass added, a contact, or a source coming within range. Cheap enough — a flag and an integer —
590
+ * that a caller never has to decide whether it is worth it.
591
+ */
592
+ wake(parcel) {
593
+ const at = this.check(parcel);
594
+ this.flags[at] = this.flags[at] & ~FLAG_SLEEPING;
595
+ this.quiet[at] = 0;
596
+ }
597
+ /**
598
+ * Decide whether this parcel is still enough to sleep, and put it to sleep if it has been for long
599
+ * enough. Returns whether it is now asleep.
600
+ *
601
+ * **A burning or smouldering parcel never sleeps, whatever its numbers say.** A steady flame is a
602
+ * *steady* parcel by every measure here, and sleeping one would put out a fire by optimising it.
603
+ */
604
+ settle(parcel) {
605
+ const at = this.check(parcel);
606
+ if ((this.flags[at] & (FLAG_BURNING | FLAG_SMOULDERING)) !== 0) {
607
+ this.quiet[at] = 0;
608
+ return false;
609
+ }
610
+ const still = this.flux[at] < QUIET_FLUX &&
611
+ Math.abs(this.release[at]) < QUIET_RELEASE;
612
+ if (!still) {
613
+ this.quiet[at] = 0;
614
+ this.flags[at] = this.flags[at] & ~FLAG_SLEEPING;
615
+ return false;
616
+ }
617
+ const count = this.quiet[at] + 1;
618
+ this.quiet[at] = count;
619
+ if (count < QUIET_TICKS)
620
+ return false;
621
+ this.flags[at] = this.flags[at] | FLAG_SLEEPING;
622
+ return true;
623
+ }
624
+ /**
625
+ * Seconds this parcel is owed by its cadence, and whether its turn has come.
626
+ *
627
+ * A `Far` parcel is stepped every fourth tick with four ticks of `dt`, so the same seconds are
628
+ * integrated either way — which is what makes a cadence conserving for free rather than by care.
629
+ */
630
+ accrue(parcel, dt) {
631
+ const at = this.check(parcel);
632
+ const owed = this.owed[at] + dt;
633
+ const cadence = TIER_CADENCE[this.tier[at]];
634
+ if (cadence <= 1 || owed >= dt * cadence * 0.999999) {
635
+ this.owed[at] = 0;
636
+ return owed;
637
+ }
638
+ this.owed[at] = owed;
639
+ return 0;
640
+ }
641
+ /** Whether a flame stands over this parcel. */
642
+ burning(parcel) {
643
+ return (this.flags[this.check(parcel)] & FLAG_BURNING) !== 0;
644
+ }
645
+ /** Whether its char is glowing, which needs no flame and far less oxygen. */
646
+ smouldering(parcel) {
647
+ return (this.flags[this.check(parcel)] & FLAG_SMOULDERING) !== 0;
648
+ }
649
+ /** Whether a pilot is present this tick. */
650
+ piloted(parcel) {
651
+ return (this.flags[this.check(parcel)] & FLAG_PILOT) !== 0;
652
+ }
653
+ /**
654
+ * Supply a pilot for one tick. **This does not start a fire**, and `§11` is why.
655
+ *
656
+ * Ignition is five conditions, and a pilot is one of them. Holding a match to wet wood does
657
+ * exactly nothing here, which is correct and is the API telling the truth about what a match is.
658
+ */
659
+ ignite(parcel) {
660
+ this.wake(parcel);
661
+ this.flags[this.check(parcel)] = this.flags[parcel] | FLAG_PILOT;
662
+ }
663
+ /** Put the flame out. Embers survive, which is the point — see `§12`. */
664
+ douse(parcel) {
665
+ this.flags[this.check(parcel)] = this.flags[parcel] & ~FLAG_BURNING;
666
+ }
667
+ setBurning(parcel, burning) {
668
+ const at = this.check(parcel);
669
+ this.flags[at] = burning
670
+ ? this.flags[at] | FLAG_BURNING
671
+ : this.flags[at] & ~FLAG_BURNING;
672
+ }
673
+ setSmouldering(parcel, smouldering) {
674
+ const at = this.check(parcel);
675
+ this.flags[at] = smouldering
676
+ ? this.flags[at] | FLAG_SMOULDERING
677
+ : this.flags[at] & ~FLAG_SMOULDERING;
678
+ }
679
+ clearPilot(parcel) {
680
+ this.flags[this.check(parcel)] = this.flags[parcel] & ~FLAG_PILOT;
681
+ }
682
+ /** kg/(m²·s) of gas leaving the surface, all of it. */
683
+ massFluxOf(parcel) {
684
+ return this.flux[this.check(parcel)];
685
+ }
686
+ /**
687
+ * And of the part of it that can burn — which is the criterion ignition turns on.
688
+ *
689
+ * The two differ by exactly what a wet log gives off. Steam leaving a surface counts toward
690
+ * diluting the mixture above it and not toward feeding it, which is why 60% moisture stops a log
691
+ * catching in front of a fire that lights a seasoned one in eighty seconds.
692
+ */
693
+ fuelFluxOf(parcel) {
694
+ return this.fuelFlux[this.check(parcel)];
695
+ }
696
+ setMassFlux(parcel, total, fuel) {
697
+ const at = this.check(parcel);
698
+ this.flux[at] = total > 0 ? total : 0;
699
+ this.fuelFlux[at] = fuel > 0 ? fuel : 0;
700
+ }
701
+ /**
702
+ * Watts the reactions released last step: positive exothermic, negative endothermic.
703
+ *
704
+ * **A fire's size is its heat release rate**, which is what every figure in the design is quoted
705
+ * against and is not the temperature. A pyrolysing surface reads *negative* here while it is
706
+ * gasifying, which is right and is why a flame has to keep feeding heat back to sustain one.
707
+ */
708
+ heatReleaseOf(parcel) {
709
+ return this.release[this.check(parcel)];
710
+ }
711
+ /**
712
+ * Kilograms of water per kilogram of dry matter — **the dry basis, which is how it is quoted**.
713
+ *
714
+ * "12% moisture content" means twelve kilograms of water per hundred of dry wood, and
715
+ * `wetComposition` folded that into a wet-basis 0.107 to store it. Undoing the same conversion
716
+ * here is what makes a script comparing against the 0.25 that decides whether a log is worth
717
+ * burning read the quantity the author wrote.
718
+ *
719
+ * Ice counts as water. A frozen log is not a dry one.
720
+ */
721
+ moistureOf(parcel) {
722
+ const at = this.check(parcel);
723
+ const liquid = this.species.indexOf('H2O(l)');
724
+ const solid = this.species.indexOf('H2O(s)');
725
+ let water = 0;
726
+ if (liquid >= 0)
727
+ water += this.parcelSpeciesMass(at, liquid);
728
+ if (solid >= 0)
729
+ water += this.parcelSpeciesMass(at, solid);
730
+ const dry = this.mass[at] - water;
731
+ return dry > 0 ? water / dry : 0;
732
+ }
733
+ /**
734
+ * `PHASE_SOLID`, `PHASE_LIQUID`, `PHASE_GAS`, or `PHASE_MIXED` where no one of them dominates.
735
+ *
736
+ * Mixed is a real answer rather than a failure to decide: wet wood is wood and water at once, and
737
+ * calling it either would be a lie a consumer would then draw.
738
+ */
739
+ parcelPhaseOf(parcel) {
740
+ const at = this.check(parcel);
741
+ const substance = this.substance[at];
742
+ const set = this.substances.speciesSetOf(substance);
743
+ const count = this.shellCount(at);
744
+ const start = this.compositionOffset[at];
745
+ let total = 0;
746
+ let solid = 0;
747
+ let liquid = 0;
748
+ let gas = 0;
749
+ for (let shell = 0; shell < count; shell++) {
750
+ for (let i = 0; i < set.length; i++) {
751
+ const kilograms = this.composition[start + shell * set.length + i];
752
+ if (kilograms === 0)
753
+ continue;
754
+ total += kilograms;
755
+ const phase = this.species.phaseOf(set[i]);
756
+ if (phase === PHASE_SOLID)
757
+ solid += kilograms;
758
+ else if (phase === PHASE_LIQUID)
759
+ liquid += kilograms;
760
+ else
761
+ gas += kilograms;
762
+ }
763
+ }
764
+ if (!(total > 0))
765
+ return PHASE_MIXED;
766
+ const dominant = PHASE_DOMINANT * total;
767
+ if (solid >= dominant)
768
+ return PHASE_SOLID;
769
+ if (liquid >= dominant)
770
+ return PHASE_LIQUID;
771
+ if (gas >= dominant)
772
+ return PHASE_GAS;
773
+ return PHASE_MIXED;
774
+ }
775
+ /**
776
+ * Kilograms of **free** water on the surface, per square metre of it.
777
+ *
778
+ * **Free, meaning beyond what the material holds as its own moisture**, and that distinction is
779
+ * the whole reading. A seasoned oak log carries 12% water on a dry basis and is not glossy; a log
780
+ * somebody threw a bucket over is. Reporting the shell's total water would make the two identical
781
+ * and every piece of wood in a scene look rained on.
782
+ *
783
+ * **Derived rather than a column**, because `wet` puts real water in the surface shell rather than
784
+ * setting a flag — so `§17`'s wet-film roughness reads a quantity the boiling reaction is
785
+ * simultaneously consuming, which is what makes drying visible.
786
+ */
787
+ wetnessOf(parcel) {
788
+ const at = this.check(parcel);
789
+ const area = this.area[at];
790
+ if (!(area > 0))
791
+ return 0;
792
+ const water = this.species.indexOf('H2O(l)');
793
+ if (water < 0)
794
+ return 0;
795
+ /*
796
+ * Measured **on a dry basis against the shell's own dry matter**, which is the one formulation
797
+ * that is stable under pouring water on it. Against a fraction of the shell's *total* mass, the
798
+ * baseline would grow with every drop added and a bucket would read as a third of itself.
799
+ */
800
+ const substance = this.substance[at];
801
+ const composition = this.substances.definitionOf(substance).composition;
802
+ let wetBasis = 0;
803
+ for (let i = 0; i < composition.species.length; i++) {
804
+ if (composition.species[i] === water)
805
+ wetBasis = composition.fraction[i];
806
+ }
807
+ if (!(wetBasis < 1))
808
+ return 0;
809
+ const held = this.speciesMassOf(at, 0, water);
810
+ const dry = this.shellMassOf(at, 0) - held;
811
+ const authored = dry * (wetBasis / (1 - wetBasis));
812
+ const free = held - authored;
813
+ return free > 0 ? free / area : 0;
814
+ }
815
+ /**
816
+ * Pour water on it: liquid water into the surface shell, where the boiling reaction will find it.
817
+ *
818
+ * **Not a flag, and that is the whole point.** The heat sink `§12` describes is the same
819
+ * 2.44 MJ/kg every other drop of water in this model carries, so a doused fire goes out for the
820
+ * reason a real one does and a light sprinkling on a hot ember does not.
821
+ *
822
+ * Refused, naming the substance, where that substance cannot hold water at all — which means it
823
+ * declares no reaction that touches it. Silently doing nothing would make "I poured a bucket on
824
+ * it" and "this material has no water in its model" indistinguishable.
825
+ */
826
+ wet(parcel, kilograms) {
827
+ const at = this.check(parcel);
828
+ this.wake(at);
829
+ if (!(kilograms > 0))
830
+ return;
831
+ const water = this.species.indexOf('H2O(l)');
832
+ const substance = this.substance[at];
833
+ if (water < 0 || this.substances.localSlotOf(substance, water) < 0) {
834
+ throw new Error(`parcel ${parcel} is ${this.substances.idOf(substance)}, which holds no liquid water — ` +
835
+ 'declare a reaction that touches `H2O(l)`, such as `boil-water`, to make it wettable');
836
+ }
837
+ this.addSpeciesMass(at, 0, water, kilograms);
838
+ this.mass[at] = this.mass[at] + kilograms;
839
+ }
840
+ /** Take surface water away: a cloth, a hot dry pan, or wind. Never below what is there. */
841
+ dry(parcel, kilograms) {
842
+ const at = this.check(parcel);
843
+ if (!(kilograms > 0))
844
+ return;
845
+ const water = this.species.indexOf('H2O(l)');
846
+ if (water < 0 || this.substances.localSlotOf(this.substance[at], water) < 0)
847
+ return;
848
+ const held = this.speciesMassOf(at, 0, water);
849
+ const taken = kilograms < held ? kilograms : held;
850
+ this.addSpeciesMass(at, 0, water, -taken);
851
+ this.mass[at] = this.mass[at] - taken;
852
+ }
853
+ /**
854
+ * Metres of char measured inward from the surface.
855
+ *
856
+ * The shells that are mostly carbon, summed by their own thickness. A shell counts once it is past
857
+ * half char, which is a threshold on a continuum and is said out loud: a finer answer needs a char
858
+ * *front* within a shell, which is a sub-shell quantity this model does not carry.
859
+ */
860
+ charDepthOf(parcel) {
861
+ const at = this.check(parcel);
862
+ const char = this.species.indexOf('C(char)');
863
+ if (char < 0)
864
+ return 0;
865
+ const count = this.shellCount(at);
866
+ let depth = 0;
867
+ for (let shell = 0; shell < count; shell++) {
868
+ const mass = this.shellMassOf(at, shell);
869
+ if (!(mass > 0))
870
+ continue;
871
+ if (this.speciesMassOf(at, shell, char) / mass < CHAR_SHELL_FRACTION)
872
+ break;
873
+ depth += this.shellDepthOf(at, shell);
874
+ }
875
+ return depth;
876
+ }
877
+ /**
878
+ * How much of this parcel is still load-bearing, 1 down to 0.
879
+ *
880
+ * **`§18` refuses structural failure in writing and this is the refusal reporting.** A beam whose
881
+ * char depth has eaten a fraction of its thickness has lost that fraction of its strength, and
882
+ * what happens next — a joint releasing, a body splitting, a building coming down — belongs to
883
+ * whoever owns the solver. This package writes the scalar, emits an event at a threshold, and
884
+ * breaks nothing.
885
+ */
886
+ structuralIntegrityOf(parcel) {
887
+ const at = this.check(parcel);
888
+ const count = this.shellCount(at);
889
+ let thickness = 0;
890
+ for (let shell = 0; shell < count; shell++)
891
+ thickness += this.shellDepthOf(at, shell);
892
+ if (!(thickness > 0))
893
+ return 1;
894
+ const left = 1 - this.charDepthOf(at) / thickness;
895
+ return left > 0 ? (left < 1 ? left : 1) : 0;
896
+ }
897
+ /**
898
+ * Pour `from` into `into`; `from` is consumed. Mass, elements and enthalpy all conserved exactly.
899
+ *
900
+ * **Both must be the same substance, and two that are not are refused naming both.** Two
901
+ * substances are two species sets and two shell counts, so mixing them would silently discard
902
+ * whichever set was narrower — and a stew is not a substance change. What a consumer combining two
903
+ * genuinely different materials wants is a third substance that is the mixture, authored as one.
904
+ */
905
+ mix(from, into) {
906
+ const a = this.check(from);
907
+ const b = this.check(into);
908
+ if (a === b)
909
+ return;
910
+ const substance = this.substance[a];
911
+ if (substance !== this.substance[b]) {
912
+ throw new Error(`cannot mix ${this.substances.idOf(substance)} into ` +
913
+ `${this.substances.idOf(this.substance[b])}: two substances are two species ` +
914
+ 'sets, and a mixture of them is a third substance rather than either');
915
+ }
916
+ const set = this.substances.speciesSetOf(substance);
917
+ const countA = this.shellCount(a);
918
+ const countB = this.shellCount(b);
919
+ const startA = this.compositionOffset[a];
920
+ const startB = this.compositionOffset[b];
921
+ /* Spread over the receiver's shells in proportion to what each already holds, so a hot core
922
+ stays hotter than a cold surface rather than being averaged flat. */
923
+ const massB = this.mass[b];
924
+ for (let shell = 0; shell < countB; shell++) {
925
+ const share = massB > 0 ? this.shellMassOf(b, shell) / massB : 1 / countB;
926
+ for (let i = 0; i < set.length; i++) {
927
+ let incoming = 0;
928
+ for (let s = 0; s < countA; s++)
929
+ incoming += this.composition[startA + s * set.length + i];
930
+ this.composition[startB + shell * set.length + i] =
931
+ this.composition[startB + shell * set.length + i] + incoming * share;
932
+ }
933
+ let joules = 0;
934
+ for (let s = 0; s < countA; s++)
935
+ joules += this.enthalpy[a * MAX_SHELLS + s];
936
+ this.enthalpy[b * MAX_SHELLS + shell] =
937
+ this.enthalpy[b * MAX_SHELLS + shell] + joules * share;
938
+ }
939
+ this.mass[b] = massB + this.mass[a];
940
+ this.destroy(a);
941
+ }
942
+ /**
943
+ * Joules into one named shell.
944
+ *
945
+ * The difference between this and `addSurfaceHeat` is a microwave and an oven: one deposits where
946
+ * the field penetrates to, the other at the surface. Nothing here decides which is physical.
947
+ */
948
+ addShellHeat(parcel, shell, joules) {
949
+ const at = this.check(parcel);
950
+ this.wake(at);
951
+ const index = this.checkShell(at, shell);
952
+ this.enthalpy[at * MAX_SHELLS + index] =
953
+ this.enthalpy[at * MAX_SHELLS + index] + joules;
954
+ }
955
+ /**
956
+ * What fraction of its original volume is left, as mass leaves it.
957
+ *
958
+ * **The number a shrinking log is drawn from.** A parcel's volume is its mass over its substance's
959
+ * density, and the density does not change — so this is simply what share of the mass is still
960
+ * here, which falls as volatiles leave and rises for nothing.
961
+ */
962
+ volumeShareOf(parcel) {
963
+ const at = this.check(parcel);
964
+ let held = 0;
965
+ const count = this.shellCount(at);
966
+ for (let shell = 0; shell < count; shell++)
967
+ held += this.shellMassOf(at, shell);
968
+ const original = this.mass[at];
969
+ return original > 0 ? held / original : 0;
970
+ }
971
+ /**
972
+ * Joules per kelvin of the surface shell, from what it is made of right now.
973
+ *
974
+ * **What every explicit surface term has to be bounded against.** A flux times an area times a
975
+ * step is joules, and joules into a small enough shell is an arbitrarily large temperature change
976
+ * — so a term that does not know this number cannot tell the difference between heating something
977
+ * and destroying it. `ChemistryWorld` uses it to stop a radiative or convective step carrying a
978
+ * surface past the thing driving it.
979
+ */
980
+ surfaceHeatCapacityOf(parcel) {
981
+ const at = this.check(parcel);
982
+ const substance = this.substance[at];
983
+ const set = this.substances.speciesSetOf(substance);
984
+ const model = this.substances.reactionsOf(substance);
985
+ const start = this.compositionOffset[at];
986
+ const temperature = this.shellTemperatureOf(at, 0);
987
+ let capacity = 0;
988
+ for (let i = 0; i < set.length; i++) {
989
+ const kilograms = this.composition[start + i];
990
+ if (kilograms === 0)
991
+ continue;
992
+ capacity +=
993
+ kilograms * (model.cpA[i] + model.cpB[i] * (temperature - 273.15));
994
+ }
995
+ return capacity;
996
+ }
997
+ /** What this parcel's substance looks like, or `null` where nobody said. */
998
+ appearanceOf(parcel) {
999
+ return this.substances.appearanceOf(this.substance[this.check(parcel)]);
1000
+ }
1001
+ /** Share of this parcel's mass that is char, 0..1. */
1002
+ charFractionOf(parcel) {
1003
+ const at = this.check(parcel);
1004
+ const char = this.species.indexOf('C(char)');
1005
+ if (char < 0)
1006
+ return 0;
1007
+ const mass = this.mass[at];
1008
+ return mass > 0 ? this.parcelSpeciesMass(at, char) / mass : 0;
1009
+ }
1010
+ /** Add (or with a negative value, remove) mass of one species in one shell. */
1011
+ addSpeciesMass(parcel, shell, species, kilograms) {
1012
+ const at = this.check(parcel);
1013
+ this.wake(at);
1014
+ const index = this.checkShell(at, shell);
1015
+ const substance = this.substance[at];
1016
+ const local = this.substances.localSlotOf(substance, species);
1017
+ if (local < 0) {
1018
+ throw new Error(`parcel ${parcel} is ${this.substances.idOf(substance)}, which cannot hold ` +
1019
+ `"${this.species.idOf(species)}"`);
1020
+ }
1021
+ const width = this.substances.speciesSetOf(substance).length;
1022
+ const slot = this.compositionOffset[at] + index * width + local;
1023
+ const next = this.composition[slot] + kilograms;
1024
+ this.composition[slot] = next > 0 ? next : 0;
1025
+ }
1026
+ /** How many substances this store's registry holds. `fingerprintChemistry` covers all of them. */
1027
+ get substanceCount() {
1028
+ return this.substances.count;
1029
+ }
1030
+ /**
1031
+ * A number standing for what a registered substance *is*, for the fingerprint.
1032
+ *
1033
+ * Its density, porosity, emissivity, shell count and the species it can hold — enough that two
1034
+ * libraries differing anywhere disagree here, and cheap enough to compute once a tick. `§15` asks
1035
+ * for the registry to be covered because two worlds with different substances are different
1036
+ * worlds, and a replay crossing them should say so rather than diverge later.
1037
+ */
1038
+ substanceDigestOf(substance) {
1039
+ const set = this.substances.speciesSetOf(substance);
1040
+ let digest = this.substances.densityOf(substance) +
1041
+ this.substances.porosityOf(substance) * 7 +
1042
+ this.substances.emissivityOf(substance) * 13 +
1043
+ set.length * 31;
1044
+ for (let i = 0; i < set.length; i++)
1045
+ digest += set[i] * (i + 1);
1046
+ return digest;
1047
+ }
1048
+ /** Global species indices this parcel's substance can hold. */
1049
+ speciesSetOf(substance) {
1050
+ return this.substances.speciesSetOf(substance);
1051
+ }
1052
+ idOfSpecies(species) {
1053
+ return this.species.idOf(species);
1054
+ }
1055
+ phaseOf(species) {
1056
+ return this.species.phaseOf(species);
1057
+ }
1058
+ emissivityOf(parcel) {
1059
+ return this.substances.emissivityOf(this.substance[this.check(parcel)]);
1060
+ }
1061
+ /** When this parcel's substance catches, or `null` where nobody said it does. */
1062
+ ignitionOf(substance) {
1063
+ return this.substances.ignitionOf(substance);
1064
+ }
1065
+ porosityOf(parcel) {
1066
+ return this.substances.porosityOf(this.substance[this.check(parcel)]);
1067
+ }
1068
+ /** W/(m·K) at a temperature, or zero where the substance never said. */
1069
+ conductivityOf(parcel, temperature) {
1070
+ const model = this.substances.conductionOf(this.substance[this.check(parcel)]);
1071
+ const k = model.conductivity;
1072
+ if (k === null)
1073
+ return 0;
1074
+ const value = k.k0 + k.k1 * (temperature - 298.15);
1075
+ return value > 0 ? value : 0;
1076
+ }
1077
+ /** Metres from the outside of shell `shell` to the inside of it. */
1078
+ shellDepthOf(parcel, shell) {
1079
+ const at = this.check(parcel);
1080
+ const index = this.checkShell(at, shell);
1081
+ const substance = this.substance[at];
1082
+ /* At the shells this parcel is currently resolved to: a `Distant` log is one thick shell, and a
1083
+ depth read against the declared four would be a quarter of the real thing. */
1084
+ const model = this.substances.conductionAt(substance, this.shells[at]);
1085
+ const area = this.area[at];
1086
+ if (!(area > 0))
1087
+ return 0;
1088
+ const volume = this.mass[at] / this.substances.densityOf(substance);
1089
+ const depth = (model.geometry.depthFactor * volume) / area;
1090
+ return depth * model.geometry.thicknessFraction[index];
1091
+ }
1092
+ checkShell(parcel, shell) {
1093
+ const count = this.shellCount(parcel);
1094
+ if (!Number.isInteger(shell) || shell < 0 || shell >= count) {
1095
+ throw new Error(`shell ${shell} is outside a parcel of ${count}`);
1096
+ }
1097
+ return shell;
1098
+ }
1099
+ grow() {
1100
+ const size = this.substance.length * 2;
1101
+ const substance = new Int32Array(size);
1102
+ substance.set(this.substance);
1103
+ this.substance = substance;
1104
+ /* Explicitly `Float64Array<ArrayBuffer>` rather than inferred: TypeScript 7 parameterises a
1105
+ typed array by its buffer, and an unannotated parameter widens to `ArrayBufferLike` — which
1106
+ includes `SharedArrayBuffer` and so will not assign back to the fields. */
1107
+ const grow64 = (from) => {
1108
+ const to = new Float64Array(size);
1109
+ to.set(from);
1110
+ return to;
1111
+ };
1112
+ this.mass = grow64(this.mass);
1113
+ this.area = grow64(this.area);
1114
+ const enthalpy = new Float64Array(size * MAX_SHELLS);
1115
+ enthalpy.set(this.enthalpy);
1116
+ this.enthalpy = enthalpy;
1117
+ this.x = grow64(this.x);
1118
+ this.y = grow64(this.y);
1119
+ this.z = grow64(this.z);
1120
+ const live = new Uint8Array(size);
1121
+ live.set(this.live);
1122
+ this.live = live;
1123
+ const flags = new Uint8Array(size);
1124
+ flags.set(this.flags);
1125
+ this.flags = flags;
1126
+ this.flux = grow64(this.flux);
1127
+ this.fuelFlux = grow64(this.fuelFlux);
1128
+ this.release = grow64(this.release);
1129
+ this.owed = grow64(this.owed);
1130
+ const tier = new Uint8Array(size);
1131
+ tier.set(this.tier);
1132
+ this.tier = tier;
1133
+ const shells = new Int32Array(size);
1134
+ shells.set(this.shells);
1135
+ this.shells = shells;
1136
+ const quiet = new Int32Array(size);
1137
+ quiet.set(this.quiet);
1138
+ this.quiet = quiet;
1139
+ const offsets = new Int32Array(size);
1140
+ offsets.set(this.compositionOffset);
1141
+ this.compositionOffset = offsets;
1142
+ }
1143
+ check(parcel) {
1144
+ if (!Number.isInteger(parcel) || parcel < 0 || parcel >= this.used) {
1145
+ throw new Error(`parcel ${parcel} is outside a store of ${this.used}`);
1146
+ }
1147
+ if (this.live[parcel] !== 1) {
1148
+ throw new Error(`parcel ${parcel} was destroyed`);
1149
+ }
1150
+ return parcel;
1151
+ }
1152
+ }