@jgengine/core 0.29.0 → 0.31.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 +53 -0
- package/dist/agents/agent.d.ts +134 -0
- package/dist/agents/agent.js +224 -0
- package/dist/agents/mood.d.ts +74 -0
- package/dist/agents/mood.js +97 -0
- package/dist/agents/relationships.d.ts +92 -0
- package/dist/agents/relationships.js +132 -0
- package/dist/agents/schedule.d.ts +58 -0
- package/dist/agents/schedule.js +72 -0
- package/dist/ai/director/boss.d.ts +63 -0
- package/dist/ai/director/boss.js +87 -0
- package/dist/ai/director/encounter.d.ts +146 -0
- package/dist/ai/director/encounter.js +195 -0
- package/dist/ai/director/pressure.d.ts +59 -0
- package/dist/ai/director/pressure.js +71 -0
- package/dist/ai/director/roles.d.ts +61 -0
- package/dist/ai/director/roles.js +118 -0
- package/dist/ai/director/sound.d.ts +79 -0
- package/dist/ai/director/sound.js +113 -0
- package/dist/game/gameSave.d.ts +6 -0
- package/dist/game/gameSave.js +6 -0
- package/dist/game/playableGame.d.ts +2 -2
- package/dist/game/saveStore.d.ts +7 -0
- package/dist/game/saveStore.js +35 -0
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/item/affix.d.ts +2 -0
- package/dist/loot/drop.d.ts +47 -0
- package/dist/loot/drop.js +57 -0
- package/dist/loot/haul.d.ts +78 -0
- package/dist/loot/haul.js +126 -0
- package/dist/loot/lootGen.d.ts +7 -1
- package/dist/loot/lootGen.js +24 -5
- package/dist/loot/salvage.d.ts +74 -0
- package/dist/loot/salvage.js +68 -0
- package/dist/loot/vendor.d.ts +76 -0
- package/dist/loot/vendor.js +72 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +47 -1
- package/dist/mission/mission.d.ts +24 -0
- package/dist/mission/mission.js +28 -3
- package/dist/progression/careerBank.d.ts +63 -0
- package/dist/progression/careerBank.js +83 -0
- package/dist/progression/gates.d.ts +66 -0
- package/dist/progression/gates.js +88 -0
- package/dist/progression/standing.d.ts +77 -0
- package/dist/progression/standing.js +151 -0
- package/dist/progression/upgrades.d.ts +70 -0
- package/dist/progression/upgrades.js +128 -0
- package/dist/ui/accessibility.d.ts +8 -0
- package/dist/ui/accessibility.js +14 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-gameplay/SKILL.md +12 -1
- package/skills/jgengine-gameplay/api.md +141 -3
- package/skills/jgengine-gameplay/capabilities.md +74 -0
- package/skills/jgengine-gameplay/recipes/agent-lives.md +76 -0
- package/skills/jgengine-world/api.md +52 -0
- package/skills/jgengine-world/capabilities.md +32 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { fromMinorUnits, toMinorUnits } from "../economy/currency.js";
|
|
2
|
+
import { charge, createEmptyWallet, grant } from "../economy/wallet.js";
|
|
3
|
+
function sanitizeAmount(value) {
|
|
4
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Money that means something between runs: a persistent balance plus a run's haul that is banked on
|
|
8
|
+
* a cash-out and lost on a failure. Amounts are snapped to the currency's minor units, so a save
|
|
9
|
+
* round-trip and a display never drift. Pair it with `economy/upgrades` for a career upgrade tree.
|
|
10
|
+
*
|
|
11
|
+
* @capability career-bank persistent career money with a run haul that can be banked or lost
|
|
12
|
+
*/
|
|
13
|
+
export function createCareerBank(config) {
|
|
14
|
+
const currency = config.currency;
|
|
15
|
+
const snap = (amount) => fromMinorUnits(currency, toMinorUnits(currency, sanitizeAmount(amount)));
|
|
16
|
+
const haulCap = config.haulCap === undefined ? Number.POSITIVE_INFINITY : snap(config.haulCap);
|
|
17
|
+
const walletOf = (bankedAmount) => bankedAmount > 0 ? grant(createEmptyWallet(), currency, bankedAmount) : createEmptyWallet();
|
|
18
|
+
let wallet = walletOf(snap(config.start ?? 0));
|
|
19
|
+
let haul = 0;
|
|
20
|
+
let deposited = 0;
|
|
21
|
+
let lost = 0;
|
|
22
|
+
let bestHaul = 0;
|
|
23
|
+
let runs = 0;
|
|
24
|
+
const banked = () => wallet.balances[currency.id] ?? 0;
|
|
25
|
+
if (config.state !== undefined) {
|
|
26
|
+
wallet = walletOf(snap(config.state.banked));
|
|
27
|
+
haul = snap(config.state.haul);
|
|
28
|
+
deposited = snap(config.state.deposited);
|
|
29
|
+
lost = snap(config.state.lost);
|
|
30
|
+
bestHaul = snap(config.state.bestHaul);
|
|
31
|
+
runs = Number.isInteger(config.state.runs) && config.state.runs > 0 ? config.state.runs : 0;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
currency: () => currency,
|
|
35
|
+
banked,
|
|
36
|
+
haul: () => haul,
|
|
37
|
+
earn(amount) {
|
|
38
|
+
const added = snap(amount);
|
|
39
|
+
if (added > 0)
|
|
40
|
+
haul = snap(Math.min(haul + added, haulCap));
|
|
41
|
+
return haul;
|
|
42
|
+
},
|
|
43
|
+
cashOut() {
|
|
44
|
+
const held = haul;
|
|
45
|
+
if (held > 0) {
|
|
46
|
+
wallet = grant(wallet, currency, held);
|
|
47
|
+
deposited = snap(deposited + held);
|
|
48
|
+
bestHaul = Math.max(bestHaul, held);
|
|
49
|
+
}
|
|
50
|
+
haul = 0;
|
|
51
|
+
runs += 1;
|
|
52
|
+
return { deposited: held, banked: banked() };
|
|
53
|
+
},
|
|
54
|
+
loseHaul() {
|
|
55
|
+
const gone = haul;
|
|
56
|
+
haul = 0;
|
|
57
|
+
lost = snap(lost + gone);
|
|
58
|
+
runs += 1;
|
|
59
|
+
return { lost: gone, banked: banked() };
|
|
60
|
+
},
|
|
61
|
+
spend(amount) {
|
|
62
|
+
const cost = snap(amount);
|
|
63
|
+
if (cost <= 0)
|
|
64
|
+
return false;
|
|
65
|
+
const paid = charge(wallet, currency, cost);
|
|
66
|
+
if (paid.status !== "ok")
|
|
67
|
+
return false;
|
|
68
|
+
wallet = paid.state;
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
afford: (amount) => banked() >= snap(amount),
|
|
72
|
+
counters: () => ({ deposited, lost, bestHaul, runs }),
|
|
73
|
+
snapshot: () => ({ banked: banked(), haul, deposited, lost, bestHaul, runs }),
|
|
74
|
+
restore(next) {
|
|
75
|
+
wallet = walletOf(snap(next.banked));
|
|
76
|
+
haul = snap(next.haul);
|
|
77
|
+
deposited = snap(next.deposited);
|
|
78
|
+
lost = snap(next.lost);
|
|
79
|
+
bestHaul = snap(next.bestHaul);
|
|
80
|
+
runs = Number.isInteger(next.runs) && next.runs > 0 ? next.runs : 0;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { UnlockCatalog } from "../game/unlocks.js";
|
|
2
|
+
import type { Standing, StandingRequirement } from "./standing.js";
|
|
3
|
+
/** What a gate is evaluated against: standing by track and money by currency. */
|
|
4
|
+
export interface GateFacts {
|
|
5
|
+
standing(trackId: string): number;
|
|
6
|
+
balance(currency: string): number;
|
|
7
|
+
}
|
|
8
|
+
/** Every condition a gate can require; all named conditions must hold. */
|
|
9
|
+
export interface GateRequirement {
|
|
10
|
+
standing?: StandingRequirement;
|
|
11
|
+
/** Smallest balance of `currency` that passes. */
|
|
12
|
+
balance?: {
|
|
13
|
+
currency: string;
|
|
14
|
+
min: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A milestone: content that opens exactly when its requirements hold and names the unlock it
|
|
19
|
+
* grants. `unlock` must resolve in the catalog the set is built with, or the set refuses to load.
|
|
20
|
+
*/
|
|
21
|
+
export interface GateDef {
|
|
22
|
+
id: string;
|
|
23
|
+
unlock: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
category?: string;
|
|
26
|
+
requires?: GateRequirement;
|
|
27
|
+
}
|
|
28
|
+
/** Which named condition a closed gate still needs. */
|
|
29
|
+
export type GateMiss = "standing" | "balance";
|
|
30
|
+
/** One gate against live facts. */
|
|
31
|
+
export interface GateStatus {
|
|
32
|
+
gate: GateDef;
|
|
33
|
+
open: boolean;
|
|
34
|
+
missing: readonly GateMiss[];
|
|
35
|
+
}
|
|
36
|
+
/** Evaluated gate content: the table, its status against facts, and the grants a caller applies. */
|
|
37
|
+
export interface GateSet {
|
|
38
|
+
gates(): readonly GateDef[];
|
|
39
|
+
gate(gateId: string): GateDef | null;
|
|
40
|
+
status(facts: GateFacts): readonly GateStatus[];
|
|
41
|
+
isOpen(gateId: string, facts: GateFacts): boolean;
|
|
42
|
+
open(facts: GateFacts): readonly GateDef[];
|
|
43
|
+
/** Gates met now whose `unlock` is not in `granted` — the unlocks to grant this pass. */
|
|
44
|
+
claim(facts: GateFacts, granted: readonly string[]): readonly GateDef[];
|
|
45
|
+
}
|
|
46
|
+
/** Content and the resolvers a {@link GateSet} is built from. */
|
|
47
|
+
export interface GateSetConfig {
|
|
48
|
+
gates: readonly GateDef[];
|
|
49
|
+
/** The ids an unlock may resolve to; normally the game's content catalog. */
|
|
50
|
+
catalog: Pick<UnlockCatalog, "has">;
|
|
51
|
+
/** The standing a requirement reads; also validates every requirement's track at load. */
|
|
52
|
+
standing?: Pick<Standing, "hasTrack">;
|
|
53
|
+
}
|
|
54
|
+
/** Read a {@link Standing} and a balance record as {@link GateFacts}. */
|
|
55
|
+
export declare function gateFacts(standing: Pick<Standing, "standing">, actorId: string, balances?: Readonly<Record<string, number>>): GateFacts;
|
|
56
|
+
/** Check an authored gate table against the content it grants; throws `RangeError` naming the gate. */
|
|
57
|
+
export declare function assertGates(config: GateSetConfig): void;
|
|
58
|
+
/**
|
|
59
|
+
* Authored unlock gates: each one names the unlock it grants and the standing or balance it reads.
|
|
60
|
+
* Evaluated against caller-supplied facts, so one table serves a single-player save, a server actor
|
|
61
|
+
* or a probe. Load refuses a gate whose unlock id resolves to nothing (the D3.8 class), so a dead
|
|
62
|
+
* milestone fails at startup instead of in play. A gate with no requirements is open from the start.
|
|
63
|
+
*
|
|
64
|
+
* @capability unlock-gates milestones gated by standing or money, validated against the content they grant
|
|
65
|
+
*/
|
|
66
|
+
export declare function createGateSet(config: GateSetConfig): GateSet;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Read a {@link Standing} and a balance record as {@link GateFacts}. */
|
|
2
|
+
export function gateFacts(standing, actorId, balances = {}) {
|
|
3
|
+
return {
|
|
4
|
+
standing: (trackId) => standing.standing(actorId, trackId),
|
|
5
|
+
balance: (currency) => balances[currency] ?? 0,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
function check(condition, message) {
|
|
9
|
+
if (!condition)
|
|
10
|
+
throw new RangeError(message);
|
|
11
|
+
}
|
|
12
|
+
/** Check an authored gate table against the content it grants; throws `RangeError` naming the gate. */
|
|
13
|
+
export function assertGates(config) {
|
|
14
|
+
const ids = new Set();
|
|
15
|
+
for (const gate of config.gates) {
|
|
16
|
+
const id = gate.id.trim();
|
|
17
|
+
check(id !== "", "Gate id must not be empty");
|
|
18
|
+
check(!ids.has(id), `Gate "${id}" is declared twice`);
|
|
19
|
+
ids.add(id);
|
|
20
|
+
check(gate.unlock.trim() !== "", `Gate "${id}" names an empty unlock`);
|
|
21
|
+
check(config.catalog.has(gate.unlock), `Gate "${id}" unlocks unknown id "${gate.unlock}" - the reward resolves to nothing`);
|
|
22
|
+
const standing = gate.requires?.standing;
|
|
23
|
+
if (standing !== undefined) {
|
|
24
|
+
check(standing.trackId.trim() !== "", `Gate "${id}" names an empty standing track`);
|
|
25
|
+
check(config.standing === undefined || config.standing.hasTrack(standing.trackId), `Gate "${id}" reads unknown standing track "${standing.trackId}"`);
|
|
26
|
+
if (standing.min !== undefined)
|
|
27
|
+
check(Number.isFinite(standing.min), `Gate "${id}" standing min must be finite`);
|
|
28
|
+
if (standing.max !== undefined)
|
|
29
|
+
check(Number.isFinite(standing.max), `Gate "${id}" standing max must be finite`);
|
|
30
|
+
check(standing.min === undefined || standing.max === undefined || standing.min <= standing.max, `Gate "${id}" needs standing min <= max`);
|
|
31
|
+
}
|
|
32
|
+
const balance = gate.requires?.balance;
|
|
33
|
+
if (balance !== undefined) {
|
|
34
|
+
check(balance.currency.trim() !== "", `Gate "${id}" names an empty currency`);
|
|
35
|
+
check(Number.isFinite(balance.min) && balance.min >= 0, `Gate "${id}" balance min must be finite and >= 0`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function missingFor(gate, facts) {
|
|
40
|
+
const missing = [];
|
|
41
|
+
const standing = gate.requires?.standing;
|
|
42
|
+
if (standing !== undefined) {
|
|
43
|
+
const value = facts.standing(standing.trackId);
|
|
44
|
+
if ((standing.min !== undefined && value < standing.min) || (standing.max !== undefined && value > standing.max)) {
|
|
45
|
+
missing.push("standing");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const balance = gate.requires?.balance;
|
|
49
|
+
if (balance !== undefined && facts.balance(balance.currency) < balance.min)
|
|
50
|
+
missing.push("balance");
|
|
51
|
+
return missing;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Authored unlock gates: each one names the unlock it grants and the standing or balance it reads.
|
|
55
|
+
* Evaluated against caller-supplied facts, so one table serves a single-player save, a server actor
|
|
56
|
+
* or a probe. Load refuses a gate whose unlock id resolves to nothing (the D3.8 class), so a dead
|
|
57
|
+
* milestone fails at startup instead of in play. A gate with no requirements is open from the start.
|
|
58
|
+
*
|
|
59
|
+
* @capability unlock-gates milestones gated by standing or money, validated against the content they grant
|
|
60
|
+
*/
|
|
61
|
+
export function createGateSet(config) {
|
|
62
|
+
assertGates(config);
|
|
63
|
+
const definitions = config.gates.slice();
|
|
64
|
+
function statusOf(gate, facts) {
|
|
65
|
+
const missing = missingFor(gate, facts);
|
|
66
|
+
return { gate, open: missing.length === 0, missing };
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
gates: () => definitions.slice(),
|
|
70
|
+
gate: (gateId) => definitions.find((gate) => gate.id === gateId) ?? null,
|
|
71
|
+
status: (facts) => definitions.map((gate) => statusOf(gate, facts)),
|
|
72
|
+
isOpen(gateId, facts) {
|
|
73
|
+
const gate = definitions.find((entry) => entry.id === gateId);
|
|
74
|
+
return gate === undefined ? false : missingFor(gate, facts).length === 0;
|
|
75
|
+
},
|
|
76
|
+
open: (facts) => definitions.filter((gate) => missingFor(gate, facts).length === 0),
|
|
77
|
+
claim(facts, granted) {
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const gate of definitions) {
|
|
80
|
+
if (granted.includes(gate.unlock))
|
|
81
|
+
continue;
|
|
82
|
+
if (missingFor(gate, facts).length === 0)
|
|
83
|
+
out.push(gate);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type ReputationTier } from "../faction/reputation.js";
|
|
2
|
+
/** One standing track: a faction, a district, a licence board — whatever an authored outcome moves. */
|
|
3
|
+
export interface StandingTrackDef {
|
|
4
|
+
id: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Tiers this track crosses, ascending by `min`; defaults to the shared reputation ladder. */
|
|
7
|
+
tiers?: readonly ReputationTier[];
|
|
8
|
+
/** Standing every actor starts at (default `0`). */
|
|
9
|
+
initial?: number;
|
|
10
|
+
/** Floor and ceiling; omitted means unbounded. */
|
|
11
|
+
min?: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
}
|
|
14
|
+
/** One outcome a game can report, and the standing it moves on each track. */
|
|
15
|
+
export interface StandingOutcomeDef {
|
|
16
|
+
id: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Track id → signed standing. A negative number is the fall. */
|
|
19
|
+
impacts: Readonly<Record<string, number>>;
|
|
20
|
+
}
|
|
21
|
+
/** Authored standing content: the tracks an actor stands with, and the outcomes that move them. */
|
|
22
|
+
export interface StandingConfig {
|
|
23
|
+
tracks: readonly StandingTrackDef[];
|
|
24
|
+
outcomes?: readonly StandingOutcomeDef[];
|
|
25
|
+
}
|
|
26
|
+
/** One track's movement, with the tier on both sides so a caller can announce a crossing. */
|
|
27
|
+
export interface StandingChange {
|
|
28
|
+
trackId: string;
|
|
29
|
+
before: number;
|
|
30
|
+
after: number;
|
|
31
|
+
delta: number;
|
|
32
|
+
previousTier: string;
|
|
33
|
+
tier: string;
|
|
34
|
+
}
|
|
35
|
+
/** A threshold on one track, as an unlock or an upgrade reads it. */
|
|
36
|
+
export interface StandingRequirement {
|
|
37
|
+
trackId: string;
|
|
38
|
+
/** Smallest standing that passes; omitted means no floor. */
|
|
39
|
+
min?: number;
|
|
40
|
+
/** Largest standing that passes; omitted means no ceiling. */
|
|
41
|
+
max?: number;
|
|
42
|
+
}
|
|
43
|
+
/** Plain JSON state of a {@link Standing}; tracks left at their authored `initial` are absent. */
|
|
44
|
+
export interface StandingState {
|
|
45
|
+
records: Record<string, Record<string, number>>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Live standing over authored tracks, moved by named outcomes or direct adjustments, with the
|
|
49
|
+
* tier on each track and a serializable snapshot.
|
|
50
|
+
*/
|
|
51
|
+
export interface Standing {
|
|
52
|
+
tracks(): readonly StandingTrackDef[];
|
|
53
|
+
hasTrack(trackId: string): boolean;
|
|
54
|
+
hasOutcome(outcomeId: string): boolean;
|
|
55
|
+
standing(actorId: string, trackId: string): number;
|
|
56
|
+
tier(actorId: string, trackId: string): ReputationTier;
|
|
57
|
+
/** Every track's standing for one actor, authored tracks included at their initial value. */
|
|
58
|
+
standings(actorId: string): Record<string, number>;
|
|
59
|
+
/** Apply an authored outcome; returns each track it moved. Throws on an unknown outcome id. */
|
|
60
|
+
apply(actorId: string, outcomeId: string): readonly StandingChange[];
|
|
61
|
+
/** Move one track directly; returns the change. Throws on an unknown track id. */
|
|
62
|
+
adjust(actorId: string, trackId: string, delta: number): StandingChange;
|
|
63
|
+
meets(actorId: string, requirement: StandingRequirement): boolean;
|
|
64
|
+
snapshot(): StandingState;
|
|
65
|
+
restore(next: StandingState): void;
|
|
66
|
+
}
|
|
67
|
+
/** Check authored standing content before use; throws `RangeError` naming the offending field. */
|
|
68
|
+
export declare function assertStandingConfig(config: StandingConfig): void;
|
|
69
|
+
/**
|
|
70
|
+
* Per-faction or per-district standing that rises and falls from named outcomes, over authored
|
|
71
|
+
* tracks. Numbers live in the shared reputation ledger (`faction/reputation`); this adds the
|
|
72
|
+
* authored outcome table, tier-per-track and the one snapshot a save stores. An outcome id or
|
|
73
|
+
* track id that resolves to nothing throws at the call, so authored content fails loudly.
|
|
74
|
+
*
|
|
75
|
+
* @capability standing per-track standing moved by authored outcomes, with tiers and one snapshot
|
|
76
|
+
*/
|
|
77
|
+
export declare function createStanding(config: StandingConfig): Standing;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createReputationLedger, DEFAULT_REPUTATION_TIERS, } from "../faction/reputation.js";
|
|
2
|
+
function check(condition, message) {
|
|
3
|
+
if (!condition)
|
|
4
|
+
throw new RangeError(message);
|
|
5
|
+
}
|
|
6
|
+
function isFiniteNumber(value) {
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
8
|
+
}
|
|
9
|
+
/** Check authored standing content before use; throws `RangeError` naming the offending field. */
|
|
10
|
+
export function assertStandingConfig(config) {
|
|
11
|
+
const trackIds = new Set();
|
|
12
|
+
for (const track of config.tracks) {
|
|
13
|
+
const id = track.id.trim();
|
|
14
|
+
check(id !== "", "Standing track id must not be empty");
|
|
15
|
+
check(!trackIds.has(id), `Standing track "${id}" is declared twice`);
|
|
16
|
+
trackIds.add(id);
|
|
17
|
+
if (track.min !== undefined)
|
|
18
|
+
check(isFiniteNumber(track.min), `Standing track "${id}" min must be finite`);
|
|
19
|
+
if (track.max !== undefined)
|
|
20
|
+
check(isFiniteNumber(track.max), `Standing track "${id}" max must be finite`);
|
|
21
|
+
check(track.min === undefined || track.max === undefined || track.min <= track.max, `Standing track "${id}" needs min <= max`);
|
|
22
|
+
if (track.initial !== undefined) {
|
|
23
|
+
check(isFiniteNumber(track.initial), `Standing track "${id}" initial must be finite`);
|
|
24
|
+
check((track.min === undefined || track.initial >= track.min) && (track.max === undefined || track.initial <= track.max), `Standing track "${id}" initial ${track.initial} is outside its bounds`);
|
|
25
|
+
}
|
|
26
|
+
const tiers = track.tiers ?? DEFAULT_REPUTATION_TIERS;
|
|
27
|
+
check(tiers.length > 0, `Standing track "${id}" needs at least one tier`);
|
|
28
|
+
const tierIds = new Set();
|
|
29
|
+
for (const tier of tiers) {
|
|
30
|
+
check(tier.id.trim() !== "", `Standing track "${id}" has a tier with an empty id`);
|
|
31
|
+
check(!tierIds.has(tier.id), `Standing track "${id}" declares tier "${tier.id}" twice`);
|
|
32
|
+
check(isFiniteNumber(tier.min), `Standing track "${id}" tier "${tier.id}" min must be finite`);
|
|
33
|
+
tierIds.add(tier.id);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const outcomeIds = new Set();
|
|
37
|
+
for (const outcome of config.outcomes ?? []) {
|
|
38
|
+
const id = outcome.id.trim();
|
|
39
|
+
check(id !== "", "Standing outcome id must not be empty");
|
|
40
|
+
check(!outcomeIds.has(id), `Standing outcome "${id}" is declared twice`);
|
|
41
|
+
outcomeIds.add(id);
|
|
42
|
+
const impacts = Object.entries(outcome.impacts);
|
|
43
|
+
check(impacts.length > 0, `Standing outcome "${id}" moves no track`);
|
|
44
|
+
for (const [trackId, delta] of impacts) {
|
|
45
|
+
check(trackIds.has(trackId), `Standing outcome "${id}" names unknown track "${trackId}"`);
|
|
46
|
+
check(isFiniteNumber(delta), `Standing outcome "${id}" impact on "${trackId}" must be finite`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Per-faction or per-district standing that rises and falls from named outcomes, over authored
|
|
52
|
+
* tracks. Numbers live in the shared reputation ledger (`faction/reputation`); this adds the
|
|
53
|
+
* authored outcome table, tier-per-track and the one snapshot a save stores. An outcome id or
|
|
54
|
+
* track id that resolves to nothing throws at the call, so authored content fails loudly.
|
|
55
|
+
*
|
|
56
|
+
* @capability standing per-track standing moved by authored outcomes, with tiers and one snapshot
|
|
57
|
+
*/
|
|
58
|
+
export function createStanding(config) {
|
|
59
|
+
assertStandingConfig(config);
|
|
60
|
+
const tracks = new Map();
|
|
61
|
+
for (const track of config.tracks)
|
|
62
|
+
tracks.set(track.id, track);
|
|
63
|
+
const outcomes = new Map();
|
|
64
|
+
for (const outcome of config.outcomes ?? [])
|
|
65
|
+
outcomes.set(outcome.id, outcome);
|
|
66
|
+
const ledgers = new Map();
|
|
67
|
+
for (const track of config.tracks) {
|
|
68
|
+
ledgers.set(track.id, createReputationLedger({
|
|
69
|
+
tiers: track.tiers ?? DEFAULT_REPUTATION_TIERS,
|
|
70
|
+
initial: { [track.id]: track.initial ?? 0 },
|
|
71
|
+
...(track.min === undefined ? {} : { min: track.min }),
|
|
72
|
+
...(track.max === undefined ? {} : { max: track.max }),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
function ledgerFor(trackId) {
|
|
76
|
+
const ledger = ledgers.get(trackId);
|
|
77
|
+
if (ledger === undefined)
|
|
78
|
+
throw new RangeError(`Standing track "${trackId}" is not authored`);
|
|
79
|
+
return ledger;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
tracks: () => config.tracks.slice(),
|
|
83
|
+
hasTrack: (trackId) => tracks.has(trackId),
|
|
84
|
+
hasOutcome: (outcomeId) => outcomes.has(outcomeId),
|
|
85
|
+
standing(actorId, trackId) {
|
|
86
|
+
return ledgerFor(trackId).standing(actorId, trackId);
|
|
87
|
+
},
|
|
88
|
+
tier: (actorId, trackId) => ledgerFor(trackId).tier(actorId, trackId),
|
|
89
|
+
standings(actorId) {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const trackId of ledgers.keys())
|
|
92
|
+
out[trackId] = ledgerFor(trackId).standing(actorId, trackId);
|
|
93
|
+
return out;
|
|
94
|
+
},
|
|
95
|
+
apply(actorId, outcomeId) {
|
|
96
|
+
const outcome = outcomes.get(outcomeId);
|
|
97
|
+
if (outcome === undefined)
|
|
98
|
+
throw new RangeError(`Standing outcome "${outcomeId}" is not authored`);
|
|
99
|
+
const changes = [];
|
|
100
|
+
for (const [trackId, delta] of Object.entries(outcome.impacts)) {
|
|
101
|
+
const ledger = ledgerFor(trackId);
|
|
102
|
+
const before = ledger.standing(actorId, trackId);
|
|
103
|
+
const previousTier = ledger.tier(actorId, trackId).id;
|
|
104
|
+
ledger.gain(actorId, trackId, delta);
|
|
105
|
+
const after = ledger.standing(actorId, trackId);
|
|
106
|
+
changes.push({ trackId, before, after, delta: after - before, previousTier, tier: ledger.tier(actorId, trackId).id });
|
|
107
|
+
}
|
|
108
|
+
return changes;
|
|
109
|
+
},
|
|
110
|
+
adjust(actorId, trackId, delta) {
|
|
111
|
+
const ledger = ledgerFor(trackId);
|
|
112
|
+
const before = ledger.standing(actorId, trackId);
|
|
113
|
+
const previousTier = ledger.tier(actorId, trackId).id;
|
|
114
|
+
ledger.gain(actorId, trackId, delta);
|
|
115
|
+
const after = ledger.standing(actorId, trackId);
|
|
116
|
+
return { trackId, before, after, delta: after - before, previousTier, tier: ledger.tier(actorId, trackId).id };
|
|
117
|
+
},
|
|
118
|
+
meets(actorId, requirement) {
|
|
119
|
+
const value = ledgerFor(requirement.trackId).standing(actorId, requirement.trackId);
|
|
120
|
+
return (requirement.min === undefined || value >= requirement.min) && (requirement.max === undefined || value <= requirement.max);
|
|
121
|
+
},
|
|
122
|
+
snapshot() {
|
|
123
|
+
const records = {};
|
|
124
|
+
for (const [trackId, ledger] of ledgers) {
|
|
125
|
+
for (const [actorId, row] of Object.entries(ledger.snapshot().records)) {
|
|
126
|
+
const target = records[actorId] ?? (records[actorId] = {});
|
|
127
|
+
const value = row[trackId];
|
|
128
|
+
if (value !== undefined)
|
|
129
|
+
target[trackId] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { records };
|
|
133
|
+
},
|
|
134
|
+
restore(next) {
|
|
135
|
+
const byTrack = new Map();
|
|
136
|
+
for (const [actorId, row] of Object.entries(next.records ?? {})) {
|
|
137
|
+
for (const [trackId, value] of Object.entries(row)) {
|
|
138
|
+
if (!ledgers.has(trackId) || !isFiniteNumber(value))
|
|
139
|
+
continue;
|
|
140
|
+
const target = byTrack.get(trackId);
|
|
141
|
+
if (target === undefined)
|
|
142
|
+
byTrack.set(trackId, { [actorId]: { [trackId]: value } });
|
|
143
|
+
else
|
|
144
|
+
target[actorId] = { [trackId]: value };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const [trackId, ledger] of ledgers)
|
|
148
|
+
ledger.restore({ records: byTrack.get(trackId) ?? {} });
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type Curve } from "../game/progression.js";
|
|
2
|
+
import type { Standing, StandingRequirement } from "./standing.js";
|
|
3
|
+
/**
|
|
4
|
+
* One upgrade node. Its price is the shared progression {@link Curve} evaluated at the rank being
|
|
5
|
+
* bought, so a game authors one curve and every rank of every node follows it.
|
|
6
|
+
*/
|
|
7
|
+
export interface UpgradeNodeDef {
|
|
8
|
+
id: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Cost of rank `n`, priced by this curve at `n` (1-based). */
|
|
11
|
+
cost: Curve;
|
|
12
|
+
/** Ranks purchasable (default `1`). */
|
|
13
|
+
maxRank?: number;
|
|
14
|
+
/** Node ids that must be owned before this one can be bought. */
|
|
15
|
+
requires?: readonly string[];
|
|
16
|
+
standing?: StandingRequirement;
|
|
17
|
+
}
|
|
18
|
+
/** Plain JSON state: node id → ranks owned. Nodes at rank `0` are absent. */
|
|
19
|
+
export interface UpgradeState {
|
|
20
|
+
ranks: Readonly<Record<string, number>>;
|
|
21
|
+
}
|
|
22
|
+
/** Why a purchase was refused; the tree is left untouched on any rejection. */
|
|
23
|
+
export type UpgradeRejection = "unknown-node" | "max-rank" | "missing-prerequisites" | "standing" | "insufficient-funds";
|
|
24
|
+
/** Result of {@link UpgradeTree.buy}: the rank reached and what it cost, or why it was refused. */
|
|
25
|
+
export type UpgradeBuy = {
|
|
26
|
+
ok: true;
|
|
27
|
+
nodeId: string;
|
|
28
|
+
rank: number;
|
|
29
|
+
cost: number;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: UpgradeRejection;
|
|
33
|
+
};
|
|
34
|
+
/** Authored upgrade nodes with a per-node cost curve, prerequisites, a standing gate and one snapshot. */
|
|
35
|
+
export interface UpgradeTree {
|
|
36
|
+
nodes(): readonly UpgradeNodeDef[];
|
|
37
|
+
node(nodeId: string): UpgradeNodeDef | null;
|
|
38
|
+
/** Ranks owned for `nodeId`; `0` when unknown or unowned. */
|
|
39
|
+
rank(nodeId: string): number;
|
|
40
|
+
maxRank(nodeId: string): number;
|
|
41
|
+
/** Price of `rank` (default: the next rank), or `null` when unknown or past the cap. */
|
|
42
|
+
costOf(nodeId: string, rank?: number): number | null;
|
|
43
|
+
/** What buying the next rank needs, without changing anything. */
|
|
44
|
+
canBuy(nodeId: string, actorId: string): UpgradeBuy;
|
|
45
|
+
/** Buy the next rank, charging `pay` only after every gate passes. */
|
|
46
|
+
buy(nodeId: string, actorId: string, pay: (cost: number) => boolean): UpgradeBuy;
|
|
47
|
+
owned(): readonly string[];
|
|
48
|
+
spendTotal(): number;
|
|
49
|
+
snapshot(): UpgradeState;
|
|
50
|
+
restore(next: UpgradeState): void;
|
|
51
|
+
}
|
|
52
|
+
/** Authored nodes plus the standing a node's requirement reads. */
|
|
53
|
+
export interface UpgradeTreeConfig {
|
|
54
|
+
nodes: readonly UpgradeNodeDef[];
|
|
55
|
+
/** The standing a node's requirement reads, for both validation and purchase. */
|
|
56
|
+
standing?: Pick<Standing, "hasTrack" | "standing">;
|
|
57
|
+
/** Rehydrate from a prior {@link UpgradeTree.snapshot}. */
|
|
58
|
+
state?: UpgradeState;
|
|
59
|
+
}
|
|
60
|
+
/** Check authored upgrade nodes; throws `RangeError` naming the offending node or field. */
|
|
61
|
+
export declare function assertUpgrades(config: UpgradeTreeConfig): void;
|
|
62
|
+
/**
|
|
63
|
+
* An upgrade tree whose prices come from the shared curve primitive: each node authors one cost
|
|
64
|
+
* curve, each rank is that curve at the rank, and a purchase passes prerequisites, a standing gate
|
|
65
|
+
* and the caller's own payment step. State is one rank map that JSON round-trips, so a career save
|
|
66
|
+
* stores what a player paid for rather than the arithmetic behind it.
|
|
67
|
+
*
|
|
68
|
+
* @capability upgrade-tree ranked upgrades priced by a cost curve, gated by prerequisites and standing
|
|
69
|
+
*/
|
|
70
|
+
export declare function createUpgradeTree(config: UpgradeTreeConfig): UpgradeTree;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { evalCurve } from "../game/progression.js";
|
|
2
|
+
function check(condition, message) {
|
|
3
|
+
if (!condition)
|
|
4
|
+
throw new RangeError(message);
|
|
5
|
+
}
|
|
6
|
+
/** Check authored upgrade nodes; throws `RangeError` naming the offending node or field. */
|
|
7
|
+
export function assertUpgrades(config) {
|
|
8
|
+
const nodes = new Set();
|
|
9
|
+
for (const node of config.nodes) {
|
|
10
|
+
const id = node.id.trim();
|
|
11
|
+
check(id !== "", "Upgrade node id must not be empty");
|
|
12
|
+
check(!nodes.has(id), `Upgrade node "${id}" is declared twice`);
|
|
13
|
+
nodes.add(id);
|
|
14
|
+
const maxRank = node.maxRank ?? 1;
|
|
15
|
+
check(Number.isInteger(maxRank) && maxRank >= 1, `Upgrade node "${id}" maxRank must be an integer >= 1`);
|
|
16
|
+
for (let rank = 1; rank <= maxRank; rank += 1) {
|
|
17
|
+
const cost = evalCurve(node.cost, rank);
|
|
18
|
+
check(Number.isFinite(cost) && cost >= 0, `Upgrade node "${id}" cost at rank ${rank} must be finite and >= 0`);
|
|
19
|
+
}
|
|
20
|
+
const standing = node.standing;
|
|
21
|
+
if (standing !== undefined) {
|
|
22
|
+
check(standing.trackId.trim() !== "", `Upgrade node "${id}" names an empty standing track`);
|
|
23
|
+
check(config.standing === undefined || config.standing.hasTrack(standing.trackId), `Upgrade node "${id}" reads unknown standing track "${standing.trackId}"`);
|
|
24
|
+
check(standing.min === undefined || standing.max === undefined || standing.min <= standing.max, `Upgrade node "${id}" needs standing min <= max`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const node of config.nodes) {
|
|
28
|
+
check(!(node.requires ?? []).includes(node.id), `Upgrade node "${node.id}" requires itself`);
|
|
29
|
+
for (const required of node.requires ?? []) {
|
|
30
|
+
check(nodes.has(required), `Upgrade node "${node.id}" requires unknown node "${required}"`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* An upgrade tree whose prices come from the shared curve primitive: each node authors one cost
|
|
36
|
+
* curve, each rank is that curve at the rank, and a purchase passes prerequisites, a standing gate
|
|
37
|
+
* and the caller's own payment step. State is one rank map that JSON round-trips, so a career save
|
|
38
|
+
* stores what a player paid for rather than the arithmetic behind it.
|
|
39
|
+
*
|
|
40
|
+
* @capability upgrade-tree ranked upgrades priced by a cost curve, gated by prerequisites and standing
|
|
41
|
+
*/
|
|
42
|
+
export function createUpgradeTree(config) {
|
|
43
|
+
assertUpgrades(config);
|
|
44
|
+
const definitions = config.nodes.slice();
|
|
45
|
+
const byId = new Map();
|
|
46
|
+
for (const node of definitions)
|
|
47
|
+
byId.set(node.id, node);
|
|
48
|
+
const ranks = new Map();
|
|
49
|
+
let spent = 0;
|
|
50
|
+
function load(state) {
|
|
51
|
+
ranks.clear();
|
|
52
|
+
spent = 0;
|
|
53
|
+
for (const [nodeId, rank] of Object.entries(state.ranks ?? {})) {
|
|
54
|
+
const node = byId.get(nodeId);
|
|
55
|
+
if (node === undefined || !Number.isInteger(rank) || rank <= 0)
|
|
56
|
+
continue;
|
|
57
|
+
const capped = Math.min(rank, node.maxRank ?? 1);
|
|
58
|
+
ranks.set(nodeId, capped);
|
|
59
|
+
for (let index = 1; index <= capped; index += 1)
|
|
60
|
+
spent += evalCurve(node.cost, index);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (config.state !== undefined)
|
|
64
|
+
load(config.state);
|
|
65
|
+
const rankOf = (nodeId) => ranks.get(nodeId) ?? 0;
|
|
66
|
+
function costAt(node, rank) {
|
|
67
|
+
if (!Number.isInteger(rank) || rank < 1 || rank > (node.maxRank ?? 1))
|
|
68
|
+
return null;
|
|
69
|
+
return evalCurve(node.cost, rank);
|
|
70
|
+
}
|
|
71
|
+
function checkBuy(node, actorId) {
|
|
72
|
+
const current = rankOf(node.id);
|
|
73
|
+
if (current >= (node.maxRank ?? 1))
|
|
74
|
+
return { ok: false, reason: "max-rank" };
|
|
75
|
+
const missing = (node.requires ?? []).filter((required) => rankOf(required) < 1);
|
|
76
|
+
if (missing.length > 0)
|
|
77
|
+
return { ok: false, reason: "missing-prerequisites" };
|
|
78
|
+
const standing = node.standing;
|
|
79
|
+
if (standing !== undefined) {
|
|
80
|
+
const reader = config.standing;
|
|
81
|
+
if (reader === undefined)
|
|
82
|
+
return { ok: false, reason: "standing" };
|
|
83
|
+
const value = reader.standing(actorId, standing.trackId);
|
|
84
|
+
if ((standing.min !== undefined && value < standing.min) || (standing.max !== undefined && value > standing.max)) {
|
|
85
|
+
return { ok: false, reason: "standing" };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const cost = costAt(node, current + 1);
|
|
89
|
+
if (cost === null)
|
|
90
|
+
return { ok: false, reason: "max-rank" };
|
|
91
|
+
return { ok: true, nodeId: node.id, rank: current + 1, cost };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
nodes: () => definitions.slice(),
|
|
95
|
+
node: (nodeId) => byId.get(nodeId) ?? null,
|
|
96
|
+
rank: rankOf,
|
|
97
|
+
maxRank: (nodeId) => byId.get(nodeId)?.maxRank ?? 0,
|
|
98
|
+
costOf(nodeId, rank) {
|
|
99
|
+
const node = byId.get(nodeId);
|
|
100
|
+
if (node === undefined)
|
|
101
|
+
return null;
|
|
102
|
+
return costAt(node, rank ?? rankOf(nodeId) + 1);
|
|
103
|
+
},
|
|
104
|
+
canBuy: (nodeId, actorId) => {
|
|
105
|
+
const node = byId.get(nodeId);
|
|
106
|
+
if (node === undefined)
|
|
107
|
+
return { ok: false, reason: "unknown-node" };
|
|
108
|
+
return checkBuy(node, actorId);
|
|
109
|
+
},
|
|
110
|
+
buy(nodeId, actorId, pay) {
|
|
111
|
+
const node = byId.get(nodeId);
|
|
112
|
+
if (node === undefined)
|
|
113
|
+
return { ok: false, reason: "unknown-node" };
|
|
114
|
+
const checkResult = checkBuy(node, actorId);
|
|
115
|
+
if (!checkResult.ok)
|
|
116
|
+
return checkResult;
|
|
117
|
+
if (!pay(checkResult.cost))
|
|
118
|
+
return { ok: false, reason: "insufficient-funds" };
|
|
119
|
+
ranks.set(nodeId, checkResult.rank);
|
|
120
|
+
spent += checkResult.cost;
|
|
121
|
+
return checkResult;
|
|
122
|
+
},
|
|
123
|
+
owned: () => Array.from(ranks.keys()),
|
|
124
|
+
spendTotal: () => spent,
|
|
125
|
+
snapshot: () => ({ ranks: Object.fromEntries(ranks) }),
|
|
126
|
+
restore: load,
|
|
127
|
+
};
|
|
128
|
+
}
|