Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/evaluator/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,9 +801,9 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
*
* Rendering mirrors `evalExplode` / `evalModifier`: emits
* `<targetExpr><code>[<sortedDice>]`, replacing any inline dice brackets
* the target itself rendered. Multi-sub-roll groups (`{4d6, 3d6}s`) fall
* through to this flat-pool path — hierarchical per-sub-roll sorting is
* deferred (see Stage 3 spec §3 "Group interaction").
* the target itself rendered. Multi-sub-roll Group targets (`{4d6, 3d6}s`)
* are rejected at parse time with `INVALID_SORT_TARGET` until hierarchical
* per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
*/
function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
Expand Down
25 changes: 19 additions & 6 deletions src/parser/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2196,12 +2196,6 @@ describe('Parser', () => {
it('should accept single-sub-roll group', () => {
expect(parse('{4d6}s')).toEqual(sort('ascending', group([dice(literal(4), literal(6))])));
});

it('should accept multi-sub-roll group', () => {
expect(parse('{4d6, 3d6}sd')).toEqual(
sort('descending', group([dice(literal(4), literal(6)), dice(literal(3), literal(6))])),
);
});
});

describe('errors', () => {
Expand All @@ -2219,6 +2213,25 @@ describe('Parser', () => {
expect(() => parse('(1+2)sd')).toThrow(ParseError);
});

it('should reject sort on a multi-sub-roll group', () => {
expect(() => parse('{1d6, 2d8}s')).toThrow(ParseError);
try {
parse('{1d6, 2d8}s');
} catch (e) {
expect((e as ParseError).code).toBe('INVALID_SORT_TARGET');
expect((e as ParseError).message).toContain('not yet support');
}
});

it('should reject sort on a parens-wrapped multi-sub-roll group', () => {
expect(() => parse('({1d6, 2d8})s')).toThrow(ParseError);
try {
parse('({1d6, 2d8})s');
} catch (e) {
expect((e as ParseError).code).toBe('INVALID_SORT_TARGET');
}
});

it('should reject sort on SuccessCount target', () => {
expect(() => parse('4d6>=4s')).toThrow(ParseError);
try {
Expand Down
17 changes: 17 additions & 0 deletions src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,23 @@ export class Parser {
);
}

// ! Multi-sub-roll groups (`{a, b}s`, `({a, b})s`) need hierarchical
// sort per Stage 3 spec §3 (sort dice within each sub-roll, then sort
// sub-rolls by total) — `evalSort` only flat-sorts, so accepting the
// syntax would silently ship non-spec behaviour. Reject at parse time
// until the deferred Stage 4 implementation lands. Single-sub Groups
// keep passing through (the unwrap returns a `Group` with one
// expression, which is the user's flat-pool escape hatch).
const base = unwrapTransparent(target, ['Grouped', 'Modifier', 'Sort', 'CritThreshold']);
if (base.type === 'Group' && base.expressions.length >= 2) {
throw new ParseError(
`Sort modifier does not yet support multi-sub-roll groups`,
'INVALID_SORT_TARGET',
token.position,
token,
);
}

const order: SortNode['order'] = token.type === TokenType.SORT_ASC ? 'ascending' : 'descending';

// ? Chained sorts (`4d6ss`, `4d6sasd`) are allowed — sort is idempotent
Expand Down
Loading