@jgengine/core 0.8.0 → 0.10.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 +36 -0
- package/dist/ai/flock.d.ts +36 -0
- package/dist/ai/flock.js +182 -0
- package/dist/ai/groupAssist.d.ts +19 -0
- package/dist/ai/groupAssist.js +59 -0
- package/dist/ai/laneSelect.d.ts +21 -0
- package/dist/ai/laneSelect.js +37 -0
- package/dist/ai/mobBrain.d.ts +68 -0
- package/dist/ai/mobBrain.js +115 -0
- package/dist/ai/spawnDirector.d.ts +13 -0
- package/dist/ai/spawnDirector.js +41 -17
- package/dist/ai/spawnPoint.d.ts +5 -0
- package/dist/ai/spawnPoint.js +5 -0
- package/dist/ai/threat.d.ts +2 -0
- package/dist/ai/threat.js +34 -0
- package/dist/audio/audioFalloff.d.ts +5 -2
- package/dist/audio/music.d.ts +53 -0
- package/dist/audio/music.js +34 -0
- package/dist/audio/synth.d.ts +60 -0
- package/dist/audio/synth.js +8 -0
- package/dist/authoring.d.ts +14 -0
- package/dist/authoring.js +14 -0
- package/dist/cartridge/runtime.d.ts +15 -0
- package/dist/cartridge/runtime.js +556 -0
- package/dist/cartridge/spec.d.ts +188 -0
- package/dist/cartridge/spec.js +19 -0
- package/dist/cartridge/validate.d.ts +2 -0
- package/dist/cartridge/validate.js +78 -0
- package/dist/combat/abilityKit.d.ts +17 -2
- package/dist/combat/abilityKit.js +62 -13
- package/dist/combat/castRunner.d.ts +40 -0
- package/dist/combat/castRunner.js +56 -0
- package/dist/combat/comboPoints.d.ts +14 -0
- package/dist/combat/comboPoints.js +52 -0
- package/dist/combat/defensiveWindow.d.ts +1 -1
- package/dist/combat/defensiveWindow.js +5 -3
- package/dist/combat/dotField.d.ts +43 -0
- package/dist/combat/dotField.js +48 -0
- package/dist/combat/projectiles.d.ts +33 -12
- package/dist/combat/projectiles.js +214 -129
- package/dist/combat/regenShield.d.ts +35 -0
- package/dist/combat/regenShield.js +45 -0
- package/dist/combat/resistance.d.ts +16 -4
- package/dist/combat/resistance.js +43 -7
- package/dist/combat/resourcePool.d.ts +20 -0
- package/dist/combat/resourcePool.js +55 -0
- package/dist/combat/shotOrigin.d.ts +52 -0
- package/dist/combat/shotOrigin.js +86 -0
- package/dist/combat/telegraph.d.ts +25 -0
- package/dist/combat/telegraph.js +47 -0
- package/dist/crafting/recipe.js +4 -1
- package/dist/devtools/devtools.d.ts +72 -24
- package/dist/devtools/devtools.js +449 -101
- package/dist/devtools/transformTunables.js +3 -3
- package/dist/devtools/tunableSchema.d.ts +140 -0
- package/dist/devtools/tunableSchema.js +476 -0
- package/dist/editor/commands.d.ts +65 -0
- package/dist/editor/commands.js +193 -0
- package/dist/editor/document.d.ts +40 -0
- package/dist/editor/document.js +153 -0
- package/dist/editor/index.d.ts +30 -0
- package/dist/editor/index.js +6 -0
- package/dist/editor/types.d.ts +69 -0
- package/dist/editor/types.js +29 -0
- package/dist/faction/factions.d.ts +33 -0
- package/dist/faction/factions.js +76 -0
- package/dist/faction/reputation.d.ts +32 -0
- package/dist/faction/reputation.js +82 -0
- package/dist/game/chat.d.ts +2 -0
- package/dist/game/chat.js +9 -1
- package/dist/game/chatFilter.d.ts +15 -0
- package/dist/game/chatFilter.js +55 -0
- package/dist/game/connectedPlayers.d.ts +28 -0
- package/dist/game/connectedPlayers.js +21 -0
- package/dist/game/controlGate.d.ts +4 -0
- package/dist/game/controlGate.js +7 -0
- package/dist/game/defineGame.d.ts +44 -1
- package/dist/game/defineGame.js +1 -0
- package/dist/game/events.d.ts +25 -0
- package/dist/game/gamePhase.d.ts +20 -0
- package/dist/game/gamePhase.js +21 -0
- package/dist/game/keyValueStore.d.ts +25 -0
- package/dist/game/keyValueStore.js +61 -0
- package/dist/game/levelSequence.d.ts +24 -2
- package/dist/game/levelSequence.js +51 -2
- package/dist/game/lootTable.d.ts +14 -1
- package/dist/game/lootTable.js +28 -7
- package/dist/game/modelAnimation.d.ts +8 -0
- package/dist/game/modelAnimation.js +18 -0
- package/dist/game/playableGame.d.ts +116 -4
- package/dist/game/playableGame.js +1 -1
- package/dist/game/quest.d.ts +7 -3
- package/dist/game/quest.js +17 -8
- package/dist/game/race.d.ts +39 -1
- package/dist/game/race.js +96 -11
- package/dist/game/recordBook.d.ts +27 -0
- package/dist/game/recordBook.js +86 -0
- package/dist/game/talents.d.ts +47 -0
- package/dist/game/talents.js +140 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/input/actionBindings.d.ts +2 -0
- package/dist/input/actionBindings.js +1 -0
- package/dist/input/axisInput.d.ts +33 -3
- package/dist/input/axisInput.js +60 -4
- package/dist/input/bindingOverrides.d.ts +25 -0
- package/dist/input/bindingOverrides.js +95 -0
- package/dist/input/pointerAxis.d.ts +30 -0
- package/dist/input/pointerAxis.js +31 -0
- package/dist/input/touchScheme.d.ts +6 -0
- package/dist/input/touchScheme.js +45 -7
- package/dist/interaction/contextMenu.d.ts +1 -0
- package/dist/interaction/contextMenu.js +1 -0
- package/dist/interaction/proximityPrompt.d.ts +3 -1
- package/dist/interaction/proximityPrompt.js +3 -3
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +44 -1
- package/dist/movement/dash.d.ts +3 -0
- package/dist/movement/dash.js +19 -5
- package/dist/movement/glideModel.d.ts +44 -0
- package/dist/movement/glideModel.js +54 -0
- package/dist/movement/grappleSwing.d.ts +34 -0
- package/dist/movement/grappleSwing.js +56 -0
- package/dist/movement/leaderTrail.d.ts +29 -0
- package/dist/movement/leaderTrail.js +67 -0
- package/dist/movement/movementModel.d.ts +4 -0
- package/dist/movement/movementModel.js +13 -0
- package/dist/movement/playerMovement.d.ts +41 -0
- package/dist/movement/playerMovement.js +181 -0
- package/dist/movement/steering.d.ts +27 -0
- package/dist/movement/steering.js +40 -0
- package/dist/multiplayer/feedWriteGate.d.ts +10 -0
- package/dist/multiplayer/feedWriteGate.js +12 -0
- package/dist/multiplayer/presenceModel.d.ts +14 -2
- package/dist/multiplayer/presenceModel.js +15 -3
- package/dist/nav/corridors.d.ts +30 -0
- package/dist/nav/corridors.js +69 -0
- package/dist/nav/navGrid.js +30 -4
- package/dist/nav/railGraph.d.ts +66 -0
- package/dist/nav/railGraph.js +129 -0
- package/dist/nav/timetable.d.ts +41 -0
- package/dist/nav/timetable.js +94 -0
- package/dist/physics/buoyancy.d.ts +6 -0
- package/dist/physics/buoyancy.js +14 -0
- package/dist/physics/flowTube.d.ts +32 -0
- package/dist/physics/flowTube.js +64 -0
- package/dist/physics/forceVolume.d.ts +27 -0
- package/dist/physics/forceVolume.js +38 -0
- package/dist/physics/kinematicVehicle.d.ts +52 -0
- package/dist/physics/kinematicVehicle.js +81 -0
- package/dist/physics/physicsWorld.d.ts +13 -6
- package/dist/physics/physicsWorld.js +169 -40
- package/dist/physics/ragdoll.js +1 -1
- package/dist/physics/vehicleBody.js +2 -1
- package/dist/random/rng.d.ts +2 -0
- package/dist/random/rng.js +2 -0
- package/dist/random/seedLink.d.ts +5 -0
- package/dist/random/seedLink.js +35 -0
- package/dist/render/postProcessing.d.ts +56 -0
- package/dist/render/postProcessing.js +1 -0
- package/dist/runtime/adapter.d.ts +19 -0
- package/dist/runtime/adapter.js +11 -5
- package/dist/runtime/cameraDirector.d.ts +6 -1
- package/dist/runtime/cameraDirector.js +6 -0
- package/dist/runtime/commandRunner.d.ts +2 -2
- package/dist/runtime/commandRunner.js +2 -2
- package/dist/runtime/gameContext.d.ts +72 -8
- package/dist/runtime/gameContext.js +374 -61
- package/dist/runtime/gameRuntime.d.ts +5 -1
- package/dist/runtime/gameRuntime.js +13 -12
- package/dist/runtime/hostPersistence.d.ts +9 -1
- package/dist/runtime/hostPersistence.js +23 -8
- package/dist/runtime/hostedGameRunner.d.ts +49 -0
- package/dist/runtime/hostedGameRunner.js +62 -0
- package/dist/runtime/hostedWorldSession.d.ts +65 -0
- package/dist/runtime/hostedWorldSession.js +57 -0
- package/dist/runtime/inputSnapshot.d.ts +18 -0
- package/dist/runtime/inputSnapshot.js +7 -0
- package/dist/runtime/motionIntents.d.ts +16 -6
- package/dist/runtime/motionIntents.js +31 -2
- package/dist/runtime/snapshot.d.ts +1 -0
- package/dist/runtime/snapshot.js +1 -1
- package/dist/runtime/visibility.d.ts +74 -0
- package/dist/runtime/visibility.js +127 -0
- package/dist/runtime/worldChannel.d.ts +58 -0
- package/dist/runtime/worldChannel.js +69 -0
- package/dist/runtime/worldMirror.d.ts +24 -0
- package/dist/runtime/worldMirror.js +31 -0
- package/dist/runtime/worldReplication.d.ts +46 -0
- package/dist/runtime/worldReplication.js +185 -0
- package/dist/runtime/worldSnapshot.d.ts +17 -0
- package/dist/runtime/worldSnapshot.js +15 -0
- package/dist/scene/assetCatalog.d.ts +1 -0
- package/dist/scene/colliders.d.ts +50 -0
- package/dist/scene/colliders.js +101 -0
- package/dist/scene/entityStats.d.ts +4 -0
- package/dist/scene/entityStats.js +21 -0
- package/dist/scene/entityStore.d.ts +51 -5
- package/dist/scene/entityStore.js +119 -12
- package/dist/scene/objectQuery.d.ts +13 -2
- package/dist/scene/objectQuery.js +34 -2
- package/dist/scene/objectStore.d.ts +2 -0
- package/dist/scene/objectStore.js +31 -12
- package/dist/scene/sceneRaycast.d.ts +80 -0
- package/dist/scene/sceneRaycast.js +284 -0
- package/dist/scene/spatial.d.ts +11 -16
- package/dist/scene/spatial.js +99 -38
- package/dist/sensor/recordingBuffer.d.ts +6 -0
- package/dist/sensor/recordingBuffer.js +7 -0
- package/dist/sensor/replayLoop.d.ts +39 -0
- package/dist/sensor/replayLoop.js +64 -0
- package/dist/sensor/visionCone.d.ts +31 -0
- package/dist/sensor/visionCone.js +72 -0
- package/dist/session/extraction.d.ts +2 -2
- package/dist/session/extraction.js +13 -2
- package/dist/settings/settingsModel.d.ts +97 -0
- package/dist/settings/settingsModel.js +100 -0
- package/dist/stats/statModifiers.d.ts +5 -2
- package/dist/stats/statModifiers.js +32 -14
- package/dist/store/observableKeyedStore.d.ts +4 -0
- package/dist/store/observableKeyedStore.js +17 -2
- package/dist/survival/decayMeter.d.ts +1 -1
- package/dist/survival/decayMeter.js +0 -1
- package/dist/tactics/fallingGrid.d.ts +2 -2
- package/dist/tactics/fallingGrid.js +2 -2
- package/dist/time/beatClock.d.ts +18 -0
- package/dist/time/beatClock.js +24 -0
- package/dist/time/idleProgress.d.ts +29 -0
- package/dist/time/idleProgress.js +31 -0
- package/dist/time/serverTick.d.ts +7 -1
- package/dist/time/serverTick.js +27 -4
- package/dist/time/stateSchedule.d.ts +57 -0
- package/dist/time/stateSchedule.js +121 -0
- package/dist/turn/commit.d.ts +2 -2
- package/dist/turn/commit.js +2 -2
- package/dist/ui/gameLayout.d.ts +125 -0
- package/dist/ui/gameLayout.js +129 -0
- package/dist/ui/hudLayout.d.ts +58 -0
- package/dist/ui/hudLayout.js +207 -0
- package/dist/ui/hudScale.d.ts +44 -0
- package/dist/ui/hudScale.js +55 -0
- package/dist/ui/orientation.d.ts +39 -0
- package/dist/ui/orientation.js +49 -0
- package/dist/ui/swingTimer.d.ts +42 -0
- package/dist/ui/swingTimer.js +38 -0
- package/dist/visibility/assetStreaming.d.ts +66 -0
- package/dist/visibility/assetStreaming.js +201 -0
- package/dist/visibility/bounds.d.ts +66 -0
- package/dist/visibility/bounds.js +113 -0
- package/dist/visibility/camera.d.ts +21 -0
- package/dist/visibility/camera.js +3 -0
- package/dist/visibility/config.d.ts +21 -0
- package/dist/visibility/config.js +1 -0
- package/dist/visibility/diagnostics.d.ts +26 -0
- package/dist/visibility/diagnostics.js +36 -0
- package/dist/visibility/distance.d.ts +11 -0
- package/dist/visibility/distance.js +30 -0
- package/dist/visibility/frustum.d.ts +47 -0
- package/dist/visibility/frustum.js +186 -0
- package/dist/visibility/occlusion.d.ts +36 -0
- package/dist/visibility/occlusion.js +45 -0
- package/dist/visibility/settings.d.ts +75 -0
- package/dist/visibility/settings.js +49 -0
- package/dist/visibility/simulationCulling.d.ts +34 -0
- package/dist/visibility/simulationCulling.js +36 -0
- package/dist/visibility/spatialIndex.d.ts +45 -0
- package/dist/visibility/spatialIndex.js +212 -0
- package/dist/visibility/visibilitySystem.d.ts +90 -0
- package/dist/visibility/visibilitySystem.js +257 -0
- package/dist/world/buildings.d.ts +6 -0
- package/dist/world/buildings.js +152 -0
- package/dist/world/carve.d.ts +7 -2
- package/dist/world/carve.js +17 -7
- package/dist/world/cellStates.d.ts +45 -0
- package/dist/world/cellStates.js +103 -0
- package/dist/world/environmentSummary.d.ts +21 -4
- package/dist/world/environmentSummary.js +22 -3
- package/dist/world/features.d.ts +185 -8
- package/dist/world/features.js +64 -6
- package/dist/world/gridInstances.d.ts +7 -8
- package/dist/world/gridInstances.js +145 -7
- package/dist/world/lod.d.ts +17 -0
- package/dist/world/lod.js +66 -0
- package/dist/world/mapLayers.d.ts +60 -0
- package/dist/world/mapLayers.js +44 -0
- package/dist/world/massing.d.ts +45 -0
- package/dist/world/massing.js +581 -0
- package/dist/world/minimap.d.ts +13 -1
- package/dist/world/minimap.js +21 -2
- package/dist/world/roads.d.ts +47 -0
- package/dist/world/roads.js +146 -0
- package/dist/world/streets.d.ts +80 -0
- package/dist/world/streets.js +144 -0
- package/dist/world/terrain.d.ts +69 -3
- package/dist/world/terrain.js +309 -16
- package/dist/world/water.d.ts +7 -0
- package/dist/world/water.js +19 -4
- package/dist/world/weather.d.ts +3 -18
- package/dist/world/weather.js +4 -17
- package/dist/world/windZones.d.ts +47 -0
- package/dist/world/windZones.js +59 -0
- package/llms.txt +3013 -1726
- package/package.json +12 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const DEFAULT_REPUTATION_TIERS = [
|
|
2
|
+
{ id: "hated", min: -42000, relation: "hostile" },
|
|
3
|
+
{ id: "hostile", min: -6000, relation: "hostile" },
|
|
4
|
+
{ id: "unfriendly", min: -3000, relation: "neutral" },
|
|
5
|
+
{ id: "neutral", min: 0, relation: "neutral" },
|
|
6
|
+
{ id: "friendly", min: 3000, relation: "friendly" },
|
|
7
|
+
{ id: "honored", min: 9000, relation: "friendly" },
|
|
8
|
+
{ id: "revered", min: 21000, relation: "friendly" },
|
|
9
|
+
{ id: "exalted", min: 42000, relation: "friendly" },
|
|
10
|
+
];
|
|
11
|
+
export function tierForStanding(tiers, standing) {
|
|
12
|
+
let match = tiers[0];
|
|
13
|
+
for (const tier of tiers) {
|
|
14
|
+
if (standing >= tier.min && tier.min >= match.min)
|
|
15
|
+
match = tier;
|
|
16
|
+
}
|
|
17
|
+
return match;
|
|
18
|
+
}
|
|
19
|
+
export function createReputationLedger(config = {}) {
|
|
20
|
+
const tiers = [...(config.tiers ?? DEFAULT_REPUTATION_TIERS)].sort((a, b) => a.min - b.min);
|
|
21
|
+
const initial = config.initial ?? {};
|
|
22
|
+
const min = config.min ?? Number.NEGATIVE_INFINITY;
|
|
23
|
+
const max = config.max ?? Number.POSITIVE_INFINITY;
|
|
24
|
+
const records = new Map();
|
|
25
|
+
function clamp(value) {
|
|
26
|
+
return Math.min(max, Math.max(min, value));
|
|
27
|
+
}
|
|
28
|
+
function standing(actorId, factionId) {
|
|
29
|
+
const stored = records.get(actorId)?.get(factionId);
|
|
30
|
+
if (stored !== undefined)
|
|
31
|
+
return stored;
|
|
32
|
+
return initial[factionId] ?? 0;
|
|
33
|
+
}
|
|
34
|
+
function store(actorId, factionId, value) {
|
|
35
|
+
const clamped = clamp(value);
|
|
36
|
+
let row = records.get(actorId);
|
|
37
|
+
if (row === undefined) {
|
|
38
|
+
row = new Map();
|
|
39
|
+
records.set(actorId, row);
|
|
40
|
+
}
|
|
41
|
+
row.set(factionId, clamped);
|
|
42
|
+
return clamped;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
standing,
|
|
46
|
+
hasStanding(actorId, factionId) {
|
|
47
|
+
return records.get(actorId)?.has(factionId) === true || initial[factionId] !== undefined;
|
|
48
|
+
},
|
|
49
|
+
gain(actorId, factionId, amount) {
|
|
50
|
+
return store(actorId, factionId, standing(actorId, factionId) + amount);
|
|
51
|
+
},
|
|
52
|
+
set(actorId, factionId, value) {
|
|
53
|
+
return store(actorId, factionId, value);
|
|
54
|
+
},
|
|
55
|
+
tier(actorId, factionId) {
|
|
56
|
+
return tierForStanding(tiers, standing(actorId, factionId));
|
|
57
|
+
},
|
|
58
|
+
relation(actorId, factionId) {
|
|
59
|
+
return tierForStanding(tiers, standing(actorId, factionId)).relation;
|
|
60
|
+
},
|
|
61
|
+
standings(actorId) {
|
|
62
|
+
const out = { ...initial };
|
|
63
|
+
const row = records.get(actorId);
|
|
64
|
+
if (row !== undefined)
|
|
65
|
+
for (const [factionId, value] of row)
|
|
66
|
+
out[factionId] = value;
|
|
67
|
+
return out;
|
|
68
|
+
},
|
|
69
|
+
reset(actorId, factionId) {
|
|
70
|
+
if (factionId === undefined)
|
|
71
|
+
records.delete(actorId);
|
|
72
|
+
else
|
|
73
|
+
records.get(actorId)?.delete(factionId);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function effectiveRelation(input) {
|
|
78
|
+
if (input.ledger.hasStanding(input.actorId, input.factionId)) {
|
|
79
|
+
return input.ledger.relation(input.actorId, input.factionId);
|
|
80
|
+
}
|
|
81
|
+
return input.base;
|
|
82
|
+
}
|
package/dist/game/chat.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GameEvents } from "./events.js";
|
|
2
|
+
import type { ChatFilter } from "./chatFilter.js";
|
|
2
3
|
import type { EmotesDeps } from "./social.js";
|
|
3
4
|
export type ChatChannelKind = "global" | "party" | "proximity";
|
|
4
5
|
export interface ChatRateLimit {
|
|
@@ -52,6 +53,7 @@ export interface ChatDeps {
|
|
|
52
53
|
blockedBy?: (userId: string) => readonly string[];
|
|
53
54
|
maxBodyLength?: number;
|
|
54
55
|
defaultRateLimit?: ChatRateLimit;
|
|
56
|
+
filter?: ChatFilter;
|
|
55
57
|
}
|
|
56
58
|
export declare const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimit;
|
|
57
59
|
export declare const DEFAULT_CHAT_HISTORY_LIMIT = 100;
|
package/dist/game/chat.js
CHANGED
|
@@ -34,6 +34,7 @@ export function createChat(deps) {
|
|
|
34
34
|
const maxBodyLength = deps.maxBodyLength ?? DEFAULT_CHAT_BODY_LENGTH;
|
|
35
35
|
const defaultRateLimit = deps.defaultRateLimit ?? DEFAULT_CHAT_RATE_LIMIT;
|
|
36
36
|
const blockedBy = deps.blockedBy ?? (() => []);
|
|
37
|
+
const filter = deps.filter;
|
|
37
38
|
const channelDefs = new Map();
|
|
38
39
|
for (const def of BUILTIN_CHANNELS)
|
|
39
40
|
channelDefs.set(def.id, { ...def });
|
|
@@ -97,6 +98,13 @@ export function createChat(deps) {
|
|
|
97
98
|
return { reason: "empty message" };
|
|
98
99
|
if (trimmed.length > maxBodyLength)
|
|
99
100
|
return { reason: "message too long" };
|
|
101
|
+
let filteredBody = trimmed;
|
|
102
|
+
if (filter !== undefined) {
|
|
103
|
+
const filtered = filter.apply(trimmed);
|
|
104
|
+
if (!filtered.ok)
|
|
105
|
+
return { reason: "filtered" };
|
|
106
|
+
filteredBody = filtered.body;
|
|
107
|
+
}
|
|
100
108
|
const resolved = resolveRecipients(fromUserId, channelId, def);
|
|
101
109
|
if ("reason" in resolved)
|
|
102
110
|
return resolved;
|
|
@@ -110,7 +118,7 @@ export function createChat(deps) {
|
|
|
110
118
|
id: `msg_${counter}`,
|
|
111
119
|
channelId,
|
|
112
120
|
fromUserId,
|
|
113
|
-
body:
|
|
121
|
+
body: filteredBody,
|
|
114
122
|
at,
|
|
115
123
|
};
|
|
116
124
|
append(channelId, message, def?.historyLimit ?? DEFAULT_CHAT_HISTORY_LIMIT);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ChatFilterConfig {
|
|
2
|
+
blockedWords: readonly string[];
|
|
3
|
+
mode?: "mask" | "reject";
|
|
4
|
+
mask?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ChatFilterResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
body: string;
|
|
9
|
+
matched: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
export interface ChatFilter {
|
|
12
|
+
apply(body: string): ChatFilterResult;
|
|
13
|
+
}
|
|
14
|
+
export declare function normalizeChatText(text: string): string;
|
|
15
|
+
export declare function createChatFilter(config: ChatFilterConfig): ChatFilter;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const LEET_SUBSTITUTIONS = {
|
|
2
|
+
"0": "o",
|
|
3
|
+
"1": "i",
|
|
4
|
+
"3": "e",
|
|
5
|
+
"4": "a",
|
|
6
|
+
"5": "s",
|
|
7
|
+
"7": "t",
|
|
8
|
+
"@": "a",
|
|
9
|
+
$: "s",
|
|
10
|
+
"!": "i",
|
|
11
|
+
};
|
|
12
|
+
const TOKEN_PATTERN = /[\p{L}\p{N}@$!]+/gu;
|
|
13
|
+
export function normalizeChatText(text) {
|
|
14
|
+
let normalized = "";
|
|
15
|
+
for (const char of text.toLowerCase()) {
|
|
16
|
+
normalized += LEET_SUBSTITUTIONS[char] ?? char;
|
|
17
|
+
}
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
export function createChatFilter(config) {
|
|
21
|
+
const mode = config.mode ?? "mask";
|
|
22
|
+
const mask = config.mask ?? "*";
|
|
23
|
+
const blockedByNormalized = new Map();
|
|
24
|
+
for (const word of config.blockedWords) {
|
|
25
|
+
blockedByNormalized.set(normalizeChatText(word), word);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
apply(body) {
|
|
29
|
+
if (blockedByNormalized.size === 0)
|
|
30
|
+
return { ok: true, body, matched: [] };
|
|
31
|
+
const hit = new Set();
|
|
32
|
+
let masked = "";
|
|
33
|
+
let cursor = 0;
|
|
34
|
+
for (const token of body.matchAll(TOKEN_PATTERN)) {
|
|
35
|
+
const word = token[0];
|
|
36
|
+
const start = token.index;
|
|
37
|
+
const blockedWord = blockedByNormalized.get(normalizeChatText(word));
|
|
38
|
+
if (blockedWord === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
hit.add(blockedWord);
|
|
41
|
+
if (mode === "reject")
|
|
42
|
+
continue;
|
|
43
|
+
masked += body.slice(cursor, start) + mask.repeat(word.length);
|
|
44
|
+
cursor = start + word.length;
|
|
45
|
+
}
|
|
46
|
+
if (hit.size === 0)
|
|
47
|
+
return { ok: true, body, matched: [] };
|
|
48
|
+
const matched = [...new Set(config.blockedWords)].filter((word) => hit.has(word));
|
|
49
|
+
if (mode === "reject")
|
|
50
|
+
return { ok: false, body, matched };
|
|
51
|
+
masked += body.slice(cursor);
|
|
52
|
+
return { ok: true, body: masked, matched };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { InputFrame } from "../runtime/inputSnapshot.js";
|
|
2
|
+
/** A player currently joined to a hosted world — the unit a shared-world loop iterates instead of `ctx.player`. */
|
|
3
|
+
export interface ConnectedPlayer {
|
|
4
|
+
userId: string;
|
|
5
|
+
isNew: boolean;
|
|
6
|
+
/** This player's latest {@link InputFrame}, written by the host each time the client sends input; `null` until the first frame arrives. The per-player movement seam reads it in `onTick`. */
|
|
7
|
+
input: InputFrame | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The set of players connected to one hosted world. A single-player game uses `ctx.player`; a shared-world loop
|
|
11
|
+
* reads `ctx.game.players` so `onTick` can advance every connected hero, not just the one local player. The host
|
|
12
|
+
* (`HostedGameRunner`) drives `join`/`leave`/`setInput`; game code reads `list`/`ids`/`has`/`count`/`input`.
|
|
13
|
+
*/
|
|
14
|
+
export interface ConnectedPlayers {
|
|
15
|
+
join(userId: string, isNew: boolean): void;
|
|
16
|
+
leave(userId: string): boolean;
|
|
17
|
+
has(userId: string): boolean;
|
|
18
|
+
get(userId: string): ConnectedPlayer | null;
|
|
19
|
+
list(): readonly ConnectedPlayer[];
|
|
20
|
+
ids(): readonly string[];
|
|
21
|
+
count(): number;
|
|
22
|
+
/** Record a player's latest input frame (host-driven); a no-op for a player that hasn't joined. */
|
|
23
|
+
setInput(userId: string, frame: InputFrame): void;
|
|
24
|
+
/** This player's latest input frame, or `null` if they haven't joined or no frame has arrived yet. */
|
|
25
|
+
input(userId: string): InputFrame | null;
|
|
26
|
+
}
|
|
27
|
+
/** Build an empty {@link ConnectedPlayers} registry — the host joins/leaves players; the game loop reads them. */
|
|
28
|
+
export declare function createConnectedPlayers(): ConnectedPlayers;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Build an empty {@link ConnectedPlayers} registry — the host joins/leaves players; the game loop reads them. */
|
|
2
|
+
export function createConnectedPlayers() {
|
|
3
|
+
const players = new Map();
|
|
4
|
+
return {
|
|
5
|
+
join(userId, isNew) {
|
|
6
|
+
players.set(userId, { userId, isNew, input: null });
|
|
7
|
+
},
|
|
8
|
+
leave: (userId) => players.delete(userId),
|
|
9
|
+
has: (userId) => players.has(userId),
|
|
10
|
+
get: (userId) => players.get(userId) ?? null,
|
|
11
|
+
list: () => Array.from(players.values()),
|
|
12
|
+
ids: () => Array.from(players.keys()),
|
|
13
|
+
count: () => players.size,
|
|
14
|
+
setInput(userId, frame) {
|
|
15
|
+
const player = players.get(userId);
|
|
16
|
+
if (player !== undefined)
|
|
17
|
+
player.input = frame;
|
|
18
|
+
},
|
|
19
|
+
input: (userId) => players.get(userId)?.input ?? null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GameContext } from "../runtime/gameContext.js";
|
|
2
|
+
export declare const PLAY_CONTROLS_STORE_KEY = "jg.playControls";
|
|
3
|
+
export declare function setPlayControlsActive(ctx: GameContext, active: boolean): void;
|
|
4
|
+
export declare function playControlsActive(ctx: GameContext): boolean;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const PLAY_CONTROLS_STORE_KEY = "jg.playControls";
|
|
2
|
+
export function setPlayControlsActive(ctx, active) {
|
|
3
|
+
ctx.game.store.set(PLAY_CONTROLS_STORE_KEY, active);
|
|
4
|
+
}
|
|
5
|
+
export function playControlsActive(ctx) {
|
|
6
|
+
return ctx.game.store.get(PLAY_CONTROLS_STORE_KEY) !== false;
|
|
7
|
+
}
|
|
@@ -7,10 +7,14 @@ import { type AssetCatalog, type ModelAssetRef } from "../scene/assetCatalog.js"
|
|
|
7
7
|
import { type EntityStore } from "../scene/entityStore.js";
|
|
8
8
|
import type { TimeConfig } from "../time/simClock.js";
|
|
9
9
|
import type { WorldFeature } from "../world/features.js";
|
|
10
|
+
/** World gravity and jump tuning, plus scene-object collision opt-ins, for the game's physics step. */
|
|
10
11
|
export interface PhysicsConfig {
|
|
11
12
|
gravity?: number;
|
|
12
13
|
jumpVelocity?: number;
|
|
14
|
+
/** Opt placed scene objects into projectile raycasts as blocking cover AABBs. Default false — enabling changes where shots land. */
|
|
15
|
+
projectileObstacles?: boolean;
|
|
13
16
|
}
|
|
17
|
+
/** Shape of one named inventory a game declares — slot count, accepted item types, HUD binding. */
|
|
14
18
|
export interface InventoryDeclaration {
|
|
15
19
|
slots: number;
|
|
16
20
|
hud?: string;
|
|
@@ -19,20 +23,57 @@ export interface InventoryDeclaration {
|
|
|
19
23
|
applyModifiers?: boolean;
|
|
20
24
|
tier?: StorageTier;
|
|
21
25
|
}
|
|
26
|
+
/** Hosting mode for a game's multiplayer server: `"persistent"`, or a custom mode with its own options. */
|
|
22
27
|
export type GameServerConfig = "persistent" | {
|
|
23
28
|
mode: string;
|
|
24
29
|
[key: string]: unknown;
|
|
25
30
|
};
|
|
31
|
+
/** Identity of a player joining or leaving a hosted world — passed to the multiplayer loop hooks. */
|
|
32
|
+
export interface LoopPlayer {
|
|
33
|
+
userId: string;
|
|
34
|
+
isNew: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave. */
|
|
26
37
|
export interface GameLoop<TContext = unknown> {
|
|
27
38
|
onInit?(ctx: TContext): void;
|
|
28
|
-
|
|
39
|
+
/** Once per join. `player` identifies the joiner in a hosted world; single-player callers may omit it. */
|
|
40
|
+
onNewPlayer?(ctx: TContext, player?: LoopPlayer): void;
|
|
29
41
|
onTick?(ctx: TContext, dt: number): void;
|
|
42
|
+
/** Once per leave in a hosted world (never fired single-player). Despawn the player's entities / release their slots here. */
|
|
43
|
+
onPlayerLeave?(ctx: TContext, player: LoopPlayer): void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Opt-in `ctx.game.*` subsystems. Absent = off: the game doesn't carry (or expose) it, and `ctx.game.<name>`
|
|
47
|
+
* is `undefined`. Present (`true`) builds it. The universal base — `commands`, `events`, `store`, `feed` — is
|
|
48
|
+
* always on and not listed here. This is what keeps core genre-agnostic: a puzzle game isn't handed an MMO's
|
|
49
|
+
* leaderboard/roster/turn plumbing it never asked for.
|
|
50
|
+
*/
|
|
51
|
+
export interface GameFeatures {
|
|
52
|
+
/** Owned/captured entity roster (`ctx.game.roster`) — pet/monster collection games. */
|
|
53
|
+
roster?: boolean;
|
|
54
|
+
/** Card pile zones (`ctx.game.cards`) — deckbuilders, card games. */
|
|
55
|
+
cards?: boolean;
|
|
56
|
+
/** Turn/phase loop (`ctx.game.turn`) — turn-based games. */
|
|
57
|
+
turn?: boolean;
|
|
58
|
+
/** Lap/checkpoint race state (`ctx.game.race`) — racers. */
|
|
59
|
+
race?: boolean;
|
|
60
|
+
/** Competitive score tracking (`ctx.game.leaderboard`) — ranked/arcade games. */
|
|
61
|
+
leaderboard?: boolean;
|
|
62
|
+
/** Friends/party/presence/emotes/world-invites (`ctx.game.social`) — social/MMO games. */
|
|
63
|
+
social?: boolean;
|
|
64
|
+
/** Channels + messages (`ctx.game.chat`) — implies `social`; multiplayer/social games. */
|
|
65
|
+
chat?: boolean;
|
|
66
|
+
/** Connected-player set (`ctx.game.players`) — shared-world hosts whose loop ticks every player, not just `ctx.player`. */
|
|
67
|
+
players?: boolean;
|
|
30
68
|
}
|
|
69
|
+
/** Fully-resolved game description produced by {@link defineGame} — assets, scene, and opted-in subsystems. */
|
|
31
70
|
export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> {
|
|
32
71
|
name: string;
|
|
33
72
|
assets: AssetCatalog<TAssetRef>;
|
|
34
73
|
multiplayer: TMultiplayer;
|
|
35
74
|
scene: EntityStore;
|
|
75
|
+
/** Opt-in `ctx.game.*` subsystems beyond the always-on base; omitted systems are `undefined` on `ctx.game`. */
|
|
76
|
+
features?: GameFeatures;
|
|
36
77
|
world?: WorldFeature;
|
|
37
78
|
physics?: PhysicsConfig;
|
|
38
79
|
/** Simulation clock: real→game time scale, selectable speeds, calendar. Exposed as `ctx.time`; the shell feeds its scaled dt to `loop.onTick`. */
|
|
@@ -46,7 +87,9 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
|
|
|
46
87
|
ui?: unknown;
|
|
47
88
|
loop?: GameLoop<any>;
|
|
48
89
|
}
|
|
90
|
+
/** Input to {@link defineGame} — a `GameDefinition` with `scene` derived and `assets` optional. */
|
|
49
91
|
export type GameDefinitionConfig<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> = Omit<GameDefinition<TAssetRef, TMultiplayer>, "scene" | "assets"> & {
|
|
50
92
|
assets?: AssetCatalog<TAssetRef>;
|
|
51
93
|
};
|
|
94
|
+
/** Task-first entry point for authoring a game: fills in `scene` and default `assets`, validates `name`. */
|
|
52
95
|
export declare function defineGame<TAssetRef extends ModelAssetRef, TMultiplayer>(config: GameDefinitionConfig<TAssetRef, TMultiplayer>): GameDefinition<TAssetRef, TMultiplayer>;
|
package/dist/game/defineGame.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAssetCatalog } from "../scene/assetCatalog.js";
|
|
2
2
|
import { createEntityStore } from "../scene/entityStore.js";
|
|
3
|
+
/** Task-first entry point for authoring a game: fills in `scene` and default `assets`, validates `name`. */
|
|
3
4
|
export function defineGame(config) {
|
|
4
5
|
if (config.name.trim().length === 0) {
|
|
5
6
|
throw new Error("defineGame: name must be non-empty");
|
package/dist/game/events.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface EntityDiedEvent {
|
|
|
22
22
|
position: [number, number, number];
|
|
23
23
|
serverId?: string;
|
|
24
24
|
}
|
|
25
|
+
/** Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. */
|
|
26
|
+
export interface EntityAnimationEvent {
|
|
27
|
+
instanceId: string;
|
|
28
|
+
event: string;
|
|
29
|
+
}
|
|
25
30
|
export interface LootGrantedEvent {
|
|
26
31
|
userId: string;
|
|
27
32
|
drops: {
|
|
@@ -109,6 +114,10 @@ export interface CombatTelegraphEvent {
|
|
|
109
114
|
windupMs: number;
|
|
110
115
|
kind: string;
|
|
111
116
|
}
|
|
117
|
+
export interface CombatTelegraphCancelledEvent {
|
|
118
|
+
/** The `combat.telegraph` event id whose decal should disappear early. */
|
|
119
|
+
id: number;
|
|
120
|
+
}
|
|
112
121
|
export interface CombatHitReactionEvent {
|
|
113
122
|
instanceId?: string;
|
|
114
123
|
position: [number, number, number];
|
|
@@ -156,10 +165,22 @@ export interface FormChangedEvent {
|
|
|
156
165
|
instanceId: string;
|
|
157
166
|
formId: string | null;
|
|
158
167
|
}
|
|
168
|
+
export interface AudioPlayEvent {
|
|
169
|
+
sound: string;
|
|
170
|
+
at?: readonly [number, number, number];
|
|
171
|
+
}
|
|
172
|
+
/** Request that the shell's audio engine resume its (browser-gesture-suspended) context; carries no payload. */
|
|
173
|
+
export type AudioResumeEvent = Record<string, never>;
|
|
174
|
+
/** Crossfade the procedural soundtrack to `theme` (null fades out), optionally transposing the incoming theme by `transpose` semitones. */
|
|
175
|
+
export interface AudioMusicEvent {
|
|
176
|
+
theme: string | null;
|
|
177
|
+
transpose?: number;
|
|
178
|
+
}
|
|
159
179
|
export interface GameEventMap {
|
|
160
180
|
"entity.died": EntityDiedEvent;
|
|
161
181
|
"entity.floatText": EntityFloatTextEvent;
|
|
162
182
|
"combat.telegraph": CombatTelegraphEvent;
|
|
183
|
+
"combat.telegraphCancelled": CombatTelegraphCancelledEvent;
|
|
163
184
|
"combat.hitReaction": CombatHitReactionEvent;
|
|
164
185
|
"loot.granted": LootGrantedEvent;
|
|
165
186
|
"inventory.added": InventoryAddedEvent;
|
|
@@ -180,6 +201,10 @@ export interface GameEventMap {
|
|
|
180
201
|
"emote.played": EmotePlayedEvent;
|
|
181
202
|
"possession.swapped": PossessionSwappedEvent;
|
|
182
203
|
"form.changed": FormChangedEvent;
|
|
204
|
+
"audio.play": AudioPlayEvent;
|
|
205
|
+
"audio.music": AudioMusicEvent;
|
|
206
|
+
"audio.resume": AudioResumeEvent;
|
|
207
|
+
"entity.animation": EntityAnimationEvent;
|
|
183
208
|
}
|
|
184
209
|
export type GameEventHandler<TPayload> = (payload: TPayload) => void;
|
|
185
210
|
export interface GameEvents<TMap extends GameEventMap = GameEventMap> {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GameContext } from "../runtime/gameContext.js";
|
|
2
|
+
/**
|
|
3
|
+
* Canonical run phase every game moves through. `menu` (title/main menu),
|
|
4
|
+
* `playing` (live), `paused` (mid-run pause), `ended` (win/lose/results).
|
|
5
|
+
* Touch controls are shown only while `playing`; menus and results never paint
|
|
6
|
+
* the touch dock over themselves.
|
|
7
|
+
*/
|
|
8
|
+
export type GamePhase = "menu" | "playing" | "paused" | "ended";
|
|
9
|
+
export declare const GAME_PHASE_STORE_KEY = "jg.phase";
|
|
10
|
+
/**
|
|
11
|
+
* Set the current phase. Publishes it to `ctx.game.store` (React reads it via
|
|
12
|
+
* `useGamePhase`) and gates the shell's on-screen touch controls in one call —
|
|
13
|
+
* `playing` shows them, every other phase hides them. This is the whole
|
|
14
|
+
* "main menu shouldn't show touch controls" wiring: call it once per phase
|
|
15
|
+
* transition and the dock follows.
|
|
16
|
+
*/
|
|
17
|
+
export declare function setGamePhase(ctx: GameContext, phase: GamePhase): void;
|
|
18
|
+
/** Current phase; defaults to `playing` when unset so always-live games need no wiring. */
|
|
19
|
+
export declare function gamePhase(ctx: GameContext): GamePhase;
|
|
20
|
+
export declare function isPlaying(ctx: GameContext): boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { setPlayControlsActive } from "./controlGate.js";
|
|
2
|
+
export const GAME_PHASE_STORE_KEY = "jg.phase";
|
|
3
|
+
/**
|
|
4
|
+
* Set the current phase. Publishes it to `ctx.game.store` (React reads it via
|
|
5
|
+
* `useGamePhase`) and gates the shell's on-screen touch controls in one call —
|
|
6
|
+
* `playing` shows them, every other phase hides them. This is the whole
|
|
7
|
+
* "main menu shouldn't show touch controls" wiring: call it once per phase
|
|
8
|
+
* transition and the dock follows.
|
|
9
|
+
*/
|
|
10
|
+
export function setGamePhase(ctx, phase) {
|
|
11
|
+
ctx.game.store.set(GAME_PHASE_STORE_KEY, phase);
|
|
12
|
+
setPlayControlsActive(ctx, phase === "playing");
|
|
13
|
+
}
|
|
14
|
+
/** Current phase; defaults to `playing` when unset so always-live games need no wiring. */
|
|
15
|
+
export function gamePhase(ctx) {
|
|
16
|
+
const value = ctx.game.store.get(GAME_PHASE_STORE_KEY);
|
|
17
|
+
return value === "menu" || value === "paused" || value === "ended" ? value : "playing";
|
|
18
|
+
}
|
|
19
|
+
export function isPlaying(ctx) {
|
|
20
|
+
return gamePhase(ctx) === "playing";
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Structural, DOM-free storage backend: the browser `localStorage` satisfies it, as does a test stub or `null`. The one storage seam core primitives target so persistence code never needs the DOM `Storage` lib. */
|
|
2
|
+
export interface KeyValueStorage {
|
|
3
|
+
getItem(key: string): string | null;
|
|
4
|
+
setItem(key: string, value: string): void;
|
|
5
|
+
removeItem(key: string): void;
|
|
6
|
+
}
|
|
7
|
+
/** The ambient `localStorage` as a {@link KeyValueStorage} when one exists (browser), otherwise `null` (server/tests) — never references the DOM `Storage` type, so it is safe in core. */
|
|
8
|
+
export declare function defaultKeyValueStorage(): KeyValueStorage | null;
|
|
9
|
+
/** A single persisted, mutable cell: read the current value, overwrite it, or read-modify-write with {@link KeyValueStore.update}. Unlike a record book it has no monotonic guard — the value goes wherever you set it. */
|
|
10
|
+
export interface KeyValueStore<T> {
|
|
11
|
+
get(): T;
|
|
12
|
+
set(value: T): void;
|
|
13
|
+
update(mutate: (previous: T) => T): T;
|
|
14
|
+
clear(): void;
|
|
15
|
+
}
|
|
16
|
+
/** Config for {@link createKeyValueStore}: the storage `key`, the `initial` value used before anything is saved, an optional `storage` backend (defaults to `localStorage`, pass `null` for memory-only), and optional custom `serialize`/`deserialize` (default JSON). */
|
|
17
|
+
export interface KeyValueStoreConfig<T> {
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly initial: T;
|
|
20
|
+
readonly storage?: KeyValueStorage | null;
|
|
21
|
+
readonly serialize?: (value: T) => string;
|
|
22
|
+
readonly deserialize?: (raw: string) => T;
|
|
23
|
+
}
|
|
24
|
+
/** A lightweight mutable local save cell for single-player state (a credit bank, a settings blob, level progress) — the read-modify-write counterpart to the monotonic `recordBook`. Persists through a {@link KeyValueStorage} (browser `localStorage` by default); corrupt or unavailable storage degrades to in-memory and never throws into a game tick. */
|
|
25
|
+
export declare function createKeyValueStore<T>(config: KeyValueStoreConfig<T>): KeyValueStore<T>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The ambient `localStorage` as a {@link KeyValueStorage} when one exists (browser), otherwise `null` (server/tests) — never references the DOM `Storage` type, so it is safe in core. */
|
|
2
|
+
export function defaultKeyValueStorage() {
|
|
3
|
+
try {
|
|
4
|
+
if (typeof globalThis !== "undefined" && "localStorage" in globalThis) {
|
|
5
|
+
return globalThis.localStorage ?? null;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
/** A lightweight mutable local save cell for single-player state (a credit bank, a settings blob, level progress) — the read-modify-write counterpart to the monotonic `recordBook`. Persists through a {@link KeyValueStorage} (browser `localStorage` by default); corrupt or unavailable storage degrades to in-memory and never throws into a game tick. */
|
|
14
|
+
export function createKeyValueStore(config) {
|
|
15
|
+
const storage = config.storage === undefined ? defaultKeyValueStorage() : config.storage;
|
|
16
|
+
const serialize = config.serialize ?? ((value) => JSON.stringify(value));
|
|
17
|
+
const deserialize = config.deserialize ?? ((raw) => JSON.parse(raw));
|
|
18
|
+
const read = () => {
|
|
19
|
+
if (storage === null)
|
|
20
|
+
return config.initial;
|
|
21
|
+
try {
|
|
22
|
+
const raw = storage.getItem(config.key);
|
|
23
|
+
return raw === null ? config.initial : deserialize(raw);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return config.initial;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
let current = read();
|
|
30
|
+
const write = (value) => {
|
|
31
|
+
current = value;
|
|
32
|
+
if (storage === null)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
storage.setItem(config.key, serialize(value));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// storage rejected the write; keep the value in memory for this session
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
get: () => current,
|
|
43
|
+
set: (value) => write(value),
|
|
44
|
+
update: (mutate) => {
|
|
45
|
+
const next = mutate(current);
|
|
46
|
+
write(next);
|
|
47
|
+
return next;
|
|
48
|
+
},
|
|
49
|
+
clear: () => {
|
|
50
|
+
current = config.initial;
|
|
51
|
+
if (storage === null)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
storage.removeItem(config.key);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// ignore removal failure; in-memory value already reset
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -1,11 +1,23 @@
|
|
|
1
|
+
import { type KeyValueStorage } from "./keyValueStore.js";
|
|
1
2
|
export interface LevelDescriptor<TLevelConfig> {
|
|
2
3
|
id: string;
|
|
3
4
|
config: TLevelConfig;
|
|
4
5
|
}
|
|
6
|
+
/** A level's star rating, 0 (cleared, no stars) to 3. */
|
|
7
|
+
export type LevelStars = 0 | 1 | 2 | 3;
|
|
8
|
+
/** Persisted per-level outcome: whether it has ever been cleared and the best star rating achieved. */
|
|
9
|
+
export interface LevelRecord {
|
|
10
|
+
cleared: boolean;
|
|
11
|
+
stars: LevelStars;
|
|
12
|
+
}
|
|
5
13
|
export interface LevelSequenceConfig<TLevelConfig> {
|
|
6
14
|
levels: readonly LevelDescriptor<TLevelConfig>[];
|
|
7
15
|
/** Extra attempts granted after the first per level. Defaults to 0 (a single attempt, no retries). */
|
|
8
16
|
retriesPerLevel?: number;
|
|
17
|
+
/** Persistence key for per-level completion/stars. Omit to keep records in-memory only. */
|
|
18
|
+
key?: string;
|
|
19
|
+
/** Storage backend for the records (browser `localStorage` by default when `key` is set); pass `null` for memory-only. */
|
|
20
|
+
storage?: KeyValueStorage | null;
|
|
9
21
|
}
|
|
10
22
|
export type LevelSequenceStatus = "idle" | "playing" | "cleared" | "failed" | "complete";
|
|
11
23
|
export interface CurrentLevel<TLevelConfig> {
|
|
@@ -24,15 +36,25 @@ export interface LevelSequence<TLevelConfig> {
|
|
|
24
36
|
status(): LevelSequenceStatus;
|
|
25
37
|
/** Enters level 0. A no-op on an empty level list, which leaves `status()` at "idle" forever. */
|
|
26
38
|
start(): void;
|
|
27
|
-
/** Marks the current level cleared; call `advance()` to move to the next one. */
|
|
28
|
-
clear(): void;
|
|
39
|
+
/** Marks the current level cleared, recording its best star rating; call `advance()` to move to the next one. */
|
|
40
|
+
clear(stars?: LevelStars): void;
|
|
29
41
|
/** Consumes an attempt on the current level. Returns "retry" while attempts remain, else "failed". */
|
|
30
42
|
fail(): "retry" | "failed";
|
|
31
43
|
/** Restarts the current level after a "retry"-eligible failure. Returns false if there was nothing to retry. */
|
|
32
44
|
retry(): boolean;
|
|
33
45
|
/** Moves past a cleared level, or to "complete" if it was the last one. Returns false unless status is "cleared". */
|
|
34
46
|
advance(): boolean;
|
|
47
|
+
/** Jumps directly to an unlocked level and starts it. Returns false for an unknown or still-locked level. */
|
|
48
|
+
select(level: string): boolean;
|
|
49
|
+
/** Whether a level can be entered: the first level always, later ones once the preceding level has a cleared record. */
|
|
50
|
+
isUnlocked(level: string): boolean;
|
|
51
|
+
/** The persisted record for a level, or `null` if it has never been cleared. */
|
|
52
|
+
record(level: string): LevelRecord | null;
|
|
53
|
+
/** A snapshot of every persisted level record, keyed by level id. */
|
|
54
|
+
records(): Readonly<Record<string, LevelRecord>>;
|
|
35
55
|
reset(): void;
|
|
56
|
+
/** Wipes all persisted completion/star records (and the frontier), returning to a fresh campaign. */
|
|
57
|
+
clearRecords(): void;
|
|
36
58
|
progress(): LevelSequenceProgress;
|
|
37
59
|
}
|
|
38
60
|
/**
|