~/den/log $cat mocking-the-ai-dungeon-script-sandbox.md NODE 01 2026.06.05 SIGNAL OK
SIGNAL 2026.06.05

Mocking the AI Dungeon Script Sandbox

Let's test our scripts locally without having to run them in AI Dungeon by building a fake version of the script sandbox

In the previous post, we talked about how to manage large scripts with tsdown. Now, let’s talk about how to test our scripts locally without having to run them in AI Dungeon by building a fake version of the script sandbox. In Software Engineering, we call this “mocking”, which comes from the idea that we’re copying something real and replacing it with a fake version. Like a child on the playground copying what someone else says until they say “I’m an idiot”, then they respond “You’re an idiot” instead.

Personally, I find testing scripts on AI Dungeon a bit tedious, because I have to build my script, then paste it into AI Dungeon, then build an adventure frame that makes sense for it, then do the actions that might reproduce the issue I’m trying to solve. That’s a lot of work, and potentially takes time if you’re using a model like Deepseek, which I’ve seen take 30~60 seconds per turn.

What I do instead is run a couple turns of an adventure in AI Dungeon, and copy the input / context / output to a file, then use that to test my script locally.

##Project Setup

We’re going to need some tools to help us build our fake script sandbox, but let’s look at where we’re starting, first.

###The Starting Point

We’ll be working in our project from the previous post, a configurable dice roll script. If you’re not following along, here’s what our project looks like:

[ Files ]
dice-roll
├───src
│   ├───aidungeon.d.ts
│   ├───config.ts
│   ├───index.ts
│   └───parse.ts
├───package.json
└───rolldown.config.ts

The main files we’re interested in are index.ts, which holds our main script code (note, these are collapsed by default; expand them if you want to see the code):

[ TypeScript ]index.ts
import { getConfig, type DiceRollConfig } from "./config";

function getDiceRollType(config: DiceRollConfig, text: string): string | null {
  const modifierWords = Object.values(config.banks)
    .flatMap((bank) => bank.words)
    .join("|");
  const modifierToResult = Object.fromEntries(
    Object.values(config.banks).flatMap((bank) =>
      bank.words.map((word) => [word, bank.results]),
    ),
  );
  const attemptRegex = new RegExp(
    `> (You (?:(${modifierWords}) )?(${config.triggers.join("|")})[^.?!\\n]*[.?!]?)`,
    "i",
  );
  const match = text.match(attemptRegex);
  if (match) {
    const modifier = match[2];
    return (modifier ? modifierToResult[modifier] : null) ?? config.defaultResults;
  }
  return null;
}

function getRollResult(config: DiceRollConfig, diceRollType: string): string {
  const results = diceRollType.split(" ");
  const result = results[Math.floor(Math.random() * results.length)] ?? "";
  return config.results[result] ?? "";
}

export default {
  Hooks:{
    Input: (text: string): string => {
      const config = getConfig();
      if (!config.enable) {
        return text;
      }

      const diceRollType = getDiceRollType(config, text);
      if (diceRollType) {
        return text + ` [🎲 Dice Roll: ${getRollResult(config, diceRollType)}]`;
      }
      return text;
    },
    Output: (text: string): string => {
      return text;
    },
    Context: (text: string): string => {
      return text;
    },
  },
};

Which then imports config.ts, which holds our dice roll config:

[ TypeScript ]config.ts
import {
  parseIndented,
  parseBoolean,
  parseList,
  asString,
  asSection,
  asStringRecord,
} from "./parse";

export interface DiceRollConfig {
  enable: boolean;
  triggers: string[];
  results: Record<string, string>;
  defaultResults: string;
  banks: Record<string, { words: string[]; results: string }>;
}

export const defaultConfig = `\
Enable: true
Triggers: try, attempt
Results:
  S: Critical Success!
  s: Success!
  p: Partial Success!
  f: Failure!
  F: Critical Failure!
Default Results: S s s s s s p p f
Banks:
  advantage:
    words: assuredly, confidently, doubtlessly, skillfully
    results: S S S s s s s p
  disadvantage:
    words: clumsily, tentatively, doubtfully, hesitantly, hapzardly
    results: F F F f f f f p`;

function getOrCreateConfigEntry(): string {
  for (const card of storyCards) {
    if (card.type === "Class" && card.title === "Configure Dice Roll") {
      if (card.entry) {
        return card.entry;
      }
    }
  }
  const newCard = addStoryCard(
    "",
    defaultConfig,
    "Class",
    "Configure Dice Roll",
    "",
    { returnCard: true },
  );
  return newCard.entry ?? defaultConfig;
}

export function getConfig(): DiceRollConfig {
  const raw = parseIndented(getOrCreateConfigEntry());

  return {
    enable: parseBoolean(asString(raw.Enable)),
    triggers: parseList(asString(raw.Triggers)),
    results: asStringRecord(raw.Results),
    defaultResults: asString(raw["Default Results"]) ?? "",
    banks: Object.fromEntries(
      Object.entries(asSection(raw.Banks)).map(
        ([name, bank]): [string, { words: string[]; results: string }] => {
          const fields = asSection(bank);
          return [
            name,
            {
              words: parseList(asString(fields.words)),
              results: asString(fields.results) ?? "",
            },
          ];
        },
      ),
    ),
  };
}

Which then imports parse.ts, which holds config and value parsing:

[ TypeScript ]parse.ts
export type ConfigValue = string | ConfigSection;

export interface ConfigSection {
  [key: string]: ConfigValue;
}

export function parseIndented(text: string): ConfigSection {
  const root: ConfigSection = {};
  const stack: { indent: number; node: ConfigSection }[] = [
    { indent: -1, node: root },
  ];
  for (const line of text.split("\n")) {
    if (!line.trim()) continue;
    const colon = line.indexOf(":");
    if (colon === -1) continue;
    const indent = line.length - line.trimStart().length;
    const key = line.slice(0, colon).trim();
    const value = line.slice(colon + 1).trim();
    while (stack.length > 1 && indent <= (stack[stack.length - 1]?.indent ?? 0)) {
      stack.pop();
    }
    const parent = stack[stack.length - 1]?.node ?? root;
    if (value === "") {
      const child = {};
      parent[key] = child;
      stack.push({ indent, node: child });
    } else {
      parent[key] = value;
    }
  }
  return root;
}

export function asString(value: ConfigValue | undefined): string | null {
  return typeof value === "string" ? value : null;
}

export function asSection(value: ConfigValue | undefined): ConfigSection {
  return typeof value === "object" && value !== null ? value : {};
}

export function asStringRecord(value: ConfigValue | undefined): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [key, child] of Object.entries(asSection(value))) {
    if (typeof child === "string") out[key] = child;
  }
  return out;
}

export function parseBoolean(value: string | null, defaultValue = false): boolean {
  if (value == null) {
    return defaultValue;
  }
  const normalized = value.toLowerCase();
  if (["true", "yes", "1", "on", "enabled", "enable"].includes(normalized)) {
    return true;
  }
  if (["false", "no", "0", "off", "disabled", "disable"].includes(normalized)) {
    return false;
  }
  return defaultValue;
}

export function parseList(value: string | null): string[] {
  return (value ?? "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

###Importing Some Tools

I’ve been using node and npm for this project, so we’ll keep using those. Luckily, modern versions of node have a built in test runner and assertions, along with the ability to run TypeScript code by stripping the types. Let’s add that configuration:

[ JSON ]package.json
{
  "name": "dice-roll",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
    "test": "node --test"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "devDependencies": {
    "tsdown": "^0.22.2",
    "typescript": "^6.0.3"
  }
}

If you were doing this in the past, you might expect that we’d need to add a dependency to jest or vitest or something. But no, we can just use the built-in test runner, which I think is really cool. No extra tooling or dependencies.

##Writing a Simple Test

I find it easiest to start by writing a simple test that checks that our setup works. Let’s write a new file that just does some simple assertions.

[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";

describe("DiceRoll", () => {
  test("true should be true", () => {
    assert.equal(true, true);
  });

  test("true should be truthy", () => {
    assert.ok(true);
  });

  test("math should be mathing", () => {
    assert.equal(1 + 1, 2);
  });
});

Run it, and node’s test runner does the rest:

[ SHELL ]dice-roll
npm test> dice-roll@1.0.0 test> node --test▶ DiceRoll  ✔ true should be true (0.5715ms)  ✔ true should be truthy (0.1383ms)  ✔ math should be mathing (0.1083ms)✔ DiceRoll (1.6527ms)ℹ tests 3ℹ suites 1ℹ pass 3ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 117.5936

###Importing Our Files

Cool, so our test setup works. Now let’s try to run a test that tries to parse a boolean from our parsers:

[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";
import { parseBoolean } from "./parse";

describe("DiceRoll", () => {
  describe("parse", () => {
    test("parseBoolean should return true for true", () => {
      assert.equal(parseBoolean("true"), true);
    });
  })
});

Now let’s run it, and watch it make testing our code locally really easy:

[ SHELL ]dice-roll
npm run test> dice-roll@1.0.0 test> node --testnode:internal/modules/esm/resolve:271    throw new ERR_MODULE_NOT_FOUND(          ^Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'C:\Users\Worldsmythe\dice-roll\src\parse' imported from C:\Users\Worldsmythe\dice-roll\src\index.test.ts    at finalizeResolution (node:internal/modules/esm/resolve:271:11)    at moduleResolve (node:internal/modules/esm/resolve:861:10)    at defaultResolve (node:internal/modules/esm/resolve:988:11)    at #cachedDefaultResolve (node:internal/modules/esm/loader:697:20)    at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:714:38)    at ModuleLoader.resolveSync (node:internal/modules/esm/loader:746:52)    at #resolve (node:internal/modules/esm/loader:679:17)    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:599:35)    at ModuleJob.syncLink (node:internal/modules/esm/module_job:162:33)    at ModuleJob.link (node:internal/modules/esm/module_job:252:17) {  code: 'ERR_MODULE_NOT_FOUND',  url: 'file:///C:/Users/Worldsmythe/dice-roll/src/parse'}Node.js v24.15.0✖ src\index.test.ts (80.9932ms)

Oh, so something’s wrong. We’re trying to import the parse module, but node is trying to resolve it from src/parse.js. We have src/parse.ts, so let’s make that explicit in each of our files:

[ TypeScript ]src/index.ts
import { getConfig, type DiceRollConfig } from "./config";
import { getConfig, type DiceRollConfig } from "./config.ts";

function getDiceRollType(config: DiceRollConfig, text: string): string | null {
  const modifierWords = Object.values(config.banks)
    .flatMap((bank) => bank.words)
    .join("|");
  const modifierToResult = Object.fromEntries(
    Object.values(config.banks).flatMap((bank) =>
      bank.words.map((word) => [word, bank.results]),
    ),
  );
  const attemptRegex = new RegExp(
    `> (You (?:(${modifierWords}) )?(${config.triggers.join("|")})[^.?!\\n]*[.?!]?)`,
    "i",
  );
  const match = text.match(attemptRegex);
  if (match) {
    const modifier = match[2];
    return (modifier ? modifierToResult[modifier] : null) ?? config.defaultResults;
  }
  return null;
}

function getRollResult(config: DiceRollConfig, diceRollType: string): string {
  const results = diceRollType.split(" ");
  const result = results[Math.floor(Math.random() * results.length)] ?? "";
  return config.results[result] ?? "";
}

export default {
  Hooks:{
    Input: (text: string): string => {
      const config = getConfig();
      if (!config.enable) {
        return text;
      }

      const diceRollType = getDiceRollType(config, text);
      if (diceRollType) {
        return text + ` [🎲 Dice Roll: ${getRollResult(config, diceRollType)}]`;
      }
      return text;
    },
    Output: (text: string): string => {
      return text;
    },
    Context: (text: string): string => {
      return text;
    },
  },
};
[ TypeScript ]src/config.ts
import {
  parseIndented,
  parseBoolean,
  parseList,
  asString,
  asSection,
  asStringRecord,
} from "./parse";
} from "./parse.ts";

export interface DiceRollConfig {
  enable: boolean;
  triggers: string[];
  results: Record<string, string>;
  defaultResults: string;
  banks: Record<string, { words: string[]; results: string }>;
}

export const defaultConfig = `\
Enable: true
Triggers: try, attempt
Results:
  S: Critical Success!
  s: Success!
  p: Partial Success!
  f: Failure!
  F: Critical Failure!
Default Results: S s s s s s p p f
Banks:
  advantage:
    words: assuredly, confidently, doubtlessly, skillfully
    results: S S S s s s s p
  disadvantage:
    words: clumsily, tentatively, doubtfully, hesitantly, hapzardly
    results: F F F f f f f p`;

function getOrCreateConfigEntry(): string {
  for (const card of storyCards) {
    if (card.type === "Class" && card.title === "Configure Dice Roll") {
      if (card.entry) {
        return card.entry;
      }
    }
  }
  const newCard = addStoryCard(
    "",
    defaultConfig,
    "Class",
    "Configure Dice Roll",
    "",
    { returnCard: true },
  );
  return newCard.entry ?? defaultConfig;
}

export function getConfig(): DiceRollConfig {
  const raw = parseIndented(getOrCreateConfigEntry());

  return {
    enable: parseBoolean(asString(raw.Enable)),
    triggers: parseList(asString(raw.Triggers)),
    results: asStringRecord(raw.Results),
    defaultResults: asString(raw["Default Results"]) ?? "",
    banks: Object.fromEntries(
      Object.entries(asSection(raw.Banks)).map(
        ([name, bank]): [string, { words: string[]; results: string }] => {
          const fields = asSection(bank);
          return [
            name,
            {
              words: parseList(asString(fields.words)),
              results: asString(fields.results) ?? "",
            },
          ];
        },
      ),
    ),
  };
}
[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";
import { parseBoolean } from "./parse";
import { parseBoolean } from "./parse.ts";

describe("DiceRoll", () => {
  describe("parse", () => {
    test("parseBoolean should return true for true", () => {
      assert.equal(parseBoolean("true"), true);
    });
  })
});

Nice. The tests for the parser are pretty straightforward, and what we’re really here to do is build tests that run in an environment similar to AI Dungeon’s, so let’s do that next. We’ll move our parser tests to parse.test.ts, and refocus our main test file on the script logic.

###Running a Test With Our Hook

Let’s try running a test that runs our hook:

[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";
import DiceRoll from "./index.ts";

describe("DiceRoll", () => {
  describe("Hooks", () => {
    test("Input should return the input with the dice roll result", () => {
      const result = DiceRoll.Hooks.Input("\n> You try to go to the store.\n");
      assert.match(result, /\[🎲 Dice Roll: .+\]$/);
    });
  })
});

And run it:

[ SHELL ]
npm run test> dice-roll@1.0.0 test> node --test▶ DiceRoll  ▶ Hooks    ✖ Input should return the input with the dice roll result (0.6398ms)  ✖ parse (1.2679ms)✖ DiceRoll (1.6435ms)ℹ tests 1ℹ suites 2ℹ pass 0ℹ fail 1ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 119.7273✖ failing tests:test at src\index.test.ts:7:5✖ Input should return the input with the dice roll result (0.6398ms)  ReferenceError: storyCards is not defined      at getOrCreateConfigEntry (file:///C:/Users/Worldsmythe/dice-roll/src/config.ts:37:22)      at getConfig (file:///C:/Users/Worldsmythe/dice-roll/src/config.ts:56:29)      at Object.Input (file:///C:/Users/Worldsmythe/dice-roll/src/index.ts:33:22)      at TestContext.<anonymous> (file:///C:/Users/Worldsmythe/dice-roll/src/index.test.ts:8:37)      at Test.runInAsyncScope (node:async_hooks:227:14)      at Test.run (node:internal/test_runner/test:1201:25)      at Test.start (node:internal/test_runner/test:1096:17)      at node:internal/test_runner/test:1617:71      at node:internal/per_context/primordials:466:82      at new Promise (<anonymous>)

Interesting, it looks like we’re trying to access storyCards, which is not defined. That probably makes sense, because we’re not running in the script sandbox. This is why we’d need to mock the sandbox environment in our tests.

##Setting Up the Sandbox

I covered the basic things that are included in the script sandbox in my first post, so I’m going to jump right into implementing them. If you want a refresher, go read this post.

The basic shape of it looks something like this:

[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";
import DiceRoll from "./index.ts";

let nextCardId = 0;

export function testWithAiDungeonEnvironment(
  testName: string,
  testFn: () => void | Promise<void>,
): Promise<void> {
  return test(testName, async () => {
    nextCardId = 0;
    const properties: (keyof typeof globalThis)[] = [
      "storyCards",
      "history",
      "info",
      "state",
      "log",
      "addStoryCard",
      "removeStoryCard",
      "updateStoryCard",
    ];

    const originals = Object.fromEntries(
      properties.map((key) => [key, globalThis[key]]),
    );

    globalThis.storyCards = [];
    globalThis.history = [];
    globalThis.info = {
      actionCount: 0,
      characterNames: [],
    };
    globalThis.state = {
      memory: {},
      message: "",
    };

    globalThis.log = (): void => {
      // Silent in tests
    };

    globalThis.addStoryCard = ((
      keys: string,
      entry?: string,
      type: string = "Custom",
      name?: string,
      description?: string,
      options?: { returnCard: boolean },
    ): StoryCard | number => {
      const card: StoryCard = {
        id: `${nextCardId++}`,
        keys: keys ? [keys] : undefined,
        entry,
        type,
        title: name || keys,
        description: description || "",
      };
      globalThis.storyCards.push(card);

      if (options?.returnCard) {
        return card;
      }
      return globalThis.storyCards.length;
    }) as typeof globalThis.addStoryCard;

    globalThis.removeStoryCard = (index: number): void => {
      const card = globalThis.storyCards[index];
      if (card) {
        globalThis.storyCards.splice(index, 1);
      } else {
        throw new Error(
          `Story card not found at index ${index} in removeStoryCard`,
        );
      }
    };

    globalThis.updateStoryCard = (
      index: number,
      keys: string,
      entry: string,
      type?: string,
      name?: string,
      notes?: string,
    ): void => {
      const existing = globalThis.storyCards[index];
      if (existing) {
        globalThis.storyCards[index] = {
          id: existing.id,
          keys: keys ? [keys] : undefined,
          entry,
          type: type ?? existing.type,
          title: name ?? existing.title,
          description: notes ?? existing.description,
        };
      } else {
        throw new Error(
          `Story card not found at index ${index} in updateStoryCard`,
        );
      }
    };

    try {
      await testFn();
    } finally {
      for (const key of properties) {
        Reflect.deleteProperty(globalThis, key);
        Reflect.set(globalThis, key, originals[key]);
      }
    }
  });
}

describe("DiceRoll", () => {
  describe("Hooks", () => {
    testWithAiDungeonEnvironment("Input should return the input with the dice roll result", () => {
      const result = DiceRoll.Hooks.Input("\n> You try to go to the store.\n");
      assert.match(result, /\[🎲 Dice Roll: .+\]$/);
    });
  })
});

This is a super long function, but it has a couple key parts. First, we’re creating a new test function that will run in our sandbox environment, so instead of calling test directly, we’re wrapping test in a function that sets up the environment, aptly called testWithAiDungeonEnvironment:

[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";
import DiceRoll from "./index.ts";

let nextCardId = 0;

export function testWithAiDungeonEnvironment(
  testName: string,
  testFn: () => void | Promise<void>,
): Promise<void> {
  return test(testName, async () => {
    nextCardId = 0;
    const properties: (keyof typeof globalThis)[] = [
      "storyCards",
      "history",
      "info",
      "state",
      "log",
      "addStoryCard",
      "removeStoryCard",
      "updateStoryCard",
    ];

    const originals = Object.fromEntries(
      properties.map((key) => [key, globalThis[key]]),
    );

    globalThis.storyCards = [];
    globalThis.history = [];
    globalThis.info = {
      actionCount: 0,
      characterNames: [],
    };
    globalThis.state = {
      memory: {},
      message: "",
    };

    globalThis.log = (): void => {
      // Silent in tests
    };

    globalThis.addStoryCard = ((
      keys: string,
      entry?: string,
      type: string = "Custom",
      name?: string,
      description?: string,
      options?: { returnCard: boolean },
    ): StoryCard | number => {
      const card: StoryCard = {
        id: `${nextCardId++}`,
        keys: keys ? [keys] : undefined,
        entry,
        type,
        title: name || keys,
        description: description || "",
      };
      globalThis.storyCards.push(card);

      if (options?.returnCard) {
        return card;
      }
      return globalThis.storyCards.length;
    }) as typeof globalThis.addStoryCard;

    globalThis.removeStoryCard = (index: number): void => {
      const card = globalThis.storyCards[index];
      if (card) {
        globalThis.storyCards.splice(index, 1);
      } else {
        throw new Error(
          `Story card not found at index ${index} in removeStoryCard`,
        );
      }
    };

    globalThis.updateStoryCard = (
      index: number,
      keys: string,
      entry: string,
      type?: string,
      name?: string,
      notes?: string,
    ): void => {
      const existing = globalThis.storyCards[index];
      if (existing) {
        globalThis.storyCards[index] = {
          id: existing.id,
          keys: keys ? [keys] : undefined,
          entry,
          type: type ?? existing.type,
          title: name ?? existing.title,
          description: notes ?? existing.description,
        };
      } else {
        throw new Error(
          `Story card not found at index ${index} in updateStoryCard`,
        );
      }
    };

    try {
      await testFn();
    } finally {
      for (const key of properties) {
        Reflect.deleteProperty(globalThis, key);
        Reflect.set(globalThis, key, originals[key]);
      }
    }
  });
}

describe("DiceRoll", () => {
  describe("Hooks", () => {
    testWithAiDungeonEnvironment("Input should return the input with the dice roll result", () => {
      const result = DiceRoll.Hooks.Input("\n> You try to go to the store.\n");
      assert.match(result, /\[🎲 Dice Roll: .+\]$/);
    });
  })
});

Next, we’re saving whatever properties we’re interested in before we start, and then restoring them after we’re done.

[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";
import DiceRoll from "./index.ts";

let nextCardId = 0;

export function testWithAiDungeonEnvironment(
  testName: string,
  testFn: () => void | Promise<void>,
): Promise<void> {
  return test(testName, async () => {
    nextCardId = 0;
    const properties: (keyof typeof globalThis)[] = [
      "storyCards",
      "history",
      "info",
      "state",
      "log",
      "addStoryCard",
      "removeStoryCard",
      "updateStoryCard",
    ];

    const originals = Object.fromEntries(
      properties.map((key) => [key, globalThis[key]]),
    );

    globalThis.storyCards = [];
    globalThis.history = [];
    globalThis.info = {
      actionCount: 0,
      characterNames: [],
    };
    globalThis.state = {
      memory: {},
      message: "",
    };

    globalThis.log = (): void => {
      // Silent in tests
    };

    globalThis.addStoryCard = ((
      keys: string,
      entry?: string,
      type: string = "Custom",
      name?: string,
      description?: string,
      options?: { returnCard: boolean },
    ): StoryCard | number => {
      const card: StoryCard = {
        id: `${nextCardId++}`,
        keys: keys ? [keys] : undefined,
        entry,
        type,
        title: name || keys,
        description: description || "",
      };
      globalThis.storyCards.push(card);

      if (options?.returnCard) {
        return card;
      }
      return globalThis.storyCards.length;
    }) as typeof globalThis.addStoryCard;

    globalThis.removeStoryCard = (index: number): void => {
      const card = globalThis.storyCards[index];
      if (card) {
        globalThis.storyCards.splice(index, 1);
      } else {
        throw new Error(
          `Story card not found at index ${index} in removeStoryCard`,
        );
      }
    };

    globalThis.updateStoryCard = (
      index: number,
      keys: string,
      entry: string,
      type?: string,
      name?: string,
      notes?: string,
    ): void => {
      const existing = globalThis.storyCards[index];
      if (existing) {
        globalThis.storyCards[index] = {
          id: existing.id,
          keys: keys ? [keys] : undefined,
          entry,
          type: type ?? existing.type,
          title: name ?? existing.title,
          description: notes ?? existing.description,
        };
      } else {
        throw new Error(
          `Story card not found at index ${index} in updateStoryCard`,
        );
      }
    };

    try {
      await testFn();
    } finally {
      for (const key of properties) {
        Reflect.deleteProperty(globalThis, key);
        Reflect.set(globalThis, key, originals[key]);
      }
    }
  });
}

describe("DiceRoll", () => {
  describe("Hooks", () => {
    testWithAiDungeonEnvironment("Input should return the input with the dice roll result", () => {
      const result = DiceRoll.Hooks.Input("\n> You try to go to the store.\n");
      assert.match(result, /\[🎲 Dice Roll: .+\]$/);
    });
  })
});

This is mostly for hygiene, so that we’re not polluting the global state without being aware of it.

Finally, we’re setting up the sandbox environment to have the same global helpers as AI Dungeon’s, so that our tests are as close to the real thing as possible:

[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";
import DiceRoll from "./index.ts";

let nextCardId = 0;

export function testWithAiDungeonEnvironment(
  testName: string,
  testFn: () => void | Promise<void>,
): Promise<void> {
  return test(testName, async () => {
    nextCardId = 0;
    const properties: (keyof typeof globalThis)[] = [
      "storyCards",
      "history",
      "info",
      "state",
      "log",
      "addStoryCard",
      "removeStoryCard",
      "updateStoryCard",
    ];

    const originals = Object.fromEntries(
      properties.map((key) => [key, globalThis[key]]),
    );

    globalThis.storyCards = [];
    globalThis.history = [];
    globalThis.info = {
      actionCount: 0,
      characterNames: [],
    };
    globalThis.state = {
      memory: {},
      message: "",
    };

    globalThis.log = (): void => {
      // Silent in tests
    };

    globalThis.addStoryCard = ((
      keys: string,
      entry?: string,
      type: string = "Custom",
      name?: string,
      description?: string,
      options?: { returnCard: boolean },
    ): StoryCard | number => {
      const card: StoryCard = {
        id: `${nextCardId++}`,
        keys: keys ? [keys] : undefined,
        entry,
        type,
        title: name || keys,
        description: description || "",
      };
      globalThis.storyCards.push(card);

      if (options?.returnCard) {
        return card;
      }
      return globalThis.storyCards.length;
    }) as typeof globalThis.addStoryCard;

    globalThis.removeStoryCard = (index: number): void => {
      const card = globalThis.storyCards[index];
      if (card) {
        globalThis.storyCards.splice(index, 1);
      } else {
        throw new Error(
          `Story card not found at index ${index} in removeStoryCard`,
        );
      }
    };

    globalThis.updateStoryCard = (
      index: number,
      keys: string,
      entry: string,
      type?: string,
      name?: string,
      notes?: string,
    ): void => {
      const existing = globalThis.storyCards[index];
      if (existing) {
        globalThis.storyCards[index] = {
          id: existing.id,
          keys: keys ? [keys] : undefined,
          entry,
          type: type ?? existing.type,
          title: name ?? existing.title,
          description: notes ?? existing.description,
        };
      } else {
        throw new Error(
          `Story card not found at index ${index} in updateStoryCard`,
        );
      }
    };

    try {
      await testFn();
    } finally {
      for (const key of properties) {
        Reflect.deleteProperty(globalThis, key);
        Reflect.set(globalThis, key, originals[key]);
      }
    }
  });
}

describe("DiceRoll", () => {
  describe("Hooks", () => {
    testWithAiDungeonEnvironment("Input should return the input with the dice roll result", () => {
      const result = DiceRoll.Hooks.Input("\n> You try to go to the store.\n");
      assert.match(result, /\[🎲 Dice Roll: .+\]$/);
    });
  })
});

Let’s try running our test again with that helper above it:

[ SHELL ]dice-roll
npm run test> dice-roll@1.0.0 test> node --test▶ DiceRoll  ▶ Hooks    ✔ Input should return the input with the dice roll result (1.3482ms)  ✔ Hooks (1.9422ms)✔ DiceRoll (2.2864ms)ℹ tests 1ℹ suites 2ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 118.27

Very nice. That means we can find out in just under a 10th of a second if our script is going to work, rather than needing to upload it to AI Dungeon, then run a turn in a scenario which takes seconds at minimum.

###Additional Tests

Now, I’d like to add some additional tests to verify that our config is working as expected:

  • When disabled, the input should return the input unchanged
  • Should pull from custom modifier banks
  • Should pull from result types and triggers
[ TypeScript ]src/index.test.ts
import { describe, test } from "node:test";
import assert from "node:assert";
import DiceRoll from "./index.ts";
import { defaultConfig } from "./config.ts";

let nextCardId = 0;

export function testWithAiDungeonEnvironment(
  testName: string,
  testFn: () => void | Promise<void>,
): Promise<void> {
  return test(testName, async () => {
    nextCardId = 0;
    const properties: (keyof typeof globalThis)[] = [
      "storyCards",
      "history",
      "info",
      "state",
      "log",
      "addStoryCard",
      "removeStoryCard",
      "updateStoryCard",
    ];

    const originals = Object.fromEntries(
      properties.map((key) => [key, globalThis[key]]),
    );

    globalThis.storyCards = [];
    globalThis.history = [];
    globalThis.info = {
      actionCount: 0,
      characterNames: [],
    };
    globalThis.state = {
      memory: {},
      message: "",
    };

    globalThis.log = (): void => {
      // Silent in tests
    };

    globalThis.addStoryCard = ((
      keys: string,
      entry?: string,
      type: string = "Custom",
      name?: string,
      description?: string,
      options?: { returnCard: boolean },
    ): StoryCard | number => {
      const card: StoryCard = {
        id: `${nextCardId++}`,
        keys: keys ? [keys] : undefined,
        entry,
        type,
        title: name || keys,
        description: description || "",
      };
      globalThis.storyCards.push(card);

      if (options?.returnCard) {
        return card;
      }
      return globalThis.storyCards.length;
    }) as typeof globalThis.addStoryCard;

    globalThis.removeStoryCard = (index: number): void => {
      const card = globalThis.storyCards[index];
      if (card) {
        globalThis.storyCards.splice(index, 1);
      } else {
        throw new Error(
          `Story card not found at index ${index} in removeStoryCard`,
        );
      }
    };

    globalThis.updateStoryCard = (
      index: number,
      keys: string,
      entry: string,
      type?: string,
      name?: string,
      notes?: string,
    ): void => {
      const existing = globalThis.storyCards[index];
      if (existing) {
        globalThis.storyCards[index] = {
          id: existing.id,
          keys: keys ? [keys] : undefined,
          entry,
          type: type ?? existing.type,
          title: name ?? existing.title,
          description: notes ?? existing.description,
        };
      } else {
        throw new Error(
          `Story card not found at index ${index} in updateStoryCard`,
        );
      }
    };

    try {
      await testFn();
    } finally {
      for (const key of properties) {
        Reflect.deleteProperty(globalThis, key);
        Reflect.set(globalThis, key, originals[key]);
      }
    }
  });
}

describe("DiceRoll", () => {
  describe("Hooks", () => {
    testWithAiDungeonEnvironment("Input should return the input with the dice roll result", () => {
      const result = DiceRoll.Hooks.Input("\n> You try to go to the store.\n");
      assert.match(result, /\[🎲 Dice Roll: .+\]$/);
    });
  })

    testWithAiDungeonEnvironment("Input should return the input when disabled", () => {
      storyCards.push({
        id: `${nextCardId++}`,
        keys: ["enable"],
        entry: defaultConfig.replace("Enable: true", "Enable: false"),
        type: "Class",
        title: "Configure Dice Roll",
        description: "",
      });
      const result = DiceRoll.Hooks.Input("\n> You try to go to the store.\n");
      assert.equal(result, "\n> You try to go to the store.\n");
    });

    testWithAiDungeonEnvironment("Should pull from custom modifier banks", () => {
      for (let i = 0; i < 10; i++) {
        storyCards.push({
          id: `${nextCardId++}`,
          keys: ["advantage"],
          entry: `\
  Enable: true
  Triggers: try, attempt
  Results:
    S: Critical Success!
    s: Success!
    p: Partial Success!
    f: Failure!
    F: Critical Failure!
  Default Results: S s s s s s p p f
  Banks:
    advantage:
      words: surely
      results: S S`,
          type: "Class",
          title: "Configure Dice Roll",
          description: "",
        });
        const result = DiceRoll.Hooks.Input("\n> You surely attempt the lock.\n");
        assert.match(result, /\[🎲 Dice Roll: Critical Success!\]$/, `Result ${i}/10 should be Critical Success!`);
      }
    });

    testWithAiDungeonEnvironment("Should pull from result types and triggers", () => {
      for (let i = 0; i < 10; i++) {
        storyCards.push({
          id: `${nextCardId++}`,
          keys: ["result"],
          entry: `\
Enable: true
Triggers: flip
Results:
  H: Heads!
  T: Tails!
Default Results: H T
  `,
          type: "Class",
          title: "Configure Dice Roll",
          description: "",
        });
        const result = DiceRoll.Hooks.Input("\n> You flip a coin.\n");
        assert.match(result, /\[🎲 Dice Roll: (Heads|Tails)!\]$/, `Result ${i}/10 should be Heads or Tails!`);
      }
    });
  });
});

And run it:

[ SHELL ]dice-roll
npm run test> dice-roll@1.0.0 test> node --test▶ DiceRoll  ▶ Hooks    ✔ Input should return the input with the dice roll result (1.3756ms)    ✔ Input should return the input when disabled (0.2571ms)    ✔ Should pull from custom modifier banks (1.4018ms)    ✔ Should pull from result types (0.4803ms)  ✔ Hooks (4.2244ms)✔ DiceRoll (4.5721ms)ℹ tests 4ℹ suites 2ℹ pass 4ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 123.1465

One of the cool things you can see me doing here is looping a test multiple times to verify that we haven’t just gotten lucky and gotten a random result that follows what we’re expecting.

##Some Inspiration for Your Own Helpers

There are some helpers that I’ve written to make working with this easier, like an array of history messages and types that we can use to fill out the history before our test runs, or function helpers that produce a config entry for us. The sky’s kind of the limit here. When I found an incompatibility between my script and Inner Self, I was able to write a test that would mutate the context like Inner Self does, then run my script, and verify that that class of issues were never introduced again. I’ll include a couple examples here that you’re free to steal from.

This one is a helper that creates a config card for some of FoxTweaks’ intricacies, like using the description rather than the entry to store the config.

###Config Card Helper

[ TypeScript ]
export function createConfigCard(description: string): StoryCard {
  const length = globalThis.addStoryCard(
    "Configure FoxTweaks behavior",
    "",
    "class"
  );
  // We know addStoryCard returns number in this case (no returnCard option)
  const cardIndex =
    (typeof length === "number" ? length : globalThis.storyCards.length) - 1;
  const card = globalThis.storyCards[cardIndex];
  if (!card) {
    throw new Error("Failed to create config card");
  }
  card.title = "FoxTweaks Config";
  card.description = description;
  return card;
}

###Add History Action Helper

This one is a helper that adds a history action to the history array, and gives me nice autocomplete for the action types.

[ TypeScript ]
export function addHistoryAction(text: string, type: History["type"]): void {
  globalThis.history.push({ text, type });
}

###History Helper

Another one I have is a big chunk of history that I can use to fill out the history for me (this goes on for several turns), produced by adding log(text), log(JSON.stringify(history)), and log(JSON.stringify(state)) to a scenario in each of the hooks:

[ TypeScript ]
const turns = [
  {
    input: {
      state: {
        placeholders: [],
        memory: {
          context: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.",
          authorsNote: ""
        }
      },
      history: [],
      text: "You are an adventurer in a dungeon."
    },
    context: {
      state: {
        placeholders: [],
        memory: {
          context: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.",
          authorsNote: ""
        }
      },
      history: [
        {
          text: "You are an adventurer in a dungeon.",
          type: "start",
          rawText: "You are an adventurer in a dungeon."
        }
      ],
      text: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.\nYou are an adventurer in a dungeon."
    }
    output: {
      state: {
        placeholders: [],
        memory: {
          context: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.",
          authorsNote: ""
        }
      },
      history: [
        {
          text: "You are an adventurer in a dungeon.",
          type: "start",
          rawText: "You are an adventurer in a dungeon."
        }
      ],
      text: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\""
    }
  },
  {
    // Continue action, no input
    input: undefined,
    context: {
      state: {
        placeholders: [],
        memory: {
          context: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.",
          authorsNote: ""
        }
      },
      history: [
        {
          text: "The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\"",
          type: "continue",
          rawText: "The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\""
        }
      ],
      text: " Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallic smell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings worn smooth by time—perhaps depicting robed figures in procession. The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes."
    },
    output: {
      state: {
        placeholders: [],
        memory: {
          context: "### Character\\n\\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\\n\\n### Premise\\n\\nYou're delving a dungeon with Elara and Lyra.\",\"authorsNote\":\"\"
        }
      },
      history: [
        {
          text: "You are an adventurer in a dungeon.",
          type: "start",
          rawText: "You are an adventurer in a dungeon."
        },
        {
          text: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\"",
          type: "continue",
          rawText: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\""
        },
      ],
      text: " Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallic smell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings worn smooth by time—perhaps depicting robed figures in procession. The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes."
    }
  },
  // Do action
  {
    input: {
      state: {
        placeholders: [],
        memory: {
          context: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.",
          authorsNote: ""
        }
      },
      history: [
        {
          text: "You are an adventurer in a dungeon.",
          type: "start",
          rawText: "You are an adventurer in a dungeon."
        },
        {
          text: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\"",
          type: "continue",
          rawText: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is
        },
        {
          text: " Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallic smell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings worn smooth by time—perhaps depicting robed figures in procession.The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes.",
          type: "continue",
          rawText: " Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallicsmell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings worn smooth by time—perhaps depicting robed figures in procession. The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes."
        }
      ],
      text: "\n> You try to follow the scent.\n"
    },
    context: {
      state: {
        placeholders: [],
        memory: {
          context: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.",
          authorsNote: ""
        }
      },
      history: [
        {
          text: "You are an adventurer in a dungeon.",
          type: "start",
          rawText: "You are an adventurer in a dungeon."
        },
        {
          text: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\"",
          type: "continue",
          rawText: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\""
        },
        {
          text: " Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallic smell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings worn smooth by time—perhaps depicting robed figures in procession.The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes.",
          type: "continue",
          rawText: " Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallicsmell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings worn smooth by time—perhaps depicting robed figures in procession. The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes."
        },
        {
          text: "\n> You try to follow the scent.\n",
          type: "do",
          rawText: "\n> You try to follow the scent.\n"
        }
      ],
      text: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.\nWorld Lore:\nLyra is a wiry human rogue with blonde hair and green eyes.\n\nElara is an elf with silver hair and violet eyes.\n\nRecent Story:\nYou are an adventurer in a dungeon. The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\" Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallic smell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings wornsmooth by time—perhaps depicting robed figures in procession. The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes.\n\n> You try to follow the scent.\n"
    },
    output: {
      state: {
        placeholders: [],
        memory: {
          context: "### Character\n\nYou are Jace, an adventurer. You are strong, wielding a sword and shield, and have black hair and blue eyes. You travel with Elara, an elven mage with silver hair and violet eyes, and Lyra, a wiry rogue with blonde hair and green eyes.\n\n### Premise\n\nYou're delving a dungeon with Elara and Lyra.",
          authorsNote: ""
        }
      },
      history: [
        {
          text: "You are an adventurer in a dungeon.",
          type: "start",
          rawText: "You are an adventurer in a dungeon."
        },
        {
          text: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\"",
          type: "continue",
          rawText: " The Dungeon Entrance\n\nThe heavy iron door groans on ancient hinges as you push it open, revealing a dark corridor ahead. The air is cool and damp, smelling of wet stone and something faintly metallic. From your torch, flickering light dances across rough-hewn walls glistening with condensation.\n\nBeside you, Elara adjusts the silver-white braid over her shoulder, her violet eyes narrowed as she peers into the gloom. \"The aura here is… old,\" she murmurs, one hand resting on the smooth wood of her staff. \"There's residual magic, but it's faded. Like an echo.\""
        },
        {
          text: " Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallic smell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings worn smooth by time—perhaps depicting robed figures in procession.The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes.",
          type: "continue",
          rawText: " Lyra leans against the wall, a grin playing on her lips. She tests the edge of one of her daggers with a thumb. \"Old and faded sounds like someone already looted the place. But hey, echoes can be valuable too.\" She pushes off the wall, her leathers creaking softly. \"Shall we see what's making that metallicsmell? Could be rust… or something shinier.\" \n\nTorchlight licks the walls, revealing crude carvings worn smooth by time—perhaps depicting robed figures in procession. The corridor stretches ahead into darkness, the floor uneven with settled flagstones. From deep within, a faint, rhythmic dripping echoes."
        },
        {
          text: "\n> You try to follow the scent.\n",
          type: "do",
          rawText: "\n> You try to follow the scent.\n"
        }
      ],
      text: "You step forward, the torchlight shifting with your movement as you lead the way down the corridor. The metallic scent grows stronger—not the sharp tang of blood, but something heavier, like aged copper or bronze. Your boots scuff against the stone, disturbing a fine layer of dust that puffs up into the torchlight.\n\nElara follows close behind, her staff held loosely but ready. \"Be careful,\" she says softly, her voice carrying in the narrow space. \"Old dungeons have a way of hiding their teeth.\" Lyra is already a few steps ahead of you, peering at the walls. She traces a finger along one of the weathered carvings."
    }
  }
];

I have a little helper script that takes in the history strings from the log copied straight from AI Dungeon and puts them into the JSON format, like this:

[ TypeScript ]parseLog.ts
import { readFileSync } from "node:fs";

interface ModifierFrame {
  state: State;
  history: History[];
  text: string;
}

interface Turn {
  input?: ModifierFrame;
  context: ModifierFrame;
  output: ModifierFrame;
}

interface Section {
  kind: string;
  logs: string[];
}

const HEADER = /^(Input|Context|Output) Modifier @ .+:$/;
const LOG_PREFIX = "Log: ";

function parseStringLog(log: string): string {
  const value: unknown = JSON.parse(log);
  if (typeof value !== "string") {
    throw new Error(`Expected a quoted string log entry, got: ${log}`);
  }
  return value;
}

function toFrame(section: Section): ModifierFrame {
  if (section.logs.length < 3) {
    throw new Error(
      `${section.kind} modifier needs state, history, and text logs, found ${section.logs.length}`,
    );
  }
  const state: State = JSON.parse(parseStringLog(section.logs[0]));
  const history: History[] = JSON.parse(parseStringLog(section.logs[1]));
  const text = parseStringLog(section.logs[2]);
  return { state, history, text };
}

export function parseLog(raw: string): Turn[] {
  const sections: Section[] = [];
  for (const line of raw.split(/\r?\n/)) {
    const header = line.match(HEADER);
    if (header) {
      sections.push({ kind: header[1], logs: [] });
      continue;
    }
    if (line.startsWith(LOG_PREFIX) && sections.length > 0) {
      sections[sections.length - 1].logs.push(line.slice(LOG_PREFIX.length));
    }
  }

  const turns: Turn[] = [];
  let input: ModifierFrame | undefined;
  let context: ModifierFrame | undefined;
  for (const section of sections) {
    const frame = toFrame(section);
    if (section.kind === "Input") {
      input = frame;
    } else if (section.kind === "Context") {
      context = frame;
    } else {
      if (!context) {
        throw new Error("Output modifier with no preceding context modifier");
      }
      turns.push({ input, context, output: frame });
      input = undefined;
      context = undefined;
    }
  }
  return turns;
}

if (import.meta.main) {
  const path = process.argv[2];
  const raw = readFileSync(path ?? 0, "utf8");
  process.stdout.write(`${JSON.stringify(parseLog(raw), null, 2)}\n`);
}

Run like:

[ SHELL ]
node parseLog.ts dump.log

##Conclusion

I hope you find this process useful! I’ve found that it makes me much faster, and by the time I actually build my project and upload it to AI Dungeon, I tend to find far fewer issues than if I had just built it in AI Dungeon because I can verify and iterate much faster.

LLMs can also find this much easier to work with these, since they don’t know the sandbox and scripting API, and this gives them access to that without needing them to need to control a browser or use the API or something like that (though if you do want them to use the API, check out my ai-dungeon skill!)

As always, if this was useful to you please let me know! I’m worldsmythe_ on the AI Dungeon Discord.