@jgengine/core 0.11.0 → 0.12.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 +26 -0
- package/dist/ai/populationDirector.d.ts +103 -0
- package/dist/ai/populationDirector.js +190 -0
- package/dist/ai/pursuit.d.ts +56 -0
- package/dist/ai/pursuit.js +53 -0
- package/dist/authoring.d.ts +1 -1
- package/dist/authoring.js +1 -1
- package/dist/combat.d.ts +1 -0
- package/dist/combat.js +1 -0
- package/dist/devtools/controls.d.ts +38 -0
- package/dist/devtools/controls.js +181 -0
- package/dist/devtools/devtools.d.ts +5 -197
- package/dist/devtools/devtools.js +38 -774
- package/dist/devtools/discover.d.ts +32 -0
- package/dist/devtools/discover.js +240 -0
- package/dist/devtools/fallbackSeams.d.ts +50 -0
- package/dist/devtools/fallbackSeams.js +111 -0
- package/dist/devtools/frame.d.ts +15 -0
- package/dist/devtools/frame.js +247 -0
- package/dist/devtools/logs.d.ts +15 -0
- package/dist/devtools/logs.js +86 -0
- package/dist/devtools/overrides.d.ts +16 -0
- package/dist/devtools/overrides.js +66 -0
- package/dist/devtools/types.d.ts +212 -0
- package/dist/devtools/types.js +1 -0
- package/dist/economy/auctionBook.d.ts +122 -0
- package/dist/economy/auctionBook.js +213 -0
- package/dist/economy/priceHistory.d.ts +44 -0
- package/dist/economy/priceHistory.js +89 -0
- package/dist/economy/unlockPoints.d.ts +92 -0
- package/dist/economy/unlockPoints.js +103 -0
- package/dist/editor/commandApply.d.ts +5 -0
- package/dist/editor/commandApply.js +755 -0
- package/dist/editor/commands.d.ts +48 -3
- package/dist/editor/commands.js +40 -754
- package/dist/editor/directives.d.ts +94 -0
- package/dist/editor/directives.js +206 -0
- package/dist/editor/document.d.ts +37 -2
- package/dist/editor/document.js +422 -6
- package/dist/editor/entityCatalog.d.ts +42 -0
- package/dist/editor/entityCatalog.js +101 -0
- package/dist/editor/environment.d.ts +74 -0
- package/dist/editor/environment.js +169 -0
- package/dist/editor/index.d.ts +26 -1
- package/dist/editor/index.js +8 -1
- package/dist/editor/minimap.d.ts +31 -0
- package/dist/editor/minimap.js +73 -0
- package/dist/editor/streamer.d.ts +66 -0
- package/dist/editor/streamer.js +68 -0
- package/dist/editor/types.d.ts +143 -0
- package/dist/editor/world.d.ts +136 -0
- package/dist/editor/world.js +303 -0
- package/dist/game/breeding.d.ts +161 -0
- package/dist/game/breeding.js +181 -0
- package/dist/game/cameraConfig.d.ts +451 -0
- package/dist/game/cameraConfig.js +1 -0
- package/dist/game/defineGame.d.ts +16 -9
- package/dist/game/defineGame.js +14 -6
- package/dist/game/defineSystem.d.ts +2 -0
- package/dist/game/defineSystem.js +2 -0
- package/dist/game/events.d.ts +28 -0
- package/dist/game/playableGame.d.ts +34 -435
- package/dist/game/playableGame.js +1 -1
- package/dist/game/progression.d.ts +35 -0
- package/dist/game/progression.js +19 -0
- package/dist/game/quest.js +1 -3
- package/dist/game/tribe.d.ts +184 -0
- package/dist/game/tribe.js +432 -0
- package/dist/gameplay.d.ts +3 -1
- package/dist/gameplay.js +3 -1
- package/dist/inventory/encumbrance.d.ts +69 -0
- package/dist/inventory/encumbrance.js +68 -0
- package/dist/item/durability.js +1 -3
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +24 -1
- package/dist/movement/movementModel.d.ts +15 -1
- package/dist/movement/movementModel.js +33 -15
- package/dist/movement/playerMovement.js +102 -2
- package/dist/multiplayer/combatSnapshot.js +13 -8
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +8 -0
- package/dist/physics/drivableVehicle.d.ts +2 -0
- package/dist/physics/drivableVehicle.js +1 -1
- package/dist/physics/flightDynamics.d.ts +82 -0
- package/dist/physics/flightDynamics.js +165 -0
- package/dist/physics/gravityField.d.ts +25 -0
- package/dist/physics/gravityField.js +41 -0
- package/dist/physics/kinematicVehicle.d.ts +87 -0
- package/dist/physics/kinematicVehicle.js +193 -11
- package/dist/physics/physicsWorld.d.ts +26 -0
- package/dist/physics/physicsWorld.js +109 -117
- package/dist/physics/vehicleObstacles.d.ts +44 -0
- package/dist/physics/vehicleObstacles.js +115 -0
- package/dist/random/rng.d.ts +4 -1
- package/dist/random/rng.js +4 -1
- package/dist/runtime/adapter.d.ts +13 -0
- package/dist/runtime/adapter.js +13 -0
- package/dist/runtime/context/combat.d.ts +51 -0
- package/dist/runtime/context/combat.js +130 -0
- package/dist/runtime/context/deathLoot.d.ts +30 -0
- package/dist/runtime/context/deathLoot.js +39 -0
- package/dist/runtime/context/player.d.ts +48 -0
- package/dist/runtime/context/player.js +125 -0
- package/dist/runtime/context/scene.d.ts +59 -0
- package/dist/runtime/context/scene.js +291 -0
- package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
- package/dist/runtime/context/snapshotCodecs.js +26 -0
- package/dist/runtime/descriptors/baseline.js +26 -3
- package/dist/runtime/descriptors/features.d.ts +5 -0
- package/dist/runtime/descriptors/features.js +20 -2
- package/dist/runtime/gameContext.d.ts +2 -428
- package/dist/runtime/gameContext.js +88 -501
- package/dist/runtime/gameContextTypes.d.ts +467 -0
- package/dist/runtime/gameContextTypes.js +1 -0
- package/dist/runtime/headlessRunner.d.ts +4 -2
- package/dist/runtime/headlessRunner.js +1 -0
- package/dist/runtime/hostPolicy.d.ts +51 -0
- package/dist/runtime/hostPolicy.js +68 -0
- package/dist/runtime/hostedGameRunner.d.ts +3 -1
- package/dist/runtime/hostedGameRunner.js +1 -0
- package/dist/runtime/hostedWorldSession.d.ts +3 -1
- package/dist/runtime/hostedWorldSession.js +1 -0
- package/dist/runtime/worldSnapshot.d.ts +13 -1
- package/dist/runtime/worldSnapshot.js +15 -3
- package/dist/scene/assetCatalog.d.ts +5 -0
- package/dist/scene/assetSpace.js +1 -3
- package/dist/scene/authoredTriggers.d.ts +50 -0
- package/dist/scene/authoredTriggers.js +114 -0
- package/dist/scene/builtinSceneKinds.js +8 -3
- package/dist/scene/colliders.d.ts +58 -0
- package/dist/scene/colliders.js +108 -0
- package/dist/scene/collisionMesh.d.ts +94 -0
- package/dist/scene/collisionMesh.js +699 -0
- package/dist/scene/companion.d.ts +147 -0
- package/dist/scene/companion.js +193 -0
- package/dist/scene/entityStats.d.ts +7 -9
- package/dist/scene/entityStats.js +10 -14
- package/dist/scene/sceneKinds.d.ts +16 -0
- package/dist/scene/sceneKinds.js +4 -0
- package/dist/scene/sceneRaycast.js +15 -0
- package/dist/stats/spawnLevelStats.d.ts +110 -0
- package/dist/stats/spawnLevelStats.js +140 -0
- package/dist/stats/statPool.d.ts +70 -0
- package/dist/stats/statPool.js +59 -0
- package/dist/store/changeSignal.d.ts +3 -0
- package/dist/store/changeSignal.js +2 -0
- package/dist/store/observableKeyedStore.d.ts +2 -0
- package/dist/store/observableKeyedStore.js +1 -0
- package/dist/survival/decayMeter.js +1 -3
- package/dist/survival/regionHealth.js +1 -3
- package/dist/ui/hudLayout.d.ts +5 -0
- package/dist/ui/hudLayout.js +17 -1
- package/dist/world/authoredEntities.d.ts +29 -0
- package/dist/world/authoredEntities.js +29 -0
- package/dist/world/authoredObjects.d.ts +15 -1
- package/dist/world/authoredObjects.js +13 -1
- package/dist/world/authoredSpawn.d.ts +39 -0
- package/dist/world/authoredSpawn.js +37 -0
- package/dist/world/buildSockets.d.ts +152 -0
- package/dist/world/buildSockets.js +188 -0
- package/dist/world/cityBlocks.d.ts +152 -0
- package/dist/world/cityBlocks.js +670 -0
- package/dist/world/cityContent.d.ts +77 -0
- package/dist/world/cityContent.js +261 -0
- package/dist/world/cityGeometry.d.ts +114 -0
- package/dist/world/cityGeometry.js +573 -0
- package/dist/world/cityKind.d.ts +314 -0
- package/dist/world/cityKind.js +1578 -0
- package/dist/world/features.d.ts +18 -3
- package/dist/world/features.js +10 -1
- package/dist/world/grassKind.js +2 -2
- package/dist/world/markers.d.ts +50 -4
- package/dist/world/markers.js +49 -0
- package/dist/world/massing.js +1 -1
- package/dist/world/minimapBake.d.ts +78 -0
- package/dist/world/minimapBake.js +208 -0
- package/dist/world/minimapTrack.d.ts +17 -0
- package/dist/world/minimapTrack.js +22 -0
- package/dist/world/pathNetwork.d.ts +159 -0
- package/dist/world/pathNetwork.js +684 -0
- package/dist/world/place.d.ts +166 -0
- package/dist/world/place.js +96 -0
- package/dist/world/placeAsset.js +3 -1
- package/dist/world/poleLineKind.d.ts +40 -0
- package/dist/world/poleLineKind.js +97 -0
- package/dist/world/polyline.js +9 -2
- package/dist/world/resourceNode.d.ts +109 -0
- package/dist/world/resourceNode.js +140 -0
- package/dist/world/scatter.js +2 -20
- package/dist/world/streets.d.ts +29 -0
- package/dist/world/streets.js +55 -0
- package/dist/world/terraform.js +58 -88
- package/dist/world/terrain.d.ts +45 -0
- package/dist/world/terrain.js +78 -0
- package/dist/world.d.ts +14 -5
- package/dist/world.js +12 -3
- package/package.json +1 -1
- package/skills/game-design/SKILL.md +28 -0
- package/skills/game-design/agents/openai.yaml +4 -0
- package/skills/game-design/references/application-playbook.md +170 -0
- package/skills/game-design/references/field-guide.md +288 -0
- package/skills/jgengine/SKILL.md +7 -4
- package/skills/jgengine/api.md +172 -79
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-combat/SKILL.md +5 -1
- package/skills/jgengine-combat/api.md +36 -0
- package/skills/jgengine-combat/capabilities.md +44 -0
- package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
- package/skills/jgengine-combat/reference.md +12 -2
- package/skills/jgengine-gameplay/api.md +152 -26
- package/skills/jgengine-gameplay/capabilities.md +78 -2
- package/skills/jgengine-gameplay/reference-systems.md +1 -5
- package/skills/jgengine-world/SKILL.md +13 -0
- package/skills/jgengine-world/api.md +419 -15
- package/skills/jgengine-world/capabilities.md +108 -2
- package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
- package/skills/jgengine-world/reference.md +43 -4
- package/skills/jgengine/reference-cartridge.md +0 -72
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { applyEditorDocumentOverlay, createEmptyEditorDocument, editorDocumentExtras, extractEditorFragment, mergeEditorDocuments, } from "./document.js";
|
|
2
|
+
/**
|
|
3
|
+
* A sharded **world**: a scene that is no longer one monolithic `editor.scene.json` but a manifest
|
|
4
|
+
* plus spatially-partitioned shard documents. A single-file game is the degenerate one-shard case,
|
|
5
|
+
* so nothing pays for scale it does not have. The shard/overlay documents are the exact
|
|
6
|
+
* {@link EditorDocument} format loaders already consume — this module is composition over
|
|
7
|
+
* `mergeEditorDocuments` / `extractEditorFragment` / `editorDocumentBounds`, not a new format.
|
|
8
|
+
*/
|
|
9
|
+
/** The manifest `kind` discriminator — a world folder's `world.json`. */
|
|
10
|
+
export const WORLD_MANIFEST_KIND = "world";
|
|
11
|
+
/** True when a shard is resident regardless of any spatial query (unbounded or explicitly `"always"`). */
|
|
12
|
+
function isAlwaysResident(shard) {
|
|
13
|
+
return shard.residency === "always" || shard.bounds === undefined;
|
|
14
|
+
}
|
|
15
|
+
/** Closest-point disc↔AABB overlap test on the XZ plane. */
|
|
16
|
+
function discIntersectsBounds(cx, cz, radius, bounds) {
|
|
17
|
+
const qx = Math.max(bounds.min[0], Math.min(cx, bounds.max[0]));
|
|
18
|
+
const qz = Math.max(bounds.min[1], Math.min(cz, bounds.max[1]));
|
|
19
|
+
const dx = cx - qx;
|
|
20
|
+
const dz = cz - qz;
|
|
21
|
+
return dx * dx + dz * dz <= radius * radius;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* True when a shard should be resident for a query: always-resident shards always, bounded shards
|
|
25
|
+
* only when the query disc (`center` ± `radius`) overlaps their footprint.
|
|
26
|
+
* @capability world-shards select shards for a neighborhood by spatial query
|
|
27
|
+
*/
|
|
28
|
+
export function shardMatchesQuery(shard, query) {
|
|
29
|
+
if (isAlwaysResident(shard))
|
|
30
|
+
return true;
|
|
31
|
+
return discIntersectsBounds(query.center.x, query.center.z, query.radius ?? 0, shard.bounds);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The shards a query needs — always-resident shards plus every bounded shard whose footprint the
|
|
35
|
+
* query disc overlaps, in manifest order. With no query every shard is selected (load the planet),
|
|
36
|
+
* so a one-shard game and an unqueried load behave exactly as a monolithic document does today.
|
|
37
|
+
* @capability world-shards select shards for a neighborhood by spatial query
|
|
38
|
+
*/
|
|
39
|
+
export function selectWorldShards(manifest, query) {
|
|
40
|
+
if (query === undefined)
|
|
41
|
+
return [...manifest.shards];
|
|
42
|
+
return manifest.shards.filter((shard) => shardMatchesQuery(shard, query));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Loads the live neighborhood document for a query: reads each selected shard through `resolve`,
|
|
46
|
+
* applies its overlay (if any) on top, then merges them into one {@link EditorDocument} downstream
|
|
47
|
+
* consumers (`placeAuthoredObjects`, `collectAuthoredTriggers`, scatter, …) read unchanged. The
|
|
48
|
+
* monolith becomes an internal detail of the loader; a one-shard world yields exactly that shard.
|
|
49
|
+
* @capability world-shards merge the shards for a neighborhood into one live document
|
|
50
|
+
*/
|
|
51
|
+
export function loadWorldDocument(manifest, resolve, query) {
|
|
52
|
+
const shards = selectWorldShards(manifest, query);
|
|
53
|
+
const docs = [];
|
|
54
|
+
for (const shard of shards) {
|
|
55
|
+
const base = resolve(shard.file);
|
|
56
|
+
if (base === null)
|
|
57
|
+
continue;
|
|
58
|
+
if (shard.overlay === undefined) {
|
|
59
|
+
docs.push(base);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const overlay = resolve(shard.overlay);
|
|
63
|
+
docs.push(overlay === null ? base : applyEditorDocumentOverlay(base, overlay));
|
|
64
|
+
}
|
|
65
|
+
if (docs.length === 0)
|
|
66
|
+
return createEmptyEditorDocument();
|
|
67
|
+
return mergeEditorDocuments(...docs);
|
|
68
|
+
}
|
|
69
|
+
/** The always-resident base shard id used by {@link splitEditorDocumentIntoShards}. */
|
|
70
|
+
export const WORLD_BASE_SHARD_ID = "base";
|
|
71
|
+
/** Representative XZ point for an object, used to assign it to a grid cell. */
|
|
72
|
+
function objectCell(x, z, cellSize) {
|
|
73
|
+
return { gx: Math.floor(x / cellSize), gz: Math.floor(z / cellSize) };
|
|
74
|
+
}
|
|
75
|
+
function pathCentroid(points) {
|
|
76
|
+
if (points.length === 0)
|
|
77
|
+
return { x: 0, z: 0 };
|
|
78
|
+
let x = 0;
|
|
79
|
+
let z = 0;
|
|
80
|
+
for (const point of points) {
|
|
81
|
+
x += point.x;
|
|
82
|
+
z += point.z;
|
|
83
|
+
}
|
|
84
|
+
return { x: x / points.length, z: z / points.length };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Splits a monolithic document into a sharded world: placeable objects (markers, volumes, paths,
|
|
88
|
+
* notes) are bucketed into `cellSize`-meter grid cells by a representative point and extracted into
|
|
89
|
+
* per-cell shard documents; the non-placeable extras (terrain, catalogs, prefabs, collections,
|
|
90
|
+
* grids, HUD layout) go into one always-resident `base` shard. Round-trips through
|
|
91
|
+
* {@link loadWorldDocument} to the same object set — the reverse of monolith authoring, so a large
|
|
92
|
+
* scene can be sharded once and streamed thereafter. Cell shards carry their exact object footprint
|
|
93
|
+
* as bounds; empty cells are omitted; order is deterministic (row-major by grid coordinate).
|
|
94
|
+
* @capability world-shards split a monolithic scene document into spatial shards
|
|
95
|
+
*/
|
|
96
|
+
export function splitEditorDocumentIntoShards(doc, options = {}) {
|
|
97
|
+
const cellSize = options.cellSize !== undefined && options.cellSize > 0 ? options.cellSize : 256;
|
|
98
|
+
const shardDir = options.shardDir ?? "shards";
|
|
99
|
+
// Bucket every placeable object id by the grid cell containing its representative point.
|
|
100
|
+
const cellIds = new Map();
|
|
101
|
+
const addToCell = (gx, gz, id) => {
|
|
102
|
+
const key = `${gx}:${gz}`;
|
|
103
|
+
const bucket = cellIds.get(key);
|
|
104
|
+
if (bucket === undefined)
|
|
105
|
+
cellIds.set(key, [id]);
|
|
106
|
+
else
|
|
107
|
+
bucket.push(id);
|
|
108
|
+
};
|
|
109
|
+
for (const marker of doc.markers) {
|
|
110
|
+
const { gx, gz } = objectCell(marker.position.x, marker.position.z, cellSize);
|
|
111
|
+
addToCell(gx, gz, marker.id);
|
|
112
|
+
}
|
|
113
|
+
for (const volume of doc.volumes) {
|
|
114
|
+
const { gx, gz } = objectCell(volume.center.x, volume.center.z, cellSize);
|
|
115
|
+
addToCell(gx, gz, volume.id);
|
|
116
|
+
}
|
|
117
|
+
for (const path of doc.paths) {
|
|
118
|
+
const c = pathCentroid(path.points);
|
|
119
|
+
const { gx, gz } = objectCell(c.x, c.z, cellSize);
|
|
120
|
+
addToCell(gx, gz, path.id);
|
|
121
|
+
}
|
|
122
|
+
for (const note of doc.annotations) {
|
|
123
|
+
const { gx, gz } = objectCell(note.position.x, note.position.z, cellSize);
|
|
124
|
+
addToCell(gx, gz, note.id);
|
|
125
|
+
}
|
|
126
|
+
// Always-resident base shard carries the non-placeable extras verbatim.
|
|
127
|
+
const baseFile = `${shardDir}/${WORLD_BASE_SHARD_ID}.json`;
|
|
128
|
+
const baseDoc = {
|
|
129
|
+
...createEmptyEditorDocument(),
|
|
130
|
+
...editorDocumentExtras(doc),
|
|
131
|
+
};
|
|
132
|
+
const shards = { [baseFile]: baseDoc };
|
|
133
|
+
const manifestShards = [
|
|
134
|
+
{ id: WORLD_BASE_SHARD_ID, file: baseFile, residency: "always" },
|
|
135
|
+
];
|
|
136
|
+
const sortedKeys = [...cellIds.keys()].sort((a, b) => {
|
|
137
|
+
const [ax, az] = a.split(":").map(Number);
|
|
138
|
+
const [bx, bz] = b.split(":").map(Number);
|
|
139
|
+
return az === bz ? ax - bx : az - bz;
|
|
140
|
+
});
|
|
141
|
+
for (const key of sortedKeys) {
|
|
142
|
+
const [gx, gz] = key.split(":").map(Number);
|
|
143
|
+
const ids = cellIds.get(key);
|
|
144
|
+
const shardDoc = extractEditorFragment(doc, ids);
|
|
145
|
+
const id = `cell_${gx}_${gz}`;
|
|
146
|
+
const file = `${shardDir}/${id}.json`;
|
|
147
|
+
shards[file] = shardDoc;
|
|
148
|
+
manifestShards.push({
|
|
149
|
+
id,
|
|
150
|
+
file,
|
|
151
|
+
residency: "streamed",
|
|
152
|
+
bounds: {
|
|
153
|
+
min: [gx * cellSize, gz * cellSize],
|
|
154
|
+
max: [(gx + 1) * cellSize, (gz + 1) * cellSize],
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
manifest: {
|
|
160
|
+
kind: WORLD_MANIFEST_KIND,
|
|
161
|
+
version: 1,
|
|
162
|
+
grid: { cellSize },
|
|
163
|
+
shards: manifestShards,
|
|
164
|
+
},
|
|
165
|
+
shards,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The backward-compatible degenerate manifest: one always-resident shard pointing at a single scene
|
|
170
|
+
* document file. `loadWorldDocument(singleShardWorldManifest(file), resolve)` returns exactly that
|
|
171
|
+
* document — the migration path for a game that has one `editor.scene.json` and wants the world API
|
|
172
|
+
* without sharding yet.
|
|
173
|
+
* @capability world-shards wrap a single scene document as a one-shard world
|
|
174
|
+
*/
|
|
175
|
+
export function singleShardWorldManifest(file, shardId = WORLD_BASE_SHARD_ID) {
|
|
176
|
+
return {
|
|
177
|
+
kind: WORLD_MANIFEST_KIND,
|
|
178
|
+
version: 1,
|
|
179
|
+
shards: [{ id: shardId, file, residency: "always" }],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function isPlainObject(value) {
|
|
183
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
184
|
+
}
|
|
185
|
+
function decodeBounds(value, path, errors) {
|
|
186
|
+
if (value === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
if (!isPlainObject(value)) {
|
|
189
|
+
errors.push({ path, message: "expected an object" });
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const pair = (v, p) => {
|
|
193
|
+
if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== "number" || typeof v[1] !== "number") {
|
|
194
|
+
errors.push({ path: p, message: "expected [number, number]" });
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return [v[0], v[1]];
|
|
198
|
+
};
|
|
199
|
+
const min = pair(value.min, `${path}.min`);
|
|
200
|
+
const max = pair(value.max, `${path}.max`);
|
|
201
|
+
if (min === null || max === null)
|
|
202
|
+
return undefined;
|
|
203
|
+
return { min, max };
|
|
204
|
+
}
|
|
205
|
+
function decodeShard(value, path, errors) {
|
|
206
|
+
if (!isPlainObject(value)) {
|
|
207
|
+
errors.push({ path, message: "expected an object" });
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
if (typeof value.id !== "string")
|
|
211
|
+
errors.push({ path: `${path}.id`, message: "expected a string" });
|
|
212
|
+
if (typeof value.file !== "string")
|
|
213
|
+
errors.push({ path: `${path}.file`, message: "expected a string" });
|
|
214
|
+
const bounds = decodeBounds(value.bounds, `${path}.bounds`, errors);
|
|
215
|
+
let residency;
|
|
216
|
+
if (value.residency !== undefined) {
|
|
217
|
+
if (value.residency === "always" || value.residency === "streamed")
|
|
218
|
+
residency = value.residency;
|
|
219
|
+
else
|
|
220
|
+
errors.push({ path: `${path}.residency`, message: "expected \"always\" | \"streamed\"" });
|
|
221
|
+
}
|
|
222
|
+
if (value.overlay !== undefined && typeof value.overlay !== "string") {
|
|
223
|
+
errors.push({ path: `${path}.overlay`, message: "expected a string" });
|
|
224
|
+
}
|
|
225
|
+
if (typeof value.id !== "string" || typeof value.file !== "string")
|
|
226
|
+
return null;
|
|
227
|
+
const shard = { id: value.id, file: value.file };
|
|
228
|
+
if (bounds !== undefined)
|
|
229
|
+
shard.bounds = bounds;
|
|
230
|
+
if (residency !== undefined)
|
|
231
|
+
shard.residency = residency;
|
|
232
|
+
if (typeof value.overlay === "string")
|
|
233
|
+
shard.overlay = value.overlay;
|
|
234
|
+
return shard;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The authoritative decoder for a `world.json` manifest arriving from disk or an agent: validates
|
|
238
|
+
* `kind`, the shard list, and each shard's fields with a path-specific diagnostic on failure, so a
|
|
239
|
+
* malformed manifest fails loudly at the boundary instead of streaming a broken world.
|
|
240
|
+
* @capability world-shards decode an untrusted world manifest with per-field diagnostics
|
|
241
|
+
*/
|
|
242
|
+
export function decodeWorldManifest(raw) {
|
|
243
|
+
if (!isPlainObject(raw)) {
|
|
244
|
+
return { ok: false, errors: [{ path: "$", message: "world manifest must be an object" }] };
|
|
245
|
+
}
|
|
246
|
+
const errors = [];
|
|
247
|
+
if (raw.kind !== WORLD_MANIFEST_KIND) {
|
|
248
|
+
errors.push({ path: "$.kind", message: `expected "${WORLD_MANIFEST_KIND}"` });
|
|
249
|
+
}
|
|
250
|
+
if (raw.version !== undefined && typeof raw.version !== "number") {
|
|
251
|
+
errors.push({ path: "$.version", message: "expected a number" });
|
|
252
|
+
}
|
|
253
|
+
let grid;
|
|
254
|
+
if (raw.grid !== undefined) {
|
|
255
|
+
if (isPlainObject(raw.grid) && typeof raw.grid.cellSize === "number")
|
|
256
|
+
grid = { cellSize: raw.grid.cellSize };
|
|
257
|
+
else
|
|
258
|
+
errors.push({ path: "$.grid", message: "expected { cellSize: number }" });
|
|
259
|
+
}
|
|
260
|
+
let streaming;
|
|
261
|
+
if (raw.streaming !== undefined) {
|
|
262
|
+
if (isPlainObject(raw.streaming) &&
|
|
263
|
+
typeof raw.streaming.loadRadius === "number" &&
|
|
264
|
+
typeof raw.streaming.keepRadius === "number") {
|
|
265
|
+
streaming = { loadRadius: raw.streaming.loadRadius, keepRadius: raw.streaming.keepRadius };
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
errors.push({ path: "$.streaming", message: "expected { loadRadius: number, keepRadius: number }" });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const shards = [];
|
|
272
|
+
if (!Array.isArray(raw.shards)) {
|
|
273
|
+
errors.push({ path: "$.shards", message: "expected an array" });
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
raw.shards.forEach((item, index) => {
|
|
277
|
+
const shard = decodeShard(item, `$.shards[${index}]`, errors);
|
|
278
|
+
if (shard !== null)
|
|
279
|
+
shards.push(shard);
|
|
280
|
+
});
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
shards.forEach((shard, index) => {
|
|
283
|
+
if (seen.has(shard.id))
|
|
284
|
+
errors.push({ path: `$.shards[${index}].id`, message: `duplicate shard id "${shard.id}"` });
|
|
285
|
+
else
|
|
286
|
+
seen.add(shard.id);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (errors.length > 0)
|
|
290
|
+
return { ok: false, errors };
|
|
291
|
+
const manifest = { kind: WORLD_MANIFEST_KIND, shards };
|
|
292
|
+
if (typeof raw.version === "number")
|
|
293
|
+
manifest.version = raw.version;
|
|
294
|
+
if (grid !== undefined)
|
|
295
|
+
manifest.grid = grid;
|
|
296
|
+
if (streaming !== undefined)
|
|
297
|
+
manifest.streaming = streaming;
|
|
298
|
+
return { ok: true, manifest };
|
|
299
|
+
}
|
|
300
|
+
/** Serializes a world manifest to pretty JSON for writing `world.json`. */
|
|
301
|
+
export function exportWorldManifestJson(manifest, pretty = true) {
|
|
302
|
+
return JSON.stringify(manifest, null, pretty ? 2 : undefined);
|
|
303
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic creature breeding & genetics — a genre-agnostic engine primitive for
|
|
3
|
+
* taming/husbandry games. Everything here operates over plain serializable records so a
|
|
4
|
+
* genome round-trips through save/load and multiplayer sync, and every random decision is
|
|
5
|
+
* driven by an injected `rng: () => number` (values in `[0, 1)`) so identical inputs and an
|
|
6
|
+
* identical rng stream always produce the identical offspring.
|
|
7
|
+
*
|
|
8
|
+
* The lifecycle it models:
|
|
9
|
+
* - **inheritance** — each stat is drawn independently from the higher or lower parent,
|
|
10
|
+
* - **mutation** — a bounded number of rolls that boost a stat, bump a heritable lineage
|
|
11
|
+
* counter, and optionally flag a cosmetic/color mutation, gated by a soft cap,
|
|
12
|
+
* - **imprint** — a rearing bonus accrued by fulfilling care requests over maturation,
|
|
13
|
+
* - **incubation & maturation** — temperature-gated gestation viability and stage resolution.
|
|
14
|
+
*
|
|
15
|
+
* No species tables, coordinates, or game content live here; callers supply their own stat
|
|
16
|
+
* ids and tuning.
|
|
17
|
+
*/
|
|
18
|
+
/** A stat block: stat id → numeric point value. Plain serializable record, no ordering assumed. */
|
|
19
|
+
export type StatBlock = Record<string, number>;
|
|
20
|
+
/**
|
|
21
|
+
* A creature's heritable genome. `stats` are the inherited/mutated point values;
|
|
22
|
+
* `mutationCount` and `colorMutationCount` are the lineage counters that carry across
|
|
23
|
+
* generations and drive the mutation soft cap. Fully serializable — no methods or closures.
|
|
24
|
+
*/
|
|
25
|
+
export interface Genome {
|
|
26
|
+
stats: StatBlock;
|
|
27
|
+
/** Accumulated point mutations in this creature's lineage. Heritable, monotonic. */
|
|
28
|
+
mutationCount: number;
|
|
29
|
+
/** Accumulated cosmetic/color mutations in this creature's lineage. Heritable, monotonic. */
|
|
30
|
+
colorMutationCount: number;
|
|
31
|
+
}
|
|
32
|
+
/** One mutation that occurred while breeding a single offspring. */
|
|
33
|
+
export interface MutationEvent {
|
|
34
|
+
/** Stat id that received the point delta. */
|
|
35
|
+
stat: string;
|
|
36
|
+
/** Points added to that stat (the configured `mutationDelta`). */
|
|
37
|
+
delta: number;
|
|
38
|
+
/** True when this mutation was also flagged cosmetic/color. */
|
|
39
|
+
color: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Soft-cap rule for mutation eligibility. When a parent's `mutationCount` exceeds
|
|
43
|
+
* `threshold` its lineage is considered "capped"; the per-roll mutation chance is then
|
|
44
|
+
* either zeroed (`mode: "block"`) or scaled by `reducedFactor` (`mode: "reduce"`).
|
|
45
|
+
* `scope` decides whether it takes `both` parents capped, or `either` one, to trigger.
|
|
46
|
+
*/
|
|
47
|
+
export interface MutationSoftCap {
|
|
48
|
+
/** Counter value a parent must exceed to count as capped. Default `20`. */
|
|
49
|
+
threshold?: number;
|
|
50
|
+
/** Trigger when `both` parents are capped (default) or `either` one is. */
|
|
51
|
+
scope?: "both" | "either";
|
|
52
|
+
/** Zero the chance (`block`, default) or scale it by `reducedFactor` (`reduce`). */
|
|
53
|
+
mode?: "block" | "reduce";
|
|
54
|
+
/** Multiplier applied to the base chance when `mode: "reduce"` and capped. Default `0.5`. */
|
|
55
|
+
reducedFactor?: number;
|
|
56
|
+
}
|
|
57
|
+
/** Tuning for {@link breedOffspring}. Every field has an ARK-style default; all are optional. */
|
|
58
|
+
export interface BreedConfig {
|
|
59
|
+
/** Per-stat probability the offspring inherits the higher parent's value. Default `0.55`. */
|
|
60
|
+
higherStatChance?: number;
|
|
61
|
+
/** Number of independent mutation rolls per offspring. Default `3`. */
|
|
62
|
+
mutationRolls?: number;
|
|
63
|
+
/** Probability each mutation roll succeeds (before the soft cap). Default `0.025`. */
|
|
64
|
+
mutationChance?: number;
|
|
65
|
+
/** Points added to a random stat by a successful mutation. Default `2`. */
|
|
66
|
+
mutationDelta?: number;
|
|
67
|
+
/** Probability a successful mutation is additionally flagged cosmetic. Default `0.5`. */
|
|
68
|
+
colorMutationChance?: number;
|
|
69
|
+
/** Mutation-count soft cap rule. Defaults to `{ threshold: 20, scope: "both", mode: "block" }`. */
|
|
70
|
+
mutationSoftCap?: MutationSoftCap;
|
|
71
|
+
}
|
|
72
|
+
/** Result of {@link breedOffspring}: the new genome plus the mutations that produced it. */
|
|
73
|
+
export interface BreedResult {
|
|
74
|
+
genome: Genome;
|
|
75
|
+
mutations: readonly MutationEvent[];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Breed one offspring from two parents. For each stat present in either parent (iterated in
|
|
79
|
+
* sorted id order for a stable rng stream) the offspring independently inherits the higher
|
|
80
|
+
* parent's value with probability `higherStatChance` (default `0.55`), else the lower — a
|
|
81
|
+
* per-stat coin flip, so a strong parent does not guarantee a strong child. Then up to
|
|
82
|
+
* `mutationRolls` mutation rolls fire at the soft-capped chance; each success adds
|
|
83
|
+
* `mutationDelta` to a random stat, bumps the heritable `mutationCount`, and is flagged
|
|
84
|
+
* cosmetic with probability `colorMutationChance`. Lineage counters carry as the max of the
|
|
85
|
+
* two parents plus the new mutations, keeping them monotonic and heritable.
|
|
86
|
+
*
|
|
87
|
+
* Deterministic: the same parents, config, and rng sequence yield byte-identical results.
|
|
88
|
+
*
|
|
89
|
+
* @capability creature-breeding deterministic stat inheritance, mutation, and lineage genetics for taming games
|
|
90
|
+
*/
|
|
91
|
+
export declare function breedOffspring(parentA: Genome, parentB: Genome, rng: () => number, config?: BreedConfig): BreedResult;
|
|
92
|
+
/** Options controlling how an imprint bonus is applied. */
|
|
93
|
+
export interface ImprintBonusConfig {
|
|
94
|
+
/** Stat multiplier at full imprint, applied to everyone. Default `0.20` (+20%). */
|
|
95
|
+
statBonusAtFull?: number;
|
|
96
|
+
/** Extra multiplier at full imprint, applied only for the imprinting owner. Default `0.30` (+30%). */
|
|
97
|
+
handlerBonusAtFull?: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Imprint gained per fulfilled care request when `requestCount` requests are scheduled over
|
|
101
|
+
* the maturation window: `1 / requestCount`, so fulfilling every request reaches full (1.0)
|
|
102
|
+
* imprint. Returns `0` for a non-positive request count.
|
|
103
|
+
*
|
|
104
|
+
* @capability imprint-increment per-care-request imprint gain across a maturation schedule
|
|
105
|
+
*/
|
|
106
|
+
export declare function imprintIncrementPerRequest(requestCount: number): number;
|
|
107
|
+
/**
|
|
108
|
+
* Apply the imprint rearing bonus to a stat block. Every stat is scaled by
|
|
109
|
+
* `1 + statBonusAtFull * imprint`; when `asOwner` is set, the imprinting handler additionally
|
|
110
|
+
* gets `1 + handlerBonusAtFull * imprint`. `imprint` is a 0..1 fraction (clamped) and scales
|
|
111
|
+
* both bonuses linearly, so half-imprint yields half the bonus. Returns a fresh stat block.
|
|
112
|
+
*
|
|
113
|
+
* @capability imprint-bonus apply imprint stat and handler bonuses at an imprint fraction
|
|
114
|
+
*/
|
|
115
|
+
export declare function applyImprintBonus(stats: StatBlock, imprint: number, config?: ImprintBonusConfig, options?: {
|
|
116
|
+
asOwner?: boolean;
|
|
117
|
+
}): StatBlock;
|
|
118
|
+
/** Serializable incubation/gestation state advanced by {@link tickIncubation}. */
|
|
119
|
+
export interface IncubationState {
|
|
120
|
+
/** Remaining egg/embryo health; incubation fails at `0`. */
|
|
121
|
+
health: number;
|
|
122
|
+
/** Accumulated in-range incubation time; only advances while temperature is viable. */
|
|
123
|
+
elapsed: number;
|
|
124
|
+
}
|
|
125
|
+
/** Temperature gate for {@link tickIncubation}. */
|
|
126
|
+
export interface IncubationConfig {
|
|
127
|
+
minTemp: number;
|
|
128
|
+
maxTemp: number;
|
|
129
|
+
/** Health lost per unit `dt` while temperature is outside `[minTemp, maxTemp]`. Default `1`. */
|
|
130
|
+
healthLossPerTick?: number;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Advance incubation one tick. While `temperature` is within `[minTemp, maxTemp]` the embryo
|
|
134
|
+
* makes progress (`elapsed += dt`) and keeps its health; outside the range it makes no
|
|
135
|
+
* progress and loses `healthLossPerTick * dt` health (clamped at `0`). Pure — returns a new
|
|
136
|
+
* state, never mutates the input.
|
|
137
|
+
*
|
|
138
|
+
* @capability incubation-tick advance egg incubation health inside/outside its temperature range
|
|
139
|
+
*/
|
|
140
|
+
export declare function tickIncubation(state: IncubationState, temperature: number, dt: number, config: IncubationConfig): IncubationState;
|
|
141
|
+
/**
|
|
142
|
+
* True while the incubation still has health to hatch.
|
|
143
|
+
*
|
|
144
|
+
* @capability incubation-viability test whether an egg incubation still has health to hatch
|
|
145
|
+
*/
|
|
146
|
+
export declare function incubationViable(state: IncubationState): boolean;
|
|
147
|
+
/** A maturation stage keyed by the elapsed fraction (0..1) at which it begins. */
|
|
148
|
+
export interface MaturationStage {
|
|
149
|
+
id: string;
|
|
150
|
+
/** Fraction of the maturation duration at which this stage starts (inclusive). */
|
|
151
|
+
at: number;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the maturation stage for an elapsed time against a duration. Computes the clamped
|
|
155
|
+
* `elapsed / duration` fraction and returns the id of the last stage whose `at` threshold has
|
|
156
|
+
* been reached (boundaries inclusive). Stages need not be pre-sorted. Returns `""` when no
|
|
157
|
+
* stage qualifies (e.g. an empty list or all thresholds above the current fraction).
|
|
158
|
+
*
|
|
159
|
+
* @capability maturation-stage resolve the baby/juvenile/adolescent/adult stage from elapsed maturation
|
|
160
|
+
*/
|
|
161
|
+
export declare function maturationStage(elapsed: number, duration: number, stages: readonly MaturationStage[]): string;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic creature breeding & genetics — a genre-agnostic engine primitive for
|
|
3
|
+
* taming/husbandry games. Everything here operates over plain serializable records so a
|
|
4
|
+
* genome round-trips through save/load and multiplayer sync, and every random decision is
|
|
5
|
+
* driven by an injected `rng: () => number` (values in `[0, 1)`) so identical inputs and an
|
|
6
|
+
* identical rng stream always produce the identical offspring.
|
|
7
|
+
*
|
|
8
|
+
* The lifecycle it models:
|
|
9
|
+
* - **inheritance** — each stat is drawn independently from the higher or lower parent,
|
|
10
|
+
* - **mutation** — a bounded number of rolls that boost a stat, bump a heritable lineage
|
|
11
|
+
* counter, and optionally flag a cosmetic/color mutation, gated by a soft cap,
|
|
12
|
+
* - **imprint** — a rearing bonus accrued by fulfilling care requests over maturation,
|
|
13
|
+
* - **incubation & maturation** — temperature-gated gestation viability and stage resolution.
|
|
14
|
+
*
|
|
15
|
+
* No species tables, coordinates, or game content live here; callers supply their own stat
|
|
16
|
+
* ids and tuning.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_HIGHER_STAT_CHANCE = 0.55;
|
|
19
|
+
const DEFAULT_MUTATION_ROLLS = 3;
|
|
20
|
+
const DEFAULT_MUTATION_CHANCE = 0.025;
|
|
21
|
+
const DEFAULT_MUTATION_DELTA = 2;
|
|
22
|
+
const DEFAULT_COLOR_MUTATION_CHANCE = 0.5;
|
|
23
|
+
const DEFAULT_SOFT_CAP_THRESHOLD = 20;
|
|
24
|
+
const DEFAULT_REDUCED_FACTOR = 0.5;
|
|
25
|
+
function clamp01(x) {
|
|
26
|
+
return x < 0 ? 0 : x > 1 ? 1 : x;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Per-roll mutation chance after applying the soft cap to both parents. Exposed so callers
|
|
30
|
+
* and tests can inspect the exact gating rule without breeding.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
function effectiveMutationChance(a, b, config) {
|
|
35
|
+
const base = config.mutationChance ?? DEFAULT_MUTATION_CHANCE;
|
|
36
|
+
const cap = config.mutationSoftCap;
|
|
37
|
+
const threshold = cap?.threshold ?? DEFAULT_SOFT_CAP_THRESHOLD;
|
|
38
|
+
const scope = cap?.scope ?? "both";
|
|
39
|
+
const mode = cap?.mode ?? "block";
|
|
40
|
+
const aOver = a.mutationCount > threshold;
|
|
41
|
+
const bOver = b.mutationCount > threshold;
|
|
42
|
+
const capped = scope === "both" ? aOver && bOver : aOver || bOver;
|
|
43
|
+
if (!capped)
|
|
44
|
+
return base;
|
|
45
|
+
return mode === "reduce" ? base * (cap?.reducedFactor ?? DEFAULT_REDUCED_FACTOR) : 0;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Breed one offspring from two parents. For each stat present in either parent (iterated in
|
|
49
|
+
* sorted id order for a stable rng stream) the offspring independently inherits the higher
|
|
50
|
+
* parent's value with probability `higherStatChance` (default `0.55`), else the lower — a
|
|
51
|
+
* per-stat coin flip, so a strong parent does not guarantee a strong child. Then up to
|
|
52
|
+
* `mutationRolls` mutation rolls fire at the soft-capped chance; each success adds
|
|
53
|
+
* `mutationDelta` to a random stat, bumps the heritable `mutationCount`, and is flagged
|
|
54
|
+
* cosmetic with probability `colorMutationChance`. Lineage counters carry as the max of the
|
|
55
|
+
* two parents plus the new mutations, keeping them monotonic and heritable.
|
|
56
|
+
*
|
|
57
|
+
* Deterministic: the same parents, config, and rng sequence yield byte-identical results.
|
|
58
|
+
*
|
|
59
|
+
* @capability creature-breeding deterministic stat inheritance, mutation, and lineage genetics for taming games
|
|
60
|
+
*/
|
|
61
|
+
export function breedOffspring(parentA, parentB, rng, config = {}) {
|
|
62
|
+
const higherChance = config.higherStatChance ?? DEFAULT_HIGHER_STAT_CHANCE;
|
|
63
|
+
const rolls = Math.max(0, Math.floor(config.mutationRolls ?? DEFAULT_MUTATION_ROLLS));
|
|
64
|
+
const delta = config.mutationDelta ?? DEFAULT_MUTATION_DELTA;
|
|
65
|
+
const colorChance = config.colorMutationChance ?? DEFAULT_COLOR_MUTATION_CHANCE;
|
|
66
|
+
// Stable union of stat ids so the rng stream does not depend on object key order.
|
|
67
|
+
const statIds = Array.from(new Set([...Object.keys(parentA.stats), ...Object.keys(parentB.stats)])).sort();
|
|
68
|
+
const stats = {};
|
|
69
|
+
for (const id of statIds) {
|
|
70
|
+
const av = parentA.stats[id];
|
|
71
|
+
const bv = parentB.stats[id];
|
|
72
|
+
// A stat missing on one side behaves as if both parents shared the present value:
|
|
73
|
+
// one rng draw is still consumed so adding a stat to a parent cannot shift the stream.
|
|
74
|
+
const present = av ?? bv ?? 0;
|
|
75
|
+
const high = av === undefined || bv === undefined ? present : Math.max(av, bv);
|
|
76
|
+
const low = av === undefined || bv === undefined ? present : Math.min(av, bv);
|
|
77
|
+
stats[id] = rng() < higherChance ? high : low;
|
|
78
|
+
}
|
|
79
|
+
const chance = effectiveMutationChance(parentA, parentB, config);
|
|
80
|
+
const mutations = [];
|
|
81
|
+
let newMutations = 0;
|
|
82
|
+
let newColor = 0;
|
|
83
|
+
for (let i = 0; i < rolls; i++) {
|
|
84
|
+
if (rng() >= chance)
|
|
85
|
+
continue;
|
|
86
|
+
newMutations += 1;
|
|
87
|
+
let stat = "";
|
|
88
|
+
if (statIds.length > 0) {
|
|
89
|
+
const index = Math.min(statIds.length - 1, Math.floor(rng() * statIds.length));
|
|
90
|
+
stat = statIds[index];
|
|
91
|
+
stats[stat] = (stats[stat] ?? 0) + delta;
|
|
92
|
+
}
|
|
93
|
+
const color = rng() < colorChance;
|
|
94
|
+
if (color)
|
|
95
|
+
newColor += 1;
|
|
96
|
+
mutations.push({ stat, delta: stat === "" ? 0 : delta, color });
|
|
97
|
+
}
|
|
98
|
+
const genome = {
|
|
99
|
+
stats,
|
|
100
|
+
mutationCount: Math.max(parentA.mutationCount, parentB.mutationCount) + newMutations,
|
|
101
|
+
colorMutationCount: Math.max(parentA.colorMutationCount, parentB.colorMutationCount) + newColor,
|
|
102
|
+
};
|
|
103
|
+
return { genome, mutations };
|
|
104
|
+
}
|
|
105
|
+
const DEFAULT_STAT_BONUS = 0.2;
|
|
106
|
+
const DEFAULT_HANDLER_BONUS = 0.3;
|
|
107
|
+
/**
|
|
108
|
+
* Imprint gained per fulfilled care request when `requestCount` requests are scheduled over
|
|
109
|
+
* the maturation window: `1 / requestCount`, so fulfilling every request reaches full (1.0)
|
|
110
|
+
* imprint. Returns `0` for a non-positive request count.
|
|
111
|
+
*
|
|
112
|
+
* @capability imprint-increment per-care-request imprint gain across a maturation schedule
|
|
113
|
+
*/
|
|
114
|
+
export function imprintIncrementPerRequest(requestCount) {
|
|
115
|
+
return requestCount > 0 ? 1 / requestCount : 0;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Apply the imprint rearing bonus to a stat block. Every stat is scaled by
|
|
119
|
+
* `1 + statBonusAtFull * imprint`; when `asOwner` is set, the imprinting handler additionally
|
|
120
|
+
* gets `1 + handlerBonusAtFull * imprint`. `imprint` is a 0..1 fraction (clamped) and scales
|
|
121
|
+
* both bonuses linearly, so half-imprint yields half the bonus. Returns a fresh stat block.
|
|
122
|
+
*
|
|
123
|
+
* @capability imprint-bonus apply imprint stat and handler bonuses at an imprint fraction
|
|
124
|
+
*/
|
|
125
|
+
export function applyImprintBonus(stats, imprint, config = {}, options = {}) {
|
|
126
|
+
const fraction = clamp01(imprint);
|
|
127
|
+
const statBonus = config.statBonusAtFull ?? DEFAULT_STAT_BONUS;
|
|
128
|
+
const handlerBonus = config.handlerBonusAtFull ?? DEFAULT_HANDLER_BONUS;
|
|
129
|
+
let multiplier = 1 + statBonus * fraction;
|
|
130
|
+
if (options.asOwner)
|
|
131
|
+
multiplier *= 1 + handlerBonus * fraction;
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const id of Object.keys(stats))
|
|
134
|
+
out[id] = stats[id] * multiplier;
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Advance incubation one tick. While `temperature` is within `[minTemp, maxTemp]` the embryo
|
|
139
|
+
* makes progress (`elapsed += dt`) and keeps its health; outside the range it makes no
|
|
140
|
+
* progress and loses `healthLossPerTick * dt` health (clamped at `0`). Pure — returns a new
|
|
141
|
+
* state, never mutates the input.
|
|
142
|
+
*
|
|
143
|
+
* @capability incubation-tick advance egg incubation health inside/outside its temperature range
|
|
144
|
+
*/
|
|
145
|
+
export function tickIncubation(state, temperature, dt, config) {
|
|
146
|
+
if (dt <= 0)
|
|
147
|
+
return { health: state.health, elapsed: state.elapsed };
|
|
148
|
+
const inRange = temperature >= config.minTemp && temperature <= config.maxTemp;
|
|
149
|
+
if (inRange)
|
|
150
|
+
return { health: state.health, elapsed: state.elapsed + dt };
|
|
151
|
+
const loss = (config.healthLossPerTick ?? 1) * dt;
|
|
152
|
+
return { health: Math.max(0, state.health - loss), elapsed: state.elapsed };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* True while the incubation still has health to hatch.
|
|
156
|
+
*
|
|
157
|
+
* @capability incubation-viability test whether an egg incubation still has health to hatch
|
|
158
|
+
*/
|
|
159
|
+
export function incubationViable(state) {
|
|
160
|
+
return state.health > 0;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the maturation stage for an elapsed time against a duration. Computes the clamped
|
|
164
|
+
* `elapsed / duration` fraction and returns the id of the last stage whose `at` threshold has
|
|
165
|
+
* been reached (boundaries inclusive). Stages need not be pre-sorted. Returns `""` when no
|
|
166
|
+
* stage qualifies (e.g. an empty list or all thresholds above the current fraction).
|
|
167
|
+
*
|
|
168
|
+
* @capability maturation-stage resolve the baby/juvenile/adolescent/adult stage from elapsed maturation
|
|
169
|
+
*/
|
|
170
|
+
export function maturationStage(elapsed, duration, stages) {
|
|
171
|
+
const fraction = duration > 0 ? clamp01(elapsed / duration) : 1;
|
|
172
|
+
let bestId = "";
|
|
173
|
+
let bestAt = -Infinity;
|
|
174
|
+
for (const stage of stages) {
|
|
175
|
+
if (stage.at <= fraction && stage.at >= bestAt) {
|
|
176
|
+
bestAt = stage.at;
|
|
177
|
+
bestId = stage.id;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return bestId;
|
|
181
|
+
}
|