@plasius/renderer 1.0.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 (79) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/CODE_OF_CONDUCT.md +79 -0
  3. package/CONTRIBUTORS.md +27 -0
  4. package/LICENSE +203 -0
  5. package/README.md +70 -0
  6. package/SECURITY.md +17 -0
  7. package/dist/adaptivedpr.d.ts +2 -0
  8. package/dist/adaptivedpr.d.ts.map +1 -0
  9. package/dist/adaptivedpr.js +65 -0
  10. package/dist/camera/cameraRigProfile.d.ts +12 -0
  11. package/dist/camera/cameraRigProfile.d.ts.map +1 -0
  12. package/dist/camera/cameraRigProfile.js +18 -0
  13. package/dist/camera/managedCameraController.d.ts +49 -0
  14. package/dist/camera/managedCameraController.d.ts.map +1 -0
  15. package/dist/camera/managedCameraController.js +271 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +3 -0
  19. package/dist/landscape.d.ts +2 -0
  20. package/dist/landscape.d.ts.map +1 -0
  21. package/dist/landscape.js +120 -0
  22. package/dist/player/player.d.ts +8 -0
  23. package/dist/player/player.d.ts.map +1 -0
  24. package/dist/player/player.js +203 -0
  25. package/dist/player/playerstore.d.ts +205 -0
  26. package/dist/player/playerstore.d.ts.map +1 -0
  27. package/dist/player/playerstore.js +500 -0
  28. package/dist/renderStateProvider.d.ts +57 -0
  29. package/dist/renderStateProvider.d.ts.map +1 -0
  30. package/dist/renderStateProvider.js +50 -0
  31. package/dist/renderer.d.ts +9 -0
  32. package/dist/renderer.d.ts.map +1 -0
  33. package/dist/renderer.js +165 -0
  34. package/dist/scene.d.ts +7 -0
  35. package/dist/scene.d.ts.map +1 -0
  36. package/dist/scene.js +10 -0
  37. package/dist/shaders/fragment/landscapeFragmentShader.js +141 -0
  38. package/dist/shaders/landscapeShader.d.ts +13 -0
  39. package/dist/shaders/landscapeShader.d.ts.map +1 -0
  40. package/dist/shaders/landscapeShader.js +25 -0
  41. package/dist/shaders/vertex/landscapeVertexShader.js +67 -0
  42. package/dist/styles/renderer.module.css +90 -0
  43. package/dist/worldSpaceCompositor.d.ts +50 -0
  44. package/dist/worldSpaceCompositor.d.ts.map +1 -0
  45. package/dist/worldSpaceCompositor.js +159 -0
  46. package/dist/xr/rendererXrBridge.d.ts +12 -0
  47. package/dist/xr/rendererXrBridge.d.ts.map +1 -0
  48. package/dist/xr/rendererXrBridge.js +17 -0
  49. package/docs/adrs/adr-0001-renderer-package-scope.md +21 -0
  50. package/docs/adrs/adr-0002-public-repo-governance.md +24 -0
  51. package/docs/adrs/adr-0003-world-space-compositor-contracts.md +34 -0
  52. package/docs/adrs/adr-template.md +35 -0
  53. package/docs/design/0001-public-package-scope.md +18 -0
  54. package/docs/tdrs/index.md +3 -0
  55. package/docs/tdrs/tdr-0001-renderer-public-package-standards-alignment.md +19 -0
  56. package/legal/CLA-REGISTRY.csv +1 -0
  57. package/legal/CLA.md +22 -0
  58. package/legal/CORPORATE_CLA.md +57 -0
  59. package/legal/INDIVIDUAL_CLA.md +91 -0
  60. package/package.json +117 -0
  61. package/src/adaptivedpr.tsx +74 -0
  62. package/src/camera/cameraRigProfile.ts +29 -0
  63. package/src/camera/managedCameraController.tsx +401 -0
  64. package/src/global.d.ts +10 -0
  65. package/src/index.ts +3 -0
  66. package/src/landscape.tsx +321 -0
  67. package/src/player/player.tsx +257 -0
  68. package/src/player/playerstore.tsx +733 -0
  69. package/src/renderStateProvider.tsx +121 -0
  70. package/src/renderer.tsx +294 -0
  71. package/src/scene.tsx +42 -0
  72. package/src/shaders/fragment/landscapeFragmentShader.d.ts +4 -0
  73. package/src/shaders/fragment/landscapeFragmentShader.js +141 -0
  74. package/src/shaders/landscapeShader.tsx +39 -0
  75. package/src/shaders/vertex/landscapeVertexShader.d.ts +4 -0
  76. package/src/shaders/vertex/landscapeVertexShader.js +67 -0
  77. package/src/styles/renderer.module.css +90 -0
  78. package/src/worldSpaceCompositor.ts +265 -0
  79. package/src/xr/rendererXrBridge.ts +44 -0
@@ -0,0 +1,733 @@
1
+ import React from "react";
2
+ import { createScopedStoreContext } from "@plasius/react-state";
3
+
4
+ /**
5
+ * PlayerStore — React Scoped Store for player state (position, stats, inventory, equipment, skills, effects)
6
+ * No external deps. Strongly typed. Designed to be colocated with the renderer/game.
7
+ */
8
+
9
+ // ====== Basic Types ======
10
+ export interface Vec3 {
11
+ x: number;
12
+ y: number;
13
+ z: number;
14
+ }
15
+
16
+ export type AttributeKey =
17
+ // Physical
18
+ | "strength"
19
+ | "dexterity"
20
+ | "endurance"
21
+ // Mental
22
+ | "intellect"
23
+ | "willpower"
24
+ | "creativity"
25
+ // Spiritual
26
+ | "spirit"
27
+ | "wisdom"
28
+ | "charisma";
29
+
30
+ // Triangular domain types
31
+ export interface PhysicalAttributes {
32
+ strength: number;
33
+ dexterity: number;
34
+ endurance: number;
35
+ }
36
+ export interface MentalAttributes {
37
+ intellect: number;
38
+ willpower: number;
39
+ creativity: number;
40
+ }
41
+ export interface SpiritualAttributes {
42
+ spirit: number;
43
+ wisdom: number;
44
+ charisma: number;
45
+ }
46
+
47
+ // Base layer (earned): each triangle must sum to 99, each attr in [1,49]
48
+ export interface AttributesBase {
49
+ physical: PhysicalAttributes;
50
+ mental: MentalAttributes;
51
+ spiritual: SpiritualAttributes;
52
+ }
53
+
54
+ // Optional additive layers (gear/effects): same shape as flat; values can be +/-
55
+ export interface AttributesFlat {
56
+ strength: number;
57
+ dexterity: number;
58
+ endurance: number;
59
+ intellect: number;
60
+ willpower: number;
61
+ creativity: number;
62
+ spirit: number;
63
+ wisdom: number;
64
+ charisma: number;
65
+ }
66
+
67
+ export interface Resources {
68
+ health: number; // current
69
+ healthMax: number;
70
+ energy: number; // e.g., stamina/energy/mana — name-agnostic, customize later
71
+ energyMax: number;
72
+ }
73
+
74
+ export interface Item {
75
+ id: string; // stable id (instance or template id depending on your pipeline)
76
+ name: string;
77
+ kind:
78
+ | "consumable"
79
+ | "weapon"
80
+ | "armor"
81
+ | "trinket"
82
+ | "material"
83
+ | "quest"
84
+ | "misc";
85
+ weight?: number; // carry weight contribution
86
+ stackable?: boolean; // can the same id stack?
87
+ maxStack?: number; // upper bound if stackable (default 99)
88
+ tags?: string[]; // arbitrary tags for filtering/rules
89
+ // Attribute modifiers applied when equipped (or when consumed if consumable)
90
+ modifiers?: Partial<Record<AttributeKey, number>>;
91
+ }
92
+
93
+ export interface InventorySlot {
94
+ itemId: string; // references Item.id
95
+ qty: number;
96
+ }
97
+
98
+ export type EquipmentSlot =
99
+ | "head"
100
+ | "chest"
101
+ | "legs"
102
+ | "hands"
103
+ | "feet"
104
+ | "weapon"
105
+ | "offhand"
106
+ | "back"
107
+ | "ring1"
108
+ | "ring2"
109
+ | "amulet";
110
+
111
+ export type Equipment = Partial<Record<EquipmentSlot, string /* itemId */>>;
112
+
113
+ export interface Skill {
114
+ id: string;
115
+ name: string;
116
+ level: number; // proficiency 0–100 (used by ln scaling)
117
+ active?: boolean; // whether the skill is currently active (toggles penalties/bonuses)
118
+ source?: "innate" | "taught" | "researched"; // acquisition path (optional)
119
+ tags?: string[]; // e.g., ["rare", "forbidden", "movement"]
120
+ cooldownMs?: number;
121
+ resourceCost?: number; // cost from energy/mana etc.
122
+ }
123
+
124
+ export interface StatusEffectModifier {
125
+ attributes?: Partial<Record<AttributeKey, number>>; // additive modifiers
126
+ resources?: Partial<Pick<Resources, "healthMax" | "energyMax">>;
127
+ moveSpeedMult?: number; // multiplicative e.g. 0.9 = -10%
128
+ damageMult?: number;
129
+ }
130
+
131
+ export interface StatusEffect {
132
+ id: string;
133
+ name: string;
134
+ appliedAt: number; // epoch ms
135
+ durationMs: number; // 0 = infinite
136
+ modifiers?: StatusEffectModifier;
137
+ }
138
+
139
+ // ====== Player State ======
140
+ export interface PlayerState {
141
+ // Transform
142
+ position: Vec3;
143
+ lookAt: Vec3;
144
+ velocity: Vec3;
145
+
146
+ // Core
147
+ attributesBase: AttributesBase; // earned, triangle-constrained
148
+ attributesGear: AttributesFlat; // from equipment totals (additive)
149
+ attributesEffects: AttributesFlat; // from active effects (additive)
150
+ resources: Resources;
151
+ lastActiveAt?: number; // epoch ms for decay logic
152
+
153
+ // Equipment/Inventory
154
+ items: Record<string, Item>; // item registry (by id). Up to the game to hydrate
155
+ inventory: InventorySlot[]; // backpack list
156
+ inventoryCapacity: number; // max slots
157
+ equipment: Equipment; // slot -> itemId
158
+
159
+ // Skills/Effects
160
+ skills: Record<string, Skill>;
161
+ effects: Record<string, StatusEffect>;
162
+ }
163
+
164
+ export const defaultAttributesBase: AttributesBase = {
165
+ physical: { strength: 33, dexterity: 33, endurance: 33 },
166
+ mental: { intellect: 33, willpower: 33, creativity: 33 },
167
+ spiritual: { spirit: 33, wisdom: 33, charisma: 33 },
168
+ };
169
+
170
+ export const zeroFlat: AttributesFlat = {
171
+ strength: 0,
172
+ dexterity: 0,
173
+ endurance: 0,
174
+ intellect: 0,
175
+ willpower: 0,
176
+ creativity: 0,
177
+ spirit: 0,
178
+ wisdom: 0,
179
+ charisma: 0,
180
+ };
181
+
182
+ export const defaultState: PlayerState = {
183
+ position: { x: 0, y: 1.6, z: 0 },
184
+ lookAt: { x: 0, y: 1.6, z: -1 },
185
+ velocity: { x: 0, y: 0, z: 0 },
186
+ attributesBase: { ...defaultAttributesBase },
187
+ attributesGear: { ...zeroFlat },
188
+ attributesEffects: { ...zeroFlat },
189
+ resources: { health: 100, healthMax: 100, energy: 100, energyMax: 100 },
190
+ items: {},
191
+ inventory: [],
192
+ inventoryCapacity: 24,
193
+ equipment: {},
194
+ skills: {},
195
+ effects: {},
196
+ lastActiveAt: undefined,
197
+ };
198
+
199
+ // ====== Actions ======
200
+ export type PlayerAction =
201
+ | { type: "set_position"; payload: Vec3 }
202
+ | { type: "set_look_at"; payload: Vec3 }
203
+ | { type: "set_velocity"; payload: Vec3 }
204
+ | { type: "learn_skill"; payload: Skill }
205
+ | { type: "set_skill_active"; payload: { id: string; active: boolean } }
206
+ | { type: "set_skill_level"; payload: { id: string; level: number } }
207
+ | { type: "forget_skill"; payload: { id: string } }
208
+ | { type: "apply_effect"; payload: StatusEffect }
209
+ | { type: "remove_effect"; payload: { id: string } }
210
+ | { type: "register_item"; payload: Item }
211
+ | { type: "add_item"; payload: { itemId: string; qty: number } }
212
+ | { type: "remove_item"; payload: { itemId: string; qty: number } }
213
+ | { type: "equip"; payload: { slot: EquipmentSlot; itemId: string } }
214
+ | { type: "unequip"; payload: { slot: EquipmentSlot } }
215
+ | { type: "set_resource"; payload: Partial<Resources> }
216
+ | {
217
+ type: "set_attributes_base";
218
+ payload: Partial<Record<AttributeKey, number>>;
219
+ }
220
+ | {
221
+ type: "set_attributes_gear";
222
+ payload: Partial<Record<AttributeKey, number>>;
223
+ }
224
+ | {
225
+ type: "set_attributes_effects";
226
+ payload: Partial<Record<AttributeKey, number>>;
227
+ };
228
+
229
+ // ====== Helpers ======
230
+ const clamp = (v: number, min: number, max: number) =>
231
+ Math.max(min, Math.min(max, v));
232
+
233
+ function stackableCount(item: Item | undefined): number {
234
+ if (!item) return 1;
235
+ if (!item.stackable) return 1;
236
+ return item.maxStack ?? 99;
237
+ }
238
+
239
+ function addToInventory(
240
+ state: PlayerState,
241
+ itemId: string,
242
+ qty: number
243
+ ): PlayerState {
244
+ if (qty <= 0) return state;
245
+ const item = state.items[itemId];
246
+ const maxStack = stackableCount(item);
247
+ let remaining = qty;
248
+
249
+ // Fill existing stacks first
250
+ const inv = state.inventory.map((slot) => {
251
+ if (remaining === 0) return slot;
252
+ if (slot.itemId !== itemId) return slot;
253
+ const space = maxStack - slot.qty;
254
+ if (space <= 0) return slot;
255
+ const add = Math.min(space, remaining);
256
+ remaining -= add;
257
+ return { ...slot, qty: slot.qty + add };
258
+ });
259
+
260
+ // Create new stacks while capacity allows
261
+ const result = [...inv];
262
+ while (remaining > 0 && result.length < state.inventoryCapacity) {
263
+ const add = Math.min(remaining, maxStack);
264
+ result.push({ itemId, qty: add });
265
+ remaining -= add;
266
+ }
267
+
268
+ return { ...state, inventory: result };
269
+ }
270
+
271
+ function removeFromInventory(
272
+ state: PlayerState,
273
+ itemId: string,
274
+ qty: number
275
+ ): PlayerState {
276
+ if (qty <= 0) return state;
277
+ let remaining = qty;
278
+ const result: InventorySlot[] = [];
279
+ for (const slot of state.inventory) {
280
+ if (slot.itemId !== itemId) {
281
+ result.push(slot);
282
+ continue;
283
+ }
284
+ if (remaining === 0) {
285
+ result.push(slot);
286
+ continue;
287
+ }
288
+ if (slot.qty > remaining) {
289
+ result.push({ itemId, qty: slot.qty - remaining });
290
+ remaining = 0;
291
+ } else {
292
+ remaining -= slot.qty; // drop this slot
293
+ }
294
+ }
295
+ return { ...state, inventory: result };
296
+ }
297
+
298
+ function clampAttr(n: number) {
299
+ return Math.max(1, Math.min(49, Math.round(n)));
300
+ }
301
+ function sum3(a: number, b: number, c: number) {
302
+ return a + b + c;
303
+ }
304
+ function normalizeTriangle(a: number, b: number, c: number) {
305
+ // Ensure sum=99 and each in [1,49]
306
+ let x = clampAttr(a),
307
+ y = clampAttr(b),
308
+ z = clampAttr(c);
309
+ let total = x + y + z;
310
+ if (total === 99) return [x, y, z] as const;
311
+ // Scale to 99, then clamp again, then adjust leftovers greedily
312
+ const scale = 99 / total;
313
+ x = clampAttr(x * scale);
314
+ y = clampAttr(y * scale);
315
+ z = clampAttr(z * scale);
316
+ total = x + y + z;
317
+ // Distribute deficit/excess (typed tuples + stable resorting)
318
+ let order: Array<["x" | "y" | "z", number]> = [
319
+ ["x", x] as ["x", number],
320
+ ["y", y] as ["y", number],
321
+ ["z", z] as ["z", number],
322
+ ];
323
+ const sortDesc = () => {
324
+ order.sort((a, b) => b[1] - a[1]);
325
+ };
326
+ sortDesc();
327
+
328
+ while (total !== 99) {
329
+ if (total > 99) {
330
+ // reduce the largest above 1
331
+ for (const [k, v] of order) {
332
+ if (v > 1) {
333
+ if (k === "x") x--;
334
+ else if (k === "y") y--;
335
+ else z--;
336
+ total--;
337
+ // update order values and resort
338
+ if (k === "x") order[0][1] = x;
339
+ else if (k === "y") order[1][1] = y;
340
+ else order[2][1] = z;
341
+ sortDesc();
342
+ break;
343
+ }
344
+ }
345
+ } else {
346
+ // increase the smallest below 49
347
+ for (const [k, v] of [...order].reverse()) {
348
+ if (v < 49) {
349
+ if (k === "x") x++;
350
+ else if (k === "y") y++;
351
+ else z++;
352
+ total++;
353
+ // update order values and resort
354
+ if (k === "x") order[0][1] = x;
355
+ else if (k === "y") order[1][1] = y;
356
+ else order[2][1] = z;
357
+ sortDesc();
358
+ break;
359
+ }
360
+ }
361
+ }
362
+ }
363
+ return [x, y, z] as const;
364
+ }
365
+
366
+ function flattenBase(base: AttributesBase): AttributesFlat {
367
+ const [S, D, E] = normalizeTriangle(
368
+ base.physical.strength,
369
+ base.physical.dexterity,
370
+ base.physical.endurance
371
+ );
372
+ const [I, W, C] = normalizeTriangle(
373
+ base.mental.intellect,
374
+ base.mental.willpower,
375
+ base.mental.creativity
376
+ );
377
+ const [Sp, Wi, Ch] = normalizeTriangle(
378
+ base.spiritual.spirit,
379
+ base.spiritual.wisdom,
380
+ base.spiritual.charisma
381
+ );
382
+ return {
383
+ strength: S,
384
+ dexterity: D,
385
+ endurance: E,
386
+ intellect: I,
387
+ willpower: W,
388
+ creativity: C,
389
+ spirit: Sp,
390
+ wisdom: Wi,
391
+ charisma: Ch,
392
+ };
393
+ }
394
+
395
+ // === Skill-side cross-triangle modifiers ===
396
+ function lnContribution(x: number, s = 25) {
397
+ return Math.log(1 + Math.max(0, x) / s); // safe-guard
398
+ }
399
+
400
+ function emptyFlat(): AttributesFlat {
401
+ return {
402
+ strength: 0,
403
+ dexterity: 0,
404
+ endurance: 0,
405
+ intellect: 0,
406
+ willpower: 0,
407
+ creativity: 0,
408
+ spirit: 0,
409
+ wisdom: 0,
410
+ charisma: 0,
411
+ };
412
+ }
413
+
414
+ // Map rare/hidden skills to cross-triangle penalties (and potential future bonuses) using ln scaling
415
+ // Data-driven skill → attribute modifier map (multiplier per ln unit)
416
+ const SKILL_ATTR_MODS: Record<string, Partial<Record<AttributeKey, number>>> = {
417
+ shadowstep: { charisma: -2.0 },
418
+ dreamwalking: { wisdom: -1.0, charisma: -1.0 },
419
+ soulbinding: { creativity: -1.5, endurance: -1.5 },
420
+ chronomancy: { endurance: -1.5, spirit: -0.5 },
421
+ astral_projection: { strength: -1.0, endurance: -1.0 },
422
+ bloodcraft: { charisma: -2.0, spirit: -2.0 },
423
+ telepathy: { charisma: -0.5, wisdom: -0.5 },
424
+ telekinesis: { endurance: -0.5 },
425
+ possession: { spirit: -2.5, charisma: -2.5 },
426
+ };
427
+
428
+ function addFlatInto(dst: AttributesFlat, src: AttributesFlat) {
429
+ dst.strength += src.strength;
430
+ dst.dexterity += src.dexterity;
431
+ dst.endurance += src.endurance;
432
+ dst.intellect += src.intellect;
433
+ dst.willpower += src.willpower;
434
+ dst.creativity += src.creativity;
435
+ dst.spirit += src.spirit;
436
+ dst.wisdom += src.wisdom;
437
+ dst.charisma += src.charisma;
438
+ }
439
+
440
+ function skillAttributeModifiers(state: PlayerState): AttributesFlat {
441
+ const out = emptyFlat();
442
+ for (const s of Object.values(state.skills)) {
443
+ if (!s || !(s.active ?? false) || (s.level ?? 0) <= 0) continue;
444
+ const lnP = lnContribution(s.level ?? 0, 25);
445
+ const mods = SKILL_ATTR_MODS[s.id.toLowerCase()];
446
+ if (!mods) continue;
447
+ for (const [k, mult] of Object.entries(mods)) {
448
+ const key = k as AttributeKey;
449
+ const delta = Math.trunc((mult as number) * lnP);
450
+ (out as any)[key] = ((out as any)[key] ?? 0) + delta;
451
+ }
452
+ }
453
+ return out;
454
+ }
455
+
456
+ function effectiveAttributes(state: PlayerState): AttributesFlat {
457
+ // Start with normalized base
458
+ const base = flattenBase(state.attributesBase);
459
+ // Add gear/effects layers
460
+ const eff: AttributesFlat = { ...base };
461
+ const addFlat = (src: AttributesFlat) => {
462
+ eff.strength += src.strength;
463
+ eff.dexterity += src.dexterity;
464
+ eff.endurance += src.endurance;
465
+ eff.intellect += src.intellect;
466
+ eff.willpower += src.willpower;
467
+ eff.creativity += src.creativity;
468
+ eff.spirit += src.spirit;
469
+ eff.wisdom += src.wisdom;
470
+ eff.charisma += src.charisma;
471
+ };
472
+ addFlat(state.attributesGear);
473
+ addFlat(state.attributesEffects);
474
+
475
+ // Add equipment modifiers
476
+ for (const slot of Object.keys(state.equipment) as EquipmentSlot[]) {
477
+ const itemId = state.equipment[slot];
478
+ if (!itemId) continue;
479
+ const item = state.items[itemId];
480
+ if (item?.modifiers) {
481
+ for (const [k, v] of Object.entries(item.modifiers)) {
482
+ const key = k as AttributeKey;
483
+ const val = v ?? 0;
484
+ (eff as any)[key] = ((eff as any)[key] ?? 0) + val;
485
+ }
486
+ }
487
+ }
488
+ // Add status effects
489
+ for (const effx of Object.values(state.effects)) {
490
+ const mods = effx.modifiers?.attributes;
491
+ if (!mods) continue;
492
+ for (const [k, v] of Object.entries(mods)) {
493
+ const key = k as AttributeKey;
494
+ const val = v ?? 0;
495
+ (eff as any)[key] = ((eff as any)[key] ?? 0) + val;
496
+ }
497
+ }
498
+ // Apply cross-triangle penalties/bonuses derived from active skills (rare/hidden)
499
+ const skillMods = skillAttributeModifiers(state);
500
+ addFlatInto(eff, skillMods);
501
+ return eff;
502
+ }
503
+
504
+ // ====== Reducer ======
505
+ function reducer(state: PlayerState, action: PlayerAction): PlayerState {
506
+ switch (action.type) {
507
+ case "set_position":
508
+ return { ...state, position: { ...action.payload } };
509
+ case "set_look_at":
510
+ return { ...state, lookAt: { ...action.payload } };
511
+ case "set_velocity":
512
+ return { ...state, velocity: { ...action.payload } };
513
+
514
+ case "set_resource": {
515
+ const next: Resources = { ...state.resources, ...action.payload };
516
+ next.health = clamp(next.health, 0, next.healthMax);
517
+ next.energy = clamp(next.energy, 0, next.energyMax);
518
+ return { ...state, resources: next };
519
+ }
520
+ case "set_attributes_base": {
521
+ const next = { ...state.attributesBase };
522
+ const apply = (k: AttributeKey, v: number) => {
523
+ switch (k) {
524
+ case "strength":
525
+ next.physical.strength = v;
526
+ break;
527
+ case "dexterity":
528
+ next.physical.dexterity = v;
529
+ break;
530
+ case "endurance":
531
+ next.physical.endurance = v;
532
+ break;
533
+ case "intellect":
534
+ next.mental.intellect = v;
535
+ break;
536
+ case "willpower":
537
+ next.mental.willpower = v;
538
+ break;
539
+ case "creativity":
540
+ next.mental.creativity = v;
541
+ break;
542
+ case "spirit":
543
+ next.spiritual.spirit = v;
544
+ break;
545
+ case "wisdom":
546
+ next.spiritual.wisdom = v;
547
+ break;
548
+ case "charisma":
549
+ next.spiritual.charisma = v;
550
+ break;
551
+ }
552
+ };
553
+ for (const [k, v] of Object.entries(action.payload))
554
+ apply(k as AttributeKey, clampAttr(v as number));
555
+ return { ...state, attributesBase: next };
556
+ }
557
+ case "set_attributes_gear": {
558
+ const next: AttributesFlat = {
559
+ ...state.attributesGear,
560
+ } as AttributesFlat;
561
+ for (const [k, v] of Object.entries(action.payload))
562
+ (next as any)[k] = ((next as any)[k] ?? 0) + (v as number);
563
+ return { ...state, attributesGear: next };
564
+ }
565
+ case "set_attributes_effects": {
566
+ const next: AttributesFlat = {
567
+ ...state.attributesEffects,
568
+ } as AttributesFlat;
569
+ for (const [k, v] of Object.entries(action.payload))
570
+ (next as any)[k] = ((next as any)[k] ?? 0) + (v as number);
571
+ return { ...state, attributesEffects: next };
572
+ }
573
+
574
+ case "register_item": {
575
+ const it = action.payload;
576
+ return { ...state, items: { ...state.items, [it.id]: it } };
577
+ }
578
+ case "add_item":
579
+ return addToInventory(state, action.payload.itemId, action.payload.qty);
580
+ case "remove_item":
581
+ return removeFromInventory(
582
+ state,
583
+ action.payload.itemId,
584
+ action.payload.qty
585
+ );
586
+
587
+ case "equip": {
588
+ const { slot, itemId } = action.payload;
589
+ // Ensure we have the item and it exists in inventory or is otherwise obtainable
590
+ if (!state.items[itemId]) return state;
591
+ return { ...state, equipment: { ...state.equipment, [slot]: itemId } };
592
+ }
593
+ case "unequip": {
594
+ const { slot } = action.payload;
595
+ const eq = { ...state.equipment };
596
+ delete eq[slot];
597
+ return { ...state, equipment: eq };
598
+ }
599
+
600
+ case "learn_skill": {
601
+ const s = action.payload;
602
+ return { ...state, skills: { ...state.skills, [s.id]: s } };
603
+ }
604
+
605
+ case "set_skill_active": {
606
+ const { id, active } = action.payload;
607
+ const prev = state.skills[id];
608
+ if (!prev) return state;
609
+ return {
610
+ ...state,
611
+ skills: { ...state.skills, [id]: { ...prev, active } },
612
+ };
613
+ }
614
+
615
+ case "set_skill_level": {
616
+ const { id, level } = action.payload;
617
+ const prev = state.skills[id];
618
+ if (!prev) return state;
619
+ return {
620
+ ...state,
621
+ skills: {
622
+ ...state.skills,
623
+ [id]: {
624
+ ...prev,
625
+ level: Math.max(0, Math.min(100, Math.round(level))),
626
+ },
627
+ },
628
+ };
629
+ }
630
+
631
+ case "forget_skill": {
632
+ const { id } = action.payload;
633
+ const next = { ...state.skills };
634
+ delete next[id];
635
+ return { ...state, skills: next };
636
+ }
637
+
638
+ case "apply_effect": {
639
+ const e = action.payload;
640
+ return { ...state, effects: { ...state.effects, [e.id]: e } };
641
+ }
642
+ case "remove_effect": {
643
+ const { id } = action.payload;
644
+ const next = { ...state.effects };
645
+ delete next[id];
646
+ return { ...state, effects: next };
647
+ }
648
+
649
+ default:
650
+ return state;
651
+ }
652
+ }
653
+
654
+ // ====== Scoped Store Wiring (using shared react-state) ======
655
+ export type PlayerStoreContext = ReturnType<
656
+ typeof createScopedStoreContext<PlayerState, PlayerAction>
657
+ >;
658
+
659
+ export const PlayerStore: PlayerStoreContext =
660
+ createScopedStoreContext<PlayerState, PlayerAction>(reducer, defaultState);
661
+
662
+ // Convenience re-exports for common actions (keeps call sites tidy)
663
+ export const PlayerActions = {
664
+ setPosition: (payload: Vec3): PlayerAction => ({
665
+ type: "set_position",
666
+ payload,
667
+ }),
668
+ setLookAt: (payload: Vec3): PlayerAction => ({
669
+ type: "set_look_at",
670
+ payload,
671
+ }),
672
+ setVelocity: (payload: Vec3): PlayerAction => ({
673
+ type: "set_velocity",
674
+ payload,
675
+ }),
676
+ setResource: (payload: Partial<Resources>): PlayerAction => ({
677
+ type: "set_resource",
678
+ payload,
679
+ }),
680
+ setAttributesBase: (
681
+ payload: Partial<Record<AttributeKey, number>>
682
+ ): PlayerAction => ({ type: "set_attributes_base", payload }),
683
+ setAttributesGear: (
684
+ payload: Partial<Record<AttributeKey, number>>
685
+ ): PlayerAction => ({ type: "set_attributes_gear", payload }),
686
+ setAttributesEffects: (
687
+ payload: Partial<Record<AttributeKey, number>>
688
+ ): PlayerAction => ({ type: "set_attributes_effects", payload }),
689
+ registerItem: (payload: Item): PlayerAction => ({
690
+ type: "register_item",
691
+ payload,
692
+ }),
693
+ addItem: (itemId: string, qty: number): PlayerAction => ({
694
+ type: "add_item",
695
+ payload: { itemId, qty },
696
+ }),
697
+ removeItem: (itemId: string, qty: number): PlayerAction => ({
698
+ type: "remove_item",
699
+ payload: { itemId, qty },
700
+ }),
701
+ equip: (slot: EquipmentSlot, itemId: string): PlayerAction => ({
702
+ type: "equip",
703
+ payload: { slot, itemId },
704
+ }),
705
+ unequip: (slot: EquipmentSlot): PlayerAction => ({
706
+ type: "unequip",
707
+ payload: { slot },
708
+ }),
709
+ learnSkill: (payload: Skill): PlayerAction => ({
710
+ type: "learn_skill",
711
+ payload,
712
+ }),
713
+ setSkillActive: (id: string, active: boolean): PlayerAction => ({
714
+ type: "set_skill_active",
715
+ payload: { id, active },
716
+ }),
717
+ setSkillLevel: (id: string, level: number): PlayerAction => ({
718
+ type: "set_skill_level",
719
+ payload: { id, level },
720
+ }),
721
+ forgetSkill: (id: string): PlayerAction => ({
722
+ type: "forget_skill",
723
+ payload: { id },
724
+ }),
725
+ applyEffect: (payload: StatusEffect): PlayerAction => ({
726
+ type: "apply_effect",
727
+ payload,
728
+ }),
729
+ removeEffect: (id: string): PlayerAction => ({
730
+ type: "remove_effect",
731
+ payload: { id },
732
+ }),
733
+ } as const;