1
0
Fork 0

Refactor Eval context

This commit is contained in:
Pabloader 2026-05-07 19:38:31 +00:00
parent 6b19eebd53
commit a52762cc18
12 changed files with 112 additions and 72 deletions

View File

@ -304,10 +304,59 @@ export class SeededRandom {
*/ */
clone(): SeededRandom { clone(): SeededRandom {
const child = new SeededRandom(this.getState()); const child = new SeededRandom(this.getState());
this.next(); // diverge parent so the two streams never overlap this.jump(); // diverge parent so the two streams never overlap
return child; return child;
} }
/**
* Advance the state by 2^64 steps (equivalent to 2^64 calls to next()).
* Use this to generate 2^64 non-overlapping subsequences for parallel work.
*/
jump(): void {
const JUMP = [0x8764000b, 0xf542d2d3, 0x6fa035c3, 0x77f2db5b];
let s0 = 0, s1 = 0, s2 = 0, s3 = 0;
for (const j of JUMP) {
for (let b = 0; b < 32; b++) {
if ((j >>> b) & 1) {
s0 ^= this.s[0];
s1 ^= this.s[1];
s2 ^= this.s[2];
s3 ^= this.s[3];
}
this.next();
}
}
this.s[0] = s0 >>> 0;
this.s[1] = s1 >>> 0;
this.s[2] = s2 >>> 0;
this.s[3] = s3 >>> 0;
}
/**
* Advance the state by 2^96 steps (equivalent to 2^96 calls to next()).
* Generates 2^32 starting points; each jump() from those gives 2^32
* non-overlapping subsequences useful for distributed computation.
*/
longJump(): void {
const LONG_JUMP = [0xb523952e, 0x0b6f099f, 0xccf5a0ef, 0x1c580662];
let s0 = 0, s1 = 0, s2 = 0, s3 = 0;
for (const j of LONG_JUMP) {
for (let b = 0; b < 32; b++) {
if ((j >>> b) & 1) {
s0 ^= this.s[0];
s1 ^= this.s[1];
s2 ^= this.s[2];
s3 ^= this.s[3];
}
this.next();
}
}
this.s[0] = s0 >>> 0;
this.s[1] = s1 >>> 0;
this.s[2] = s2 >>> 0;
this.s[3] = s3 >>> 0;
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Serialisation // Serialisation
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@ -28,7 +28,6 @@ abstract class BaseEffect extends Component<{
condition?: string; condition?: string;
stacking?: 'stack' | 'unique' | 'replace'; stacking?: 'stack' | 'unique' | 'replace';
tag?: string; tag?: string;
perSecond?: boolean;
}) { }) {
super({ super({
target: typeof opts.target === 'string' target: typeof opts.target === 'string'
@ -45,6 +44,18 @@ abstract class BaseEffect extends Component<{
stacking: opts.stacking ?? 'stack', stacking: opts.stacking ?? 'stack',
tag: opts.tag ?? null, tag: opts.tag ?? null,
}); });
if (opts.gradual && opts.duration == null) {
throw new Error('Effect cannot be gradual without a duration');
}
if (opts.duration != null && opts.duration <= 0) {
throw new Error('Effect duration must be greater than zero');
}
if (opts.permanent && opts.duration != null) {
throw new Error('Effect cannot be both permanent and have a duration');
}
} }
} }
@ -57,6 +68,13 @@ export class Effect extends BaseEffect {
override onAdd(): void { override onAdd(): void {
const { stacking, tag } = this.state; const { stacking, tag } = this.state;
if (this.state.permanent && this.state.condition) {
if (!evaluateCondition(this.state.condition, this.entity)) {
this.entity.remove(this);
return;
}
}
if (tag != null && stacking !== 'stack') { if (tag != null && stacking !== 'stack') {
const siblings = this.entity.getAll(Effect).filter(e => e !== this && e.state.tag === tag); const siblings = this.entity.getAll(Effect).filter(e => e !== this && e.state.tag === tag);
@ -79,6 +97,10 @@ export class Effect extends BaseEffect {
this.applyDelta(this.state.delta); this.applyDelta(this.state.delta);
} }
if (this.state.permanent) {
this.entity.remove(this); // permanent effects are removed immediately
}
if (this.state.remaining || this.state.condition) { if (this.state.remaining || this.state.condition) {
this.ensureEffectSystem(); this.ensureEffectSystem();
} }
@ -112,7 +134,7 @@ export class Effect extends BaseEffect {
this.emit('expired'); this.emit('expired');
} }
update(dt: number, ctx: EvalContext): void { update(dt: number): void {
if (this.state.remaining != null) { if (this.state.remaining != null) {
if (this.state.remaining > 0) { if (this.state.remaining > 0) {
this.state.remaining -= dt; this.state.remaining -= dt;
@ -126,7 +148,7 @@ export class Effect extends BaseEffect {
} }
} }
} else if (this.state.condition != null) { } else if (this.state.condition != null) {
if (!evaluateCondition(this.state.condition, ctx)) { if (!evaluateCondition(this.state.condition, this.entity)) {
this.clear(); this.clear();
} }
} }

View File

@ -233,7 +233,7 @@ export class Inventory extends Component<InventoryState> {
* `slotId` specifies which inventory slot to use from (otherwise any slot is used). * `slotId` specifies which inventory slot to use from (otherwise any slot is used).
*/ */
@action @action
use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): boolean { use(arg?: string | { itemId?: string; slotId?: SlotId }): boolean {
const resolved = this.#resolveItem(arg); const resolved = this.#resolveItem(arg);
if (!resolved) return false; if (!resolved) return false;
const { itemId, slotId } = resolved; const { itemId, slotId } = resolved;
@ -257,7 +257,7 @@ export class Inventory extends Component<InventoryState> {
if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId }); if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId });
usable.use(ctx ?? this.context); usable.use(this.entity);
return true; return true;
} }

View File

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

View File

@ -14,10 +14,7 @@ interface WorldEvent<T = unknown> {
type EntityEventHandler = <T>(event: EntityEvent<T>) => void; type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
type WorldEventHandler = <T>(event: WorldEvent<T>) => void; type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
export interface EvalContext { export type EvalContext = Entity | World;
self: Entity | World;
world: World;
}
/** Symbol used by Serialization to access World's entity counter. */ /** Symbol used by Serialization to access World's entity counter. */
export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter'); export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter');
@ -96,10 +93,6 @@ export abstract class Component<TState = Record<string, unknown>> {
} }
return actions; return actions;
} }
get context(): EvalContext {
return this.entity.context;
}
} }
export abstract class System { export abstract class System {
@ -118,10 +111,6 @@ export class Entity {
readonly world: World, readonly world: World,
) { } ) { }
get context(): EvalContext {
return { self: this, world: this.world };
}
add<T extends Component<any>>(component: T, k?: string): T { add<T extends Component<any>>(component: T, k?: string): T {
const key = k ?? Symbol(); const key = k ?? Symbol();
@ -291,10 +280,7 @@ export class World {
set [WORLD_ENTITY_COUNTER](n: number) { this.#entityCounter = n; } set [WORLD_ENTITY_COUNTER](n: number) { this.#entityCounter = n; }
get id() { return 'world'; } get id() { return 'world'; }
get world() { return this; }
get context(): EvalContext {
return { self: this, world: this };
}
/** /**
* Create a new entity and add it to the world. * Create a new entity and add it to the world.
@ -460,16 +446,6 @@ export class World {
} }
} }
export function isEvalContext(v: unknown): v is EvalContext { export const isEvalContext = (x: unknown): x is EvalContext => (
return typeof v === 'object' && v != null x instanceof Entity || x instanceof World
&& ( );
(v as EvalContext).self instanceof Entity
|| (v as EvalContext).self instanceof World
)
&& (v as EvalContext).world instanceof World;
}
/** Narrows an {@link EvalContext} to one where `self` is an `Entity`, not a `World`. */
export function isEntityContext(ctx: EvalContext): ctx is { self: Entity; world: World } {
return ctx.self instanceof Entity;
}

View File

@ -231,7 +231,7 @@ export class DialogEngine {
} }
private getVariables(): RPGVariables { private getVariables(): RPGVariables {
if (isEvalContext(this.options)) return resolveVariables(this.options.self); if (isEvalContext(this.options)) return resolveVariables(this.options);
return this.options.variables; return this.options.variables;
} }

View File

@ -51,7 +51,11 @@ export class CombatSystem extends System {
if (!random) { if (!random) {
random = getWorldRandom(world); random = getWorldRandom(world);
} }
damageAmount += random.use(r => r.nextInt(-variance, variance)); const variedDamage = random.use(r => r.nextInt(-variance, variance));
if (variedDamage > 0) {
damageAmount += variedDamage;
}
} }
const crit = source.get(Crit); const crit = source.get(Crit);
@ -69,9 +73,11 @@ export class CombatSystem extends System {
const defense = target.get(Defense, (c) => c.state.damageType === damageType); const defense = target.get(Defense, (c) => c.state.damageType === damageType);
if (defense) { if (defense) {
damageAmount = Math.max(minDamage, damageAmount - defense.value); damageAmount -= defense.value;
} }
damageAmount = Math.max(0, minDamage, damageAmount);
// Apply on-hit effects from source onto target // Apply on-hit effects from source onto target
for (const component of source.getAll(EffectTemplate)) { for (const component of source.getAll(EffectTemplate)) {
const s = component.state; const s = component.state;
@ -82,6 +88,8 @@ export class CombatSystem extends System {
delta: s.delta, delta: s.delta,
targetField: s.targetField, targetField: s.targetField,
duration: s.duration ?? undefined, duration: s.duration ?? undefined,
permanent: s.permanent,
gradual: s.gradual,
condition: s.condition ?? undefined, condition: s.condition ?? undefined,
stacking: s.stacking, stacking: s.stacking,
tag: s.tag ?? undefined, tag: s.tag ?? undefined,

View File

@ -6,7 +6,7 @@ export class EffectSystem extends System {
const expired: [Entity, Component<any>][] = []; const expired: [Entity, Component<any>][] = [];
for (const [entity, , effect] of world.query(Effect)) { for (const [entity, , effect] of world.query(Effect)) {
effect.update(dt, entity.context); effect.update(dt);
if (effect.state.remaining !== null && effect.state.remaining <= 0) { if (effect.state.remaining !== null && effect.state.remaining <= 0) {
expired.push([entity, effect]); expired.push([entity, effect]);
} }

View File

@ -132,11 +132,10 @@ export class QuestSystem extends System {
const tracking = this.#tracking.get(entity.id); const tracking = this.#tracking.get(entity.id);
if (!tracking || tracking.varToQuests.size === 0) return; if (!tracking || tracking.varToQuests.size === 0) return;
const ctx: EvalContext = { self: entity, world };
const dirty = new Set<string>(); const dirty = new Set<string>();
for (const [varName, questIds] of tracking.varToQuests) { for (const [varName, questIds] of tracking.varToQuests) {
const curr = resolveVariable(varName, ctx); const curr = resolveVariable(varName, entity);
const prev = tracking.snapshot.get(varName); const prev = tracking.snapshot.get(varName);
// Treat "not yet in snapshot" as dirty — catches conditions already met at init // Treat "not yet in snapshot" as dirty — catches conditions already met at init
if (!tracking.snapshot.has(varName) || curr !== prev) { if (!tracking.snapshot.has(varName) || curr !== prev) {
@ -152,8 +151,6 @@ export class QuestSystem extends System {
const questLog = entity.get(QuestLog); const questLog = entity.get(QuestLog);
if (!questLog) return; if (!questLog) return;
const ctx: EvalContext = { self: entity, world };
for (const [questId, { quest, state }] of questLog.entries()) { for (const [questId, { quest, state }] of questLog.entries()) {
if (state.status !== 'active') continue; if (state.status !== 'active') continue;
if (filter !== 'all' && !filter.has(questId)) continue; if (filter !== 'all' && !filter.has(questId)) continue;
@ -164,14 +161,14 @@ export class QuestSystem extends System {
continue; continue;
} }
if (stage.failConditions?.some(c => evaluateCondition(c, ctx))) { if (stage.failConditions?.some(c => evaluateCondition(c, entity))) {
questLog.fail(questId); questLog.fail(questId);
continue; continue;
} }
if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue; if (!stage.objectives.every(o => evaluateCondition(o.condition, entity))) continue;
for (const action of stage.actions) executeAction(action, ctx); for (const action of stage.actions) executeAction(action, entity);
questLog._advance(questId); questLog._advance(questId);
} }
} }

View File

@ -8,15 +8,12 @@ export const ACTION_KEYS = Symbol('rpg.actions');
export const VARIABLE_KEYS = Symbol('rpg.variables'); export const VARIABLE_KEYS = Symbol('rpg.variables');
export const STATE_KEYS = Symbol('rpg.state_variables'); export const STATE_KEYS = Symbol('rpg.state_variables');
export function action<T extends (arg?: any, ctx?: any) => unknown>( export function action<T extends (arg?: any) => unknown>(
target: T, _target: T,
context: ClassMethodDecoratorContext<unknown, T> context: ClassMethodDecoratorContext<unknown, T>
): T { ): void {
const prev = context.metadata[ACTION_KEYS] as Set<string | symbol> | undefined; const prev = context.metadata[ACTION_KEYS] as Set<string | symbol> | undefined;
context.metadata[ACTION_KEYS] = new Set(prev).add(context.name); context.metadata[ACTION_KEYS] = new Set(prev).add(context.name);
return function (this: any, arg?: any, ctx?: any) {
return target.call(this, arg, ctx ?? this.context);
} as unknown as T;
} }
type VariableContext<T extends RPGVariables[string]> = type VariableContext<T extends RPGVariables[string]> =

View File

@ -51,7 +51,7 @@ export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[st
return resolveVariables(entity)[varName]; return resolveVariables(entity)[varName];
} }
// bare name → self entity // bare name → self entity
return resolveVariables(ctx.self)[name]; return resolveVariables(ctx)[name];
} }
export function resolveActions(target: Entity | World): RPGActions { export function resolveActions(target: Entity | World): RPGActions {
const result: RPGActions = {}; const result: RPGActions = {};
@ -76,21 +76,14 @@ export function resolveActions(target: Entity | World): RPGActions {
return result; return result;
} }
interface Contextable { export function executeAction(action: RPGAction, ctx: EvalContext): unknown {
readonly context: EvalContext;
}
export function executeAction(action: RPGAction, ctx: EvalContext | Contextable): unknown {
if (typeof action === 'string') { if (typeof action === 'string') {
action = { type: action }; action = { type: action };
} }
if ('context' in ctx) {
ctx = ctx.context;
}
if (!action.type) { if (!action.type) {
throw new TypeError(`[executeAction] action object is missing a 'type' property`); throw new TypeError(`[executeAction] action object is missing a 'type' property`);
} }
let entity = ctx.self; let entity = ctx;
let actionType = action.type; let actionType = action.type;
// @entityId.component.action → dispatch to another entity // @entityId.component.action → dispatch to another entity

View File

@ -227,19 +227,19 @@ describe('QuestLog — stage access', () => {
const { player, vars, questLog } = makePlayer(w, [simpleQuest()]); const { player, vars, questLog } = makePlayer(w, [simpleQuest()]);
questLog.start('q1'); questLog.start('q1');
const before = questLog.getObjectiveProgress('q1', player.context); const before = questLog.getObjectiveProgress('q1', player);
expect(before).toHaveLength(1); expect(before).toHaveLength(1);
expect(before![0].done).toBeFalse(); expect(before![0].done).toBeFalse();
vars.set({ key: 'done', value: true }); vars.set({ key: 'done', value: true });
const after = questLog.getObjectiveProgress('q1', player.context); const after = questLog.getObjectiveProgress('q1', player);
expect(after![0].done).toBeTrue(); expect(after![0].done).toBeTrue();
}); });
it('getObjectiveProgress returns undefined when not active', () => { it('getObjectiveProgress returns undefined when not active', () => {
const w = world(); const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]); const { player, questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.getObjectiveProgress('q1', player.context)).toBeUndefined(); expect(questLog.getObjectiveProgress('q1', player)).toBeUndefined();
}); });
}); });
@ -249,14 +249,14 @@ describe('QuestLog — availability', () => {
it('quest with no conditions is always available', () => { it('quest with no conditions is always available', () => {
const w = world(); const w = world();
const { player, questLog } = makePlayer(w, [simpleQuest()]); const { player, questLog } = makePlayer(w, [simpleQuest()]);
expect(questLog.isAvailable('q1', player.context)).toBeTrue(); expect(questLog.isAvailable('q1', player)).toBeTrue();
}); });
it('quest with unsatisfied condition is not available', () => { it('quest with unsatisfied condition is not available', () => {
const w = world(); const w = world();
const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] }; const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] };
const { player, questLog } = makePlayer(w, [quest]); const { player, questLog } = makePlayer(w, [quest]);
expect(questLog.isAvailable('q1', player.context)).toBeFalse(); expect(questLog.isAvailable('q1', player)).toBeFalse();
}); });
it('quest with satisfied condition is available', () => { it('quest with satisfied condition is available', () => {
@ -264,7 +264,7 @@ describe('QuestLog — availability', () => {
const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] }; const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] };
const { player, vars, questLog } = makePlayer(w, [quest]); const { player, vars, questLog } = makePlayer(w, [quest]);
vars.set({ key: 'unlocked', value: true }); vars.set({ key: 'unlocked', value: true });
expect(questLog.isAvailable('q1', player.context)).toBeTrue(); expect(questLog.isAvailable('q1', player)).toBeTrue();
}); });
}); });