Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions apps/backend/scripts/seed-technical-badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ async function seedTechnicalBadgesData() {
],
},

// Line 3: Conditions with different comparison operators
// Line 3: Conditions with different comparison operators and variable conditions
{
labelId,
sequence: 3,
Expand All @@ -317,7 +317,10 @@ async function seedTechnicalBadgesData() {
intelligence: { value: 10, operator: ">" },
luck: { value: 2, operator: "<" },
},
variables: ["has_key", "is_daytime"],
variables: {
has_key: { value: true, operator: "truthy" },
is_daytime: { value: true, operator: "truthy" },
},
},
},

Expand Down Expand Up @@ -420,7 +423,7 @@ async function seedTechnicalBadgesData() {
],
},

// Line 8: Multiple conditions with different operators
// Line 8: Multiple conditions with all variable condition types
{
labelId,
sequence: 8,
Expand All @@ -434,7 +437,20 @@ async function seedTechnicalBadgesData() {
luck: { value: 5, operator: "==" },
gold: { value: 100, operator: "!=" },
},
variables: ["has_spell", "is_main_quest"],
variables: {
// Truthy: bare identifier check (if has_spell:)
has_spell: { value: true, operator: "truthy" },
// Falsy: negation check (if not is_main_quest:)
is_main_quest: { value: true, operator: "falsy" },
// String equality: (if alignment == "good":)
alignment: { value: "good", operator: "==" },
// String inequality: (if faction != "evil":)
faction: { value: "evil", operator: "!=" },
// Boolean equality: (if has_drink == True:)
has_drink: { value: true, operator: "==" },
// Boolean inequality: (if is_cursed != False:)
is_cursed: { value: false, operator: "!=" },
},
},
},

Expand Down Expand Up @@ -634,7 +650,9 @@ async function seedTechnicalBadgesData() {
jumpType: "MENU_CHOICE",
choiceText: "Run away",
conditions: {
variables: ["has_stamina"],
variables: {
has_stamina: { value: true, operator: "truthy" },
},
},
},
{
Expand Down Expand Up @@ -703,7 +721,9 @@ async function seedTechnicalBadgesData() {
jumpType: "MENU_CHOICE",
choiceText: "Magic gem",
conditions: {
variables: ["has_gem"],
variables: {
has_gem: { value: true, operator: "truthy" },
},
},
},
{
Expand Down Expand Up @@ -775,7 +795,9 @@ async function seedTechnicalBadgesData() {
console.log(
" ✅ Conditions with all comparison operators (>=, <=, >, <, ==, !=)"
);
console.log(" ✅ Variables conditions");
console.log(
" ✅ Variable conditions (truthy, falsy, ==, != with string and boolean values)"
);
console.log(" ✅ Visual statements (SCENE, SHOW, HIDE)");
console.log(" ✅ Jump targets");
console.log(" ✅ Incoming jumps on target labels");
Expand All @@ -784,7 +806,7 @@ async function seedTechnicalBadgesData() {
console.log(
` ✅ ${createdTargetLabels.length} target labels created for testing resolution\n`
);
console.log("To test comparison operators:");
console.log("To test comparison operators and variable conditions:");
console.log(` 1. Open the app and login with: ${TEST_EMAIL}`);
console.log(` 2. Password: ${TEST_PASSWORD}`);
console.log(" 3. Open the 'Technical Badges Test' project");
Expand All @@ -793,15 +815,23 @@ async function seedTechnicalBadgesData() {
" 5. Click the eye icon in the top-right to toggle technical badges"
);
console.log(
" 6. Click/hover on badges on lines 3, 8, and 10 to see operator symbols"
" 6. Click/hover on badges on lines 3, 8, and 10 to see condition details"
);
console.log(
' 7. Verify plain-English operators: "is at least", "is at most", "is more than", "is less than", "is", "is not"'
);
console.log(" 7. Verify symbols: ≥, ≤, =, ≠ are displayed correctly");
console.log("\n 8. Navigate to lines with menu options (lines 2, 6, 9)");
console.log(" 8. Line 8 showcases all variable condition types:");
console.log(" - truthy: is has_spell");
console.log(" - falsy: not is_main_quest");
console.log(' - ==: alignment is "good", has_drink is True');
console.log(' - !=: faction is not "evil", is_cursed is not False');
console.log(" (Boolean conditions render as True/False words)");
console.log("\n 9. Navigate to lines with menu options (lines 2, 6, 9)");
console.log(
" 9. Hover over the choice badges to see resolved target label IDs"
" 10. Hover over the choice badges to see resolved target label IDs"
);
console.log(
" 10. Verify that targetLabelId is populated with actual database IDs"
" 11. Verify that targetLabelId is populated with actual database IDs"
);

process.exit(0);
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/db/schema/tables/label-lines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { contentTypeEnum, visualTypeEnum } from "../enums.js";
import { labels } from "./labels.js";
import { characters } from "./characters.js";
import { projectFiles } from "./project-files.js";
import type { StatCondition } from "@branchforge/shared";
import type { StatCondition, VariableCondition } from "@branchforge/shared";

export const labelLines = pgTable(
"label_lines",
Expand Down Expand Up @@ -52,7 +52,7 @@ export const labelLines = pgTable(
// Line-level conditions (from issue #160)
conditions: jsonb("conditions").$type<{
stats?: Record<string, StatCondition | number>;
variables?: string[];
variables?: Record<string, VariableCondition>;
}>(),

// Scene/show/hide statements
Expand Down
10 changes: 5 additions & 5 deletions apps/backend/src/db/schema/tables/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
index,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import type { IncomingJump } from "@branchforge/shared";
import type { IncomingJump, VariableCondition } from "@branchforge/shared";
import {
labelStatusEnum,
labelVisibilityEnum,
Expand Down Expand Up @@ -54,10 +54,10 @@ export const labels = pgTable(
onDelete: "set null",
}),
status: labelStatusEnum("status").default("DRAFT"),
conditions: jsonb("conditions")
.notNull()
.default({})
.$type<{ variables?: string[]; stats?: Record<string, number> }>(), // {variables: [], stats: {}}
conditions: jsonb("conditions").notNull().default({}).$type<{
variables?: Record<string, VariableCondition>;
stats?: Record<string, number>;
}>(), // {variables: {}, stats: {}}
incomingJumps: jsonb("incoming_jumps").$type<IncomingJump[] | null>(),
effects: jsonb("effects").notNull().default({}).$type<{
variablesSet?: string[];
Expand Down
10 changes: 9 additions & 1 deletion apps/backend/src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,15 @@ export const updateLabelSchema = z
conditions: z
.object({
stats: z.record(z.string(), z.number().finite()).optional(),
variables: z.array(z.string()).optional(),
variables: z
.record(
z.string(),
z.object({
value: z.union([z.string(), z.boolean()]),
operator: z.enum(["==", "!=", "truthy", "falsy"]),
})
)
.optional(),
})
.optional()
.nullable(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ describe("extractTechnicalConstructs - comparison operators", () => {
expect(result.conditions?.stats).toEqual({
strength: { value: 5, operator: ">=" },
});
expect(result.conditions?.variables).toEqual(["flag_luna"]);
expect(result.conditions?.variables).toEqual({
flag_luna: { value: true, operator: "truthy" },
});
});
});

Expand Down
8 changes: 6 additions & 2 deletions apps/backend/src/services/label-line-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { calculateContentHash } from "../lib/hash.js";
import type { NewLabelLine } from "../db/schema/index.js";
import { ValidationError } from "../middleware/error-handler.middleware.js";
import { logError, LogEventType } from "../lib/logger.js";
import type { ComparisonOperator, StatCondition } from "@branchforge/shared";
import type {
ComparisonOperator,
StatCondition,
VariableCondition,
} from "@branchforge/shared";

// ============================================================================
// Type Definitions
Expand All @@ -32,7 +36,7 @@ export type ContentType =
*/
export type LineConditions = {
stats?: Record<string, number | StatCondition>;
variables?: string[];
variables?: Record<string, VariableCondition>;
statDeltas?: Record<string, number>;
};

Expand Down
17 changes: 13 additions & 4 deletions apps/backend/src/services/labels.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
RENPY_LABEL_REGEX,
type ComparisonOperator,
type StatCondition,
type VariableCondition,
} from "@branchforge/shared";
import type { IncomingJump } from "@branchforge/shared";
import { createAuditFields, updateAuditFields } from "../lib/audit.js";
Expand Down Expand Up @@ -2290,7 +2291,7 @@ export async function updateLabel(
visibility?: "EXCLUSIVE" | "SHARED" | "DUO_PAIR";
labelName?: string | null;
conditions?: {
variables?: string[];
variables?: Record<string, VariableCondition>;
stats?: Record<string, number>;
} | null;
}
Expand Down Expand Up @@ -2452,8 +2453,9 @@ export async function updateLabel(
? Object.keys(data.conditions.stats)
: [];
const variableKeys =
data.conditions?.variables && data.conditions.variables.length > 0
? data.conditions.variables
data.conditions?.variables &&
Object.keys(data.conditions.variables).length > 0
? Object.keys(data.conditions.variables)
: [];

const [existingStats, existingVariables] = await Promise.all([
Expand Down Expand Up @@ -2800,7 +2802,14 @@ export async function updateIncomingJumpsForLabels(
jumpType: "MENU_CHOICE" as const,
choiceText: option.label,
conditions: option.conditionFlags
? { variables: option.conditionFlags }
? {
variables: Object.fromEntries(
option.conditionFlags.map((f) => [
f,
{ value: true, operator: "truthy" as const },
])
),
}
: undefined,
});
}
Expand Down
81 changes: 61 additions & 20 deletions apps/backend/src/services/rpy-generator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Patches existing RPY content with conditional logic and variable assignments.
*/

import { RENPY_LABEL_REGEX } from "@branchforge/shared";
import { RENPY_LABEL_REGEX, type VariableCondition } from "@branchforge/shared";

// ============================================================================
// Types
Expand All @@ -29,11 +29,23 @@ export function isValidRenpyIdentifier(name: string): boolean {
return RENPY_IDENTIFIER_REGEX.test(name);
}

/**
* Escape a string value for safe embedding inside a Ren'Py double-quoted
* string literal. Replaces backslashes, double quotes, and newlines so the
* generated RPY always contains a valid string literal.
*/
export function escapeRenpyString(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n");
}

/**
* Prerequisites from label configuration
*/
export interface Conditions {
variables?: string[];
variables?: Record<string, VariableCondition>;
stats?: Record<string, number>;
}

Expand Down Expand Up @@ -69,8 +81,8 @@ export interface LabelWithConditions {
* @returns Array of code lines
*
* @example
* generateConditionCode({ variables: ["met_alex"] })
* // Returns: [" if not met_alex:", " return"]
* generateConditionCode({ variables: { met_alex: { value: true, operator: "truthy" } } })
* // Returns: [" if met_alex:", " return"]
*/
export function generateConditionCode(
conditions: Conditions,
Expand All @@ -81,22 +93,53 @@ export function generateConditionCode(
const nestedIndent = " ".repeat(indentLevel + 1);

// Generate variable checks
if (conditions.variables && conditions.variables.length > 0) {
// Validate each variable name and filter out invalid entries
const validVariables = conditions.variables.filter((sv) => {
if (!isValidRenpyIdentifier(sv)) {
if (conditions.variables && Object.keys(conditions.variables).length > 0) {
const conditionsList: string[] = [];

for (const [varName, condition] of Object.entries(conditions.variables)) {
// Validate variable name before using it
if (!isValidRenpyIdentifier(varName)) {
process.stderr.write(
`Warning: Skipping invalid variable name in conditions: "${sv}"\n`
`Warning: Skipping invalid variable name in conditions: "${varName}"\n`
);
return false;
continue;
}
return true;
});

if (validVariables.length > 0) {
// Combine multiple variables with OR logic
const conditionChecks = validVariables.map((sv) => `not ${sv}`);
lines.push(`${indent}if ${conditionChecks.join(" or ")}:`);
switch (condition.operator) {
case "truthy":
conditionsList.push(varName);
break;
case "falsy":
conditionsList.push(`not ${varName}`);
break;
case "==":
if (typeof condition.value === "boolean") {
conditionsList.push(
`${varName} == ${condition.value ? "True" : "False"}`
);
} else {
conditionsList.push(
`${varName} == "${escapeRenpyString(condition.value)}"`
);
}
break;
case "!=":
if (typeof condition.value === "boolean") {
conditionsList.push(
`${varName} != ${condition.value ? "True" : "False"}`
);
} else {
conditionsList.push(
`${varName} != "${escapeRenpyString(condition.value)}"`
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
break;
}
}

if (conditionsList.length > 0) {
// Guard clause: return early if ANY condition fails (negate the combined AND)
lines.push(`${indent}if not (${conditionsList.join(" and ")}):`);
lines.push(`${nestedIndent}return`);
}
}
Expand Down Expand Up @@ -333,7 +376,7 @@ export function patchRPYWithVariables(
if (labelData?.conditions) {
const hasConditions =
(labelData.conditions.variables &&
labelData.conditions.variables.length > 0) ||
Object.keys(labelData.conditions.variables).length > 0) ||
(labelData.conditions.stats &&
Object.keys(labelData.conditions.stats).length > 0);

Expand Down Expand Up @@ -601,9 +644,7 @@ export function generateCharacterDefinitionsFile(
lines.push("");

for (const char of characters) {
const escapedName = char.displayName
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
const escapedName = escapeRenpyString(char.displayName);
lines.push(
`define ${char.renpyTag} = Character("${escapedName}", color="${char.color}")`
);
Expand Down
Loading