-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathdungeon-primitives.js
More file actions
861 lines (836 loc) · 34.5 KB
/
Copy pathdungeon-primitives.js
File metadata and controls
861 lines (836 loc) · 34.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
// Reusable dungeon-grammar building blocks, ported from dunjs.
//
// Each factory returns a fresh graphgram rule object; compose them into
// the `rules` list of a grammar (or a stage) along with any other rules
// you want. All factories accept the usual graphgram rule options:
// { name, weight, limit, type, delay, condition }
//
// Rules expect `path` edges as the substrate for expansion. When a node
// is created, its label gets a `type` field (customizable via opts).
//
// The keyDoor primitive additionally accepts `{ narrate: true }` to draw
// its `text`, `before`, `link`, `after` fields from the `$kdBundle`
// narrator helper (register that with `narrator.registerNarrator()` first).
const EDGE_PATH = 'path'
// Two distinct ways an edge can be unlocked retroactively:
// backtrack — unlocked by having traversed a specific forward edge
// (referenced via prereq.traversed = <edgeId>)
// return — unlocked by having visited the destination node
// (referenced via prereq.visited = <nodeId>)
const EDGE_BACKTRACK = 'backtrack'
const EDGE_RETURN = 'return'
const EDGE_PASSAGE = 'passage'
const EDGE_MONSTER = 'monster'
const EDGE_PUZZLE = 'puzzle'
// Edge types used inside expanded monster/puzzle mini-games:
// choice — player picks from alternatives at a choice node
// consequence — engine rolls (by `weight`) from a random node; carries
// playerDamage / monsterDamage on the [0,1] scale
// retreat — player-choice edge out of a battle back to its source
const EDGE_CHOICE = 'choice'
const EDGE_CONSEQUENCE = 'consequence'
const EDGE_RETREAT = 'retreat'
const NODE_START = 'start'
const NODE_WIN = 'win'
const NODE_ROOM = 'room'
const NODE_DEAD_END = 'dead_end'
const NODE_KEY = 'key'
const NODE_DOOR = 'door'
// Node types created by the monster / puzzle expansion rules:
// choice — player-choice node (player picks outgoing `choice` edge)
// random — random / engine-roll node (engine picks outgoing
// `consequence` edge by `weight`)
// death — terminal sink for lethal consequences
// puzzle_intro — entry node for a multiple-choice puzzle
// distractor — wrong-answer node that routes back to the puzzle source
// potion — pickup that restores HP (by `healValue`) when visited
const NODE_CHOICE = 'choice'
const NODE_RANDOM = 'random'
const NODE_DEATH = 'death'
const NODE_PUZZLE_INTRO = 'puzzle_intro'
const NODE_DISTRACTOR = 'distractor'
const NODE_POTION = 'potion'
// Identifier generators for CYOA-style gating. Every grammar-generated node
// carries a `nodeId` in its label so that `prereq.visited: <nodeId>` on any
// edge references a stable, player-trackable identity. Every forward /
// backtrack edge pair carries a shared `edgeId` so that a `prereq.traversed`
// on the backtrack references exactly the corresponding forward edge.
//
// $$iter is the current iteration counter of the enclosing stage. It resets
// at each stage boundary, so we qualify IDs with a stage-role string to
// avoid collisions with init-stage hardcoded IDs. Within the expand stage
// (which owns almost all ID-generating rules) every rule application
// advances $$iter, so `<role>_<iter>` pairs are unique.
function nodeIdExpr (role) {
return { $eval: '"' + role + '_" + ($$iter + 1)' }
}
function edgeIdExpr (role) {
return { $eval: '"e_' + role + '_" + ($$iter + 1)' }
}
// Copy common rule-level options onto a rule object without clobbering
// fields that the factory itself populated.
function withOpts (rule, opts) {
const keys = ['name', 'weight', 'limit', 'type', 'delay', 'condition', 'induced']
keys.forEach(function (k) {
if (opts && typeof opts[k] !== 'undefined' && typeof rule[k] === 'undefined') rule[k] = opts[k]
})
return rule
}
// Insert a midpoint room between the endpoints of a `path` edge. By default
// the resulting room is two-way — the player can return b -> m -> a as well
// as go forward a -> m -> b — so that edges correspond cleanly to CYOA
// links. Pass `{ oneWay: true }` to get the older one-way variant; that
// variant is gated with a `condition` that requires the edge's endpoints to
// already be connected by a direct back-edge (typically from a prior
// cycleCloseShortcut), so one-way midpoints only deepen existing cycles
// rather than stranding the player at b.
//
// two-way (default): a <-path-> m <-path-> b
// one-way: a --path-> m --path-> b (fires only when $$graph.hasEdge(b,a))
function midpointRoom (opts) {
opts = opts || {}
const pathType = opts.pathType || EDGE_PATH
const backtrackType = opts.backtrackType || EDGE_BACKTRACK
const roomType = opts.roomType || NODE_ROOM
const winType = opts.winType || NODE_WIN
const oneWay = !!opts.oneWay
const idAM = edgeIdExpr('am')
const idMB = edgeIdExpr('mb')
function backtrackLabel (traversedId) {
return {
type: backtrackType,
prereq: { traversed: traversedId },
dot: { label: backtrackType, style: 'dashed', color: 'gray' }
}
}
// Forward edges carry an `edgeId`; return edges carry the corresponding
// `prereq.traversed` so the player can only walk them after having taken
// the paired forward edge.
const edges = [
{ v: 'a', w: 'm', label: { type: pathType, edgeId: idAM } },
{ v: 'm', w: 'b', label: { type: pathType, edgeId: idMB } }
]
// LHS guard: only guard b against being `win` in the two-way case, since
// only the two-way variant adds an edge sourced at b.
const lhsNodes = [{ id: 'a' }, { id: 'b' }]
if (!oneWay) {
edges.push({ v: 'b', w: 'm', label: backtrackLabel(idMB) })
edges.push({ v: 'm', w: 'a', label: backtrackLabel(idAM) })
lhsNodes[1] = { id: 'b', label: { $not: { type: winType } } }
}
// Refuse to split any edge that already has an edgeId — those are paired
// with a backtrack elsewhere, and splitting would leave the backtrack's
// `prereq.traversed` referencing a forward edge that no longer exists.
// (Anonymous path edges, including the initial start->win and the
// preserved a->b in parallelPath / deadEnd, are still fair game.)
const edgePattern = {
$and: [{ type: pathType }, { $not: { edgeId: '(.+)' } }]
}
const rule = {
name: oneWay ? 'midpoint-room-oneway' : 'midpoint-room',
lhs: {
node: lhsNodes,
edge: [{ v: 'a', w: 'b', label: edgePattern }]
},
rhs: {
node: [
{ id: 'a' },
{ id: 'b' },
{ id: 'm', label: { type: roomType, nodeId: nodeIdExpr('room') } }
],
edge: edges
}
}
if (oneWay) rule.condition = '$$graph.hasEdge($b.id, $a.id)'
return withOpts(rule, opts)
}
// Hang a dead-end branch off the source of a `path` edge. The original edge
// is preserved, and a `backtrack` edge is added from the dead-end back to
// the source so the dead-end is traversable in both directions — important
// when edges represent explicit CYOA links that the player follows. Pass
// `{ noBacktrack: true }` for the old one-way behavior (or override
// `backtrackType`).
//
// a --path--> b => a --path--> b & a --path--> dead_end & dead_end --backtrack--> a
function deadEnd (opts) {
opts = opts || {}
const pathType = opts.pathType || EDGE_PATH
const backtrackType = opts.backtrackType || EDGE_BACKTRACK
const deadEndType = opts.deadEndType || NODE_DEAD_END
const withBacktrack = !opts.noBacktrack
const idAD = edgeIdExpr('ad')
const edges = [
{ v: 'a', w: 'b', label: { type: pathType } },
{ v: 'a', w: 'd', label: { type: pathType, edgeId: idAD } }
]
if (withBacktrack) {
edges.push({
v: 'd', w: 'a',
label: {
type: backtrackType,
prereq: { traversed: idAD },
dot: { label: backtrackType, style: 'dashed', color: 'gray' }
}
})
}
// Preserve the a->b edge's label (in particular its edgeId, if any) by
// giving it an LHS id and referencing it unchanged on the RHS. Without
// this, the RHS would clobber the label — and any backtrack elsewhere in
// the graph pointing at the old edgeId would go dangling.
return withOpts({
name: 'dead-end',
lhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{ v: 'a', w: 'b', label: { type: pathType }, id: 'e' }]
},
rhs: {
node: [
{ id: 'a' },
{ id: 'b' },
{ id: 'd', label: { type: deadEndType, nodeId: nodeIdExpr('deadend') } }
],
edge: ['e'].concat(edges.slice(1))
}
}, opts)
}
// Parallel path between the endpoints, routed through a midpoint room so
// the single-graph edge invariant holds.
// a --path--> b => a --path--> b & a --path--> room --path--> b
// The first edge of the new parallel path (a -> m) inherits the matched
// edge's `edgeId` AND `prereq` when they exist. edgeId inheritance keeps
// any external backtrack keyed on it reachable; prereq inheritance keeps
// gated corridors (locked doors, return edges) from being bypassable by
// the new parallel route. $extend drops the fields entirely when they're
// undefined on the matched edge.
function parallelPath (opts) {
opts = opts || {}
const pathType = opts.pathType || EDGE_PATH
const roomType = opts.roomType || NODE_ROOM
// Preserve a->b's label (including any edgeId) via the LHS id='e' trick.
return withOpts({
name: 'parallel-path',
lhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{ v: 'a', w: 'b', label: { type: pathType }, id: 'e' }]
},
rhs: {
node: [
{ id: 'a' },
{ id: 'b' },
{ id: 'm', label: { type: roomType, nodeId: nodeIdExpr('room') } }
],
edge: [
'e',
{ v: 'a', w: 'm', label: {
$extend: [
{ type: pathType },
{ edgeId: { $eval: '$e.label.edgeId' },
prereq: { $eval: '$e.label.prereq' } }
]
} },
{ v: 'm', w: 'b', label: { type: pathType } }
]
}
}, opts)
}
// Key/door side-branch. The key and door share a `pairId` (derived from
// the iteration number so it's unique per rule application), and the
// locked edge carries a `prereq.pairId` pointing back to the key.
//
// a --path--> b =>
// a --path--> key
// key --backtrack--> a
// a --path--> door
// door --path{prereq:{pairId,...}}--> b
//
// With `opts.narrate = true`, text fields (on nodes and the prereq) are
// filled from the `$kdBundle` narrator helper. Otherwise plain templates
// are used. Register narrator functions (see narrator.js) before evolving
// if `narrate` is true.
function keyDoor (opts) {
opts = opts || {}
const pathType = opts.pathType || EDGE_PATH
const backtrackType = opts.backtrackType || EDGE_BACKTRACK
const keyType = opts.keyType || NODE_KEY
const doorType = opts.doorType || NODE_DOOR
const startType = opts.startType || NODE_START
const winType = opts.winType || NODE_WIN
const narrate = !!opts.narrate
// $iter is exposed in the graphgram label-eval context (as `$$iter`
// inside $eval strings). We use it both as the shared pairId for the
// key/door and as the cache key for $kdBundle, so every label in a
// single rule application sees the same narrative bundle. We shift
// iter by 1 because iter=0 would otherwise stringify to an empty
// pairId under the ${...} template fallback.
const pairId = { $eval: '"pair_" + ($$iter + 1)' }
const iterArg = { $eval: '$$iter' }
function bundled (field, fallback) {
if (!narrate) return fallback
return { $kdBundle: [iterArg, field] }
}
// `dot` styling baked in so rendered PDFs show the key/door pairing
// and visually distinguish the locked edge and the backtrack edge.
const pairLabel = function (type) {
return { $eval: '"' + type + ' (pair_" + ($$iter + 1) + ")"' }
}
const keyLabel = {
type: keyType,
pairId: pairId,
nodeId: nodeIdExpr('key'),
text: bundled('keyText', 'There is a key here. You pick it up.'),
dot: { label: pairLabel(keyType), shape: 'diamond' }
}
const doorLabel = {
type: doorType,
pairId: pairId,
nodeId: nodeIdExpr('door'),
text: bundled('shutText', 'There is a door here. It is closed and locked.'),
dot: { label: pairLabel(doorType), shape: 'house' }
}
if (narrate) doorLabel.theme = { $kdBundle: [iterArg, 'theme'] }
// Edge IDs for the three forward-edge-to-backtrack pairs:
// a->k / k->a (branch out to the key and back)
// a->d / d->a (approach the door and retreat)
// d->b / b->d (unlock the door forward, and come back through once open)
const idAK = edgeIdExpr('ak')
const idAD = edgeIdExpr('ad')
const idDB = edgeIdExpr('db')
const branchEdgeLabel = {
type: pathType,
edgeId: idAK,
before: bundled('before', 'You see a passage.'),
link: bundled('link', 'Take the passage.'),
// dot.label is deliberately omitted so the decorate stage can fill
// in whatever type the edge is refined to (passage / monster /
// puzzle). The dotted style still visually flags this as the
// key-branch edge.
dot: { style: 'dotted' }
}
const lockedEdgeLabel = {
type: pathType,
edgeId: idDB,
prereq: {
pairId: pairId,
link: bundled('unlock', 'Unlock the door with the key.'),
after: bundled('after', 'The key unlocks the door. You go through.')
},
dot: { label: pairLabel('locked'), style: 'bold', color: 'red' }
}
// k -> a (backtrack, requires having taken the a->k forward edge)
const backtrackEdgeLabel = {
type: backtrackType,
prereq: { traversed: idAK },
dot: { label: backtrackType, style: 'dashed', color: 'gray' }
}
// Door backtracks: d -> a (retreat from the door before unlocking it;
// requires having walked a->d forward)
// b -> d (once unlocked, the door stays open — gated
// on having traversed d->b, which itself
// required the key. No double-gating: traversed
// d->b implies the player had the key.)
const doorFrontBacktrack = {
type: backtrackType,
prereq: { traversed: idAD },
dot: { label: backtrackType, style: 'dashed', color: 'gray' }
}
const doorBackBacktrack = {
type: backtrackType,
prereq: { traversed: idDB },
dot: { label: backtrackType, style: 'dashed', color: 'gray' }
}
return withOpts({
name: 'key-door',
lhs: {
node: [
// Don't place the key on the start node: cycleCloseShortcut's
// non-start guard would then never match a key-bearing `a`, and
// returns would never fire. keyDoor therefore waits until
// parallelPath / midpointRoom have produced at least one path
// edge whose source isn't start.
{ id: 'a', label: { $not: { type: startType } } },
// Don't make the goal node a source: the b -> d backtrack would
// otherwise give `win` an outgoing edge.
{ id: 'b', label: { $not: { type: winType } } }
],
// Refuse edges that already carry an edgeId — keyDoor replaces the
// a->b edge with a longer a->d->b chain, so splitting a paired edge
// would leave its backtrack's prereq.traversed dangling.
edge: [{ v: 'a', w: 'b',
label: { $and: [{ type: pathType }, { $not: { edgeId: '(.+)' } }] } }]
},
rhs: {
node: [
{ id: 'a' },
{ id: 'b' },
{ id: 'k', label: keyLabel },
{ id: 'd', label: doorLabel }
],
edge: [
{ v: 'a', w: 'k', label: branchEdgeLabel },
{ v: 'k', w: 'a', label: backtrackEdgeLabel },
{ v: 'a', w: 'd', label: { type: pathType, edgeId: idAD } },
{ v: 'd', w: 'a', label: doorFrontBacktrack },
{ v: 'd', w: 'b', label: lockedEdgeLabel },
{ v: 'b', w: 'd', label: doorBackBacktrack }
]
}
}, opts)
}
// Cycle-closing return edge: match a two-edge path a --path--> m --path--> b
// where a already has a key hanging off it (from a prior keyDoor
// application), and add a `return` edge b --> a. This turns tree-shaped
// dungeons into cyclic ones — the player walks the long way round, and the
// return becomes available after they've reached a.
//
// Unlike a `backtrack` (gated on traversing its paired forward edge), a
// `return` is gated on having *visited the destination node* — i.e. the
// player must have been at `a` at some point before they can take the
// return from `b`. This matches the Dormans cyclic-generation intent:
// you're not unwinding a specific corridor you came down, you're discovering
// a loop.
//
// The edge gets `type: return` (not `path` or `backtrack`), so the refine
// stage — which rewrites `path` edges into passage/monster/puzzle — leaves
// it alone. Add a refine rule for EDGE_RETURN if you want returns to be
// flavored as full corridors.
//
// Structurally requires an existing key at `a` so that the return has
// narrative motivation, even though the gating is destination-visit rather
// than key-pickup. `a` is also constrained to be a non-start room: if `a`
// were the start node, `prereq.visited: start` would be trivially true the
// whole game, which defeats the "you discovered a loop" purpose.
//
// Guarded by `condition` against re-closing an already-closed cycle.
// Intended to run AFTER keyDoor has populated keys and BEFORE refineEdges
// rewrites path edges.
function cycleCloseShortcut (opts) {
opts = opts || {}
const pathType = opts.pathType || EDGE_PATH
const returnType = opts.returnType || EDGE_RETURN
const keyType = opts.keyType || NODE_KEY
const startType = opts.startType || NODE_START
const winType = opts.winType || NODE_WIN
return withOpts({
name: 'cycle-close-return',
lhs: {
node: [
// Capture a's `nodeId` so the return's prereq can reference it,
// AND require a not to be the start node — otherwise the return is
// trivially always unlocked since the player starts on `a`.
{ id: 'a', label: { $and: [
{ nodeId: '(.+)' },
{ $not: { type: startType } }
] } },
{ id: 'm' },
// Do not make `win` a source of a return — the goal node must not
// have any outgoing edges. This $not guard also tolerates nodes
// without object labels (e.g. the transient raw START node).
{ id: 'b', label: { $not: { type: winType } } },
{ id: 'k', label: { $and: [{ type: keyType }, { pairId: '(pair_.*)' }] } }
],
// LHS edges get ids so RHS can preserve their labels (edgeIds etc.)
// unchanged — the shortcut only ADDS a b->a edge, it doesn't modify
// the existing structure.
edge: [
{ v: 'a', w: 'm', label: { type: pathType }, id: 'eam' },
{ v: 'm', w: 'b', label: { type: pathType }, id: 'emb' },
{ v: 'a', w: 'k', label: { type: pathType }, id: 'eak' }
]
},
// Do not fire if a direct b->a edge already exists (either as a shortcut
// we added previously, or as part of some other cycle). Without this, the
// rule would try to re-match on its own output and duplicate edges.
condition: '!$$graph.hasEdge($b.id, $a.id)',
rhs: {
node: [{ id: 'a' }, { id: 'm' }, { id: 'b' }, { id: 'k' }],
edge: [
'eam',
'emb',
'eak',
// New return edge: type=return, gated on having visited a.
// The dot label still advertises the paired key so the cyclic
// structure reads visually in rendered graphs.
{ v: 'b', w: 'a', label: {
type: returnType,
prereq: { visited: '${a.match.nodeId[1]}' },
dot: {
label: { $eval: '"return (" + $k.label.pairId + ")"' },
style: 'bold',
color: 'blue'
}
} }
]
}
}, opts)
}
// Rewrite the label of any `fromType` edge to be of `toType` (e.g.
// path -> passage / monster / puzzle). Useful for turning the neutral
// `path` substrate into flavored dungeon encounters. Other label fields
// (e.g. `prereq` on a locked door edge) are preserved via $assign.
function refineEdge (fromType, toType, opts) {
opts = opts || {}
return withOpts({
name: 'refine-' + fromType + '-to-' + toType,
lhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{ v: 'a', w: 'b', label: { type: fromType }, id: 'e' }]
},
rhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{
v: 'a',
w: 'b',
label: { $assign: [{ $eval: '$e.label' }, { type: toType }] }
}]
}
}, opts)
}
// Convenience: refine `fromType` edges into any of the supplied target
// types, one rule per target. Call as refineEdges('path', ['passage',
// 'monster', 'puzzle'], { weight: 1 }).
function refineEdges (fromType, toTypes, opts) {
return toTypes.map(function (t) { return refineEdge(fromType, t, opts) })
}
// --------------------------------------------------------------------
// Flavor primitives: monster battles, puzzle quizzes, health potions.
// --------------------------------------------------------------------
// Expand a `monster` edge into a small Markov battle. The battle is a
// bipartite graph alternating between **choice** nodes (where the player
// picks an outgoing edge) and **random** nodes (where the play engine
// rolls among outgoing edges weighted by `weight`). Consequence edges
// carry `playerDamage` and `monsterDamage` on a [0,1] scale; the engine
// subtracts from the appropriate HP. A lethal roll routes to a `death`
// node; a victorious roll routes to `b` (the original monster edge's
// destination). From any choice node the player can retreat back to `a`.
//
// Structure:
// a --path--> cNormal (enter battle)
// cNormal <-choice-> {rAttack, rDefend} (aggressive / defensive)
// cAdvantage <-choice-> {rAttack, rDefend} (press / hold)
// cNormal / cAdvantage --retreat--> a
// rAttack / rDefend --consequence--> {cNormal, cAdvantage, b, death}
//
// All damage / weight numbers are tuned for a demo and can be overridden
// via opts.tune — pass an object keyed by consequence-edge role.
function monsterBattle (opts) {
opts = opts || {}
const monsterType = opts.monsterType || EDGE_MONSTER
const pathType = opts.pathType || EDGE_PATH
const choiceType = opts.choiceType || EDGE_CHOICE
const consequenceType = opts.consequenceType || EDGE_CONSEQUENCE
const retreatType = opts.retreatType || EDGE_RETREAT
const choiceNodeType = opts.choiceNodeType || NODE_CHOICE
const randomNodeType = opts.randomNodeType || NODE_RANDOM
const deathNodeType = opts.deathNodeType || NODE_DEATH
const retreatStyle = { label: retreatType, style: 'dashed', color: 'orange' }
// Default consequence tuning. Keys match the `role` field we set on each
// consequence edge so users can override individual weights / damages.
const tune = opts.tune || {}
function c (role, defaults) {
const d = tune[role] || {}
return Object.assign({
type: consequenceType, role,
weight: defaults.weight,
playerDamage: defaults.playerDamage || 0,
monsterDamage: defaults.monsterDamage || 0
}, d)
}
// Victory consequences should inherit any edgeId the original monster
// edge carried (often left over from refine rewriting a paired path
// edge into `monster`). Otherwise the paired backtrack's
// prereq.traversed will never be satisfied — the player defeated the
// monster, but the graph's "you traversed the connection from a to b"
// flag was attached to an edge that no longer exists.
function vic (role, defaults) {
const base = c(role, defaults)
base.edgeId = { $eval: '$e.label.edgeId' }
return base
}
return withOpts({
name: 'monster-battle',
lhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{ v: 'a', w: 'b', label: { type: monsterType }, id: 'e' }]
},
rhs: {
node: [
{ id: 'a' }, { id: 'b' },
{ id: 'cN', label: {
type: choiceNodeType, state: 'normal',
nodeId: nodeIdExpr('battle_normal'),
dot: { label: 'battle (normal)', shape: 'box', color: 'orange' } } },
{ id: 'cA', label: {
type: choiceNodeType, state: 'advantage',
nodeId: nodeIdExpr('battle_advantage'),
dot: { label: 'battle (advantage)', shape: 'box', color: 'darkgreen' } } },
{ id: 'rA', label: {
type: randomNodeType, flavor: 'attack',
nodeId: nodeIdExpr('roll_attack'),
dot: { label: 'roll (attack)', shape: 'ellipse', color: 'purple' } } },
{ id: 'rD', label: {
type: randomNodeType, flavor: 'defend',
nodeId: nodeIdExpr('roll_defend'),
dot: { label: 'roll (defend)', shape: 'ellipse', color: 'purple' } } },
{ id: 'death', label: {
type: deathNodeType, nodeId: nodeIdExpr('death'),
dot: { label: 'you died', shape: 'doublecircle', color: 'red' } } }
],
edge: [
// Enter battle (replaces the original monster edge). The first
// edge of the expansion inherits the matched edge's edgeId and
// prereq. edgeId preserves any external backtrack keyed on it;
// prereq preserves any gating (e.g. a pairId-locked corridor
// that got refined to monster) so the gate isn't bypassable.
// $extend drops missing fields.
{ v: 'a', w: 'cN', label: {
$extend: [
{ type: pathType },
{ edgeId: { $eval: '$e.label.edgeId' },
prereq: { $eval: '$e.label.prereq' } }
]
} },
// Player choices at cNormal.
{ v: 'cN', w: 'rA', label: {
type: choiceType, flavor: 'aggressive', risk: 0.7,
dot: { label: 'attack', color: 'purple' } } },
{ v: 'cN', w: 'rD', label: {
type: choiceType, flavor: 'defensive', risk: 0.2,
dot: { label: 'defend', color: 'purple' } } },
{ v: 'cN', w: 'a', label: { type: retreatType, dot: retreatStyle } },
// Player choices at cAdvantage.
{ v: 'cA', w: 'rA', label: {
type: choiceType, flavor: 'press-attack', risk: 0.3,
dot: { label: 'press attack', color: 'purple' } } },
{ v: 'cA', w: 'rD', label: {
type: choiceType, flavor: 'hold-position', risk: 0.1,
dot: { label: 'hold position', color: 'purple' } } },
{ v: 'cA', w: 'a', label: { type: retreatType, dot: retreatStyle } },
// Consequences of an attack roll.
{ v: 'rA', w: 'cN', label: c('attack-stalemate', { weight: 0.4, playerDamage: 0.2, monsterDamage: 0.3 }) },
{ v: 'rA', w: 'cA', label: c('attack-advantage', { weight: 0.2, monsterDamage: 0.5 }) },
{ v: 'rA', w: 'b', label: vic('attack-victory', { weight: 0.3, monsterDamage: 1.0 }) },
{ v: 'rA', w: 'death', label: c('attack-death', { weight: 0.1, playerDamage: 1.0 }) },
// Consequences of a defend roll.
{ v: 'rD', w: 'cN', label: c('defend-stalemate', { weight: 0.5 }) },
{ v: 'rD', w: 'cA', label: c('defend-advantage', { weight: 0.3 }) },
{ v: 'rD', w: 'b', label: vic('defend-victory', { weight: 0.1, monsterDamage: 1.0 }) },
{ v: 'rD', w: 'death', label: c('defend-death', { weight: 0.1, playerDamage: 0.5 }) }
]
}
}, opts)
}
// Expand a `puzzle` edge into a multiple-choice quiz. The LHS matches
// a --puzzle--> b; the RHS adds:
// a -> puzzle_intro (path, forward into the quiz)
// puzzle_intro -> b (the one correct `choice` edge)
// puzzle_intro -> distractor_i (for each wrong answer)
// distractor_i -> a (auto-routes back to the puzzle source)
//
// The engine presents choices at `puzzle_intro`; picking `correct: true`
// advances to `b`, picking a distractor sends the player back to `a` to
// try again (each attempt costs a move). Score = moves to reach win.
function puzzleChoice (opts) {
opts = opts || {}
const puzzleType = opts.puzzleType || EDGE_PUZZLE
const pathType = opts.pathType || EDGE_PATH
const choiceType = opts.choiceType || EDGE_CHOICE
const introType = opts.introType || NODE_PUZZLE_INTRO
const distractorType = opts.distractorType || NODE_DISTRACTOR
const numDistractors = typeof opts.numDistractors === 'number' ? opts.numDistractors : 3
const rhsNodes = [
{ id: 'a' }, { id: 'b' },
{ id: 'p', label: {
type: introType, nodeId: nodeIdExpr('puzzle'),
dot: { label: 'puzzle', shape: 'diamond', color: 'gold' } } }
]
// If the matched puzzle edge carried an `edgeId` (e.g. because it was
// originally a paired forward edge of a deadEnd / keyDoor / midpoint
// that then got refined from `path` to `puzzle`), propagate that id
// onto the `correct` choice so the paired backtrack's
// prereq.traversed is still reachable once the player solves the
// puzzle. Without this the backtrack goes permanently dangling.
const rhsEdges = [
// First edge of the expansion inherits the matched puzzle edge's
// edgeId and prereq (same principle as monsterBattle / parallelPath
// / set-piece). $extend drops missing fields.
{ v: 'a', w: 'p', label: {
$extend: [
{ type: pathType },
{ edgeId: { $eval: '$e.label.edgeId' },
prereq: { $eval: '$e.label.prereq' } }
]
} },
{ v: 'p', w: 'b', label: {
type: choiceType, correct: true,
edgeId: { $eval: '$e.label.edgeId' },
dot: { label: 'correct', color: 'darkgreen' } } }
]
for (let i = 1; i <= numDistractors; i++) {
const did = 'd' + i
rhsNodes.push({ id: did, label: {
type: distractorType, nodeId: nodeIdExpr('distractor' + i),
dot: { label: 'wrong', shape: 'octagon', color: 'gray' } } })
rhsEdges.push({ v: 'p', w: did, label: {
type: choiceType, correct: false, index: i,
dot: { label: 'distractor ' + i, color: 'gray' } } })
rhsEdges.push({ v: did, w: 'a', label: {
type: pathType, dot: { label: 'back to puzzle', style: 'dotted', color: 'gray' } } })
}
return withOpts({
name: 'puzzle-choice',
lhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{ v: 'a', w: 'b', label: { type: puzzleType }, id: 'e' }]
},
rhs: { node: rhsNodes, edge: rhsEdges }
}, opts)
}
// Attach a health-potion pickup as a side-branch off a path edge's
// source, with a paired backtrack. Structurally identical to deadEnd
// but the attached node carries a `healValue` (default 0.3) so the
// play engine can restore that fraction of HP when the player visits.
function healthPotion (opts) {
opts = opts || {}
const pathType = opts.pathType || EDGE_PATH
const backtrackType = opts.backtrackType || EDGE_BACKTRACK
const potionType = opts.potionType || NODE_POTION
const healValue = typeof opts.healValue !== 'undefined' ? opts.healValue : 0.3
const idAP = edgeIdExpr('ap')
return withOpts({
name: 'health-potion',
lhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{
v: 'a', w: 'b',
label: { $and: [{ type: pathType }, { $not: { edgeId: '(.+)' } }] },
id: 'e'
}]
},
rhs: {
node: [
{ id: 'a' }, { id: 'b' },
{ id: 'p', label: {
type: potionType,
nodeId: nodeIdExpr('potion'),
healValue: healValue,
dot: { label: 'potion (+' + healValue + ')', shape: 'invtriangle', color: 'darkgreen' } } }
],
edge: [
'e',
{ v: 'a', w: 'p', label: { type: pathType, edgeId: idAP } },
{ v: 'p', w: 'a', label: {
type: backtrackType,
prereq: { traversed: idAP },
dot: { label: backtrackType, style: 'dashed', color: 'gray' } } }
]
}
}, opts)
}
// Everything-and-the-kitchen-sink default: returns the full list of
// dungeon primitives ready to drop into a grammar stage. Accepts
// per-factory overrides via opts.{midpointRoom,deadEnd,parallelPath,keyDoor,refinements}.
function defaultRules (opts) {
opts = opts || {}
const refineTargets = opts.refineTargets || [EDGE_PASSAGE, EDGE_MONSTER, EDGE_PUZZLE]
return [
midpointRoom(opts.midpointRoom || { weight: 1 }),
deadEnd(opts.deadEnd || { weight: 1 }),
parallelPath(opts.parallelPath || { weight: 1 }),
keyDoor(opts.keyDoor || { weight: 1 })
].concat(refineEdges(EDGE_PATH, refineTargets, opts.refinement || { weight: 1 }))
}
// A final stage of decoration rules that populate `label.dot.label` on
// every node and edge, from the `type` field. Drop this into a grammar's
// stage list (typically last) so `bin/transform.js -d` produces a DOT
// file whose nodes and edges actually render with readable captions.
function dotDecorationStage (opts) {
opts = opts || {}
return {
name: opts.name || 'decorate',
rules: [
{ name: 'decorate-node',
lhs: { node: [{ id: 'a', label: { $and: [{ type: '(.*)' }, { $not: { $test: '(l)=>l&&l.dot&&l.dot.label' } }] } }] },
rhs: { node: [{ id: 'a', update: { dot: { label: '${a.match.type[1]}' } } }] }
},
{ name: 'decorate-edge',
lhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{ v: 'a', w: 'b', label: { $and: [{ type: '(.*)' }, { $not: { $test: '(l)=>l&&l.dot&&l.dot.label' } }] }, id: 'e' }]
},
rhs: {
node: [{ id: 'a' }, { id: 'b' }],
edge: [{
v: 'a', w: 'b',
label: { $assign: [{ $eval: '$e.label' }, { dot: { label: '${e.match.type[1]}' } }] }
}]
}
}
]
}
}
// The canonical initial subgraph for a dunjs-style dungeon: a START
// node that expands once into start --path--> win. Use this as the
// `init` stage (with `limit: 1`) of a grammar whose later stages apply
// the primitives above.
function initStartGoalStage (opts) {
opts = opts || {}
const startType = opts.startType || NODE_START
const winType = opts.winType || NODE_WIN
const pathType = opts.pathType || EDGE_PATH
const startLabel = opts.startLabel || 'START'
return {
name: opts.name || 'init',
limit: 1,
rules: [{
name: 'spawn-start-goal',
lhs: startLabel,
rhs: {
// `nodeId` is hardcoded for the two singleton init nodes so that
// downstream rules can reference them via `prereq.visited: 'start'`
// or similar without worrying about iter-derived collisions.
node: [
{ id: 's', label: { type: startType, nodeId: 'start' } },
{ id: 'g', label: { type: winType, nodeId: 'win' } }
],
edge: [{ v: 's', w: 'g', label: { type: pathType } }]
}
}]
}
}
module.exports = {
midpointRoom,
deadEnd,
parallelPath,
keyDoor,
cycleCloseShortcut,
healthPotion,
monsterBattle,
puzzleChoice,
refineEdge,
refineEdges,
defaultRules,
initStartGoalStage,
dotDecorationStage,
// type constants
EDGE_PATH,
EDGE_BACKTRACK,
EDGE_RETURN,
EDGE_PASSAGE,
EDGE_MONSTER,
EDGE_PUZZLE,
EDGE_CHOICE,
EDGE_CONSEQUENCE,
EDGE_RETREAT,
NODE_START,
NODE_WIN,
NODE_ROOM,
NODE_DEAD_END,
NODE_KEY,
NODE_DOOR,
NODE_CHOICE,
NODE_RANDOM,
NODE_DEATH,
NODE_PUZZLE_INTRO,
NODE_DISTRACTOR,
NODE_POTION
}