@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.
Files changed (119) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +11 -0
  3. package/dist/assets/spriteSheet.d.ts +46 -0
  4. package/dist/assets/spriteSheet.js +86 -0
  5. package/dist/combat/death.d.ts +61 -0
  6. package/dist/combat/death.js +61 -0
  7. package/dist/combat/effects.d.ts +61 -0
  8. package/dist/combat/effects.js +108 -0
  9. package/dist/combat/projectiles.d.ts +45 -0
  10. package/dist/combat/projectiles.js +156 -0
  11. package/dist/commands/commandRegistry.d.ts +23 -0
  12. package/dist/commands/commandRegistry.js +26 -0
  13. package/dist/economy/currency.d.ts +26 -0
  14. package/dist/economy/currency.js +28 -0
  15. package/dist/economy/wallet.d.ts +16 -0
  16. package/dist/economy/wallet.js +49 -0
  17. package/dist/game/defineGame.d.ts +41 -0
  18. package/dist/game/defineGame.js +7 -0
  19. package/dist/game/events.d.ts +87 -0
  20. package/dist/game/events.js +24 -0
  21. package/dist/game/feed.d.ts +19 -0
  22. package/dist/game/feed.js +47 -0
  23. package/dist/game/leaderboard.d.ts +46 -0
  24. package/dist/game/leaderboard.js +60 -0
  25. package/dist/game/loadout.d.ts +47 -0
  26. package/dist/game/loadout.js +36 -0
  27. package/dist/game/lootTable.d.ts +27 -0
  28. package/dist/game/lootTable.js +78 -0
  29. package/dist/game/playableGame.d.ts +55 -0
  30. package/dist/game/playableGame.js +1 -0
  31. package/dist/game/quest.d.ts +93 -0
  32. package/dist/game/quest.js +228 -0
  33. package/dist/game/social.d.ts +78 -0
  34. package/dist/game/social.js +227 -0
  35. package/dist/game/trade.d.ts +35 -0
  36. package/dist/game/trade.js +53 -0
  37. package/dist/game/unlocks.d.ts +13 -0
  38. package/dist/game/unlocks.js +34 -0
  39. package/dist/index.d.ts +6 -0
  40. package/dist/index.js +6 -0
  41. package/dist/input/actionBindings.d.ts +41 -0
  42. package/dist/input/actionBindings.js +115 -0
  43. package/dist/input/lookChannel.d.ts +29 -0
  44. package/dist/input/lookChannel.js +44 -0
  45. package/dist/input/touchGestures.d.ts +31 -0
  46. package/dist/input/touchGestures.js +72 -0
  47. package/dist/interaction/proximityPrompt.d.ts +51 -0
  48. package/dist/interaction/proximityPrompt.js +76 -0
  49. package/dist/inventory/inventoryModel.d.ts +57 -0
  50. package/dist/inventory/inventoryModel.js +208 -0
  51. package/dist/item/use.d.ts +25 -0
  52. package/dist/item/use.js +40 -0
  53. package/dist/item/weapon.d.ts +8 -0
  54. package/dist/item/weapon.js +18 -0
  55. package/dist/movement/avatarGait.d.ts +14 -0
  56. package/dist/movement/avatarGait.js +20 -0
  57. package/dist/movement/cameraRig.d.ts +15 -0
  58. package/dist/movement/cameraRig.js +43 -0
  59. package/dist/movement/movementModel.d.ts +96 -0
  60. package/dist/movement/movementModel.js +171 -0
  61. package/dist/movement/poseState.d.ts +23 -0
  62. package/dist/movement/poseState.js +61 -0
  63. package/dist/multiplayer/poseSyncGate.d.ts +18 -0
  64. package/dist/multiplayer/poseSyncGate.js +30 -0
  65. package/dist/multiplayer/presenceContract.d.ts +60 -0
  66. package/dist/multiplayer/presenceContract.js +19 -0
  67. package/dist/multiplayer/presenceModel.d.ts +60 -0
  68. package/dist/multiplayer/presenceModel.js +51 -0
  69. package/dist/runtime/adapter.d.ts +24 -0
  70. package/dist/runtime/adapter.js +12 -0
  71. package/dist/runtime/commandRunner.d.ts +15 -0
  72. package/dist/runtime/commandRunner.js +25 -0
  73. package/dist/runtime/gameContext.d.ts +138 -0
  74. package/dist/runtime/gameContext.js +371 -0
  75. package/dist/runtime/gameRuntime.d.ts +42 -0
  76. package/dist/runtime/gameRuntime.js +95 -0
  77. package/dist/runtime/hostPersistence.d.ts +105 -0
  78. package/dist/runtime/hostPersistence.js +117 -0
  79. package/dist/runtime/save.d.ts +9 -0
  80. package/dist/runtime/save.js +29 -0
  81. package/dist/runtime/snapshot.d.ts +87 -0
  82. package/dist/runtime/snapshot.js +75 -0
  83. package/dist/runtime/transport.d.ts +60 -0
  84. package/dist/runtime/transport.js +1 -0
  85. package/dist/scene/assetCatalog.d.ts +10 -0
  86. package/dist/scene/assetCatalog.js +17 -0
  87. package/dist/scene/assetPreload.d.ts +7 -0
  88. package/dist/scene/assetPreload.js +22 -0
  89. package/dist/scene/behaviors.d.ts +19 -0
  90. package/dist/scene/behaviors.js +18 -0
  91. package/dist/scene/entityStats.d.ts +38 -0
  92. package/dist/scene/entityStats.js +65 -0
  93. package/dist/scene/entityStore.d.ts +52 -0
  94. package/dist/scene/entityStore.js +84 -0
  95. package/dist/scene/objectStore.d.ts +28 -0
  96. package/dist/scene/objectStore.js +69 -0
  97. package/dist/scene/spatial.d.ts +34 -0
  98. package/dist/scene/spatial.js +96 -0
  99. package/dist/scene/targeting.d.ts +20 -0
  100. package/dist/scene/targeting.js +56 -0
  101. package/dist/stats/statModifiers.d.ts +18 -0
  102. package/dist/stats/statModifiers.js +56 -0
  103. package/dist/store/changeSignal.d.ts +7 -0
  104. package/dist/store/changeSignal.js +32 -0
  105. package/dist/store/engineStore.d.ts +22 -0
  106. package/dist/store/engineStore.js +50 -0
  107. package/dist/store/features.d.ts +10 -0
  108. package/dist/store/features.js +23 -0
  109. package/dist/store/observableKeyedStore.d.ts +10 -0
  110. package/dist/store/observableKeyedStore.js +42 -0
  111. package/dist/time/calendarClock.d.ts +17 -0
  112. package/dist/time/calendarClock.js +42 -0
  113. package/dist/time/gameClock.d.ts +9 -0
  114. package/dist/time/gameClock.js +20 -0
  115. package/dist/time/serverTick.d.ts +16 -0
  116. package/dist/time/serverTick.js +18 -0
  117. package/dist/world/features.d.ts +39 -0
  118. package/dist/world/features.js +15 -0
  119. package/package.json +32 -0
@@ -0,0 +1,55 @@
1
+ import type { GameContext, GameContextContent } from "../runtime/gameContext.js";
2
+ import type { GameDefinition, GameLoop } from "./defineGame.js";
3
+ export interface CameraFollowState {
4
+ entityId: string;
5
+ target: {
6
+ x: number;
7
+ y: number;
8
+ z: number;
9
+ };
10
+ camera: {
11
+ x: number;
12
+ y: number;
13
+ z: number;
14
+ };
15
+ distance: number;
16
+ }
17
+ export interface GameCameraConfig {
18
+ minDistance?: number;
19
+ maxDistance?: number;
20
+ targetHeight?: number;
21
+ /** Extra offset on top of entity position for the orbit target. */
22
+ targetOffset?: {
23
+ x?: number;
24
+ y?: number;
25
+ z?: number;
26
+ };
27
+ initialDistance?: number;
28
+ initialHeight?: number;
29
+ /** Lock orbit radius while the follow target moves (default true in shell). */
30
+ followLock?: boolean;
31
+ /** When false, orbit target stays fixed (cinematic / debug). Default true. */
32
+ followEnabled?: boolean;
33
+ /** Instance id to follow; defaults to local player in the dev shell. */
34
+ followEntityId?: string;
35
+ /** Fired each frame after cameraFollow lock is applied. */
36
+ onCameraFollow?: (state: CameraFollowState) => void;
37
+ rotateSpeed?: number;
38
+ zoomSpeed?: number;
39
+ dampingFactor?: number;
40
+ targetSmoothing?: number;
41
+ dragTargetSmoothing?: number;
42
+ distanceSmoothing?: number;
43
+ }
44
+ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown> {
45
+ game: GameDefinition;
46
+ content: GameContextContent;
47
+ loop: Required<GameLoop<GameContext>>;
48
+ GameUI: TUi;
49
+ /** Optional canvas-layer VFX component (e.g. traveling projectiles). */
50
+ WorldOverlay?: TWorldOverlay;
51
+ /** Optional scroll-selected hotbar index for primary ability (mouse0). */
52
+ hotbarSelection?: () => number;
53
+ /** Third-person orbit camera tuning for the dev game player shell. */
54
+ camera?: GameCameraConfig;
55
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import type { GameEvents } from "./events.js";
2
+ export interface QuestObjective {
3
+ id: string;
4
+ kind: "kill" | "collect" | string;
5
+ target?: string;
6
+ item?: string;
7
+ count: number;
8
+ partyShare?: {
9
+ radius: number;
10
+ credit: "all" | "tagger";
11
+ };
12
+ }
13
+ export interface QuestRewards {
14
+ xp?: {
15
+ amount: number;
16
+ };
17
+ economy?: Record<string, number>;
18
+ items?: {
19
+ item: string;
20
+ count: number;
21
+ inventory: string;
22
+ }[];
23
+ unlocks?: string[];
24
+ quests?: string[];
25
+ }
26
+ export interface QuestDef {
27
+ id: string;
28
+ title: string;
29
+ description?: string;
30
+ giver?: string;
31
+ turnIn?: string;
32
+ requires?: string[];
33
+ objectives: QuestObjective[];
34
+ rewards?: QuestRewards;
35
+ }
36
+ export type QuestStatus = "active" | "completed";
37
+ export interface QuestObjectiveProgress {
38
+ id: string;
39
+ kind: string;
40
+ count: number;
41
+ progress: number;
42
+ complete: boolean;
43
+ }
44
+ export interface QuestInstance {
45
+ questId: string;
46
+ status: QuestStatus;
47
+ objectives: QuestObjectiveProgress[];
48
+ }
49
+ export type QuestSnapshotEntry = {
50
+ questId: string;
51
+ status: QuestStatus;
52
+ progress: Record<string, number>;
53
+ };
54
+ export interface QuestJournalDeps {
55
+ events: GameEvents;
56
+ rewards: {
57
+ grantXp(userId: string, amount: number): void;
58
+ grantEconomy(userId: string, currencyId: string, amount: number): void;
59
+ grantItem(userId: string, inventoryId: string, itemId: string, count: number): {
60
+ reason: string;
61
+ } | null;
62
+ grantUnlock(userId: string, unlockId: string): void;
63
+ };
64
+ hasUnlock?(userId: string, id: string): boolean;
65
+ partyMembersNear?(userId: string, radius: number): string[];
66
+ }
67
+ export interface QuestJournal {
68
+ register(catalog: QuestDef[] | Record<string, QuestDef>): void;
69
+ has(questId: string): boolean;
70
+ canAccept(userId: string, questId: string): {
71
+ reason: string;
72
+ } | null;
73
+ accept(userId: string, questId: string): {
74
+ reason: string;
75
+ } | null;
76
+ abandon(userId: string, questId: string): void;
77
+ progress(userId: string, questId: string, objectiveId: string, delta: number): void;
78
+ canTurnIn(userId: string, questId: string): {
79
+ reason: string;
80
+ } | null;
81
+ turnIn(userId: string, questId: string): {
82
+ reason: string;
83
+ } | null;
84
+ grant(userId: string, questId: string, options?: {
85
+ completed?: boolean;
86
+ }): void;
87
+ revoke(userId: string, questId: string): void;
88
+ list(userId: string): QuestInstance[];
89
+ bind(action: "entity.died" | "inventory.added"): () => void;
90
+ snapshot(userId: string): QuestSnapshotEntry[];
91
+ hydrate(userId: string, data: QuestSnapshotEntry[]): void;
92
+ }
93
+ export declare function createQuestJournal(deps: QuestJournalDeps): QuestJournal;
@@ -0,0 +1,228 @@
1
+ function clamp(value, min, max) {
2
+ return Math.min(max, Math.max(min, value));
3
+ }
4
+ export function createQuestJournal(deps) {
5
+ const catalog = new Map();
6
+ const users = new Map();
7
+ function requireUserQuests(userId) {
8
+ let quests = users.get(userId);
9
+ if (!quests) {
10
+ quests = new Map();
11
+ users.set(userId, quests);
12
+ }
13
+ return quests;
14
+ }
15
+ function requirementMet(userId, requirementId) {
16
+ if (users.get(userId)?.get(requirementId)?.status === "completed")
17
+ return true;
18
+ return deps.hasUnlock?.(userId, requirementId) ?? false;
19
+ }
20
+ function canAccept(userId, questId) {
21
+ const def = catalog.get(questId);
22
+ if (def === undefined)
23
+ return { reason: `unknown quest "${questId}"` };
24
+ const state = users.get(userId)?.get(questId);
25
+ if (state?.status === "active")
26
+ return { reason: `quest "${questId}" already active` };
27
+ if (state?.status === "completed")
28
+ return { reason: `quest "${questId}" already completed` };
29
+ for (const requirementId of def.requires ?? []) {
30
+ if (!requirementMet(userId, requirementId)) {
31
+ return { reason: `quest "${questId}" requires "${requirementId}"` };
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ function accept(userId, questId) {
37
+ const denied = canAccept(userId, questId);
38
+ if (denied !== null)
39
+ return denied;
40
+ requireUserQuests(userId).set(questId, { status: "active", progress: new Map() });
41
+ deps.events.emit("quest.accepted", { userId, questId });
42
+ return null;
43
+ }
44
+ function progress(userId, questId, objectiveId, delta) {
45
+ const def = catalog.get(questId);
46
+ const state = users.get(userId)?.get(questId);
47
+ if (def === undefined || state === undefined || state.status !== "active")
48
+ return;
49
+ const objective = def.objectives.find((candidate) => candidate.id === objectiveId);
50
+ if (objective === undefined)
51
+ return;
52
+ const previous = state.progress.get(objectiveId) ?? 0;
53
+ const next = clamp(previous + delta, 0, objective.count);
54
+ if (next === previous)
55
+ return;
56
+ state.progress.set(objectiveId, next);
57
+ deps.events.emit("quest.updated", { userId, questId, objectiveId, progress: next });
58
+ }
59
+ function canTurnIn(userId, questId) {
60
+ const def = catalog.get(questId);
61
+ if (def === undefined)
62
+ return { reason: `unknown quest "${questId}"` };
63
+ const state = users.get(userId)?.get(questId);
64
+ if (state === undefined || state.status !== "active") {
65
+ return { reason: `quest "${questId}" is not active` };
66
+ }
67
+ for (const objective of def.objectives) {
68
+ if ((state.progress.get(objective.id) ?? 0) < objective.count) {
69
+ return { reason: `objective "${objective.id}" incomplete` };
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ function applyRewards(userId, rewards) {
75
+ if (rewards.xp)
76
+ deps.rewards.grantXp(userId, rewards.xp.amount);
77
+ for (const [currencyId, amount] of Object.entries(rewards.economy ?? {})) {
78
+ deps.rewards.grantEconomy(userId, currencyId, amount);
79
+ }
80
+ for (const entry of rewards.items ?? []) {
81
+ deps.rewards.grantItem(userId, entry.inventory, entry.item, entry.count);
82
+ }
83
+ for (const unlockId of rewards.unlocks ?? []) {
84
+ deps.rewards.grantUnlock(userId, unlockId);
85
+ }
86
+ }
87
+ function turnIn(userId, questId) {
88
+ const denied = canTurnIn(userId, questId);
89
+ if (denied !== null)
90
+ return denied;
91
+ const def = catalog.get(questId);
92
+ const state = users.get(userId).get(questId);
93
+ state.status = "completed";
94
+ if (def.rewards)
95
+ applyRewards(userId, def.rewards);
96
+ deps.events.emit("quest.completed", { userId, questId });
97
+ for (const nextQuestId of def.rewards?.quests ?? []) {
98
+ if (canAccept(userId, nextQuestId) === null)
99
+ accept(userId, nextQuestId);
100
+ }
101
+ return null;
102
+ }
103
+ function creditKill(killerUserId, catalogId) {
104
+ for (const def of catalog.values()) {
105
+ for (const objective of def.objectives) {
106
+ if (objective.kind !== "kill" || objective.target !== catalogId)
107
+ continue;
108
+ const recipients = new Set([killerUserId]);
109
+ if (objective.partyShare?.credit === "all" && deps.partyMembersNear) {
110
+ for (const member of deps.partyMembersNear(killerUserId, objective.partyShare.radius)) {
111
+ recipients.add(member);
112
+ }
113
+ }
114
+ for (const userId of recipients)
115
+ progress(userId, def.id, objective.id, 1);
116
+ }
117
+ }
118
+ }
119
+ function creditCollect(userId, itemId, count) {
120
+ for (const def of catalog.values()) {
121
+ for (const objective of def.objectives) {
122
+ if (objective.kind !== "collect" || objective.item !== itemId)
123
+ continue;
124
+ progress(userId, def.id, objective.id, count);
125
+ }
126
+ }
127
+ }
128
+ return {
129
+ register(defs) {
130
+ const entries = Array.isArray(defs) ? defs : Object.values(defs);
131
+ for (const def of entries)
132
+ catalog.set(def.id, def);
133
+ },
134
+ has(questId) {
135
+ return catalog.has(questId);
136
+ },
137
+ canAccept,
138
+ accept,
139
+ abandon(userId, questId) {
140
+ const quests = users.get(userId);
141
+ if (quests?.get(questId)?.status === "active")
142
+ quests.delete(questId);
143
+ },
144
+ progress,
145
+ canTurnIn,
146
+ turnIn,
147
+ grant(userId, questId, options) {
148
+ const def = catalog.get(questId);
149
+ if (def === undefined)
150
+ return;
151
+ const completed = options?.completed ?? false;
152
+ const progressMap = new Map();
153
+ if (completed) {
154
+ for (const objective of def.objectives)
155
+ progressMap.set(objective.id, objective.count);
156
+ }
157
+ requireUserQuests(userId).set(questId, {
158
+ status: completed ? "completed" : "active",
159
+ progress: progressMap,
160
+ });
161
+ if (completed)
162
+ deps.events.emit("quest.completed", { userId, questId });
163
+ else
164
+ deps.events.emit("quest.accepted", { userId, questId });
165
+ },
166
+ revoke(userId, questId) {
167
+ users.get(userId)?.delete(questId);
168
+ },
169
+ list(userId) {
170
+ const quests = users.get(userId);
171
+ if (!quests)
172
+ return [];
173
+ const instances = [];
174
+ for (const [questId, state] of quests) {
175
+ const def = catalog.get(questId);
176
+ if (def === undefined)
177
+ continue;
178
+ instances.push({
179
+ questId,
180
+ status: state.status,
181
+ objectives: def.objectives.map((objective) => {
182
+ const current = state.progress.get(objective.id) ?? 0;
183
+ return {
184
+ id: objective.id,
185
+ kind: objective.kind,
186
+ count: objective.count,
187
+ progress: current,
188
+ complete: current >= objective.count,
189
+ };
190
+ }),
191
+ });
192
+ }
193
+ return instances;
194
+ },
195
+ bind(action) {
196
+ if (action === "entity.died") {
197
+ return deps.events.on("entity.died", (event) => {
198
+ if (event.reason.kind !== "player_kill")
199
+ return;
200
+ creditKill(event.reason.killerUserId, event.catalogId);
201
+ });
202
+ }
203
+ return deps.events.on("inventory.added", (event) => {
204
+ creditCollect(event.userId, event.item, event.count);
205
+ });
206
+ },
207
+ snapshot(userId) {
208
+ const quests = users.get(userId);
209
+ if (!quests)
210
+ return [];
211
+ return Array.from(quests, ([questId, state]) => ({
212
+ questId,
213
+ status: state.status,
214
+ progress: Object.fromEntries(state.progress),
215
+ }));
216
+ },
217
+ hydrate(userId, data) {
218
+ const quests = new Map();
219
+ for (const entry of data) {
220
+ quests.set(entry.questId, {
221
+ status: entry.status,
222
+ progress: new Map(Object.entries(entry.progress)),
223
+ });
224
+ }
225
+ users.set(userId, quests);
226
+ },
227
+ };
228
+ }
@@ -0,0 +1,78 @@
1
+ import type { GameEvents } from "./events.js";
2
+ export interface PresenceInfo {
3
+ online: boolean;
4
+ serverId?: string;
5
+ zoneId?: string;
6
+ instanceId?: string;
7
+ }
8
+ export interface SocialDeps {
9
+ events: GameEvents;
10
+ presence?: (userId: string) => PresenceInfo;
11
+ now?: () => number;
12
+ }
13
+ export interface FriendEntry {
14
+ userId: string;
15
+ online: boolean;
16
+ }
17
+ export interface FriendsSnapshot {
18
+ friends: string[];
19
+ blocked: string[];
20
+ }
21
+ export interface Friends {
22
+ canRequest(fromUserId: string, toUserId: string): {
23
+ reason: string;
24
+ } | null;
25
+ request(fromUserId: string, toUserId: string): {
26
+ requestId: string;
27
+ } | {
28
+ reason: string;
29
+ };
30
+ accept(userId: string, requestId: string): {
31
+ reason: string;
32
+ } | null;
33
+ remove(userId: string, friendUserId: string): void;
34
+ block(userId: string, targetUserId: string): void;
35
+ list(userId: string): FriendEntry[];
36
+ snapshot(userId: string): FriendsSnapshot;
37
+ hydrate(userId: string, data: FriendsSnapshot): void;
38
+ }
39
+ export type PartyRole = "leader" | "member";
40
+ export interface PartyMemberEntry {
41
+ userId: string;
42
+ role: PartyRole;
43
+ }
44
+ export interface PartyConfig {
45
+ maxMembers: number;
46
+ inviteTtlMs?: number;
47
+ }
48
+ export interface Party {
49
+ register(config: PartyConfig): void;
50
+ canInvite(fromUserId: string, toUserId: string): {
51
+ reason: string;
52
+ } | null;
53
+ invite(fromUserId: string, toUserId: string): {
54
+ inviteId: string;
55
+ } | {
56
+ reason: string;
57
+ };
58
+ accept(userId: string, inviteId: string): {
59
+ reason: string;
60
+ } | null;
61
+ kick(leaderUserId: string, memberUserId: string): {
62
+ reason: string;
63
+ } | null;
64
+ leave(userId: string): void;
65
+ promote(leaderUserId: string, memberUserId: string): {
66
+ reason: string;
67
+ } | null;
68
+ list(userId: string): PartyMemberEntry[];
69
+ membersOf(userId: string): string[];
70
+ }
71
+ export interface Social {
72
+ friends: Friends;
73
+ party: Party;
74
+ presence: {
75
+ get(userId: string): PresenceInfo;
76
+ };
77
+ }
78
+ export declare function createSocial(deps: SocialDeps): Social;
@@ -0,0 +1,227 @@
1
+ const DEFAULT_INVITE_TTL_MS = 60_000;
2
+ export function createSocial(deps) {
3
+ const now = deps.now ?? Date.now;
4
+ const events = deps.events;
5
+ const friendships = new Map();
6
+ const blocked = new Map();
7
+ const friendRequests = new Map();
8
+ let requestCounter = 0;
9
+ function requireSet(map, userId) {
10
+ let set = map.get(userId);
11
+ if (!set) {
12
+ set = new Set();
13
+ map.set(userId, set);
14
+ }
15
+ return set;
16
+ }
17
+ function pendingBetween(a, b) {
18
+ for (const request of friendRequests.values()) {
19
+ if ((request.from === a && request.to === b) || (request.from === b && request.to === a))
20
+ return true;
21
+ }
22
+ return false;
23
+ }
24
+ function canRequest(fromUserId, toUserId) {
25
+ if (fromUserId === toUserId)
26
+ return { reason: "cannot friend yourself" };
27
+ if (friendships.get(fromUserId)?.has(toUserId))
28
+ return { reason: "already friends" };
29
+ if (pendingBetween(fromUserId, toUserId))
30
+ return { reason: "request already pending" };
31
+ if (blocked.get(toUserId)?.has(fromUserId) || blocked.get(fromUserId)?.has(toUserId)) {
32
+ return { reason: "blocked" };
33
+ }
34
+ return null;
35
+ }
36
+ const friends = {
37
+ canRequest,
38
+ request(fromUserId, toUserId) {
39
+ const denied = canRequest(fromUserId, toUserId);
40
+ if (denied !== null)
41
+ return denied;
42
+ requestCounter += 1;
43
+ const requestId = `freq_${requestCounter}`;
44
+ friendRequests.set(requestId, { from: fromUserId, to: toUserId });
45
+ return { requestId };
46
+ },
47
+ accept(userId, requestId) {
48
+ const request = friendRequests.get(requestId);
49
+ if (request === undefined)
50
+ return { reason: `unknown request "${requestId}"` };
51
+ if (request.to !== userId)
52
+ return { reason: "request is not addressed to this user" };
53
+ friendRequests.delete(requestId);
54
+ requireSet(friendships, request.from).add(request.to);
55
+ requireSet(friendships, request.to).add(request.from);
56
+ events.emit("social.friend.added", { userId: request.to, friendUserId: request.from });
57
+ events.emit("social.friend.added", { userId: request.from, friendUserId: request.to });
58
+ return null;
59
+ },
60
+ remove(userId, friendUserId) {
61
+ friendships.get(userId)?.delete(friendUserId);
62
+ friendships.get(friendUserId)?.delete(userId);
63
+ },
64
+ block(userId, targetUserId) {
65
+ requireSet(blocked, userId).add(targetUserId);
66
+ friends.remove(userId, targetUserId);
67
+ for (const [requestId, request] of friendRequests) {
68
+ const between = (request.from === userId && request.to === targetUserId) ||
69
+ (request.from === targetUserId && request.to === userId);
70
+ if (between)
71
+ friendRequests.delete(requestId);
72
+ }
73
+ },
74
+ list(userId) {
75
+ return Array.from(friendships.get(userId) ?? [], (friendUserId) => ({
76
+ userId: friendUserId,
77
+ online: deps.presence?.(friendUserId).online ?? false,
78
+ }));
79
+ },
80
+ snapshot(userId) {
81
+ return {
82
+ friends: Array.from(friendships.get(userId) ?? []),
83
+ blocked: Array.from(blocked.get(userId) ?? []),
84
+ };
85
+ },
86
+ hydrate(userId, data) {
87
+ friendships.set(userId, new Set(data.friends));
88
+ blocked.set(userId, new Set(data.blocked));
89
+ },
90
+ };
91
+ let partyConfig = null;
92
+ const parties = new Map();
93
+ const memberToParty = new Map();
94
+ const partyInvites = new Map();
95
+ let partyCounter = 0;
96
+ let inviteCounter = 0;
97
+ function partyOf(userId) {
98
+ const partyId = memberToParty.get(userId);
99
+ if (partyId === undefined)
100
+ return null;
101
+ return parties.get(partyId) ?? null;
102
+ }
103
+ function canInvite(fromUserId, toUserId) {
104
+ if (partyConfig === null)
105
+ return { reason: "party not configured" };
106
+ if (fromUserId === toUserId)
107
+ return { reason: "cannot invite yourself" };
108
+ if (memberToParty.has(toUserId))
109
+ return { reason: "already in a party" };
110
+ const party = partyOf(fromUserId);
111
+ if (party !== null && party.members.length >= partyConfig.maxMembers) {
112
+ return { reason: "party full" };
113
+ }
114
+ return null;
115
+ }
116
+ function addMember(party, userId) {
117
+ party.members.push(userId);
118
+ memberToParty.set(userId, party.id);
119
+ events.emit("social.party.joined", { userId, partyId: party.id });
120
+ }
121
+ function removeMember(party, userId) {
122
+ party.members = party.members.filter((member) => member !== userId);
123
+ memberToParty.delete(userId);
124
+ events.emit("social.party.left", { userId, partyId: party.id });
125
+ if (party.members.length === 0) {
126
+ parties.delete(party.id);
127
+ return;
128
+ }
129
+ if (party.leader === userId)
130
+ party.leader = party.members[0];
131
+ }
132
+ const party = {
133
+ register(config) {
134
+ partyConfig = config;
135
+ },
136
+ canInvite,
137
+ invite(fromUserId, toUserId) {
138
+ const denied = canInvite(fromUserId, toUserId);
139
+ if (denied !== null)
140
+ return denied;
141
+ inviteCounter += 1;
142
+ const inviteId = `pinv_${inviteCounter}`;
143
+ partyInvites.set(inviteId, { from: fromUserId, to: toUserId, createdAt: now() });
144
+ return { inviteId };
145
+ },
146
+ accept(userId, inviteId) {
147
+ if (partyConfig === null)
148
+ return { reason: "party not configured" };
149
+ const invite = partyInvites.get(inviteId);
150
+ if (invite === undefined)
151
+ return { reason: `unknown invite "${inviteId}"` };
152
+ if (invite.to !== userId)
153
+ return { reason: "invite is not addressed to this user" };
154
+ const ttlMs = partyConfig.inviteTtlMs ?? DEFAULT_INVITE_TTL_MS;
155
+ if (now() - invite.createdAt > ttlMs) {
156
+ partyInvites.delete(inviteId);
157
+ return { reason: "invite expired" };
158
+ }
159
+ if (memberToParty.has(userId))
160
+ return { reason: "already in a party" };
161
+ const existing = partyOf(invite.from);
162
+ if (existing !== null && existing.members.length >= partyConfig.maxMembers) {
163
+ return { reason: "party full" };
164
+ }
165
+ partyInvites.delete(inviteId);
166
+ if (existing !== null) {
167
+ addMember(existing, userId);
168
+ return null;
169
+ }
170
+ partyCounter += 1;
171
+ const created = { id: `party_${partyCounter}`, leader: invite.from, members: [] };
172
+ parties.set(created.id, created);
173
+ addMember(created, invite.from);
174
+ addMember(created, userId);
175
+ return null;
176
+ },
177
+ kick(leaderUserId, memberUserId) {
178
+ const leaderParty = partyOf(leaderUserId);
179
+ if (leaderParty === null || leaderParty.leader !== leaderUserId) {
180
+ return { reason: "not the party leader" };
181
+ }
182
+ if (!leaderParty.members.includes(memberUserId) || memberUserId === leaderUserId) {
183
+ return { reason: "not a member of this party" };
184
+ }
185
+ removeMember(leaderParty, memberUserId);
186
+ return null;
187
+ },
188
+ leave(userId) {
189
+ const current = partyOf(userId);
190
+ if (current === null)
191
+ return;
192
+ removeMember(current, userId);
193
+ },
194
+ promote(leaderUserId, memberUserId) {
195
+ const leaderParty = partyOf(leaderUserId);
196
+ if (leaderParty === null || leaderParty.leader !== leaderUserId) {
197
+ return { reason: "not the party leader" };
198
+ }
199
+ if (!leaderParty.members.includes(memberUserId)) {
200
+ return { reason: "not a member of this party" };
201
+ }
202
+ leaderParty.leader = memberUserId;
203
+ return null;
204
+ },
205
+ list(userId) {
206
+ const current = partyOf(userId);
207
+ if (current === null)
208
+ return [];
209
+ return current.members.map((member) => ({
210
+ userId: member,
211
+ role: member === current.leader ? "leader" : "member",
212
+ }));
213
+ },
214
+ membersOf(userId) {
215
+ return partyOf(userId)?.members.slice() ?? [];
216
+ },
217
+ };
218
+ return {
219
+ friends,
220
+ party,
221
+ presence: {
222
+ get(userId) {
223
+ return deps.presence?.(userId) ?? { online: false };
224
+ },
225
+ },
226
+ };
227
+ }