amble_script/
parser.rs

1//! Parser and AST builders for the Amble DSL.
2//!
3//! Wraps the Pest-generated grammar with helpers that construct the
4//! compiler's abstract syntax tree for triggers, rooms, items, and more.
5
6use 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/// Errors that can happen when parsing the DSL input.
22#[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
32/// Parse a single trigger source string; returns the first trigger found.
33///
34/// # Errors
35/// Returns an error if the source cannot be parsed or if no trigger is found.
36pub 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
41/// Parse multiple triggers from a full source file (triggers only view).
42///
43/// # Errors
44/// Returns an error if the source cannot be parsed.
45pub fn parse_program(source: &str) -> Result<Vec<TriggerAst>, AstError> {
46    let (triggers, ..) = parse_program_full(source)?;
47    Ok(triggers)
48}
49
50/// Parse a full program returning triggers, rooms, items, and spinners.
51///
52/// # Errors
53/// Returns an error when parsing fails or when the grammar encounters an
54/// unexpected shape.
55pub 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    // trigger -> "trigger" ~ string ~ (only once|note)* ~ "when" ~ when_cond ~ block
144    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    // optional modifiers: only once and/or note in any order
151    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    // Parse the trigger body and lower into multiple TriggerAst entries:
350    // - Each top-level `if { ... }` becomes its own trigger with those actions.
351    // - Top-level `do ...` lines (not inside any if) become an unconditional trigger (if any).
352    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        // Skip whitespace
359        while i < inner.len() && (bytes[i] as char).is_whitespace() {
360            i += 1;
361        }
362        if i >= inner.len() {
363            break;
364        }
365        // Skip comments
366        if bytes[i] as char == '#' {
367            while i < inner.len() && (bytes[i] as char) != '\n' {
368                i += 1;
369            }
370            continue;
371        }
372        // If-block
373        if inner[i..].starts_with("if ") {
374            let if_pos = i;
375            // Find opening brace
376            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            // Extract the block body after this '{' balancing braces
397            let block_after = &rest[brace_rel..]; // starts with '{'
398            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            // Advance i to after the block we just consumed
410            let consumed = brace_rel + 1 + body.len() + 1; // '{' + body + '}'
411            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        // Top-level do schedule ... or do ... line
483        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            // Consume a single line
494            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        // Unknown token on this line, skip to newline
520        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    // Inject note into previously lowered triggers
536    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    // room_def = "room" ~ ident ~ room_block
546    let (src_line, _src_col) = room.as_span().start_pos().line_col();
547    let mut it = room.into_inner();
548    // capture source line from the outer pair's span; .line_col() is 1-based
549    // Note: this is the start of the room keyword; good enough for a reference
550    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        // room_block yields Rule::room_stmt nodes; unwrap to the concrete inner rule
566        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                // Defaults
611                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                            // Simplest detection by textual head, then use children for values
620                            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                            // pull children
631                            let children: Vec<_> = opt.clone().into_inner().collect();
632                            // barred <string>
633                            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                            // required_items(...): list of idents only
638                            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                            // required_flags(...): list of idents or flag_req; we normalize to base name
647                            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                                            // Extract ident child and keep only base name (ignore step/end since equality is by name)
655                                            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                // overlay if <cond_list> { text "..." }
683                let mut it = inner_stmt.into_inner();
684                // First group: overlay_cond_list
685                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                    // Unknown overlay condition; ignore silently per current behavior
774                }
775                // Ensure at least one condition was parsed (catch typos early)
776                if conds.is_empty() {
777                    return Err(AstError::Shape("overlay requires at least one condition"));
778                }
779
780                // Then block with text
781                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                // overlay if flag <id> { set "..." unset "..." }
796                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                // overlay if item <id> { present "..." absent "..." }
813                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                // overlay if npc <id> { present "..." absent "..." }
830                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                // overlay if npc <id> here { <state> "..." | custom(<id>) "..." }+
847                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                        // named state
877                        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                // New order: ability first, then interaction
1034                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                // Store as (interaction, ability) to match TOML mapping
1045                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        // width is optional; default to 1
1153        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                // First token: either ident or 'custom'
1290                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                    // custom ident
1295                    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                // movement <random|route> rooms (<ids>) [timing <ident>] [active <bool>]
1306                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                // rooms list inside (...)
1313                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                // dialogue <state|custom ident> { "..."+ }
1359                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                // Fallback: simple text-based parsing for robustness
1381                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                    // or description
1390                    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                        // take first token as named state
1411                        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                    // dialogue <state|custom id> { "..."+ }
1472                    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                                    // consume until next quote or end
1501                                    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
1534/// Parse only rooms from a source (helper/testing).
1535/// Parse only room definitions from the given source.
1536///
1537/// # Errors
1538/// Returns an error if the source cannot be parsed into rooms.
1539pub fn parse_rooms(source: &str) -> Result<Vec<RoomAst>, AstError> {
1540    let (_, rooms, _, _, _, _) = parse_program_full(source)?;
1541    Ok(rooms)
1542}
1543
1544/// Parse only items from a source (helper/testing).
1545/// Parse only item definitions from the given source.
1546///
1547/// # Errors
1548/// Returns an error if the source cannot be parsed into items.
1549pub fn parse_items(source: &str) -> Result<Vec<ItemAst>, AstError> {
1550    let (_, _, items, _, _, _) = parse_program_full(source)?;
1551    Ok(items)
1552}
1553
1554/// Parse only spinners from a source (helper/testing).
1555/// Parse only spinner definitions from the given source.
1556///
1557/// # Errors
1558/// Returns an error if the source cannot be parsed into spinners.
1559pub fn parse_spinners(source: &str) -> Result<Vec<SpinnerAst>, AstError> {
1560    let (_, _, _, spinners, _, _) = parse_program_full(source)?;
1561    Ok(spinners)
1562}
1563
1564/// Parse only npcs from a source (helper/testing).
1565/// Parse only NPC definitions from the given source.
1566///
1567/// # Errors
1568/// Returns an error if the source cannot be parsed into NPCs.
1569pub fn parse_npcs(source: &str) -> Result<Vec<NpcAst>, AstError> {
1570    let (_, _, _, _, npcs, _) = parse_program_full(source)?;
1571    Ok(npcs)
1572}
1573
1574/// Parse only goal definitions from the given source.
1575///
1576/// # Errors
1577/// Returns an error if the source cannot be parsed into goals.
1578pub 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
1587/// Parse a string literal (supports "...", r"...", and """..."""). Returns the decoded value.
1588fn parse_string(s: &str) -> Result<String, AstError> {
1589    let (v, _u) = parse_string_at(s)?;
1590    Ok(v)
1591}
1592
1593/// Parse a string literal starting at the beginning of `s`. Returns (decoded, bytes_consumed).
1594fn 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    // Triple-quoted
1600    if s.starts_with("\"\"\"") {
1601        let mut out = String::new();
1602        let mut i = 3usize; // after opening """
1603        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    // Raw r"..." and hashed raw r#"..."#
1634    if s.starts_with('r') {
1635        // Count hashes after r
1636        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    // Single-quoted
1660    if b[0] as char == '\'' {
1661        let mut out = String::new();
1662        let mut i = 1usize; // skip opening '
1663        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    // Regular quoted
1694    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; // skip opening quote
1699    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            // inside string, line starts don't apply
1759            i += 1;
1760            continue;
1761        }
1762        match c {
1763            '\n' => {
1764                at_line_start = true;
1765            },
1766            ' ' | '\t' | '\r' => {
1767                // keep at_line_start as-is
1768            },
1769            '"' => {
1770                in_str = true;
1771                at_line_start = false;
1772            },
1773            '#' => {
1774                // Treat '#' as a comment only if it begins the line (ignoring leading spaces)
1775                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    // Preferred: container <container> has item <item>
1875    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    // Preferred shorthand: "in rooms <r1,r2,...>" expands to any(player in room r1, player in room r2, ...)
1923    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 only one room, return simple PlayerInRoom; else return Any of PlayerInRoom
1934        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                // Only split at commas that separate conditions, not those inside
1969                // lists like "ambient ... in rooms a,b,c".
1970                let mut j = i + 1;
1971                while j < bytes.len() && (bytes[j] as char).is_whitespace() {
1972                    j += 1;
1973                }
1974                // capture next word
1975                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        // do add wedge "text" [width <n>] spinner <ident>
2026        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        // optional width
2030        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        // Syntax: do add seq flag <name> [limit <n>]
2054        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        // format: <npc> into room <room>
2101        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        // format: <item> into room <room>
2115        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        // do set barred message from <exit_from> to <exit_to> "msg"
2150        let rest = rest.trim();
2151        if let Some((from, tail)) = rest.split_once(" to ") {
2152            let tail = tail.trim();
2153            // tail is: <exit_to> "message..."
2154            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        // do give item <item> to player from npc <npc>
2201        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        // format: <from> to <to> direction <dir>
2212        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        // format: <item> "text"
2233        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        // format: <npc> "quote"
2248        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        // format: <npc> <true/false>
2283        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        // format: <npc> <state>
2301        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        // do set container state <item> <state|none>
2322        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    // schedule actions are parsed at the block level in parse_actions_from_body
2342    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    // SAFETY: `body` was produced by `extract_body` from `block_slice`, so both pointers
2562    // lie within the same string slice.
2563    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    // SAFETY: `body` was produced by `extract_body` from `block_slice`, so both pointers
2712    // lie within the same string slice.
2713    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    // parse number
3058    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    // Find the opening brace of the block and capture header between number and '{'
3070    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        // Conditional schedule path
3079        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        // Note can appear anywhere in header; parse from full header too
3111        if let Some(n) = extract_note(header) {
3112            note = Some(n);
3113        }
3114        cond = Some(parse_condition_text(cond_str, sets)?);
3115    } else {
3116        // Unconditional schedule path; allow optional note in header
3117        if !header.is_empty() {
3118            if let Some(n) = extract_note(header) {
3119                note = Some(n);
3120            } else {
3121                // Unknown tokens in header
3122                // Be forgiving: ignore whitespace-only; otherwise error
3123                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    // Extract block body
3133    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
3206// ---------- Error mapping helpers ----------
3207struct 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        // show contains a newline
3293        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        // npc says contains a quote
3301        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        // TOML may re-escape newlines or include them directly; just ensure both parts appear
3309        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        // Ensure value with embedded quotes is preserved (serializer may re-escape)
3584        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        // The rule should stop before the trailing keyword to allow the block to parse the next statement.
3658        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        // Using a keyword as an identifier should fail to parse
3712        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}
3723/// Composite AST collections returned by [`parse_program_full`].
3724pub 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}