1use pest::Parser;
7use pest_derive::Parser as PestParser;
8
9use crate::{
10 ActionAst, ActionStmt, ConditionAst, ConsumableAst, ConsumableWhenAst, ContainerStateAst, GoalAst, GoalCondAst,
11 GoalGroupAst, IngestModeAst, ItemAbilityAst, ItemAst, ItemLocationAst, ItemPatchAst, NpcAst, NpcDialoguePatchAst,
12 NpcMovementAst, NpcMovementPatchAst, NpcMovementTypeAst, NpcPatchAst, NpcStateValue, NpcTimingPatchAst, OnFalseAst,
13 RoomAst, RoomExitPatchAst, RoomPatchAst, SpinnerAst, SpinnerWedgeAst, TriggerAst,
14};
15use std::{borrow::Cow, collections::HashMap};
16
17#[derive(PestParser)]
18#[grammar = "src/grammar.pest"]
19struct DslParser;
20
21#[derive(Debug, thiserror::Error)]
23pub enum AstError {
24 #[error("parse error: {0}")]
25 Pest(String),
26 #[error("unexpected grammar shape: {0}")]
27 Shape(&'static str),
28 #[error("unexpected grammar shape: {msg} ({context})")]
29 ShapeAt { msg: &'static str, context: String },
30}
31
32pub fn parse_trigger(source: &str) -> Result<TriggerAst, AstError> {
37 let v = parse_program(source)?;
38 v.into_iter().next().ok_or(AstError::Shape("no trigger found"))
39}
40
41pub fn parse_program(source: &str) -> Result<Vec<TriggerAst>, AstError> {
46 let (triggers, ..) = parse_program_full(source)?;
47 Ok(triggers)
48}
49
50pub fn parse_program_full(source: &str) -> Result<ProgramAstBundle, AstError> {
56 let mut pairs = DslParser::parse(Rule::program, source).map_err(|e| AstError::Pest(e.to_string()))?;
57 let pair = pairs.next().ok_or(AstError::Shape("expected program"))?;
58 let smap = SourceMap::new(source);
59 let mut sets: HashMap<String, Vec<String>> = HashMap::new();
60 let mut trigger_pairs = Vec::new();
61 let mut room_pairs = Vec::new();
62 let mut item_pairs = Vec::new();
63 let mut spinner_pairs = Vec::new();
64 let mut npc_pairs = Vec::new();
65 let mut goal_pairs = Vec::new();
66 for item in pair.clone().into_inner() {
67 match item.as_rule() {
68 Rule::set_decl => {
69 let mut it = item.into_inner();
70 let name = it.next().expect("set name").as_str().to_string();
71 let list_pair = it.next().expect("set list");
72 let mut vals = Vec::new();
73 for p in list_pair.into_inner() {
74 if p.as_rule() == Rule::ident {
75 vals.push(p.as_str().to_string());
76 }
77 }
78 sets.insert(name, vals);
79 },
80 Rule::trigger => {
81 trigger_pairs.push(item);
82 },
83 Rule::room_def => {
84 room_pairs.push(item);
85 },
86 Rule::item_def => {
87 item_pairs.push(item);
88 },
89 Rule::spinner_def => {
90 spinner_pairs.push(item);
91 },
92 Rule::npc_def => {
93 npc_pairs.push(item);
94 },
95 Rule::goal_def => {
96 goal_pairs.push(item);
97 },
98 _ => {},
99 }
100 }
101 let mut out = Vec::new();
102 for trig in trigger_pairs {
103 let mut ts = parse_trigger_pair(trig, source, &smap, &sets)?;
104 out.append(&mut ts);
105 }
106 let mut rooms = Vec::new();
107 for rp in room_pairs {
108 let r = parse_room_pair(rp, source)?;
109 rooms.push(r);
110 }
111 let mut items = Vec::new();
112 for ip in item_pairs {
113 let it = parse_item_pair(ip, source)?;
114 items.push(it);
115 }
116 let mut spinners = Vec::new();
117 for sp in spinner_pairs {
118 let s = parse_spinner_pair(sp, source)?;
119 spinners.push(s);
120 }
121 let mut npcs = Vec::new();
122 for np in npc_pairs {
123 let n = parse_npc_pair(np, source)?;
124 npcs.push(n);
125 }
126 let mut goals = Vec::new();
127 for gp in goal_pairs {
128 let g = parse_goal_pair(gp, source)?;
129 goals.push(g);
130 }
131 Ok((out, rooms, items, spinners, npcs, goals))
132}
133
134fn parse_trigger_pair(
135 trig: pest::iterators::Pair<Rule>,
136 source: &str,
137 smap: &SourceMap,
138 sets: &HashMap<String, Vec<String>>,
139) -> Result<Vec<TriggerAst>, AstError> {
140 let src_line = trig.as_span().start_pos().line_col().0;
141 let mut it = trig.into_inner();
142
143 let q = it.next().ok_or(AstError::Shape("expected trigger name"))?;
145 if q.as_rule() != Rule::string {
146 return Err(AstError::Shape("expected string trigger name"));
147 }
148 let name = unquote(q.as_str());
149
150 let mut only_once = false;
152 let mut trig_note: Option<String> = None;
153 let mut next_pair = it.next().ok_or(AstError::Shape("expected when/only once/note"))?;
154 loop {
155 match next_pair.as_rule() {
156 Rule::only_once_kw => {
157 only_once = true;
158 },
159 Rule::note_kw => {
160 let mut inner = next_pair.into_inner();
161 let s = inner.next().ok_or(AstError::Shape("missing note string"))?;
162 trig_note = Some(unquote(s.as_str()));
163 },
164 _ => break,
165 }
166 next_pair = it.next().ok_or(AstError::Shape("expected when or more modifiers"))?;
167 }
168 let mut when = next_pair;
169 if when.as_rule() == Rule::when_cond {
170 when = when.into_inner().next().ok_or(AstError::Shape("empty when_cond"))?;
171 }
172 let event = match when.as_rule() {
173 Rule::always_event => ConditionAst::Always,
174 Rule::enter_room => {
175 let mut i = when.into_inner();
176 let ident = i
177 .next()
178 .ok_or(AstError::Shape("enter room ident"))?
179 .as_str()
180 .to_string();
181 ConditionAst::EnterRoom(ident)
182 },
183 Rule::take_item => {
184 let mut i = when.into_inner();
185 let ident = i.next().ok_or(AstError::Shape("take item ident"))?.as_str().to_string();
186 ConditionAst::TakeItem(ident)
187 },
188 Rule::touch_item => {
189 let mut i = when.into_inner();
190 let ident = i
191 .next()
192 .ok_or(AstError::Shape("touch item ident"))?
193 .as_str()
194 .to_string();
195 ConditionAst::TouchItem(ident)
196 },
197 Rule::talk_to_npc => {
198 let mut i = when.into_inner();
199 let ident = i.next().ok_or(AstError::Shape("talk npc ident"))?.as_str().to_string();
200 ConditionAst::TalkToNpc(ident)
201 },
202 Rule::open_item => {
203 let mut i = when.into_inner();
204 let ident = i.next().ok_or(AstError::Shape("open item ident"))?.as_str().to_string();
205 ConditionAst::OpenItem(ident)
206 },
207 Rule::leave_room => {
208 let mut i = when.into_inner();
209 let ident = i
210 .next()
211 .ok_or(AstError::Shape("leave room ident"))?
212 .as_str()
213 .to_string();
214 ConditionAst::LeaveRoom(ident)
215 },
216 Rule::look_at_item => {
217 let mut i = when.into_inner();
218 let ident = i
219 .next()
220 .ok_or(AstError::Shape("look at item ident"))?
221 .as_str()
222 .to_string();
223 ConditionAst::LookAtItem(ident)
224 },
225 Rule::use_item => {
226 let mut i = when.into_inner();
227 let item = i.next().ok_or(AstError::Shape("use item ident"))?.as_str().to_string();
228 let ability = i
229 .next()
230 .ok_or(AstError::Shape("use item ability"))?
231 .as_str()
232 .to_string();
233 ConditionAst::UseItem { item, ability }
234 },
235 Rule::give_to_npc => {
236 let mut i = when.into_inner();
237 let item = i.next().ok_or(AstError::Shape("give item ident"))?.as_str().to_string();
238 let npc = i
239 .next()
240 .ok_or(AstError::Shape("give to npc ident"))?
241 .as_str()
242 .to_string();
243 ConditionAst::GiveToNpc { item, npc }
244 },
245 Rule::use_item_on_item => {
246 let mut i = when.into_inner();
247 let tool = i.next().ok_or(AstError::Shape("use tool ident"))?.as_str().to_string();
248 let target = i
249 .next()
250 .ok_or(AstError::Shape("use target ident"))?
251 .as_str()
252 .to_string();
253 let interaction = i
254 .next()
255 .ok_or(AstError::Shape("use interaction ident"))?
256 .as_str()
257 .to_string();
258 ConditionAst::UseItemOnItem {
259 tool,
260 target,
261 interaction,
262 }
263 },
264 Rule::ingest_item => {
265 let mut i = when.into_inner();
266 let mode_pair = i.next().ok_or(AstError::Shape("ingest mode"))?;
267 let mode = match mode_pair.as_str() {
268 "eat" => IngestModeAst::Eat,
269 "drink" => IngestModeAst::Drink,
270 "inhale" => IngestModeAst::Inhale,
271 other => {
272 return Err(AstError::ShapeAt {
273 msg: "unsupported ingest mode",
274 context: other.to_string(),
275 });
276 },
277 };
278 let item = i
279 .next()
280 .ok_or(AstError::Shape("ingest item ident"))?
281 .as_str()
282 .to_string();
283 ConditionAst::Ingest { item, mode }
284 },
285 Rule::act_on_item => {
286 let mut i = when.into_inner();
287 let action = i
288 .next()
289 .ok_or(AstError::Shape("act interaction ident"))?
290 .as_str()
291 .to_string();
292 let target = i
293 .next()
294 .ok_or(AstError::Shape("act target ident"))?
295 .as_str()
296 .to_string();
297 ConditionAst::ActOnItem { target, action }
298 },
299 Rule::take_from_npc => {
300 let mut i = when.into_inner();
301 let item = i
302 .next()
303 .ok_or(AstError::Shape("take-from item ident"))?
304 .as_str()
305 .to_string();
306 let npc = i
307 .next()
308 .ok_or(AstError::Shape("take-from npc ident"))?
309 .as_str()
310 .to_string();
311 ConditionAst::TakeFromNpc { item, npc }
312 },
313 Rule::insert_item_into => {
314 let mut i = when.into_inner();
315 let item = i
316 .next()
317 .ok_or(AstError::Shape("insert item ident"))?
318 .as_str()
319 .to_string();
320 let container = i
321 .next()
322 .ok_or(AstError::Shape("insert into container ident"))?
323 .as_str()
324 .to_string();
325 ConditionAst::InsertItemInto { item, container }
326 },
327 Rule::drop_item => {
328 let mut i = when.into_inner();
329 let ident = i.next().ok_or(AstError::Shape("drop item ident"))?.as_str().to_string();
330 ConditionAst::DropItem(ident)
331 },
332 Rule::unlock_item => {
333 let mut i = when.into_inner();
334 let ident = i
335 .next()
336 .ok_or(AstError::Shape("unlock item ident"))?
337 .as_str()
338 .to_string();
339 ConditionAst::UnlockItem(ident)
340 },
341 _ => return Err(AstError::Shape("unknown when condition")),
342 };
343
344 let block = it.next().ok_or(AstError::Shape("expected block"))?;
345 if block.as_rule() != Rule::block {
346 return Err(AstError::Shape("expected block"));
347 }
348
349 let inner = extract_body(block.as_str())?;
353 let mut unconditional_actions: Vec<ActionStmt> = Vec::new();
354 let mut lowered: Vec<TriggerAst> = Vec::new();
355 let bytes = inner.as_bytes();
356 let mut i = 0usize;
357 while i < inner.len() {
358 while i < inner.len() && (bytes[i] as char).is_whitespace() {
360 i += 1;
361 }
362 if i >= inner.len() {
363 break;
364 }
365 if bytes[i] as char == '#' {
367 while i < inner.len() && (bytes[i] as char) != '\n' {
368 i += 1;
369 }
370 continue;
371 }
372 if inner[i..].starts_with("if ") {
374 let if_pos = i;
375 let rest = &inner[if_pos + 3..];
377 let brace_rel = rest.find('{').ok_or(AstError::Shape("missing '{' after if"))?;
378 let cond_text = &rest[..brace_rel].trim();
379 let cond = match parse_condition_text(cond_text, sets) {
380 Ok(c) => c,
381 Err(AstError::Shape(m)) => {
382 let base_offset = str_offset(source, inner);
383 let cond_abs = base_offset + (cond_text.as_ptr() as usize - inner.as_ptr() as usize);
384 let (line, col) = smap.line_col(cond_abs);
385 let snippet = smap.line_snippet(line);
386 return Err(AstError::ShapeAt {
387 msg: m,
388 context: format!(
389 "line {line}, col {col}: {snippet}\n{}^",
390 " ".repeat(col.saturating_sub(1))
391 ),
392 });
393 },
394 Err(e) => return Err(e),
395 };
396 let block_after = &rest[brace_rel..]; let body = extract_body(block_after)?;
399 let actions = parse_actions_from_body(body, source, smap, sets)?;
400 lowered.push(TriggerAst {
401 name: name.clone(),
402 note: None,
403 src_line,
404 event: event.clone(),
405 conditions: vec![cond],
406 actions,
407 only_once,
408 });
409 let consumed = brace_rel + 1 + body.len() + 1; i = if_pos + 3 + consumed;
412 continue;
413 }
414 let remainder = &inner[i..];
415 match parse_modify_item_action(remainder) {
416 Ok((action, used)) => {
417 unconditional_actions.push(action);
418 i += used;
419 continue;
420 },
421 Err(AstError::Shape("not a modify item action")) => {},
422 Err(AstError::Shape(m)) => {
423 let base = str_offset(source, inner);
424 let abs = base + i;
425 let (line_no, col) = smap.line_col(abs);
426 let snippet = smap.line_snippet(line_no);
427 return Err(AstError::ShapeAt {
428 msg: m,
429 context: format!(
430 "line {line_no}, col {col}: {snippet}\n{}^",
431 " ".repeat(col.saturating_sub(1))
432 ),
433 });
434 },
435 Err(e) => return Err(e),
436 }
437 match parse_modify_room_action(remainder) {
438 Ok((action, used)) => {
439 unconditional_actions.push(action);
440 i += used;
441 continue;
442 },
443 Err(AstError::Shape("not a modify room action")) => {},
444 Err(AstError::Shape(m)) => {
445 let base = str_offset(source, inner);
446 let abs = base + i;
447 let (line_no, col) = smap.line_col(abs);
448 let snippet = smap.line_snippet(line_no);
449 return Err(AstError::ShapeAt {
450 msg: m,
451 context: format!(
452 "line {line_no}, col {col}: {snippet}\n{}^",
453 " ".repeat(col.saturating_sub(1))
454 ),
455 });
456 },
457 Err(e) => return Err(e),
458 }
459
460 match parse_modify_npc_action(remainder) {
461 Ok((action, used)) => {
462 unconditional_actions.push(action);
463 i += used;
464 continue;
465 },
466 Err(AstError::Shape("not a modify npc action")) => {},
467 Err(AstError::Shape(m)) => {
468 let base = str_offset(source, inner);
469 let abs = base + i;
470 let (line_no, col) = smap.line_col(abs);
471 let snippet = smap.line_snippet(line_no);
472 return Err(AstError::ShapeAt {
473 msg: m,
474 context: format!(
475 "line {line_no}, col {col}: {snippet}\n{}^",
476 " ".repeat(col.saturating_sub(1))
477 ),
478 });
479 },
480 Err(e) => return Err(e),
481 }
482 match parse_schedule_action(remainder, source, smap, sets) {
484 Ok((action, used)) => {
485 unconditional_actions.push(action);
486 i += used;
487 continue;
488 },
489 Err(AstError::Shape("not a schedule action")) => {},
490 Err(e) => return Err(e),
491 }
492 if remainder.starts_with("do ") {
493 let mut j = i;
495 while j < inner.len() && (bytes[j] as char) != '\n' {
496 j += 1;
497 }
498 let line = inner[i..j].trim_end();
499 match parse_action_from_str(line) {
500 Ok(a) => unconditional_actions.push(a),
501 Err(AstError::Shape(m)) => {
502 let base = str_offset(source, inner);
503 let abs = base + i;
504 let (line_no, col) = smap.line_col(abs);
505 let snippet = smap.line_snippet(line_no);
506 return Err(AstError::ShapeAt {
507 msg: m,
508 context: format!(
509 "line {line_no}, col {col}: {snippet}\n{}^",
510 " ".repeat(col.saturating_sub(1))
511 ),
512 });
513 },
514 Err(e) => return Err(e),
515 }
516 i = j;
517 continue;
518 }
519 while i < inner.len() && (bytes[i] as char) != '\n' {
521 i += 1;
522 }
523 }
524 if !unconditional_actions.is_empty() {
525 lowered.push(TriggerAst {
526 name,
527 note: trig_note.clone(),
528 src_line,
529 event,
530 conditions: Vec::new(),
531 actions: unconditional_actions,
532 only_once,
533 });
534 }
535 for t in &mut lowered {
537 if t.note.is_none() {
538 t.note = trig_note.clone();
539 }
540 }
541 Ok(lowered)
542}
543
544fn parse_room_pair(room: pest::iterators::Pair<Rule>, _source: &str) -> Result<RoomAst, AstError> {
545 let (src_line, _src_col) = room.as_span().start_pos().line_col();
547 let mut it = room.into_inner();
548 let id = it
551 .next()
552 .ok_or(AstError::Shape("expected room ident"))?
553 .as_str()
554 .to_string();
555 let block = it.next().ok_or(AstError::Shape("expected room block"))?;
556 if block.as_rule() != Rule::room_block {
557 return Err(AstError::Shape("expected room block"));
558 }
559 let mut name: Option<String> = None;
560 let mut desc: Option<String> = None;
561 let mut visited: Option<bool> = None;
562 let mut exits: Vec<(String, crate::ExitAst)> = Vec::new();
563 let mut overlays: Vec<crate::OverlayAst> = Vec::new();
564 for stmt in block.into_inner() {
565 let inner_stmt = {
567 let mut it = stmt.clone().into_inner();
568 if let Some(p) = it.next() { p } else { stmt.clone() }
569 };
570 match inner_stmt.as_rule() {
571 Rule::room_name => {
572 let s = inner_stmt
573 .into_inner()
574 .next()
575 .ok_or(AstError::Shape("missing room name string"))?;
576 name = Some(unquote(s.as_str()));
577 },
578 Rule::room_desc => {
579 let s = inner_stmt
580 .into_inner()
581 .next()
582 .ok_or(AstError::Shape("missing room desc string"))?;
583 desc = Some(unquote(s.as_str()));
584 },
585 Rule::room_visited => {
586 let tok = inner_stmt
587 .into_inner()
588 .next()
589 .ok_or(AstError::Shape("missing visited token"))?;
590 let val = match tok.as_str() {
591 "true" => true,
592 "false" => false,
593 _ => return Err(AstError::Shape("visited must be true or false")),
594 };
595 visited = Some(val);
596 },
597 Rule::exit_stmt => {
598 let mut it = inner_stmt.into_inner();
599 let dir_tok = it.next().ok_or(AstError::Shape("exit direction"))?;
600 let dir = if dir_tok.as_rule() == Rule::string {
601 unquote(dir_tok.as_str())
602 } else {
603 dir_tok.as_str().to_string()
604 };
605 let to = it
606 .next()
607 .ok_or(AstError::Shape("exit destination"))?
608 .as_str()
609 .to_string();
610 let mut hidden = false;
612 let mut locked = false;
613 let mut barred_message: Option<String> = None;
614 let mut required_items: Vec<String> = Vec::new();
615 let mut required_flags: Vec<String> = Vec::new();
616 if let Some(next) = it.next() {
617 if next.as_rule() == Rule::exit_opts {
618 for opt in next.into_inner() {
619 let opt_text = opt.as_str().trim();
621 if opt_text == "hidden" {
622 hidden = true;
623 continue;
624 }
625 if opt_text == "locked" {
626 locked = true;
627 continue;
628 }
629
630 let children: Vec<_> = opt.clone().into_inner().collect();
632 if let Some(s) = children.iter().find(|p| p.as_rule() == Rule::string) {
634 barred_message = Some(unquote(s.as_str()));
635 continue;
636 }
637 if children.iter().all(|p| p.as_rule() == Rule::ident)
639 && opt_text.starts_with("required_items")
640 {
641 for idp in children {
642 required_items.push(idp.as_str().to_string());
643 }
644 continue;
645 }
646 if opt_text.starts_with("required_flags") {
648 for frp in opt.into_inner() {
649 match frp.as_rule() {
650 Rule::ident => {
651 required_flags.push(frp.as_str().to_string());
652 },
653 Rule::flag_req => {
654 let mut itf = frp.into_inner();
656 let ident =
657 itf.next().ok_or(AstError::Shape("flag ident"))?.as_str().to_string();
658 let base = ident.split('#').next().unwrap_or(&ident).to_string();
659 required_flags.push(base);
660 },
661 _ => {},
662 }
663 }
664 continue;
665 }
666 }
667 }
668 }
669 exits.push((
670 dir,
671 crate::ExitAst {
672 to,
673 hidden,
674 locked,
675 barred_message,
676 required_flags,
677 required_items,
678 },
679 ));
680 },
681 Rule::overlay_stmt => {
682 let mut it = inner_stmt.into_inner();
684 let conds_pair = it.next().ok_or(AstError::Shape("overlay cond list"))?;
686 let mut conds = Vec::new();
687 for cp in conds_pair.into_inner() {
688 if cp.as_rule() != Rule::overlay_cond {
689 continue;
690 }
691 let text = cp.as_str().trim();
692 let mut kids = cp.clone().into_inner();
693 if let Some(stripped) = text.strip_prefix("flag set ") {
694 let name = kids.next().ok_or(AstError::Shape("flag name"))?.as_str().to_string();
695 debug_assert_eq!(stripped, name);
696 conds.push(crate::OverlayCondAst::FlagSet(name));
697 continue;
698 }
699 if let Some(stripped) = text.strip_prefix("flag unset ") {
700 let name = kids.next().ok_or(AstError::Shape("flag name"))?.as_str().to_string();
701 debug_assert_eq!(stripped, name);
702 conds.push(crate::OverlayCondAst::FlagUnset(name));
703 continue;
704 }
705 if let Some(stripped) = text.strip_prefix("flag complete ") {
706 let name = kids.next().ok_or(AstError::Shape("flag name"))?.as_str().to_string();
707 debug_assert_eq!(stripped, name);
708 conds.push(crate::OverlayCondAst::FlagComplete(name));
709 continue;
710 }
711 if let Some(stripped) = text.strip_prefix("item present ") {
712 let item = kids.next().ok_or(AstError::Shape("item id"))?.as_str().to_string();
713 debug_assert_eq!(stripped, item);
714 conds.push(crate::OverlayCondAst::ItemPresent(item));
715 continue;
716 }
717 if let Some(stripped) = text.strip_prefix("item absent ") {
718 let item = kids.next().ok_or(AstError::Shape("item id"))?.as_str().to_string();
719 debug_assert_eq!(stripped, item);
720 conds.push(crate::OverlayCondAst::ItemAbsent(item));
721 continue;
722 }
723 if text.starts_with("player has item ") {
724 let item = kids.next().ok_or(AstError::Shape("item id"))?.as_str().to_string();
725 conds.push(crate::OverlayCondAst::PlayerHasItem(item));
726 continue;
727 }
728 if text.starts_with("player missing item ") {
729 let item = kids.next().ok_or(AstError::Shape("item id"))?.as_str().to_string();
730 conds.push(crate::OverlayCondAst::PlayerMissingItem(item));
731 continue;
732 }
733 if text.starts_with("npc present ") {
734 let npc = kids.next().ok_or(AstError::Shape("npc id"))?.as_str().to_string();
735 conds.push(crate::OverlayCondAst::NpcPresent(npc));
736 continue;
737 }
738 if text.starts_with("npc absent ") {
739 let npc = kids.next().ok_or(AstError::Shape("npc id"))?.as_str().to_string();
740 conds.push(crate::OverlayCondAst::NpcAbsent(npc));
741 continue;
742 }
743 if text.starts_with("npc in state ") {
744 let npc = kids.next().ok_or(AstError::Shape("npc id"))?.as_str().to_string();
745 let nxt = kids.next().ok_or(AstError::Shape("state token"))?;
746 let oc = match nxt.as_rule() {
747 Rule::ident => crate::OverlayCondAst::NpcInState {
748 npc,
749 state: crate::NpcStateValue::Named(nxt.as_str().to_string()),
750 },
751 Rule::string => crate::OverlayCondAst::NpcInState {
752 npc,
753 state: crate::NpcStateValue::Custom(unquote(nxt.as_str())),
754 },
755 _ => {
756 let mut sub = nxt.into_inner();
757 let sval = sub.next().ok_or(AstError::Shape("custom string"))?;
758 crate::OverlayCondAst::NpcInState {
759 npc,
760 state: crate::NpcStateValue::Custom(unquote(sval.as_str())),
761 }
762 },
763 };
764 conds.push(oc);
765 continue;
766 }
767 if text.starts_with("item in room ") {
768 let item = kids.next().ok_or(AstError::Shape("item id"))?.as_str().to_string();
769 let room = kids.next().ok_or(AstError::Shape("room id"))?.as_str().to_string();
770 conds.push(crate::OverlayCondAst::ItemInRoom { item, room });
771 continue;
772 }
773 }
775 if conds.is_empty() {
777 return Err(AstError::Shape("overlay requires at least one condition"));
778 }
779
780 let block = it.next().ok_or(AstError::Shape("overlay block"))?;
782 let mut txt = String::new();
783 for p in block.into_inner() {
784 if p.as_rule() == Rule::string {
785 txt = unquote(p.as_str());
786 break;
787 }
788 }
789 overlays.push(crate::OverlayAst {
790 conditions: conds,
791 text: txt,
792 });
793 },
794 Rule::overlay_flag_pair_stmt => {
795 let mut it = inner_stmt.into_inner();
797 let flag = it.next().ok_or(AstError::Shape("flag name"))?.as_str().to_string();
798 let block = it.next().ok_or(AstError::Shape("flag pair block"))?;
799 let mut bi = block.into_inner();
800 let set_txt = unquote(bi.next().ok_or(AstError::Shape("set text"))?.as_str());
801 let unset_txt = unquote(bi.next().ok_or(AstError::Shape("unset text"))?.as_str());
802 overlays.push(crate::OverlayAst {
803 conditions: vec![crate::OverlayCondAst::FlagSet(flag.clone())],
804 text: set_txt,
805 });
806 overlays.push(crate::OverlayAst {
807 conditions: vec![crate::OverlayCondAst::FlagUnset(flag)],
808 text: unset_txt,
809 });
810 },
811 Rule::overlay_item_pair_stmt => {
812 let mut it = inner_stmt.into_inner();
814 let item = it.next().ok_or(AstError::Shape("item id"))?.as_str().to_string();
815 let block = it.next().ok_or(AstError::Shape("item pair block"))?;
816 let mut bi = block.into_inner();
817 let present_txt = unquote(bi.next().ok_or(AstError::Shape("present text"))?.as_str());
818 let absent_txt = unquote(bi.next().ok_or(AstError::Shape("absent text"))?.as_str());
819 overlays.push(crate::OverlayAst {
820 conditions: vec![crate::OverlayCondAst::ItemPresent(item.clone())],
821 text: present_txt,
822 });
823 overlays.push(crate::OverlayAst {
824 conditions: vec![crate::OverlayCondAst::ItemAbsent(item)],
825 text: absent_txt,
826 });
827 },
828 Rule::overlay_npc_pair_stmt => {
829 let mut it = inner_stmt.into_inner();
831 let npc = it.next().ok_or(AstError::Shape("npc id"))?.as_str().to_string();
832 let block = it.next().ok_or(AstError::Shape("npc pair block"))?;
833 let mut bi = block.into_inner();
834 let present_txt = unquote(bi.next().ok_or(AstError::Shape("present text"))?.as_str());
835 let absent_txt = unquote(bi.next().ok_or(AstError::Shape("absent text"))?.as_str());
836 overlays.push(crate::OverlayAst {
837 conditions: vec![crate::OverlayCondAst::NpcPresent(npc.clone())],
838 text: present_txt,
839 });
840 overlays.push(crate::OverlayAst {
841 conditions: vec![crate::OverlayCondAst::NpcAbsent(npc)],
842 text: absent_txt,
843 });
844 },
845 Rule::overlay_npc_states_stmt => {
846 let mut it = inner_stmt.into_inner();
848 let npc = it.next().ok_or(AstError::Shape("npc id"))?.as_str().to_string();
849 let block = it.next().ok_or(AstError::Shape("npc states block"))?;
850 for line in block.into_inner() {
851 let mut kids = line.clone().into_inner();
852 let is_custom = line.as_str().trim_start().starts_with("custom(");
853 if is_custom {
854 let mut state_ident: Option<String> = None;
855 let mut text = None;
856 for p in kids {
857 match p.as_rule() {
858 Rule::ident => state_ident = Some(p.as_str().to_string()),
859 Rule::string => text = Some(unquote(p.as_str())),
860 _ => {},
861 }
862 }
863 let s = state_ident.ok_or(AstError::Shape("custom(state) requires ident"))?;
864 let txt = text.ok_or(AstError::Shape("custom(state) requires text"))?;
865 overlays.push(crate::OverlayAst {
866 conditions: vec![
867 crate::OverlayCondAst::NpcPresent(npc.clone()),
868 crate::OverlayCondAst::NpcInState {
869 npc: npc.clone(),
870 state: crate::NpcStateValue::Custom(s),
871 },
872 ],
873 text: txt,
874 });
875 } else {
876 let state_tok = kids.next().ok_or(AstError::Shape("npc state name"))?;
878 let state_name = state_tok.as_str().to_string();
879 let txt_pair = kids.next().ok_or(AstError::Shape("npc state text"))?;
880 let text = unquote(txt_pair.as_str());
881 overlays.push(crate::OverlayAst {
882 conditions: vec![
883 crate::OverlayCondAst::NpcPresent(npc.clone()),
884 crate::OverlayCondAst::NpcInState {
885 npc: npc.clone(),
886 state: crate::NpcStateValue::Named(state_name),
887 },
888 ],
889 text,
890 });
891 }
892 }
893 },
894 _ => {},
895 }
896 }
897 let name = name.ok_or(AstError::Shape("room missing name"))?;
898 let desc = desc.ok_or(AstError::Shape("room missing desc"))?;
899 Ok(RoomAst {
900 id,
901 name,
902 desc,
903 visited: visited.unwrap_or(false),
904 exits,
905 overlays,
906 src_line,
907 })
908}
909
910fn parse_item_pair(item: pest::iterators::Pair<Rule>, _source: &str) -> Result<ItemAst, AstError> {
911 let (src_line, _src_col) = item.as_span().start_pos().line_col();
912 let mut it = item.into_inner();
913 let id = it
914 .next()
915 .ok_or(AstError::Shape("expected item ident"))?
916 .as_str()
917 .to_string();
918 let block = it.next().ok_or(AstError::Shape("expected item block"))?;
919 let mut name: Option<String> = None;
920 let mut desc: Option<String> = None;
921 let mut portable: Option<bool> = None;
922 let mut location: Option<ItemLocationAst> = None;
923 let mut container_state: Option<ContainerStateAst> = None;
924 let mut restricted: Option<bool> = None;
925 let mut abilities: Vec<ItemAbilityAst> = Vec::new();
926 let mut text: Option<String> = None;
927 let mut requires: Vec<(String, String)> = Vec::new();
928 let mut consumable: Option<ConsumableAst> = None;
929 for stmt in block.into_inner() {
930 match stmt.as_rule() {
931 Rule::item_name => {
932 let s = stmt.into_inner().next().ok_or(AstError::Shape("missing item name"))?;
933 name = Some(unquote(s.as_str()));
934 },
935 Rule::item_desc => {
936 let s = stmt.into_inner().next().ok_or(AstError::Shape("missing item desc"))?;
937 desc = Some(unquote(s.as_str()));
938 },
939 Rule::item_portable => {
940 let tok = stmt
941 .into_inner()
942 .next()
943 .ok_or(AstError::Shape("missing portable token"))?;
944 portable = Some(tok.as_str() == "true");
945 },
946 Rule::item_restricted => {
947 let tok = stmt
948 .into_inner()
949 .next()
950 .ok_or(AstError::Shape("missing restricted token"))?;
951 restricted = Some(tok.as_str() == "true");
952 },
953 Rule::item_location => {
954 let mut li = stmt.into_inner();
955 let branch = li.next().ok_or(AstError::Shape("location kind"))?;
956 let loc = match branch.as_rule() {
957 Rule::inventory_loc => {
958 let owner = branch
959 .into_inner()
960 .next()
961 .ok_or(AstError::Shape("inventory id"))?
962 .as_str()
963 .to_string();
964 ItemLocationAst::Inventory(owner)
965 },
966 Rule::room_loc => {
967 let room = branch
968 .into_inner()
969 .next()
970 .ok_or(AstError::Shape("room id"))?
971 .as_str()
972 .to_string();
973 ItemLocationAst::Room(room)
974 },
975 Rule::npc_loc => {
976 let npc = branch
977 .into_inner()
978 .next()
979 .ok_or(AstError::Shape("npc id"))?
980 .as_str()
981 .to_string();
982 ItemLocationAst::Npc(npc)
983 },
984 Rule::chest_loc => {
985 let chest = branch
986 .into_inner()
987 .next()
988 .ok_or(AstError::Shape("chest id"))?
989 .as_str()
990 .to_string();
991 ItemLocationAst::Chest(chest)
992 },
993 Rule::nowhere_loc => {
994 let note = branch
995 .into_inner()
996 .next()
997 .ok_or(AstError::Shape("nowhere note"))?
998 .as_str();
999 ItemLocationAst::Nowhere(unquote(note))
1000 },
1001 _ => return Err(AstError::Shape("unknown location kind")),
1002 };
1003 location = Some(loc);
1004 },
1005 Rule::item_container_state => {
1006 let val = stmt
1007 .as_str()
1008 .split_whitespace()
1009 .last()
1010 .ok_or(AstError::Shape("container state"))?;
1011 container_state = match val {
1012 "open" => Some(ContainerStateAst::Open),
1013 "closed" => Some(ContainerStateAst::Closed),
1014 "locked" => Some(ContainerStateAst::Locked),
1015 "transparentClosed" => Some(ContainerStateAst::TransparentClosed),
1016 "transparentLocked" => Some(ContainerStateAst::TransparentLocked),
1017 "none" => None,
1018 _ => None,
1019 };
1020 },
1021 Rule::item_ability => {
1022 let mut ai = stmt.into_inner();
1023 let ability = ai.next().ok_or(AstError::Shape("ability name"))?.as_str().to_string();
1024 let target = ai.next().map(|p| p.as_str().to_string());
1025 abilities.push(ItemAbilityAst { ability, target });
1026 },
1027 Rule::item_text => {
1028 let s = stmt.into_inner().next().ok_or(AstError::Shape("missing text"))?;
1029 text = Some(unquote(s.as_str()));
1030 },
1031 Rule::item_requires => {
1032 let mut ri = stmt.into_inner();
1033 let ability = ri
1035 .next()
1036 .ok_or(AstError::Shape("requires ability"))?
1037 .as_str()
1038 .to_string();
1039 let interaction = ri
1040 .next()
1041 .ok_or(AstError::Shape("requires interaction"))?
1042 .as_str()
1043 .to_string();
1044 requires.push((interaction, ability));
1046 },
1047 Rule::item_consumable => {
1048 let mut uses_left: Option<usize> = None;
1049 let mut consume_on: Vec<ItemAbilityAst> = Vec::new();
1050 let mut when_consumed: Option<ConsumableWhenAst> = None;
1051 let mut stmt_iter = stmt.into_inner();
1052 let block = stmt_iter.next().ok_or(AstError::Shape("consumable block"))?;
1053 for cons_stmt in block.into_inner() {
1054 let mut cons = cons_stmt.into_inner();
1055 let Some(inner) = cons.next() else { continue };
1056 match inner.as_rule() {
1057 Rule::consumable_uses => {
1058 let num_pair = inner.into_inner().next().ok_or(AstError::Shape("consumable uses"))?;
1059 let raw = num_pair.as_str();
1060 let val: i64 = raw
1061 .parse()
1062 .map_err(|_| AstError::Shape("consumable uses must be a number"))?;
1063 if val < 0 {
1064 return Err(AstError::Shape("consumable uses must be >= 0"));
1065 }
1066 uses_left = Some(val as usize);
1067 },
1068 Rule::consumable_consume_on => {
1069 let mut ci = inner.into_inner();
1070 let ability = ci
1071 .next()
1072 .ok_or(AstError::Shape("consume_on ability"))?
1073 .as_str()
1074 .to_string();
1075 let target = ci.next().map(|p| p.as_str().to_string());
1076 consume_on.push(ItemAbilityAst { ability, target });
1077 },
1078 Rule::consumable_when_consumed => {
1079 let mut wi = inner.into_inner();
1080 let variant = wi.next().ok_or(AstError::Shape("when_consumed value"))?;
1081 when_consumed = Some(match variant.as_rule() {
1082 Rule::consume_despawn => ConsumableWhenAst::Despawn,
1083 Rule::consume_replace_inventory => {
1084 let replacement = variant
1085 .into_inner()
1086 .next()
1087 .ok_or(AstError::Shape("when_consumed replacement"))?
1088 .as_str()
1089 .to_string();
1090 ConsumableWhenAst::ReplaceInventory { replacement }
1091 },
1092 Rule::consume_replace_current_room => {
1093 let replacement = variant
1094 .into_inner()
1095 .next()
1096 .ok_or(AstError::Shape("when_consumed replacement"))?
1097 .as_str()
1098 .to_string();
1099 ConsumableWhenAst::ReplaceCurrentRoom { replacement }
1100 },
1101 _ => return Err(AstError::Shape("unknown when_consumed variant")),
1102 });
1103 },
1104 _ => {},
1105 }
1106 }
1107 let uses_left = uses_left.ok_or(AstError::Shape("consumable missing uses_left"))?;
1108 let when_consumed = when_consumed.ok_or(AstError::Shape("consumable missing when_consumed"))?;
1109 consumable = Some(ConsumableAst {
1110 uses_left,
1111 consume_on,
1112 when_consumed,
1113 });
1114 },
1115 _ => {},
1116 }
1117 }
1118 let name = name.ok_or(AstError::Shape("item missing name"))?;
1119 let desc = desc.ok_or(AstError::Shape("item missing desc"))?;
1120 let portable = portable.ok_or(AstError::Shape("item missing portable"))?;
1121 let location = location.ok_or(AstError::Shape("item missing location"))?;
1122 Ok(ItemAst {
1123 id,
1124 name,
1125 desc,
1126 portable,
1127 location,
1128 container_state,
1129 restricted: restricted.unwrap_or(false),
1130 abilities,
1131 text,
1132 interaction_requires: requires,
1133 consumable,
1134 src_line,
1135 })
1136}
1137
1138fn parse_spinner_pair(sp: pest::iterators::Pair<Rule>, _source: &str) -> Result<SpinnerAst, AstError> {
1139 let (src_line, _src_col) = sp.as_span().start_pos().line_col();
1140 let mut it = sp.into_inner();
1141 let id = it
1142 .next()
1143 .ok_or(AstError::Shape("expected spinner ident"))?
1144 .as_str()
1145 .to_string();
1146 let block = it.next().ok_or(AstError::Shape("expected spinner block"))?;
1147 let mut wedges = Vec::new();
1148 for w in block.into_inner() {
1149 let mut wi = w.into_inner();
1150 let text_pair = wi.next().ok_or(AstError::Shape("wedge text"))?;
1151 let text = unquote(text_pair.as_str());
1152 let width: usize = if let Some(width_pair) = wi.next() {
1154 width_pair
1155 .as_str()
1156 .parse()
1157 .map_err(|_| AstError::Shape("invalid wedge width"))?
1158 } else {
1159 1
1160 };
1161 wedges.push(SpinnerWedgeAst { text, width });
1162 }
1163 Ok(SpinnerAst { id, wedges, src_line })
1164}
1165
1166fn parse_goal_pair(goal: pest::iterators::Pair<Rule>, _source: &str) -> Result<GoalAst, AstError> {
1167 let (src_line, _src_col) = goal.as_span().start_pos().line_col();
1168 let mut it = goal.into_inner();
1169 let id = it.next().ok_or(AstError::Shape("goal id"))?.as_str().to_string();
1170 let block = it.next().ok_or(AstError::Shape("goal block"))?;
1171 let mut name: Option<String> = None;
1172 let mut description: Option<String> = None;
1173 let mut group: Option<GoalGroupAst> = None;
1174 let mut activate_when: Option<GoalCondAst> = None;
1175 let mut finished_when: Option<GoalCondAst> = None;
1176 let mut failed_when: Option<GoalCondAst> = None;
1177 for p in block.into_inner() {
1178 match p.as_rule() {
1179 Rule::goal_name => {
1180 let s = p.into_inner().next().ok_or(AstError::Shape("goal name text"))?.as_str();
1181 name = Some(unquote(s));
1182 },
1183 Rule::goal_desc => {
1184 let s = p.into_inner().next().ok_or(AstError::Shape("desc text"))?.as_str();
1185 description = Some(unquote(s));
1186 },
1187 Rule::goal_group => {
1188 let val = p.as_str().split_whitespace().last().unwrap_or("");
1189 group = Some(match val {
1190 "required" => GoalGroupAst::Required,
1191 "optional" => GoalGroupAst::Optional,
1192 "status-effect" => GoalGroupAst::StatusEffect,
1193 _ => GoalGroupAst::Required,
1194 });
1195 },
1196 Rule::goal_start => {
1197 let cond = p.into_inner().next().ok_or(AstError::Shape("start cond"))?;
1198 activate_when = Some(parse_goal_cond_pair(cond));
1199 },
1200 Rule::goal_done => {
1201 let cond = p.into_inner().next().ok_or(AstError::Shape("done cond"))?;
1202 finished_when = Some(parse_goal_cond_pair(cond));
1203 },
1204 Rule::goal_fail => {
1205 let cond = p.into_inner().next().ok_or(AstError::Shape("fail cond"))?;
1206 failed_when = Some(parse_goal_cond_pair(cond));
1207 },
1208 _ => {},
1209 }
1210 }
1211 let name = name.ok_or(AstError::Shape("goal missing name"))?;
1212 let description = description.ok_or(AstError::Shape("goal missing desc"))?;
1213 let group = group.ok_or(AstError::Shape("goal missing group"))?;
1214 let finished_when = finished_when.ok_or(AstError::Shape("goal missing done"))?;
1215 Ok(GoalAst {
1216 id,
1217 name,
1218 description,
1219 group,
1220 activate_when,
1221 failed_when,
1222 finished_when,
1223 src_line,
1224 })
1225}
1226
1227fn parse_goal_cond_pair(p: pest::iterators::Pair<Rule>) -> GoalCondAst {
1228 let s = p.as_str().trim();
1229 if let Some(rest) = s.strip_prefix("has flag ") {
1230 return GoalCondAst::HasFlag(rest.trim().to_string());
1231 }
1232 if let Some(rest) = s.strip_prefix("missing flag ") {
1233 return GoalCondAst::MissingFlag(rest.trim().to_string());
1234 }
1235 if let Some(rest) = s.strip_prefix("has item ") {
1236 return GoalCondAst::HasItem(rest.trim().to_string());
1237 }
1238 if let Some(rest) = s.strip_prefix("reached room ") {
1239 return GoalCondAst::ReachedRoom(rest.trim().to_string());
1240 }
1241 if let Some(rest) = s.strip_prefix("goal complete ") {
1242 return GoalCondAst::GoalComplete(rest.trim().to_string());
1243 }
1244 if let Some(rest) = s.strip_prefix("flag in progress ") {
1245 return GoalCondAst::FlagInProgress(rest.trim().to_string());
1246 }
1247 if let Some(rest) = s.strip_prefix("flag complete ") {
1248 return GoalCondAst::FlagComplete(rest.trim().to_string());
1249 }
1250 GoalCondAst::HasFlag(s.to_string())
1251}
1252fn parse_npc_pair(npc: pest::iterators::Pair<Rule>, _source: &str) -> Result<NpcAst, AstError> {
1253 let (src_line, _src_col) = npc.as_span().start_pos().line_col();
1254 let mut it = npc.into_inner();
1255 let id = it
1256 .next()
1257 .ok_or(AstError::Shape("expected npc ident"))?
1258 .as_str()
1259 .to_string();
1260 let block = it.next().ok_or(AstError::Shape("expected npc block"))?;
1261 let mut name: Option<String> = None;
1262 let mut desc: Option<String> = None;
1263 let mut location: Option<crate::NpcLocationAst> = None;
1264 let mut state: Option<NpcStateValue> = None;
1265 let mut movement: Option<NpcMovementAst> = None;
1266 let mut dialogue: Vec<(String, Vec<String>)> = Vec::new();
1267 for stmt in block.into_inner() {
1268 match stmt.as_rule() {
1269 Rule::npc_name => {
1270 let s = stmt.into_inner().next().ok_or(AstError::Shape("missing npc name"))?;
1271 name = Some(unquote(s.as_str()));
1272 },
1273 Rule::npc_desc => {
1274 let s = stmt.into_inner().next().ok_or(AstError::Shape("missing npc desc"))?;
1275 desc = Some(unquote(s.as_str()));
1276 },
1277 Rule::npc_location => {
1278 let mut li = stmt.into_inner();
1279 let tok = li.next().ok_or(AstError::Shape("location value"))?;
1280 let loc = match tok.as_rule() {
1281 Rule::ident => crate::NpcLocationAst::Room(tok.as_str().to_string()),
1282 Rule::string => crate::NpcLocationAst::Nowhere(unquote(tok.as_str())),
1283 _ => return Err(AstError::Shape("npc location")),
1284 };
1285 location = Some(loc);
1286 },
1287 Rule::npc_state => {
1288 let mut si = stmt.into_inner();
1289 let first = si.next().ok_or(AstError::Shape("state token"))?;
1291 let st = if first.as_rule() == Rule::ident {
1292 NpcStateValue::Named(first.as_str().to_string())
1293 } else {
1294 let v = si
1296 .next()
1297 .ok_or(AstError::Shape("custom state ident"))?
1298 .as_str()
1299 .to_string();
1300 NpcStateValue::Custom(v)
1301 };
1302 state = Some(st);
1303 },
1304 Rule::npc_movement => {
1305 let s = stmt.as_str();
1307 let mtype = if s.contains(" movement random ") || s.trim_start().starts_with("movement random ") {
1308 NpcMovementTypeAst::Random
1309 } else {
1310 NpcMovementTypeAst::Route
1311 };
1312 let mut rooms: Vec<String> = Vec::new();
1314 if let Some(open) = s.find('(') {
1315 if let Some(close_rel) = s[open + 1..].find(')') {
1316 let inner = &s[open + 1..open + 1 + close_rel];
1317 for tok in inner.split(',').map(|x| x.trim()).filter(|x| !x.is_empty()) {
1318 rooms.push(tok.to_string());
1319 }
1320 }
1321 }
1322 let timing = s
1323 .find(" timing ")
1324 .map(|idx| s[idx + 8..].split_whitespace().next().unwrap_or("").to_string());
1325 let active = if let Some(idx) = s.find(" active ") {
1326 let rest = &s[idx + 8..];
1327 if rest.trim_start().starts_with("true") {
1328 Some(true)
1329 } else if rest.trim_start().starts_with("false") {
1330 Some(false)
1331 } else {
1332 None
1333 }
1334 } else {
1335 None
1336 };
1337 let loop_route = if let Some(idx) = s.find(" loop ") {
1338 let rest = &s[idx + 6..];
1339 if rest.trim_start().starts_with("true") {
1340 Some(true)
1341 } else if rest.trim_start().starts_with("false") {
1342 Some(false)
1343 } else {
1344 None
1345 }
1346 } else {
1347 None
1348 };
1349 movement = Some(NpcMovementAst {
1350 movement_type: mtype,
1351 rooms,
1352 timing,
1353 active,
1354 loop_route,
1355 });
1356 },
1357 Rule::npc_dialogue_block => {
1358 let mut di = stmt.into_inner();
1360 let first = di.next().ok_or(AstError::Shape("dialogue state"))?;
1361 let key = if first.as_rule() == Rule::ident {
1362 first.as_str().to_string()
1363 } else {
1364 let id = di
1365 .next()
1366 .ok_or(AstError::Shape("custom dialogue state ident"))?
1367 .as_str()
1368 .to_string();
1369 format!("custom:{id}")
1370 };
1371 let mut lines: Vec<String> = Vec::new();
1372 for p in di {
1373 if p.as_rule() == Rule::string {
1374 lines.push(unquote(p.as_str()));
1375 }
1376 }
1377 dialogue.push((key, lines));
1378 },
1379 _ => {
1380 let txt = stmt.as_str().trim_start();
1382 if let Some(rest) = txt.strip_prefix("name ") {
1383 let (nm, _used) =
1384 parse_string_at(rest).map_err(|_| AstError::Shape("npc name invalid quoted text"))?;
1385 name = Some(nm);
1386 continue;
1387 }
1388 if let Some(rest) = txt.strip_prefix("desc ") {
1389 let (ds, _used) =
1391 parse_string_at(rest).map_err(|_| AstError::Shape("npc desc invalid quoted text"))?;
1392 desc = Some(ds);
1393 continue;
1394 }
1395 if let Some(rest) = txt.strip_prefix("location room ") {
1396 location = Some(crate::NpcLocationAst::Room(rest.trim().to_string()));
1397 continue;
1398 }
1399 if let Some(rest) = txt.strip_prefix("location nowhere ") {
1400 let (note, _used) = parse_string_at(rest)
1401 .map_err(|_| AstError::Shape("npc location nowhere invalid quoted text"))?;
1402 location = Some(crate::NpcLocationAst::Nowhere(note));
1403 continue;
1404 }
1405 if let Some(rest) = txt.strip_prefix("state ") {
1406 let rest = rest.trim();
1407 if let Some(val) = rest.strip_prefix("custom ") {
1408 state = Some(NpcStateValue::Custom(val.trim().to_string()));
1409 } else {
1410 let token = rest.split_whitespace().next().unwrap_or("");
1412 if !token.is_empty() {
1413 state = Some(NpcStateValue::Named(token.to_string()));
1414 }
1415 }
1416 continue;
1417 }
1418 if let Some(rest) = txt.strip_prefix("movement ") {
1419 let mut mtype = NpcMovementTypeAst::Route;
1420 if rest.trim_start().starts_with("random ") {
1421 mtype = NpcMovementTypeAst::Random;
1422 }
1423 let mut rooms: Vec<String> = Vec::new();
1424 if let Some(open) = txt.find('(') {
1425 if let Some(close_rel) = txt[open + 1..].find(')') {
1426 let inner = &txt[open + 1..open + 1 + close_rel];
1427 for tok in inner.split(',').map(|x| x.trim()).filter(|x| !x.is_empty()) {
1428 rooms.push(tok.to_string());
1429 }
1430 }
1431 }
1432
1433 let timing = txt
1434 .find(" timing ")
1435 .map(|idx| txt[idx + 8..].split_whitespace().next().unwrap_or("").to_string());
1436
1437 let active = if let Some(idx) = txt.find(" active ") {
1438 let rest = &txt[idx + 8..];
1439 if rest.trim_start().starts_with("true") {
1440 Some(true)
1441 } else if rest.trim_start().starts_with("false") {
1442 Some(false)
1443 } else {
1444 None
1445 }
1446 } else {
1447 None
1448 };
1449 let loop_route = if let Some(idx) = txt.find(" loop ") {
1450 let rest = &txt[idx + 6..];
1451 if rest.trim_start().starts_with("true") {
1452 Some(true)
1453 } else if rest.trim_start().starts_with("false") {
1454 Some(false)
1455 } else {
1456 None
1457 }
1458 } else {
1459 None
1460 };
1461 movement = Some(NpcMovementAst {
1462 movement_type: mtype,
1463 rooms,
1464 timing,
1465 active,
1466 loop_route,
1467 });
1468 continue;
1469 }
1470 if let Some(rest) = txt.strip_prefix("dialogue ") {
1471 let rest = rest.trim_start();
1473 let (key, after_key) = if let Some(val) = rest.strip_prefix("custom ") {
1474 let mut parts = val.splitn(2, char::is_whitespace);
1475 let id = parts.next().unwrap_or("").to_string();
1476 (format!("custom:{id}"), parts.next().unwrap_or("").to_string())
1477 } else {
1478 let mut parts = rest.splitn(2, char::is_whitespace);
1479 let id = parts.next().unwrap_or("").to_string();
1480 (id, parts.next().unwrap_or("").to_string())
1481 };
1482 if let Some(open_idx) = after_key.find('{') {
1483 if let Some(close_rel) = after_key[open_idx + 1..].rfind('}') {
1484 let mut inner = &after_key[open_idx + 1..open_idx + 1 + close_rel];
1485 let mut lines: Vec<String> = Vec::new();
1486 loop {
1487 inner = inner.trim_start();
1488 if inner.is_empty() {
1489 break;
1490 }
1491 if inner.starts_with('"') || inner.starts_with('r') || inner.starts_with('\'') {
1492 if let Ok((val, used)) = parse_string_at(inner) {
1493 lines.push(val);
1494 inner = &inner[used..];
1495 continue;
1496 } else {
1497 break;
1498 }
1499 } else {
1500 if let Some(pos) = inner.find('"') {
1502 inner = &inner[pos..];
1503 } else {
1504 break;
1505 }
1506 }
1507 }
1508 if !lines.is_empty() {
1509 dialogue.push((key, lines));
1510 continue;
1511 }
1512 }
1513 }
1514 }
1515 },
1516 }
1517 }
1518 let name = name.ok_or(AstError::Shape("npc missing name"))?;
1519 let desc = desc.ok_or(AstError::Shape("npc missing desc"))?;
1520 let location = location.ok_or(AstError::Shape("npc missing location"))?;
1521 let state = state.unwrap_or(NpcStateValue::Named("normal".to_string()));
1522 Ok(NpcAst {
1523 id,
1524 name,
1525 desc,
1526 location,
1527 state,
1528 movement,
1529 dialogue,
1530 src_line,
1531 })
1532}
1533
1534pub fn parse_rooms(source: &str) -> Result<Vec<RoomAst>, AstError> {
1540 let (_, rooms, _, _, _, _) = parse_program_full(source)?;
1541 Ok(rooms)
1542}
1543
1544pub fn parse_items(source: &str) -> Result<Vec<ItemAst>, AstError> {
1550 let (_, _, items, _, _, _) = parse_program_full(source)?;
1551 Ok(items)
1552}
1553
1554pub fn parse_spinners(source: &str) -> Result<Vec<SpinnerAst>, AstError> {
1560 let (_, _, _, spinners, _, _) = parse_program_full(source)?;
1561 Ok(spinners)
1562}
1563
1564pub fn parse_npcs(source: &str) -> Result<Vec<NpcAst>, AstError> {
1570 let (_, _, _, _, npcs, _) = parse_program_full(source)?;
1571 Ok(npcs)
1572}
1573
1574pub fn parse_goals(source: &str) -> Result<Vec<GoalAst>, AstError> {
1579 let (_, _, _, _, _, goals) = parse_program_full(source)?;
1580 Ok(goals)
1581}
1582
1583fn unquote(s: &str) -> String {
1584 parse_string(s).unwrap_or_else(|_| s.to_string())
1585}
1586
1587fn parse_string(s: &str) -> Result<String, AstError> {
1589 let (v, _u) = parse_string_at(s)?;
1590 Ok(v)
1591}
1592
1593fn parse_string_at(s: &str) -> Result<(String, usize), AstError> {
1595 let b = s.as_bytes();
1596 if b.is_empty() {
1597 return Err(AstError::Shape("empty string"));
1598 }
1599 if s.starts_with("\"\"\"") {
1601 let mut out = String::new();
1602 let mut i = 3usize; let mut escape = false;
1604 while i < s.len() {
1605 if !escape && s[i..].starts_with("\"\"\"") {
1606 return Ok((out, i + 3));
1607 }
1608 let ch = s[i..].chars().next().unwrap();
1609 i += ch.len_utf8();
1610 if escape {
1611 match ch {
1612 'n' => out.push('\n'),
1613 'r' => out.push('\r'),
1614 't' => out.push('\t'),
1615 '"' => out.push('"'),
1616 '\\' => out.push('\\'),
1617 other => {
1618 out.push('\\');
1619 out.push(other);
1620 },
1621 }
1622 escape = false;
1623 continue;
1624 }
1625 if ch == '\\' {
1626 escape = true;
1627 } else {
1628 out.push(ch);
1629 }
1630 }
1631 return Err(AstError::Shape("missing closing triple quote"));
1632 }
1633 if s.starts_with('r') {
1635 let mut i = 1usize;
1637 while i < s.len() && s.as_bytes()[i] as char == '#' {
1638 i += 1;
1639 }
1640 if i < s.len() && s.as_bytes()[i] as char == '"' {
1641 let num_hashes = i - 1;
1642 let close_seq = {
1643 let mut seq = String::from("\"");
1644 for _ in 0..num_hashes {
1645 seq.push('#');
1646 }
1647 seq
1648 };
1649 let content_start = i + 1;
1650 let rest = &s[content_start..];
1651 if let Some(pos) = rest.find(&close_seq) {
1652 let val = &rest[..pos];
1653 return Ok((val.to_string(), content_start + pos + close_seq.len()));
1654 } else {
1655 return Err(AstError::Shape("missing closing raw quote"));
1656 }
1657 }
1658 }
1659 if b[0] as char == '\'' {
1661 let mut out = String::new();
1662 let mut i = 1usize; let mut escape = false;
1664 while i < s.len() {
1665 let ch = s[i..].chars().next().unwrap();
1666 i += ch.len_utf8();
1667 if escape {
1668 match ch {
1669 'n' => out.push('\n'),
1670 'r' => out.push('\r'),
1671 't' => out.push('\t'),
1672 '\'' => out.push('\''),
1673 '"' => out.push('"'),
1674 '\\' => out.push('\\'),
1675 other => {
1676 out.push('\\');
1677 out.push(other);
1678 },
1679 }
1680 escape = false;
1681 continue;
1682 }
1683 match ch {
1684 '\\' => {
1685 escape = true;
1686 },
1687 '\'' => return Ok((out, i)),
1688 _ => out.push(ch),
1689 }
1690 }
1691 return Err(AstError::Shape("missing closing single quote"));
1692 }
1693 if b[0] as char != '"' {
1695 return Err(AstError::Shape("missing opening quote"));
1696 }
1697 let mut out = String::new();
1698 let mut i = 1usize; let mut escape = false;
1700 while i < s.len() {
1701 let ch = s[i..].chars().next().unwrap();
1702 i += ch.len_utf8();
1703 if escape {
1704 match ch {
1705 'n' => out.push('\n'),
1706 'r' => out.push('\r'),
1707 't' => out.push('\t'),
1708 '"' => out.push('"'),
1709 '\\' => out.push('\\'),
1710 other => {
1711 out.push('\\');
1712 out.push(other);
1713 },
1714 }
1715 escape = false;
1716 continue;
1717 }
1718 match ch {
1719 '\\' => {
1720 escape = true;
1721 },
1722 '"' => return Ok((out, i)),
1723 _ => out.push(ch),
1724 }
1725 }
1726 Err(AstError::Shape("missing closing quote"))
1727}
1728
1729fn extract_body(src: &str) -> Result<&str, AstError> {
1730 let bytes = src.as_bytes();
1731 let mut depth = 0i32;
1732 let mut start = None;
1733 let mut end = None;
1734 let mut i = 0usize;
1735 let mut in_str = false;
1736 let mut escape = false;
1737 let mut in_comment = false;
1738 let mut at_line_start = true;
1739 while i < bytes.len() {
1740 let c = bytes[i] as char;
1741 if in_comment {
1742 if c == '\n' {
1743 in_comment = false;
1744 at_line_start = true;
1745 }
1746 i += 1;
1747 continue;
1748 }
1749 if in_str {
1750 if escape {
1751 escape = false;
1752 } else if c == '\\' {
1753 escape = true;
1754 } else if c == '"' {
1755 in_str = false;
1756 }
1757
1758 i += 1;
1760 continue;
1761 }
1762 match c {
1763 '\n' => {
1764 at_line_start = true;
1765 },
1766 ' ' | '\t' | '\r' => {
1767 },
1769 '"' => {
1770 in_str = true;
1771 at_line_start = false;
1772 },
1773 '#' => {
1774 if at_line_start {
1776 in_comment = true;
1777 }
1778 at_line_start = false;
1779 },
1780 '{' => {
1781 if depth == 0 {
1782 start = Some(i + 1);
1783 }
1784 depth += 1;
1785 at_line_start = false;
1786 },
1787 '}' => {
1788 depth -= 1;
1789 if depth == 0 {
1790 end = Some(i);
1791 break;
1792 }
1793 at_line_start = false;
1794 },
1795 _ => {
1796 at_line_start = false;
1797 },
1798 }
1799 i += 1;
1800 }
1801 let s = start.ok_or(AstError::Shape("missing '{' body start"))?;
1802 let e = end.ok_or(AstError::Shape("missing '}' body end"))?;
1803 Ok(&src[s..e])
1804}
1805
1806fn parse_condition_text(text: &str, sets: &HashMap<String, Vec<String>>) -> Result<ConditionAst, AstError> {
1807 let t = text.trim();
1808 if let Some(inner) = t.strip_prefix("all(") {
1809 let inner = inner.strip_suffix(')').ok_or(AstError::Shape("all() close"))?;
1810 let parts = split_top_level_commas(inner);
1811 let mut kids = Vec::new();
1812 for p in parts {
1813 kids.push(parse_condition_text(p, sets)?);
1814 }
1815 return Ok(ConditionAst::All(kids));
1816 }
1817 if let Some(inner) = t.strip_prefix("any(") {
1818 let inner = inner.strip_suffix(')').ok_or(AstError::Shape("any() close"))?;
1819 let parts = split_top_level_commas(inner);
1820 let mut kids = Vec::new();
1821 for p in parts {
1822 kids.push(parse_condition_text(p, sets)?);
1823 }
1824 return Ok(ConditionAst::Any(kids));
1825 }
1826 if let Some(rest) = t.strip_prefix("has flag ") {
1827 return Ok(ConditionAst::HasFlag(rest.trim().to_string()));
1828 }
1829 if let Some(rest) = t.strip_prefix("missing flag ") {
1830 return Ok(ConditionAst::MissingFlag(rest.trim().to_string()));
1831 }
1832 if let Some(rest) = t.strip_prefix("has item ") {
1833 return Ok(ConditionAst::HasItem(rest.trim().to_string()));
1834 }
1835 if let Some(rest) = t.strip_prefix("has visited room ") {
1836 return Ok(ConditionAst::HasVisited(rest.trim().to_string()));
1837 }
1838 if let Some(rest) = t.strip_prefix("missing item ") {
1839 return Ok(ConditionAst::MissingItem(rest.trim().to_string()));
1840 }
1841 if let Some(rest) = t.strip_prefix("flag in progress ") {
1842 return Ok(ConditionAst::FlagInProgress(rest.trim().to_string()));
1843 }
1844 if let Some(rest) = t.strip_prefix("flag complete ") {
1845 return Ok(ConditionAst::FlagComplete(rest.trim().to_string()));
1846 }
1847 if let Some(rest) = t.strip_prefix("with npc ") {
1848 return Ok(ConditionAst::WithNpc(rest.trim().to_string()));
1849 }
1850 if let Some(rest) = t.strip_prefix("npc has item ") {
1851 let rest = rest.trim();
1852 if let Some(space) = rest.find(' ') {
1853 let npc = &rest[..space];
1854 let item = rest[space + 1..].trim();
1855 return Ok(ConditionAst::NpcHasItem {
1856 npc: npc.to_string(),
1857 item: item.to_string(),
1858 });
1859 }
1860 return Err(AstError::Shape("npc has item syntax"));
1861 }
1862 if let Some(rest) = t.strip_prefix("npc in state ") {
1863 let rest = rest.trim();
1864 if let Some(space) = rest.find(' ') {
1865 let npc = &rest[..space];
1866 let state = rest[space + 1..].trim();
1867 return Ok(ConditionAst::NpcInState {
1868 npc: npc.to_string(),
1869 state: state.to_string(),
1870 });
1871 }
1872 return Err(AstError::Shape("npc in state syntax"));
1873 }
1874 if let Some(rest) = t.strip_prefix("container ") {
1876 let rest = rest.trim();
1877 if let Some(idx) = rest.find(" has item ") {
1878 let container = &rest[..idx];
1879 let item = &rest[idx + " has item ".len()..];
1880 return Ok(ConditionAst::ContainerHasItem {
1881 container: container.trim().to_string(),
1882 item: item.trim().to_string(),
1883 });
1884 }
1885 }
1886 if let Some(rest) = t.strip_prefix("container has item ") {
1887 let rest = rest.trim();
1888 if let Some(space) = rest.find(' ') {
1889 let container = &rest[..space];
1890 let item = rest[space + 1..].trim();
1891 return Ok(ConditionAst::ContainerHasItem {
1892 container: container.to_string(),
1893 item: item.to_string(),
1894 });
1895 }
1896 return Err(AstError::Shape("container has item syntax"));
1897 }
1898 if let Some(rest) = t.strip_prefix("ambient ") {
1899 let rest = rest.trim();
1900 if let Some(idx) = rest.find(" in rooms ") {
1901 let spinner = rest[..idx].trim().to_string();
1902 let list = rest[idx + 10..].trim();
1903 let mut rooms: Vec<String> = Vec::new();
1904 for tok in list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
1905 if let Some(v) = sets.get(tok) {
1906 rooms.extend(v.clone());
1907 } else {
1908 rooms.push(tok.to_string());
1909 }
1910 }
1911 return Ok(ConditionAst::Ambient {
1912 spinner,
1913 rooms: Some(rooms),
1914 });
1915 } else {
1916 return Ok(ConditionAst::Ambient {
1917 spinner: rest.to_string(),
1918 rooms: None,
1919 });
1920 }
1921 }
1922 if let Some(rest) = t.strip_prefix("in rooms ") {
1924 let list = rest.trim();
1925 let mut rooms: Vec<String> = Vec::new();
1926 for tok in list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
1927 if let Some(v) = sets.get(tok) {
1928 rooms.extend(v.clone());
1929 } else {
1930 rooms.push(tok.to_string());
1931 }
1932 }
1933 if rooms.len() == 1 {
1935 return Ok(ConditionAst::PlayerInRoom(rooms.remove(0)));
1936 } else if !rooms.is_empty() {
1937 let kids = rooms.into_iter().map(ConditionAst::PlayerInRoom).collect();
1938 return Ok(ConditionAst::Any(kids));
1939 } else {
1940 return Err(AstError::Shape("in rooms requires at least one room"));
1941 }
1942 }
1943 if let Some(rest) = t.strip_prefix("player in room ") {
1944 return Ok(ConditionAst::PlayerInRoom(rest.trim().to_string()));
1945 }
1946 if let Some(rest) = t.strip_prefix("chance ") {
1947 let rest = rest.trim();
1948 let num = rest.strip_suffix('%').ok_or(AstError::Shape("chance percent %"))?;
1949 let pct: f64 = num
1950 .trim()
1951 .parse()
1952 .map_err(|_| AstError::Shape("invalid chance percent"))?;
1953 return Ok(ConditionAst::ChancePercent(pct));
1954 }
1955 Err(AstError::Shape("unknown condition"))
1956}
1957
1958fn split_top_level_commas(s: &str) -> Vec<&str> {
1959 let mut out = Vec::new();
1960 let mut depth = 0i32;
1961 let mut start = 0usize;
1962 let bytes = s.as_bytes();
1963 for (i, &b) in bytes.iter().enumerate() {
1964 match b as char {
1965 '(' => depth += 1,
1966 ')' => depth -= 1,
1967 ',' if depth == 0 => {
1968 let mut j = i + 1;
1971 while j < bytes.len() && (bytes[j] as char).is_whitespace() {
1972 j += 1;
1973 }
1974 let mut k = j;
1976 while k < bytes.len() {
1977 let ch = bytes[k] as char;
1978 if ch.is_alphanumeric() || ch == '-' || ch == '_' {
1979 k += 1;
1980 } else {
1981 break;
1982 }
1983 }
1984 let next_word = if j < k {
1985 s[j..k].to_ascii_lowercase()
1986 } else {
1987 String::new()
1988 };
1989 let is_sep = next_word.is_empty()
1990 || matches!(
1991 next_word.as_str(),
1992 "has"
1993 | "missing"
1994 | "player"
1995 | "container"
1996 | "ambient"
1997 | "in"
1998 | "npc"
1999 | "with"
2000 | "flag"
2001 | "chance"
2002 | "all"
2003 | "any"
2004 );
2005 if is_sep {
2006 out.push(s[start..i].trim());
2007 start = i + 1;
2008 }
2009 },
2010 _ => {},
2011 }
2012 }
2013 if start < s.len() {
2014 out.push(s[start..].trim());
2015 }
2016 out
2017}
2018
2019fn parse_action_core(text: &str) -> Result<ActionAst, AstError> {
2020 let t = text.trim();
2021 if let Some(rest) = t.strip_prefix("do show ") {
2022 return Ok(ActionAst::Show(super::parser::unquote(rest.trim())));
2023 }
2024 if let Some(rest) = t.strip_prefix("do add wedge ") {
2025 let r = rest.trim();
2027 let (text, used) = parse_string_at(r).map_err(|_| AstError::Shape("add wedge missing or invalid quote"))?;
2028 let mut after = r[used..].trim_start();
2029 let mut width: usize = 1;
2031 if let Some(wrest) = after.strip_prefix("width ") {
2032 let mut j = 0usize;
2033 while j < wrest.len() && wrest.as_bytes()[j].is_ascii_digit() {
2034 j += 1;
2035 }
2036 if j == 0 {
2037 return Err(AstError::Shape("add wedge missing width number"));
2038 }
2039 width = wrest[..j].parse().map_err(|_| AstError::Shape("invalid wedge width"))?;
2040 after = wrest[j..].trim_start();
2041 }
2042 let spinner = after
2043 .strip_prefix("spinner ")
2044 .ok_or(AstError::Shape("add wedge missing 'spinner'"))?
2045 .trim()
2046 .to_string();
2047 return Ok(ActionAst::AddSpinnerWedge { spinner, width, text });
2048 }
2049 if let Some(rest) = t.strip_prefix("do add flag ") {
2050 return Ok(ActionAst::AddFlag(rest.trim().to_string()));
2051 }
2052 if let Some(rest) = t.strip_prefix("do add seq flag ") {
2053 let rest = rest.trim();
2055 if let Some((name, tail)) = rest.split_once(" limit ") {
2056 let end: u8 = tail
2057 .trim()
2058 .parse()
2059 .map_err(|_| AstError::Shape("invalid seq flag limit"))?;
2060 return Ok(ActionAst::AddSeqFlag {
2061 name: name.trim().to_string(),
2062 end: Some(end),
2063 });
2064 }
2065 return Ok(ActionAst::AddSeqFlag {
2066 name: rest.to_string(),
2067 end: None,
2068 });
2069 }
2070 if let Some(rest) = t.strip_prefix("do remove flag ") {
2071 return Ok(ActionAst::RemoveFlag(rest.trim().to_string()));
2072 }
2073 if let Some(rest) = t.strip_prefix("do reset flag ") {
2074 return Ok(ActionAst::ResetFlag(rest.trim().to_string()));
2075 }
2076 if let Some(rest) = t.strip_prefix("do advance flag ") {
2077 return Ok(ActionAst::AdvanceFlag(rest.trim().to_string()));
2078 }
2079 if let Some(rest) = t.strip_prefix("do replace item ") {
2080 let rest = rest.trim();
2081 if let Some((old_sym, new_sym)) = rest.split_once(" with ") {
2082 return Ok(ActionAst::ReplaceItem {
2083 old_sym: old_sym.trim().to_string(),
2084 new_sym: new_sym.trim().to_string(),
2085 });
2086 }
2087 return Err(AstError::Shape("replace item syntax"));
2088 }
2089 if let Some(rest) = t.strip_prefix("do replace drop item ") {
2090 let rest = rest.trim();
2091 if let Some((old_sym, new_sym)) = rest.split_once(" with ") {
2092 return Ok(ActionAst::ReplaceDropItem {
2093 old_sym: old_sym.trim().to_string(),
2094 new_sym: new_sym.trim().to_string(),
2095 });
2096 }
2097 return Err(AstError::Shape("replace drop item syntax"));
2098 }
2099 if let Some(rest) = t.strip_prefix("do spawn npc ") {
2100 let rest = rest.trim();
2102 if let Some((npc, room)) = rest.split_once(" into room ") {
2103 return Ok(ActionAst::SpawnNpcIntoRoom {
2104 npc: npc.trim().to_string(),
2105 room: room.trim().to_string(),
2106 });
2107 }
2108 return Err(AstError::Shape("spawn npc into room syntax"));
2109 }
2110 if let Some(rest) = t.strip_prefix("do despawn npc ") {
2111 return Ok(ActionAst::DespawnNpc(rest.trim().to_string()));
2112 }
2113 if let Some(rest) = t.strip_prefix("do spawn item ") {
2114 let rest = rest.trim();
2116 if let Some((item, tail)) = rest.split_once(" into room ") {
2117 return Ok(ActionAst::SpawnItemIntoRoom {
2118 item: item.trim().to_string(),
2119 room: tail.trim().to_string(),
2120 });
2121 }
2122 if let Some((item, tail)) = rest.split_once(" into container ") {
2123 return Ok(ActionAst::SpawnItemInContainer {
2124 item: item.trim().to_string(),
2125 container: tail.trim().to_string(),
2126 });
2127 }
2128 if let Some((item, tail)) = rest.split_once(" in container ") {
2129 return Ok(ActionAst::SpawnItemInContainer {
2130 item: item.trim().to_string(),
2131 container: tail.trim().to_string(),
2132 });
2133 }
2134 if let Some(item) = rest.strip_suffix(" in inventory") {
2135 return Ok(ActionAst::SpawnItemInInventory(item.trim().to_string()));
2136 }
2137 if let Some(item) = rest.strip_suffix(" in current room") {
2138 return Ok(ActionAst::SpawnItemCurrentRoom(item.trim().to_string()));
2139 }
2140 return Err(AstError::Shape("spawn item syntax"));
2141 }
2142 if let Some(rest) = t.strip_prefix("do lock item ") {
2143 return Ok(ActionAst::LockItem(rest.trim().to_string()));
2144 }
2145 if let Some(rest) = t.strip_prefix("do unlock item ") {
2146 return Ok(ActionAst::UnlockItemAction(rest.trim().to_string()));
2147 }
2148 if let Some(rest) = t.strip_prefix("do set barred message from ") {
2149 let rest = rest.trim();
2151 if let Some((from, tail)) = rest.split_once(" to ") {
2152 let tail = tail.trim();
2153 let mut parts = tail.splitn(2, ' ');
2155 let exit_to = parts
2156 .next()
2157 .ok_or(AstError::Shape("barred message missing exit_to"))?
2158 .to_string();
2159 let msg_part = parts
2160 .next()
2161 .ok_or(AstError::Shape("barred message missing message"))?
2162 .trim();
2163 let (msg, _used) =
2164 parse_string_at(msg_part).map_err(|_| AstError::Shape("barred message invalid quoted text"))?;
2165 return Ok(ActionAst::SetBarredMessage {
2166 exit_from: from.trim().to_string(),
2167 exit_to,
2168 msg,
2169 });
2170 }
2171 return Err(AstError::Shape("set barred message syntax"));
2172 }
2173 if let Some(rest) = t.strip_prefix("do lock exit from ") {
2174 if let Some((from, tail)) = rest.split_once(" direction ") {
2175 let tail = tail.trim_start();
2176 let direction = match parse_string_at(tail) {
2177 Ok((s, _used)) => s,
2178 Err(_) => tail.trim().to_string(),
2179 };
2180 return Ok(ActionAst::LockExit {
2181 from_room: from.trim().to_string(),
2182 direction,
2183 });
2184 }
2185 }
2186 if let Some(rest) = t.strip_prefix("do unlock exit from ") {
2187 if let Some((from, tail)) = rest.split_once(" direction ") {
2188 let tail = tail.trim_start();
2189 let direction = match parse_string_at(tail) {
2190 Ok((s, _used)) => s,
2191 Err(_) => tail.trim().to_string(),
2192 };
2193 return Ok(ActionAst::UnlockExit {
2194 from_room: from.trim().to_string(),
2195 direction,
2196 });
2197 }
2198 }
2199 if let Some(rest) = t.strip_prefix("do give item ") {
2200 let rest = rest.trim();
2202 if let Some((item, tail)) = rest.split_once(" to player from npc ") {
2203 return Ok(ActionAst::GiveItemToPlayer {
2204 item: item.trim().to_string(),
2205 npc: tail.trim().to_string(),
2206 });
2207 }
2208 return Err(AstError::Shape("give item to player syntax"));
2209 }
2210 if let Some(rest) = t.strip_prefix("do reveal exit from ") {
2211 if let Some((from, tail)) = rest.split_once(" to ") {
2213 if let Some((to, dir_tail)) = tail.split_once(" direction ") {
2214 let dir_tail = dir_tail.trim_start();
2215 let direction = match parse_string_at(dir_tail) {
2216 Ok((s, _used)) => s,
2217 Err(_) => dir_tail.trim().to_string(),
2218 };
2219 return Ok(ActionAst::RevealExit {
2220 exit_from: from.trim().to_string(),
2221 exit_to: to.trim().to_string(),
2222 direction,
2223 });
2224 }
2225 }
2226 return Err(AstError::Shape("reveal exit syntax"));
2227 }
2228 if let Some(rest) = t.strip_prefix("do push player to ") {
2229 return Ok(ActionAst::PushPlayerTo(rest.trim().to_string()));
2230 }
2231 if let Some(rest) = t.strip_prefix("do set item description ") {
2232 let rest = rest.trim();
2234 if let Some(space) = rest.find(' ') {
2235 let item = &rest[..space];
2236 let txt = rest[space..].trim();
2237 let (text, _used) =
2238 parse_string_at(txt).map_err(|_| AstError::Shape("set item description missing or invalid quote"))?;
2239 return Ok(ActionAst::SetItemDescription {
2240 item: item.to_string(),
2241 text,
2242 });
2243 }
2244 return Err(AstError::Shape("set item description syntax"));
2245 }
2246 if let Some(rest) = t.strip_prefix("do npc says ") {
2247 let rest = rest.trim();
2249 if let Some(space) = rest.find(' ') {
2250 let npc = &rest[..space];
2251 let txt = rest[space..].trim();
2252 let (quote, _used) =
2253 parse_string_at(txt).map_err(|_| AstError::Shape("npc says missing or invalid quote"))?;
2254 return Ok(ActionAst::NpcSays {
2255 npc: npc.to_string(),
2256 quote,
2257 });
2258 }
2259 return Err(AstError::Shape("npc says syntax"));
2260 }
2261 if let Some(rest) = t.strip_prefix("do npc random dialogue ") {
2262 return Ok(ActionAst::NpcSaysRandom {
2263 npc: rest.trim().to_string(),
2264 });
2265 }
2266 if let Some(rest) = t.strip_prefix("do npc refuse item ") {
2267 let rest = rest.trim();
2268 let mut parts = rest.splitn(2, ' ');
2269 let npc = parts
2270 .next()
2271 .ok_or(AstError::Shape("npc refuse item missing npc"))?
2272 .to_string();
2273 let reason_part = parts
2274 .next()
2275 .ok_or(AstError::Shape("npc refuse item missing reason"))?
2276 .trim();
2277 let (reason, _used) =
2278 parse_string_at(reason_part).map_err(|_| AstError::Shape("npc refuse missing or invalid quote"))?;
2279 return Ok(ActionAst::NpcRefuseItem { npc, reason });
2280 }
2281 if let Some(rest) = t.strip_prefix("do set npc active ") {
2282 let rest = rest.trim();
2284 if let Some(space) = rest.find(' ') {
2285 let npc = &rest[..space];
2286 let active_str = rest[space + 1..].trim();
2287 let active = match active_str {
2288 "true" => true,
2289 "false" => false,
2290 _ => return Err(AstError::Shape("set npc active requires 'true' or 'false'")),
2291 };
2292 return Ok(ActionAst::SetNpcActive {
2293 npc: npc.to_string(),
2294 active,
2295 });
2296 }
2297 return Err(AstError::Shape("set npc active syntax"));
2298 }
2299 if let Some(rest) = t.strip_prefix("do set npc state ") {
2300 let rest = rest.trim();
2302 if let Some(space) = rest.find(' ') {
2303 let npc = &rest[..space];
2304 let state = rest[space + 1..].trim();
2305 return Ok(ActionAst::SetNpcState {
2306 npc: npc.to_string(),
2307 state: state.to_string(),
2308 });
2309 }
2310 return Err(AstError::Shape("set npc state syntax"));
2311 }
2312 if let Some(rest) = t.strip_prefix("do deny read ") {
2313 let (msg, _used) =
2314 parse_string_at(rest.trim()).map_err(|_| AstError::Shape("deny read missing or invalid quote"))?;
2315 return Ok(ActionAst::DenyRead(msg));
2316 }
2317 if let Some(rest) = t.strip_prefix("do restrict item ") {
2318 return Ok(ActionAst::RestrictItem(rest.trim().to_string()));
2319 }
2320 if let Some(rest) = t.strip_prefix("do set container state ") {
2321 let mut parts = rest.split_whitespace();
2323 let item = parts
2324 .next()
2325 .ok_or(AstError::Shape("set container state missing item"))?
2326 .to_string();
2327 let state_tok = parts
2328 .next()
2329 .ok_or(AstError::Shape("set container state missing state"))?;
2330 let state = match state_tok {
2331 "none" => None,
2332 s => Some(s.to_string()),
2333 };
2334 return Ok(ActionAst::SetContainerState { item, state });
2335 }
2336 if let Some(rest) = t.strip_prefix("do spinner message ") {
2337 return Ok(ActionAst::SpinnerMessage {
2338 spinner: rest.trim().to_string(),
2339 });
2340 }
2341 if let Some(rest) = t.strip_prefix("do despawn item ") {
2343 return Ok(ActionAst::DespawnItem(rest.trim().to_string()));
2344 }
2345 if let Some(rest) = t.strip_prefix("do award points ") {
2346 let rest = rest.trim();
2347 let mut parts = rest.splitn(2, ' ');
2348 let amount_part = parts.next().ok_or(AstError::Shape("award points missing amount"))?;
2349 let amount: i64 = amount_part
2350 .parse()
2351 .map_err(|_| AstError::Shape("invalid points number"))?;
2352 let remainder = parts
2353 .next()
2354 .ok_or(AstError::Shape("award points missing reason"))?
2355 .trim();
2356 let reason_text = remainder
2357 .strip_prefix("reason")
2358 .ok_or(AstError::Shape("award points missing reason keyword"))?
2359 .trim();
2360 let (reason, _used) =
2361 parse_string_at(reason_text).map_err(|_| AstError::Shape("award points missing or invalid reason"))?;
2362 return Ok(ActionAst::AwardPoints { amount, reason });
2363 }
2364 Err(AstError::Shape("unknown action"))
2365}
2366
2367fn parse_actions_from_body(
2368 body: &str,
2369 source: &str,
2370 smap: &SourceMap,
2371 sets: &HashMap<String, Vec<String>>,
2372) -> Result<Vec<ActionStmt>, AstError> {
2373 let mut out = Vec::new();
2374 let bytes = body.as_bytes();
2375 let mut i = 0usize;
2376 while i < bytes.len() {
2377 while i < bytes.len() && (bytes[i] as char).is_whitespace() {
2378 i += 1;
2379 }
2380 if i >= bytes.len() {
2381 break;
2382 }
2383 if bytes[i] as char == '#' {
2384 while i < bytes.len() && (bytes[i] as char) != '\n' {
2385 i += 1;
2386 }
2387 continue;
2388 }
2389 if body[i..].starts_with("if ") {
2390 let if_pos = i;
2391 let rest = &body[if_pos + 3..];
2392 let brace_rel = rest.find('{').ok_or(AstError::Shape("missing '{' after if"))?;
2393 let cond_text = rest[..brace_rel].trim();
2394 let cond = match parse_condition_text(cond_text, sets) {
2395 Ok(c) => c,
2396 Err(AstError::Shape(m)) => {
2397 let base = str_offset(source, body);
2398 let cond_abs = base + (cond_text.as_ptr() as usize - body.as_ptr() as usize);
2399 let (line_no, col) = smap.line_col(cond_abs);
2400 let snippet = smap.line_snippet(line_no);
2401 return Err(AstError::ShapeAt {
2402 msg: m,
2403 context: format!(
2404 "line {line_no}, col {col}: {snippet}\n{}^",
2405 " ".repeat(col.saturating_sub(1))
2406 ),
2407 });
2408 },
2409 Err(e) => return Err(e),
2410 };
2411 let block_after = &rest[brace_rel..];
2412 let inner_body = extract_body(block_after)?;
2413 let actions = parse_actions_from_body(inner_body, source, smap, sets)?;
2414 out.push(ActionStmt::new(ActionAst::Conditional {
2415 condition: Box::new(cond),
2416 actions,
2417 }));
2418 let consumed = brace_rel + 1 + inner_body.len() + 1;
2419 i = if_pos + 3 + consumed;
2420 continue;
2421 }
2422 let remainder = &body[i..];
2423 let trimmed_remainder = remainder.trim_start();
2424
2425 match parse_modify_item_action(remainder) {
2426 Ok((action, used)) => {
2427 out.push(action);
2428 i += used;
2429 continue;
2430 },
2431 Err(AstError::Shape("not a modify item action")) => {},
2432 Err(AstError::Shape(m)) => {
2433 let base = str_offset(source, body);
2434 let abs = base + i;
2435 let (line_no, col) = smap.line_col(abs);
2436 let snippet = smap.line_snippet(line_no);
2437 return Err(AstError::ShapeAt {
2438 msg: m,
2439 context: format!(
2440 "line {line_no}, col {col}: {snippet}\n{}^",
2441 " ".repeat(col.saturating_sub(1))
2442 ),
2443 });
2444 },
2445 Err(e) => return Err(e),
2446 }
2447
2448 match parse_modify_room_action(remainder) {
2449 Ok((action, used)) => {
2450 out.push(action);
2451 i += used;
2452 continue;
2453 },
2454 Err(AstError::Shape("not a modify room action")) => {},
2455 Err(AstError::Shape(m)) => {
2456 let base = str_offset(source, body);
2457 let abs = base + i;
2458 let (line_no, col) = smap.line_col(abs);
2459 let snippet = smap.line_snippet(line_no);
2460 return Err(AstError::ShapeAt {
2461 msg: m,
2462 context: format!(
2463 "line {line_no}, col {col}: {snippet}\n{}^",
2464 " ".repeat(col.saturating_sub(1))
2465 ),
2466 });
2467 },
2468 Err(e) => return Err(e),
2469 }
2470
2471 match parse_modify_npc_action(remainder) {
2472 Ok((action, used)) => {
2473 out.push(action);
2474 i += used;
2475 continue;
2476 },
2477 Err(AstError::Shape("not a modify npc action")) => {},
2478 Err(AstError::Shape(m)) => {
2479 let base = str_offset(source, body);
2480 let abs = base + i;
2481 let (line_no, col) = smap.line_col(abs);
2482 let snippet = smap.line_snippet(line_no);
2483 return Err(AstError::ShapeAt {
2484 msg: m,
2485 context: format!(
2486 "line {line_no}, col {col}: {snippet}\n{}^",
2487 " ".repeat(col.saturating_sub(1))
2488 ),
2489 });
2490 },
2491 Err(e) => return Err(e),
2492 }
2493
2494 match parse_schedule_action(remainder, source, smap, sets) {
2495 Ok((action, used)) => {
2496 out.push(action);
2497 i += used;
2498 continue;
2499 },
2500 Err(AstError::Shape("not a schedule action")) => {},
2501 Err(e) => return Err(e),
2502 }
2503 if trimmed_remainder.starts_with("do ") {
2504 let mut j = i;
2505 while j < bytes.len() && (bytes[j] as char) != '\n' {
2506 j += 1;
2507 }
2508 let line = body[i..j].trim_end();
2509 match parse_action_from_str(line) {
2510 Ok(a) => out.push(a),
2511 Err(AstError::Shape(m)) => {
2512 let base = str_offset(source, body);
2513 let abs = base + i;
2514 let (line_no, col) = smap.line_col(abs);
2515 let snippet = smap.line_snippet(line_no);
2516 return Err(AstError::ShapeAt {
2517 msg: m,
2518 context: format!(
2519 "line {line_no}, col {col}: {snippet}\n{}^",
2520 " ".repeat(col.saturating_sub(1))
2521 ),
2522 });
2523 },
2524 Err(e) => return Err(e),
2525 }
2526 i = j;
2527 continue;
2528 }
2529 while i < bytes.len() && (bytes[i] as char) != '\n' {
2530 i += 1;
2531 }
2532 }
2533 Ok(out)
2534}
2535
2536fn parse_modify_item_action(text: &str) -> Result<(ActionStmt, usize), AstError> {
2537 let s = text.trim_start();
2538 let leading_ws = text.len() - s.len();
2539 let (priority, rest_after_do) = match strip_priority_clause(s) {
2540 Ok(v) => v,
2541 Err(AstError::Shape("not a do action")) => return Err(AstError::Shape("not a modify item action")),
2542 Err(e) => return Err(e),
2543 };
2544 let rest0 = rest_after_do
2545 .strip_prefix("modify item ")
2546 .ok_or(AstError::Shape("not a modify item action"))?;
2547 let rest0 = rest0.trim_start();
2548 let ident_len = rest0.chars().take_while(|&c| is_ident_char(c)).count();
2549 if ident_len == 0 {
2550 return Err(AstError::Shape("modify item missing item identifier"));
2551 }
2552 let ident_str = &rest0[..ident_len];
2553 DslParser::parse(Rule::ident, ident_str).map_err(|_| AstError::Shape("modify item has invalid item identifier"))?;
2554 let item = ident_str.to_string();
2555 let after_ident = rest0[ident_len..].trim_start();
2556 if !after_ident.starts_with('{') {
2557 return Err(AstError::Shape("modify item missing '{' block"));
2558 }
2559 let block_slice = after_ident;
2560 let body = extract_body(block_slice)?;
2561 let start_offset = unsafe { body.as_ptr().offset_from(block_slice.as_ptr()) as usize };
2564 let block_total_len = start_offset + body.len() + 1;
2565 let remaining = &block_slice[block_total_len..];
2566 let consumed = leading_ws + (s.len() - remaining.len());
2567 let patch_block = &block_slice[..block_total_len];
2568
2569 let mut pairs = DslParser::parse(Rule::item_patch_block, patch_block).map_err(|e| AstError::Pest(e.to_string()))?;
2570 let block_pair = pairs
2571 .next()
2572 .ok_or(AstError::Shape("modify item block missing statements"))?;
2573 let mut patch = ItemPatchAst::default();
2574
2575 for stmt in block_pair.into_inner() {
2576 match stmt.as_rule() {
2577 Rule::item_name_patch => {
2578 let mut inner = stmt.into_inner();
2579 let val = inner
2580 .next()
2581 .ok_or(AstError::Shape("modify item name missing value"))?
2582 .as_str();
2583 patch.name = Some(unquote(val));
2584 },
2585 Rule::item_desc_patch => {
2586 let mut inner = stmt.into_inner();
2587 let val = inner
2588 .next()
2589 .ok_or(AstError::Shape("modify item description missing value"))?
2590 .as_str();
2591 patch.desc = Some(unquote(val));
2592 },
2593 Rule::item_text_patch => {
2594 let mut inner = stmt.into_inner();
2595 let val = inner
2596 .next()
2597 .ok_or(AstError::Shape("modify item text missing value"))?
2598 .as_str();
2599 patch.text = Some(unquote(val));
2600 },
2601 Rule::item_portable_patch => {
2602 let mut inner = stmt.into_inner();
2603 let tok = inner
2604 .next()
2605 .ok_or(AstError::Shape("modify item portable missing value"))?
2606 .as_str();
2607 let portable = match tok {
2608 "true" => true,
2609 "false" => false,
2610 _ => return Err(AstError::Shape("portable expects true or false")),
2611 };
2612 patch.portable = Some(portable);
2613 },
2614 Rule::item_restricted_patch => {
2615 let mut inner = stmt.into_inner();
2616 let tok = inner
2617 .next()
2618 .ok_or(AstError::Shape("modify item restricted missing value"))?
2619 .as_str();
2620 let restricted = match tok {
2621 "true" => true,
2622 "false" => false,
2623 _ => return Err(AstError::Shape("restricted expects true or false")),
2624 };
2625 patch.restricted = Some(restricted);
2626 },
2627 Rule::item_container_state_patch => {
2628 let state_word = stmt
2629 .as_str()
2630 .split_whitespace()
2631 .last()
2632 .ok_or(AstError::Shape("container state missing value"))?;
2633 match state_word {
2634 "off" => {
2635 patch.remove_container_state = true;
2636 patch.container_state = None;
2637 },
2638 "open" => {
2639 patch.container_state = Some(ContainerStateAst::Open);
2640 patch.remove_container_state = false;
2641 },
2642 "closed" => {
2643 patch.container_state = Some(ContainerStateAst::Closed);
2644 patch.remove_container_state = false;
2645 },
2646 "locked" => {
2647 patch.container_state = Some(ContainerStateAst::Locked);
2648 patch.remove_container_state = false;
2649 },
2650 "transparentClosed" => {
2651 patch.container_state = Some(ContainerStateAst::TransparentClosed);
2652 patch.remove_container_state = false;
2653 },
2654 "transparentLocked" => {
2655 patch.container_state = Some(ContainerStateAst::TransparentLocked);
2656 patch.remove_container_state = false;
2657 },
2658 _ => return Err(AstError::Shape("invalid container state in item patch")),
2659 }
2660 },
2661 Rule::item_add_ability => {
2662 let mut inner = stmt.into_inner();
2663 let ability_pair = inner.next().ok_or(AstError::Shape("add ability missing ability id"))?;
2664 patch.add_abilities.push(parse_patch_ability(ability_pair)?);
2665 },
2666 Rule::item_remove_ability => {
2667 let mut inner = stmt.into_inner();
2668 let ability_pair = inner
2669 .next()
2670 .ok_or(AstError::Shape("remove ability missing ability id"))?;
2671 patch.remove_abilities.push(parse_patch_ability(ability_pair)?);
2672 },
2673 _ => return Err(AstError::Shape("unexpected statement in item patch block")),
2674 }
2675 }
2676
2677 Ok((
2678 ActionStmt {
2679 priority,
2680 action: ActionAst::ModifyItem { item, patch },
2681 },
2682 consumed,
2683 ))
2684}
2685
2686fn parse_modify_room_action(text: &str) -> Result<(ActionStmt, usize), AstError> {
2687 let s = text.trim_start();
2688 let leading_ws = text.len() - s.len();
2689 let (priority, rest_after_do) = match strip_priority_clause(s) {
2690 Ok(v) => v,
2691 Err(AstError::Shape("not a do action")) => return Err(AstError::Shape("not a modify room action")),
2692 Err(e) => return Err(e),
2693 };
2694 let rest0 = rest_after_do
2695 .strip_prefix("modify room ")
2696 .ok_or(AstError::Shape("not a modify room action"))?;
2697 let rest0 = rest0.trim_start();
2698 let ident_len = rest0.chars().take_while(|&c| is_ident_char(c)).count();
2699 if ident_len == 0 {
2700 return Err(AstError::Shape("modify room missing room identifier"));
2701 }
2702 let ident_str = &rest0[..ident_len];
2703 DslParser::parse(Rule::ident, ident_str).map_err(|_| AstError::Shape("modify room has invalid room identifier"))?;
2704 let room = ident_str.to_string();
2705 let after_ident = rest0[ident_len..].trim_start();
2706 if !after_ident.starts_with('{') {
2707 return Err(AstError::Shape("modify room missing '{' block"));
2708 }
2709 let block_slice = after_ident;
2710 let body = extract_body(block_slice)?;
2711 let start_offset = unsafe { body.as_ptr().offset_from(block_slice.as_ptr()) as usize };
2714 let block_total_len = start_offset + body.len() + 1;
2715 let remaining = &block_slice[block_total_len..];
2716 let consumed = leading_ws + (s.len() - remaining.len());
2717 let patch_block = &block_slice[..block_total_len];
2718
2719 let mut pairs = DslParser::parse(Rule::room_patch_block, patch_block).map_err(|e| AstError::Pest(e.to_string()))?;
2720 let block_pair = pairs
2721 .next()
2722 .ok_or(AstError::Shape("modify room block missing statements"))?;
2723 let mut patch = RoomPatchAst::default();
2724
2725 for stmt in block_pair.into_inner() {
2726 match stmt.as_rule() {
2727 Rule::room_patch_name => {
2728 let mut inner = stmt.into_inner();
2729 let val = inner
2730 .next()
2731 .ok_or(AstError::Shape("modify room name missing value"))?
2732 .as_str();
2733 patch.name = Some(unquote(val));
2734 },
2735 Rule::room_patch_desc => {
2736 let mut inner = stmt.into_inner();
2737 let val = inner
2738 .next()
2739 .ok_or(AstError::Shape("modify room description missing value"))?
2740 .as_str();
2741 patch.desc = Some(unquote(val));
2742 },
2743 Rule::room_patch_remove_exit => {
2744 let mut inner = stmt.into_inner();
2745 let dest = inner
2746 .next()
2747 .ok_or(AstError::Shape("modify room remove exit missing destination"))?
2748 .as_str()
2749 .to_string();
2750 patch.remove_exits.push(dest);
2751 },
2752 Rule::room_patch_add_exit => {
2753 let mut inner = stmt.into_inner();
2754 let dir_pair = inner
2755 .next()
2756 .ok_or(AstError::Shape("modify room add exit missing direction"))?;
2757 let direction = if dir_pair.as_rule() == Rule::string {
2758 unquote(dir_pair.as_str())
2759 } else {
2760 dir_pair.as_str().to_string()
2761 };
2762 let dest_pair = inner
2763 .next()
2764 .ok_or(AstError::Shape("modify room add exit missing destination"))?;
2765 let to = dest_pair.as_str().to_string();
2766 let mut exit = RoomExitPatchAst {
2767 direction,
2768 to,
2769 ..Default::default()
2770 };
2771 if let Some(opts) = inner.next() {
2772 parse_room_patch_exit_opts(opts, &mut exit)?;
2773 }
2774 patch.add_exits.push(exit);
2775 },
2776 _ => return Err(AstError::Shape("unexpected statement in room patch block")),
2777 }
2778 }
2779
2780 Ok((
2781 ActionStmt {
2782 priority,
2783 action: ActionAst::ModifyRoom { room, patch },
2784 },
2785 consumed,
2786 ))
2787}
2788
2789fn parse_modify_npc_action(text: &str) -> Result<(ActionStmt, usize), AstError> {
2790 let s = text.trim_start();
2791 let leading_ws = text.len() - s.len();
2792 let (priority, rest_after_do) = match strip_priority_clause(s) {
2793 Ok(v) => v,
2794 Err(AstError::Shape("not a do action")) => return Err(AstError::Shape("not a modify npc action")),
2795 Err(e) => return Err(e),
2796 };
2797 let rest0 = rest_after_do
2798 .strip_prefix("modify npc ")
2799 .ok_or(AstError::Shape("not a modify npc action"))?;
2800 let rest0 = rest0.trim_start();
2801 let ident_len = rest0.chars().take_while(|&c| is_ident_char(c)).count();
2802 if ident_len == 0 {
2803 return Err(AstError::Shape("modify npc missing npc identifier"));
2804 }
2805 let ident_str = &rest0[..ident_len];
2806 DslParser::parse(Rule::ident, ident_str).map_err(|_| AstError::Shape("modify npc has invalid npc identifier"))?;
2807 let npc = ident_str.to_string();
2808 let after_ident = rest0[ident_len..].trim_start();
2809 if !after_ident.starts_with('{') {
2810 return Err(AstError::Shape("modify npc missing '{' block"));
2811 }
2812 let block_slice = after_ident;
2813 let body = extract_body(block_slice)?;
2814 let start_offset = unsafe { body.as_ptr().offset_from(block_slice.as_ptr()) as usize };
2815 let block_total_len = start_offset + body.len() + 1;
2816 let remaining = &block_slice[block_total_len..];
2817 let consumed = leading_ws + (s.len() - remaining.len());
2818 let patch_block = &block_slice[..block_total_len];
2819
2820 let mut pairs = DslParser::parse(Rule::npc_patch_block, patch_block).map_err(|e| AstError::Pest(e.to_string()))?;
2821 let block_pair = pairs
2822 .next()
2823 .ok_or(AstError::Shape("modify npc block missing statements"))?;
2824 let mut patch = NpcPatchAst::default();
2825 let mut movement_patch = NpcMovementPatchAst::default();
2826 let mut movement_touched = false;
2827
2828 for stmt in block_pair.into_inner() {
2829 match stmt.as_rule() {
2830 Rule::npc_patch_name => {
2831 let mut inner = stmt.into_inner();
2832 let val = inner
2833 .next()
2834 .ok_or(AstError::Shape("modify npc name missing value"))?
2835 .as_str();
2836 patch.name = Some(unquote(val));
2837 },
2838 Rule::npc_patch_desc => {
2839 let mut inner = stmt.into_inner();
2840 let val = inner
2841 .next()
2842 .ok_or(AstError::Shape("modify npc description missing value"))?
2843 .as_str();
2844 patch.desc = Some(unquote(val));
2845 },
2846 Rule::npc_patch_state => {
2847 let mut inner = stmt.into_inner();
2848 let state_pair = inner.next().ok_or(AstError::Shape("modify npc state missing value"))?;
2849 patch.state = Some(parse_npc_state_value(state_pair)?);
2850 },
2851 Rule::npc_patch_add_line => {
2852 let mut inner = stmt.into_inner();
2853 let line_pair = inner
2854 .next()
2855 .ok_or(AstError::Shape("modify npc add line missing text"))?;
2856 let state_pair = inner
2857 .next()
2858 .ok_or(AstError::Shape("modify npc add line missing state"))?;
2859 patch.add_lines.push(NpcDialoguePatchAst {
2860 line: unquote(line_pair.as_str()),
2861 state: parse_npc_state_value(state_pair)?,
2862 });
2863 },
2864 Rule::npc_patch_route => {
2865 if movement_patch.random_rooms.is_some() {
2866 return Err(AstError::Shape("modify npc cannot set both route and random rooms"));
2867 }
2868 let rooms = stmt.into_inner().map(|p| p.as_str().to_string()).collect::<Vec<_>>();
2869 movement_patch.route = Some(rooms);
2870 movement_patch.random_rooms = None;
2871 movement_touched = true;
2872 },
2873 Rule::npc_patch_random_rooms => {
2874 if movement_patch.route.is_some() {
2875 return Err(AstError::Shape("modify npc cannot set both route and random rooms"));
2876 }
2877 let rooms = stmt.into_inner().map(|p| p.as_str().to_string()).collect::<Vec<_>>();
2878 movement_patch.random_rooms = Some(rooms);
2879 movement_patch.route = None;
2880 movement_touched = true;
2881 },
2882 Rule::npc_patch_timing_every => {
2883 let mut inner = stmt.into_inner();
2884 let num_pair = inner
2885 .next()
2886 .ok_or(AstError::Shape("modify npc timing every missing turns"))?;
2887 let turns: i64 = num_pair
2888 .as_str()
2889 .parse()
2890 .map_err(|_| AstError::Shape("modify npc timing every invalid number"))?;
2891 if turns < 0 {
2892 return Err(AstError::Shape("modify npc timing every requires non-negative turns"));
2893 }
2894 movement_patch.timing = Some(NpcTimingPatchAst::EveryNTurns(turns as usize));
2895 movement_touched = true;
2896 },
2897 Rule::npc_patch_timing_on => {
2898 let mut inner = stmt.into_inner();
2899 let num_pair = inner
2900 .next()
2901 .ok_or(AstError::Shape("modify npc timing on missing turn"))?;
2902 let turn: i64 = num_pair
2903 .as_str()
2904 .parse()
2905 .map_err(|_| AstError::Shape("modify npc timing on invalid number"))?;
2906 if turn < 0 {
2907 return Err(AstError::Shape("modify npc timing on requires non-negative turn"));
2908 }
2909 movement_patch.timing = Some(NpcTimingPatchAst::OnTurn(turn as usize));
2910 movement_touched = true;
2911 },
2912 Rule::npc_patch_active => {
2913 let mut inner = stmt.into_inner();
2914 let bool_pair = inner.next().ok_or(AstError::Shape("modify npc active missing value"))?;
2915 let val = match bool_pair.as_str() {
2916 "true" => true,
2917 "false" => false,
2918 _ => return Err(AstError::Shape("modify npc active expects true or false")),
2919 };
2920 movement_patch.active = Some(val);
2921 movement_touched = true;
2922 },
2923 Rule::npc_patch_loop => {
2924 let mut inner = stmt.into_inner();
2925 let bool_pair = inner.next().ok_or(AstError::Shape("modify npc loop missing value"))?;
2926 let val = match bool_pair.as_str() {
2927 "true" => true,
2928 "false" => false,
2929 _ => return Err(AstError::Shape("modify npc loop expects true or false")),
2930 };
2931 movement_patch.loop_route = Some(val);
2932 movement_touched = true;
2933 },
2934 _ => return Err(AstError::Shape("unexpected statement in npc patch block")),
2935 }
2936 }
2937
2938 if movement_touched {
2939 patch.movement = Some(movement_patch);
2940 }
2941
2942 Ok((
2943 ActionStmt {
2944 priority,
2945 action: ActionAst::ModifyNpc { npc, patch },
2946 },
2947 consumed,
2948 ))
2949}
2950
2951fn parse_room_patch_exit_opts(opts: pest::iterators::Pair<Rule>, exit: &mut RoomExitPatchAst) -> Result<(), AstError> {
2952 debug_assert_eq!(opts.as_rule(), Rule::exit_opts);
2953 for opt in opts.into_inner() {
2954 let opt_text = opt.as_str().trim();
2955 if opt_text == "hidden" {
2956 exit.hidden = true;
2957 continue;
2958 }
2959 if opt_text == "locked" {
2960 exit.locked = true;
2961 continue;
2962 }
2963 let children: Vec<_> = opt.clone().into_inner().collect();
2964 if let Some(s) = children.iter().find(|p| p.as_rule() == Rule::string) {
2965 exit.barred_message = Some(unquote(s.as_str()));
2966 continue;
2967 }
2968 if opt_text.starts_with("required_items") && children.iter().all(|p| p.as_rule() == Rule::ident) {
2969 for item in children {
2970 exit.required_items.push(item.as_str().to_string());
2971 }
2972 continue;
2973 }
2974 if opt_text.starts_with("required_flags") {
2975 for flag_pair in opt.into_inner() {
2976 match flag_pair.as_rule() {
2977 Rule::ident => exit.required_flags.push(flag_pair.as_str().to_string()),
2978 Rule::flag_req => {
2979 let mut it = flag_pair.into_inner();
2980 let ident = it
2981 .next()
2982 .ok_or(AstError::Shape("flag requirement missing identifier"))?
2983 .as_str()
2984 .to_string();
2985 let base = ident.split('#').next().unwrap_or(&ident).to_string();
2986 exit.required_flags.push(base);
2987 },
2988 _ => {},
2989 }
2990 }
2991 continue;
2992 }
2993 }
2994 Ok(())
2995}
2996
2997fn parse_npc_state_value(pair: pest::iterators::Pair<Rule>) -> Result<NpcStateValue, AstError> {
2998 match pair.as_rule() {
2999 Rule::npc_state_value => {
3000 let mut inner = pair.into_inner();
3001 let next = inner.next().ok_or(AstError::Shape("npc state missing value"))?;
3002 parse_npc_state_value(next)
3003 },
3004 Rule::npc_custom_state => {
3005 let mut inner = pair.into_inner();
3006 let ident = inner
3007 .next()
3008 .ok_or(AstError::Shape("custom npc state missing identifier"))?;
3009 Ok(NpcStateValue::Custom(ident.as_str().to_string()))
3010 },
3011 Rule::ident => Ok(NpcStateValue::Named(pair.as_str().to_string())),
3012 _ => Err(AstError::Shape("invalid npc state value")),
3013 }
3014}
3015
3016fn parse_patch_ability(pair: pest::iterators::Pair<Rule>) -> Result<ItemAbilityAst, AstError> {
3017 debug_assert!(pair.as_rule() == Rule::ability_id);
3018 let raw = pair.as_str().trim();
3019 let ability: String = raw.chars().take_while(|c| !c.is_whitespace() && *c != '(').collect();
3020 if ability.is_empty() {
3021 return Err(AstError::Shape("ability name missing"));
3022 }
3023 let mut inner = pair.into_inner();
3024 let has_parens = raw.contains('(');
3025 let target = if ability == "Unlock" && has_parens {
3026 inner.next().map(|p| p.as_str().to_string())
3027 } else {
3028 None
3029 };
3030 Ok(ItemAbilityAst { ability, target })
3031}
3032
3033fn is_ident_char(ch: char) -> bool {
3034 ch.is_ascii_alphanumeric() || matches!(ch, '-' | ':' | '_' | '#')
3035}
3036
3037fn parse_schedule_action(
3038 text: &str,
3039 source: &str,
3040 smap: &SourceMap,
3041 sets: &HashMap<String, Vec<String>>,
3042) -> Result<(ActionStmt, usize), AstError> {
3043 let s = text.trim_start();
3044 let leading_ws = text.len() - s.len();
3045 let (priority, rest_after_do) = match strip_priority_clause(s) {
3046 Ok(v) => v,
3047 Err(AstError::Shape("not a do action")) => return Err(AstError::Shape("not a schedule action")),
3048 Err(e) => return Err(e),
3049 };
3050 let (rest0, is_in) = if let Some(r) = rest_after_do.strip_prefix("schedule in ") {
3051 (r, true)
3052 } else if let Some(r) = rest_after_do.strip_prefix("schedule on ") {
3053 (r, false)
3054 } else {
3055 return Err(AstError::Shape("not a schedule action"));
3056 };
3057 let mut idx = 0usize;
3059 while idx < rest0.len() && rest0.as_bytes()[idx].is_ascii_digit() {
3060 idx += 1;
3061 }
3062 if idx == 0 {
3063 return Err(AstError::Shape("schedule missing number"));
3064 }
3065 let num: usize = rest0[..idx]
3066 .parse()
3067 .map_err(|_| AstError::Shape("invalid schedule number"))?;
3068 let rest1 = &rest0[idx..].trim_start();
3069 let brace_pos = rest1.find('{').ok_or(AstError::Shape("schedule missing '{'"))?;
3071 let header = rest1[..brace_pos].trim();
3072
3073 let mut on_false = None;
3074 let mut note = None;
3075 let mut cond: Option<ConditionAst> = None;
3076
3077 if let Some(hdr_after_if) = header.strip_prefix("if ") {
3078 let onfalse_pos = hdr_after_if.find(" onFalse ");
3080 let note_pos = hdr_after_if.find(" note ");
3081 let mut cond_end = hdr_after_if.len();
3082 if let Some(p) = onfalse_pos {
3083 cond_end = cond_end.min(p);
3084 }
3085 if let Some(p) = note_pos {
3086 cond_end = cond_end.min(p);
3087 }
3088 let cond_str = hdr_after_if[..cond_end].trim();
3089 let extras = hdr_after_if[cond_end..].trim();
3090 if let Some(idx) = extras.find("onFalse ") {
3091 let after = &extras[idx + 8..];
3092 if after.starts_with("cancel") {
3093 on_false = Some(OnFalseAst::Cancel);
3094 } else if after.starts_with("retryNextTurn") {
3095 on_false = Some(OnFalseAst::RetryNextTurn);
3096 } else if let Some(tail) = after.strip_prefix("retryAfter ") {
3097 let mut k = 0;
3098 while k < tail.len() && tail.as_bytes()[k].is_ascii_digit() {
3099 k += 1;
3100 }
3101 if k == 0 {
3102 return Err(AstError::Shape("retryAfter missing turns"));
3103 }
3104 let turns: usize = tail[..k]
3105 .parse()
3106 .map_err(|_| AstError::Shape("invalid retryAfter turns"))?;
3107 on_false = Some(OnFalseAst::RetryAfter { turns });
3108 }
3109 }
3110 if let Some(n) = extract_note(header) {
3112 note = Some(n);
3113 }
3114 cond = Some(parse_condition_text(cond_str, sets)?);
3115 } else {
3116 if !header.is_empty() {
3118 if let Some(n) = extract_note(header) {
3119 note = Some(n);
3120 } else {
3121 if !header.trim().is_empty() {
3124 return Err(AstError::Shape(
3125 "unexpected schedule header; expected 'if ...' or 'note \"...\"'",
3126 ));
3127 }
3128 }
3129 }
3130 }
3131
3132 let mut p = brace_pos + 1;
3134 let bytes2 = rest1.as_bytes();
3135 let mut depth = 1i32;
3136 while p < rest1.len() {
3137 let c = bytes2[p] as char;
3138 if c == '{' {
3139 depth += 1;
3140 } else if c == '}' {
3141 depth -= 1;
3142 if depth == 0 {
3143 break;
3144 }
3145 }
3146 p += 1;
3147 }
3148 if depth != 0 {
3149 return Err(AstError::Shape("schedule block not closed"));
3150 }
3151 let inner_body = &rest1[brace_pos + 1..p];
3152 let actions = parse_actions_from_body(inner_body, source, smap, sets)?;
3153 let consumed = leading_ws + (s.len() - rest1[p + 1..].len());
3154
3155 let act = match (is_in, cond) {
3156 (true, Some(c)) => ActionAst::ScheduleInIf {
3157 turns_ahead: num,
3158 condition: Box::new(c),
3159 on_false,
3160 actions,
3161 note,
3162 },
3163 (false, Some(c)) => ActionAst::ScheduleOnIf {
3164 on_turn: num,
3165 condition: Box::new(c),
3166 on_false,
3167 actions,
3168 note,
3169 },
3170 (true, None) => ActionAst::ScheduleIn {
3171 turns_ahead: num,
3172 actions,
3173 note,
3174 },
3175 (false, None) => ActionAst::ScheduleOn {
3176 on_turn: num,
3177 actions,
3178 note,
3179 },
3180 };
3181 Ok((ActionStmt { priority, action: act }, consumed))
3182}
3183
3184#[allow(dead_code)]
3185fn strip_leading_ws_and_comments(s: &str) -> &str {
3186 let mut i = 0usize;
3187 let bytes = s.as_bytes();
3188 while i < bytes.len() {
3189 while i < bytes.len() && (bytes[i] as char).is_whitespace() {
3190 i += 1;
3191 }
3192 if i >= bytes.len() {
3193 break;
3194 }
3195 if bytes[i] as char == '#' {
3196 while i < bytes.len() && (bytes[i] as char) != '\n' {
3197 i += 1;
3198 }
3199 continue;
3200 }
3201 break;
3202 }
3203 &s[i..]
3204}
3205
3206struct SourceMap {
3208 line_starts: Vec<usize>,
3209 src: String,
3210}
3211impl SourceMap {
3212 fn new(source: &str) -> Self {
3213 let mut starts = vec![0usize];
3214 for (i, ch) in source.char_indices() {
3215 if ch == '\n' {
3216 starts.push(i + 1);
3217 }
3218 }
3219 Self {
3220 line_starts: starts,
3221 src: source.to_string(),
3222 }
3223 }
3224 fn line_col(&self, offset: usize) -> (usize, usize) {
3225 let idx = match self.line_starts.binary_search(&offset) {
3226 Ok(i) => i,
3227 Err(i) => i.saturating_sub(1),
3228 };
3229 let line_start = *self.line_starts.get(idx).unwrap_or(&0);
3230 let line_no = idx + 1;
3231 let col = offset.saturating_sub(line_start) + 1;
3232 (line_no, col)
3233 }
3234 fn line_snippet(&self, line_no: usize) -> String {
3235 let start = *self.line_starts.get(line_no - 1).unwrap_or(&0);
3236 let end = *self.line_starts.get(line_no).unwrap_or(&self.src.len());
3237 self.src[start..end].trim_end_matches(['\r', '\n']).to_string()
3238 }
3239}
3240
3241fn str_offset(full: &str, slice: &str) -> usize {
3242 (slice.as_ptr() as usize) - (full.as_ptr() as usize)
3243}
3244
3245fn extract_note(header: &str) -> Option<String> {
3246 if let Some(idx) = header.find("note ") {
3247 let after = &header[idx + 5..];
3248 let trimmed = after.trim_start();
3249 if let Ok((n, _used)) = parse_string_at(trimmed) {
3250 return Some(n);
3251 }
3252 }
3253 None
3254}
3255
3256#[cfg(test)]
3257mod tests {
3258 use super::*;
3259
3260 #[test]
3261 fn braces_in_strings_dont_break_body_scan() {
3262 let src = r#"
3263trigger "brace text" when always {
3264 do show "Shiny {curly} braces"
3265}
3266"#;
3267 parse_trigger(src).expect("should parse");
3268 }
3269
3270 #[test]
3271 fn braces_in_comments_dont_break_body_scan() {
3272 let src = r#"
3273trigger "comment braces" when always {
3274 # { not a block } in comment
3275 do show "ok"
3276}
3277"#;
3278 parse_trigger(src).expect("should parse");
3279 }
3280
3281 #[test]
3282 fn quoted_strings_support_common_escapes() {
3283 let src = r#"
3284trigger "He said:\n\"hi\"" when always {
3285 do show "Line1\nLine2"
3286 do npc says gonk "She replied: \"no\""
3287}
3288"#;
3289 let ast = parse_trigger(src).expect("parse ok");
3290 assert!(ast.name.contains('\n'));
3291 assert!(ast.name.contains('"'));
3292 match &ast.actions[0].action {
3294 ActionAst::Show(s) => {
3295 assert!(s.contains('\n'));
3296 assert_eq!(s, "Line1\nLine2");
3297 },
3298 _ => panic!("expected show"),
3299 }
3300 match &ast.actions[1].action {
3302 ActionAst::NpcSays { npc, quote } => {
3303 assert_eq!(npc, "gonk");
3304 assert!(quote.contains('"'));
3305 },
3306 _ => panic!("expected npc says"),
3307 }
3308 let toml = crate::compile_trigger_to_toml(&ast).expect("compile ok");
3310 assert!(toml.contains("Line1"));
3311 assert!(toml.contains("Line2"));
3312 }
3313
3314 #[test]
3315 fn schedule_note_supports_escapes() {
3316 let src = r#"
3317trigger "note escapes" when always {
3318 do schedule in 1 note "lineA\nlineB" {
3319 do show "ok"
3320 }
3321}
3322"#;
3323 let ast = parse_trigger(src).expect("parse ok");
3324 let t = crate::compile_trigger_to_toml(&ast).expect("compile ok");
3325 assert!(t.contains("lineA"));
3326 assert!(t.contains("lineB"));
3327 }
3328
3329 #[test]
3330 fn modify_item_parses_patch_fields() {
3331 let src = r#"
3332trigger "patch locker" when always {
3333 do modify item locker {
3334 name "Unlocked locker"
3335 description "It's open now"
3336 text "notes"
3337 portable false
3338 restricted true
3339 container state locked
3340 add ability Unlock ( secret-door )
3341 add ability Ignite
3342 remove ability Unlock ( secret-door )
3343 remove ability Unlock
3344 }
3345}
3346"#;
3347 let ast = parse_trigger(src).expect("parse ok");
3348 assert_eq!(ast.actions.len(), 1);
3349 let action = &ast.actions[0].action;
3350 match action {
3351 ActionAst::ModifyItem { item, patch } => {
3352 assert_eq!(item, "locker");
3353 assert_eq!(patch.name.as_deref(), Some("Unlocked locker"));
3354 assert_eq!(patch.desc.as_deref(), Some("It's open now"));
3355 assert_eq!(patch.text.as_deref(), Some("notes"));
3356 assert_eq!(patch.portable, Some(false));
3357 assert_eq!(patch.restricted, Some(true));
3358 assert_eq!(patch.container_state, Some(ContainerStateAst::Locked));
3359 assert!(!patch.remove_container_state);
3360 assert_eq!(patch.add_abilities.len(), 2);
3361 assert_eq!(patch.add_abilities[0].ability, "Unlock");
3362 assert_eq!(patch.add_abilities[0].target.as_deref(), Some("secret-door"));
3363 assert_eq!(patch.add_abilities[1].ability, "Ignite");
3364 assert!(patch.add_abilities[1].target.is_none());
3365 assert_eq!(patch.remove_abilities.len(), 2);
3366 assert_eq!(patch.remove_abilities[0].ability, "Unlock");
3367 assert_eq!(patch.remove_abilities[0].target.as_deref(), Some("secret-door"));
3368 assert_eq!(patch.remove_abilities[1].ability, "Unlock");
3369 assert!(patch.remove_abilities[1].target.is_none());
3370 },
3371 other => panic!("expected modify item action, got {other:?}"),
3372 }
3373 let toml = crate::compile_trigger_to_toml(&ast).expect("compile ok");
3374 assert!(toml.contains("type = \"modifyItem\""));
3375 assert!(toml.contains("item_sym = \"locker\""));
3376 assert!(toml.contains("name = \"Unlocked locker\""));
3377 assert!(toml.contains("desc = \"It's open now\""));
3378 assert!(toml.contains("portable = false"));
3379 assert!(toml.contains("restricted = true"));
3380 assert!(toml.contains("container_state = \"locked\""));
3381 assert!(toml.contains("add_abilities = ["));
3382 assert!(toml.contains("remove_abilities = ["));
3383 }
3384
3385 #[test]
3386 fn modify_room_parses_patch_fields() {
3387 let src = r#"
3388trigger "patch lab" when always {
3389 do modify room aperture-lab {
3390 name "Ruined Lab"
3391 desc "Charred and broken."
3392 remove exit portal-room
3393 add exit "through the vault door" -> stargate-room {
3394 locked,
3395 required_items (vault-key),
3396 required_flags (opened-vault),
3397 barred "You can't go that way yet."
3398 }
3399 }
3400}
3401"#;
3402 let offset = src.find("do modify room").expect("snippet find");
3403 let snippet = &src[offset..];
3404 let (helper_action, _used) = super::parse_modify_room_action(snippet).expect("parse helper on snippet");
3405 assert!(matches!(&helper_action.action, ActionAst::ModifyRoom { .. }));
3406 let ast = parse_trigger(src).expect("parse ok");
3407 assert_eq!(ast.actions.len(), 1);
3408 match &ast.actions[0].action {
3409 ActionAst::ModifyRoom { room, patch } => {
3410 assert_eq!(room, "aperture-lab");
3411 assert_eq!(patch.name.as_deref(), Some("Ruined Lab"));
3412 assert_eq!(patch.desc.as_deref(), Some("Charred and broken."));
3413 assert_eq!(patch.remove_exits, vec!["portal-room"]);
3414 assert_eq!(patch.add_exits.len(), 1);
3415 let exit = &patch.add_exits[0];
3416 assert_eq!(exit.direction, "through the vault door");
3417 assert_eq!(exit.to, "stargate-room");
3418 assert!(exit.locked);
3419 assert!(!exit.hidden);
3420 assert_eq!(exit.required_items, vec!["vault-key"]);
3421 assert_eq!(exit.required_flags, vec!["opened-vault"]);
3422 assert_eq!(exit.barred_message.as_deref(), Some("You can't go that way yet."));
3423 },
3424 other => panic!("expected modify room action, got {other:?}"),
3425 }
3426 let toml = crate::compile_trigger_to_toml(&ast).expect("compile ok");
3427 assert!(toml.contains("type = \"modifyRoom\""));
3428 assert!(toml.contains("room_sym = \"aperture-lab\""));
3429 assert!(toml.contains("remove_exits = [\"portal-room\"]"));
3430 assert!(toml.contains("add_exits = ["));
3431 assert!(toml.contains("barred_message = \"You can't go that way yet.\""));
3432 assert!(toml.contains("required_flags = [{ type = \"simple\", name = \"opened-vault\" }]"));
3433 }
3434
3435 #[test]
3436 fn modify_npc_parses_patch_fields() {
3437 let src = r#"
3438trigger "patch emh" when always {
3439 do modify npc emh {
3440 name "Emergency Medical Hologram"
3441 desc "Program updated with bedside manner routines."
3442 state custom(patched)
3443 add line "Bedside manner protocols active." to state custom(patched)
3444 add line "Please state the nature of the medical emergency." to state normal
3445 route (sickbay, corridor)
3446 timing every 5 turns
3447 active false
3448 loop false
3449 }
3450}
3451"#;
3452 let offset = src.find("do modify npc").expect("snippet find");
3453 let snippet = &src[offset..];
3454 let (helper_action, _used) = super::parse_modify_npc_action(snippet).expect("parse helper on snippet");
3455 assert!(matches!(&helper_action.action, ActionAst::ModifyNpc { .. }));
3456 let ast = parse_trigger(src).expect("parse ok");
3457 assert_eq!(ast.actions.len(), 1);
3458 match &ast.actions[0].action {
3459 ActionAst::ModifyNpc { npc, patch } => {
3460 assert_eq!(npc, "emh");
3461 assert_eq!(patch.name.as_deref(), Some("Emergency Medical Hologram"));
3462 assert_eq!(
3463 patch.desc.as_deref(),
3464 Some("Program updated with bedside manner routines.")
3465 );
3466 assert!(matches!(patch.state, Some(NpcStateValue::Custom(ref s)) if s == "patched"));
3467 assert_eq!(patch.add_lines.len(), 2);
3468 assert!(patch.add_lines.iter().any(
3469 |entry| matches!(entry.state, NpcStateValue::Custom(ref s) if s == "patched")
3470 && entry.line == "Bedside manner protocols active."
3471 ));
3472 assert!(patch.add_lines.iter().any(
3473 |entry| matches!(entry.state, NpcStateValue::Named(ref s) if s == "normal")
3474 && entry.line == "Please state the nature of the medical emergency."
3475 ));
3476 let movement = patch.movement.as_ref().expect("movement patch");
3477 assert_eq!(movement.route.as_deref().unwrap(), ["sickbay", "corridor"]);
3478 assert!(movement.random_rooms.is_none());
3479 assert_eq!(movement.active, Some(false));
3480 assert_eq!(movement.loop_route, Some(false));
3481 assert!(matches!(movement.timing, Some(NpcTimingPatchAst::EveryNTurns(5))));
3482 },
3483 other => panic!("expected modify npc action, got {other:?}"),
3484 }
3485 let toml = crate::compile_trigger_to_toml(&ast).expect("compile ok");
3486 assert!(toml.contains("type = \"modifyNpc\""));
3487 assert!(toml.contains("npc_sym = \"emh\""));
3488 assert!(toml.contains("route = [\"sickbay\", \"corridor\"]"));
3489 assert!(toml.contains("type = \"everyNTurns\""));
3490 assert!(toml.contains("loop_route = false"));
3491 }
3492
3493 #[test]
3494 fn modify_npc_supports_random_movement() {
3495 let src = r#"
3496trigger "patch guard" when always {
3497 do modify npc guard {
3498 random rooms (hall, foyer, atrium)
3499 timing on turn 12
3500 active true
3501 }
3502}
3503"#;
3504 let ast = parse_trigger(src).expect("parse ok");
3505 assert_eq!(ast.actions.len(), 1);
3506 match &ast.actions[0].action {
3507 ActionAst::ModifyNpc { npc, patch } => {
3508 assert_eq!(npc, "guard");
3509 let movement = patch.movement.as_ref().expect("movement patch");
3510 assert!(movement.route.is_none());
3511 let mut rooms = movement.random_rooms.clone().expect("random rooms");
3512 rooms.sort();
3513 let expected = vec!["atrium".to_string(), "foyer".to_string(), "hall".to_string()];
3514 assert_eq!(rooms, expected);
3515 assert!(matches!(movement.timing, Some(NpcTimingPatchAst::OnTurn(12))));
3516 assert_eq!(movement.active, Some(true));
3517 assert!(movement.loop_route.is_none());
3518 },
3519 other => panic!("expected modify npc action, got {other:?}"),
3520 }
3521 let toml = crate::compile_trigger_to_toml(&ast).expect("compile ok");
3522 assert!(toml.contains("random_rooms = [\"hall\", \"foyer\", \"atrium\"]"));
3523 assert!(toml.contains("type = \"onTurn\""));
3524 }
3525
3526 #[test]
3527 fn parse_modify_room_action_helper_handles_basic_block() {
3528 let snippet = "do modify room lab { name \"Ruined\" }\n";
3529 let (action, used) = super::parse_modify_room_action(snippet).expect("parse helper");
3530 assert_eq!(&snippet[..used], "do modify room lab { name \"Ruined\" }");
3531 match &action.action {
3532 ActionAst::ModifyRoom { room, patch } => {
3533 assert_eq!(room, "lab");
3534 assert_eq!(patch.name.as_deref(), Some("Ruined"));
3535 },
3536 other => panic!("expected modify room action, got {other:?}"),
3537 }
3538 }
3539
3540 #[test]
3541 fn parse_modify_item_action_helper_handles_basic_block() {
3542 let snippet = "do modify item locker { name \"Ok\" }\n";
3543 let (action, used) = super::parse_modify_item_action(snippet).expect("parse helper");
3544 assert_eq!(&snippet[..used], "do modify item locker { name \"Ok\" }");
3545 match &action.action {
3546 ActionAst::ModifyItem { item, patch } => {
3547 assert_eq!(item, "locker");
3548 assert_eq!(patch.name.as_deref(), Some("Ok"));
3549 },
3550 other => panic!("expected modify item action, got {other:?}"),
3551 }
3552 }
3553
3554 #[test]
3555 fn modify_item_container_state_off_sets_flag() {
3556 let src = r#"
3557trigger "patch chest" when always {
3558 do modify item chest {
3559 container state off
3560 }
3561}
3562"#;
3563 let ast = parse_trigger(src).expect("parse ok");
3564 let action = ast.actions.first().expect("expected modify item action");
3565 match &action.action {
3566 ActionAst::ModifyItem { item, patch } => {
3567 assert_eq!(item, "chest");
3568 assert!(patch.container_state.is_none());
3569 assert!(patch.remove_container_state);
3570 },
3571 other => panic!("expected modify item action, got {other:?}"),
3572 }
3573 let toml = crate::compile_trigger_to_toml(&ast).expect("compile ok");
3574 assert!(toml.contains("remove_container_state = true"));
3575 assert!(!toml.contains("container_state = \""));
3576 }
3577
3578 #[test]
3579 fn raw_string_with_hash_quotes() {
3580 let src = "trigger r#\"raw name with \"quotes\"\"# when always {\n do show r#\"He said \"hi\"\"#\n}\n";
3581 let asts = super::parse_program(src).expect("parse ok");
3582 assert!(!asts.is_empty());
3583 let toml = crate::compile_trigger_to_toml(&asts[0]).expect("compile ok");
3585 assert!(toml.contains("He said"));
3586 assert!(toml.contains("hi"));
3587 }
3588
3589 #[test]
3590 fn consumable_when_replace_inventory_matches_rule() {
3591 let mut pairs = DslParser::parse(
3592 Rule::consumable_when_consumed,
3593 "when_consumed replace inventory wrapper",
3594 )
3595 .expect("parse ok");
3596 let pair = pairs.next().expect("pair");
3597 assert_eq!(pair.as_rule(), Rule::consumable_when_consumed);
3598 }
3599
3600 #[test]
3601 fn consumable_block_allows_replace_inventory() {
3602 let src = "consumable {\n uses_left 2\n when_consumed replace inventory wrapper\n}";
3603 let mut pairs = DslParser::parse(Rule::item_consumable, src).expect("parse ok");
3604 let pair = pairs.next().expect("pair");
3605 assert_eq!(pair.as_rule(), Rule::item_consumable);
3606 let mut inner = pair.into_inner();
3607 let block = inner.next().expect("block");
3608 assert_eq!(block.as_rule(), Rule::consumable_block);
3609 let mut block_inner = block.into_inner();
3610 let stmt = block_inner.next().expect("stmt");
3611 assert_eq!(stmt.as_rule(), Rule::consumable_stmt);
3612 assert_eq!(stmt.into_inner().next().expect("uses").as_rule(), Rule::consumable_uses);
3613 let stmt = block_inner.next().expect("stmt");
3614 assert_eq!(stmt.as_rule(), Rule::consumable_stmt);
3615 assert_eq!(
3616 stmt.into_inner().next().expect("when").as_rule(),
3617 Rule::consumable_when_consumed
3618 );
3619 }
3620
3621 #[test]
3622 fn consumable_block_with_consume_on_and_when_consumed_parses() {
3623 let src = "consumable {\n uses_left 1\n consume_on ability Use\n when_consumed replace inventory wrapper\n}";
3624 let mut pairs = DslParser::parse(Rule::item_consumable, src).expect("parse ok");
3625 let block = pairs.next().expect("pair").into_inner().next().expect("block");
3626 let mut inner = block.into_inner();
3627 let mut stmt = inner.next().expect("stmt");
3628 assert_eq!(stmt.as_rule(), Rule::consumable_stmt);
3629 assert_eq!(stmt.into_inner().next().expect("uses").as_rule(), Rule::consumable_uses);
3630 stmt = inner.next().expect("stmt");
3631 assert_eq!(stmt.as_rule(), Rule::consumable_stmt);
3632 assert_eq!(
3633 stmt.into_inner().next().expect("consume_on").as_rule(),
3634 Rule::consumable_consume_on
3635 );
3636 stmt = inner.next().expect("stmt");
3637 assert_eq!(stmt.as_rule(), Rule::consumable_stmt);
3638 assert_eq!(
3639 stmt.into_inner().next().expect("when").as_rule(),
3640 Rule::consumable_when_consumed
3641 );
3642 }
3643
3644 #[test]
3645 fn consumable_consume_on_rule_parses() {
3646 let src = "consume_on ability Use";
3647 let mut pairs = DslParser::parse(Rule::consumable_consume_on, src).expect("parse ok");
3648 let pair = pairs.next().expect("pair");
3649 assert_eq!(pair.as_rule(), Rule::consumable_consume_on);
3650 }
3651
3652 #[test]
3653 fn consumable_consume_on_does_not_consume_when_keyword() {
3654 let src = "consume_on ability Use when_consumed";
3655 let mut pairs = DslParser::parse(Rule::consumable_consume_on, src).expect("parse ok");
3656 let pair = pairs.next().expect("pair");
3657 assert_eq!(pair.as_str().trim_end(), "consume_on ability Use");
3659 }
3660
3661 #[test]
3662 fn npc_movement_loop_flag_parses_and_compiles() {
3663 let src = r#"
3664npc bot {
3665 name "Maintenance Bot"
3666 desc "Keeps the corridors tidy."
3667 location room hub
3668 state idle
3669 movement route rooms (hub, hall) timing every_3_turns active true loop false
3670}
3671"#;
3672 let npcs = crate::parse_npcs(src).expect("parse npcs ok");
3673 assert_eq!(npcs.len(), 1);
3674 let movement = npcs[0].movement.as_ref().expect("movement present");
3675 assert_eq!(movement.loop_route, Some(false));
3676
3677 let toml = crate::compile_npcs_to_toml(&npcs).expect("compile npcs");
3678 assert!(toml.contains("loop_route = false"));
3679 }
3680
3681 #[test]
3682 fn item_with_consumable_parses() {
3683 let src = r#"item snack {
3684 name "Snack"
3685 desc "Yum"
3686 portable true
3687 location inventory player
3688 consumable {
3689 uses_left 1
3690 consume_on ability Use
3691 when_consumed replace inventory wrapper
3692 }
3693}
3694"#;
3695 DslParser::parse(Rule::item_def, src).expect("parse ok");
3696 }
3697
3698 #[test]
3699 fn string_literals_preserve_utf8_characters() {
3700 let s = "\"Pilgrims Welcome – Pancakes\"";
3701 let parsed = parse_string(s).expect("parse ok");
3702 assert_eq!(parsed, "Pilgrims Welcome – Pancakes");
3703
3704 let s2 = "\"It’s fine\"";
3705 let parsed2 = parse_string(s2).expect("parse ok");
3706 assert_eq!(parsed2, "It’s fine");
3707 }
3708
3709 #[test]
3710 fn reserved_keywords_are_excluded_from_ident() {
3711 let src = r#"
3713trigger "bad ident" when enter room trigger {
3714 do show "won't get here"
3715}
3716"#;
3717 let err = parse_trigger(src).expect_err("expected parse failure");
3718 match err {
3719 AstError::Pest(_) | AstError::Shape(_) | AstError::ShapeAt { .. } => {},
3720 }
3721 }
3722}
3723pub type ProgramAstBundle = (
3725 Vec<TriggerAst>,
3726 Vec<RoomAst>,
3727 Vec<ItemAst>,
3728 Vec<SpinnerAst>,
3729 Vec<NpcAst>,
3730 Vec<GoalAst>,
3731);
3732fn strip_priority_clause(text: &str) -> Result<(Option<isize>, &str), AstError> {
3733 let rest = text.strip_prefix("do").ok_or(AstError::Shape("not a do action"))?;
3734 let mut after = rest.trim_start();
3735 if let Some(rem) = after.strip_prefix("priority") {
3736 after = rem.trim_start();
3737 let mut idx = 0usize;
3738 if after.starts_with('-') {
3739 idx += 1;
3740 }
3741 while idx < after.len() && after.as_bytes()[idx].is_ascii_digit() {
3742 idx += 1;
3743 }
3744 if idx == 0 || (idx == 1 && after.starts_with('-')) {
3745 return Err(AstError::Shape("priority missing number"));
3746 }
3747 let num: isize = after[..idx]
3748 .parse()
3749 .map_err(|_| AstError::Shape("invalid priority number"))?;
3750 let tail = after[idx..].trim_start();
3751 return Ok((Some(num), tail));
3752 }
3753 Ok((None, after))
3754}
3755
3756fn parse_action_from_str(text: &str) -> Result<ActionStmt, AstError> {
3757 let trimmed = text.trim();
3758 let (priority, rest) = strip_priority_clause(trimmed)?;
3759 let source = if priority.is_some() {
3760 Cow::Owned(format!("do {rest}"))
3761 } else {
3762 Cow::Borrowed(trimmed)
3763 };
3764 let action = parse_action_core(&source)?;
3765 Ok(ActionStmt { priority, action })
3766}