Skip to content

Fenia Cookbook

Kit Oliynyk edited this page Nov 2, 2026 · 2 revisions

TL;DR: Worked examples and short recipes for Fenia in the current style: a mob with random actions, an exit redirect, a delayed conversation, a global trigger, a thread, and about twenty one-screen tasks. Vnums are public Midgaard and library vnums or marked placeholders. Use when: you know what you want a mob, item or room to do and need a working shape to start from. Source of truth: the examples were checked against the API (dreamland_code/plug-ins/feniaroot/*wrapper.cpp) and the public repositories dreamland_world/areas.local, dreamland_fenia_public Verified: 2026-11-02 against dreamland_code 88da8302, dreamland_world 43880467, dreamland_fenia_public c28874b. The examples were read against the source and the API lookup. They were not all run on a live server.

Conventions. Strings in the examples are plain English so they stay readable. Shipped scripts wrap every player-facing string in ._("source text"), where the source is Russian and the EN and UA texts come from the translation catalog, see L10n-Fenia. The first example shows the explicit form .mm(en, ru, ua), which needs no catalog. Message formats are explained on Output-Format. Every name used here was checked with the API lookup on Fenia-API. Pauses are in pulses, four to the second.

A cat that meows and scratches the furniture

Goal: the kitten (mob 3090, Midgaard) now and then meows, yawns, or tears up a piece of furniture.

Pick the trigger. onSpec runs every 4 seconds for every mob, onArea every minute. onSpec with a small chance gives the unpredictable feel. Triggers on a prototype receive the instance as the first argument, here mob.

Roll a chance. .chance(x) is true with probability x percent. .chanceOneOf(n) is true once in n. Test with a high value, then lower it.

Find furniture. room.get_obj_type(type) gives the first item of a type. Item types are in .tables.item_table, so furniture is .tables.item_table.furniture. List a table with eval ptc(.tables.item_table.api()). Midgaard has a bench (item 3134) to test with.

.apply(function() {
    .get_mob_index(3090).onSpec = function(mob) {
        // Dead, asleep, fighting or feared mobs do nothing ambient.
        if (!.tmp.mob.canActAmbient(mob))
            return false;

        if (!.chance(1))                  // use 20 while testing
            return false;

        if (.chanceOneOf(3)) {
            mob.interpret("emote meows loudly.");
        } else if (.chanceOneOf(2)) {
            mob.interpret("emote yawns and stretches gracefully.");
        } else {
            var furniture;
            furniture = mob.in_room.get_obj_type(.tables.item_table.furniture);
            if (furniture == null)
                return false;
            // %1$ is the cat, %2$ the furniture. ^ capitalizes.
            mob.recho(.mm("%1$^C1 claws at %2$O4.",
                          "%1$^C1 царапает когтями %2$O4.",
                          "%1$^C1 дере кігтями %2$O4."), mob, furniture);
        }
        return true;
    };
})

Why it looks like this: every placeholder is numbered, so the cat can be referenced twice. The .apply wrapper makes the file one expression. See Fenia-Language.

Shuffle the exits of a room at night

Goal: in a group of "forest" rooms, a walker who leaves at night ends up in a random neighbouring room.

The room trigger onExitLocation(walker, targetRoom, door) may return a vnum as a string to redirect the move. .sunlight is 0 at night, 1 dawn, 2 day, 3 dusk (.hour is the exact hour, but dark begins at different hours in different seasons). this.exits(walker) lists the exits the walker may use, and this.getRoom(door) is the room behind one.

Assign the same trigger to several rooms with forEach. The callback is a plain function, so it cannot see outer locals. The element is this.

.apply(function() {
    var rooms; rooms = .List();
    rooms.add(20001, 20002, 20003, 20004);      // placeholder vnums: use your own rooms

    rooms.forEach(function() {
        .get_room_index(this).onExitLocation = function(walker, targetRoom, door) {
            if (.sunlight != 0)
                return "";
            // exits() is never empty here: the exit being used exists.
            var pick; pick = this.exits(walker).random();
            return "" + this.getRoom(pick).vnum;
        };
    });
})

Check that it landed with eval ptc(.get_room_index(20001).rtapi()). It prints onExitLocation. The engine treats an empty string, a non-number, or 0 as "no redirect".

A conversation with pauses

Goal: the librarian (mob 28800) answers "coffee please" by pouring a cup (item 3101) and handing it over, with pauses between the steps.

No pauses first. onSpeech(mob, talker, msg) runs inside the say command, so every action happens at once.

Add pauses. post... triggers run after the command in their own thread, and only a thread can sleep. Replace onSpeech with postSpeech, and set the old onSpeech to null, or both run. .scheduler.sleep(n) takes pulses.

Check who is still there. After every pause the mob or the speaker may be gone. .tmp.mob.valid(x) is false for a dead, extracted or disconnected character. Immortals should not wait, so the delay is 0 for them (see Code-Style).

Match loosely. msg.match(regex) tests a regular expression, so the words may come in any order and in two languages.

.apply(function() {
    .get_mob_index(28800).onSpeech = null;

    .get_mob_index(28800).postSpeech = function(mob, talker, msg) {
        if (!msg.match("coffee|кофе"))
            return;
        if (!msg.match("please|pls|пожалуйста"))
            return;

        var d; d = 4;                         // one second
        if (talker.is_immortal())
            d = 0;

        .tmp.system.sleep(d);
        if (!.tmp.mob.valid(mob) || !.tmp.mob.valid(talker))
            return;
        mob.interpret("emote takes the kettle off the stove.");

        .tmp.system.sleep(d);
        if (!.tmp.mob.valid(mob) || !.tmp.mob.valid(talker))
            return;
        mob.interpret("emote pours a cup of coffee.");

        .tmp.system.sleep(d);
        if (!.tmp.mob.valid(mob) || !.tmp.mob.valid(talker))
            return;
        .get_obj_index(3101).create().obj_to_char(mob);   // a new item must go somewhere at once
        mob.interpret("give coffee " + talker.getName());
        mob.interpret("emote smiles warmly.");
    };
})

.get_obj_index(vnum).create() makes an instance that exists nowhere until you give it to a character or put it in a room. Hand it over in the same expression.

A global trigger on a local server

The engine fires global triggers through a function .tmp.trigger_handler. A server built only from the public repositories does not have one, so global triggers do nothing until you define it. This is a minimal version of the dispatcher, plus one handler:

.apply(function() {
    if (.tmp.gtrig == null)
        .tmp.gtrig = .Array();

    .tmp.trigger_handler = function(trigName, args) {
        var handlers, result;
        result = false;
        handlers = .tmp.gtrig[trigName];
        if (handlers == null)
            return false;
        for (label in handlers.keys) {
            if (.apply(handlers[label], null, args) == true)
                result = true;
        }
        return result;
    };

    if (.tmp.gtrig["onDeath"] == null)
        .tmp.gtrig["onDeath"] = .Array();

    // Arguments: see the global trigger table on Fenia-Triggers.
    .tmp.gtrig["onDeath"]["my label"] = function(victim, killer, part, label, damtype) {
        if (killer != null && !killer.is_npc())
            killer.act("You feel stronger.");
        return false;       // true would interrupt the death
    };
})

A second handler, this time on speech, registered the same way:

.apply(function() {
    if (.tmp.gtrig["onSpeech"] == null)
        .tmp.gtrig["onSpeech"] = .Array();

    .tmp.gtrig["onSpeech"]["echo test"] = function(ch, msg) {
        if (msg.match("^ping$"))
            ch.act("pong");
        return false;
    };
})

.Array() accepts any key, including a label with a space, and survives reboots. Several labels can listen to the same trigger. gprog_registered(name) in the engine checks .tmp.gtrig[name], so an event nobody listens to costs almost nothing.

A thread that outlives the trigger

// Print a message three times, 2 seconds apart, even if the trigger already returned.
.scheduler.Thread(function(ch) {
    var i;
    for (i = 0; i < 3; i = i + 1) {
        if (!ch.is_immortal())
            .scheduler.sleep(8);
        .scheduler.yield();
        if (!.tmp.mob.valid(ch))
            return;
        ch.act("Tick.");
    }
}, ch).start();

Pass everything the thread needs as arguments. Locals of the caller are not visible. .scheduler.Thread(fn, args) builds the thread, start() runs it. .scheduler.yield() hands control to the next thread until the next pulse. The immortal guard keeps gods from waiting while they test.

Small recipes

Test flags

Flags are numbers made of bits. Tables hold the bit values.

if (.isset_bit(ch.affected_by, .tables.affect_flags.sanctuary))
    ch.act("You glow softly.");

if (ch.res_flags & .tables.res_flags.wood)
    ch.act("Wood does not scare you.");

Clear a bit with .unset_bit(mask, bit), set one with .set_bit(mask, bit). Names of the bits in a mask: .tables.res_flags.names(ch.res_flags). mask & ~bit also works, but .unset_bit reads clearer.

Where is an item?

An item is in another item, carried by someone, or lying in a room:

if (item.in_obj == container)
    item.getRoom().echo("%1$^O1 is inside %2$O2.", item, container);
else if (item.carried_by != null)
    item.getRoom().echo("%1$^C1 carries %2$O4.", item.carried_by, item);
else if (item.in_room != null)
    item.getRoom().echo("%1$^O1 lies on the floor of '%2$s'.", item, item.in_room.name);

A random other character in the room, and a loop over everyone

room.ppl is a fresh list on every read, so subtracting from it is safe.

var other; other = ch.in_room.ppl.sub(ch).random();
if (other != null)
    other.act("%1$^C1 winks at you.", ch);

for (m in ch.in_room.ppl.sub(ch)) {         // m needs no var
    if (.chance(10))
        m.interpret("emote giggles.");
}

Count the group members in the room

var n; n = 0;
for (g in ch.in_room.ppl) {
    if (ch.is_same_group(g))
        n = n + 1;
}
ch.act("%1$d of your group are here, counting you.", n);

Weather and room flags

.sky is 0 clear, 1 cloudy, 2 rain, 3 thunderstorm.

if (.isset_bit(ch.in_room.room_flags, .tables.room_flags.indoors))
    ch.act("Go outside first.");
else if (.sky == 0)
    ch.act("The sky is clear.");
else if (.sky == 2)
    ch.act("It is raining.");

Sector type

if (ch.in_room.sector_type == .tables.sector_table.forest)
    ch.act("At last, a forest.");

var s; s = .tables.sector_table.name(ch.in_room.sector_type);   // "forest", "city", ...

.tables.<table>.name(v) turns one value into its name, .names(mask) turns a mask into names, .value("name") goes back. .tables.<table>.api() lists every entry.

Weapon checks

var w; w = ch.get_eq_char("wield");
if (w != null && w.item_type == .tables.item_table.weapon) {
    ch.act("%1$^O1: %2$s, average damage %3$d.", w,
           .tables.weapon_flags.name(w.weapon_attack), w.ave);
    if (w.weapon_attack == .tables.weapon_flags.pierce
        || w.weapon_attack == .tables.weapon_flags.stab)
        ch.act("This is a good weapon for a backstab.");
}
if (w != null && w.hasWeaponFlag("flaming shocking frost"))
    return;

An affect that changes a weapon

var af; af = .Affect();
af.type     = "winters touch";
af.level    = 50;
af.duration = 10;                              // ticks
af.bitvector("weapon_type2", "frost");         // the table name picks what the bits mean
obj.affectAdd(af);

The .Affect(skill, level, duration) form creates the same thing in one call and sets only the first three fields. The where field is not for bitvectors. It only accepts wearlocations, liquids, skills and skill_groups and throws on anything else.

A plain marker affect

An affect with no numeric effect still counts for ch.isAffected("backguard") checks:

ch.affectAdd(.Affect("backguard", ch.modifyLevel, 2));

Damage with messages

ch.damage(victim, dam, skillName, damType, damFlags[, show]) needs all five arguments.

var vict; vict = ch.fighting;
if (vict == null)
    return;
ch.act("You strike %1$C4 with terrible force.", vict);
ch.rvecho(vict, "%1$^C1 strikes %2$C4 with terrible force.", ch, vict);
vict.act("%1$^C1 strikes you with terrible force.", ch);

var dam; dam = .dice(ch.modifyLevel, 18);
ch.damage(vict, dam, "earthquake", "none", "other");

Answer by pattern

match tests a POSIX extended regular expression and ignores case (matchCase does not). matchGroups(pattern) returns the captured groups as a list, last group first.

.get_mob_index(3000).onSpeech = function(mob, speaker, message) {
    var pattern; pattern = "can you (give|return|hand) ([^ ]+)";
    if (!message.match(pattern))
        return false;
    var g; g = message.matchGroups(pattern);
    // g.at(1) is the first group (the verb), g.at(0) the second (the thing).
    mob.say("I never meant to " + g.at(1) + " you any " + g.at(0) + ".");
    return true;
}

Check immunity before casting

if (!victim.isImmune(.tables.damage_table.poison, .tables.damage_flags.magic)) {
    if (!victim.isAffected("poison")) {
        ch.spell("poison", ch.modifyLevel, victim);
        return;
    }
}

Greet by religion

ch.religion is a religion struct. Its name is the English name, and none means no god. .Religion("name") builds one by name. The name must exist, or reading it throws, see Fenia-Gotchas.

if (ch.is_npc())
    return false;
if (ch.religion.name == "none")
    mob.say("Welcome, unbeliever.");
else
    mob.say("Welcome, follower of " + ch.religion.name + ".");

Different look text for an item

onExtraDescr(obj, ch, arg) returns the text to show for a keyword, or null to let the search go on. Answer only your own keywords: carried items are asked before the room.

.get_obj_index(30001).onExtraDescr = function(obj, ch, arg) {      // placeholder vnum
    if (!arg.is_name("telescope"))
        return null;
    if (obj.state == "galaxy")
        return "In the eyepiece you see a mysterious galaxy.";
    if (obj.state == "meadows")
        return "In the eyepiece you see green meadows.";
    return null;
}

Player attributes

Attributes are typed values stored in the player file. Read with attribute(name). Create once, erase before you change:

var cry; cry = ch.attribute("warcry");
if (cry != null)
    ch.interpret_raw("yell", cry);
else
    ch.act("You let out a battle cry.");

if (ch.hasAttribute("visits"))
    ch.eraseAttribute("visits");
ch.attribute("visits", "" + 5);        // string: survives remort

See also

Clone this wiki locally