1
0
Fork 0
This commit is contained in:
Pabloader 2026-04-30 11:24:45 +00:00
parent cc8f4a562f
commit 43da1388e4
3 changed files with 216 additions and 9 deletions

View File

@ -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

View File

@ -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)));
}
}

View File

@ -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);
});
});