@jgengine/core 0.11.0 → 0.13.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 +52 -0
- package/dist/ai/populationDirector.d.ts +103 -0
- package/dist/ai/populationDirector.js +190 -0
- package/dist/ai/pursuit.d.ts +56 -0
- package/dist/ai/pursuit.js +53 -0
- package/dist/authoring.d.ts +1 -1
- package/dist/authoring.js +1 -1
- package/dist/combat/effects.d.ts +11 -4
- package/dist/combat/effects.js +24 -17
- package/dist/combat/magazine.d.ts +13 -0
- package/dist/combat/magazine.js +27 -0
- package/dist/combat/vfxPresets.d.ts +176 -0
- package/dist/combat/vfxPresets.js +74 -0
- package/dist/combat/weaponFire.d.ts +114 -0
- package/dist/combat/weaponFire.js +96 -0
- package/dist/combat.d.ts +5 -2
- package/dist/combat.js +4 -1
- package/dist/devtools/controls.d.ts +38 -0
- package/dist/devtools/controls.js +181 -0
- package/dist/devtools/devtools.d.ts +5 -197
- package/dist/devtools/devtools.js +38 -774
- package/dist/devtools/discover.d.ts +32 -0
- package/dist/devtools/discover.js +240 -0
- package/dist/devtools/fallbackSeams.d.ts +50 -0
- package/dist/devtools/fallbackSeams.js +111 -0
- package/dist/devtools/frame.d.ts +15 -0
- package/dist/devtools/frame.js +247 -0
- package/dist/devtools/logs.d.ts +15 -0
- package/dist/devtools/logs.js +86 -0
- package/dist/devtools/overrides.d.ts +16 -0
- package/dist/devtools/overrides.js +66 -0
- package/dist/devtools/types.d.ts +212 -0
- package/dist/devtools/types.js +1 -0
- package/dist/devtools/urlFlags.d.ts +22 -0
- package/dist/devtools/urlFlags.js +62 -0
- package/dist/economy/auctionBook.d.ts +122 -0
- package/dist/economy/auctionBook.js +213 -0
- package/dist/economy/priceHistory.d.ts +44 -0
- package/dist/economy/priceHistory.js +89 -0
- package/dist/economy/unlockPoints.d.ts +92 -0
- package/dist/economy/unlockPoints.js +103 -0
- package/dist/editor/commandApply.d.ts +5 -0
- package/dist/editor/commandApply.js +755 -0
- package/dist/editor/commands.d.ts +48 -3
- package/dist/editor/commands.js +40 -754
- package/dist/editor/directives.d.ts +94 -0
- package/dist/editor/directives.js +206 -0
- package/dist/editor/document.d.ts +37 -2
- package/dist/editor/document.js +422 -6
- package/dist/editor/entityCatalog.d.ts +42 -0
- package/dist/editor/entityCatalog.js +101 -0
- package/dist/editor/environment.d.ts +74 -0
- package/dist/editor/environment.js +169 -0
- package/dist/editor/index.d.ts +26 -1
- package/dist/editor/index.js +8 -1
- package/dist/editor/minimap.d.ts +31 -0
- package/dist/editor/minimap.js +73 -0
- package/dist/editor/streamer.d.ts +66 -0
- package/dist/editor/streamer.js +68 -0
- package/dist/editor/types.d.ts +143 -0
- package/dist/editor/world.d.ts +136 -0
- package/dist/editor/world.js +303 -0
- package/dist/game/breeding.d.ts +161 -0
- package/dist/game/breeding.js +181 -0
- package/dist/game/cameraConfig.d.ts +451 -0
- package/dist/game/cameraConfig.js +1 -0
- package/dist/game/clipRoles.d.ts +51 -0
- package/dist/game/clipRoles.js +142 -0
- package/dist/game/defineGame.d.ts +16 -9
- package/dist/game/defineGame.js +14 -6
- package/dist/game/defineSystem.d.ts +2 -0
- package/dist/game/defineSystem.js +2 -0
- package/dist/game/events.d.ts +28 -0
- package/dist/game/partAnimation.d.ts +78 -0
- package/dist/game/partAnimation.js +147 -0
- package/dist/game/playableGame.d.ts +72 -440
- package/dist/game/playableGame.js +1 -1
- package/dist/game/progression.d.ts +35 -0
- package/dist/game/progression.js +22 -1
- package/dist/game/quest.js +1 -3
- package/dist/game/tribe.d.ts +184 -0
- package/dist/game/tribe.js +432 -0
- package/dist/gameplay.d.ts +3 -1
- package/dist/gameplay.js +3 -1
- package/dist/inventory/encumbrance.d.ts +69 -0
- package/dist/inventory/encumbrance.js +68 -0
- package/dist/inventory/inventoryModel.d.ts +24 -8
- package/dist/inventory/inventoryModel.js +47 -8
- package/dist/item/durability.js +1 -3
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +48 -1
- package/dist/movement/movementModel.d.ts +15 -1
- package/dist/movement/movementModel.js +33 -15
- package/dist/movement/playerMovement.js +102 -2
- package/dist/multiplayer/combatSnapshot.js +13 -8
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +8 -0
- package/dist/physics/drivableVehicle.d.ts +2 -0
- package/dist/physics/drivableVehicle.js +1 -1
- package/dist/physics/flightDynamics.d.ts +82 -0
- package/dist/physics/flightDynamics.js +165 -0
- package/dist/physics/gravityField.d.ts +25 -0
- package/dist/physics/gravityField.js +41 -0
- package/dist/physics/kinematicVehicle.d.ts +87 -0
- package/dist/physics/kinematicVehicle.js +193 -11
- package/dist/physics/physicsWorld.d.ts +26 -0
- package/dist/physics/physicsWorld.js +109 -117
- package/dist/physics/vehicleObstacles.d.ts +44 -0
- package/dist/physics/vehicleObstacles.js +115 -0
- package/dist/random/rng.d.ts +4 -1
- package/dist/random/rng.js +4 -1
- package/dist/runtime/adapter.d.ts +13 -0
- package/dist/runtime/adapter.js +13 -0
- package/dist/runtime/context/combat.d.ts +51 -0
- package/dist/runtime/context/combat.js +131 -0
- package/dist/runtime/context/combatFx.js +15 -5
- package/dist/runtime/context/deathLoot.d.ts +30 -0
- package/dist/runtime/context/deathLoot.js +39 -0
- package/dist/runtime/context/player.d.ts +48 -0
- package/dist/runtime/context/player.js +125 -0
- package/dist/runtime/context/scene.d.ts +60 -0
- package/dist/runtime/context/scene.js +329 -0
- package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
- package/dist/runtime/context/snapshotCodecs.js +26 -0
- package/dist/runtime/descriptors/baseline.js +26 -3
- package/dist/runtime/descriptors/features.d.ts +5 -0
- package/dist/runtime/descriptors/features.js +20 -2
- package/dist/runtime/gameContext.d.ts +2 -428
- package/dist/runtime/gameContext.js +133 -500
- package/dist/runtime/gameContextTypes.d.ts +500 -0
- package/dist/runtime/gameContextTypes.js +1 -0
- package/dist/runtime/headlessRunner.d.ts +17 -2
- package/dist/runtime/headlessRunner.js +15 -0
- package/dist/runtime/hostPolicy.d.ts +51 -0
- package/dist/runtime/hostPolicy.js +68 -0
- package/dist/runtime/hostedGameRunner.d.ts +3 -1
- package/dist/runtime/hostedGameRunner.js +1 -0
- package/dist/runtime/hostedWorldSession.d.ts +3 -1
- package/dist/runtime/hostedWorldSession.js +1 -0
- package/dist/runtime/worldSnapshot.d.ts +13 -1
- package/dist/runtime/worldSnapshot.js +15 -3
- package/dist/scene/assetCatalog.d.ts +7 -0
- package/dist/scene/assetSpace.js +1 -3
- package/dist/scene/authoredTriggers.d.ts +50 -0
- package/dist/scene/authoredTriggers.js +114 -0
- package/dist/scene/builtinSceneKinds.js +8 -3
- package/dist/scene/colliders.d.ts +79 -0
- package/dist/scene/colliders.js +170 -0
- package/dist/scene/collisionMesh.d.ts +94 -0
- package/dist/scene/collisionMesh.js +699 -0
- package/dist/scene/companion.d.ts +147 -0
- package/dist/scene/companion.js +193 -0
- package/dist/scene/entityStats.d.ts +7 -9
- package/dist/scene/entityStats.js +19 -15
- package/dist/scene/sceneKinds.d.ts +16 -0
- package/dist/scene/sceneKinds.js +4 -0
- package/dist/scene/sceneRaycast.js +15 -0
- package/dist/stats/spawnLevelStats.d.ts +110 -0
- package/dist/stats/spawnLevelStats.js +140 -0
- package/dist/stats/statModifiers.d.ts +16 -0
- package/dist/stats/statModifiers.js +30 -1
- package/dist/stats/statPool.d.ts +70 -0
- package/dist/stats/statPool.js +62 -0
- package/dist/store/changeSignal.d.ts +3 -0
- package/dist/store/changeSignal.js +2 -0
- package/dist/store/observableKeyedStore.d.ts +2 -0
- package/dist/store/observableKeyedStore.js +1 -0
- package/dist/survival/decayMeter.js +1 -3
- package/dist/survival/regionHealth.js +1 -3
- package/dist/ui/hudLayout.d.ts +5 -0
- package/dist/ui/hudLayout.js +17 -1
- package/dist/ui/panelModel.d.ts +120 -0
- package/dist/ui/panelModel.js +168 -0
- package/dist/ui.d.ts +1 -0
- package/dist/ui.js +1 -0
- package/dist/world/authoredEntities.d.ts +29 -0
- package/dist/world/authoredEntities.js +29 -0
- package/dist/world/authoredObjects.d.ts +15 -1
- package/dist/world/authoredObjects.js +13 -1
- package/dist/world/authoredSpawn.d.ts +39 -0
- package/dist/world/authoredSpawn.js +37 -0
- package/dist/world/buildSockets.d.ts +152 -0
- package/dist/world/buildSockets.js +188 -0
- package/dist/world/buildingLots.d.ts +84 -0
- package/dist/world/buildingLots.js +119 -0
- package/dist/world/buildings.d.ts +6 -0
- package/dist/world/cityBlocks.d.ts +152 -0
- package/dist/world/cityBlocks.js +670 -0
- package/dist/world/cityContent.d.ts +77 -0
- package/dist/world/cityContent.js +261 -0
- package/dist/world/cityGenerator.d.ts +41 -0
- package/dist/world/cityGenerator.js +58 -0
- package/dist/world/cityGeometry.d.ts +114 -0
- package/dist/world/cityGeometry.js +573 -0
- package/dist/world/cityKind.d.ts +314 -0
- package/dist/world/cityKind.js +1578 -0
- package/dist/world/environmentSummary.js +57 -1
- package/dist/world/features.d.ts +53 -5
- package/dist/world/features.js +15 -3
- package/dist/world/grassKind.js +2 -2
- package/dist/world/markers.d.ts +50 -4
- package/dist/world/markers.js +49 -0
- package/dist/world/massing.js +1 -1
- package/dist/world/minimapBake.d.ts +78 -0
- package/dist/world/minimapBake.js +208 -0
- package/dist/world/minimapTrack.d.ts +17 -0
- package/dist/world/minimapTrack.js +22 -0
- package/dist/world/place.d.ts +166 -0
- package/dist/world/place.js +96 -0
- package/dist/world/placeAsset.js +3 -1
- package/dist/world/poleLineKind.d.ts +40 -0
- package/dist/world/poleLineKind.js +97 -0
- package/dist/world/polyline.js +9 -2
- package/dist/world/resourceNode.d.ts +109 -0
- package/dist/world/resourceNode.js +140 -0
- package/dist/world/roads.d.ts +19 -2
- package/dist/world/roads.js +60 -3
- package/dist/world/scatter.js +2 -20
- package/dist/world/streetGenerator.d.ts +159 -0
- package/dist/world/streetGenerator.js +684 -0
- package/dist/world/streets.d.ts +103 -1
- package/dist/world/streets.js +273 -0
- package/dist/world/terraform.js +58 -88
- package/dist/world/terrain.d.ts +65 -1
- package/dist/world/terrain.js +119 -2
- package/dist/world.d.ts +14 -5
- package/dist/world.js +12 -3
- package/package.json +1 -1
- package/skills/game-design/SKILL.md +28 -0
- package/skills/game-design/agents/openai.yaml +4 -0
- package/skills/game-design/references/application-playbook.md +170 -0
- package/skills/game-design/references/field-guide.md +288 -0
- package/skills/jgengine/SKILL.md +8 -4
- package/skills/jgengine/api.md +182 -81
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-combat/SKILL.md +19 -1
- package/skills/jgengine-combat/api.md +75 -2
- package/skills/jgengine-combat/capabilities.md +61 -1
- package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
- package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
- package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
- package/skills/jgengine-combat/reference.md +33 -4
- package/skills/jgengine-gameplay/SKILL.md +8 -0
- package/skills/jgengine-gameplay/api.md +182 -26
- package/skills/jgengine-gameplay/capabilities.md +96 -2
- package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
- package/skills/jgengine-gameplay/reference-systems.md +10 -5
- package/skills/jgengine-world/SKILL.md +13 -0
- package/skills/jgengine-world/api.md +453 -21
- package/skills/jgengine-world/capabilities.md +112 -2
- package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
- package/skills/jgengine-world/reference.md +43 -4
- package/skills/jgengine/reference-cartridge.md +0 -72
package/dist/game/progression.js
CHANGED
|
@@ -35,6 +35,14 @@ function interpolate(points, x) {
|
|
|
35
35
|
}
|
|
36
36
|
return last[1];
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Evaluate a progression {@link Curve} at position `x`, applying its rounding
|
|
40
|
+
* mode and min/max clamp. This is the shared numeric backbone for XP-per-level,
|
|
41
|
+
* cost ramps, difficulty scaling, and any other level/rank → value mapping — use
|
|
42
|
+
* it instead of hand-rolling growth formulas.
|
|
43
|
+
*
|
|
44
|
+
* @capability progression-curve evaluate a level/rank curve (const, linear, power, geometric, steps, piecewise) at a point
|
|
45
|
+
*/
|
|
38
46
|
export function evalCurve(spec, x) {
|
|
39
47
|
let y = rawCurve(spec, x);
|
|
40
48
|
if (spec.min !== undefined)
|
|
@@ -52,9 +60,20 @@ export function evalCurve(spec, x) {
|
|
|
52
60
|
return y;
|
|
53
61
|
}
|
|
54
62
|
}
|
|
63
|
+
/** Bind a {@link Curve} into a reusable `(x) => value` evaluator — a partial application of {@link evalCurve}. */
|
|
55
64
|
export function curve(spec) {
|
|
56
65
|
return (x) => evalCurve(spec, x);
|
|
57
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Build a leveling track from an xp-per-level {@link Curve} and a max level. The
|
|
69
|
+
* returned {@link LevelingTrack} resolves how many levels a given xp total earns
|
|
70
|
+
* (rolling over surplus xp, capping at `maxLevel`) and, via `grantXp`, writes the
|
|
71
|
+
* new level/xp back through a {@link LevelingStatAccess} and fires `onLevelUp` for
|
|
72
|
+
* each level gained in ascending order. Reach for this instead of hand-tracking xp thresholds; pair
|
|
73
|
+
* it with `resource-pool` (mana/stamina) and `ability-bar` (cooldowns) for a hero.
|
|
74
|
+
*
|
|
75
|
+
* @capability leveling-track grant XP, resolve level-ups against a curve, and write level+xp stats back through a stat adapter
|
|
76
|
+
*/
|
|
58
77
|
export function leveling(config) {
|
|
59
78
|
const maxLevel = config.maxLevel;
|
|
60
79
|
const startLevel = config.startLevel ?? 1;
|
|
@@ -109,7 +128,9 @@ export function leveling(config) {
|
|
|
109
128
|
access.set(userId, xpStat, { current: progress.xp, max: progress.xpMax });
|
|
110
129
|
if (progress.levelsGained > 0) {
|
|
111
130
|
access.set(userId, levelStat, { current: progress.level });
|
|
112
|
-
|
|
131
|
+
for (let reached = level.current + 1; reached <= progress.level; reached += 1) {
|
|
132
|
+
onLevelUp?.(reached);
|
|
133
|
+
}
|
|
113
134
|
}
|
|
114
135
|
return progress.levelsGained;
|
|
115
136
|
};
|
package/dist/game/quest.js
CHANGED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { FactionRelation } from "../faction/factions.js";
|
|
2
|
+
/**
|
|
3
|
+
* A rank within a group's hierarchy. Higher `level` means more authority: a member can only
|
|
4
|
+
* manage (kick, re-rank) members strictly below their own level, and cannot promote anyone to a
|
|
5
|
+
* level at or above their own. `permissions` are opaque, genre-agnostic capability slugs the game
|
|
6
|
+
* checks with {@link Tribe.can} (e.g. `"build"`, `"demolish"`, `"invite"`, `"kick"`, `"manage-ranks"`,
|
|
7
|
+
* plus the {@link AssetAction} slugs `"inventory"`, `"use"`, `"command"` for group-owned assets).
|
|
8
|
+
* A `bypass` rank (an admin) skips every permission check, exactly like the founder.
|
|
9
|
+
*/
|
|
10
|
+
export interface TribeRankDef {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** Higher = more authority. Determines who can manage/promote whom. */
|
|
14
|
+
level: number;
|
|
15
|
+
/** Capability slugs this rank grants; checked by {@link Tribe.can} and {@link Tribe.canAccess}. */
|
|
16
|
+
permissions?: readonly string[];
|
|
17
|
+
/** Admin rank — bypasses all permission and hierarchy checks (like the founder). */
|
|
18
|
+
bypass?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Per-asset action gated by rank on group-owned assets. */
|
|
21
|
+
export type AssetAction = "inventory" | "use" | "command";
|
|
22
|
+
/** Opaque reference to a shared thing (structure, creature, container) by kind + id. */
|
|
23
|
+
export interface AssetRef {
|
|
24
|
+
kind: string;
|
|
25
|
+
id: string;
|
|
26
|
+
}
|
|
27
|
+
/** Who inside the group owns an asset: the group as a whole, or a specific member personally. */
|
|
28
|
+
export type AssetOwnership = {
|
|
29
|
+
scope: "group";
|
|
30
|
+
} | {
|
|
31
|
+
scope: "personal";
|
|
32
|
+
memberId: string;
|
|
33
|
+
};
|
|
34
|
+
/** The kind of change recorded in a tribe's event log (membership, ownership, or alliance). */
|
|
35
|
+
export type TribeEventType = "member-added" | "member-removed" | "member-left" | "rank-changed" | "founder-transferred" | "asset-registered" | "asset-unregistered" | "alliance-formed" | "alliance-broken";
|
|
36
|
+
/** One entry in a tribe's bounded event log. Optional fields present only when meaningful. */
|
|
37
|
+
export interface TribeEvent {
|
|
38
|
+
seq: number;
|
|
39
|
+
type: TribeEventType;
|
|
40
|
+
at: number;
|
|
41
|
+
/** Member who performed the action, when applicable. */
|
|
42
|
+
actor?: string;
|
|
43
|
+
/** Member the action targets (added/removed/re-ranked). */
|
|
44
|
+
subject?: string;
|
|
45
|
+
/** Other tribe id, for alliance events. */
|
|
46
|
+
tribeId?: string;
|
|
47
|
+
rankId?: string;
|
|
48
|
+
assetKind?: string;
|
|
49
|
+
assetId?: string;
|
|
50
|
+
}
|
|
51
|
+
/** A member of a tribe and the rank id that governs their permissions. */
|
|
52
|
+
export interface TribeMemberRecord {
|
|
53
|
+
id: string;
|
|
54
|
+
rankId: string;
|
|
55
|
+
joinedAt: number;
|
|
56
|
+
}
|
|
57
|
+
/** An asset (structure/creature) owned within a tribe — group-shared or held by one member. */
|
|
58
|
+
export interface TribeAssetRecord {
|
|
59
|
+
kind: string;
|
|
60
|
+
id: string;
|
|
61
|
+
/** `null` marks a group-owned asset; otherwise the member who owns it personally. */
|
|
62
|
+
ownerId: string | null;
|
|
63
|
+
}
|
|
64
|
+
/** Full serializable state of one tribe — the save/replication baseline. */
|
|
65
|
+
export interface TribeSnapshot {
|
|
66
|
+
id: string;
|
|
67
|
+
founderId: string;
|
|
68
|
+
ranks: TribeRankDef[];
|
|
69
|
+
defaultRankId: string;
|
|
70
|
+
founderRankId: string;
|
|
71
|
+
logCap: number;
|
|
72
|
+
members: TribeMemberRecord[];
|
|
73
|
+
assets: TribeAssetRecord[];
|
|
74
|
+
allies: string[];
|
|
75
|
+
events: TribeEvent[];
|
|
76
|
+
eventSeq: number;
|
|
77
|
+
}
|
|
78
|
+
/** Construction options for {@link createTribe}: id, founder, rank ladder, and log cap. */
|
|
79
|
+
export interface TribeConfig {
|
|
80
|
+
id: string;
|
|
81
|
+
founderId: string;
|
|
82
|
+
/** At least one rank. */
|
|
83
|
+
ranks: readonly TribeRankDef[];
|
|
84
|
+
/** Rank new members receive; defaults to the lowest-level rank. */
|
|
85
|
+
defaultRankId?: string;
|
|
86
|
+
/** Rank the founder holds; defaults to the highest-level rank. */
|
|
87
|
+
founderRankId?: string;
|
|
88
|
+
/** Max events retained in the ring buffer (default 128). */
|
|
89
|
+
logCap?: number;
|
|
90
|
+
/** Injected clock for deterministic timestamps; defaults to `Date.now`. */
|
|
91
|
+
now?: () => number;
|
|
92
|
+
}
|
|
93
|
+
/** Standard `{ reason }`-on-failure / `null`-on-success result for authorized mutations. */
|
|
94
|
+
export type TribeResult = {
|
|
95
|
+
reason: string;
|
|
96
|
+
} | null;
|
|
97
|
+
/**
|
|
98
|
+
* A shared-ownership group — a tribe, guild, clan, or company — as one first-class aggregate.
|
|
99
|
+
* Unifies ranked membership, group-vs-personal asset ownership, mutual alliances, and a bounded
|
|
100
|
+
* event log that otherwise have to be hand-assembled from parties, factions, build permissions,
|
|
101
|
+
* and per-owner rosters.
|
|
102
|
+
*/
|
|
103
|
+
export interface Tribe {
|
|
104
|
+
readonly id: string;
|
|
105
|
+
founderId(): string;
|
|
106
|
+
hasMember(memberId: string): boolean;
|
|
107
|
+
memberIds(): readonly string[];
|
|
108
|
+
rankOf(memberId: string): TribeRankDef | null;
|
|
109
|
+
/** True if the member's rank grants `permission`, or they are the founder / an admin rank. */
|
|
110
|
+
can(memberId: string, permission: string): boolean;
|
|
111
|
+
/** Founder or a `bypass` rank — skips every permission check. */
|
|
112
|
+
isAdmin(memberId: string): boolean;
|
|
113
|
+
addMember(actorId: string, memberId: string, rankId?: string): TribeResult;
|
|
114
|
+
removeMember(actorId: string, memberId: string): TribeResult;
|
|
115
|
+
leave(memberId: string): TribeResult;
|
|
116
|
+
setMemberRank(actorId: string, memberId: string, rankId: string): TribeResult;
|
|
117
|
+
transferFounder(actorId: string, memberId: string): TribeResult;
|
|
118
|
+
registerAsset(actorId: string, ref: AssetRef, ownership: AssetOwnership): TribeResult;
|
|
119
|
+
unregisterAsset(actorId: string, ref: AssetRef): TribeResult;
|
|
120
|
+
ownerOfAsset(ref: AssetRef): AssetOwnership | null;
|
|
121
|
+
/** Personal assets a member holds inside this group (they keep these on leaving). */
|
|
122
|
+
assetsOf(memberId: string): readonly AssetRef[];
|
|
123
|
+
/** Resolve access to an asset: rank permission for group assets, ownership for personal ones. */
|
|
124
|
+
canAccess(memberId: string, ref: AssetRef, action: AssetAction): boolean;
|
|
125
|
+
addAlly(tribeId: string): void;
|
|
126
|
+
removeAlly(tribeId: string): void;
|
|
127
|
+
isAllied(tribeId: string): boolean;
|
|
128
|
+
allyIds(): readonly string[];
|
|
129
|
+
/** `"friendly"` for self and allies, else `"neutral"` — never hostile by default. */
|
|
130
|
+
relationTo(tribeId: string): FactionRelation;
|
|
131
|
+
events(): readonly TribeEvent[];
|
|
132
|
+
snapshot(): TribeSnapshot;
|
|
133
|
+
hydrate(snapshot: TribeSnapshot): void;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Create a shared-ownership group aggregate (tribe / guild / clan / company). Members hold a
|
|
137
|
+
* {@link TribeRankDef rank} whose configurable permission slugs gate management actions and access
|
|
138
|
+
* to group-owned assets, while personally-owned assets stay with their member — and follow them out
|
|
139
|
+
* when they leave. Group↔group {@link Tribe.addAlly alliances} resolve allied tribes as friendly, and
|
|
140
|
+
* every membership / ownership / alliance change lands in a bounded ring-buffer {@link Tribe.events log}.
|
|
141
|
+
* All state is plain JSON via {@link Tribe.snapshot}/{@link Tribe.hydrate}; timestamps come from an
|
|
142
|
+
* injected clock for determinism. Compose many tribes with {@link createTribeRegistry} for mutual
|
|
143
|
+
* alliances and cross-member relation lookups.
|
|
144
|
+
*
|
|
145
|
+
* @capability tribe-group shared-ownership group (tribe/guild/clan) with ranked permissions, group vs personal assets, alliances, and a bounded event log
|
|
146
|
+
*/
|
|
147
|
+
export declare function createTribe(config: TribeConfig): Tribe;
|
|
148
|
+
/** Injected dependencies for {@link createTribeRegistry} (e.g. a `now()` clock for event timestamps). */
|
|
149
|
+
export interface TribeRegistryDeps {
|
|
150
|
+
now?: () => number;
|
|
151
|
+
defaultLogCap?: number;
|
|
152
|
+
}
|
|
153
|
+
/** Full serializable state of a tribe registry — every tribe's snapshot in one payload. */
|
|
154
|
+
export interface TribeRegistrySnapshot {
|
|
155
|
+
tribes: TribeSnapshot[];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A collection of {@link Tribe tribes} with mutual alliance management and cross-member relation
|
|
159
|
+
* lookups. Alliances are kept symmetric here (both sides updated together), and member-to-member
|
|
160
|
+
* relations resolve through tribe membership + alliances, mirroring the `faction` graph vocabulary.
|
|
161
|
+
*/
|
|
162
|
+
export interface TribeRegistry {
|
|
163
|
+
create(config: TribeConfig): Tribe;
|
|
164
|
+
get(id: string): Tribe | null;
|
|
165
|
+
all(): readonly Tribe[];
|
|
166
|
+
remove(id: string): void;
|
|
167
|
+
/** Form a mutual alliance, updating and logging both tribes. */
|
|
168
|
+
formAlliance(tribeA: string, tribeB: string): TribeResult;
|
|
169
|
+
/** Break a mutual alliance on both sides. */
|
|
170
|
+
breakAlliance(tribeA: string, tribeB: string): void;
|
|
171
|
+
areAllied(tribeA: string, tribeB: string): boolean;
|
|
172
|
+
relationBetweenTribes(tribeA: string, tribeB: string): FactionRelation;
|
|
173
|
+
tribeOfMember(memberId: string): Tribe | null;
|
|
174
|
+
relationBetweenMembers(memberA: string, memberB: string): FactionRelation;
|
|
175
|
+
isFriendly(memberA: string, memberB: string): boolean;
|
|
176
|
+
snapshot(): TribeRegistrySnapshot;
|
|
177
|
+
hydrate(snapshot: TribeRegistrySnapshot): void;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Create a registry that owns many tribes, keeps alliances mutual, and resolves member relations.
|
|
181
|
+
*
|
|
182
|
+
* @capability tribe-registry registry of tribes with mutual alliances and cross-tribe relation lookup
|
|
183
|
+
*/
|
|
184
|
+
export declare function createTribeRegistry(deps?: TribeRegistryDeps): TribeRegistry;
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
const DEFAULT_LOG_CAP = 128;
|
|
2
|
+
function assetKey(kind, id) {
|
|
3
|
+
// Length-prefix the kind so the key is collision-free even when `kind` or `id`
|
|
4
|
+
// contains the delimiter (e.g. kind "ab"+id "c" must not collide with "a"+"bc").
|
|
5
|
+
return `${kind.length}:${kind}:${id}`;
|
|
6
|
+
}
|
|
7
|
+
function toRankRuntime(def) {
|
|
8
|
+
return {
|
|
9
|
+
id: def.id,
|
|
10
|
+
name: def.name,
|
|
11
|
+
level: def.level,
|
|
12
|
+
permissions: new Set(def.permissions ?? []),
|
|
13
|
+
bypass: def.bypass === true,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function rankToDef(rank) {
|
|
17
|
+
return {
|
|
18
|
+
id: rank.id,
|
|
19
|
+
name: rank.name,
|
|
20
|
+
level: rank.level,
|
|
21
|
+
...(rank.permissions.size > 0 ? { permissions: [...rank.permissions] } : {}),
|
|
22
|
+
...(rank.bypass ? { bypass: true } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create a shared-ownership group aggregate (tribe / guild / clan / company). Members hold a
|
|
27
|
+
* {@link TribeRankDef rank} whose configurable permission slugs gate management actions and access
|
|
28
|
+
* to group-owned assets, while personally-owned assets stay with their member — and follow them out
|
|
29
|
+
* when they leave. Group↔group {@link Tribe.addAlly alliances} resolve allied tribes as friendly, and
|
|
30
|
+
* every membership / ownership / alliance change lands in a bounded ring-buffer {@link Tribe.events log}.
|
|
31
|
+
* All state is plain JSON via {@link Tribe.snapshot}/{@link Tribe.hydrate}; timestamps come from an
|
|
32
|
+
* injected clock for determinism. Compose many tribes with {@link createTribeRegistry} for mutual
|
|
33
|
+
* alliances and cross-member relation lookups.
|
|
34
|
+
*
|
|
35
|
+
* @capability tribe-group shared-ownership group (tribe/guild/clan) with ranked permissions, group vs personal assets, alliances, and a bounded event log
|
|
36
|
+
*/
|
|
37
|
+
export function createTribe(config) {
|
|
38
|
+
const now = config.now ?? Date.now;
|
|
39
|
+
if (config.ranks.length === 0)
|
|
40
|
+
throw new Error("tribe requires at least one rank");
|
|
41
|
+
const ranks = new Map();
|
|
42
|
+
let lowest;
|
|
43
|
+
let highest;
|
|
44
|
+
for (const def of config.ranks) {
|
|
45
|
+
if (ranks.has(def.id))
|
|
46
|
+
throw new Error(`duplicate rank id "${def.id}"`);
|
|
47
|
+
const rank = toRankRuntime(def);
|
|
48
|
+
ranks.set(rank.id, rank);
|
|
49
|
+
if (lowest === undefined || rank.level < lowest.level)
|
|
50
|
+
lowest = rank;
|
|
51
|
+
if (highest === undefined || rank.level > highest.level)
|
|
52
|
+
highest = rank;
|
|
53
|
+
}
|
|
54
|
+
const resolveRankId = (id, fallback) => {
|
|
55
|
+
if (id === undefined)
|
|
56
|
+
return fallback.id;
|
|
57
|
+
if (!ranks.has(id))
|
|
58
|
+
throw new Error(`unknown rank id "${id}"`);
|
|
59
|
+
return id;
|
|
60
|
+
};
|
|
61
|
+
let defaultRankId = resolveRankId(config.defaultRankId, lowest);
|
|
62
|
+
let founderRankId = resolveRankId(config.founderRankId, highest);
|
|
63
|
+
let logCap = Math.max(1, Math.floor(config.logCap ?? DEFAULT_LOG_CAP));
|
|
64
|
+
let founderId = config.founderId;
|
|
65
|
+
const members = new Map();
|
|
66
|
+
const assets = new Map();
|
|
67
|
+
const allies = new Set();
|
|
68
|
+
const eventLog = [];
|
|
69
|
+
let eventSeq = 0;
|
|
70
|
+
members.set(founderId, { id: founderId, rankId: founderRankId, joinedAt: now() });
|
|
71
|
+
function rankRuntimeOf(memberId) {
|
|
72
|
+
const member = members.get(memberId);
|
|
73
|
+
if (member === undefined)
|
|
74
|
+
return null;
|
|
75
|
+
return ranks.get(member.rankId) ?? null;
|
|
76
|
+
}
|
|
77
|
+
function isAdmin(memberId) {
|
|
78
|
+
if (memberId === founderId && members.has(memberId))
|
|
79
|
+
return true;
|
|
80
|
+
return rankRuntimeOf(memberId)?.bypass ?? false;
|
|
81
|
+
}
|
|
82
|
+
function can(memberId, permission) {
|
|
83
|
+
if (!members.has(memberId))
|
|
84
|
+
return false;
|
|
85
|
+
if (isAdmin(memberId))
|
|
86
|
+
return true;
|
|
87
|
+
return rankRuntimeOf(memberId)?.permissions.has(permission) ?? false;
|
|
88
|
+
}
|
|
89
|
+
function levelOf(memberId) {
|
|
90
|
+
if (isAdmin(memberId))
|
|
91
|
+
return Number.POSITIVE_INFINITY;
|
|
92
|
+
return rankRuntimeOf(memberId)?.level ?? Number.NEGATIVE_INFINITY;
|
|
93
|
+
}
|
|
94
|
+
function log(type, fields) {
|
|
95
|
+
eventSeq += 1;
|
|
96
|
+
const event = {
|
|
97
|
+
seq: eventSeq,
|
|
98
|
+
type,
|
|
99
|
+
at: now(),
|
|
100
|
+
...(fields.actor === undefined ? {} : { actor: fields.actor }),
|
|
101
|
+
...(fields.subject === undefined ? {} : { subject: fields.subject }),
|
|
102
|
+
...(fields.tribeId === undefined ? {} : { tribeId: fields.tribeId }),
|
|
103
|
+
...(fields.rankId === undefined ? {} : { rankId: fields.rankId }),
|
|
104
|
+
...(fields.assetKind === undefined ? {} : { assetKind: fields.assetKind }),
|
|
105
|
+
...(fields.assetId === undefined ? {} : { assetId: fields.assetId }),
|
|
106
|
+
};
|
|
107
|
+
eventLog.push(event);
|
|
108
|
+
if (eventLog.length > logCap)
|
|
109
|
+
eventLog.shift();
|
|
110
|
+
}
|
|
111
|
+
function dropPersonalAssets(memberId) {
|
|
112
|
+
for (const [key, asset] of assets) {
|
|
113
|
+
if (asset.ownerId === memberId)
|
|
114
|
+
assets.delete(key);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
id: config.id,
|
|
119
|
+
founderId: () => founderId,
|
|
120
|
+
hasMember: (memberId) => members.has(memberId),
|
|
121
|
+
memberIds: () => [...members.keys()],
|
|
122
|
+
rankOf(memberId) {
|
|
123
|
+
const rank = rankRuntimeOf(memberId);
|
|
124
|
+
return rank === null ? null : rankToDef(rank);
|
|
125
|
+
},
|
|
126
|
+
can,
|
|
127
|
+
isAdmin,
|
|
128
|
+
addMember(actorId, memberId, rankId) {
|
|
129
|
+
if (!members.has(actorId))
|
|
130
|
+
return { reason: "actor is not a member" };
|
|
131
|
+
if (!can(actorId, "invite"))
|
|
132
|
+
return { reason: "missing invite permission" };
|
|
133
|
+
if (members.has(memberId))
|
|
134
|
+
return { reason: "already a member" };
|
|
135
|
+
const targetRankId = rankId ?? defaultRankId;
|
|
136
|
+
const targetRank = ranks.get(targetRankId);
|
|
137
|
+
if (targetRank === undefined)
|
|
138
|
+
return { reason: `unknown rank id "${targetRankId}"` };
|
|
139
|
+
if (!isAdmin(actorId) && targetRank.level >= levelOf(actorId)) {
|
|
140
|
+
return { reason: "cannot invite at or above your own rank" };
|
|
141
|
+
}
|
|
142
|
+
members.set(memberId, { id: memberId, rankId: targetRankId, joinedAt: now() });
|
|
143
|
+
log("member-added", { actor: actorId, subject: memberId, rankId: targetRankId });
|
|
144
|
+
return null;
|
|
145
|
+
},
|
|
146
|
+
removeMember(actorId, memberId) {
|
|
147
|
+
if (!members.has(actorId))
|
|
148
|
+
return { reason: "actor is not a member" };
|
|
149
|
+
if (actorId === memberId)
|
|
150
|
+
return { reason: "use leave to remove yourself" };
|
|
151
|
+
if (!members.has(memberId))
|
|
152
|
+
return { reason: "not a member" };
|
|
153
|
+
if (memberId === founderId)
|
|
154
|
+
return { reason: "cannot remove the founder" };
|
|
155
|
+
if (!can(actorId, "kick"))
|
|
156
|
+
return { reason: "missing kick permission" };
|
|
157
|
+
if (levelOf(actorId) <= levelOf(memberId))
|
|
158
|
+
return { reason: "cannot manage an equal or higher rank" };
|
|
159
|
+
members.delete(memberId);
|
|
160
|
+
dropPersonalAssets(memberId);
|
|
161
|
+
log("member-removed", { actor: actorId, subject: memberId });
|
|
162
|
+
return null;
|
|
163
|
+
},
|
|
164
|
+
leave(memberId) {
|
|
165
|
+
if (!members.has(memberId))
|
|
166
|
+
return { reason: "not a member" };
|
|
167
|
+
if (memberId === founderId)
|
|
168
|
+
return { reason: "founder must transfer leadership first" };
|
|
169
|
+
members.delete(memberId);
|
|
170
|
+
dropPersonalAssets(memberId);
|
|
171
|
+
log("member-left", { subject: memberId });
|
|
172
|
+
return null;
|
|
173
|
+
},
|
|
174
|
+
setMemberRank(actorId, memberId, rankId) {
|
|
175
|
+
if (!members.has(actorId))
|
|
176
|
+
return { reason: "actor is not a member" };
|
|
177
|
+
if (!members.has(memberId))
|
|
178
|
+
return { reason: "not a member" };
|
|
179
|
+
if (memberId === founderId)
|
|
180
|
+
return { reason: "cannot re-rank the founder" };
|
|
181
|
+
if (!can(actorId, "manage-ranks"))
|
|
182
|
+
return { reason: "missing manage-ranks permission" };
|
|
183
|
+
const nextRank = ranks.get(rankId);
|
|
184
|
+
if (nextRank === undefined)
|
|
185
|
+
return { reason: `unknown rank id "${rankId}"` };
|
|
186
|
+
if (!isAdmin(actorId)) {
|
|
187
|
+
if (levelOf(actorId) <= levelOf(memberId))
|
|
188
|
+
return { reason: "cannot manage an equal or higher rank" };
|
|
189
|
+
if (nextRank.level >= levelOf(actorId))
|
|
190
|
+
return { reason: "cannot assign a rank at or above your own" };
|
|
191
|
+
}
|
|
192
|
+
members.get(memberId).rankId = rankId;
|
|
193
|
+
log("rank-changed", { actor: actorId, subject: memberId, rankId });
|
|
194
|
+
return null;
|
|
195
|
+
},
|
|
196
|
+
transferFounder(actorId, memberId) {
|
|
197
|
+
if (actorId !== founderId)
|
|
198
|
+
return { reason: "only the founder can transfer leadership" };
|
|
199
|
+
if (!members.has(memberId))
|
|
200
|
+
return { reason: "not a member" };
|
|
201
|
+
if (memberId === founderId)
|
|
202
|
+
return { reason: "already the founder" };
|
|
203
|
+
members.get(memberId).rankId = founderRankId;
|
|
204
|
+
founderId = memberId;
|
|
205
|
+
log("founder-transferred", { actor: actorId, subject: memberId });
|
|
206
|
+
return null;
|
|
207
|
+
},
|
|
208
|
+
registerAsset(actorId, ref, ownership) {
|
|
209
|
+
if (!members.has(actorId))
|
|
210
|
+
return { reason: "actor is not a member" };
|
|
211
|
+
const key = assetKey(ref.kind, ref.id);
|
|
212
|
+
if (assets.has(key))
|
|
213
|
+
return { reason: "asset already registered" };
|
|
214
|
+
let ownerId;
|
|
215
|
+
if (ownership.scope === "group") {
|
|
216
|
+
if (!can(actorId, "build"))
|
|
217
|
+
return { reason: "missing build permission" };
|
|
218
|
+
ownerId = null;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
if (!members.has(ownership.memberId))
|
|
222
|
+
return { reason: "owner is not a member" };
|
|
223
|
+
if (ownership.memberId !== actorId && !isAdmin(actorId)) {
|
|
224
|
+
return { reason: "cannot register assets for another member" };
|
|
225
|
+
}
|
|
226
|
+
ownerId = ownership.memberId;
|
|
227
|
+
}
|
|
228
|
+
assets.set(key, { kind: ref.kind, id: ref.id, ownerId });
|
|
229
|
+
log("asset-registered", { actor: actorId, assetKind: ref.kind, assetId: ref.id });
|
|
230
|
+
return null;
|
|
231
|
+
},
|
|
232
|
+
unregisterAsset(actorId, ref) {
|
|
233
|
+
if (!members.has(actorId))
|
|
234
|
+
return { reason: "actor is not a member" };
|
|
235
|
+
const key = assetKey(ref.kind, ref.id);
|
|
236
|
+
const asset = assets.get(key);
|
|
237
|
+
if (asset === undefined)
|
|
238
|
+
return { reason: "unknown asset" };
|
|
239
|
+
if (asset.ownerId === null) {
|
|
240
|
+
if (!can(actorId, "demolish"))
|
|
241
|
+
return { reason: "missing demolish permission" };
|
|
242
|
+
}
|
|
243
|
+
else if (asset.ownerId !== actorId && !isAdmin(actorId)) {
|
|
244
|
+
return { reason: "not the asset owner" };
|
|
245
|
+
}
|
|
246
|
+
assets.delete(key);
|
|
247
|
+
log("asset-unregistered", { actor: actorId, assetKind: ref.kind, assetId: ref.id });
|
|
248
|
+
return null;
|
|
249
|
+
},
|
|
250
|
+
ownerOfAsset(ref) {
|
|
251
|
+
const asset = assets.get(assetKey(ref.kind, ref.id));
|
|
252
|
+
if (asset === undefined)
|
|
253
|
+
return null;
|
|
254
|
+
return asset.ownerId === null ? { scope: "group" } : { scope: "personal", memberId: asset.ownerId };
|
|
255
|
+
},
|
|
256
|
+
assetsOf(memberId) {
|
|
257
|
+
const out = [];
|
|
258
|
+
for (const asset of assets.values()) {
|
|
259
|
+
if (asset.ownerId === memberId)
|
|
260
|
+
out.push({ kind: asset.kind, id: asset.id });
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
},
|
|
264
|
+
canAccess(memberId, ref, action) {
|
|
265
|
+
if (!members.has(memberId))
|
|
266
|
+
return false;
|
|
267
|
+
if (isAdmin(memberId))
|
|
268
|
+
return true;
|
|
269
|
+
const asset = assets.get(assetKey(ref.kind, ref.id));
|
|
270
|
+
if (asset === undefined)
|
|
271
|
+
return false;
|
|
272
|
+
if (asset.ownerId !== null)
|
|
273
|
+
return asset.ownerId === memberId;
|
|
274
|
+
return can(memberId, action);
|
|
275
|
+
},
|
|
276
|
+
addAlly(tribeId) {
|
|
277
|
+
if (tribeId === config.id || allies.has(tribeId))
|
|
278
|
+
return;
|
|
279
|
+
allies.add(tribeId);
|
|
280
|
+
log("alliance-formed", { tribeId });
|
|
281
|
+
},
|
|
282
|
+
removeAlly(tribeId) {
|
|
283
|
+
if (!allies.delete(tribeId))
|
|
284
|
+
return;
|
|
285
|
+
log("alliance-broken", { tribeId });
|
|
286
|
+
},
|
|
287
|
+
isAllied: (tribeId) => allies.has(tribeId),
|
|
288
|
+
allyIds: () => [...allies],
|
|
289
|
+
relationTo(tribeId) {
|
|
290
|
+
if (tribeId === config.id || allies.has(tribeId))
|
|
291
|
+
return "friendly";
|
|
292
|
+
return "neutral";
|
|
293
|
+
},
|
|
294
|
+
events: () => eventLog.slice(),
|
|
295
|
+
snapshot() {
|
|
296
|
+
return {
|
|
297
|
+
id: config.id,
|
|
298
|
+
founderId,
|
|
299
|
+
ranks: [...ranks.values()].map(rankToDef),
|
|
300
|
+
defaultRankId,
|
|
301
|
+
founderRankId,
|
|
302
|
+
logCap,
|
|
303
|
+
members: [...members.values()].map((m) => ({ ...m })),
|
|
304
|
+
assets: [...assets.values()].map((a) => ({ kind: a.kind, id: a.id, ownerId: a.ownerId })),
|
|
305
|
+
allies: [...allies],
|
|
306
|
+
events: eventLog.map((e) => ({ ...e })),
|
|
307
|
+
eventSeq,
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
hydrate(snapshot) {
|
|
311
|
+
ranks.clear();
|
|
312
|
+
for (const def of snapshot.ranks)
|
|
313
|
+
ranks.set(def.id, toRankRuntime(def));
|
|
314
|
+
defaultRankId = snapshot.defaultRankId;
|
|
315
|
+
founderRankId = snapshot.founderRankId;
|
|
316
|
+
logCap = Math.max(1, Math.floor(snapshot.logCap));
|
|
317
|
+
founderId = snapshot.founderId;
|
|
318
|
+
members.clear();
|
|
319
|
+
for (const m of snapshot.members)
|
|
320
|
+
members.set(m.id, { ...m });
|
|
321
|
+
assets.clear();
|
|
322
|
+
for (const a of snapshot.assets)
|
|
323
|
+
assets.set(assetKey(a.kind, a.id), { kind: a.kind, id: a.id, ownerId: a.ownerId });
|
|
324
|
+
allies.clear();
|
|
325
|
+
for (const t of snapshot.allies)
|
|
326
|
+
allies.add(t);
|
|
327
|
+
eventLog.length = 0;
|
|
328
|
+
for (const e of snapshot.events)
|
|
329
|
+
eventLog.push({ ...e });
|
|
330
|
+
eventSeq = snapshot.eventSeq;
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Create a registry that owns many tribes, keeps alliances mutual, and resolves member relations.
|
|
336
|
+
*
|
|
337
|
+
* @capability tribe-registry registry of tribes with mutual alliances and cross-tribe relation lookup
|
|
338
|
+
*/
|
|
339
|
+
export function createTribeRegistry(deps = {}) {
|
|
340
|
+
const tribes = new Map();
|
|
341
|
+
const build = (config) => {
|
|
342
|
+
const merged = {
|
|
343
|
+
...config,
|
|
344
|
+
...(config.now === undefined && deps.now !== undefined ? { now: deps.now } : {}),
|
|
345
|
+
...(config.logCap === undefined && deps.defaultLogCap !== undefined ? { logCap: deps.defaultLogCap } : {}),
|
|
346
|
+
};
|
|
347
|
+
return createTribe(merged);
|
|
348
|
+
};
|
|
349
|
+
function relationBetweenTribes(tribeA, tribeB) {
|
|
350
|
+
if (tribeA === tribeB)
|
|
351
|
+
return "friendly";
|
|
352
|
+
const a = tribes.get(tribeA);
|
|
353
|
+
if (a === undefined || !tribes.has(tribeB))
|
|
354
|
+
return "neutral";
|
|
355
|
+
return a.isAllied(tribeB) ? "friendly" : "neutral";
|
|
356
|
+
}
|
|
357
|
+
function tribeOfMember(memberId) {
|
|
358
|
+
for (const tribe of tribes.values()) {
|
|
359
|
+
if (tribe.hasMember(memberId))
|
|
360
|
+
return tribe;
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
function relationBetweenMembers(memberA, memberB) {
|
|
365
|
+
const a = tribeOfMember(memberA);
|
|
366
|
+
const b = tribeOfMember(memberB);
|
|
367
|
+
if (a === null || b === null)
|
|
368
|
+
return "neutral";
|
|
369
|
+
if (a.id === b.id)
|
|
370
|
+
return "friendly";
|
|
371
|
+
return relationBetweenTribes(a.id, b.id);
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
create(config) {
|
|
375
|
+
if (tribes.has(config.id))
|
|
376
|
+
throw new Error(`duplicate tribe id "${config.id}"`);
|
|
377
|
+
const tribe = build(config);
|
|
378
|
+
tribes.set(config.id, tribe);
|
|
379
|
+
return tribe;
|
|
380
|
+
},
|
|
381
|
+
get: (id) => tribes.get(id) ?? null,
|
|
382
|
+
all: () => [...tribes.values()],
|
|
383
|
+
remove(id) {
|
|
384
|
+
if (!tribes.delete(id))
|
|
385
|
+
return;
|
|
386
|
+
for (const tribe of tribes.values())
|
|
387
|
+
tribe.removeAlly(id);
|
|
388
|
+
},
|
|
389
|
+
formAlliance(tribeA, tribeB) {
|
|
390
|
+
if (tribeA === tribeB)
|
|
391
|
+
return { reason: "a tribe cannot ally with itself" };
|
|
392
|
+
const a = tribes.get(tribeA);
|
|
393
|
+
const b = tribes.get(tribeB);
|
|
394
|
+
if (a === undefined || b === undefined)
|
|
395
|
+
return { reason: "unknown tribe" };
|
|
396
|
+
a.addAlly(tribeB);
|
|
397
|
+
b.addAlly(tribeA);
|
|
398
|
+
return null;
|
|
399
|
+
},
|
|
400
|
+
breakAlliance(tribeA, tribeB) {
|
|
401
|
+
tribes.get(tribeA)?.removeAlly(tribeB);
|
|
402
|
+
tribes.get(tribeB)?.removeAlly(tribeA);
|
|
403
|
+
},
|
|
404
|
+
areAllied(tribeA, tribeB) {
|
|
405
|
+
const a = tribes.get(tribeA);
|
|
406
|
+
const b = tribes.get(tribeB);
|
|
407
|
+
return a !== undefined && b !== undefined && a.isAllied(tribeB) && b.isAllied(tribeA);
|
|
408
|
+
},
|
|
409
|
+
relationBetweenTribes,
|
|
410
|
+
tribeOfMember,
|
|
411
|
+
relationBetweenMembers,
|
|
412
|
+
isFriendly: (memberA, memberB) => relationBetweenMembers(memberA, memberB) === "friendly",
|
|
413
|
+
snapshot: () => ({ tribes: [...tribes.values()].map((tribe) => tribe.snapshot()) }),
|
|
414
|
+
hydrate(snapshot) {
|
|
415
|
+
tribes.clear();
|
|
416
|
+
for (const tribeSnap of snapshot.tribes) {
|
|
417
|
+
const config = {
|
|
418
|
+
id: tribeSnap.id,
|
|
419
|
+
founderId: tribeSnap.founderId,
|
|
420
|
+
ranks: tribeSnap.ranks,
|
|
421
|
+
defaultRankId: tribeSnap.defaultRankId,
|
|
422
|
+
founderRankId: tribeSnap.founderRankId,
|
|
423
|
+
logCap: tribeSnap.logCap,
|
|
424
|
+
...(deps.now === undefined ? {} : { now: deps.now }),
|
|
425
|
+
};
|
|
426
|
+
const tribe = createTribe(config);
|
|
427
|
+
tribe.hydrate(tribeSnap);
|
|
428
|
+
tribes.set(tribe.id, tribe);
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|