Another Parser Refactor(ial) and Scope
In the process of porting Dungeo to Sharpee, we got to a completed implementation, but some unit tests missed important steps. When I started a "walkthrough" test in phases, we discovered a glaring gap in my grammar and parser implementation. I should say fundamental misunderstanding of what the grammar definitions are responsible for, what the parser does, and what happens in actions in the standard library.
The grammar builder was trying to do some of the validation ahead of time. It took a few iterations to convince Claude this was incorrect (the stubbornness comes from all of the history of docs in the project, not Claude hallucinating).
We updated the Grammar Builder to be very simple. It connects patterns to actions and defines required Traits. Here's examples of the old builder and the new one.
OLD: Scope + boolean property matching in grammar
grammar
.define('put :item in :container')
.where('item', (scope) => scope.carried())
.where('container', (scope) => scope
.touchable()
.matching({ container: true }))
.mapsTo('if.action.inserting')
.build();
grammar
.define('board :vehicle')
.where('vehicle', (scope) => scope
.visible()
.matching({ enterable: true }))
.mapsTo('if.action.entering')
.build();
NEW: Traits only - scope moved to action validation
grammar
.define('put :item in :container')
.hasTrait('container', TraitType.CONTAINER)
.mapsTo('if.action.inserting')
.build();
grammar
.define('board :vehicle')
.hasTrait('vehicle', TraitType.ENTERABLE)
.mapsTo('if.action.entering')
.build();
Key changes:
- Removed .visible(), .touchable(), .carried()
- scope now handled by action validate()
- Replaced .matching({ container: true })
with .hasTrait('slot', TraitType.CONTAINER)
- Grammar declares only semantic constraints (what kind of entity),
not scope (visibility/reachability)And this leads to handling scope and validation more efficiently:
Action Scope Validation (NEW)
Scope is now handled in action validate() with a 4-tier system:
/**
* 4-tier scope hierarchy (ordered - higher implies lower)
*/
enum ScopeLevel {
UNAWARE = 0, // Entity not known to player
AWARE = 1, // Player knows it exists (think about, ask about)
VISIBLE = 2, // Can see it (examine, look at, read)
REACHABLE = 3, // Can physically touch (take, push, open)
CARRIED = 4 // In inventory (drop, eat, wear, insert)
}
Example: Opening Action
export const openingAction: Action = {
id: 'if.action.opening',
// Document default scope for this action
defaultScope: {
target: ScopeLevel.REACHABLE
},
validate(context: ActionContext): ValidationResult {
const target = context.command.directObject?.entity;
if (!target) {
return { valid: false, error: 'NO_TARGET' };
}
// Check scope - must be able to reach the target
const scopeCheck = context.requireScope(target, ScopeLevel.REACHABLE);
if (!scopeCheck.ok) {
return scopeCheck.error!; // Returns "You can't reach that" etc.
}
// Check trait - must be openable
if (!target.has(TraitType.OPENABLE)) {
return { valid: false, error: 'NOT_OPENABLE',
params: { item: target.name } };
}
// Check state - must be closed
if (!OpenableBehavior.canOpen(target)) {
return { valid: false, error: 'ALREADY_OPEN',
params: { item: target.name } };
}
return { valid: true };
},
// ...
};
Example: Examining Action (VISIBLE scope)
export const examiningAction: Action = {
id: 'if.action.examining',
defaultScope: {
target: ScopeLevel.VISIBLE // Only need to see it, not touch
},
validate(context: ActionContext): ValidationResult {
const target = context.command.directObject?.entity;
if (!target) {
return { valid: false, error: 'NO_TARGET' };
}
// Examining yourself always works
if (target.id !== context.player.id) {
const scopeCheck = context.requireScope(target, ScopeLevel.VISIBLE);
if (!scopeCheck.ok) {
return scopeCheck.error!; // "You can't see any such thing"
}
}
return { valid: true };
},
// ...
};The most important thing these changes enabled was composable traits. The previous implementation was unable to handle that and now we have working composition.