Factions
This commit is contained in:
parent
cc8f4a562f
commit
43da1388e4
|
|
@ -1,14 +1,5 @@
|
||||||
# RPG Engine — Remaining Work
|
# RPG Engine — Remaining Work
|
||||||
|
|
||||||
## Missing Foundational Components
|
|
||||||
|
|
||||||
### `Faction` / `Relationship` (`components/faction.ts`)
|
|
||||||
Reputation score per faction ID. Drives dialog availability, shop access, hostile
|
|
||||||
aggro thresholds, and quest unlock conditions. A separate world-level faction
|
|
||||||
definition registry (neutral/friendly/hostile thresholds) pairs with this.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deferred Combat Features
|
## Deferred Combat Features
|
||||||
|
|
||||||
### Damage modifiers
|
### Damage modifiers
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { component } from "../core/registry";
|
||||||
|
import { Component, type Entity } from "../core/world";
|
||||||
|
import { variable } from "../utils/decorators";
|
||||||
|
|
||||||
|
// ── FactionMember ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@component
|
||||||
|
export class FactionMember extends Component<{ factionId: string }> {
|
||||||
|
@variable('.') readonly member: boolean = true;
|
||||||
|
|
||||||
|
constructor(factionId: string) {
|
||||||
|
super({ factionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
get factionId(): string { return this.state.factionId; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reputation ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@component
|
||||||
|
export class Reputation extends Component<{ factionId: string; score: number }> {
|
||||||
|
constructor(factionId: string, score = 0) {
|
||||||
|
super({ factionId, score });
|
||||||
|
}
|
||||||
|
|
||||||
|
@variable('.') get score(): number { return this.state.score; }
|
||||||
|
|
||||||
|
get factionId(): string { return this.state.factionId; }
|
||||||
|
|
||||||
|
adjust(delta: number): void {
|
||||||
|
this.state.score += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Factions namespace ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function memberKey(factionId: string): string { return `faction.${factionId}.member`; }
|
||||||
|
function repKey(factionId: string): string { return `faction.${factionId}.rep`; }
|
||||||
|
|
||||||
|
export namespace Factions {
|
||||||
|
export function join(entity: Entity, factionId: string): void {
|
||||||
|
entity.add(memberKey(factionId), new FactionMember(factionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leave(entity: Entity, factionId: string): void {
|
||||||
|
entity.remove(memberKey(factionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMember(entity: Entity, factionId: string): boolean {
|
||||||
|
return entity.has(FactionMember, memberKey(factionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFactions(entity: Entity): string[] {
|
||||||
|
return entity.getAll(FactionMember).map(m => m.factionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReputation(entity: Entity, factionId: string): number {
|
||||||
|
return entity.get(Reputation, repKey(factionId))?.score ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setReputation(entity: Entity, factionId: string, value: number): void {
|
||||||
|
const key = repKey(factionId);
|
||||||
|
const existing = entity.get(Reputation, key);
|
||||||
|
if (existing) {
|
||||||
|
existing.state.score = value;
|
||||||
|
} else {
|
||||||
|
entity.add(key, new Reputation(factionId, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustReputation(entity: Entity, factionId: string, delta: number): void {
|
||||||
|
const key = repKey(factionId);
|
||||||
|
const existing = entity.get(Reputation, key);
|
||||||
|
if (existing) {
|
||||||
|
existing.adjust(delta);
|
||||||
|
} else {
|
||||||
|
entity.add(key, new Reputation(factionId, delta));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the observer's minimum reputation across all of target's factions.
|
||||||
|
* Returns null if target has no FactionMember components. */
|
||||||
|
export function getReputationBetween(observer: Entity, target: Entity): number | null {
|
||||||
|
const factions = getFactions(target);
|
||||||
|
if (factions.length === 0) return null;
|
||||||
|
return Math.min(...factions.map(f => getReputation(observer, f)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { World } from '@common/rpg/core/world';
|
||||||
|
import { Reputation, Factions } from '@common/rpg/components/faction';
|
||||||
|
import { resolveVariables } from '@common/rpg/utils/variables';
|
||||||
|
|
||||||
|
function world() { return new World(); }
|
||||||
|
|
||||||
|
describe('FactionMember', () => {
|
||||||
|
it('join() adds membership, isMember() returns true', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
Factions.join(e, 'guards');
|
||||||
|
expect(Factions.isMember(e, 'guards')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leave() removes membership, isMember() returns false', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
Factions.join(e, 'guards');
|
||||||
|
Factions.leave(e, 'guards');
|
||||||
|
expect(Factions.isMember(e, 'guards')).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isMember() returns false when entity has no FactionMember', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
expect(Factions.isMember(e, 'guards')).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFactions() returns all factionIds', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
Factions.join(e, 'guards');
|
||||||
|
Factions.join(e, 'merchants');
|
||||||
|
expect(Factions.getFactions(e).sort()).toEqual(['guards', 'merchants']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('member variable resolves on world', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity('player');
|
||||||
|
Factions.join(e, 'guards');
|
||||||
|
expect(resolveVariables(w)['player.faction.guards.member']).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reputation', () => {
|
||||||
|
it('getReputation() returns 0 when no component', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
expect(Factions.getReputation(e, 'guards')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setReputation() creates component on first call', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
Factions.setReputation(e, 'guards', 50);
|
||||||
|
expect(Factions.getReputation(e, 'guards')).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setReputation() updates score on second call without duplicate component', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
Factions.setReputation(e, 'guards', 50);
|
||||||
|
Factions.setReputation(e, 'guards', 75);
|
||||||
|
expect(Factions.getReputation(e, 'guards')).toBe(75);
|
||||||
|
expect(e.getAll(Reputation).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjustReputation() adds delta from zero default', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
Factions.adjustReputation(e, 'guards', 20);
|
||||||
|
expect(Factions.getReputation(e, 'guards')).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjustReputation() accumulates correctly', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
Factions.adjustReputation(e, 'guards', 20);
|
||||||
|
Factions.adjustReputation(e, 'guards', -5);
|
||||||
|
expect(Factions.getReputation(e, 'guards')).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reputation component adjust() mutates score', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity();
|
||||||
|
Factions.setReputation(e, 'guards', 10);
|
||||||
|
const comp = e.get(Reputation, 'faction.guards.rep')!;
|
||||||
|
comp.adjust(30);
|
||||||
|
expect(comp.score).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('score variable resolves on world', () => {
|
||||||
|
const w = world();
|
||||||
|
const e = w.createEntity('player');
|
||||||
|
Factions.setReputation(e, 'guards', 50);
|
||||||
|
expect(resolveVariables(w)['player.faction.guards.rep']).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Factions.getReputationBetween', () => {
|
||||||
|
it('returns null when target has no factions', () => {
|
||||||
|
const w = world();
|
||||||
|
const observer = w.createEntity();
|
||||||
|
const target = w.createEntity();
|
||||||
|
expect(Factions.getReputationBetween(observer, target)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns observer's rep with target's sole faction", () => {
|
||||||
|
const w = world();
|
||||||
|
const observer = w.createEntity();
|
||||||
|
const target = w.createEntity();
|
||||||
|
Factions.join(target, 'guards');
|
||||||
|
Factions.setReputation(observer, 'guards', 40);
|
||||||
|
expect(Factions.getReputationBetween(observer, target)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the minimum when target belongs to multiple factions', () => {
|
||||||
|
const w = world();
|
||||||
|
const observer = w.createEntity();
|
||||||
|
const target = w.createEntity();
|
||||||
|
Factions.join(target, 'guards');
|
||||||
|
Factions.join(target, 'bandits');
|
||||||
|
Factions.setReputation(observer, 'guards', 60);
|
||||||
|
Factions.setReputation(observer, 'bandits', -30);
|
||||||
|
expect(Factions.getReputationBetween(observer, target)).toBe(-30);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue