import { describe, it, expect } from 'bun:test'; import { World } from '@common/rpg/core/world'; import { Factions } from '@common/rpg/components/faction'; function world() { return new World(); } // ── Membership ──────────────────────────────────────────────────────────────── describe('Factions.join / isMember / leave', () => { it('isMember returns false for a brand-new entity', () => { const w = world(); expect(Factions.isMember(w.createEntity(), 'guards')).toBeFalse(); }); it('join makes isMember return true', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); expect(Factions.isMember(e, 'guards')).toBeTrue(); }); it('isMember is faction-specific', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); expect(Factions.isMember(e, 'bandits')).toBeFalse(); }); it('join is idempotent — double-joining does not duplicate', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); Factions.join(e, 'guards'); expect(Factions.getFactions(e).filter(f => f === 'guards').length).toBe(1); }); it('entity can belong to multiple factions simultaneously', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); Factions.join(e, 'merchants'); Factions.join(e, 'mages'); expect(Factions.getFactions(e).sort()).toEqual(['guards', 'mages', 'merchants']); }); it('leave removes membership', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); Factions.leave(e, 'guards'); expect(Factions.isMember(e, 'guards')).toBeFalse(); }); it('leave is safe on a non-member', () => { const w = world(); const e = w.createEntity(); expect(() => Factions.leave(e, 'guards')).not.toThrow(); }); it('leave only removes the specified faction', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); Factions.join(e, 'merchants'); Factions.leave(e, 'guards'); expect(Factions.isMember(e, 'guards')).toBeFalse(); expect(Factions.isMember(e, 'merchants')).toBeTrue(); }); it('can re-join after leaving', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); Factions.leave(e, 'guards'); Factions.join(e, 'guards'); expect(Factions.isMember(e, 'guards')).toBeTrue(); }); it('getFactions returns empty array for new entity', () => { const w = world(); expect(Factions.getFactions(w.createEntity())).toEqual([]); }); it('getFactions reflects leave', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); Factions.join(e, 'merchants'); Factions.leave(e, 'merchants'); expect(Factions.getFactions(e)).toEqual(['guards']); }); }); // ── getFaction ───────────────────────────────────────────────────────────────── describe('Factions.getFaction', () => { it('creates a faction entity on first call', () => { const w = world(); const faction = Factions.getFaction(w, 'guards'); expect(faction).toBeDefined(); }); it('returns the same component for the same factionId', () => { const w = world(); const a = Factions.getFaction(w, 'guards'); const b = Factions.getFaction(w, 'guards'); expect(a).toBe(b); }); it('returns different components for different factionIds', () => { const w = world(); const guards = Factions.getFaction(w, 'guards'); const bandits = Factions.getFaction(w, 'bandits'); expect(guards).not.toBe(bandits); }); }); // ── getReputationOf / setReputationOf / adjustReputationOf ──────────────────── // Observer's personal opinion of a named faction. describe('Factions.setReputationOf / getReputationOf', () => { it('getReputationOf returns 0 with no data', () => { const w = world(); expect(Factions.getReputationOf(w.createEntity(), 'guards')).toBe(0); }); it('setReputationOf persists the value', () => { const w = world(); const e = w.createEntity(); Factions.setReputationOf(e, 'guards', 50); expect(Factions.getReputationOf(e, 'guards')).toBe(50); }); it('setReputationOf overwrites the previous value without duplication', () => { const w = world(); const e = w.createEntity(); Factions.setReputationOf(e, 'guards', 50); Factions.setReputationOf(e, 'guards', 75); expect(Factions.getReputationOf(e, 'guards')).toBe(75); }); it('setReputationOf allows negative values', () => { const w = world(); const e = w.createEntity(); Factions.setReputationOf(e, 'bandits', -100); expect(Factions.getReputationOf(e, 'bandits')).toBe(-100); }); it('reputations are independent per faction', () => { const w = world(); const e = w.createEntity(); Factions.setReputationOf(e, 'guards', 80); Factions.setReputationOf(e, 'bandits', -20); expect(Factions.getReputationOf(e, 'guards')).toBe(80); expect(Factions.getReputationOf(e, 'bandits')).toBe(-20); }); it('reputations are independent per observer', () => { const w = world(); const a = w.createEntity(); const b = w.createEntity(); Factions.setReputationOf(a, 'guards', 60); Factions.setReputationOf(b, 'guards', 10); expect(Factions.getReputationOf(a, 'guards')).toBe(60); expect(Factions.getReputationOf(b, 'guards')).toBe(10); }); }); describe('Factions.adjustReputationOf', () => { it('adjusts from 0 by default', () => { const w = world(); const e = w.createEntity(); Factions.adjustReputationOf(e, 'guards', 20); expect(Factions.getReputationOf(e, 'guards')).toBe(20); }); it('accumulates positive adjustments', () => { const w = world(); const e = w.createEntity(); Factions.adjustReputationOf(e, 'guards', 20); Factions.adjustReputationOf(e, 'guards', 15); expect(Factions.getReputationOf(e, 'guards')).toBe(35); }); it('accumulates negative adjustments', () => { const w = world(); const e = w.createEntity(); Factions.adjustReputationOf(e, 'guards', 20); Factions.adjustReputationOf(e, 'guards', -5); expect(Factions.getReputationOf(e, 'guards')).toBe(15); }); it('can drive reputation below zero', () => { const w = world(); const e = w.createEntity(); Factions.setReputationOf(e, 'guards', 5); Factions.adjustReputationOf(e, 'guards', -10); expect(Factions.getReputationOf(e, 'guards')).toBe(-5); }); it('adjustments are faction-specific', () => { const w = world(); const e = w.createEntity(); Factions.adjustReputationOf(e, 'guards', 30); Factions.adjustReputationOf(e, 'bandits', -10); expect(Factions.getReputationOf(e, 'guards')).toBe(30); expect(Factions.getReputationOf(e, 'bandits')).toBe(-10); }); }); // Cross-faction reputation aggregation: observer inherits their own faction's opinion. describe('Factions.getReputationOf — cross-faction aggregation', () => { it("observer inherits their faction's reputation toward another faction", () => { const w = world(); const observer = w.createEntity(); const guardsFaction = Factions.getFaction(w, 'guards').entity; Factions.join(observer, 'guards'); Factions.setReputationOf(guardsFaction, 'bandits', -50); // observer personally has 0, but via guards membership inherits -50 expect(Factions.getReputationOf(observer, 'bandits')).toBe(-50); }); it('personal and inherited reputations are summed', () => { const w = world(); const observer = w.createEntity(); const guardsFaction = Factions.getFaction(w, 'guards').entity; Factions.join(observer, 'guards'); Factions.setReputationOf(observer, 'bandits', 20); Factions.setReputationOf(guardsFaction, 'bandits', 30); expect(Factions.getReputationOf(observer, 'bandits')).toBe(50); }); it('cross-faction contributions from multiple memberships are all summed', () => { const w = world(); const observer = w.createEntity(); const guardsFaction = Factions.getFaction(w, 'guards').entity; const merchantsFaction = Factions.getFaction(w, 'merchants').entity; Factions.join(observer, 'guards'); Factions.join(observer, 'merchants'); Factions.setReputationOf(guardsFaction, 'bandits', -40); Factions.setReputationOf(merchantsFaction, 'bandits', 10); expect(Factions.getReputationOf(observer, 'bandits')).toBe(-30); }); it('skipAggregation=true returns only personal reputation', () => { const w = world(); const observer = w.createEntity(); const guardsFaction = Factions.getFaction(w, 'guards').entity; Factions.join(observer, 'guards'); Factions.setReputationOf(observer, 'bandits', 10); Factions.setReputationOf(guardsFaction, 'bandits', 50); expect(Factions.getReputationOf(observer, 'bandits', true)).toBe(10); }); }); // ── getReputationIn / setReputationIn / adjustReputationIn ──────────────────── // A named faction's opinion of a target entity. describe('Factions.setReputationIn / getReputationIn', () => { it('getReputationIn returns 0 with no data', () => { const w = world(); expect(Factions.getReputationIn(w.createEntity(), 'guards')).toBe(0); }); it('setReputationIn persists the value', () => { const w = world(); const target = w.createEntity(); Factions.setReputationIn(target, 'guards', 40); expect(Factions.getReputationIn(target, 'guards')).toBe(40); }); it('setReputationIn overwrites previous value', () => { const w = world(); const target = w.createEntity(); Factions.setReputationIn(target, 'guards', 40); Factions.setReputationIn(target, 'guards', 80); expect(Factions.getReputationIn(target, 'guards')).toBe(80); }); it('setReputationIn allows negative values', () => { const w = world(); const target = w.createEntity(); Factions.setReputationIn(target, 'bandits', -60); expect(Factions.getReputationIn(target, 'bandits')).toBe(-60); }); it('opinions are independent per faction', () => { const w = world(); const target = w.createEntity(); Factions.setReputationIn(target, 'guards', 50); Factions.setReputationIn(target, 'bandits', -30); expect(Factions.getReputationIn(target, 'guards')).toBe(50); expect(Factions.getReputationIn(target, 'bandits')).toBe(-30); }); it('opinions are independent per target', () => { const w = world(); const t1 = w.createEntity(); const t2 = w.createEntity(); Factions.setReputationIn(t1, 'guards', 70); Factions.setReputationIn(t2, 'guards', 20); expect(Factions.getReputationIn(t1, 'guards')).toBe(70); expect(Factions.getReputationIn(t2, 'guards')).toBe(20); }); }); describe('Factions.adjustReputationIn', () => { it('adjusts from 0 by default', () => { const w = world(); const target = w.createEntity(); Factions.adjustReputationIn(target, 'guards', 25); expect(Factions.getReputationIn(target, 'guards')).toBe(25); }); it('accumulates correctly', () => { const w = world(); const target = w.createEntity(); Factions.adjustReputationIn(target, 'guards', 25); Factions.adjustReputationIn(target, 'guards', -10); expect(Factions.getReputationIn(target, 'guards')).toBe(15); }); }); describe('Factions.getReputationIn — faction-entity target routing', () => { it("when target is a faction entity, returns the observing faction's opinion of that faction", () => { const w = world(); const guardsFaction = Factions.getFaction(w, 'guards').entity; const banditsFaction = Factions.getFaction(w, 'bandits').entity; Factions.setReputationOf(guardsFaction, 'bandits', -80); // getReputationIn(banditsFaction, 'guards') = "how do guards see the bandits faction?" // routes to getReputationOf(guardsFaction, 'bandits') expect(Factions.getReputationIn(banditsFaction, 'guards')).toBe(-80); }); }); describe('Factions.getReputationIn — cross-faction aggregation', () => { it("faction's opinion of a target aggregates across target's faction memberships", () => { const w = world(); const target = w.createEntity(); const guardsFaction = Factions.getFaction(w, 'guards').entity; Factions.join(target, 'merchants'); Factions.setReputationOf(guardsFaction, 'merchants', 35); // guards look at target who is in merchants — cross-faction adds 35 expect(Factions.getReputationIn(target, 'guards')).toBe(35); }); it('personal and cross-faction contributions are summed', () => { const w = world(); const target = w.createEntity(); const guardsFaction = Factions.getFaction(w, 'guards').entity; Factions.join(target, 'merchants'); Factions.setReputationIn(target, 'guards', 10); Factions.setReputationOf(guardsFaction, 'merchants', 25); expect(Factions.getReputationIn(target, 'guards')).toBe(35); }); }); // ── getReputation (full observer → target resolution) ───────────────────────── describe('Factions.getReputation', () => { it('returns 0 for two entities with no factions or reputation', () => { const w = world(); expect(Factions.getReputation(w.createEntity(), w.createEntity())).toBe(0); }); it('when target is a faction entity, returns observer rep for that faction', () => { const w = world(); const observer = w.createEntity(); const guardsFaction = Factions.getFaction(w, 'guards').entity; Factions.setReputationOf(observer, 'guards', 55); expect(Factions.getReputation(observer, guardsFaction)).toBe(55); }); it('sums observer rep across all factions target belongs to', () => { const w = world(); const observer = w.createEntity(); const target = w.createEntity(); Factions.join(target, 'guards'); Factions.join(target, 'merchants'); Factions.setReputationOf(observer, 'guards', 30); Factions.setReputationOf(observer, 'merchants', 20); // target is in guards+merchants, observer's rep with each is summed expect(Factions.getReputation(observer, target)).toBeGreaterThanOrEqual(50); }); it("observer's faction membership contributes toward target's faction opinion", () => { const w = world(); const observer = w.createEntity(); const target = w.createEntity(); const guardsFaction = Factions.getFaction(w, 'guards').entity; Factions.join(observer, 'guards'); Factions.join(target, 'bandits'); Factions.setReputationOf(guardsFaction, 'bandits', -40); // guards hate bandits; observer (a guard) sees target (a bandit) more negatively expect(Factions.getReputation(observer, target)).toBeLessThan(0); }); it('personal opinion is included alongside cross-faction aggregation', () => { const w = world(); const observer = w.createEntity(); const target = w.createEntity(); Factions.join(target, 'guards'); Factions.setReputationOf(observer, 'guards', 40); expect(Factions.getReputation(observer, target)).toBe(40); }); it('symmetric scenario: both sides in factions with mutual opinions', () => { const w = world(); const hero = w.createEntity(); const villain = w.createEntity(); const heroFaction = Factions.getFaction(w, 'heroes').entity; const villainFaction = Factions.getFaction(w, 'villains').entity; Factions.join(hero, 'heroes'); Factions.join(villain, 'villains'); // heroes hate villains Factions.setReputationOf(heroFaction, 'villains', -100); // villains hate heroes Factions.setReputationOf(villainFaction, 'heroes', -100); const heroSeesVillain = Factions.getReputation(hero, villain); const villainSeesHero = Factions.getReputation(villain, hero); expect(heroSeesVillain).toBeLessThan(0); expect(villainSeesHero).toBeLessThan(0); }); it('neutral factions produce no reputation effect', () => { const w = world(); const a = w.createEntity(); const b = w.createEntity(); Factions.join(a, 'merchants'); Factions.join(b, 'farmers'); // no reputation set between merchants and farmers expect(Factions.getReputation(a, b)).toBe(0); }); it('target with no faction memberships yields only observer-set personal rep (0 if none)', () => { const w = world(); const observer = w.createEntity(); const target = w.createEntity(); Factions.join(observer, 'guards'); // target has no factions expect(Factions.getReputation(observer, target)).toBe(0); }); });