Subscribe quests
This commit is contained in:
parent
30531bcd52
commit
693b078144
|
|
@ -70,10 +70,6 @@ function connect() {
|
||||||
connect();
|
connect();
|
||||||
</script>`;
|
</script>`;
|
||||||
|
|
||||||
const POLYFILL = `<script>
|
|
||||||
if (!Symbol.metadata) Symbol.metadata = Symbol.for('Symbol.metadata');
|
|
||||||
</script>`;
|
|
||||||
|
|
||||||
async function buildBundle(game: string, production: boolean) {
|
async function buildBundle(game: string, production: boolean) {
|
||||||
const assetsDir = path.resolve(import.meta.dir, 'assets');
|
const assetsDir = path.resolve(import.meta.dir, 'assets');
|
||||||
const srcDir = path.resolve(import.meta.dir, '..', 'src');
|
const srcDir = path.resolve(import.meta.dir, '..', 'src');
|
||||||
|
|
@ -192,7 +188,7 @@ export async function buildHTML(game: string, {
|
||||||
}
|
}
|
||||||
|
|
||||||
script = script.replaceAll('await Promise.resolve().then(() =>', '(');
|
script = script.replaceAll('await Promise.resolve().then(() =>', '(');
|
||||||
let scriptPrefix = POLYFILL;
|
let scriptPrefix = '';
|
||||||
if (production) {
|
if (production) {
|
||||||
const minifyResult = UglifyJS.minify(script, {
|
const minifyResult = UglifyJS.minify(script, {
|
||||||
module: true,
|
module: true,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Systems
|
||||||
|
|
||||||
|
### `CombatSystem` (`systems/combatSystem.ts`)
|
||||||
|
Resolves attack attempts between entities. Needs a lightweight `Attack` marker
|
||||||
|
component (attacker entity ID, target entity ID, damage, damage type) that is added
|
||||||
|
to an entity to queue an attack for the next tick. Each tick the system:
|
||||||
|
1. Processes queued `Attack` components.
|
||||||
|
2. Applies damage to the target's `Health` stat (accounting for any defense modifier
|
||||||
|
Effects on the target).
|
||||||
|
3. Removes the `Attack` component after resolution.
|
||||||
|
4. Emits `'hit'` / `'kill'` events on the target entity as appropriate.
|
||||||
|
|
||||||
|
Keeping the attack as a component (rather than a direct method call) lets other
|
||||||
|
systems react before resolution (parry window, shield-block effects, etc.).
|
||||||
|
|
||||||
|
|
@ -82,6 +82,10 @@ export class QuestLog extends Component<QuestLogState> {
|
||||||
return this.state.runtimeStates[questId];
|
return this.state.runtimeStates[questId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getQuest(questId: string): Quest | undefined {
|
||||||
|
return this.state.quests[questId];
|
||||||
|
}
|
||||||
|
|
||||||
isAvailable(questId: string, ctx: EvalContext): boolean {
|
isAvailable(questId: string, ctx: EvalContext): boolean {
|
||||||
const quest = this.state.quests[questId];
|
const quest = this.state.quests[questId];
|
||||||
if (!quest) return false;
|
if (!quest) return false;
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,177 @@
|
||||||
import { System, type Entity, type World } from "../core/world";
|
import { System, type Entity, type World, type EvalContext } from "../core/world";
|
||||||
import { evaluateCondition } from "../utils/conditions";
|
import { parseCondition, evaluateCondition } from "../utils/conditions";
|
||||||
import { executeAction } from "../utils/variables";
|
import { resolveVariable, executeAction } from "../utils/variables";
|
||||||
import { QuestLog } from "../components/questLog";
|
import { QuestLog } from "../components/questLog";
|
||||||
|
import type { QuestStage, RPGVariables } from "../types";
|
||||||
|
|
||||||
|
interface EntityTracking {
|
||||||
|
/** varName → last resolved value */
|
||||||
|
snapshot: Map<string, RPGVariables[string]>;
|
||||||
|
/** varName → set of questIds that reference it in the current stage */
|
||||||
|
varToQuests: Map<string, Set<string>>;
|
||||||
|
/** removes questlog event subscriptions for this entity */
|
||||||
|
unsubscribe: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export class QuestSystem extends System {
|
export class QuestSystem extends System {
|
||||||
|
#tracking = new Map<string, EntityTracking>();
|
||||||
|
|
||||||
|
override onAdd(world: World): void {
|
||||||
|
for (const [entity, key, questLog] of world.query(QuestLog)) {
|
||||||
|
this.#initTracking(entity, key, questLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override onRemove(_world: World): void {
|
||||||
|
for (const t of this.#tracking.values()) t.unsubscribe();
|
||||||
|
this.#tracking.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
override update(world: World, _dt: number): void {
|
||||||
|
for (const [entity, key, questLog] of world.query(QuestLog)) {
|
||||||
|
if (!this.#tracking.has(entity.id)) {
|
||||||
|
this.#initTracking(entity, key, questLog);
|
||||||
|
}
|
||||||
|
void this.#diffAndCheck(entity, world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune tracking for entities that no longer exist
|
||||||
|
for (const entityId of this.#tracking.keys()) {
|
||||||
|
if (!world.getEntity(entityId)) {
|
||||||
|
this.#tracking.get(entityId)!.unsubscribe();
|
||||||
|
this.#tracking.delete(entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a full re-evaluation of all active quests. Use as an escape hatch. */
|
||||||
async triggerCheck(world: World): Promise<void> {
|
async triggerCheck(world: World): Promise<void> {
|
||||||
for (const [entity] of world.query(QuestLog)) {
|
for (const [entity] of world.query(QuestLog)) {
|
||||||
await this.#checkEntity(entity, world);
|
await this.#checkEntity(entity, world, 'all');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #checkEntity(entity: Entity, world: World): Promise<void> {
|
#initTracking(entity: Entity, key: string, questLog: QuestLog): EntityTracking {
|
||||||
const questLog = entity.get(QuestLog);
|
const existing = this.#tracking.get(entity.id);
|
||||||
if (!questLog) return;
|
if (existing) existing.unsubscribe();
|
||||||
|
|
||||||
const ctx = { self: entity, world };
|
const tracking: EntityTracking = {
|
||||||
|
snapshot: new Map(),
|
||||||
|
varToQuests: new Map(),
|
||||||
|
unsubscribe: () => { },
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
const stage = quest.stages[state.stageIndex];
|
||||||
|
if (stage) this.#addQuestVars(tracking, questId, stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep tracking fresh as quest state changes
|
||||||
|
const onStarted = ({ data }: { data?: unknown }) => {
|
||||||
|
const { questId } = data as { questId: string };
|
||||||
|
const quest = questLog.getQuest(questId);
|
||||||
|
const state = questLog.getState(questId);
|
||||||
|
const stage = quest?.stages[state?.stageIndex ?? 0];
|
||||||
|
if (stage) {
|
||||||
|
this.#addQuestVars(tracking, questId, stage);
|
||||||
|
// Evaluate immediately — conditions may already be satisfied at start
|
||||||
|
void this.#checkEntity(entity, entity.world, new Set([questId]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStage = ({ data }: { data?: unknown }) => {
|
||||||
|
const { questId, stage } = data as { questId: string; stage: QuestStage };
|
||||||
|
this.#removeQuestVars(tracking, questId);
|
||||||
|
this.#addQuestVars(tracking, questId, stage);
|
||||||
|
void this.#checkEntity(entity, entity.world, new Set([questId]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDone = ({ data }: { data?: unknown }) => {
|
||||||
|
this.#removeQuestVars(tracking, (data as { questId: string }).questId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const u1 = entity.on(`${key}.started`, onStarted);
|
||||||
|
const u2 = entity.on(`${key}.stage`, onStage);
|
||||||
|
const u3 = entity.on(`${key}.completed`, onDone);
|
||||||
|
const u4 = entity.on(`${key}.failed`, onDone);
|
||||||
|
const u5 = entity.on(`${key}.abandoned`, onDone);
|
||||||
|
tracking.unsubscribe = () => { u1(); u2(); u3(); u4(); u5(); };
|
||||||
|
|
||||||
|
this.#tracking.set(entity.id, tracking);
|
||||||
|
return tracking;
|
||||||
|
}
|
||||||
|
|
||||||
|
#addQuestVars(tracking: EntityTracking, questId: string, stage: QuestStage): void {
|
||||||
|
const conditions = [
|
||||||
|
...stage.objectives.map(o => o.condition),
|
||||||
|
...(stage.failConditions ?? []),
|
||||||
|
];
|
||||||
|
for (const cond of conditions) {
|
||||||
|
const varName = parseCondition(cond).variable;
|
||||||
|
// Do NOT seed the snapshot here. #diffAndCheck treats "not in snapshot"
|
||||||
|
// as dirty on first encounter, ensuring a fresh eval after every init or start.
|
||||||
|
if (!tracking.varToQuests.has(varName)) {
|
||||||
|
tracking.varToQuests.set(varName, new Set());
|
||||||
|
}
|
||||||
|
tracking.varToQuests.get(varName)!.add(questId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#removeQuestVars(tracking: EntityTracking, questId: string): void {
|
||||||
|
for (const [varName, questIds] of tracking.varToQuests) {
|
||||||
|
questIds.delete(questId);
|
||||||
|
if (questIds.size === 0) {
|
||||||
|
tracking.varToQuests.delete(varName);
|
||||||
|
tracking.snapshot.delete(varName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #diffAndCheck(entity: Entity, world: World): Promise<void> {
|
||||||
|
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 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) {
|
||||||
|
tracking.snapshot.set(varName, curr);
|
||||||
|
for (const questId of questIds) dirty.add(questId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty.size > 0) await this.#checkEntity(entity, world, dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #checkEntity(entity: Entity, world: World, filter: Set<string> | 'all'): Promise<void> {
|
||||||
|
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;
|
||||||
|
|
||||||
const stage = quest.stages[state.stageIndex];
|
const stage = quest.stages[state.stageIndex];
|
||||||
if (!stage) {
|
if (!stage) {
|
||||||
console.warn(`[QuestSystem] quest '${questId}' is active but has no stage at index ${state.stageIndex}`);
|
console.warn(`[QuestSystem] quest '${questId}' active but missing stage ${state.stageIndex}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stage.failConditions?.length) {
|
if (stage.failConditions?.some(c => evaluateCondition(c, ctx))) {
|
||||||
const failed = stage.failConditions.some(c =>
|
|
||||||
evaluateCondition(c, ctx)
|
|
||||||
);
|
|
||||||
if (failed) {
|
|
||||||
questLog.fail(questId);
|
questLog.fail(questId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const allDone = stage.objectives.every(obj =>
|
if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue;
|
||||||
evaluateCondition(obj.condition, ctx)
|
|
||||||
);
|
|
||||||
if (!allDone) continue;
|
|
||||||
|
|
||||||
for (const action of stage.actions) {
|
|
||||||
await executeAction(action, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
for (const action of stage.actions) await executeAction(action, ctx);
|
||||||
questLog._advance(questId);
|
questLog._advance(questId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import type { RPGVariables } from "../types";
|
import type { RPGVariables } from "../types";
|
||||||
|
|
||||||
|
// TC39 stage-3 decorators use Symbol.metadata; polyfill for runtimes that lack it (e.g. Bun).
|
||||||
|
(Symbol as { metadata?: symbol }).metadata ??= Symbol.for('Symbol.metadata');
|
||||||
|
|
||||||
export const ACTION_KEYS = Symbol('rpg.actions');
|
export const ACTION_KEYS = Symbol('rpg.actions');
|
||||||
export const VARIABLE_KEYS = Symbol('rpg.variables');
|
export const VARIABLE_KEYS = Symbol('rpg.variables');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue