From 43da1388e4877a4c66f96ec1e85da92538cb1994 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Thu, 30 Apr 2026 11:24:45 +0000 Subject: [PATCH] Factions --- src/common/rpg/TODO.md | 9 -- src/common/rpg/components/faction.ts | 88 ++++++++++++++++++ test/common/rpg/faction.test.ts | 128 +++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 src/common/rpg/components/faction.ts create mode 100644 test/common/rpg/faction.test.ts diff --git a/src/common/rpg/TODO.md b/src/common/rpg/TODO.md index ffec779..4327173 100644 --- a/src/common/rpg/TODO.md +++ b/src/common/rpg/TODO.md @@ -1,14 +1,5 @@ # 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 ### Damage modifiers diff --git a/src/common/rpg/components/faction.ts b/src/common/rpg/components/faction.ts new file mode 100644 index 0000000..9ace038 --- /dev/null +++ b/src/common/rpg/components/faction.ts @@ -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))); + } +} diff --git a/test/common/rpg/faction.test.ts b/test/common/rpg/faction.test.ts new file mode 100644 index 0000000..d1fd167 --- /dev/null +++ b/test/common/rpg/faction.test.ts @@ -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); + }); +});