@jgengine/core 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/dist/combat/projectiles.d.ts +6 -2
- package/dist/combat/projectiles.js +55 -52
- package/dist/combat/shotSpec.d.ts +239 -0
- package/dist/combat/shotSpec.js +216 -0
- package/dist/combat/weaponSystem.js +26 -21
- package/dist/combat.d.ts +1 -0
- package/dist/combat.js +1 -0
- package/dist/game/gameSave.d.ts +8 -2
- package/dist/game/gameSave.js +3 -2
- package/dist/game/playableGame.d.ts +9 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +93 -1
- package/dist/movement/characterController.d.ts +5 -0
- package/dist/movement/characterController.js +8 -1
- package/dist/movement/movementModel.js +4 -1
- package/dist/movement/playerMovement.js +4 -0
- package/dist/physics/physicsBackendConformance.js +17 -0
- package/dist/physics/physicsWorld.d.ts +7 -0
- package/dist/physics/physicsWorld.js +27 -6
- package/dist/run/runLoop.d.ts +57 -3
- package/dist/run/runLoop.js +95 -66
- package/dist/run/sessionLoop.d.ts +152 -0
- package/dist/run/sessionLoop.js +151 -0
- package/dist/world/features.d.ts +15 -1
- package/dist/world/features.js +51 -2
- package/dist/world/lod.d.ts +8 -0
- package/dist/world/lod.js +13 -5
- package/dist/world/modelLod.d.ts +44 -0
- package/dist/world/modelLod.js +75 -0
- package/dist/world.d.ts +2 -1
- package/dist/world.js +2 -1
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +7 -0
- package/skills/jgengine-combat/api.md +61 -1
- package/skills/jgengine-combat/capabilities.md +4 -0
- package/skills/jgengine-combat/reference.md +1 -0
- package/skills/jgengine-gameplay/api.md +22 -0
- package/skills/jgengine-gameplay/capabilities.md +7 -1
- package/skills/jgengine-world/api.md +17 -0
|
@@ -38,6 +38,23 @@ export function runPhysicsBackendConformance(create, harness) {
|
|
|
38
38
|
expect(state.position[1]).toBeLessThan(0.7);
|
|
39
39
|
expect(Math.abs(state.velocity[1])).toBeLessThan(0.5);
|
|
40
40
|
});
|
|
41
|
+
test("the same wall-clock time simulates the same distance at 30, 60 and 144 fps", () => {
|
|
42
|
+
// A backend that honours `fixedDt` as one step per call simulates `fixedDt * fps` seconds per
|
|
43
|
+
// second of real time — half speed at 30 fps, 2.4x at 144 fps. Horizontal travel ignores the
|
|
44
|
+
// integrator, so the distance is exactly `v * realSeconds` for any backend that accumulates.
|
|
45
|
+
const backend = create();
|
|
46
|
+
const travelled = [];
|
|
47
|
+
for (const fps of [30, 60, 144]) {
|
|
48
|
+
const ball = backend.addBody({ shape: { kind: "sphere", radius: 0.2 }, position: [0, 8, 0], velocity: [10, 0, 0] });
|
|
49
|
+
for (let i = 0; i < fps; i += 1)
|
|
50
|
+
backend.step(1 / fps);
|
|
51
|
+
travelled.push(backend.body(ball).position[0]);
|
|
52
|
+
backend.removeBody(ball);
|
|
53
|
+
}
|
|
54
|
+
const spread = Math.max(...travelled) - Math.min(...travelled);
|
|
55
|
+
expect(travelled[1]).toBeGreaterThan(8);
|
|
56
|
+
expect(spread).toBeLessThan(0.2);
|
|
57
|
+
});
|
|
41
58
|
test("raycast reports the floor with an upward normal and the right distance", () => {
|
|
42
59
|
const backend = create();
|
|
43
60
|
const ground = floor(backend);
|
|
@@ -276,6 +276,13 @@ export declare class PhysicsWorld {
|
|
|
276
276
|
getStats(): PhysicsStats;
|
|
277
277
|
private substep;
|
|
278
278
|
private constrainBounds;
|
|
279
|
+
/**
|
|
280
|
+
* Coulomb friction against the bounds floor, using the same `friction` coefficient a contact
|
|
281
|
+
* applies: horizontal speed bleeds by `friction * |gravity| * dt` per substep. The old
|
|
282
|
+
* multiplicative form (`* (1 - friction)` every substep) removed 60 × 40% per second, so a body
|
|
283
|
+
* on the world floor stopped ~6× shorter than the same body on a static floor collider.
|
|
284
|
+
*/
|
|
285
|
+
private applyFloorFriction;
|
|
279
286
|
/**
|
|
280
287
|
* Walk every broadphase cell body `i`'s AABB overlaps, invoking `visit(cell)` once per cell. The
|
|
281
288
|
* cell-range math (six `cellCoord` calls plus the row-major triple loop) lives here so the count
|
|
@@ -565,7 +565,7 @@ export class PhysicsWorld {
|
|
|
565
565
|
this.posX[i] += this.velX[i] * dt;
|
|
566
566
|
this.posY[i] += this.velY[i] * dt;
|
|
567
567
|
this.posZ[i] += this.velZ[i] * dt;
|
|
568
|
-
this.constrainBounds(i);
|
|
568
|
+
this.constrainBounds(i, dt, true);
|
|
569
569
|
}
|
|
570
570
|
this.buildGrid();
|
|
571
571
|
const iterations = this.solverIterations;
|
|
@@ -575,10 +575,10 @@ export class PhysicsWorld {
|
|
|
575
575
|
this.solveJoints(dt);
|
|
576
576
|
}
|
|
577
577
|
for (let a = 0; a < this.awakeCount; a += 1)
|
|
578
|
-
this.constrainBounds(this.awakeList[a]);
|
|
578
|
+
this.constrainBounds(this.awakeList[a], dt, false);
|
|
579
579
|
this.updateSleep();
|
|
580
580
|
}
|
|
581
|
-
constrainBounds(i) {
|
|
581
|
+
constrainBounds(i, dt, frictionPass) {
|
|
582
582
|
const min = this.bounds.min;
|
|
583
583
|
const max = this.bounds.max;
|
|
584
584
|
const e = this.restitution;
|
|
@@ -613,9 +613,8 @@ export class PhysicsWorld {
|
|
|
613
613
|
this.posY[i] = loY;
|
|
614
614
|
if (this.velY[i] < 0)
|
|
615
615
|
this.velY[i] = -this.velY[i] < rt ? 0 : this.velY[i] * -e;
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
this.velZ[i] *= tf;
|
|
616
|
+
if (frictionPass)
|
|
617
|
+
this.applyFloorFriction(i, dt);
|
|
619
618
|
}
|
|
620
619
|
else if (this.posY[i] > hiY) {
|
|
621
620
|
this.posY[i] = hiY;
|
|
@@ -623,6 +622,28 @@ export class PhysicsWorld {
|
|
|
623
622
|
this.velY[i] = this.velY[i] < rt ? 0 : -this.velY[i] * e;
|
|
624
623
|
}
|
|
625
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* Coulomb friction against the bounds floor, using the same `friction` coefficient a contact
|
|
627
|
+
* applies: horizontal speed bleeds by `friction * |gravity| * dt` per substep. The old
|
|
628
|
+
* multiplicative form (`* (1 - friction)` every substep) removed 60 × 40% per second, so a body
|
|
629
|
+
* on the world floor stopped ~6× shorter than the same body on a static floor collider.
|
|
630
|
+
*/
|
|
631
|
+
applyFloorFriction(i, dt) {
|
|
632
|
+
const decel = this.friction * Math.abs(this.gravity) * dt;
|
|
633
|
+
if (!(decel > 0))
|
|
634
|
+
return;
|
|
635
|
+
const vx = this.velX[i];
|
|
636
|
+
const vz = this.velZ[i];
|
|
637
|
+
const speed = Math.sqrt(vx * vx + vz * vz);
|
|
638
|
+
if (speed <= decel) {
|
|
639
|
+
this.velX[i] = 0;
|
|
640
|
+
this.velZ[i] = 0;
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const keep = (speed - decel) / speed;
|
|
644
|
+
this.velX[i] = vx * keep;
|
|
645
|
+
this.velZ[i] = vz * keep;
|
|
646
|
+
}
|
|
626
647
|
/**
|
|
627
648
|
* Walk every broadphase cell body `i`'s AABB overlaps, invoking `visit(cell)` once per cell. The
|
|
628
649
|
* cell-range math (six `cellCoord` calls plus the row-major triple loop) lives here so the count
|
package/dist/run/runLoop.d.ts
CHANGED
|
@@ -19,6 +19,28 @@ export interface RunOffer<T> {
|
|
|
19
19
|
changeId: string | null;
|
|
20
20
|
change: T | null;
|
|
21
21
|
}
|
|
22
|
+
/** One change a run could open with, in the shape a choice screen shows: the id and its payload. */
|
|
23
|
+
export interface RunChoice<T> {
|
|
24
|
+
id: string;
|
|
25
|
+
change: T;
|
|
26
|
+
}
|
|
27
|
+
/** What the loop reports about the run it is playing, for a capture or devtools read. */
|
|
28
|
+
export interface RunReadout {
|
|
29
|
+
/** The run in progress, or the last settled run when none is open. */
|
|
30
|
+
run: number;
|
|
31
|
+
/** What the run in progress carries and can still lose. */
|
|
32
|
+
carried: number;
|
|
33
|
+
banked: number;
|
|
34
|
+
lost: number;
|
|
35
|
+
/** The best single bank across the career. */
|
|
36
|
+
best: number;
|
|
37
|
+
/** The change the run in progress opened with, or the one the next run is offered. */
|
|
38
|
+
offered: string | null;
|
|
39
|
+
}
|
|
40
|
+
/** Read-only handle a capture or devtools uses to prove a run loop is live, not merely imported. */
|
|
41
|
+
export interface RunProbe {
|
|
42
|
+
read(): RunReadout;
|
|
43
|
+
}
|
|
22
44
|
/** Everything a run loop stores as plain JSON: the run in progress plus lifetime totals. */
|
|
23
45
|
export interface RunLoopState {
|
|
24
46
|
/** The run in progress, or `0` before the first `start`. */
|
|
@@ -66,6 +88,17 @@ export interface RunLoop<T> {
|
|
|
66
88
|
haul(): number;
|
|
67
89
|
/** The run in progress's offer, or the offer the next `start` would open. */
|
|
68
90
|
offer(): RunOffer<T>;
|
|
91
|
+
/**
|
|
92
|
+
* Up to `count` distinct changes the next run could open with, drawn weighted and in draw order,
|
|
93
|
+
* so a game can offer a choice instead of the single change {@link RunOffer} names. `choose` takes one.
|
|
94
|
+
*/
|
|
95
|
+
present(count: number): RunChoice<T>[];
|
|
96
|
+
/**
|
|
97
|
+
* Open the next run around `changeId` (or around no change for `null`), which is how a presented
|
|
98
|
+
* choice becomes the change the run carries. Returns false for an id this loop's table does not
|
|
99
|
+
* carry, leaving the offer untouched.
|
|
100
|
+
*/
|
|
101
|
+
choose(changeId: string | null): boolean;
|
|
69
102
|
/**
|
|
70
103
|
* Close the run in progress: bank the haul scaled by `multiplier`, or lose it. Throws when no run
|
|
71
104
|
* is open, so a double settle cannot bank the same haul twice.
|
|
@@ -80,9 +113,30 @@ export interface RunLoop<T> {
|
|
|
80
113
|
* A run's haul banks when it ends in a bank and is lost when it ends in a loss; the money itself stays
|
|
81
114
|
* with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
|
|
82
115
|
* run's change from `changes`: eligible when `after <= completed`, never the change just played unless
|
|
83
|
-
* it is the only one eligible, weighted by `weight`. The draw is
|
|
84
|
-
*
|
|
116
|
+
* it is the only one eligible, weighted by `weight`. The draw is seeded, so the same seed and the same
|
|
117
|
+
* run history give the same sequence, before or after a save/load. `present(count)` draws several of
|
|
118
|
+
* those changes for a choice screen and `choose(id)` adopts the pick as the next run's change.
|
|
119
|
+
*
|
|
120
|
+
* This is a composition of `createSessionLoop` (`run/sessionLoop`): the session loop owns opening,
|
|
121
|
+
* carrying, closing and restoring, and this module supplies the two things that make it a roguelike -
|
|
122
|
+
* a `settle` that banks or loses the haul, and a change table the next run can open with.
|
|
123
|
+
*
|
|
124
|
+
* @deprecated Bank-or-lose and a per-run change table are one game type's rules, not the engine's.
|
|
125
|
+
* Compose `createSessionLoop` with your own `settle` and `offers` instead. Kept unchanged for the two
|
|
126
|
+
* adopters (Beacon Bastion, Drift Foundry) that pin 0.37.0. Removal path: delete this module one minor
|
|
127
|
+
* release after both compose `createSessionLoop` directly.
|
|
85
128
|
*
|
|
86
|
-
* @capability run-loop bank or lose what a run carries, with one deterministic change per run
|
|
129
|
+
* @capability run-loop deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
|
|
87
130
|
*/
|
|
88
131
|
export declare function createRunLoop<T>(config: RunLoopConfig<T>): RunLoop<T>;
|
|
132
|
+
/**
|
|
133
|
+
* Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
|
|
134
|
+
* the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never
|
|
135
|
+
* starts, settles or changes a run. Returns the uninstaller.
|
|
136
|
+
*
|
|
137
|
+
* @deprecated Ships with {@link createRunLoop}; compose `installSessionProbe` from `run/sessionLoop`
|
|
138
|
+
* when you compose the session loop directly.
|
|
139
|
+
*
|
|
140
|
+
* @capability run-loop deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
|
|
141
|
+
*/
|
|
142
|
+
export declare function installRunProbe<T>(loop: RunLoop<T>): () => void;
|
package/dist/run/runLoop.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { seededRng } from "../random/rng.js";
|
|
1
|
+
import { createSessionLoop } from "./sessionLoop.js";
|
|
3
2
|
function positive(value) {
|
|
4
3
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
5
4
|
}
|
|
@@ -9,84 +8,114 @@ function positive(value) {
|
|
|
9
8
|
* A run's haul banks when it ends in a bank and is lost when it ends in a loss; the money itself stays
|
|
10
9
|
* with the game (pair it with `progression/careerBank`). After each settled run the loop draws the next
|
|
11
10
|
* run's change from `changes`: eligible when `after <= completed`, never the change just played unless
|
|
12
|
-
* it is the only one eligible, weighted by `weight`. The draw is
|
|
13
|
-
*
|
|
11
|
+
* it is the only one eligible, weighted by `weight`. The draw is seeded, so the same seed and the same
|
|
12
|
+
* run history give the same sequence, before or after a save/load. `present(count)` draws several of
|
|
13
|
+
* those changes for a choice screen and `choose(id)` adopts the pick as the next run's change.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* This is a composition of `createSessionLoop` (`run/sessionLoop`): the session loop owns opening,
|
|
16
|
+
* carrying, closing and restoring, and this module supplies the two things that make it a roguelike -
|
|
17
|
+
* a `settle` that banks or loses the haul, and a change table the next run can open with.
|
|
18
|
+
*
|
|
19
|
+
* @deprecated Bank-or-lose and a per-run change table are one game type's rules, not the engine's.
|
|
20
|
+
* Compose `createSessionLoop` with your own `settle` and `offers` instead. Kept unchanged for the two
|
|
21
|
+
* adopters (Beacon Bastion, Drift Foundry) that pin 0.37.0. Removal path: delete this module one minor
|
|
22
|
+
* release after both compose `createSessionLoop` directly.
|
|
23
|
+
*
|
|
24
|
+
* @capability run-loop deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
|
|
16
25
|
*/
|
|
17
26
|
export function createRunLoop(config) {
|
|
18
|
-
const table = config.changes ?? [];
|
|
19
|
-
const known = new Set();
|
|
20
|
-
for (const change of table) {
|
|
21
|
-
if (known.has(change.id))
|
|
22
|
-
throw new Error(`run change "${change.id}" is declared twice`);
|
|
23
|
-
known.add(change.id);
|
|
24
|
-
}
|
|
25
|
-
let run = 0;
|
|
26
|
-
let completed = 0;
|
|
27
|
-
let haul = 0;
|
|
28
|
-
let changeId = null;
|
|
29
27
|
let bankedTotal = 0;
|
|
30
28
|
let lostTotal = 0;
|
|
31
29
|
let bestHaul = 0;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
function offer() {
|
|
42
|
-
// An open run's change was adopted at `start`, so the offer repeats it instead of redrawing.
|
|
43
|
-
if (run > completed) {
|
|
44
|
-
const adopted = table.find((change) => change.id === changeId);
|
|
45
|
-
return { run, changeId, change: adopted?.value ?? null };
|
|
46
|
-
}
|
|
47
|
-
return drawFor(completed + 1, changeId);
|
|
48
|
-
}
|
|
49
|
-
return {
|
|
50
|
-
start() {
|
|
51
|
-
if (run > completed)
|
|
52
|
-
return offer();
|
|
53
|
-
const next = offer();
|
|
54
|
-
run = next.run;
|
|
55
|
-
haul = 0;
|
|
56
|
-
changeId = next.changeId;
|
|
57
|
-
return next;
|
|
58
|
-
},
|
|
59
|
-
carry(amount) {
|
|
60
|
-
haul += positive(amount);
|
|
61
|
-
return haul;
|
|
62
|
-
},
|
|
63
|
-
haul: () => haul,
|
|
64
|
-
offer,
|
|
65
|
-
settle(outcome, multiplier) {
|
|
66
|
-
if (run <= completed)
|
|
67
|
-
throw new Error("run loop: no run is open to settle");
|
|
68
|
-
const settledHaul = haul;
|
|
69
|
-
const scale = Number.isFinite(multiplier) ? Math.max(0, multiplier ?? 1) : 1;
|
|
70
|
-
const banked = outcome === "banked" ? Math.round(settledHaul * scale) : 0;
|
|
71
|
-
const lost = outcome === "lost" ? settledHaul : 0;
|
|
30
|
+
const loop = createSessionLoop({
|
|
31
|
+
kind: "run",
|
|
32
|
+
seed: config.seed,
|
|
33
|
+
offers: config.changes,
|
|
34
|
+
settle: ({ carried, input }) => {
|
|
35
|
+
const scale = Number.isFinite(input.multiplier) ? Math.max(0, input.multiplier ?? 1) : 1;
|
|
36
|
+
const banked = input.outcome === "banked" ? Math.round(carried * scale) : 0;
|
|
37
|
+
const lost = input.outcome === "lost" ? carried : 0;
|
|
72
38
|
bankedTotal += banked;
|
|
73
39
|
lostTotal += lost;
|
|
74
40
|
bestHaul = Math.max(bestHaul, banked);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
41
|
+
return { banked, lost };
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const offerOf = (offer) => ({
|
|
45
|
+
run: offer.session,
|
|
46
|
+
changeId: offer.optionId,
|
|
47
|
+
change: offer.option,
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
start: () => offerOf(loop.start()),
|
|
51
|
+
carry: (amount) => loop.carry(amount),
|
|
52
|
+
haul: () => loop.carried(),
|
|
53
|
+
offer: () => offerOf(loop.offer()),
|
|
54
|
+
present: (count) => loop.present(count).map((choice) => ({ id: choice.id, change: choice.value })),
|
|
55
|
+
choose: (changeId) => loop.choose(changeId),
|
|
56
|
+
settle(outcome, multiplier) {
|
|
57
|
+
if (loop.phase() !== "open")
|
|
58
|
+
throw new Error("run loop: no run is open to settle");
|
|
59
|
+
const settlement = loop.settle({ outcome, multiplier });
|
|
60
|
+
return {
|
|
61
|
+
outcome,
|
|
62
|
+
haul: settlement.carried,
|
|
63
|
+
banked: settlement.result.banked,
|
|
64
|
+
lost: settlement.result.lost,
|
|
65
|
+
completed: settlement.closed,
|
|
66
|
+
bestHaul,
|
|
67
|
+
next: offerOf(settlement.next),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
snapshot() {
|
|
71
|
+
const state = loop.snapshot();
|
|
72
|
+
return {
|
|
73
|
+
run: state.session,
|
|
74
|
+
completed: state.closed,
|
|
75
|
+
haul: state.carried,
|
|
76
|
+
changeId: state.optionId,
|
|
77
|
+
bankedTotal,
|
|
78
|
+
lostTotal,
|
|
79
|
+
bestHaul,
|
|
80
|
+
};
|
|
78
81
|
},
|
|
79
|
-
snapshot: () => ({ run, completed, haul, changeId, bankedTotal, lostTotal, bestHaul }),
|
|
80
82
|
restore(next) {
|
|
81
|
-
run
|
|
82
|
-
completed = Math.max(0, Math.floor(next.completed));
|
|
83
|
-
if (run < completed)
|
|
84
|
-
run = completed;
|
|
85
|
-
haul = positive(next.haul);
|
|
86
|
-
changeId = next.changeId !== null && known.has(next.changeId) ? next.changeId : null;
|
|
83
|
+
loop.restore({ session: next.run, closed: next.completed, carried: next.haul, optionId: next.changeId });
|
|
87
84
|
bankedTotal = positive(next.bankedTotal);
|
|
88
85
|
lostTotal = positive(next.lostTotal);
|
|
89
86
|
bestHaul = positive(next.bestHaul);
|
|
90
87
|
},
|
|
91
88
|
};
|
|
92
89
|
}
|
|
90
|
+
const RUN_PROBE_KEY = "__jgengineRun";
|
|
91
|
+
/**
|
|
92
|
+
* Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
|
|
93
|
+
* the live run, what it carries, what it banked or lost and the change it offers. Reads only; it never
|
|
94
|
+
* starts, settles or changes a run. Returns the uninstaller.
|
|
95
|
+
*
|
|
96
|
+
* @deprecated Ships with {@link createRunLoop}; compose `installSessionProbe` from `run/sessionLoop`
|
|
97
|
+
* when you compose the session loop directly.
|
|
98
|
+
*
|
|
99
|
+
* @capability run-loop deprecated roguelike composition of session-loop - bank or lose what a run carries, with one deterministic change per run
|
|
100
|
+
*/
|
|
101
|
+
export function installRunProbe(loop) {
|
|
102
|
+
const scope = globalThis;
|
|
103
|
+
const probe = {
|
|
104
|
+
read() {
|
|
105
|
+
const state = loop.snapshot();
|
|
106
|
+
return {
|
|
107
|
+
run: state.run,
|
|
108
|
+
carried: state.haul,
|
|
109
|
+
banked: state.bankedTotal,
|
|
110
|
+
lost: state.lostTotal,
|
|
111
|
+
best: state.bestHaul,
|
|
112
|
+
offered: loop.offer().changeId,
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
scope[RUN_PROBE_KEY] = probe;
|
|
117
|
+
return () => {
|
|
118
|
+
if (scope[RUN_PROBE_KEY] === probe)
|
|
119
|
+
delete scope[RUN_PROBE_KEY];
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** Where a session stands: not opened yet, in progress, or closed at its boundary. */
|
|
2
|
+
export type SessionPhase = "idle" | "open" | "closed";
|
|
3
|
+
/**
|
|
4
|
+
* One option the next session may open with. `T` is the game's own payload (a tighter rival line, an
|
|
5
|
+
* unlocked part, a shorter route) - the loop only draws and remembers which id was drawn.
|
|
6
|
+
*/
|
|
7
|
+
export interface SessionOfferOption<T> {
|
|
8
|
+
id: string;
|
|
9
|
+
value: T;
|
|
10
|
+
/** Relative draw weight (default `1`); a non-positive weight is never drawn. */
|
|
11
|
+
weight?: number;
|
|
12
|
+
/** Sessions that must close before this option can be drawn (default `0`). */
|
|
13
|
+
after?: number;
|
|
14
|
+
}
|
|
15
|
+
/** The option a session opens with, and which session it is. */
|
|
16
|
+
export interface SessionOffer<T> {
|
|
17
|
+
/** 1-based session number: `1` is the first session, before any boundary has closed. */
|
|
18
|
+
session: number;
|
|
19
|
+
optionId: string | null;
|
|
20
|
+
option: T | null;
|
|
21
|
+
}
|
|
22
|
+
/** One option the next session could open with, in the shape a choice screen shows. */
|
|
23
|
+
export interface SessionChoice<T> {
|
|
24
|
+
id: string;
|
|
25
|
+
value: T;
|
|
26
|
+
}
|
|
27
|
+
/** The session closing, handed to a game's own `settle`. */
|
|
28
|
+
export interface SettlingSession<I = void> {
|
|
29
|
+
/** 1-based number of the session closing. */
|
|
30
|
+
session: number;
|
|
31
|
+
/** The quantity the session carried to its boundary; `0` when the game carries nothing. */
|
|
32
|
+
carried: number;
|
|
33
|
+
/** The game's own reason for closing, passed to `settle`. */
|
|
34
|
+
input: I;
|
|
35
|
+
}
|
|
36
|
+
/** What closing one session did, and what the next session opens with. */
|
|
37
|
+
export interface SessionSettlement<R = void, T = never> {
|
|
38
|
+
/** The session that closed. */
|
|
39
|
+
session: number;
|
|
40
|
+
/** The quantity it carried to its boundary. */
|
|
41
|
+
carried: number;
|
|
42
|
+
/** Sessions closed so far. */
|
|
43
|
+
closed: number;
|
|
44
|
+
/** What the game's `settle` returned. */
|
|
45
|
+
result: R;
|
|
46
|
+
/** The option the next session opens with. */
|
|
47
|
+
next: SessionOffer<T>;
|
|
48
|
+
}
|
|
49
|
+
/** What the loop reports about the session it is playing, for a capture or devtools read. */
|
|
50
|
+
export interface SessionReadout {
|
|
51
|
+
/** The session in progress, or the last one started when none is open. */
|
|
52
|
+
session: number;
|
|
53
|
+
phase: SessionPhase;
|
|
54
|
+
/** What a session is in this game, or `null` when the game did not say. */
|
|
55
|
+
kind: string | null;
|
|
56
|
+
/** What the session in progress carries. */
|
|
57
|
+
carried: number;
|
|
58
|
+
closed: number;
|
|
59
|
+
/** The option the session in progress opened with, or the one the next session is offered. */
|
|
60
|
+
offered: string | null;
|
|
61
|
+
}
|
|
62
|
+
/** Read-only handle a capture or devtools uses to prove a session loop is live, not merely imported. */
|
|
63
|
+
export interface SessionProbe {
|
|
64
|
+
read(): SessionReadout;
|
|
65
|
+
}
|
|
66
|
+
/** Everything a session loop stores as plain JSON. */
|
|
67
|
+
export interface SessionLoopState {
|
|
68
|
+
/** The session in progress, or the last one started; `0` before the first `start`. */
|
|
69
|
+
session: number;
|
|
70
|
+
/** Sessions whose boundary has closed; also the number `after` gates are read against. */
|
|
71
|
+
closed: number;
|
|
72
|
+
/** The quantity the session in progress carries. */
|
|
73
|
+
carried: number;
|
|
74
|
+
/** The option the session in progress opened with, or the one the next session opens with. */
|
|
75
|
+
optionId: string | null;
|
|
76
|
+
}
|
|
77
|
+
/** Configuration for {@link createSessionLoop}. */
|
|
78
|
+
export interface SessionLoopConfig<T = never, I = void, R = void> {
|
|
79
|
+
/** What a session is, for the readout: `"day"`, `"shift"`, `"wave"`. */
|
|
80
|
+
kind?: string;
|
|
81
|
+
/** Whether a session carries a quantity (default `true`); `false` makes {@link SessionLoop.carry} inert. */
|
|
82
|
+
carries?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* What the boundary does with what the session carried: the game's own rule, so bank, lose, keep and
|
|
85
|
+
* nothing are all one callback. Omitted means closing a session only closes it.
|
|
86
|
+
*/
|
|
87
|
+
settle?: (session: SettlingSession<I>) => R;
|
|
88
|
+
/** Authored options the next session may open with. Omitted means sessions never open with one. */
|
|
89
|
+
offers?: readonly SessionOfferOption<T>[];
|
|
90
|
+
/** Seed for the option draw (default `"session"`): the same seed and history replay the same sequence. */
|
|
91
|
+
seed?: string | number;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One session's lifecycle: open it, carry a quantity, close it at its boundary, and optionally open
|
|
95
|
+
* the next one with an option the game authored.
|
|
96
|
+
*/
|
|
97
|
+
export interface SessionLoop<T = never, I = void, R = void> {
|
|
98
|
+
/** What a session is in this game, or `null` when the game did not say. */
|
|
99
|
+
kind(): string | null;
|
|
100
|
+
/** Open the next session and hand back its offer. Calling it on an open session returns that session's offer and keeps what it carries. */
|
|
101
|
+
start(): SessionOffer<T>;
|
|
102
|
+
/** Add to what the session in progress carries; negative or non-finite amounts are ignored. Returns what it carries. */
|
|
103
|
+
carry(amount: number): number;
|
|
104
|
+
/** What the session in progress carries. */
|
|
105
|
+
carried(): number;
|
|
106
|
+
/** Whether no session has opened, one is open, or the last one closed. */
|
|
107
|
+
phase(): SessionPhase;
|
|
108
|
+
/** The open session's offer, or the offer the next `start` would open with. */
|
|
109
|
+
offer(): SessionOffer<T>;
|
|
110
|
+
/**
|
|
111
|
+
* Up to `count` distinct options the next session could open with, drawn weighted and in draw order,
|
|
112
|
+
* so a game can offer a choice instead of the single option {@link SessionOffer} names. `choose` takes one.
|
|
113
|
+
*/
|
|
114
|
+
present(count: number): SessionChoice<T>[];
|
|
115
|
+
/**
|
|
116
|
+
* Open the next session around `optionId` (or around no option for `null`), which is how a presented
|
|
117
|
+
* choice becomes the option the session carries. Returns false for an id this loop's table does not
|
|
118
|
+
* carry, leaving the offer untouched.
|
|
119
|
+
*/
|
|
120
|
+
choose(optionId: string | null): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Close the session in progress and hand what it carried to the game's `settle` rule. Throws when no
|
|
123
|
+
* session is open, so a double close cannot settle the same quantity twice.
|
|
124
|
+
*/
|
|
125
|
+
settle(input?: I): SessionSettlement<R, T>;
|
|
126
|
+
snapshot(): SessionLoopState;
|
|
127
|
+
restore(next: SessionLoopState): void;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The shared shape of a game session, with the game's own rules supplied as config: a session opens,
|
|
131
|
+
* it may carry a quantity, its boundary closes it, and the game's `settle` callback decides what
|
|
132
|
+
* happens to what it carried. A park day, a night shift and a wave are the same base with a different
|
|
133
|
+
* `settle`, a different `kind` and, where nothing is at stake, `carries: false`.
|
|
134
|
+
*
|
|
135
|
+
* `offers` is optional and defaults to nothing, so a game whose sessions never differ says nothing:
|
|
136
|
+
* sessions open plain. A game that wants its next session to open with an authored change supplies the
|
|
137
|
+
* table and gets the same draw rules everywhere - eligible when `after <= closed`, never the option
|
|
138
|
+
* just played unless it is the only one eligible, weighted by `weight`, drawn as
|
|
139
|
+
* `seededRng(seed:offer:N)` so the same seed and history replay the same sequence before or after a
|
|
140
|
+
* save/load. `present(count)` draws several for a choice screen and `choose(id)` adopts the pick.
|
|
141
|
+
*
|
|
142
|
+
* @capability session-loop open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
|
|
143
|
+
*/
|
|
144
|
+
export declare function createSessionLoop<T = never, I = void, R = void>(config?: SessionLoopConfig<T, I, R>): SessionLoop<T, I, R>;
|
|
145
|
+
/**
|
|
146
|
+
* Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
|
|
147
|
+
* which session is live, its phase, what it carries and the option it offers. Reads only; it never
|
|
148
|
+
* starts, closes or changes a session. Returns the uninstaller.
|
|
149
|
+
*
|
|
150
|
+
* @capability session-loop open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
|
|
151
|
+
*/
|
|
152
|
+
export declare function installSessionProbe<T, I, R>(loop: SessionLoop<T, I, R>): () => void;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { pickWeighted } from "../random/pick.js";
|
|
2
|
+
import { seededRng } from "../random/rng.js";
|
|
3
|
+
function positive(value) {
|
|
4
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
5
|
+
}
|
|
6
|
+
function count(value) {
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The shared shape of a game session, with the game's own rules supplied as config: a session opens,
|
|
11
|
+
* it may carry a quantity, its boundary closes it, and the game's `settle` callback decides what
|
|
12
|
+
* happens to what it carried. A park day, a night shift and a wave are the same base with a different
|
|
13
|
+
* `settle`, a different `kind` and, where nothing is at stake, `carries: false`.
|
|
14
|
+
*
|
|
15
|
+
* `offers` is optional and defaults to nothing, so a game whose sessions never differ says nothing:
|
|
16
|
+
* sessions open plain. A game that wants its next session to open with an authored change supplies the
|
|
17
|
+
* table and gets the same draw rules everywhere - eligible when `after <= closed`, never the option
|
|
18
|
+
* just played unless it is the only one eligible, weighted by `weight`, drawn as
|
|
19
|
+
* `seededRng(seed:offer:N)` so the same seed and history replay the same sequence before or after a
|
|
20
|
+
* save/load. `present(count)` draws several for a choice screen and `choose(id)` adopts the pick.
|
|
21
|
+
*
|
|
22
|
+
* @capability session-loop open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
|
|
23
|
+
*/
|
|
24
|
+
export function createSessionLoop(config = {}) {
|
|
25
|
+
const table = config.offers ?? [];
|
|
26
|
+
const carries = config.carries !== false;
|
|
27
|
+
const seed = config.seed ?? "session";
|
|
28
|
+
const known = new Set();
|
|
29
|
+
for (const option of table) {
|
|
30
|
+
if (known.has(option.id))
|
|
31
|
+
throw new Error(`session option "${option.id}" is declared twice`);
|
|
32
|
+
known.add(option.id);
|
|
33
|
+
}
|
|
34
|
+
let session = 0;
|
|
35
|
+
let closed = 0;
|
|
36
|
+
let carried = 0;
|
|
37
|
+
let optionId = null;
|
|
38
|
+
const isOpen = () => session > closed;
|
|
39
|
+
function poolFor(index, previous) {
|
|
40
|
+
const eligible = table.filter((option) => (option.after ?? 0) <= index - 1);
|
|
41
|
+
const fresh = eligible.filter((option) => option.id !== previous);
|
|
42
|
+
// A one-entry table still offers its option rather than nothing.
|
|
43
|
+
return fresh.length > 0 ? fresh : eligible;
|
|
44
|
+
}
|
|
45
|
+
function drawFor(index, previous) {
|
|
46
|
+
const rng = seededRng(`${seed}:offer:${index}`);
|
|
47
|
+
const drawn = pickWeighted(rng, poolFor(index, previous), (option) => option.weight ?? 1) ?? null;
|
|
48
|
+
return { session: index, optionId: drawn?.id ?? null, option: drawn?.value ?? null };
|
|
49
|
+
}
|
|
50
|
+
function offer() {
|
|
51
|
+
// An open session's option was adopted at `start` or `choose`, so the offer repeats it instead of redrawing.
|
|
52
|
+
if (isOpen()) {
|
|
53
|
+
const adopted = table.find((option) => option.id === optionId);
|
|
54
|
+
return { session, optionId, option: adopted?.value ?? null };
|
|
55
|
+
}
|
|
56
|
+
return drawFor(closed + 1, optionId);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
kind: () => config.kind ?? null,
|
|
60
|
+
start() {
|
|
61
|
+
if (isOpen())
|
|
62
|
+
return offer();
|
|
63
|
+
const next = offer();
|
|
64
|
+
session = next.session;
|
|
65
|
+
carried = 0;
|
|
66
|
+
optionId = next.optionId;
|
|
67
|
+
return next;
|
|
68
|
+
},
|
|
69
|
+
carry(amount) {
|
|
70
|
+
if (carries)
|
|
71
|
+
carried += positive(amount);
|
|
72
|
+
return carried;
|
|
73
|
+
},
|
|
74
|
+
carried: () => carried,
|
|
75
|
+
phase: () => (session === 0 && closed === 0 ? "idle" : isOpen() ? "open" : "closed"),
|
|
76
|
+
offer,
|
|
77
|
+
present(wanted) {
|
|
78
|
+
const index = isOpen() ? session : closed + 1;
|
|
79
|
+
const remaining = poolFor(index, optionId);
|
|
80
|
+
const drawn = [];
|
|
81
|
+
const take = Number.isFinite(wanted) ? Math.max(0, Math.floor(wanted)) : 0;
|
|
82
|
+
const rng = seededRng(`${seed}:present:${index}`);
|
|
83
|
+
while (drawn.length < take && remaining.length > 0) {
|
|
84
|
+
const picked = pickWeighted(rng, remaining, (option) => option.weight ?? 1);
|
|
85
|
+
if (picked === undefined)
|
|
86
|
+
break;
|
|
87
|
+
drawn.push({ id: picked.id, value: picked.value });
|
|
88
|
+
remaining.splice(remaining.indexOf(picked), 1);
|
|
89
|
+
}
|
|
90
|
+
return drawn;
|
|
91
|
+
},
|
|
92
|
+
choose(nextOptionId) {
|
|
93
|
+
if (nextOptionId !== null && !known.has(nextOptionId))
|
|
94
|
+
return false;
|
|
95
|
+
// A closed loop has no session to adopt the pick, so the pick opens the next one.
|
|
96
|
+
if (!isOpen()) {
|
|
97
|
+
session = closed + 1;
|
|
98
|
+
carried = 0;
|
|
99
|
+
}
|
|
100
|
+
optionId = nextOptionId;
|
|
101
|
+
return true;
|
|
102
|
+
},
|
|
103
|
+
settle(input) {
|
|
104
|
+
if (!isOpen())
|
|
105
|
+
throw new Error("session loop: no session is open to settle");
|
|
106
|
+
const settling = { session, carried, input: input };
|
|
107
|
+
const result = config.settle === undefined ? undefined : config.settle(settling);
|
|
108
|
+
closed = session;
|
|
109
|
+
carried = 0;
|
|
110
|
+
return { session: settling.session, carried: settling.carried, closed, result, next: drawFor(closed + 1, optionId) };
|
|
111
|
+
},
|
|
112
|
+
snapshot: () => ({ session, closed, carried, optionId }),
|
|
113
|
+
restore(next) {
|
|
114
|
+
session = count(next.session);
|
|
115
|
+
closed = count(next.closed);
|
|
116
|
+
if (session < closed)
|
|
117
|
+
session = closed;
|
|
118
|
+
carried = carries ? positive(next.carried) : 0;
|
|
119
|
+
optionId = next.optionId !== null && known.has(next.optionId) ? next.optionId : null;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const SESSION_PROBE_KEY = "__jgengineRun";
|
|
124
|
+
/**
|
|
125
|
+
* Publish the loop's own readout on `window.__jgengineRun`, so a capture or devtools probe can state
|
|
126
|
+
* which session is live, its phase, what it carries and the option it offers. Reads only; it never
|
|
127
|
+
* starts, closes or changes a session. Returns the uninstaller.
|
|
128
|
+
*
|
|
129
|
+
* @capability session-loop open and close a game session, settle the quantity it carried and optionally draw the next session's authored option
|
|
130
|
+
*/
|
|
131
|
+
export function installSessionProbe(loop) {
|
|
132
|
+
const scope = globalThis;
|
|
133
|
+
const probe = {
|
|
134
|
+
read() {
|
|
135
|
+
const state = loop.snapshot();
|
|
136
|
+
return {
|
|
137
|
+
session: state.session,
|
|
138
|
+
phase: loop.phase(),
|
|
139
|
+
kind: loop.kind(),
|
|
140
|
+
carried: state.carried,
|
|
141
|
+
closed: state.closed,
|
|
142
|
+
offered: loop.offer().optionId,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
scope[SESSION_PROBE_KEY] = probe;
|
|
147
|
+
return () => {
|
|
148
|
+
if (scope[SESSION_PROBE_KEY] === probe)
|
|
149
|
+
delete scope[SESSION_PROBE_KEY];
|
|
150
|
+
};
|
|
151
|
+
}
|