Refactor Eval context
This commit is contained in:
parent
6b19eebd53
commit
a52762cc18
|
|
@ -304,10 +304,59 @@ export class SeededRandom {
|
|||
*/
|
||||
clone(): SeededRandom {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ abstract class BaseEffect extends Component<{
|
|||
condition?: string;
|
||||
stacking?: 'stack' | 'unique' | 'replace';
|
||||
tag?: string;
|
||||
perSecond?: boolean;
|
||||
}) {
|
||||
super({
|
||||
target: typeof opts.target === 'string'
|
||||
|
|
@ -45,6 +44,18 @@ abstract class BaseEffect extends Component<{
|
|||
stacking: opts.stacking ?? 'stack',
|
||||
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 {
|
||||
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') {
|
||||
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);
|
||||
}
|
||||
|
||||
if (this.state.permanent) {
|
||||
this.entity.remove(this); // permanent effects are removed immediately
|
||||
}
|
||||
|
||||
if (this.state.remaining || this.state.condition) {
|
||||
this.ensureEffectSystem();
|
||||
}
|
||||
|
|
@ -112,7 +134,7 @@ export class Effect extends BaseEffect {
|
|||
this.emit('expired');
|
||||
}
|
||||
|
||||
update(dt: number, ctx: EvalContext): void {
|
||||
update(dt: number): void {
|
||||
if (this.state.remaining != null) {
|
||||
if (this.state.remaining > 0) {
|
||||
this.state.remaining -= dt;
|
||||
|
|
@ -126,7 +148,7 @@ export class Effect extends BaseEffect {
|
|||
}
|
||||
}
|
||||
} else if (this.state.condition != null) {
|
||||
if (!evaluateCondition(this.state.condition, ctx)) {
|
||||
if (!evaluateCondition(this.state.condition, this.entity)) {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
* `slotId` specifies which inventory slot to use from (otherwise any slot is used).
|
||||
*/
|
||||
@action
|
||||
use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): boolean {
|
||||
use(arg?: string | { itemId?: string; slotId?: SlotId }): boolean {
|
||||
const resolved = this.#resolveItem(arg);
|
||||
if (!resolved) return false;
|
||||
const { itemId, slotId } = resolved;
|
||||
|
|
@ -257,7 +257,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
|
||||
if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId });
|
||||
|
||||
usable.use(ctx ?? this.context);
|
||||
usable.use(this.entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,11 +46,9 @@ export class Usable extends Component<UsableState> {
|
|||
@variable get consumeOnUse(): boolean { return this.state.consumeOnUse; }
|
||||
|
||||
@action
|
||||
use(arg?: EvalContext, ctx?: EvalContext): void {
|
||||
ctx = arg ?? ctx ?? this.context;
|
||||
if (!ctx) return;
|
||||
use(user: EvalContext): void {
|
||||
for (const action of this.state.actions) {
|
||||
executeAction(action, ctx);
|
||||
executeAction(action, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@ interface WorldEvent<T = unknown> {
|
|||
type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
|
||||
type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
|
||||
|
||||
export interface EvalContext {
|
||||
self: Entity | World;
|
||||
world: World;
|
||||
}
|
||||
export type EvalContext = Entity | World;
|
||||
|
||||
/** Symbol used by Serialization to access World's entity counter. */
|
||||
export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter');
|
||||
|
|
@ -96,10 +93,6 @@ export abstract class Component<TState = Record<string, unknown>> {
|
|||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
get context(): EvalContext {
|
||||
return this.entity.context;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class System {
|
||||
|
|
@ -118,10 +111,6 @@ export class Entity {
|
|||
readonly world: World,
|
||||
) { }
|
||||
|
||||
get context(): EvalContext {
|
||||
return { self: this, world: this.world };
|
||||
}
|
||||
|
||||
add<T extends Component<any>>(component: T, k?: string): T {
|
||||
const key = k ?? Symbol();
|
||||
|
||||
|
|
@ -291,10 +280,7 @@ export class World {
|
|||
set [WORLD_ENTITY_COUNTER](n: number) { this.#entityCounter = n; }
|
||||
|
||||
get id() { return 'world'; }
|
||||
|
||||
get context(): EvalContext {
|
||||
return { self: this, world: this };
|
||||
}
|
||||
get world() { return this; }
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return typeof v === 'object' && v != null
|
||||
&& (
|
||||
(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;
|
||||
}
|
||||
export const isEvalContext = (x: unknown): x is EvalContext => (
|
||||
x instanceof Entity || x instanceof World
|
||||
);
|
||||
|
|
@ -231,7 +231,7 @@ export class DialogEngine {
|
|||
}
|
||||
|
||||
private getVariables(): RPGVariables {
|
||||
if (isEvalContext(this.options)) return resolveVariables(this.options.self);
|
||||
if (isEvalContext(this.options)) return resolveVariables(this.options);
|
||||
return this.options.variables;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,11 @@ export class CombatSystem extends System {
|
|||
if (!random) {
|
||||
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);
|
||||
|
|
@ -69,9 +73,11 @@ export class CombatSystem extends System {
|
|||
|
||||
const defense = target.get(Defense, (c) => c.state.damageType === damageType);
|
||||
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
|
||||
for (const component of source.getAll(EffectTemplate)) {
|
||||
const s = component.state;
|
||||
|
|
@ -82,6 +88,8 @@ export class CombatSystem extends System {
|
|||
delta: s.delta,
|
||||
targetField: s.targetField,
|
||||
duration: s.duration ?? undefined,
|
||||
permanent: s.permanent,
|
||||
gradual: s.gradual,
|
||||
condition: s.condition ?? undefined,
|
||||
stacking: s.stacking,
|
||||
tag: s.tag ?? undefined,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export class EffectSystem extends System {
|
|||
const expired: [Entity, Component<any>][] = [];
|
||||
|
||||
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) {
|
||||
expired.push([entity, effect]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,11 +132,10 @@ export class QuestSystem extends System {
|
|||
const tracking = this.#tracking.get(entity.id);
|
||||
if (!tracking || tracking.varToQuests.size === 0) return;
|
||||
|
||||
const ctx: EvalContext = { self: entity, world };
|
||||
const dirty = new Set<string>();
|
||||
|
||||
for (const [varName, questIds] of tracking.varToQuests) {
|
||||
const curr = resolveVariable(varName, ctx);
|
||||
const curr = resolveVariable(varName, entity);
|
||||
const prev = tracking.snapshot.get(varName);
|
||||
// Treat "not yet in snapshot" as dirty — catches conditions already met at init
|
||||
if (!tracking.snapshot.has(varName) || curr !== prev) {
|
||||
|
|
@ -152,8 +151,6 @@ export class QuestSystem extends System {
|
|||
const questLog = entity.get(QuestLog);
|
||||
if (!questLog) return;
|
||||
|
||||
const ctx: EvalContext = { self: entity, world };
|
||||
|
||||
for (const [questId, { quest, state }] of questLog.entries()) {
|
||||
if (state.status !== 'active') continue;
|
||||
if (filter !== 'all' && !filter.has(questId)) continue;
|
||||
|
|
@ -164,14 +161,14 @@ export class QuestSystem extends System {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (stage.failConditions?.some(c => evaluateCondition(c, ctx))) {
|
||||
if (stage.failConditions?.some(c => evaluateCondition(c, entity))) {
|
||||
questLog.fail(questId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,15 +8,12 @@ export const ACTION_KEYS = Symbol('rpg.actions');
|
|||
export const VARIABLE_KEYS = Symbol('rpg.variables');
|
||||
export const STATE_KEYS = Symbol('rpg.state_variables');
|
||||
|
||||
export function action<T extends (arg?: any, ctx?: any) => unknown>(
|
||||
target: T,
|
||||
export function action<T extends (arg?: any) => unknown>(
|
||||
_target: T,
|
||||
context: ClassMethodDecoratorContext<unknown, T>
|
||||
): T {
|
||||
): void {
|
||||
const prev = context.metadata[ACTION_KEYS] as Set<string | symbol> | undefined;
|
||||
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]> =
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[st
|
|||
return resolveVariables(entity)[varName];
|
||||
}
|
||||
// bare name → self entity
|
||||
return resolveVariables(ctx.self)[name];
|
||||
return resolveVariables(ctx)[name];
|
||||
}
|
||||
export function resolveActions(target: Entity | World): RPGActions {
|
||||
const result: RPGActions = {};
|
||||
|
|
@ -76,21 +76,14 @@ export function resolveActions(target: Entity | World): RPGActions {
|
|||
return result;
|
||||
}
|
||||
|
||||
interface Contextable {
|
||||
readonly context: EvalContext;
|
||||
}
|
||||
|
||||
export function executeAction(action: RPGAction, ctx: EvalContext | Contextable): unknown {
|
||||
export function executeAction(action: RPGAction, ctx: EvalContext): unknown {
|
||||
if (typeof action === 'string') {
|
||||
action = { type: action };
|
||||
}
|
||||
if ('context' in ctx) {
|
||||
ctx = ctx.context;
|
||||
}
|
||||
if (!action.type) {
|
||||
throw new TypeError(`[executeAction] action object is missing a 'type' property`);
|
||||
}
|
||||
let entity = ctx.self;
|
||||
let entity = ctx;
|
||||
let actionType = action.type;
|
||||
|
||||
// @entityId.component.action → dispatch to another entity
|
||||
|
|
|
|||
|
|
@ -227,19 +227,19 @@ describe('QuestLog — stage access', () => {
|
|||
const { player, vars, questLog } = makePlayer(w, [simpleQuest()]);
|
||||
questLog.start('q1');
|
||||
|
||||
const before = questLog.getObjectiveProgress('q1', player.context);
|
||||
const before = questLog.getObjectiveProgress('q1', player);
|
||||
expect(before).toHaveLength(1);
|
||||
expect(before![0].done).toBeFalse();
|
||||
|
||||
vars.set({ key: 'done', value: true });
|
||||
const after = questLog.getObjectiveProgress('q1', player.context);
|
||||
const after = questLog.getObjectiveProgress('q1', player);
|
||||
expect(after![0].done).toBeTrue();
|
||||
});
|
||||
|
||||
it('getObjectiveProgress returns undefined when not active', () => {
|
||||
const w = world();
|
||||
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', () => {
|
||||
const w = world();
|
||||
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', () => {
|
||||
const w = world();
|
||||
const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] };
|
||||
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', () => {
|
||||
|
|
@ -264,7 +264,7 @@ describe('QuestLog — availability', () => {
|
|||
const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] };
|
||||
const { player, vars, questLog } = makePlayer(w, [quest]);
|
||||
vars.set({ key: 'unlocked', value: true });
|
||||
expect(questLog.isAvailable('q1', player.context)).toBeTrue();
|
||||
expect(questLog.isAvailable('q1', player)).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue