Skip to content

Commit 95b8ed3

Browse files
claudemhanavan4-prog
authored andcommitted
The Knight's third episode at Ashminster, and the anchoress's work for everyone
Chapters 10-12 of The Hollow Oath: find the Order's mother-house in the Ember Wastes, hear from the anchoress what the chronicle cut out (the Order begged the witches it had hunted for help, and they came), and lay fifteen of the field's dead to rest. Dame Isolt also gives two quests to any class: Lay the Field (repeatable) and The Long Watch (once; title Keeper of the Frith). The journal points to the temple portal with written directions, since the waypoint cannot lead into the Wastes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014upby61fMmizgg2cMkKvqW
1 parent 547eee3 commit 95b8ed3

10 files changed

Lines changed: 133 additions & 11 deletions

File tree

‎client/main.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3144,6 +3144,7 @@ function onWsMessage(ev) {
31443144
if (msg.type === 'reel_state') { setReelState(msg); return; }
31453145
if (msg.type === 'wanderer_record') { wandererRecord = msg.slain || {}; return; }
31463146
if (msg.type === 'wayside_state') { waysideDone = new Set(msg.done || []); return; }
3147+
if (msg.type === 'ash_quest') { openQuestDialogue(msg.npcId, msg.npcName); return; } // the anchoress, once she has said her piece (lib/ashminster.js)
31473148
if (msg.type === 'ash_state') { ashSanctuaryUntil = msg.sanctuaryUntil || 0; return; }
31483149
if (msg.type === 'village_ward') { wardState = msg; applyVillageWard(msg); return; }
31493150
if (msg.type === 'candle_state') { setCorpseCandle(msg); return; }

‎data/quests.js‎

Lines changed: 34 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎data/titles.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ const TITLES = {
4444
desc: 'Brought in the last sheaves and released what could not last the dark.',
4545
from: 'The Reckoning Harvest — Mabon'
4646
},
47+
frith_keeper: {
48+
id: 'frith_keeper', name: 'Keeper of the Frith', glyph: '🕯️',
49+
desc: 'Laid twenty-five of the field\'s dead to rest for the anchoress of Ashminster, and had their name cut under the tally in her cell.',
50+
from: 'The Long Watch — Ashminster, in the Ember Wastes'
51+
},
4752
ember_warden: {
4853
id: 'ember_warden', name: 'Warden of the Ember Wastes', glyph: '🔥',
4954
desc: 'Proved themselves in open blood out on the ash flats, and walked back wearing the Wastes like a crown.',

‎lib/ashminster.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ module.exports = function createAshminster({ send, now = () => Date.now(), onArr
4545
if (n === 0) onSpoke(player);
4646
return true;
4747
}
48-
player.isoltHeard = 0; // she begins again another time
49-
board(player, `🕯️ ${ANCHORESS.name}`, 'In the record', [...ANCHORESS.lore, `Source: ${ANCHORESS.source}`, ANCHORESS.note]);
48+
// her piece said: once, what an anchoress was; and after that, what she would have done
49+
if (n === ANCHORESS.says.length) { player.isoltHeard = n + 1; board(player, `🕯️ ${ANCHORESS.name}`, 'In the record', [...ANCHORESS.lore, `Source: ${ANCHORESS.source}`, ANCHORESS.note]); return false; }
50+
send(player.ws, { type: 'ash_quest', npcId: 'npc_anchoress', npcName: '🕯️ Dame Isolt' });
5051
return false;
5152
}
5253
if (id === 'marks') { board(player, '✴️ The marks at the breach', 'In the record', [...WITCH_MARKS.lore, `Source: ${WITCH_MARKS.source}`, WITCH_MARKS.note]); return false; }

‎server.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6455,7 +6455,8 @@ const NPC_WHEREABOUTS = {
64556455
npc_noble: '👻 the Phantom Parlor (Town Pass building)',
64566456
npc_knight: '🏛️ Town Hall — Sir Dorran on guard duty',
64576457
npc_guard: '🏛️ Town Hall — Guard Petra by the door',
6458-
npc_warden: '🔥 the Ember Wastes — Warden Kol at the refuge haven'
6458+
npc_warden: '🔥 the Ember Wastes — Warden Kol at the refuge haven',
6459+
npc_anchoress: '⛪ Ashminster, in the middle of the Ember Wastes — Dame Isolt, at the squint inside the minster'
64596460
};
64606461
// Which room each named NPC stands in — 'outside' is the town square. Lets the
64616462
// Journal's waypoint say "this person, in this building" so the client can aim
@@ -6480,6 +6481,9 @@ const NPC_ROOM = {
64806481
// text instead.
64816482
function objectiveGoal(obj) {
64826483
if (!obj) return null;
6484+
// Ashminster is out in the Ember Wastes, where the waypoint cannot lead: it points at the town, where the
6485+
// temple portal is, and the objective's own words say the rest. (A wrong arrow is worse than none.)
6486+
if (obj.room === 'ashminster' || obj.npcId === 'npc_anchoress') return { kind: 'room', room: 'outside' };
64836487
if (obj.type === 'talk_npc') return { kind: 'npc', npcId: obj.npcId, room: NPC_ROOM[obj.npcId] || null };
64846488
if (obj.type === 'visit_room') return { kind: 'room', room: obj.room };
64856489
if (obj.type === 'harvest_plant' || obj.type === 'harvest_specific') return { kind: 'room', room: 'wilds' };

‎test/ashminster-story.test.js‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// The Knight's third episode, at Ashminster in the Ember Wastes, and the
2+
// anchoress's two quests, against the real server.
3+
process.env.PORT = '0';
4+
process.env.DATA_DIR = require('fs').mkdtempSync(require('os').tmpdir() + '/tc-ashstory-test-');
5+
let pass = 0, fail = 0;
6+
function check(name, cond, extra) { if (cond) { pass++; console.log('PASS -', name); } else { fail++; console.log('FAIL -', name, extra != null ? `(${JSON.stringify(extra)})` : ''); } }
7+
function makeMockSocket(label) {
8+
return { label, OPEN: 1, readyState: 1, sent: [], _handlers: {},
9+
on(event, cb) { this._handlers[event] = cb; }, send(data) { this.sent.push(JSON.parse(data)); },
10+
emit(event, ...args) { if (this._handlers[event]) this._handlers[event](...args); },
11+
lastOfType(type) { for (let i = this.sent.length - 1; i >= 0; i--) if (this.sent[i].type === type) return this.sent[i]; return null; } };
12+
}
13+
require('../server.js');
14+
const { STORYLINES, QUEST_CATALOG } = require('../data/quests');
15+
const { TITLES } = require('../data/titles');
16+
const { EMBER_TOWN: T, ASH_SPOTS, ANCHORESS } = require('../data/emberTown');
17+
18+
const K = STORYLINES[3].chapters;
19+
check('the Knight has twelve chapters; the other classes keep their nine', K.length === 12 && [0, 1, 2, 4, 5].every((c) => STORYLINES[c].chapters.length === 9));
20+
check('the third episode: find Ashminster, speak at the squint, lay fifteen to rest', K[9].objective.type === 'visit_room' && K[9].objective.room === 'ashminster' && K[10].objective.type === 'talk_npc' && K[10].objective.npcId === 'npc_anchoress' && K[11].objective.type === 'kill_mob' && K[11].objective.target === 15);
21+
check('each new chapter has its telling before and after, its level, and its pay', K.slice(9).every((c) => c.intro.length > 150 && c.outro.length > 150 && c.requiresLevel === 17 && c.xpReward > 0 && c.goldReward > 0 && c.title));
22+
check('it keeps to the story the first two episodes told: the Order, the witches it had hunted, Hazel, and the page cut out', /leaf was cut out/.test(K[9].intro) && /five women it had been hunting/.test(K[10].outro) && /Hazel cut the one over the door/.test(K[10].outro) && /we were not ashamed of you/.test(K[10].outro) && /daisy wheel/.test(K[11].outro));
23+
check('the anchoress says the same thing to everyone at her squint', /stopped, here, at this wall/.test(ANCHORESS.says[2]) && /five women the Order had been hunting/.test(ANCHORESS.says[2]));
24+
const f = QUEST_CATALOG.isolt_field, w = QUEST_CATALOG.isolt_watch;
25+
check('her two quests: one to do again, one long one done once that follows it and gives a title that exists', f && w && f.npcId === 'npc_anchoress' && !f.oneTime && f.type === 'kill_mob' && f.target === 10 && w.oneTime === true && w.requiresQuestDone === 'isolt_field' && w.target === 25 && w.grantsTitle === 'frith_keeper' && TITLES.frith_keeper && /Ashminster/.test(TITLES.frith_keeper.from));
26+
27+
setTimeout(() => {
28+
const h = global.__testHooks;
29+
const wss = global.__wssInstances[0];
30+
const connHandler = (wss._handlers && wss._handlers.connection) || wss.listeners('connection')[0];
31+
function join(tag, charId) {
32+
const s = makeMockSocket(tag); connHandler(s);
33+
s.emit('message', JSON.stringify({ type: 'join', name: tag, charId }));
34+
const p = h.players.get(s.lastOfType('init').id);
35+
p.accountKey = tag.toLowerCase(); s._isGuest = false;
36+
const prog = h.getProgress(p); prog.level = 18; if (!prog.story) prog.story = {};
37+
return { s, p };
38+
}
39+
const setChapter = (p, idx) => { h.getProgress(p).story[p.charId] = { chapter: idx, progress: 0, active: true }; };
40+
const chapterOf = (p) => h.getProgress(p).story[p.charId].chapter;
41+
42+
const A = join('Oathkeeper', 3);
43+
setChapter(A.p, 9);
44+
Object.assign(A.p, { room: 'ember_wastes', isDead: false, x: 10000, y: 18600 });
45+
h.ashminster.tick([A.p]);
46+
check('standing at the arrival portal is not finding Ashminster', chapterOf(A.p) === 9);
47+
Object.assign(A.p, { x: T.x, y: T.y + 1900 }); h.ashminster.tick([A.p]);
48+
check('walking into the town completes "The Mother-House"', chapterOf(A.p) === 10, chapterOf(A.p));
49+
check('the next chapter waits to be taken up, as every chapter does', h.getProgress(A.p).story[3].active === false);
50+
setChapter(A.p, 10); // she takes it up in her Journal
51+
Object.assign(A.p, { x: ASH_SPOTS.anchoress.x + 20, y: ASH_SPOTS.anchoress.y + 30 });
52+
A.s.emit('message', JSON.stringify({ type: 'ash_use', id: 'anchoress' }));
53+
check('speaking at the squint completes "The Ninth Anchoress"', chapterOf(A.p) === 11, chapterOf(A.p));
54+
setChapter(A.p, 11);
55+
for (let i = 0; i < 14; i++) h.storyEvent(A.p, 'kill_mob', { pool: 'ember_mob', mobType: 'ash_wraith' });
56+
check('fourteen is not fifteen', chapterOf(A.p) === 11 && h.getProgress(A.p).story[3].progress === 14);
57+
A.s.sent.length = 0;
58+
h.storyEvent(A.p, 'kill_mob', { pool: 'ember_mob', mobType: 'ash_wraith' });
59+
const done = A.s.lastOfType('story_chapter_complete');
60+
check('the fifteenth completes the episode, and with it the Knight\'s whole story', chapterOf(A.p) === 12 && done && done.storyComplete === true, done && done.storyComplete);
61+
62+
// a Witch who walks the same road has her own story untouched by it, and is still given the anchoress's work
63+
const B = join('Hedgewitch', 0);
64+
setChapter(B.p, 3);
65+
Object.assign(B.p, { room: 'ember_wastes', isDead: false, x: T.x, y: T.y + 1900 }); h.ashminster.tick([B.p]);
66+
Object.assign(B.p, { x: ASH_SPOTS.anchoress.x + 20, y: ASH_SPOTS.anchoress.y + 30 });
67+
for (let i = 0; i < ANCHORESS.says.length + 2; i++) B.s.emit('message', JSON.stringify({ type: 'ash_use', id: 'anchoress' }));
68+
check('another class is not moved on in her own story by any of it', chapterOf(B.p) === 3);
69+
check('and after the anchoress has said her piece she is offered work', B.s.lastOfType('ash_quest') && B.s.lastOfType('ash_quest').npcId === 'npc_anchoress');
70+
B.s.sent.length = 0; B.s.emit('message', JSON.stringify({ type: 'quest_talk', npcId: 'npc_anchoress' }));
71+
const offer = B.s.sent.find((m) => /quest/.test(m.type));
72+
check('the quest she is offered is "Lay the Field"', offer && JSON.stringify(offer).includes('Lay the Field'), offer && offer.type);
73+
console.log(`\n${pass} passed, ${fail} failed`);
74+
process.exit(fail ? 1 : 0);
75+
}, 400);

‎test/ashminster.test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ at('anchoress'); sent.length = 0;
4545
const heard = []; for (let i = 0; i < ANCHORESS.says.length; i++) { ash.use(p, 'anchoress'); heard.push(sent[sent.length - 1].message); }
4646
check('the anchoress says her piece a line at a time, and the first word of it is marked for the story', heard.length === 4 && heard.every((m, i) => m.includes(ANCHORESS.says[i])) && spoke.join() === 'p');
4747
sent.length = 0;
48-
check('then pressing again reads of anchoresses, and she will begin again another time', ash.use(p, 'anchoress') === false && /Dame Isolt/.test(board().title) && board().lines.some((l) => /Julian of Norwich/.test(l)) && (ash.use(p, 'anchoress'), sent[sent.length - 1].message.includes(ANCHORESS.says[0])) && spoke.length === 1 + 1);
48+
check('then pressing again reads of anchoresses, once; and after that she has work to give', ash.use(p, 'anchoress') === false && /Dame Isolt/.test(board().title) && board().lines.some((l) => /Julian of Norwich/.test(l)) && (ash.use(p, 'anchoress'), sent[sent.length - 1].type === 'ash_quest' && sent[sent.length - 1].npcId === 'npc_anchoress') && (ash.use(p, 'anchoress'), sent[sent.length - 1].type === 'ash_quest') && spoke.length === 1);
4949
at('marks'); sent.length = 0; ash.use(p, 'marks');
5050
check('the marks are read at the breach', /marks at the breach/.test(board().title) && board().lines.some((l) => /daisy wheel/.test(l)));
5151
at('frith'); sent.length = 0; ash.use(p, 'frith');

0 commit comments

Comments
 (0)