Talebench

Examples

Talebench’s test add-on

It counts the passages entered, shows the count on a step and a page of its own, answers the formula entered(), reads its board’s label from its settings, and stops on purpose when asked spin(), to test the step budget. These are its files as they are in Talebench’s repository.

Cargo.toml
[package]
name = "talebench-package-counter"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true

# A test package (L-197, stage b): it counts the passages entered in its own state, answers
# `entered()`, gives 5 gold on the third, and loops forever when asked `spin()` (the step budget).
[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
talebench-sdk = { path = "../talebench-sdk" }
serde_json.workspace = true

[features]
# exported with `tb_prepare` (L-197 d2)
prepared = []
# its version 1.1.0 (L-197 i10, `package.v1_1.json`): its content's `label` named `title`, its
# migration from 1.0.0 renaming it and noting where its state came from
v1_1 = []
# a sandboxed page of its own (L-198 p4, `package.page.json`): its menu page a `frame` part
page = []
package.json
{
  "id": "test.counter",
  "name": { "en": "Counter", "de": "Zähler" },
  "description": {
    "en": "A test package: counts the passages entered, shows its count on a page of its own and counts up at a button.",
    "de": "Ein Testpaket: zählt die betretenen Abschnitte, zeigt die Zahl auf einer eigenen Seite und zählt auf Knopfdruck weiter."
  },
  "version": "1.0.0",
  "interface": "1.1",
  "formatVersions": [5, 5],
  "test": { "story": "test/story.json", "walkthrough": "test/walkthrough.json" },
  "capabilities": {
    "points": ["on", "take", "formula", "view", "check"],
    "owns": ["effect:count_up", "command:count_up", "event:counted", "step:board", "check:no_board"],
    "changesCoreValues": true,
    "page": false,
    "functions": [{ "name": "entered", "params": 0, "returns": "number" }]
  },
  "book": {
    "steps": { "board": { "en": "Count the passages you have entered so far.", "de": "Zähle die Abschnitte, die du bisher betreten hast." } },
    "effects": { "count_up": { "en": "add 1 to your count", "de": "zähle 1 zu deiner Zahl" } }
  },
  "state": {
    "schema": {
      "type": "object",
      "properties": {
        "count": { "type": "integer", "minimum": 0 }
      }
    }
  },
  "content": {
    "schema": {
      "type": "object",
      "properties": {
        "label": { "type": "string", "maxLength": 40, "default": "Passages entered" },
        "picture": { "type": "boolean", "default": false }
      }
    },
    "fields": {
      "label": {
        "label": { "en": "Board's label", "de": "Beschriftung der Tafel" },
        "help": { "en": "What the board calls the count.", "de": "Wie die Tafel die Zahl nennt." },
        "view": "simple"
      },
      "picture": {
        "label": { "en": "Show the board's picture", "de": "Bild der Tafel zeigen" },
        "view": "full"
      }
    }
  }
}
src/lib.rs
//! A test package for the sandbox (L-197, stage b): the same counter as the engine's native test
//! package (`talebench-core/tests/packages.rs`), built for `wasm32-unknown-unknown`.

use serde_json::{Value, json};

/// One message answered.
pub fn handle(message: &str, host: &mut dyn FnMut(&str) -> String) -> String {
    let m: Value = serde_json::from_str(message).unwrap_or(Value::Null);
    match m["point"].as_str() {
        // a trap (L-197: module.stopped): 100 gold proposed, then a loop the step budget stops
        Some("on")
            if m["moment"]["moment"] == "passage_entered" && m["passageId"] == "psg_trap" =>
        {
            host(
                &json!({ "call": "effects", "effects": [
                    { "type": "change", "valueId": "val_gold", "by": "100" }] })
                .to_string(),
            );
            let mut n: u64 = 1;
            while std::hint::black_box(n) != 0 {
                n = n.wrapping_add(2);
            }
            "{}".into()
        }
        Some("on") if m["moment"]["moment"] == "passage_entered" => {
            let state: Value =
                serde_json::from_str(&host(r#"{"call":"state"}"#)).unwrap_or(Value::Null);
            let count = state["count"].as_u64().unwrap_or(0) + 1;
            host(&json!({ "call": "set_state", "state": { "count": count } }).to_string());
            if count == 3 {
                host(
                    &json!({ "call": "effects", "effects": [
                        { "type": "change", "valueId": "val_gold", "by": "5" }] })
                    .to_string(),
                );
            }
            json!({ "flow": "pass" }).to_string()
        }
        // its own effect (L-197 e1): one more on its count, and its own event said
        Some("take")
            if (m["act"]["effect"]["type"] == "module" && m["act"]["effect"]["name"] == "count_up")
                || (m["act"]["command"]["type"] == "module"
                    && m["act"]["command"]["name"] == "count_up") =>
        {
            let state: Value =
                serde_json::from_str(&host(r#"{"call":"state"}"#)).unwrap_or(Value::Null);
            let count = state["count"].as_u64().unwrap_or(0) + 1;
            host(&json!({ "call": "set_state", "state": { "count": count } }).to_string());
            host(
                &json!({ "call": "emit", "event": {
                    "type": "Module", "name": "counted", "data": { "count": count } } })
                .to_string(),
            );
            json!({ "took": true, "done": false }).to_string()
        }
        // its own step (L-197 v2): taken, it waits where it stands
        Some("take") if m["act"]["step"]["type"] == "module" && m["act"]["step"]["name"] == "board" => {
            json!({ "took": true, "done": true }).to_string()
        }
        // the step's view: the count, and a button that counts and goes on
        // its label from its own content (L-197 i9), "Passages entered" without
        Some("view") if m["surface"] == "step" && m["name"] == "board" => {
            let content: Value =
                serde_json::from_str(&host(r#"{"call":"content"}"#)).unwrap_or(Value::Null);
            let mut parts = vec![
                json!({ "part": "value", "label": label(host), "value": m["state"]["count"].as_u64().unwrap_or(0) }),
                json!({ "part": "button", "label": "Count and go on", "command": { "name": "count_up" }, "done": true }),
            ];
            // its own picture (L-197 i15) when its settings ask for it
            if content["picture"] == true {
                parts.insert(
                    0,
                    json!({ "part": "picture", "asset": "test.counter/board", "alt": "A counting board" }),
                );
            }
            json!({ "parts": parts }).to_string()
        }
        // its sandboxed page (L-198 p4): the count and Count up in its own code
        Some("view") if cfg!(feature = "page") && m["surface"] == "page" => json!({ "parts": [
            { "part": "heading", "text": "Counter" },
            { "part": "frame", "label": "The counter's own page", "height": 260,
              "data": { "from": "the menu" } }
        ] })
        .to_string(),
        // its page (L-197 v1): the count and a button counting up
        Some("view") if m["surface"] == "page" => json!({ "parts": [
            { "part": "heading", "text": "Counter" },
            { "part": "value", "label": label(host), "value": m["state"]["count"].as_u64().unwrap_or(0) },
            { "part": "button", "label": "Count up", "command": { "name": "count_up" } }
        ] })
        .to_string(),
        Some("formula") if m["name"] == "entered" => {
            json!({ "value": m["state"]["count"].as_u64().unwrap_or(0) }).to_string()
        }
        // L-197 i10: version 1.1.0 migrates 1.0.0's content (`label` → `title`) and state
        Some("migrate") if cfg!(feature = "v1_1") => {
            let mut content = m["content"].clone();
            if let Some(obj) = content.as_object_mut()
                && let Some(label) = obj.remove("label")
            {
                obj.insert("title".into(), label);
            }
            let mut state = m["state"].clone();
            if state.is_object() {
                state["migratedFrom"] = m["from"].clone();
            }
            json!({ "content": content, "state": state }).to_string()
        }
        // L-197 i16: its own check — a game with the counter but nowhere its board
        Some("check") => {
            let story: Value =
                serde_json::from_str(&host(r#"{"call":"story"}"#)).unwrap_or(Value::Null);
            let has_board = story["passages"].as_array().into_iter().flatten().any(|p| {
                p["steps"].as_array().into_iter().flatten().any(|s| {
                    s["type"] == "module" && s["module"] == "test.counter" && s["name"] == "board"
                })
            });
            let problems = if has_board {
                vec![]
            } else {
                vec![json!({ "code": "no_board", "severity": "warning", "pointer": "/passages",
                    "message": { "en": "The counter's board is in no passage yet: add its step where players should see the count.",
                                 "de": "Die Tafel des Zählers steht noch in keiner Passage: Füge ihren Schritt dort ein, wo Spieler die Zahl sehen sollen." } })]
            };
            json!({ "problems": problems }).to_string()
        }
        // a package that never ends: the step budget stops it
        Some("formula") if m["name"] == "spin" => {
            let mut n: u64 = 0;
            loop {
                n = n.wrapping_add(std::hint::black_box(1));
                if std::hint::black_box(n) == 0 {
                    break;
                }
            }
            json!({ "value": n }).to_string()
        }
        _ => "{}".into(),
    }
}

/// Calls answered by this instance (a test of what the sandbox keeps between calls: nothing).
static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

/// `handle` counting the calls this instance answered (`calls()` tells).
pub fn counted(message: &str, host: &mut dyn FnMut(&str) -> String) -> String {
    let n = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
    let m: Value = serde_json::from_str(message).unwrap_or(Value::Null);
    if m["point"] == "formula" && m["name"] == "calls" {
        return json!({ "value": n }).to_string();
    }
    handle(message, host)
}

// L-197 d2: with `prepared`, a package with `tb_prepare` (its memory kept after it and restored
// for every call); without, each call in a fresh instance
#[cfg(feature = "prepared")]
talebench_sdk::package!(counted);
#[cfg(not(feature = "prepared"))]
talebench_sdk::package!(counted, fresh);

/// The board's label: the content's `label` (L-197 i9), "Passages entered" without one.
fn label(host: &mut dyn FnMut(&str) -> String) -> String {
    let content: Value =
        serde_json::from_str(&host(r#"{"call":"content"}"#)).unwrap_or(Value::Null);
    // version 1.1.0 names it `title` (L-197 i10)
    let key = if cfg!(feature = "v1_1") {
        "title"
    } else {
        "label"
    };
    content[key]
        .as_str()
        .unwrap_or("Passages entered")
        .to_string()
}
test/story.json
{
  "formatVersion": 5,
  "features": { "scenes": false, "characters": false, "things": false, "equipment": false,
    "documents": false, "collections": false, "checks": false, "battles": false, "skills": false,
    "levels": "off" },
  "settings": { "language": "en", "resolution": { "width": 1280, "height": 720 },
    "start": { "passageId": "psg_hall" }, "maxActivePartySize": 1,
    "inventory": { "maxDistinct": null, "defaultMaxStack": 99 }, "currencyValueId": null,
    "player": { "rewindLastChoice": false } },
  "values": [{ "id": "val_gold", "key": "gold", "name": "Gold", "type": "number",
    "scope": "game", "default": 0, "shown": true }],
  "passages": [
    { "id": "psg_hall", "title": "Hall", "steps": [
        { "id": "stp_hall", "type": "text", "text": "A quiet hall." }],
      "next": { "type": "choices", "exits": [
        { "id": "ext_garden", "label": "To the garden",
          "effects": [{ "type": "module", "module": "test.counter", "name": "count_up" }],
          "to": { "type": "passage", "passageId": "psg_garden" } }] } },
    { "id": "psg_garden", "title": "Garden", "steps": [
        { "id": "stp_board", "type": "module", "module": "test.counter", "name": "board" },
        { "id": "stp_garden", "type": "text", "text": "Roses." }],
      "next": { "type": "choices", "exits": [
        { "id": "ext_door", "label": "The hidden door", "onlyIf": "entered() >= 2",
          "to": { "type": "ending", "endingId": "end_done" } }] } }
  ],
  "endings": [{ "id": "end_done", "name": "Done", "kind": "good" }]
}
test/walkthrough.json
{
  "steps": [
    { "choose": "To the garden" },
    { "choose": "The hidden door" }
  ],
  "endingId": "end_done"
}

Talebench’s kits

Kits are content, not code. Download one and add it to a game in the editor under Kits.