474 lines
18 KiB
TypeScript
474 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|