@jgengine/core 0.1.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/LICENSE +661 -0
- package/README.md +11 -0
- package/dist/assets/spriteSheet.d.ts +46 -0
- package/dist/assets/spriteSheet.js +86 -0
- package/dist/combat/death.d.ts +61 -0
- package/dist/combat/death.js +61 -0
- package/dist/combat/effects.d.ts +61 -0
- package/dist/combat/effects.js +108 -0
- package/dist/combat/projectiles.d.ts +45 -0
- package/dist/combat/projectiles.js +156 -0
- package/dist/commands/commandRegistry.d.ts +23 -0
- package/dist/commands/commandRegistry.js +26 -0
- package/dist/economy/currency.d.ts +26 -0
- package/dist/economy/currency.js +28 -0
- package/dist/economy/wallet.d.ts +16 -0
- package/dist/economy/wallet.js +49 -0
- package/dist/game/defineGame.d.ts +41 -0
- package/dist/game/defineGame.js +7 -0
- package/dist/game/events.d.ts +87 -0
- package/dist/game/events.js +24 -0
- package/dist/game/feed.d.ts +19 -0
- package/dist/game/feed.js +47 -0
- package/dist/game/leaderboard.d.ts +46 -0
- package/dist/game/leaderboard.js +60 -0
- package/dist/game/loadout.d.ts +47 -0
- package/dist/game/loadout.js +36 -0
- package/dist/game/lootTable.d.ts +27 -0
- package/dist/game/lootTable.js +78 -0
- package/dist/game/playableGame.d.ts +55 -0
- package/dist/game/playableGame.js +1 -0
- package/dist/game/quest.d.ts +93 -0
- package/dist/game/quest.js +228 -0
- package/dist/game/social.d.ts +78 -0
- package/dist/game/social.js +227 -0
- package/dist/game/trade.d.ts +35 -0
- package/dist/game/trade.js +53 -0
- package/dist/game/unlocks.d.ts +13 -0
- package/dist/game/unlocks.js +34 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/input/actionBindings.d.ts +41 -0
- package/dist/input/actionBindings.js +115 -0
- package/dist/input/lookChannel.d.ts +29 -0
- package/dist/input/lookChannel.js +44 -0
- package/dist/input/touchGestures.d.ts +31 -0
- package/dist/input/touchGestures.js +72 -0
- package/dist/interaction/proximityPrompt.d.ts +51 -0
- package/dist/interaction/proximityPrompt.js +76 -0
- package/dist/inventory/inventoryModel.d.ts +57 -0
- package/dist/inventory/inventoryModel.js +208 -0
- package/dist/item/use.d.ts +25 -0
- package/dist/item/use.js +40 -0
- package/dist/item/weapon.d.ts +8 -0
- package/dist/item/weapon.js +18 -0
- package/dist/movement/avatarGait.d.ts +14 -0
- package/dist/movement/avatarGait.js +20 -0
- package/dist/movement/cameraRig.d.ts +15 -0
- package/dist/movement/cameraRig.js +43 -0
- package/dist/movement/movementModel.d.ts +96 -0
- package/dist/movement/movementModel.js +171 -0
- package/dist/movement/poseState.d.ts +23 -0
- package/dist/movement/poseState.js +61 -0
- package/dist/multiplayer/poseSyncGate.d.ts +18 -0
- package/dist/multiplayer/poseSyncGate.js +30 -0
- package/dist/multiplayer/presenceContract.d.ts +60 -0
- package/dist/multiplayer/presenceContract.js +19 -0
- package/dist/multiplayer/presenceModel.d.ts +60 -0
- package/dist/multiplayer/presenceModel.js +51 -0
- package/dist/runtime/adapter.d.ts +24 -0
- package/dist/runtime/adapter.js +12 -0
- package/dist/runtime/commandRunner.d.ts +15 -0
- package/dist/runtime/commandRunner.js +25 -0
- package/dist/runtime/gameContext.d.ts +138 -0
- package/dist/runtime/gameContext.js +371 -0
- package/dist/runtime/gameRuntime.d.ts +42 -0
- package/dist/runtime/gameRuntime.js +95 -0
- package/dist/runtime/hostPersistence.d.ts +105 -0
- package/dist/runtime/hostPersistence.js +117 -0
- package/dist/runtime/save.d.ts +9 -0
- package/dist/runtime/save.js +29 -0
- package/dist/runtime/snapshot.d.ts +87 -0
- package/dist/runtime/snapshot.js +75 -0
- package/dist/runtime/transport.d.ts +60 -0
- package/dist/runtime/transport.js +1 -0
- package/dist/scene/assetCatalog.d.ts +10 -0
- package/dist/scene/assetCatalog.js +17 -0
- package/dist/scene/assetPreload.d.ts +7 -0
- package/dist/scene/assetPreload.js +22 -0
- package/dist/scene/behaviors.d.ts +19 -0
- package/dist/scene/behaviors.js +18 -0
- package/dist/scene/entityStats.d.ts +38 -0
- package/dist/scene/entityStats.js +65 -0
- package/dist/scene/entityStore.d.ts +52 -0
- package/dist/scene/entityStore.js +84 -0
- package/dist/scene/objectStore.d.ts +28 -0
- package/dist/scene/objectStore.js +69 -0
- package/dist/scene/spatial.d.ts +34 -0
- package/dist/scene/spatial.js +96 -0
- package/dist/scene/targeting.d.ts +20 -0
- package/dist/scene/targeting.js +56 -0
- package/dist/stats/statModifiers.d.ts +18 -0
- package/dist/stats/statModifiers.js +56 -0
- package/dist/store/changeSignal.d.ts +7 -0
- package/dist/store/changeSignal.js +32 -0
- package/dist/store/engineStore.d.ts +22 -0
- package/dist/store/engineStore.js +50 -0
- package/dist/store/features.d.ts +10 -0
- package/dist/store/features.js +23 -0
- package/dist/store/observableKeyedStore.d.ts +10 -0
- package/dist/store/observableKeyedStore.js +42 -0
- package/dist/time/calendarClock.d.ts +17 -0
- package/dist/time/calendarClock.js +42 -0
- package/dist/time/gameClock.d.ts +9 -0
- package/dist/time/gameClock.js +20 -0
- package/dist/time/serverTick.d.ts +16 -0
- package/dist/time/serverTick.js +18 -0
- package/dist/world/features.d.ts +39 -0
- package/dist/world/features.js +15 -0
- package/package.json +32 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface CurrencyDefinition<TCurrencyId extends string = string> {
|
|
2
|
+
id: TCurrencyId;
|
|
3
|
+
name: string;
|
|
4
|
+
/** Prepended when formatting amounts (e.g. "$"). */
|
|
5
|
+
symbol?: string;
|
|
6
|
+
/** Appended when formatting amounts (e.g. "tokens"). */
|
|
7
|
+
unit?: string;
|
|
8
|
+
}
|
|
9
|
+
export type CurrencyOperation = "add" | "deduct";
|
|
10
|
+
export type CurrencyAdjustment = {
|
|
11
|
+
success: true;
|
|
12
|
+
newBalance: number;
|
|
13
|
+
appliedDelta: number;
|
|
14
|
+
} | {
|
|
15
|
+
success: false;
|
|
16
|
+
reason: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function sanitizeCurrencyAmount(amount: number): number;
|
|
19
|
+
export declare function formatCurrencyAmount(currency: CurrencyDefinition, amount: number): string;
|
|
20
|
+
export declare function insufficientCurrencyReason(currency: CurrencyDefinition, needed: number, current: number): string;
|
|
21
|
+
export declare function applyCurrencyOperation(currency: CurrencyDefinition, current: number, operation: CurrencyOperation, amount: number): CurrencyAdjustment;
|
|
22
|
+
/** Deducts clamp to the available balance (a delta can never overdraw); adds apply in full. */
|
|
23
|
+
export declare function resolveCurrencyDelta(current: number, delta: number): {
|
|
24
|
+
operation: CurrencyOperation;
|
|
25
|
+
amount: number;
|
|
26
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function sanitizeCurrencyAmount(amount) {
|
|
2
|
+
return Math.max(0, Math.floor(amount));
|
|
3
|
+
}
|
|
4
|
+
export function formatCurrencyAmount(currency, amount) {
|
|
5
|
+
const prefix = currency.symbol ?? "";
|
|
6
|
+
const suffix = currency.unit ? ` ${currency.unit}` : "";
|
|
7
|
+
return `${prefix}${amount}${suffix}`;
|
|
8
|
+
}
|
|
9
|
+
export function insufficientCurrencyReason(currency, needed, current) {
|
|
10
|
+
return `Insufficient ${currency.id}: need ${formatCurrencyAmount(currency, needed)}, have ${formatCurrencyAmount(currency, current)}`;
|
|
11
|
+
}
|
|
12
|
+
export function applyCurrencyOperation(currency, current, operation, amount) {
|
|
13
|
+
const safeAmount = sanitizeCurrencyAmount(amount);
|
|
14
|
+
if (safeAmount === 0)
|
|
15
|
+
return { success: true, newBalance: current, appliedDelta: 0 };
|
|
16
|
+
if (operation === "deduct" && current < safeAmount) {
|
|
17
|
+
return { success: false, reason: insufficientCurrencyReason(currency, safeAmount, current) };
|
|
18
|
+
}
|
|
19
|
+
const appliedDelta = operation === "add" ? safeAmount : -safeAmount;
|
|
20
|
+
return { success: true, newBalance: current + appliedDelta, appliedDelta };
|
|
21
|
+
}
|
|
22
|
+
/** Deducts clamp to the available balance (a delta can never overdraw); adds apply in full. */
|
|
23
|
+
export function resolveCurrencyDelta(current, delta) {
|
|
24
|
+
const safeDelta = Math.floor(delta);
|
|
25
|
+
if (safeDelta > 0)
|
|
26
|
+
return { operation: "add", amount: safeDelta };
|
|
27
|
+
return { operation: "deduct", amount: Math.min(Math.abs(safeDelta), current) };
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface WalletState {
|
|
2
|
+
balances: Readonly<Record<string, number>>;
|
|
3
|
+
}
|
|
4
|
+
export type ChargeResult = {
|
|
5
|
+
status: "ok";
|
|
6
|
+
state: WalletState;
|
|
7
|
+
} | {
|
|
8
|
+
status: "rejected";
|
|
9
|
+
reason: "insufficient-funds";
|
|
10
|
+
};
|
|
11
|
+
export declare function createEmptyWallet(): WalletState;
|
|
12
|
+
export declare function balance(state: WalletState, currency: string): number;
|
|
13
|
+
export declare function grant(state: WalletState, currency: string, amount: number): WalletState;
|
|
14
|
+
export declare function charge(state: WalletState, currency: string, amount: number): ChargeResult;
|
|
15
|
+
export declare function canAfford(state: WalletState, costs: Readonly<Record<string, number>>): boolean;
|
|
16
|
+
export declare function chargeAll(state: WalletState, costs: Readonly<Record<string, number>>): ChargeResult;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function createEmptyWallet() {
|
|
2
|
+
return { balances: {} };
|
|
3
|
+
}
|
|
4
|
+
function assertValidAmount(amount) {
|
|
5
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
6
|
+
throw new RangeError(`amount must be a positive finite number, got ${amount}`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function balance(state, currency) {
|
|
10
|
+
return state.balances[currency] ?? 0;
|
|
11
|
+
}
|
|
12
|
+
export function grant(state, currency, amount) {
|
|
13
|
+
assertValidAmount(amount);
|
|
14
|
+
return {
|
|
15
|
+
balances: {
|
|
16
|
+
...state.balances,
|
|
17
|
+
[currency]: balance(state, currency) + amount,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function charge(state, currency, amount) {
|
|
22
|
+
assertValidAmount(amount);
|
|
23
|
+
const current = balance(state, currency);
|
|
24
|
+
if (current < amount) {
|
|
25
|
+
return { status: "rejected", reason: "insufficient-funds" };
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
status: "ok",
|
|
29
|
+
state: {
|
|
30
|
+
balances: {
|
|
31
|
+
...state.balances,
|
|
32
|
+
[currency]: current - amount,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function canAfford(state, costs) {
|
|
38
|
+
return Object.entries(costs).every(([currency, amount]) => balance(state, currency) >= amount);
|
|
39
|
+
}
|
|
40
|
+
export function chargeAll(state, costs) {
|
|
41
|
+
if (!canAfford(state, costs)) {
|
|
42
|
+
return { status: "rejected", reason: "insufficient-funds" };
|
|
43
|
+
}
|
|
44
|
+
const balances = { ...state.balances };
|
|
45
|
+
for (const [currency, amount] of Object.entries(costs)) {
|
|
46
|
+
balances[currency] = balance(state, currency) - amount;
|
|
47
|
+
}
|
|
48
|
+
return { status: "ok", state: { balances } };
|
|
49
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ActionCodesMap } from "../input/actionBindings.js";
|
|
2
|
+
import type { ItemTraits } from "../inventory/inventoryModel.js";
|
|
3
|
+
import type { SaveConfig } from "../runtime/save.js";
|
|
4
|
+
import type { AssetCatalog, ModelAssetRef } from "../scene/assetCatalog.js";
|
|
5
|
+
import { type EntityStore } from "../scene/entityStore.js";
|
|
6
|
+
import type { WorldFeature } from "../world/features.js";
|
|
7
|
+
export interface PhysicsConfig {
|
|
8
|
+
gravity?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface InventoryDeclaration {
|
|
11
|
+
slots: number;
|
|
12
|
+
hud?: string;
|
|
13
|
+
accepts?: string | readonly string[];
|
|
14
|
+
traits?: ItemTraits;
|
|
15
|
+
applyModifiers?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export type GameServerConfig = "persistent" | {
|
|
18
|
+
mode: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
};
|
|
21
|
+
export interface GameLoop<TContext = unknown> {
|
|
22
|
+
onInit?(ctx: TContext): void;
|
|
23
|
+
onNewPlayer?(ctx: TContext): void;
|
|
24
|
+
onTick?(ctx: TContext, dt: number): void;
|
|
25
|
+
}
|
|
26
|
+
export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> {
|
|
27
|
+
name: string;
|
|
28
|
+
assets: AssetCatalog<TAssetRef>;
|
|
29
|
+
multiplayer: TMultiplayer;
|
|
30
|
+
scene: EntityStore;
|
|
31
|
+
world?: WorldFeature;
|
|
32
|
+
physics?: PhysicsConfig;
|
|
33
|
+
inventories?: Record<string, InventoryDeclaration>;
|
|
34
|
+
input?: ActionCodesMap;
|
|
35
|
+
server?: GameServerConfig;
|
|
36
|
+
save?: SaveConfig;
|
|
37
|
+
ui?: unknown;
|
|
38
|
+
loop?: GameLoop<any>;
|
|
39
|
+
}
|
|
40
|
+
export type GameDefinitionConfig<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> = Omit<GameDefinition<TAssetRef, TMultiplayer>, "scene">;
|
|
41
|
+
export declare function defineGame<TAssetRef extends ModelAssetRef, TMultiplayer>(config: GameDefinitionConfig<TAssetRef, TMultiplayer>): GameDefinition<TAssetRef, TMultiplayer>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type DeathReason = {
|
|
2
|
+
kind: "player_kill";
|
|
3
|
+
killerUserId: string;
|
|
4
|
+
via?: {
|
|
5
|
+
item?: string;
|
|
6
|
+
};
|
|
7
|
+
} | {
|
|
8
|
+
kind: "environment";
|
|
9
|
+
source: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "self";
|
|
12
|
+
source: string;
|
|
13
|
+
};
|
|
14
|
+
export interface EntityDiedEvent {
|
|
15
|
+
instanceId: string;
|
|
16
|
+
catalogId: string;
|
|
17
|
+
userId?: string;
|
|
18
|
+
displayName?: string;
|
|
19
|
+
reason: DeathReason;
|
|
20
|
+
position: [number, number, number];
|
|
21
|
+
serverId?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface LootGrantedEvent {
|
|
24
|
+
userId: string;
|
|
25
|
+
drops: {
|
|
26
|
+
item?: string;
|
|
27
|
+
currency?: string;
|
|
28
|
+
count: number;
|
|
29
|
+
}[];
|
|
30
|
+
source?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface InventoryAddedEvent {
|
|
33
|
+
userId: string;
|
|
34
|
+
item: string;
|
|
35
|
+
count: number;
|
|
36
|
+
source?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface QuestAcceptedEvent {
|
|
39
|
+
userId: string;
|
|
40
|
+
questId: string;
|
|
41
|
+
}
|
|
42
|
+
export interface QuestUpdatedEvent {
|
|
43
|
+
userId: string;
|
|
44
|
+
questId: string;
|
|
45
|
+
objectiveId?: string;
|
|
46
|
+
progress?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface QuestCompletedEvent {
|
|
49
|
+
userId: string;
|
|
50
|
+
questId: string;
|
|
51
|
+
}
|
|
52
|
+
export interface SocialFriendAddedEvent {
|
|
53
|
+
userId: string;
|
|
54
|
+
friendUserId: string;
|
|
55
|
+
}
|
|
56
|
+
export interface SocialPartyJoinedEvent {
|
|
57
|
+
userId: string;
|
|
58
|
+
partyId: string;
|
|
59
|
+
}
|
|
60
|
+
export interface SocialPartyLeftEvent {
|
|
61
|
+
userId: string;
|
|
62
|
+
partyId: string;
|
|
63
|
+
}
|
|
64
|
+
export interface StatLevelUpEvent {
|
|
65
|
+
userId: string;
|
|
66
|
+
stat: string;
|
|
67
|
+
level: number;
|
|
68
|
+
}
|
|
69
|
+
export interface GameEventMap {
|
|
70
|
+
"entity.died": EntityDiedEvent;
|
|
71
|
+
"loot.granted": LootGrantedEvent;
|
|
72
|
+
"inventory.added": InventoryAddedEvent;
|
|
73
|
+
"quest.accepted": QuestAcceptedEvent;
|
|
74
|
+
"quest.updated": QuestUpdatedEvent;
|
|
75
|
+
"quest.completed": QuestCompletedEvent;
|
|
76
|
+
"social.friend.added": SocialFriendAddedEvent;
|
|
77
|
+
"social.party.joined": SocialPartyJoinedEvent;
|
|
78
|
+
"social.party.left": SocialPartyLeftEvent;
|
|
79
|
+
"stat.levelUp": StatLevelUpEvent;
|
|
80
|
+
}
|
|
81
|
+
export type GameEventHandler<TPayload> = (payload: TPayload) => void;
|
|
82
|
+
export interface GameEvents<TMap extends GameEventMap = GameEventMap> {
|
|
83
|
+
on<TName extends keyof TMap>(name: TName, handler: GameEventHandler<TMap[TName]>): () => void;
|
|
84
|
+
subscribe<TName extends keyof TMap>(name: TName, handler: GameEventHandler<TMap[TName]>): () => void;
|
|
85
|
+
emit<TName extends keyof TMap>(name: TName, payload: TMap[TName]): void;
|
|
86
|
+
}
|
|
87
|
+
export declare function createGameEvents<TMap extends GameEventMap = GameEventMap>(): GameEvents<TMap>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function createGameEvents() {
|
|
2
|
+
const listeners = new Map();
|
|
3
|
+
function on(name, handler) {
|
|
4
|
+
let set = listeners.get(name);
|
|
5
|
+
if (!set) {
|
|
6
|
+
set = new Set();
|
|
7
|
+
listeners.set(name, set);
|
|
8
|
+
}
|
|
9
|
+
const entry = handler;
|
|
10
|
+
set.add(entry);
|
|
11
|
+
return () => set.delete(entry);
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
on,
|
|
15
|
+
subscribe: on,
|
|
16
|
+
emit(name, payload) {
|
|
17
|
+
const set = listeners.get(name);
|
|
18
|
+
if (!set)
|
|
19
|
+
return;
|
|
20
|
+
for (const handler of set)
|
|
21
|
+
handler(payload);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GameEventMap, GameEvents } from "./events.js";
|
|
2
|
+
export interface FeedEntry<T = unknown> {
|
|
3
|
+
at: number;
|
|
4
|
+
data: T;
|
|
5
|
+
}
|
|
6
|
+
export interface GameFeedOptions {
|
|
7
|
+
limit?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface GameFeed {
|
|
10
|
+
bind<TName extends keyof GameEventMap>(action: TName, events: GameEvents): () => void;
|
|
11
|
+
push(action: string, entry: unknown): void;
|
|
12
|
+
recent(action: string, options?: {
|
|
13
|
+
limit?: number;
|
|
14
|
+
}): FeedEntry[];
|
|
15
|
+
subscribe(action: string, listener: (entry: FeedEntry) => void): () => void;
|
|
16
|
+
snapshot(): Record<string, FeedEntry[]>;
|
|
17
|
+
hydrate(data: Record<string, FeedEntry[]>): void;
|
|
18
|
+
}
|
|
19
|
+
export declare function createGameFeed(options?: GameFeedOptions): GameFeed;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function createGameFeed(options) {
|
|
2
|
+
const limit = options?.limit ?? 20;
|
|
3
|
+
const buffers = new Map();
|
|
4
|
+
const listeners = new Map();
|
|
5
|
+
function append(action, entry) {
|
|
6
|
+
const buffer = buffers.get(action) ?? [];
|
|
7
|
+
buffer.push(entry);
|
|
8
|
+
if (buffer.length > limit)
|
|
9
|
+
buffer.splice(0, buffer.length - limit);
|
|
10
|
+
buffers.set(action, buffer);
|
|
11
|
+
for (const listener of listeners.get(action) ?? [])
|
|
12
|
+
listener(entry);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
bind(action, events) {
|
|
16
|
+
return events.on(action, (payload) => append(action, { at: Date.now(), data: payload }));
|
|
17
|
+
},
|
|
18
|
+
push(action, data) {
|
|
19
|
+
append(action, { at: Date.now(), data });
|
|
20
|
+
},
|
|
21
|
+
recent(action, opts) {
|
|
22
|
+
const buffer = buffers.get(action) ?? [];
|
|
23
|
+
const count = opts?.limit ?? buffer.length;
|
|
24
|
+
return buffer.slice(Math.max(0, buffer.length - count));
|
|
25
|
+
},
|
|
26
|
+
subscribe(action, listener) {
|
|
27
|
+
let set = listeners.get(action);
|
|
28
|
+
if (!set) {
|
|
29
|
+
set = new Set();
|
|
30
|
+
listeners.set(action, set);
|
|
31
|
+
}
|
|
32
|
+
set.add(listener);
|
|
33
|
+
return () => set.delete(listener);
|
|
34
|
+
},
|
|
35
|
+
snapshot() {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [action, buffer] of buffers)
|
|
38
|
+
out[action] = buffer.slice();
|
|
39
|
+
return out;
|
|
40
|
+
},
|
|
41
|
+
hydrate(data) {
|
|
42
|
+
buffers.clear();
|
|
43
|
+
for (const [action, buffer] of Object.entries(data))
|
|
44
|
+
buffers.set(action, buffer.slice(-limit));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type LeaderboardScope = "global" | "server" | "profile";
|
|
2
|
+
export interface LeaderboardTrackDef {
|
|
3
|
+
stat?: string;
|
|
4
|
+
currency?: string;
|
|
5
|
+
scope: LeaderboardScope;
|
|
6
|
+
}
|
|
7
|
+
export interface LeaderboardRow {
|
|
8
|
+
stat: string;
|
|
9
|
+
scope: LeaderboardScope;
|
|
10
|
+
serverId?: string;
|
|
11
|
+
userId: string;
|
|
12
|
+
value: number;
|
|
13
|
+
}
|
|
14
|
+
export type IncrementResult = {
|
|
15
|
+
status: "ok";
|
|
16
|
+
value: number;
|
|
17
|
+
} | {
|
|
18
|
+
status: "rejected";
|
|
19
|
+
reason: "not-tracked";
|
|
20
|
+
};
|
|
21
|
+
export interface Leaderboard {
|
|
22
|
+
track(def: LeaderboardTrackDef): void;
|
|
23
|
+
tracked(): {
|
|
24
|
+
stat: string;
|
|
25
|
+
scope: LeaderboardScope;
|
|
26
|
+
}[];
|
|
27
|
+
increment(userId: string, stat: string, options: {
|
|
28
|
+
scope: LeaderboardScope;
|
|
29
|
+
serverId?: string;
|
|
30
|
+
by?: number;
|
|
31
|
+
}): IncrementResult;
|
|
32
|
+
getTop(stat: string, options: {
|
|
33
|
+
scope: LeaderboardScope;
|
|
34
|
+
limit?: number;
|
|
35
|
+
serverId?: string;
|
|
36
|
+
}): {
|
|
37
|
+
userId: string;
|
|
38
|
+
value: number;
|
|
39
|
+
}[];
|
|
40
|
+
getProfile(userId: string): Record<string, number>;
|
|
41
|
+
snapshot(): LeaderboardRow[];
|
|
42
|
+
hydrate(rows: LeaderboardRow[]): void;
|
|
43
|
+
}
|
|
44
|
+
export declare function createLeaderboard(sink?: {
|
|
45
|
+
onIncrement?(row: LeaderboardRow): void;
|
|
46
|
+
}): Leaderboard;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function resolveStatId(def) {
|
|
2
|
+
const stat = def.stat ?? def.currency;
|
|
3
|
+
if (stat === undefined) {
|
|
4
|
+
throw new Error("leaderboard track requires stat or currency");
|
|
5
|
+
}
|
|
6
|
+
return stat;
|
|
7
|
+
}
|
|
8
|
+
function trackKey(stat, scope) {
|
|
9
|
+
return `${scope}:${stat}`;
|
|
10
|
+
}
|
|
11
|
+
function rowKey(stat, scope, serverId, userId) {
|
|
12
|
+
return `${scope}:${stat}:${serverId ?? ""}:${userId}`;
|
|
13
|
+
}
|
|
14
|
+
export function createLeaderboard(sink) {
|
|
15
|
+
const trackedPairs = new Map();
|
|
16
|
+
const rows = new Map();
|
|
17
|
+
return {
|
|
18
|
+
track(def) {
|
|
19
|
+
const stat = resolveStatId(def);
|
|
20
|
+
trackedPairs.set(trackKey(stat, def.scope), { stat, scope: def.scope });
|
|
21
|
+
},
|
|
22
|
+
tracked() {
|
|
23
|
+
return Array.from(trackedPairs.values());
|
|
24
|
+
},
|
|
25
|
+
increment(userId, stat, options) {
|
|
26
|
+
if (!trackedPairs.has(trackKey(stat, options.scope))) {
|
|
27
|
+
return { status: "rejected", reason: "not-tracked" };
|
|
28
|
+
}
|
|
29
|
+
const key = rowKey(stat, options.scope, options.serverId, userId);
|
|
30
|
+
const value = (rows.get(key)?.value ?? 0) + (options.by ?? 1);
|
|
31
|
+
const row = { stat, scope: options.scope, serverId: options.serverId, userId, value };
|
|
32
|
+
rows.set(key, row);
|
|
33
|
+
sink?.onIncrement?.(row);
|
|
34
|
+
return { status: "ok", value };
|
|
35
|
+
},
|
|
36
|
+
getTop(stat, options) {
|
|
37
|
+
return Array.from(rows.values())
|
|
38
|
+
.filter((row) => row.stat === stat && row.scope === options.scope && row.serverId === options.serverId)
|
|
39
|
+
.sort((a, b) => b.value - a.value)
|
|
40
|
+
.slice(0, options.limit ?? 10)
|
|
41
|
+
.map((row) => ({ userId: row.userId, value: row.value }));
|
|
42
|
+
},
|
|
43
|
+
getProfile(userId) {
|
|
44
|
+
const profile = {};
|
|
45
|
+
for (const row of rows.values()) {
|
|
46
|
+
if (row.userId === userId && row.scope === "profile")
|
|
47
|
+
profile[row.stat] = row.value;
|
|
48
|
+
}
|
|
49
|
+
return profile;
|
|
50
|
+
},
|
|
51
|
+
snapshot() {
|
|
52
|
+
return Array.from(rows.values());
|
|
53
|
+
},
|
|
54
|
+
hydrate(data) {
|
|
55
|
+
rows.clear();
|
|
56
|
+
for (const row of data)
|
|
57
|
+
rows.set(rowKey(row.stat, row.scope, row.serverId, row.userId), row);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface LoadoutItemEntry {
|
|
2
|
+
item: string;
|
|
3
|
+
count: number;
|
|
4
|
+
slot?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface LoadoutDef {
|
|
7
|
+
inventories?: Record<string, LoadoutItemEntry[]>;
|
|
8
|
+
stats?: Record<string, {
|
|
9
|
+
current: number;
|
|
10
|
+
max?: number;
|
|
11
|
+
min?: number;
|
|
12
|
+
}>;
|
|
13
|
+
economy?: Record<string, number>;
|
|
14
|
+
unlocks?: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface LoadoutInventoryTransaction {
|
|
17
|
+
put(inventoryId: string, itemId: string, count: number, slot?: number): {
|
|
18
|
+
reason: string;
|
|
19
|
+
} | null;
|
|
20
|
+
commit(): void;
|
|
21
|
+
}
|
|
22
|
+
export interface LoadoutDeps {
|
|
23
|
+
inventory: {
|
|
24
|
+
begin(userId: string): LoadoutInventoryTransaction;
|
|
25
|
+
};
|
|
26
|
+
stats: {
|
|
27
|
+
seed(userId: string, statId: string, pool: {
|
|
28
|
+
current: number;
|
|
29
|
+
max?: number;
|
|
30
|
+
min?: number;
|
|
31
|
+
}): void;
|
|
32
|
+
};
|
|
33
|
+
economy: {
|
|
34
|
+
grant(userId: string, currencyId: string, amount: number): void;
|
|
35
|
+
};
|
|
36
|
+
unlocks: {
|
|
37
|
+
grant(userId: string, unlockId: string): void;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface Loadouts {
|
|
41
|
+
register(defs: Record<string, LoadoutDef>): void;
|
|
42
|
+
has(loadoutId: string): boolean;
|
|
43
|
+
applyLoadout(userId: string, loadoutId: string): {
|
|
44
|
+
reason: string;
|
|
45
|
+
} | null;
|
|
46
|
+
}
|
|
47
|
+
export declare function createLoadouts(deps: LoadoutDeps): Loadouts;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function createLoadouts(deps) {
|
|
2
|
+
const definitions = new Map();
|
|
3
|
+
return {
|
|
4
|
+
register(defs) {
|
|
5
|
+
for (const [loadoutId, def] of Object.entries(defs))
|
|
6
|
+
definitions.set(loadoutId, def);
|
|
7
|
+
},
|
|
8
|
+
has(loadoutId) {
|
|
9
|
+
return definitions.has(loadoutId);
|
|
10
|
+
},
|
|
11
|
+
applyLoadout(userId, loadoutId) {
|
|
12
|
+
const def = definitions.get(loadoutId);
|
|
13
|
+
if (def === undefined)
|
|
14
|
+
return { reason: `unknown loadout "${loadoutId}"` };
|
|
15
|
+
const transaction = deps.inventory.begin(userId);
|
|
16
|
+
for (const [inventoryId, entries] of Object.entries(def.inventories ?? {})) {
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const rejection = transaction.put(inventoryId, entry.item, entry.count, entry.slot);
|
|
19
|
+
if (rejection !== null)
|
|
20
|
+
return { reason: `${inventoryId}: ${rejection.reason}` };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
transaction.commit();
|
|
24
|
+
for (const [statId, pool] of Object.entries(def.stats ?? {})) {
|
|
25
|
+
deps.stats.seed(userId, statId, pool);
|
|
26
|
+
}
|
|
27
|
+
for (const [currencyId, amount] of Object.entries(def.economy ?? {})) {
|
|
28
|
+
deps.economy.grant(userId, currencyId, amount);
|
|
29
|
+
}
|
|
30
|
+
for (const unlockId of def.unlocks ?? []) {
|
|
31
|
+
deps.unlocks.grant(userId, unlockId);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface LootEntry {
|
|
2
|
+
item?: string;
|
|
3
|
+
currency?: string;
|
|
4
|
+
count: number | [number, number];
|
|
5
|
+
weight: number;
|
|
6
|
+
}
|
|
7
|
+
export interface LootTableDef {
|
|
8
|
+
id: string;
|
|
9
|
+
rolls?: number;
|
|
10
|
+
entries: LootEntry[];
|
|
11
|
+
}
|
|
12
|
+
export interface Drop {
|
|
13
|
+
item?: string;
|
|
14
|
+
currency?: string;
|
|
15
|
+
count: number;
|
|
16
|
+
}
|
|
17
|
+
export interface LootRegistry {
|
|
18
|
+
register(def: LootTableDef): void;
|
|
19
|
+
has(id: string): boolean;
|
|
20
|
+
roll(id: string, rng?: () => number): Drop[];
|
|
21
|
+
}
|
|
22
|
+
export declare function createLootRegistry(): LootRegistry;
|
|
23
|
+
export declare function lootTable(def: LootTableDef): LootTableDef;
|
|
24
|
+
export declare function grantDrops(drops: Drop[], appliers: {
|
|
25
|
+
putItem: (itemId: string, count: number) => unknown;
|
|
26
|
+
grantCurrency: (currencyId: string, amount: number) => unknown;
|
|
27
|
+
}): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
function assertValidEntry(entry) {
|
|
2
|
+
const hasItem = entry.item !== undefined;
|
|
3
|
+
const hasCurrency = entry.currency !== undefined;
|
|
4
|
+
if (hasItem === hasCurrency) {
|
|
5
|
+
throw new Error("loot entry must have exactly one of item or currency");
|
|
6
|
+
}
|
|
7
|
+
if (!(entry.weight > 0)) {
|
|
8
|
+
throw new Error(`loot entry weight must be positive, got ${entry.weight}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function assertValidDef(def) {
|
|
12
|
+
if (def.entries.length === 0) {
|
|
13
|
+
throw new Error(`loot table "${def.id}" must have at least one entry`);
|
|
14
|
+
}
|
|
15
|
+
for (const entry of def.entries)
|
|
16
|
+
assertValidEntry(entry);
|
|
17
|
+
}
|
|
18
|
+
function resolveCount(count, rng) {
|
|
19
|
+
if (typeof count === "number")
|
|
20
|
+
return count;
|
|
21
|
+
const [min, max] = count;
|
|
22
|
+
return min + Math.floor(rng() * (max - min + 1));
|
|
23
|
+
}
|
|
24
|
+
function pickEntry(entries, rng) {
|
|
25
|
+
const total = entries.reduce((sum, entry) => sum + entry.weight, 0);
|
|
26
|
+
let roll = rng() * total;
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
roll -= entry.weight;
|
|
29
|
+
if (roll < 0)
|
|
30
|
+
return entry;
|
|
31
|
+
}
|
|
32
|
+
return entries[entries.length - 1];
|
|
33
|
+
}
|
|
34
|
+
function rollEntry(entry, rng) {
|
|
35
|
+
const count = resolveCount(entry.count, rng);
|
|
36
|
+
return entry.item !== undefined ? { item: entry.item, count } : { currency: entry.currency, count };
|
|
37
|
+
}
|
|
38
|
+
export function createLootRegistry() {
|
|
39
|
+
const tables = new Map();
|
|
40
|
+
return {
|
|
41
|
+
register(def) {
|
|
42
|
+
assertValidDef(def);
|
|
43
|
+
if (tables.has(def.id)) {
|
|
44
|
+
throw new Error(`loot table "${def.id}" is already registered`);
|
|
45
|
+
}
|
|
46
|
+
tables.set(def.id, def);
|
|
47
|
+
},
|
|
48
|
+
has(id) {
|
|
49
|
+
return tables.has(id);
|
|
50
|
+
},
|
|
51
|
+
roll(id, rng = Math.random) {
|
|
52
|
+
const table = tables.get(id);
|
|
53
|
+
if (!table) {
|
|
54
|
+
throw new Error(`unknown loot table: ${id}`);
|
|
55
|
+
}
|
|
56
|
+
const rolls = table.rolls ?? 1;
|
|
57
|
+
const drops = [];
|
|
58
|
+
for (let i = 0; i < rolls; i++) {
|
|
59
|
+
drops.push(rollEntry(pickEntry(table.entries, rng), rng));
|
|
60
|
+
}
|
|
61
|
+
return drops;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function lootTable(def) {
|
|
66
|
+
assertValidDef(def);
|
|
67
|
+
return def;
|
|
68
|
+
}
|
|
69
|
+
export function grantDrops(drops, appliers) {
|
|
70
|
+
for (const drop of drops) {
|
|
71
|
+
if (drop.item !== undefined) {
|
|
72
|
+
appliers.putItem(drop.item, drop.count);
|
|
73
|
+
}
|
|
74
|
+
else if (drop.currency !== undefined) {
|
|
75
|
+
appliers.grantCurrency(drop.currency, drop.count);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|