Skip to content

Commit 68eaf21

Browse files
authored
Fix dialogue extraction and reconstruction for menu titles (#186)
2 parents ea77c58 + 6ce0c84 commit 68eaf21

2 files changed

Lines changed: 154 additions & 7 deletions

File tree

apps/backend/src/services/__tests__/rpy-parser.service.unit.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,26 @@ label START:
263263

264264
expect(dialogue[0].text).toContain("multiline dialogue");
265265
});
266+
267+
it("should extract menu titles as editable dialogue", () => {
268+
const menuRPY = `label start:
269+
"Before menu"
270+
menu:
271+
"Menu title"
272+
"Choice 1":
273+
jump somewhere
274+
"Choice 2":
275+
jump elsewhere
276+
"After menu"
277+
`;
278+
const dialogue = extractDialogue(menuRPY);
279+
280+
// Menu titles are editable in write mode — extract them as narration
281+
expect(dialogue).toHaveLength(3);
282+
expect(dialogue[0].text).toBe("Before menu");
283+
expect(dialogue[1].text).toBe("Menu title");
284+
expect(dialogue[2].text).toBe("After menu");
285+
});
266286
});
267287

268288
describe("extractChoices", () => {
@@ -776,6 +796,29 @@ label title:
776796
);
777797
expect(resultWithPartial.fileType).toBe("STORY");
778798
});
799+
800+
it("should extract menu titles as editable dialogue in label dialogue", () => {
801+
const menuRPY = `label start:
802+
"Before menu"
803+
menu:
804+
"Menu title"
805+
"Choice 1":
806+
jump somewhere
807+
"Choice 2":
808+
jump elsewhere
809+
"After menu"
810+
`;
811+
const result = parseRPYFileWithLabels(menuRPY);
812+
813+
const startLabel = result.labels.find((l) => l.label === "start");
814+
expect(startLabel).toBeDefined();
815+
816+
// Menu titles are editable in write mode — extract them as narration
817+
expect(startLabel?.dialogue).toHaveLength(3);
818+
expect(startLabel?.dialogue[0].text).toBe("Before menu");
819+
expect(startLabel?.dialogue[1].text).toBe("Menu title");
820+
expect(startLabel?.dialogue[2].text).toBe("After menu");
821+
});
779822
});
780823

781824
describe("convertToBranchForgeFormatFromLabels", () => {
@@ -1124,6 +1167,70 @@ label chapter1:
11241167

11251168
expect(result).toContain('e "Updated speaker"');
11261169
});
1170+
1171+
it("should NOT duplicate menu title during reconstruction", () => {
1172+
const originalContent = `label start:
1173+
"Some narration"
1174+
menu:
1175+
"Are you sure?"
1176+
"Yes":
1177+
jump yes_label
1178+
"No":
1179+
jump no_label
1180+
`;
1181+
1182+
const updatedDialogue = new Map([
1183+
[
1184+
"start",
1185+
[
1186+
{ speaker: null, text: "Some narration" },
1187+
{ speaker: null, text: "Are you sure?" },
1188+
],
1189+
],
1190+
]);
1191+
1192+
const result = reconstructRPYFile({
1193+
originalContent,
1194+
updatedDialogue,
1195+
});
1196+
1197+
// The menu title "Are you sure?" should NOT be duplicated outside the menu
1198+
expect(result).toContain("menu:");
1199+
expect(result).toContain('"Are you sure?"');
1200+
1201+
// Count occurrences of "Are you sure?" - should be exactly 1.
1202+
// Pre-fix, the second entry would be inserted before `menu:` and the
1203+
// original title line preserved inside the menu, producing two matches.
1204+
const matches = result.match(/"Are you sure\?"/g);
1205+
expect(matches).toHaveLength(1);
1206+
});
1207+
1208+
it("should NOT duplicate jump statements during reconstruction", () => {
1209+
const originalContent = `label end:
1210+
ma "Always."
1211+
jump end
1212+
`;
1213+
1214+
const updatedDialogue = new Map([
1215+
["end", [{ speaker: "ma", text: "Always." }]],
1216+
]);
1217+
1218+
const result = reconstructRPYFile({
1219+
originalContent,
1220+
updatedDialogue,
1221+
});
1222+
1223+
// Should contain the jump statement exactly once
1224+
const jumpMatches = result.match(/jump end/g);
1225+
expect(jumpMatches).toHaveLength(1);
1226+
1227+
// Should NOT contain jump statement as a dialogue string
1228+
expect(result).not.toContain('"jump end"');
1229+
1230+
// Verify the reconstruction preserves the original structure
1231+
expect(result).toContain('ma "Always."');
1232+
expect(result).toContain("jump end");
1233+
});
11271234
});
11281235

11291236
describe("addLabelToRPYContent", () => {

apps/backend/src/services/rpy-parser.service.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -450,12 +450,16 @@ export function extractChoices(
450450
}
451451

452452
// Check for menu end (indentation decreases)
453-
// Pop from stack until we find the appropriate menu level
453+
// Pop from stack until we find the appropriate menu level.
454+
// A line at the same indent as `menu:` is outside the menu (a sibling
455+
// of the menu block, e.g. the next label or a keyword), so pop with `<=`.
456+
// Use a while loop so a single dedent unwinds all nested menu levels
457+
// the line has escaped.
454458
const lineIndent = line.search(/\S/);
455-
if (
459+
while (
456460
menuStack.length > 0 &&
457461
trimmed &&
458-
lineIndent < menuStack[menuStack.length - 1]
462+
lineIndent <= menuStack[menuStack.length - 1]
459463
) {
460464
menuStack.pop();
461465
}
@@ -1122,8 +1126,19 @@ export function reconstructRPYFile(options: ReconstructedFileOptions): string {
11221126
const labelIndentation = new Map<string, string>();
11231127
let lastDialogueIndent = " "; // Default RPY indentation
11241128

1125-
// Keywords that signal the end of a label's dialogue block
1126-
const labelEndKeywords = new Set(["return", "menu", "jump", "call"]);
1129+
// Track menu block nesting to prevent premature dialogue insertion.
1130+
// Menu titles are editable dialogue entries, so they must be matched and
1131+
// replaced inside menu blocks. However, jump/call/return statements inside
1132+
// menu choice bodies should NOT trigger dialogue insertion — they're part of
1133+
// the menu structure, not the label's main dialogue flow.
1134+
const menuStack: number[] = [];
1135+
1136+
// Keywords that signal the end of a label's dialogue block.
1137+
// The `menuStack.length === 0` guard below prevents premature insertion at
1138+
// `menu:` (and any other line inside a menu block). This is the mechanism
1139+
// that stops the menu title from being inserted before `menu:` and again
1140+
// matched in place, which would produce duplicate dialogue entries.
1141+
const labelEndKeywords = new Set(["return", "jump", "call"]);
11271142
const isLabelEndKeyword = (trimmed: string): boolean => {
11281143
const firstWord = trimmed.split(/\s+/)[0];
11291144
// Normalize by stripping trailing colon and other punctuation
@@ -1171,16 +1186,35 @@ export function reconstructRPYFile(options: ReconstructedFileOptions): string {
11711186

11721187
currentLabel = labelMatch[1];
11731188
labelDialogueIndices.set(currentLabel, 0);
1189+
// Reset menu tracking on label boundary
1190+
menuStack.length = 0;
11741191
result.push(line);
11751192
continue;
11761193
}
11771194

1195+
// Track menu block nesting: push on menu:, pop on dedent
1196+
if (trimmed === "menu:") {
1197+
menuStack.push(line.search(/\S/));
1198+
} else if (menuStack.length > 0 && trimmed) {
1199+
const lineIndent = line.search(/\S/);
1200+
while (
1201+
menuStack.length > 0 &&
1202+
lineIndent <= menuStack[menuStack.length - 1]
1203+
) {
1204+
menuStack.pop();
1205+
}
1206+
}
1207+
11781208
// Check if this is a dialogue line
11791209
const dialogueMatch = trimmed.match(
11801210
/^([a-zA-Z_][a-zA-Z0-9_]*)\s+"((?:[^"\\]|\\.)*)"$/
11811211
);
11821212
const narrationMatch = trimmed.match(/^"(.*)"$/);
11831213

1214+
// Match and replace dialogue/narration both outside AND inside menu blocks.
1215+
// Menu titles are editable entries that should be updated like any other
1216+
// dialogue. The label-end insertion below handles the case where there are
1217+
// more entries than original lines.
11841218
if (
11851219
(dialogueMatch || narrationMatch) &&
11861220
currentLabel &&
@@ -1216,8 +1250,14 @@ export function reconstructRPYFile(options: ReconstructedFileOptions): string {
12161250
// The line will be added by the fall-through below.
12171251
}
12181252

1219-
// Before adding a line that ends the label block, insert any remaining dialogue
1220-
if (currentLabel && updatedDialogue.has(currentLabel)) {
1253+
// Before adding a line that ends the label block, insert any remaining dialogue.
1254+
// Only do this OUTSIDE menu blocks — jump/call/return inside menu choice
1255+
// bodies are part of the menu structure, not the label's dialogue flow.
1256+
if (
1257+
currentLabel &&
1258+
updatedDialogue.has(currentLabel) &&
1259+
menuStack.length === 0
1260+
) {
12211261
const labelDialogue = updatedDialogue.get(currentLabel)!;
12221262
const currentIndex = labelDialogueIndices.get(currentLabel) ?? 0;
12231263

0 commit comments

Comments
 (0)