From 4b2a16c28fa11bdf52dc37da3ba04ec0a3bedcf9 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Wed, 6 May 2026 16:28:19 +0000 Subject: [PATCH] Rework faction system --- src/common/display/text.ts | 121 ++++- src/common/rpg/components/faction.ts | 228 +++++++-- src/common/rpg/components/render/viewport.ts | 8 - src/common/rpg/core/world.ts | 7 + src/common/rpg/systems/render/text.ts | 4 +- src/games/crawler/index.ts | 43 +- test/common/rpg/faction.test.ts | 489 ++++++++++++++++--- 7 files changed, 761 insertions(+), 139 deletions(-) diff --git a/src/common/display/text.ts b/src/common/display/text.ts index 0283e6a..7f50e92 100644 --- a/src/common/display/text.ts +++ b/src/common/display/text.ts @@ -57,18 +57,93 @@ export enum Color { export const DEFAULT_FG = Color.WHITE; export const DEFAULT_BG = Color.BLACK; -export enum Direction { - NORTH = 0, - EAST = 1, - SOUTH = 2, - WEST = 3, - UP = 4, - DOWN = 5, +export enum Glyphs { + // Pure single-line box drawing + SINGLE_HORIZONTAL = '─', + SINGLE_VERTICAL = '│', + SINGLE_TOP_LEFT = '┌', + SINGLE_TOP_RIGHT = '┐', + SINGLE_BOTTOM_LEFT = '└', + SINGLE_BOTTOM_RIGHT = '┘', + SINGLE_TEE_RIGHT = '├', + SINGLE_TEE_LEFT = '┤', + SINGLE_TEE_DOWN = '┬', + SINGLE_TEE_UP = '┴', + SINGLE_CROSS = '┼', + + // Pure double-line box drawing + DOUBLE_HORIZONTAL = '═', + DOUBLE_VERTICAL = '║', + DOUBLE_TOP_LEFT = '╔', + DOUBLE_TOP_RIGHT = '╗', + DOUBLE_BOTTOM_LEFT = '╚', + DOUBLE_BOTTOM_RIGHT = '╝', + DOUBLE_TEE_RIGHT = '╠', + DOUBLE_TEE_LEFT = '╣', + DOUBLE_TEE_DOWN = '╦', + DOUBLE_TEE_UP = '╩', + DOUBLE_CROSS = '╬', + + // Mixed single/double combinations (_DBL_H = double horizontal, _DBL_V = double vertical) + MIX_TOP_LEFT_DBL_H = '╒', + MIX_TOP_LEFT_DBL_V = '╓', + MIX_TOP_RIGHT_DBL_H = '╕', + MIX_TOP_RIGHT_DBL_V = '╖', + MIX_BOTTOM_LEFT_DBL_H = '╘', + MIX_BOTTOM_LEFT_DBL_V = '╙', + MIX_BOTTOM_RIGHT_DBL_H = '╛', + MIX_BOTTOM_RIGHT_DBL_V = '╜', + MIX_TEE_RIGHT_DBL_H = '╞', + MIX_TEE_RIGHT_DBL_V = '╟', + MIX_TEE_LEFT_DBL_H = '╡', + MIX_TEE_LEFT_DBL_V = '╢', + MIX_TEE_DOWN_DBL_H = '╤', + MIX_TEE_DOWN_DBL_V = '╥', + MIX_TEE_UP_DBL_H = '╧', + MIX_TEE_UP_DBL_V = '╨', + MIX_CROSS_DBL_H = '╪', + MIX_CROSS_DBL_V = '╫', + + // Block elements + SPACE = ' ', + LIGHT_SHADE = '░', + MEDIUM_SHADE = '▒', + DARK_SHADE = '▓', + FULL_BLOCK = '█', + UPPER_HALF_BLOCK = '▀', + LOWER_HALF_BLOCK = '▄', + LEFT_HALF_BLOCK = '▌', + RIGHT_HALF_BLOCK = '▐', + + // Arrows + ARROW_LEFT = '←', + ARROW_UP = '↑', + ARROW_RIGHT = '→', + ARROW_DOWN = '↓', + ARROW_HORIZONTAL = '↔', + ARROW_VERTICAL = '↕', + TRIANGLE_UP = '▲', + TRIANGLE_RIGHT = '►', + TRIANGLE_DOWN = '▼', + TRIANGLE_LEFT = '◄', + + // Symbols + SMILEY = '☺', + SMILEY_FILLED = '☻', + FEMALE = '♀', + MALE = '♂', + SPADE = '♠', + CLUB = '♣', + HEART = '♥', + DIAMOND = '♦', } -export const isVertical = (char: string) => char === '│'; -export const isHorizontal = (char: string) => char === '─'; -export const isCorner = (char: string) => '┌┐└┘'.includes(char); +export const isVertical = (char: string) => char === Glyphs.SINGLE_VERTICAL || char === Glyphs.DOUBLE_VERTICAL; +export const isHorizontal = (char: string) => char === Glyphs.SINGLE_HORIZONTAL || char === Glyphs.DOUBLE_HORIZONTAL; +export const isCorner = (char: string) => [ + Glyphs.SINGLE_TOP_LEFT, Glyphs.SINGLE_TOP_RIGHT, Glyphs.SINGLE_BOTTOM_LEFT, Glyphs.SINGLE_BOTTOM_RIGHT, + Glyphs.DOUBLE_TOP_LEFT, Glyphs.DOUBLE_TOP_RIGHT, Glyphs.DOUBLE_BOTTOM_LEFT, Glyphs.DOUBLE_BOTTOM_RIGHT, +].includes(char as Glyphs); interface BoxOptions { vertical?: string; @@ -226,7 +301,7 @@ export class TextDisplay { } } - setChar(x: number, y: number, char: Char = '█') { + setChar(x: number, y: number, char: Char = Glyphs.FULL_BLOCK) { this.setCharRaw(x | 0, y | 0, ...parseChar(char)); } @@ -313,7 +388,7 @@ export class TextDisplay { } } - drawVLine(x: number, y1: number, y2: number, char: Char = '│') { + drawVLine(x: number, y1: number, y2: number, char: Char = Glyphs.SINGLE_VERTICAL) { x = x | 0; y1 = y1 | 0; y2 = y2 | 0; if (y2 < y1) { const t = y2; y2 = y1; y1 = t; } @@ -327,7 +402,7 @@ export class TextDisplay { } } - drawHLine(x1: number, x2: number, y: number, char: Char = '─') { + drawHLine(x1: number, x2: number, y: number, char: Char = Glyphs.SINGLE_HORIZONTAL) { x1 = x1 | 0; x2 = x2 | 0; y = y | 0; if (x2 < x1) { const t = x2; x2 = x1; x1 = t; } x1 = Math.max(this.clipLeft, x1); @@ -343,12 +418,12 @@ export class TextDisplay { drawBox(x: number, y: number, width: number, height: number, options: BoxOptions = {}) { x = x | 0; y = y | 0; const { - vertical = '│', - horizontal = '─', - topLeft = '┌', - topRight = '┐', - bottomLeft = '└', - bottomRight = '┘', + vertical = Glyphs.SINGLE_VERTICAL, + horizontal = Glyphs.SINGLE_HORIZONTAL, + topLeft = Glyphs.SINGLE_TOP_LEFT, + topRight = Glyphs.SINGLE_TOP_RIGHT, + bottomLeft = Glyphs.SINGLE_BOTTOM_LEFT, + bottomRight = Glyphs.SINGLE_BOTTOM_RIGHT, fg = DEFAULT_FG, bg = DEFAULT_BG, fill, @@ -389,14 +464,14 @@ export class TextDisplay { this.drawString(text, x + 1, y + 1, fg, bg); } - fillBox(x: number, y: number, width: number, height: number, char: Char = '█') { + fillBox(x: number, y: number, width: number, height: number, char: Char = Glyphs.FULL_BLOCK) { x = x | 0; y = y | 0; for (let i = y; i < y + height; i++) { this.drawHLine(x, x + width - 1, i, char); } } - drawLine(fromX: number, fromY: number, toX: number, toY: number, char: Char = '█') { + drawLine(fromX: number, fromY: number, toX: number, toY: number, char: Char = Glyphs.FULL_BLOCK) { const [ch, fg, bg] = parseChar(char); const options: BresenhamLineOptions = { minX: this.clipLeft, @@ -424,11 +499,11 @@ export class TextDisplay { } } - drawCircle(cx: number, cy: number, radius: number, char: Char = '█') { + drawCircle(cx: number, cy: number, radius: number, char: Char = Glyphs.FULL_BLOCK) { this.#circle(cx, cy, radius, char, false); } - fillCircle(cx: number, cy: number, radius: number, char: Char = '█') { + fillCircle(cx: number, cy: number, radius: number, char: Char = Glyphs.FULL_BLOCK) { this.#circle(cx, cy, radius, char, true); } diff --git a/src/common/rpg/components/faction.ts b/src/common/rpg/components/faction.ts index 795211d..4b0d7ab 100644 --- a/src/common/rpg/components/faction.ts +++ b/src/common/rpg/components/faction.ts @@ -1,11 +1,22 @@ -import { Component, type Entity } from "../core/world"; +import { Component, Entity, World } from "../core/world"; import type { RPGVariables } from "../types"; import { action, component } from "../utils/decorators"; +// ── Faction ─────────────────────────────────────────────────────────────────── + +@component +class Faction extends Component<{ factionId: string }> { + constructor(factionId: string) { + super({ factionId }); + } + + get factionId(): string { return this.state.factionId; } +} + // ── FactionMember ───────────────────────────────────────────────────────────── @component -export class FactionMember extends Component<{ factionId: string }> { +class FactionMember extends Component<{ factionId: string }> { constructor(factionId: string) { super({ factionId }); } @@ -19,10 +30,35 @@ export class FactionMember extends Component<{ factionId: string }> { } } -// ── Reputation ──────────────────────────────────────────────────────────────── +// ── ReputationOf ────────────────────────────────────────────────────────────── +// Observer's opinion of a faction. @component -export class Reputation extends Component<{ factionId: string; score: number }> { +class ReputationOf extends Component<{ factionId: string; score: number }> { + constructor(factionId: string, score = 0) { + super({ factionId, score }); + } + + get score(): number { return this.state.score; } + + get factionId(): string { return this.state.factionId; } + + adjust(delta: number): void { + this.state.score += delta; + } + + override getVariables(): RPGVariables { + return { + [this.factionId]: this.score, + } + } +} + +// ── ReputationIn ────────────────────────────────────────────────────────────── +// A faction's opinion of a specific entity. + +@component +class ReputationIn extends Component<{ factionId: string; score: number }> { constructor(factionId: string, score = 0) { super({ factionId, score }); } @@ -45,7 +81,7 @@ export class Reputation extends Component<{ factionId: string; score: number }> //── FactionManager ──────────────────────────────────────────────────────────── @component -export class FactionManager extends Component<{}> { +class FactionManager extends Component<{}> { constructor() { super({}); } @action @@ -59,13 +95,23 @@ export class FactionManager extends Component<{}> { } @action - setReputation({ factionId, value }: { factionId: string, value: number }): void { - Factions.setReputation(this.entity, factionId, value); + setReputationOf({ factionId, value }: { factionId: string, value: number }): void { + Factions.setReputationOf(this.entity, factionId, value); } @action - adjustReputation({ factionId, value }: { factionId: string, value: number }): void { - Factions.adjustReputation(this.entity, factionId, value); + adjustReputationOf({ factionId, value }: { factionId: string, value: number }): void { + Factions.adjustReputationOf(this.entity, factionId, value); + } + + @action + setReputationIn({ factionId, value }: { factionId: string, value: number }): void { + Factions.setReputationIn(this.entity, factionId, value); + } + + @action + adjustReputationIn({ factionId, value }: { factionId: string, value: number }): void { + Factions.adjustReputationIn(this.entity, factionId, value); } } @@ -77,10 +123,22 @@ export namespace Factions { entity.add(new FactionManager()); } } + + export function getFaction(world: World, factionId: string): Faction { + const existing = world.findComponent(Faction, (c) => c.factionId === factionId); + if (existing) { + return existing; + } + + const factionEntity = world.createEntity(); + factionEntity.add(new FactionManager()); + return factionEntity.add(new Faction(factionId)); + } + export function join(entity: Entity, factionId: string): void { addFactionManager(entity); - const existing = entity.get(FactionMember, (c) => c.factionId === factionId); - if (!existing) { + if (!entity.has(FactionMember, (c) => c.factionId === factionId)) { + getFaction(entity.world, factionId); entity.add(new FactionMember(factionId)); } } @@ -95,38 +153,152 @@ export namespace Factions { } export function getFactions(entity: Entity): string[] { - return entity.getAll(FactionMember).map(m => m.factionId); + return [ + ...entity.getAll(FactionMember), + ...entity.getAll(Faction), + ].map(m => m.factionId); } - export function getReputation(entity: Entity, factionId: string): number { - return entity.get(Reputation, (c) => c.factionId === factionId)?.score ?? 0; + // How observer sees a faction. Aggregates cross-faction scores when observer + // is not itself a faction entity. + export function getReputationOf(observer: Entity | Component, factionId: string, skipAggregation = false): number { + if (!(observer instanceof Entity)) { + observer = observer.entity; + } + const personal = observer.get(ReputationOf, (c) => c.factionId === factionId)?.score ?? 0; + + if (observer.has(Faction) || skipAggregation) { + return personal; + } + + const crossFaction = getFactions(observer) + .map(myFactionId => getFaction(observer.world, myFactionId).entity.get(ReputationOf, (c) => c.factionId === factionId)?.score ?? 0) + .reduce((a, b) => a + b, 0); + + return personal + crossFaction; } - export function setReputation(entity: Entity, factionId: string, value: number): void { - addFactionManager(entity); - const existing = entity.get(Reputation, (c) => c.factionId === factionId); + // How a specific faction sees target. Aggregates across target's faction memberships. + // Faction-faction reputation is canonical on ReputationOf of the observer faction. + export function getReputationIn(target: Entity | Component, factionId: string, skipAggregation = false): number { + if (!(target instanceof Entity)) { + target = target.entity; + } + const targetFaction = target.get(Faction); + const observer = getFaction(target.world, factionId).entity; + + if (targetFaction) { + const targetFactionId = targetFaction.factionId; + return getReputationOf(observer, targetFactionId); + } + + const personal = target.get(ReputationIn, (c) => c.factionId === factionId)?.score ?? 0; + + if (skipAggregation) { + return personal; + } + + const crossFaction = getFactions(target) + .map(memberFactionId => getReputationOf(observer, memberFactionId)) + .reduce((a, b) => a + b, 0); + + return personal + crossFaction; + } + + // How observer sees target. Routes to getReputationOf when target is a faction + // entity; otherwise sums observer's opinion of each faction target belongs to. + export function getReputation(observer: Entity | Component, target: Entity | Component): number { + if (!(observer instanceof Entity)) { + observer = observer.entity; + } + if (!(target instanceof Entity)) { + target = target.entity; + } + const targetFaction = target.get(Faction); + + if (targetFaction) { + return getReputationOf(observer, targetFaction.factionId); + } + + return [ + ...getFactions(target).map(fid => getReputationOf(observer, fid)), + ...getFactions(observer).map(fid => getReputationIn(target, fid, true)), + ].reduce((a, b) => a + b, 0); + } + + export function setReputationOf(observer: Entity | Component, factionId: string, value: number): void { + if (!(observer instanceof Entity)) { + observer = observer.entity; + } + addFactionManager(observer); + getFaction(observer.world, factionId); + + const existing = observer.get(ReputationOf, (c) => c.factionId === factionId); if (existing) { existing.state.score = value; } else { - entity.add(new Reputation(factionId, value)); + observer.add(new ReputationOf(factionId, value)); } } - export function adjustReputation(entity: Entity, factionId: string, delta: number): void { - addFactionManager(entity); - const existing = entity.get(Reputation, (c) => c.factionId === factionId); + export function adjustReputationOf(observer: Entity | Component, factionId: string, delta: number): void { + if (!(observer instanceof Entity)) { + observer = observer.entity; + } + addFactionManager(observer); + getFaction(observer.world, factionId); + + const existing = observer.get(ReputationOf, (c) => c.factionId === factionId); if (existing) { existing.adjust(delta); } else { - entity.add(new Reputation(factionId, delta)); + observer.add(new ReputationOf(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))); + export function setReputationIn(target: Entity | Component, factionId: string, value: number): void { + if (!(target instanceof Entity)) { + target = target.entity; + } + const targetFaction = target.get(Faction); + if (targetFaction) { + const targetFactionId = targetFaction.factionId; + const observer = getFaction(target.world, factionId).entity; + setReputationOf(observer, targetFactionId, value); + return; + } + + addFactionManager(target); + getFaction(target.world, factionId); + + const existing = target.get(ReputationIn, (c) => c.factionId === factionId); + if (existing) { + existing.state.score = value; + } else { + target.add(new ReputationIn(factionId, value)); + } + } + + export function adjustReputationIn(target: Entity | Component, factionId: string, delta: number): void { + if (!(target instanceof Entity)) { + target = target.entity; + } + const targetFaction = target.get(Faction); + if (targetFaction) { + const targetFactionId = targetFaction.factionId; + const observer = getFaction(target.world, factionId).entity; + adjustReputationOf(observer, targetFactionId, delta); + return; + } + + addFactionManager(target); + getFaction(target.world, factionId); + + const existing = target.get(ReputationIn, (c) => c.factionId === factionId); + if (existing) { + existing.adjust(delta); + } else { + target.add(new ReputationIn(factionId, delta)); + } } } diff --git a/src/common/rpg/components/render/viewport.ts b/src/common/rpg/components/render/viewport.ts index 5c76440..2c664ee 100644 --- a/src/common/rpg/components/render/viewport.ts +++ b/src/common/rpg/components/render/viewport.ts @@ -62,11 +62,3 @@ export const createViewport = (world: World, viewportData: ViewportData) => { return viewport; } - -export const getViewport = (world: World): Viewport | null => { - for (const [, , viewport] of world.query(Viewport)) { - return viewport; - } - - return null; -} diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index d9bebf2..7a2552b 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -344,6 +344,13 @@ export class World { return target; } + findComponent>(ctor: Class, filter?: ComponentFilter): T | undefined { + for (const [, , c] of this.query(ctor)) { + if (!filter || filter(c)) return c; + } + return undefined; + } + query>(ctor: Class): Generator<[Entity, string, T]>; query, B extends Component>(ctorA: Class, ctorB: Class): Generator<[Entity, A, B]>; query, B extends Component, C extends Component>(ctorA: Class, ctorB: Class, ctorC: Class): Generator<[Entity, A, B, C]>; diff --git a/src/common/rpg/systems/render/text.ts b/src/common/rpg/systems/render/text.ts index 4b099dc..79a008f 100644 --- a/src/common/rpg/systems/render/text.ts +++ b/src/common/rpg/systems/render/text.ts @@ -1,6 +1,6 @@ import { TextRegion, TextDisplay } from "@common/display/text"; import { Position } from "@common/rpg/components/position"; -import { getViewport } from "@common/rpg/components/render/viewport"; +import { Viewport } from "@common/rpg/components/render/viewport"; import { Hidden, Sprite } from "@common/rpg/components/sprite"; import { System, World } from "@common/rpg/core/world"; import { Resources } from "@common/rpg/utils/resources"; @@ -16,7 +16,7 @@ export class TextDisplaySystem extends System { } override update(world: World) { - const viewport = getViewport(world); + const viewport = world.findComponent(Viewport); const offset = viewport ? viewport.screenToWorld({ x: 0, y: 0 }) : undefined; const viewportClipRect = viewport ? { x: viewport.screenX, diff --git a/src/games/crawler/index.ts b/src/games/crawler/index.ts index a7cb3b8..67d5a61 100644 --- a/src/games/crawler/index.ts +++ b/src/games/crawler/index.ts @@ -1,16 +1,19 @@ -import { Color, TextRegion } from "@common/display/text"; +import { Color, Glyphs, isCorner, isHorizontal, isVertical, TextRegion } from "@common/display/text"; import { gameLoop } from "@common/game"; import type { Point } from "@common/geometry"; import Input from "@common/input"; import { BSP } from "@common/level/bsp"; import { bresenhamCircleGen } from "@common/navigation/bresenham"; import { SeededRandom } from "@common/random"; +import { Factions } from "@common/rpg/components/faction"; import { getPosition, move, Position } from "@common/rpg/components/position"; import { createViewport } from "@common/rpg/components/render/viewport"; import { Sprite } from "@common/rpg/components/sprite"; +import { Serialization } from "@common/rpg/core/serialization"; import { World } from "@common/rpg/core/world"; import { TextDisplaySystem } from "@common/rpg/systems/render/text"; import { Resources } from "@common/rpg/utils/resources"; +import { resolveVariables } from "@common/rpg/utils/variables"; const WALL = '#'; const PLAYER = '@'; @@ -20,8 +23,8 @@ function createMap(world: World, random: SeededRandom) { const mapData = new Array(MAP_SIZE * MAP_SIZE).fill(WALL); BSP.generateLevel(MAP_SIZE, MAP_SIZE, (x, y) => { mapData[x + y * MAP_SIZE] = '.'; }, { - minWidth: 8, - minHeight: 4, + minWidth: 10, + minHeight: 8, depth: 10, random, }); @@ -40,18 +43,29 @@ function createMap(world: World, random: SeededRandom) { const mask = world.createEntity('mask'); mask.add(new Sprite(Resources.add('mask', new TextRegion(maskDataString)))); - mask.add(new Position(-MAP_SIZE, -MAP_SIZE, 9)); + mask.add(new Position(-MAP_SIZE, -MAP_SIZE, 100)); return { map, mask }; } -function createPlayer(world: World, x = 0, y = 0) { +function createPlayer(world: World, x: number, y: number) { const player = world.createEntity('player'); player.add(new Position(x, y, 10)); player.add(new Sprite(Resources.add('player', new TextRegion(PLAYER, Color.YELLOW)))); + Factions.join(player, 'players'); + return player; } +function createEnemy(world: World, x: number, y: number, sprite: string, ...factions: string[]) { + const enemy = world.createEntity('enemy_*'); + enemy.add(new Position(x, y, 9)); + enemy.add(new Sprite(sprite)); + factions.forEach(faction => Factions.join(enemy, faction)); + + return enemy; +} + function handleMovement() { let dx = 0; let dy = 0; @@ -115,6 +129,8 @@ export default gameLoop(() => { const maskData = Resources.get(TextRegion, mask.get(Sprite)!.image)!; const startCell = random.choice(emptyCells); + const enemyCell = random.choice(emptyCells.filter(c => c !== startCell)); + const viewport = createViewport(world, { width: display.width, height: display.height, @@ -125,6 +141,17 @@ export default gameLoop(() => { }); const player = createPlayer(world, startCell.x, startCell.y); + Resources.add('goblin', new TextRegion('g', Color.DARK_GREEN)); + const goblins = Factions.getFaction(world, 'goblins'); + const monsters = Factions.getFaction(world, 'monsters'); + + Factions.setReputationOf(goblins.entity, 'players', -100); + Factions.setReputationOf(monsters.entity, 'players', -100); + + Factions.setReputationOf(goblins.entity, 'monsters', 100); + Factions.setReputationOf(monsters.entity, 'goblins', 100); + + const enemy = createEnemy(world, enemyCell.x, enemyCell.y, 'goblin', 'goblins', 'monsters'); const state = { display, world, @@ -133,9 +160,10 @@ export default gameLoop(() => { random, viewport, player, + enemy, isWall: (x: number, y: number): boolean => { const [ch] = state.mapData.get(x, y); - return ch === WALL; + return ch === WALL || isVertical(ch) || isHorizontal(ch) || isCorner(ch); }, updateMask: () => { if (!state.maskDirty) return; @@ -158,6 +186,9 @@ export default gameLoop(() => { } }; + Factions.setReputationIn(player, 'monsters', 5); + console.log(Factions.getReputation(enemy, player)); + return state; }, (dt, state) => { const { diff --git a/test/common/rpg/faction.test.ts b/test/common/rpg/faction.test.ts index e2dd7a6..95d5b2d 100644 --- a/test/common/rpg/faction.test.ts +++ b/test/common/rpg/faction.test.ts @@ -1,19 +1,49 @@ 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'; +import { Factions } from '@common/rpg/components/faction'; function world() { return new World(); } -describe('FactionMember', () => { - it('join() adds membership, isMember() returns true', () => { +// ── 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('leave() removes membership, isMember() returns false', () => { + 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'); @@ -21,108 +51,423 @@ describe('FactionMember', () => { expect(Factions.isMember(e, 'guards')).toBeFalse(); }); - it('isMember() returns false when entity has no FactionMember', () => { + it('leave is safe on a non-member', () => { const w = world(); const e = w.createEntity(); - expect(Factions.isMember(e, 'guards')).toBeFalse(); + expect(() => Factions.leave(e, 'guards')).not.toThrow(); }); - it('getFactions() returns all factionIds', () => { + it('leave only removes the specified faction', () => { const w = world(); const e = w.createEntity(); Factions.join(e, 'guards'); Factions.join(e, 'merchants'); - expect(Factions.getFactions(e).sort()).toEqual(['guards', 'merchants']); + Factions.leave(e, 'guards'); + expect(Factions.isMember(e, 'guards')).toBeFalse(); + expect(Factions.isMember(e, 'merchants')).toBeTrue(); }); - it('member variable resolves on world', () => { + it('can re-join after leaving', () => { const w = world(); - const e = w.createEntity('player'); + const e = w.createEntity(); Factions.join(e, 'guards'); - expect(resolveVariables(w)['player.FactionMember.guards']).toBeTrue(); + 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']); }); }); -describe('Reputation', () => { - it('getReputation() returns 0 when no component', () => { +// ── getFaction ───────────────────────────────────────────────────────────────── + +describe('Factions.getFaction', () => { + it('creates a faction entity on first call', () => { const w = world(); - const e = w.createEntity(); - expect(Factions.getReputation(e, 'guards')).toBe(0); + const faction = Factions.getFaction(w, 'guards'); + expect(faction).toBeDefined(); }); - it('setReputation() creates component on first call', () => { + it('returns the same component for the same factionId', () => { const w = world(); - const e = w.createEntity(); - Factions.setReputation(e, 'guards', 50); - expect(Factions.getReputation(e, 'guards')).toBe(50); + const a = Factions.getFaction(w, 'guards'); + const b = Factions.getFaction(w, 'guards'); + expect(a).toBe(b); }); - it('setReputation() updates score on second call without duplicate component', () => { + it('returns different components for different factionIds', () => { 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, (c) => c.factionId == 'guards')!; - 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.Reputation.guards']).toBe(50); + const guards = Factions.getFaction(w, 'guards'); + const bandits = Factions.getFaction(w, 'bandits'); + expect(guards).not.toBe(bandits); }); }); -describe('Factions.getReputationBetween', () => { - it('returns null when target has no factions', () => { +// ── 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(); - const observer = w.createEntity(); - const target = w.createEntity(); - expect(Factions.getReputationBetween(observer, target)).toBeNull(); + expect(Factions.getReputationOf(w.createEntity(), 'guards')).toBe(0); }); - it("returns observer's rep with target's sole faction", () => { + it('setReputationOf persists the value', () => { 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); + const e = w.createEntity(); + Factions.setReputationOf(e, 'guards', 50); + expect(Factions.getReputationOf(e, 'guards')).toBe(50); }); - it('returns the minimum when target belongs to multiple factions', () => { + 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.setReputation(observer, 'guards', 60); - Factions.setReputation(observer, 'bandits', -30); - expect(Factions.getReputationBetween(observer, target)).toBe(-30); + 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); }); });