1
0
Fork 0

Compare commits

..

4 Commits

Author SHA1 Message Date
Pabloader 43da1388e4 Factions 2026-04-30 11:24:45 +00:00
Pabloader cc8f4a562f Aray - record for inventory & equipment 2026-04-30 10:55:24 +00:00
Pabloader 561ffb1d7e Add stacking rules to effect 2026-04-29 18:12:40 +00:00
Pabloader d7a3830740 Remove async actions 2026-04-29 17:54:59 +00:00
19 changed files with 505 additions and 204 deletions

View File

@ -1,20 +1,7 @@
# 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
### Effect stacking rules
Add `stack` (default) / `unique` / `replace` modes with a tag discriminator to
`Effect`. Prevents e.g. multiple poison stacks when the design calls for one.
### Damage modifiers
Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on
the attacker/source that CombatSystem folds into the final damage value.

View File

@ -13,31 +13,56 @@ export class Effect extends Component<{
remaining: number | null; // countdown in seconds; null for condition-based/permanent
condition: string | null; // keep effect while true; remove when it becomes false
scope: 'equip' | 'onHit'; // 'equip' = live modifier on owner; 'onHit' = template applied to attack target
stacking: 'stack' | 'unique' | 'replace';
tag: string | null; // discriminator for stacking; null = no stacking enforcement
}> {
/** True while the effect's delta is applied to the target stat. */
@variable('.') active: boolean = false;
constructor(
targetStat: string,
delta: number,
targetField?: 'value' | 'max' | 'min',
duration?: number,
condition?: string,
scope?: 'equip' | 'onHit',
) {
constructor(opts: {
targetStat: string;
delta: number;
targetField?: 'value' | 'max' | 'min';
duration?: number;
condition?: string;
scope?: 'equip' | 'onHit';
stacking?: 'stack' | 'unique' | 'replace';
tag?: string;
}) {
super({
targetStat,
targetField: targetField ?? 'value',
delta,
duration: duration ?? null,
remaining: duration ?? null,
condition: condition ?? null,
scope: scope ?? 'equip',
targetStat: opts.targetStat,
targetField: opts.targetField ?? 'value',
delta: opts.delta,
duration: opts.duration ?? null,
remaining: opts.duration ?? null,
condition: opts.condition ?? null,
scope: opts.scope ?? 'equip',
stacking: opts.stacking ?? 'stack',
tag: opts.tag ?? null,
});
}
override onAdd(): void {
if (this.state.scope === 'onHit') return;
const { stacking, tag } = this.state;
if (tag != null && stacking !== 'stack') {
const siblings = this.entity.getAll(Effect).filter(e => e !== this && e.state.tag === tag);
if (stacking === 'unique' && siblings.length > 0) {
// An effect with this tag is already active — discard the incoming one.
this.entity.remove(this.key);
return;
}
if (stacking === 'replace') {
for (const old of siblings) {
this.entity.remove(old.key);
}
}
}
const stat = this.entity.get(Stat, this.state.targetStat);
if (stat) {
stat.applyModifier(this.state.delta, this.state.targetField);
@ -46,7 +71,7 @@ export class Effect extends Component<{
}
override onRemove(): void {
if (this.state.scope === 'onHit') return;
if (!this.active) return;
const stat = this.entity.get(Stat, this.state.targetStat);
if (stat) {
stat.removeModifier(this.state.delta, this.state.targetField);
@ -67,7 +92,6 @@ export class Effect extends Component<{
@action
clear(): void {
this.state.remaining = 0;
this.active = false;
this.emit('expired');
}

View File

@ -34,7 +34,7 @@ interface SlotRecord {
}
interface EquipmentState {
slots: SlotRecord[];
slots: Record<string, SlotRecord>;
}
export type SlotInput =
@ -46,17 +46,17 @@ export class Equipment extends Component<EquipmentState> {
#cachedVars: RPGVariables | null = null;
constructor(slots: SlotInput[]) {
super({
slots: slots.map(s => {
const record: Record<string, SlotRecord> = {};
for (const s of slots) {
const slotName = typeof s === 'string' ? s : s.slotName;
const type = typeof s === 'object' && s.type ? s.type : null;
return { slotName, type, itemId: null, appliedEffectKeys: [] };
}),
});
record[slotName] = { slotName, type, itemId: null, appliedEffectKeys: [] };
}
super({ slots: record });
}
#slot(slotName: string): SlotRecord | undefined {
return this.state.slots.find(s => s.slotName === slotName);
return this.state.slots[slotName];
}
/** ItemId in the named slot, or null if empty. */
@ -71,7 +71,7 @@ export class Equipment extends Component<EquipmentState> {
*/
findCompatibleSlot(slotType: string): string | null {
let genericFallback: string | null = null;
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.itemId !== null) continue;
if (slot.type === null) { genericFallback ??= slot.slotName; continue; }
if (slot.type === slotType) return slot.slotName;
@ -81,7 +81,7 @@ export class Equipment extends Component<EquipmentState> {
/** All currently equipped `{ slotName, itemId }` pairs. */
getEquipped(): { slotName: string; itemId: string }[] {
return this.state.slots
return Object.values(this.state.slots)
.filter((s): s is SlotRecord & { itemId: string } => s.itemId !== null)
.map(({ slotName, itemId }) => ({ slotName, itemId }));
}
@ -154,7 +154,7 @@ export class Equipment extends Component<EquipmentState> {
if (this.#cachedVars) return this.#cachedVars;
const result: RPGVariables = {};
for (const { slotName, itemId } of this.state.slots) {
for (const { slotName, itemId } of Object.values(this.state.slots)) {
result[slotName] = itemId ?? '';
}
this.#cachedVars = result;

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

@ -15,29 +15,25 @@ interface SlotRecord {
interface InventoryState {
infinite: boolean;
nextSlotId: number; // auto-increment counter for infinite mode
slots: SlotRecord[];
slots: Record<SlotId, SlotRecord>;
}
function buildInventoryState(input?: number | InventorySlotInput[]): InventoryState {
if (input === undefined) {
return { infinite: true, nextSlotId: 0, slots: [] };
return { infinite: true, nextSlotId: 0, slots: {} as Record<SlotId, SlotRecord> };
}
if (typeof input === 'number') {
return {
infinite: false,
nextSlotId: 0,
slots: Array.from({ length: input }, (_, i) => ({ slotId: i, limit: undefined, contents: null })),
};
const slots = {} as Record<SlotId, SlotRecord>;
for (let i = 0; i < input; i++) slots[i] = { slotId: i, limit: undefined, contents: null };
return { infinite: false, nextSlotId: 0, slots };
}
return {
infinite: false,
nextSlotId: 0,
slots: input.map(def => ({
slotId: typeof def === 'object' ? def.slotId : def,
limit: typeof def === 'object' ? def.limit : undefined,
contents: null,
})),
};
const slots = {} as Record<SlotId, SlotRecord>;
for (const def of input) {
const slotId = typeof def === 'object' ? def.slotId : def;
const limit = typeof def === 'object' ? def.limit : undefined;
slots[slotId] = { slotId, limit, contents: null };
}
return { infinite: false, nextSlotId: 0, slots };
}
@component
@ -55,7 +51,7 @@ export class Inventory extends Component<InventoryState> {
}
#slot(slotId: SlotId): SlotRecord | undefined {
return this.state.slots.find(s => s.slotId === slotId);
return this.state.slots[slotId];
}
#capFor(slot: SlotRecord, itemId: string): number {
@ -94,7 +90,7 @@ export class Inventory extends Component<InventoryState> {
// ── Finite inventory: two-phase (check → apply) ───────────────────────
if (!this.state.infinite) {
let canFit = 0;
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.contents === null || slot.contents.itemId === itemId)
canFit += this.#roomFor(slot, itemId);
}
@ -102,7 +98,7 @@ export class Inventory extends Component<InventoryState> {
let remaining = amount;
const slotIds: SlotId[] = [];
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.contents?.itemId === itemId && remaining > 0) {
const take = Math.min(this.#roomFor(slot, itemId), remaining);
slot.contents!.amount += take;
@ -110,7 +106,7 @@ export class Inventory extends Component<InventoryState> {
slotIds.push(slot.slotId);
}
}
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.contents === null && remaining > 0) {
const take = Math.min(this.#capFor(slot, itemId), remaining);
slot.contents = { itemId, amount: take };
@ -126,7 +122,7 @@ export class Inventory extends Component<InventoryState> {
let remaining = amount;
const slotIds: SlotId[] = [];
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.contents?.itemId === itemId && remaining > 0) {
const take = Math.min(this.#roomFor(slot, itemId), remaining);
if (take > 0) {
@ -136,7 +132,7 @@ export class Inventory extends Component<InventoryState> {
}
}
}
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.contents === null && remaining > 0) {
const take = Math.min(this.#capFor(slot, itemId), remaining);
slot.contents = { itemId, amount: take };
@ -146,7 +142,7 @@ export class Inventory extends Component<InventoryState> {
}
while (remaining > 0) {
const newSlot: SlotRecord = { slotId: this.state.nextSlotId++, limit: undefined, contents: null };
this.state.slots.push(newSlot);
this.state.slots[newSlot.slotId] = newSlot;
const take = Math.min(this.#capFor(newSlot, itemId), remaining);
newSlot.contents = { itemId, amount: take };
remaining -= take;
@ -177,7 +173,7 @@ export class Inventory extends Component<InventoryState> {
let remaining = amount;
const slotIds: SlotId[] = [];
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.contents?.itemId === itemId && remaining > 0) {
const take = Math.min(slot.contents.amount, remaining);
slot.contents.amount -= take;
@ -237,7 +233,7 @@ export class Inventory extends Component<InventoryState> {
* `slotId` specifies which inventory slot to use from (otherwise any slot is used).
*/
@action
async use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): Promise<boolean> {
use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): boolean {
const resolved = this.#resolveItem(arg);
if (!resolved) return false;
const { itemId, slotId } = resolved;
@ -261,7 +257,7 @@ export class Inventory extends Component<InventoryState> {
if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId });
await usable.use(ctx ?? this.context);
usable.use(ctx ?? this.context);
return true;
}
@ -290,7 +286,7 @@ export class Inventory extends Component<InventoryState> {
return slot?.contents?.itemId === itemId ? slot.contents.amount : 0;
}
let total = 0;
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.contents?.itemId === itemId) total += slot.contents.amount;
}
return total;
@ -298,7 +294,7 @@ export class Inventory extends Component<InventoryState> {
getItems(): Map<string, number> {
const result = new Map<string, number>();
for (const slot of this.state.slots) {
for (const slot of Object.values(this.state.slots)) {
if (slot.contents) {
const { itemId, amount } = slot.contents;
result.set(itemId, (result.get(itemId) ?? 0) + amount);

View File

@ -46,11 +46,11 @@ export class Usable extends Component<UsableState> {
@variable get consumeOnUse(): boolean { return this.state.consumeOnUse; }
@action
async use(arg?: EvalContext, ctx?: EvalContext): Promise<void> {
use(arg?: EvalContext, ctx?: EvalContext): void {
ctx = arg ?? ctx ?? this.context;
if (!ctx) return;
for (const action of this.state.actions) {
await executeAction(action, ctx);
executeAction(action, ctx);
}
}
}

View File

@ -78,7 +78,7 @@ export abstract class Component<TState = Record<string, unknown>> {
export abstract class System {
onAdd(_world: World): void { }
onRemove(_world: World): void { }
async update(_world: World, _dt: number): Promise<void> { };
update(_world: World, _dt: number): void { };
}
type ComponentFilter<T> = (component: T) => boolean;
@ -316,8 +316,10 @@ export class World {
}
}
async update(dt: number): Promise<void> {
for (const system of this.#systems) await system.update(this, dt);
update(dt: number) {
for (const system of this.#systems) {
system.update(this, dt);
}
}
emit(entityId: string, event: string, data?: unknown): void {

View File

@ -6,7 +6,7 @@ import { System, World } from "../core/world";
let hitEffectCounter = 0;
export class CombatSystem extends System {
override async update(world: World): Promise<void> {
override update(world: World) {
for (const [target] of world.query(Attacked)) {
const health = target.get(Health);
if (!health) {
@ -55,7 +55,15 @@ export class CombatSystem extends System {
const s = component.state;
target.add(
`__hit_${source.id}_${key}_${hitEffectCounter++}`,
new Effect(s.targetStat, s.delta, s.targetField, s.duration ?? undefined, s.condition ?? undefined),
new Effect({
targetStat: s.targetStat,
delta: s.delta,
targetField: s.targetField,
duration: s.duration ?? undefined,
condition: s.condition ?? undefined,
stacking: s.stacking,
tag: s.tag ?? undefined,
}),
);
}

View File

@ -2,7 +2,7 @@ import { Cooldown } from "../components/cooldown";
import { System, type World } from "../core/world";
export class CooldownSystem extends System {
override async update(world: World, dt: number): Promise<void> {
override update(world: World, dt: number) {
for (const [, , cooldown] of world.query(Cooldown)) {
cooldown.update(dt);
}

View File

@ -2,12 +2,12 @@ import { Effect } from "../components/effect";
import { System, type Entity, type World } from "../core/world";
export class EffectSystem extends System {
override async update(world: World, dt: number): Promise<void> {
override update(world: World, dt: number) {
const expired: [Entity, string][] = [];
for (const [entity, key, effect] of world.query(Effect)) {
effect.update(dt, entity.context);
if (!effect.active && effect.state.scope !== 'onHit') {
if (effect.state.remaining !== null && effect.state.remaining <= 0) {
expired.push([entity, key]);
}
}

View File

@ -27,12 +27,12 @@ export class QuestSystem extends System {
this.#tracking.clear();
}
override async update(world: World, _dt: number): Promise<void> {
override update(world: World) {
for (const [entity, key, questLog] of world.query(QuestLog)) {
if (!this.#tracking.has(entity.id)) {
this.#initTracking(entity, key, questLog);
}
await this.#diffAndCheck(entity, world);
this.#diffAndCheck(entity, world);
}
// Prune tracking for entities that no longer exist
@ -45,9 +45,9 @@ export class QuestSystem extends System {
}
/** Force a full re-evaluation of all active quests. Use as an escape hatch. */
async triggerCheck(world: World): Promise<void> {
triggerCheck(world: World) {
for (const [entity] of world.query(QuestLog)) {
await this.#checkEntity(entity, world, 'all');
this.#checkEntity(entity, world, 'all');
}
}
@ -68,7 +68,7 @@ export class QuestSystem extends System {
}
// Keep tracking fresh as quest state changes
const onStarted = async ({ data }: { data?: unknown }) => {
const onStarted = ({ data }: { data?: unknown }) => {
const { questId } = data as { questId: string };
const quest = questLog.getQuest(questId);
const state = questLog.getState(questId);
@ -76,15 +76,15 @@ export class QuestSystem extends System {
if (stage) {
this.#addQuestVars(tracking, questId, stage);
// Evaluate immediately — conditions may already be satisfied at start
await this.#checkEntity(entity, entity.world, new Set([questId]));
this.#checkEntity(entity, entity.world, new Set([questId]));
}
};
const onStage = async ({ data }: { data?: unknown }) => {
const onStage = ({ data }: { data?: unknown }) => {
const { questId, stage } = data as { questId: string; stage: QuestStage };
this.#removeQuestVars(tracking, questId);
this.#addQuestVars(tracking, questId, stage);
await this.#checkEntity(entity, entity.world, new Set([questId]));
this.#checkEntity(entity, entity.world, new Set([questId]));
};
const onDone = ({ data }: { data?: unknown }) => {
@ -128,7 +128,7 @@ export class QuestSystem extends System {
}
}
async #diffAndCheck(entity: Entity, world: World): Promise<void> {
#diffAndCheck(entity: Entity, world: World) {
const tracking = this.#tracking.get(entity.id);
if (!tracking || tracking.varToQuests.size === 0) return;
@ -145,10 +145,10 @@ export class QuestSystem extends System {
}
}
if (dirty.size > 0) await this.#checkEntity(entity, world, dirty);
if (dirty.size > 0) this.#checkEntity(entity, world, dirty);
}
async #checkEntity(entity: Entity, world: World, filter: Set<string> | 'all'): Promise<void> {
#checkEntity(entity: Entity, world: World, filter: Set<string> | 'all') {
const questLog = entity.get(QuestLog);
if (!questLog) return;
@ -171,7 +171,7 @@ export class QuestSystem extends System {
if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue;
for (const action of stage.actions) await executeAction(action, ctx);
for (const action of stage.actions) executeAction(action, ctx);
questLog._advance(questId);
}
}

View File

@ -69,7 +69,7 @@ interface Contextable {
readonly context: EvalContext;
}
export async function executeAction(action: RPGAction, ctx: EvalContext | Contextable): Promise<unknown> {
export function executeAction(action: RPGAction, ctx: EvalContext | Contextable): unknown {
if (typeof action === 'string') {
action = { type: action };
}

View File

@ -14,7 +14,7 @@ function world() {
}
describe('CombatSystem — damage', () => {
it('reduces target health by damage value', async () => {
it('reduces target health by damage value', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 20, damageType: 'physical' }));
@ -22,11 +22,11 @@ describe('CombatSystem — damage', () => {
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(80);
});
it('defense on target reduces damage', async () => {
it('defense on target reduces damage', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 20, damageType: 'physical' }));
@ -35,11 +35,11 @@ describe('CombatSystem — damage', () => {
target.add('health', new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 8, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(88);
});
it('defense only applies to matching damage type', async () => {
it('defense only applies to matching damage type', () => {
const w = world();
const spell = w.createEntity('spell');
spell.add('dmg', new Damage({ value: 20, damageType: 'fire' }));
@ -48,11 +48,11 @@ describe('CombatSystem — damage', () => {
target.add('health', new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 8, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'spell' }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(80);
});
it('minDamage is enforced when defense exceeds damage', async () => {
it('minDamage is enforced when defense exceeds damage', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical', minDamage: 3 }));
@ -61,22 +61,22 @@ describe('CombatSystem — damage', () => {
target.add('health', new Health({ value: 100, min: 0 }));
target.add('armor', new Defense({ value: 10, damageType: 'physical' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(97);
});
it('null sourceId falls back to Damage on attacker', async () => {
it('null sourceId falls back to Damage on attacker', () => {
const w = world();
const attacker = w.createEntity('attacker');
attacker.add('dmg', new Damage({ value: 15, damageType: 'physical' }));
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: null }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(85);
});
it('multiple attacks in one tick accumulate', async () => {
it('multiple attacks in one tick accumulate', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
@ -85,11 +85,11 @@ describe('CombatSystem — damage', () => {
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(80);
});
it('Attacked components are removed after processing', async () => {
it('Attacked components are removed after processing', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
@ -97,62 +97,62 @@ describe('CombatSystem — damage', () => {
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(target.has(Attacked)).toBeFalse();
});
it('missing attacker entity skips attack gracefully', async () => {
it('missing attacker entity skips attack gracefully', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'ghost', sourceId: null }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(100);
});
it('missing source entity skips attack gracefully', async () => {
it('missing source entity skips attack gracefully', () => {
const w = world();
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'gone' }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(100);
});
it('source with no Damage component skips attack gracefully', async () => {
it('source with no Damage component skips attack gracefully', () => {
const w = world();
w.createEntity('attacker');
w.createEntity('empty_source');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'empty_source' }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(100);
});
it('target with no Health component is skipped gracefully', async () => {
it('target with no Health component is skipped gracefully', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1); // should not throw
w.update(1); // should not throw
});
});
describe('CombatSystem — on-hit effects', () => {
it('onHit effect is applied to target on hit', async () => {
it('onHit effect is applied to target on hit', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
sword.add('burn', new Effect('health', -5, 'value', 10, undefined, 'onHit'));
sword.add('burn', new Effect({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10, scope: 'onHit' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(target.get(Health)!.value).toBe(85); // 100 - 10 dmg - 5 burn modifier
expect(target.getAll(Effect).length).toBe(1);
});
@ -162,29 +162,97 @@ describe('CombatSystem — on-hit effects', () => {
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
sword.add('str', new Stat({ value: 50 }));
sword.add('drain', new Effect('str', -99, 'value', undefined, undefined, 'onHit'));
sword.add('drain', new Effect({ targetStat: 'str', delta: -99, targetField: 'value', scope: 'onHit' }));
expect(sword.get(Stat, 'str')!.value).toBe(50);
});
it('onHit effect expires on target after duration', async () => {
it('onHit effect expires on target after duration', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
sword.add('burn', new Effect('health', -10, 'value', 2, undefined, 'onHit'));
sword.add('burn', new Effect({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2, scope: 'onHit' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
w.update(1);
const afterHit = target.get(Health)!.value; // 85
await w.update(2); // burn expires
w.update(2); // burn expires
expect(target.getAll(Effect).length).toBe(0);
expect(target.get(Health)!.value).toBe(afterHit + 10); // modifier reverted
});
});
describe('Effect stacking', () => {
it('stack: allows multiple effects with the same tag', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(90);
});
it('unique: second effect with same tag is discarded', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(1);
expect(target.get(Health)!.value).toBe(95);
});
it('unique: effects with different tags both apply', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -3, stacking: 'unique', tag: 'burn' }));
expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(92);
});
it('replace: removes existing effect and applies new one', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'replace', tag: 'poison' }));
expect(target.get(Health)!.value).toBe(95);
target.add('e2', new Effect({ targetStat: 'health', delta: -10, stacking: 'replace', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(1);
expect(target.get(Health)!.value).toBe(90); // old -5 reversed, new -10 applied
});
it('no tag: unique mode does not enforce limits', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' }));
expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(90);
});
it('unique onHit effect is discarded when same tag already on target', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 0, damageType: 'physical' }));
sword.add('burn', new Effect({ targetStat: 'health', delta: -5, duration: 10, scope: 'onHit', stacking: 'unique', tag: 'burn' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('pre', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'burn' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1);
expect(target.getAll(Effect).length).toBe(1); // onHit copy discarded
expect(target.get(Health)!.value).toBe(95);
});
});
describe('CombatSystem — events', () => {
it("emits 'hit' on target with attack info", async () => {
it("emits 'hit' on target with attack info", () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'fire' }));
@ -194,7 +262,7 @@ describe('CombatSystem — events', () => {
const hits: unknown[] = [];
target.on('hit', ({ data }) => hits.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(hits.length).toBe(1);
expect((hits[0] as any).damageType).toBe('fire');
expect((hits[0] as any).amount).toBe(10);
@ -202,7 +270,7 @@ describe('CombatSystem — events', () => {
expect((hits[0] as any).sourceId).toBe('sword');
});
it("emits 'kill' when target health reaches zero", async () => {
it("emits 'kill' when target health reaches zero", () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 999, damageType: 'physical' }));
@ -212,11 +280,11 @@ describe('CombatSystem — events', () => {
const kills: unknown[] = [];
target.on('kill', ({ data }) => kills.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(kills.length).toBe(1);
});
it("does not emit 'kill' when target survives", async () => {
it("does not emit 'kill' when target survives", () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
@ -226,11 +294,11 @@ describe('CombatSystem — events', () => {
const kills: unknown[] = [];
target.on('kill', ({ data }) => kills.push(data));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(kills.length).toBe(0);
});
it("emits 'hit' per attack when multiple attacks land", async () => {
it("emits 'hit' per attack when multiple attacks land", () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
@ -241,7 +309,7 @@ describe('CombatSystem — events', () => {
target.on('hit', ({ data }) => hits.push(data));
target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
await w.update(1);
w.update(1);
expect(hits.length).toBe(2);
});
});

View File

@ -136,33 +136,33 @@ describe('Cooldown — update(dt)', () => {
});
describe('CooldownSystem', () => {
it('drives all cooldowns each tick', async () => {
it('drives all cooldowns each tick', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(2));
await w.update(1);
w.update(1);
expect(e.get(Cooldown, 'cd')!.state.remaining).toBe(1);
});
it('marks cooldown ready after enough ticks', async () => {
it('marks cooldown ready after enough ticks', () => {
const w = world();
const e = w.createEntity();
e.add('cd', new Cooldown(2));
const events: unknown[] = [];
e.on('cd.ready', () => events.push(true));
await w.update(1);
await w.update(1);
w.update(1);
w.update(1);
expect(events.length).toBe(1);
expect(e.get(Cooldown, 'cd')!.ready).toBeTrue();
});
it('handles multiple cooldowns on different entities', async () => {
it('handles multiple cooldowns on different entities', () => {
const w = world();
const a = w.createEntity('a');
const b = w.createEntity('b');
a.add('cd', new Cooldown(1));
b.add('cd', new Cooldown(3));
await w.update(2);
w.update(2);
expect(a.get(Cooldown, 'cd')!.ready).toBeTrue();
expect(b.get(Cooldown, 'cd')!.ready).toBeFalse();
});

View File

@ -20,13 +20,13 @@ function withStat(value = 10, min?: number, max?: number) {
describe('Effect — onAdd / onRemove', () => {
it('applies delta to stat on add', () => {
const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 5));
e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
expect(stat.value).toBe(15);
});
it('reverts delta on remove', () => {
const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 5));
e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
e.remove('fx');
expect(stat.value).toBe(10);
});
@ -34,7 +34,7 @@ describe('Effect — onAdd / onRemove', () => {
it('applies delta to max field', () => {
const { e } = withStat(10, undefined, 20);
const s = e.get(Stat, 'str')!;
e.add('fx', new Effect('str', 10, 'max'));
e.add('fx', new Effect({ targetStat: 'str', delta: 10, targetField: 'max' }));
expect(s.max).toBe(30);
e.remove('fx');
expect(s.max).toBe(20);
@ -42,73 +42,73 @@ describe('Effect — onAdd / onRemove', () => {
it('active is true after add', () => {
const { e } = withStat(10);
e.add('fx', new Effect('str', 1));
e.add('fx', new Effect({ targetStat: 'str', delta: 1 }));
expect(e.get(Effect, 'fx')!.active).toBeTrue();
});
it('no-op if target stat is missing', () => {
const w = world();
const e = w.createEntity();
expect(() => e.add('fx', new Effect('str', 5))).not.toThrow();
expect(() => e.add('fx', new Effect({ targetStat: 'str', delta: 5 }))).not.toThrow();
});
});
describe('Effect — duration', () => {
it('expires after duration ticks', async () => {
it('expires after duration ticks', () => {
const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 2));
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 }));
expect(stat.value).toBe(15);
await w.update(1);
w.update(1);
expect(e.has(Effect)).toBeTrue();
await w.update(1);
w.update(1);
expect(e.has(Effect)).toBeFalse();
expect(stat.value).toBe(10);
});
it('emits expired before removal', async () => {
it('emits expired before removal', () => {
const { w, e } = withStat(10);
e.add('fx', new Effect('str', 1, 'value', 1));
e.add('fx', new Effect({ targetStat: 'str', delta: 1, duration: 1 }));
const events: string[] = [];
e.on('fx.expired', () => events.push('expired'));
await w.update(1);
w.update(1);
expect(events).toEqual(['expired']);
});
it('reset() restarts timer', async () => {
it('reset() restarts timer', () => {
const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 2));
await w.update(1.5);
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 }));
w.update(1.5);
e.get(Effect, 'fx')!.reset();
await w.update(1.5); // would have expired without reset
w.update(1.5); // would have expired without reset
expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15);
});
it('reset(duration) changes duration', async () => {
it('reset(duration) changes duration', () => {
const { w, e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 1));
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1 }));
e.get(Effect, 'fx')!.reset(10);
await w.update(5);
w.update(5);
expect(e.has(Effect)).toBeTrue();
});
it('clear() immediately expires effect', async () => {
it('clear() immediately expires effect', () => {
const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 100));
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 100 }));
e.get(Effect, 'fx')!.clear();
await w.update(0.01);
w.update(0.01);
expect(e.has(Effect)).toBeFalse();
expect(stat.value).toBe(10);
});
});
describe('Effect — permanent', () => {
it('permanent effect is never removed by EffectSystem', async () => {
it('permanent effect is never removed by EffectSystem', () => {
const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5));
await w.update(100);
e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
w.update(100);
expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15);
});
@ -117,27 +117,27 @@ describe('Effect — permanent', () => {
describe('Effect — scope: onHit', () => {
it('onAdd is a no-op for onHit scope', () => {
const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
expect(stat.value).toBe(10);
});
it('onRemove is a no-op for onHit scope', () => {
const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
e.remove('fx');
expect(stat.value).toBe(10);
});
it('EffectSystem does not tick or remove onHit effects', async () => {
it('EffectSystem does not tick or remove onHit effects', () => {
const { w, e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 1, undefined, 'onHit'));
await w.update(10);
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1, scope: 'onHit' }));
w.update(10);
expect(e.has(Effect)).toBeTrue();
});
it('active stays false for onHit scope', () => {
const { e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', undefined, undefined, 'onHit'));
e.add('fx', new Effect({ targetStat: 'str', delta: 5, scope: 'onHit' }));
expect(e.get(Effect, 'fx')!.active).toBeFalse();
});
});
@ -145,15 +145,15 @@ describe('Effect — scope: onHit', () => {
describe('Effect — multiple effects on same stat', () => {
it('multiple effects stack additively', () => {
const { e, stat } = withStat(10);
e.add('a', new Effect('str', 3));
e.add('b', new Effect('str', 7));
e.add('a', new Effect({ targetStat: 'str', delta: 3 }));
e.add('b', new Effect({ targetStat: 'str', delta: 7 }));
expect(stat.value).toBe(20);
});
it('removing one effect reverts only that delta', () => {
const { e, stat } = withStat(10);
e.add('a', new Effect('str', 3));
e.add('b', new Effect('str', 7));
e.add('a', new Effect({ targetStat: 'str', delta: 3 }));
e.add('b', new Effect({ targetStat: 'str', delta: 7 }));
e.remove('a');
expect(stat.value).toBe(17);
});

View File

@ -91,7 +91,7 @@ describe('Equipment — equip-scope effects', () => {
it('clones equip-scope Effect onto owner on equip', () => {
const w = world();
const sword = makeSword(w);
sword.add('bonus', new Effect('str', 5)); // scope: equip (default)
sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); // scope: equip (default)
const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15);
@ -100,7 +100,7 @@ describe('Equipment — equip-scope effects', () => {
it('does NOT clone onHit-scope Effect onto owner on equip', () => {
const w = world();
const sword = makeSword(w);
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected
@ -110,8 +110,8 @@ describe('Equipment — equip-scope effects', () => {
it('clones multiple equip effects', () => {
const w = world();
const sword = makeSword(w);
sword.add('a', new Effect('str', 3));
sword.add('b', new Effect('str', 7));
sword.add('a', new Effect({ targetStat: 'str', delta: 3 }));
sword.add('b', new Effect({ targetStat: 'str', delta: 7 }));
const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(20);
@ -120,8 +120,8 @@ describe('Equipment — equip-scope effects', () => {
it('equip + onHit: only equip effects reach owner', () => {
const w = world();
const sword = makeSword(w);
sword.add('passive', new Effect('str', 5));
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
sword.add('passive', new Effect({ targetStat: 'str', delta: 5 }));
sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15);
@ -133,7 +133,7 @@ describe('Equipment — unequip', () => {
it('unequip reverts cloned effects', () => {
const w = world();
const sword = makeSword(w);
sword.add('bonus', new Effect('str', 5));
sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 }));
const player = makePlayer(w);
const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword' });

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

View File

@ -235,7 +235,7 @@ describe('Inventory — equip', () => {
});
describe('Inventory — use', () => {
it('executes Usable actions', async () => {
it('executes Usable actions', () => {
const w = world();
const player = w.createEntity('player');
player.add('str', new Stat({ value: 10 }));
@ -244,11 +244,11 @@ describe('Inventory — use', () => {
usable: { actions: [{ type: 'str.update', arg: 5 }], consumeOnUse: false },
});
player.get(Inventory)!.add({ itemId: 'potion', amount: 1 });
await player.get(Inventory)!.use({ itemId: 'potion' });
player.get(Inventory)!.use({ itemId: 'potion' });
expect(player.get(Stat, 'str')!.value).toBe(15);
});
it('consumes item when consumeOnUse is true', async () => {
it('consumes item when consumeOnUse is true', () => {
const w = world();
const player = w.createEntity('player');
player.add('inv', new Inventory());
@ -257,7 +257,7 @@ describe('Inventory — use', () => {
usable: { actions: [], consumeOnUse: true },
});
player.get(Inventory)!.add({ itemId: 'herb', amount: 3 });
await player.get(Inventory)!.use({ itemId: 'herb' });
player.get(Inventory)!.use({ itemId: 'herb' });
expect(player.get(Inventory)!.getAmount('herb')).toBe(2);
});
});

View File

@ -371,38 +371,38 @@ describe('Quests.validate', () => {
// ── QuestSystem — objective completion ────────────────────────────────────────
describe('QuestSystem — objective completion', () => {
it('completes single-stage quest when objective is satisfied', async () => {
it('completes single-stage quest when objective is satisfied', () => {
const w = world();
const { vars, questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
vars.set({ key: 'done', value: true });
await w.update(1);
w.update(1);
expect(questLog.getState('q1')?.status).toBe('completed');
});
it('does not complete quest while objective is unsatisfied', async () => {
it('does not complete quest while objective is unsatisfied', () => {
const w = world();
const { questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1');
await w.update(1);
w.update(1);
expect(questLog.getState('q1')?.status).toBe('active');
});
it('does not process inactive quests', async () => {
it('does not process inactive quests', () => {
const w = world();
const { vars, questLog } = makePlayer(w, [simpleQuest()]);
vars.set({ key: 'done', value: true });
await w.update(1);
w.update(1);
expect(questLog.getState('q1')?.status).toBe('inactive');
});
it('runs stage actions before advancing', async () => {
it('runs stage actions before advancing', () => {
const w = world();
// action sets vars.reward = true on the player entity
const quest = simpleQuest('q1', [{ type: 'vars.set', arg: { key: 'reward', value: true } }]);
@ -410,7 +410,7 @@ describe('QuestSystem — objective completion', () => {
questLog.start('q1');
vars.set({ key: 'done', value: true });
await w.update(1);
w.update(1);
expect(questLog.getState('q1')?.status).toBe('completed');
expect(vars.state.vars['reward']).toBe(true);
@ -418,7 +418,7 @@ describe('QuestSystem — objective completion', () => {
});
describe('QuestSystem — multi-stage progression', () => {
it('advances through stages as objectives are satisfied', async () => {
it('advances through stages as objectives are satisfied', () => {
const w = world();
const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
@ -426,34 +426,34 @@ describe('QuestSystem — multi-stage progression', () => {
// satisfy stage 0
vars.set({ key: 'step', value: 1 });
await w.update(1);
w.update(1);
expect(questLog.getState('q2')?.stageIndex).toBe(1);
expect(questLog.getState('q2')?.status).toBe('active');
// satisfy stage 1
vars.set({ key: 'step', value: 2 });
await w.update(1);
w.update(1);
expect(questLog.getState('q2')?.status).toBe('completed');
});
it('does not skip stages', async () => {
it('does not skip stages', () => {
const w = world();
const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
questLog.start('q2');
vars.set({ key: 'step', value: 2 }); // would satisfy both stages
await w.update(1);
w.update(1);
// only advances one stage per tick
expect(questLog.getState('q2')?.stageIndex).toBe(1);
await w.update(1); // second tick completes it
w.update(1); // second tick completes it
expect(questLog.getState('q2')?.status).toBe('completed');
});
});
describe('QuestSystem — fail conditions', () => {
it('fails quest when failCondition is met', async () => {
it('fails quest when failCondition is met', () => {
const w = world();
const quest: Quest = {
id: 'q1',
@ -471,12 +471,12 @@ describe('QuestSystem — fail conditions', () => {
questLog.start('q1');
vars.set({ key: 'failed', value: true });
await w.update(1);
w.update(1);
expect(questLog.getState('q1')?.status).toBe('failed');
});
it('fail condition takes priority over objective completion', async () => {
it('fail condition takes priority over objective completion', () => {
const w = world();
const quest: Quest = {
id: 'q1',
@ -494,14 +494,14 @@ describe('QuestSystem — fail conditions', () => {
questLog.start('q1');
vars.set({ key: 'done', value: true });
await w.update(1);
w.update(1);
expect(questLog.getState('q1')?.status).toBe('failed'); // fail checked first
});
});
describe('QuestSystem — multiple quests', () => {
it('tracks multiple quests independently', async () => {
it('tracks multiple quests independently', () => {
const w = world();
const player = w.createEntity('player');
const vars = player.add('vars', new Variables());
@ -520,7 +520,7 @@ describe('QuestSystem — multiple quests', () => {
log.start('q2');
vars.set({ key: 'done1', value: true }); // only q1's condition satisfied
await w.update(1);
w.update(1);
expect(log.getState('q1')?.status).toBe('completed');
expect(log.getState('q2')?.status).toBe('active');