Job data reference
This page is the full schema reference for job data directories. If you are new to job authoring, start with jobs-cookbook for step-by-step recipes, then come back here when you need the exact attribute list.
Everything documented here is supported today: it is the format the current engine parses. A separate section at the end describes where the job model is heading, clearly marked, so you can see what is coming without mistaking it for something you can ship.
Directory layout
Section titled “Directory layout”Each job lives in its own directory under resources/jobs/:
resources/jobs/<id>/ job.xml required -- identity, title, description, eligibility effects.xml optional -- stat/skill/brothel changes applied each shift performance.xml optional -- how the performance score is calculated wage.xml optional -- gold paid per shift, and who pays it gains.xml optional -- skill XP and trait grants each shift messages/ work.xml optional -- shift-end message variants refuses.xml optional -- refusal-arm message variants banks.xml optional -- named fragment banks for composed messages text/ en.xml optional -- localizable strings for the messages aboveOnly job.xml is required. A job with no other files is valid: the girl shows up in the job list, can be assigned, but nothing happens each shift.
The directory name (<id>) must be lowercase, no spaces, and must match the id attribute in job.xml. It also becomes the prefix for all text IDs in messages/work.xml and text/en.xml.
Jobs in packs
Section titled “Jobs in packs”Everything on this page also works inside a content pack. Put the same job directory under your pack’s jobs/ folder:
resources/packages/<YourPack>/ package.xml jobs/ barback/ job.xml ...The rules that differ from core jobs:
- The folder must be named
jobs/, all lowercase. A capitalizedJobs/happens to work on Windows and most Macs because those filesystems ignore case, but the pack’s jobs silently vanish on Linux. - A pack job’s full id is
<pack>/<folder>, for examplemypack/barback. That qualified id is how other files refer to the job, so renaming the folder changes the id. The older spelling with a colon,mypack:barback, means the same job and still works; see the note below. - Jobs-only packs are valid: a
package.xmlplus ajobs/folder is a complete, installable pack (game version 1.17.2 or newer). - The
Iddeclared inpackage.xmlmust match the pack’s folder name, or be left out so the folder name is the only identity. When a declaredIddisagrees with the folder, the game skips everything the pack addresses by id: jobs, buildings, resources and heritage (characters, traits and items still load), so a working pack can still have dead jobs. Checkgamelog.txtif a job never appears. - Where the job shows up in the game is decided by
<Filter>, exactly as described in thejob.xmlsection above: a filter names the building type that offers the job, and a job without one is a brothel job. - Pack jobs load after every core job, so your
performance.xmland messages can rely on stock jobs already existing.
Message overlays: adding lines to an existing job
Section titled “Message overlays: adding lines to an existing job”A folder under jobs/ without a job.xml but with messages/work.xml or messages/refuses.xml is an overlay. Instead of defining a new job, its <Text> variants are appended to an existing job’s message pool. This is the way to add flavor lines to a stock job without replacing anything.
An overlay file names its target job with a qualified id on the root element:
<Bank job="core/barmaid"> <Text weight="5">The new line you are adding.</Text></Bank>- Stock jobs are addressed as
core/<id>(core/barmaid); pack jobs as<pack>/<folder>. Either separator is accepted from 1.21 on, and only the colon before it (see the note above). A barebarmaidis rejected with a warning ingamelog.txt. - Targeting another pack’s job works, but declare that pack in your
<Requires>block; without the dependency the game merges anyway and logs a warning, because nothing guarantees the other pack is installed. - Overlays are purely additive: your lines join the existing pool alongside the stock ones, and uninstalling the pack removes them cleanly.
- If any
<Text>in an overlay file fails to parse, the whole file is skipped. This is deliberate: a line whose condition failed to parse would otherwise fire for everyone.
job.xml
Section titled “job.xml”Declares the job’s identity and optional eligibility gate.
<?xml version="1.0" encoding="UTF-8"?><Job id="barmaid" schema="1"> <Title>Barmaid</Title> <Description>She will staff the bar and serve drinks.</Description> <DefaultImage>profile</DefaultImage></Job>| Element / attribute | Required | Notes |
|---|---|---|
id (attribute on <Job>) |
Yes | Must match the directory name. Lowercase, no spaces. |
schema="1" (attribute on <Job>) |
Yes | Always 1 for now. |
<Title> |
Yes | Displayed in the job picker UI. |
<Description> |
Yes | Short tooltip shown in job details. |
<DefaultImage> |
No | Image catalog type used for the shift-event portrait. Defaults to profile if omitted. Common values: profile, sex, strip, oral, wait, cook, massage, escort, dom. Individual outcomes can override this per-<Text> via the Image= attribute (see messages/work.xml below). |
<Eligibility> |
No | A block of <When> leaves. Leaves are direct children of <Eligibility> (do not wrap them in <When> here; the loader iterates the children directly, unlike the message-bank <When> surfaces where the wrapper is required). If present, a girl can only be assigned this job while every child leaf is satisfied; the same gate is re-checked at the top of every shift, so a girl whose state flipped after assignment (stock drained, status changed) produces a “could not work” event instead of running the shift. See when-conditions for the full leaf catalogue. |
<Filter> |
No | Restricts the job to buildings that have the named feature. Text content; current values: Bar. Omit (or leave empty) for brothel-core jobs that any building hosts. |
<Action> |
No | One of combat, sex, general, cleaning, matron, bar, hall, show, security, advertising, torturer, caring, exactly. Anything else rejects the whole job at load. When present, every shift first runs the shared preprocessing: she gains tiredness whether or not she works, the shift refusal check runs (the job’s difficulty and fear against her state; a refusal ends the shift with “refused to work during the day/night shift” and its reason), and a worked shift raises the building’s filth by 1. The value itself changes two things: sex consults the building’s conduct policy at the refusal check, and combat equips her for a fight; every other value puts the gear away unless the job has <Gear>combat</Gear>. It does not pick which enjoyment a line writes (each <Enjoyment action> names its own) and does not change pay. Absent: no preprocessing at all (no shift tiredness, no refusal check, no filth), and the gear is left as it is unless <Gear> asks for it. Scripts can read it as action_code. |
<Records> |
No | <Records><Stat name="..." from="<resource id>"/></Records> feeds one of the engine’s own per-worker counters from what a shift produced. Both attributes are required and name must be a counter the engine keeps; today the only one is beast_captures, and any other name rejects the job. from is a resource id, normalised as <Stock> ids are. After a shift whose resource step committed (not a rejected shift, and not one that killed her), the counter rises by the amount of that resource the shift staged, before any <ProductionScale> or reduce. beast_captures is the “beasts she personally captured” figure the Hunter keeps, so a Beast Keeper’s bred animals are never counted as catches. |
<Gear> |
No | Game 1.21, unreleased. combat is the only value: for every shift of this job the worker equips her best armour and up to two weapons, the same as a job declared <Action>combat</Action> does, without the job being a fight. Any other text rejects the whole job at load (“is not combat, the only gear a job can ask for”). A job with neither <Gear> nor <Action>combat</Action> puts the gear away for its shift. The player’s auto combat equip setting governs both. The Catacomb Rancher uses it: dangerous animal work, worn armour, caring enjoyment. |
<Phase> |
No | Selects the turn-loop pass this job runs in. Text content; one of Prepare, Produce, Main (default), Late. Use Prepare for jobs that must run before customer traffic (Advertising), Produce for jobs that publish a value other jobs read this turn (BarCook publishing food quality before BarMaid serves it), Main for everything else, Late for clean-up passes that run after the rest of the turn. |
Example with eligibility:
<Job id="basictraining" schema="1"> <Title>Basic Training</Title> <Description>She trains her combat skills.</Description> <DefaultImage>profile</DefaultImage> <Eligibility> <Stat name="Level" le="10"/> </Eligibility></Job>When a job should have an eligibility gate
Section titled “When a job should have an eligibility gate”An eligibility gate is a list of conditions that all have to be true. While any of them is false, the player cannot put a worker on the job at all, and a worker already assigned stops working until it is true again.
That makes it a statement about the job rather than about the worker. It does not mean “she would be bad at this”, it means “this cannot be attempted”, and it removes the player’s choice instead of warning them. Save it for something that makes the work impossible or pointless:
- nothing to work with, like an arena beast fight with no beast in the pen;
- a job that only exists for a stage of the game, like basic training being for beginners;
- a trait that is the role, where without it there is no job to do.
The one gate that ships with the game is the first kind: beast fights require the estate to hold at least one beast, because otherwise there is nothing to fight.
Do not use a gate as a skill floor. A worker who is not good enough yet should be allowed to go and do the job badly. That is a result the player can read and act on. Blocking her instead just looks broken, especially when she is one point short and nothing tells her so. If a job is hard, let the poor result say it.
Equipment usually works the same way. A worker without the right outfit or weapon normally does the job worse, which belongs in the job’s own numbers. Reserve a gate for the rare case where the item is the whole point of the assignment, so without it there is nothing to attempt.
Example with <Filter> and <Phase>:
<Job id="barcook" schema="1"> <Title>Bar Cook</Title> <Description>She will cook food for the bar.</Description> <DefaultImage>profile</DefaultImage> <Filter>Bar</Filter> <Phase>Produce</Phase></Job>This job is offered only in buildings that have a Bar, and runs in the Produce pass so that any Main-pass consumer (BarMaid, BarWaitress) sees the value it publishes for the rest of the turn.
effects.xml
Section titled “effects.xml”Applied once per shift for every girl working this job. Can change the girl’s stats and skills, fan out changes to all girls in the building, or change building properties.
<?xml version="1.0" encoding="UTF-8"?><Effects> <SetStat target="self" stat="Tiredness" delta="-25"/> <SetStat target="self" stat="Happiness" delta="10"/></Effects><SetStat>
Section titled “<SetStat>”Changes a girl’s stat by a fixed or random amount.
| Attribute | Required | Notes |
|---|---|---|
target |
Yes | Who is affected. See targets below. |
stat |
Yes | Stat name. Same set as <When><Stat>. |
delta |
Yes (or delta_min+delta_max) | Fixed integer change. Negative values decrease the stat. |
delta_min / delta_max |
Yes (or delta) | Random range, inclusive. The engine picks uniformly. |
clamp_max |
No | Cap the resulting value at this ceiling. Useful for Fame, Health, etc. |
<SetSkill>
Section titled “<SetSkill>”Changes a girl’s skill by a fixed or random amount.
<SetSkill target="self" skill="NormalSex" delta_min="2" delta_max="4"/>Same attributes as <SetStat> with skill instead of stat. Skill names: Anal, Magic, BDSM, NormalSex, Beastiality, Group, Lesbian, Service, Strip, Combat, Performance, Farming, AnimalHandling, Crafting, Herbalism, Brewing, Cooking, Medicine, OralSex, TittySex, Handjob, Footjob. Eleven of these arrive with game 1.21, which is not released yet, and they are not all in the same state. OralSex, TittySex, Handjob and Footjob are already weighed by the Escort job, so setting them does change a shift. The seven occupational skills (Farming, AnimalHandling, Crafting, Herbalism, Brewing, Cooking, Medicine) are accepted everywhere but no job reads them yet, so setting one changes nothing in a shift until the Farm jobs are wired to them.
<SetBrothel>
Section titled “<SetBrothel>”Changes a property on the building.
<SetBrothel target="brothel" key="Filthiness" delta="-5"/><SetBrothel target="player.brothels" key="Fame" delta="$delta" clamp_max="100"/>| Attribute | Notes |
|---|---|
target |
brothel for the current building; player.brothels to fan out to every building the player owns. |
key |
Building property to change. Current set: Filthiness, Fame. |
delta |
Fixed integer, or $bindname to reference a <Bind> value (see below). |
clamp_max |
Optional ceiling. |
<EffectGroup> (also written <Group>)
Section titled “<EffectGroup> (also written <Group>)”Groups a set of effects behind a <When> condition and optional <Bind> declarations. If the <When> is not satisfied, the entire group is skipped.
<EffectGroup> <Bind name="delta" expr="(Charisma + Intelligence + Service) / 60"/> <When> <Bind name="delta" ge="1"/> </When> <SetBrothel target="player.brothels" key="Fame" delta="$delta" clamp_max="100"/> <SetStat target="self" stat="Happiness" delta="1"/></EffectGroup>Order inside a group: <Bind> elements are evaluated first, then <When> is checked using those binds, then the remaining entries are applied if the check passed.
The short alias <Group> is accepted by the engine in addition to <EffectGroup>.
<Bind>
Section titled “<Bind>”Declares a named integer computed from a formula. Only valid inside an <EffectGroup>. The result can be referenced in that group’s <When> as <Bind name="..." op="..." value="N"/> and in delta="$name" on effect entries.
<Bind name="bonus" expr="clamp((Intelligence + Service) / 2 / 33, 0, 3)"/>Supported identifiers in expr: stat names, skill names, and the clamp(value, min, max) function. You cannot reference a bind from another group; repeat the formula if you need it in two places.
<RandomChoice>
Section titled “<RandomChoice>”Picks exactly one child <Option> per shift, weighted.
<RandomChoice> <Option weight="6"><SetStat target="self" stat="Exp" delta="0"/></Option> <Option weight="2"><SetSkill target="self" skill="Service" delta="1"/></Option> <Option weight="2"><SetSkill target="self" skill="Strip" delta="1"/></Option></RandomChoice>Weights are relative. In the example above: 60% no gain, 20% Service +1, 20% Strip +1.
Targets
Section titled “Targets”| Target string | Meaning |
|---|---|
self |
The girl working the job this shift. |
brothel.girls |
Every girl currently assigned to the same building (including self). |
player.brothels |
All buildings the player currently owns (for <SetBrothel> only). |
brothel |
The current building (for <SetBrothel> only). |
Built-in jobs with native logic
Section titled “Built-in jobs with native logic”A few built-in jobs run engine code as well as their data files. Their XML describes part of the job, not all of it, so copying a built-in job’s directory into a pack does not reproduce its behavior.
The clearest case is Explore Catacombs. Its data files own the performance proxy, the messages and the stat gains, while the search for creatures, the encounter floor, the fight, the consolation progress for an empty or lost expedition, and the loot all live in engine code. None of those are job properties, and there is no attribute that configures them.
If you want a job that behaves like the catacombs, you are writing new engine behavior, not new job data. What you can do today in data is everything on this page: eligibility, effects, performance weights, wage curves, gains and messages.
performance.xml
Section titled “performance.xml”Defines how the girl’s performance score is calculated for this shift. The score is a weighted sum of her stats and skills, modified by traits.
<?xml version="1.0" encoding="UTF-8"?><Performance> <Factor skill="Service" weight="3"/> <Factor stat="Intelligence" weight="3"/> <Factor stat="Charisma" weight="2"/> <Factor skill="Performance" weight="2"/>
<TraitMod trait="Psychic" delta="10"/> <TraitMod trait="Fleet of Foot" delta="10"/> <TraitMod trait="Cum Addict" delta="-5"/></Performance><Factor>
Section titled “<Factor>”Contributes one stat or skill to the performance total.
| Attribute | Required | Notes |
|---|---|---|
stat or skill |
Yes (one) | The stat or skill to pull from. |
weight |
Yes | Multiplier. Higher = more influence. No maximum. |
<When> child |
No | If present, this factor only applies when the condition is satisfied. |
The engine multiplies the girl’s value by the weight for each factor, sums all factors, and divides by the total weight. The result is a number in roughly 0-1000+ range.
<TraitMod>
Section titled “<TraitMod>”Flat bonus or penalty added to the score when the girl has the named trait.
| Attribute | Notes |
|---|---|
trait |
Trait name, case-sensitive. |
delta |
Integer. Positive = bonus, negative = penalty. |
If the girl does not have the trait, the entry has no effect.
Jobs without a performance.xml are pure-effect jobs: the performance score is always 0, so there is no performance tier (and a wage.xml would earn nothing). Use this shape only when the shift outcome does not vary by quality, such as pure rest.
Performance-banded narration requires this file. If your messages/work.xml gates any <Text> on a <Performance> band (<Performance ge="245"/>, <Performance ge="100" le="144"/>, and so on), you must ship a performance.xml. Without one the score is always 0, so every shift matches only the lowest band (<Performance le="..."/>) and the higher bands never fire. This catches even simple household jobs: a cook or cleaner whose narration should read better when she does well still needs a performance.xml to compute that score. “Pure-effect” and “performance-banded” are mutually exclusive.
<Competencies>: saying what the job is about
Section titled “<Competencies>: saying what the job is about”Weights answer one question: how much gold does this shift earn. They cannot say whether a worker suits the role. A weight of 5 on Charisma means “Charisma moves the wage a lot”, not “this job is about charm”, so a screen that wants to tell a player “she is well suited to this” has nothing to read.
<Competencies> is that second statement, and the ordinary form has no numbers
in it at all:
<Performance> <Competencies> <Key> <Factor skill="Combat"/> </Key> <Supporting> <Factor skill="Magic"/> <Factor stat="Agility"/> </Supporting> </Competencies></Performance>| Group | What it means |
|---|---|
<Key> |
What the job is fundamentally about. At least one is required. |
<Supporting> |
Helps, but is not the point of the role. |
<OutputOnly> |
Improves the takings, the audience reaction or the narration, and says nothing about whether the worker can handle the job. |
OutputOnly exists so an arena fight can pay better for a famous fighter
without Fame pretending to be something that keeps her alive.
Optional attributes
Section titled “Optional attributes”None are required, and each means exactly one thing.
| Attribute | Meaning |
|---|---|
requires="N" |
Below N the job cannot be assigned at all. This is a hard gate, so the same warning applies as for <Eligibility> above: use it for something that makes the work impossible, never as a skill floor. |
effectiveFrom="N" |
At or below N this factor contributes nothing to suitability. |
masteredAt="N" |
At or above N it contributes everything. Values in between scale smoothly. |
performanceWeight="N" |
The wage weight, under its own name. This and only this feeds the score. It never affects how well the worker is judged to suit the role. Omitted, it depends on the group: 3 in <Key>, 1 in <Supporting> and <OutputOnly>. |
Those defaults are why the numberless form is usable at all. A job written with no numbers gets 3/1/1, which is the commonest shape in the game already, so the plain declaration is a real starting point rather than something you always have to override.
performanceWeight is what lets an existing job move across without changing
what it pays. A job weighted 5 on Charisma and 5 on Service becomes:
<Competencies> <Key> <Factor stat="Charisma" performanceWeight="5"/> </Key> <Supporting> <Factor skill="Service" performanceWeight="5"/> </Supporting></Competencies>and earns exactly the same gold it did before.
One form or the other
Section titled “One form or the other”A job declares its abilities semantically or by legacy weights, never both. A
performance.xml carrying a bare <Factor> alongside a <Competencies> block
is refused at load, because there would be no honest answer to which one
describes the job.
Inside <Competencies> everything is checked strictly, and a mistake refuses
the job with a message naming the file, the group and the factor. Outside it,
performance.xml stays as forgiving as it has always been: your <TraitMod>
and other existing children are untouched.
The block is refused for any of these: no <Key> factor; an empty group; a
group declared twice; more than one <Competencies> block; an unknown stat or
skill; both skill= and stat= on one factor; the same attribute declared in
two groups; weight= instead of performanceWeight=; a performanceWeight
below 1; any child inside a <Factor>, including <When>; a number that is not
a whole number, such as requires="abc"; a threshold outside 0 to 100;
effectiveFrom at or above masteredAt; requires above masteredAt; or an
unknown child or attribute anywhere in the block, including on <Competencies>
itself and on the group elements.
<When> is refused on purpose. Suitability for a role is a durable statement
about the worker, and a number that changed with the shift or the venue would
be a different thing wearing the same name.
wage.xml
Section titled “wage.xml”Converts the performance score into gold earned this shift. Uses a piecewise linear curve: the engine finds which two breakpoints the score falls between and interpolates linearly. For the full picture of how wages compose with girl Ask Prices, tips, and trait modifiers, see pricing.md.
<?xml version="1.0" encoding="UTF-8"?><Wage currency="gold"> <Curve type="piecewise"> <Point perf="245" wage="155"/> <Point perf="185" wage="95"/> <Point perf="145" wage="55"/> <Point perf="100" wage="15"/> <Point perf="70" wage="-5"/> <Point perf="0" wage="-15"/> </Curve></Wage>| Attribute | Notes |
|---|---|
currency="gold" |
Only gold is supported in v1. Required. |
channel on <Wage> |
Where the money comes from: omit it for a salary you pay, or set channel="earnings" for money the job brings in. See the next section; this choice decides whether the job ever shows up as income. |
type="piecewise" |
Only piecewise is supported in v1. Required. |
perf on <Point> |
Performance score at this breakpoint. Points must be listed in descending order. |
wage on <Point> |
Gold earned at this performance. Negative values deduct from the brothel (costs the player money). |
A job without wage.xml earns no gold. This is correct for training and household-service jobs.
Who pays the wage: the channel attribute
Section titled “Who pays the wage: the channel attribute”The curve tells the game how much gold a shift is worth. The channel attribute tells it whose gold that is, and the two choices behave very differently in the Accounting screen:
No channel (the default): a salary you pay. The curve amount is a wage the player pays the worker out of pocket each shift, like hiring a cleaner or a guard. It appears in Accounting as an expense, never as income. Slaves are not paid a salary at all, so on a slave the default curve pays nothing to anyone.
channel="earnings": money the job brings in. The curve amount is treated as gold arriving from outside, from customers, ticket sales, or a prize purse. It flows like other worker earnings: the house takes its percentage cut (the worker’s House setting) as brothel income, which is what you see in Accounting, and the worker keeps the rest.
<Wage currency="gold" channel="earnings"> <Salary gold="20"/> <Curve type="piecewise"> ... </Curve></Wage>The optional <Salary gold="N"/> line only has an effect together with channel="earnings": it adds a small guaranteed retainer paid as an ordinary salary on top of the earnings, for jobs where the worker should never walk away empty-handed.
Rule of thumb: if the job sells something to customers, use channel="earnings". If you are paying a worker to do something for you (cleaning, guarding, training), leave the channel off.
Two things that trip people up:
- “My job pays out, but Accounting says no income.” The Turn Summary shows what the worker received; with the default channel that money was a salary you paid her, so the income section stays empty (look at the expense side instead). Switch to
channel="earnings"if the job is supposed to make you money. - Copying the whore job as a template. The stock whore job’s wage.xml has no
channelbecause its real money comes from a per-customer loop built into the engine; the curve there is only a no-customer fallback. A copied job does not inherit that loop, so if your job is meant to earn from customers, addchannel="earnings"yourself. The arena fighting jobs (fightgirls,fightbeasts) are the stock examples that already use it.
gains.xml
Section titled “gains.xml”Controls skill and stat XP distributed at the end of each shift. XP is shared across the declared entries proportional to their weights.
<?xml version="1.0" encoding="UTF-8"?><Gains xp="15" baseSkill="3"> <Skill name="Service" weight="3"/> <Skill name="Performance" weight="2"/> <Stat name="Charisma" weight="1"/></Gains>| Attribute / element | Notes |
|---|---|
xp on <Gains> |
Total XP distributed this shift, before weights. |
baseSkill on <Gains> |
Flat skill-point floor. Added to each entry regardless of XP. |
<Skill name="..." weight="..." max="..."> |
A skill that receives XP. Each worked shift adds baseSkill plus weight to it, and one more point when the shift’s performance is above 200. max (lowercase; Max is not read) caps the resulting skill value: the amount is cut so that the skill never passes it, and once there the entry adds nothing. So <Skill name="Service" weight="1" max="70"/> is one point a shift, two above performance 200, never past 70. |
<Stat name="..." weight="..."> |
A stat that receives XP. |
<GainTrait> / <LoseTrait> |
Optional. Grant or remove a trait via a progress accumulator. Key attributes: trait= (required), threshold= (default 1000), amount= (default 100). The trait fires after enough qualifying shifts. Can include a <When> gate so only matching shifts count. Progress is visible to players in Girl Details. See gains.md for the full reference and examples. |
A job without gains.xml grants no skill XP. Jobs that are purely for rest or housework (cook, rest) typically omit this file.
messages/work.xml
Section titled “messages/work.xml”Shift-end message variants. The engine picks one eligible entry at random (weighted) and shows it in the turn summary.
<?xml version="1.0" encoding="UTF-8"?><Bank id="work"> <Text id="barmaid.work.perfect.1" weight="1"> <When><Performance ge="245"/></When> </Text> <Text id="barmaid.work.great.1" weight="2"> <When><Performance ge="185" le="244"/></When> </Text> <Text id="barmaid.work.ok.1" weight="2"> <When><Performance ge="100" le="144"/></When> </Text></Bank>| Attribute | Notes |
|---|---|
id on <Bank> |
Always work for shift messages. |
id on <Text> |
Must match an entry in text/en.xml. Convention: <jobid>.work.<tier>.<n>. |
weight |
Relative probability. Higher = more likely to be selected. |
Updates="..." |
Optional shorthand for in-line stat / earnings adjustments fired together with the message. Semicolon-separated list of <Name><op><Value> entries, e.g. Updates="Tiredness+=5;Tips+=10". Recognised aliases on the earnings side: Tips= and Wages=. |
Image="..." |
Optional. Overrides the job’s <DefaultImage> for this single outcome (1.15.5+). Same vocabulary as <DefaultImage>: any image-catalog tag name (sex, oral, anal, massage, wait, ecchi, etc.). Empty or omitted: the job’s <DefaultImage> is used. Unknown tag names degrade to profile the same way <DefaultImage> does, so a typo can’t brick a shift. |
Participants="..." |
Optional (1.18.1+). Locks this outcome’s image to a scene shape, e.g. <Text Image="sex" Participants="lesbian">. One token: solo, lesbian, hetero, ffm, mmf, gangbang, lesgroup, orgy, other. This is a hard rule, not a preference: only art explicitly tagged with that participants value can show, and if the character has none the game shows her portrait instead of a wrong-shape image. Unlike Image=, an unknown token is a load error, on purpose: a silently dropped constraint would show exactly the wrong art this attribute exists to prevent. See the image-types reference, “Scene identity: lesbian and group art”. |
<When> child |
If present, this variant only fires when the condition is satisfied. Multiple eligible variants are drawn from using their weights. |
Every job should have at least one entry with no <When> (or a <When> that always matches), so the turn summary always has something to show.
Per-outcome image override
Section titled “Per-outcome image override”A single message bag can paint different portraits for different outcomes. The Masseuse bag is the canonical example: a clean shift shows the massage portrait, a happy-ending variant shows sex or oral, a refusal arm shows refuse.
<Bank id="work"> <!-- Default outcome: uses the job's <DefaultImage> (massage). --> <Text id="masseuse.work.great.1" weight="3"> <When><Performance ge="185"/></When> </Text> <!-- Horny variant: paints "oral" instead of "massage". --> <Text id="masseuse.work.horny.oral.1" weight="1" Image="oral"> <When> <Performance ge="185"/> <Stat name="Libido" ge="80"/> </When> </Text></Bank>Image= is per-<Text>, never per-<Bank>. Leave it off and you fall back to <DefaultImage>. Set it to any image-catalog tag and that outcome paints the matching portrait instead. Unknown tag names degrade to profile, just like an unknown <DefaultImage>.
Chance-rolled event branches: <RandomChoice> in the work bank
Section titled “Chance-rolled event branches: <RandomChoice> in the work bank”A work bank can make the shift roll WHAT KIND of shift it was before narrating it. Declare one <RandomChoice> of weighted <Pick> entries, and move every <Text> into named <Message> groups; the engine makes exactly one weighted draw to pick the branch, then selects a line inside it exactly as usual (weights, <When> gates, Performance bands, trait overlays all still work per branch). The shipped City Guard job is the canonical example: most shifts are the ordinary patrol, the rest split between three thief events.
<Bank id="work"> <RandomChoice> <Pick weight="70" message="event.calm"/> <Pick weight="14" message="event.caught-thief.easy"/> <Pick weight="8" message="event.caught-thief.hard"/> <Pick weight="8" message="event.lost-thief"/> </RandomChoice>
<Message Name="event.calm"> <Text id="cityguard.work.perfect.1" weight="2"> <When><Performance ge="400"/></When> </Text> <!-- ... the whole ordinary ladder lives here ... --> </Message>
<Message Name="event.caught-thief.easy"> <Text id="cityguard.event.caught.easy.1" weight="2"/> <!-- ... --> </Message> <!-- ... one <Message> per declared Pick ... --></Bank>Load rules, all checked when the pack loads:
- Weights are positive integers; a
<Pick>must reference a declared<Message Name>, and every<Message>must be referenced by some<Pick>(no dead content). - A bank that declares a
<RandomChoice>may not also carry top-level<Text>variants: everything lives in a branch. At most one<RandomChoice>per bank. - Event branches are narrative-only:
Updates="..."inline effects inside them are a load error. A branch changes what the shift reads like, never what it pays or costs. - A bank without
<RandomChoice>behaves exactly as before, with no extra roll.
messages/refuses.xml
Section titled “messages/refuses.xml”The refusal bag. When a girl refuses (a customer on a customer-facing job, or the whole shift), the engine picks one eligible line from here instead of a work.xml line. Same authoring shape as work.xml – a <Bank> of weighted <Text> variants, each with an optional <When> gate – so everything you know from shift messages carries straight over.
<?xml version="1.0" encoding="UTF-8"?><Bank> <!-- Baseline pool: ungated lines so every refusal has something to show. --> <Text weight="3"> ${name} crossed her arms and refused to follow the customer to a room. </Text>
<!-- Personality overlays: gate on traits, refusal axes, or the reason. --> <Text weight="2"> <When><Trait id="Noble"/></When> ${name} regarded the customer the way one regards mud on a clean floor, and informed him he had mistaken her for something purchasable. </Text> <Text weight="2"> <When><Dignity ge="70"/></When> ${name} drew herself up, looked the customer dead in the eye, and refused. He thought better of pressing the point. </Text> <Text weight="2"> <When><Obedience le="20"/></When> ${name} spat at the customer's feet and stormed off. There was no convincing her. </Text> <Text weight="2"> <When><RefusalReason name="fear"/></When> ${name} cowered in the corner until the customer cursed and left. </Text></Bank>| Attribute | Notes |
|---|---|
id on <Bank> |
Optional; the engine identifies the bag by filename (refuses.xml). |
<Text> body |
Author the prose inline (above), or use id= plus a matching text/en.xml entry exactly like work.xml if you want it localized. |
weight |
Relative probability among eligible lines. Keep the ungated baseline lines at higher weights so they stay dominant when no overlay fires. From game 1.21 a work line may write weight="$name": the weight is that <Bind> from effects.xml for this shift, and 0 or less makes the line ineligible. The bind must be declared in effects.xml or the job is refused at load, and a bound weight on a refusal line or in a fragment bank is refused too. Use it to split one draw between two lines at a competence-driven chance: the Farmer’s cut and its magic rebound share the cut’s weight, split by Magic / 2 per cent. |
Image="..." |
Optional per-line portrait override (e.g. profile, bdsm); defaults to the refuse image type for the bag. |
<When> child |
Gates the line. A refusal bag has the full <When> grammar plus <RefusalReason> and the <Dignity> / <Fear> / <Obedience> shorthands (see when-conditions). |
What a refusal line can key on:
- Personality traits –
<Trait id="Iron Will"/>,<Trait id="Shy"/>,<Trait id="Aggressive"/>,<Trait id="Noble"/>, so a proud girl refuses differently than a timid one. - Character-state axes –
<Dignity ge="70"/>(proud),<Obedience le="20"/>(defiant),<Fear ge="60"/>(afraid of you),<Stat name="Happiness" le="20"/>(despairing),<Stat name="Tiredness" ge="80"/>(exhausted),<Stat name="PCHate" ge="70"/>(resentful of you). - The inferred reason –
<RefusalReason name="dignity|rebellion|fear|hate|daunted|disease|orientation|beast|generic"/>, so the prose matches why she balked. - A specific girl –
<Girl name="..."/>for per-character refusal lines (a character pack can ship her own).
<Performance> gates do not belong here: the shift’s performance score is not computed before a refusal, so a <Performance> gate in a refusal bag silently never matches.
The whore jobs ship a refusal bag today; other customer-facing jobs gain their own refuses.xml as the refusal model extends to them. Until a job has its own bag (or while a <When> matches nothing), the engine falls back to a generic refusal line, so a partial bag is always safe.
messages/banks.xml
Section titled “messages/banks.xml”Optional fragment banks for composing a message from interchangeable phrases instead of writing every combination out by hand. A bank is a named pool of short prose fragments; a work.xml (or refuses.xml) line drops a ${pick:<bank-id>} slot into its body, and the engine fills each slot independently when the message fires.
A slot whose bank has no eligible variant at that moment (every fragment gated off) inserts nothing, and the spaces around it are tidied away, so a sentence with an optional slot at its end reads cleanly either way (game 1.21, unreleased).
Composition is optional and additive – it does not replace full authored messages. It lets one part of an otherwise hand-written sentence vary. Fixed lines and composed lines coexist freely in the same work.xml bag, and most jobs use no banks at all. A composed message is still authored prose; the bank just supplies the variable phrase.
The 80 / 20 pattern
Section titled “The 80 / 20 pattern”The intended shape is mostly authored prose with one small dynamic slot – roughly 80% fixed sentence, 20% picked fragment. The sentence, tone, punctuation, and any mechanics stay in the top-level message; only the genuinely variable phrase comes from a bank.
<!-- messages/work.xml: the authored line, gated to a performance tier --><Text id="escort.work.outing.good" weight="2"> <When><Performance ge="145" le="184"/></When></Text><!-- text/en.xml: 80% authored, 20% dynamic --><Text id="escort.work.outing.good">${name} accompanied her client to ${pick:outing}, kept him easy company all evening, and saw him home satisfied.</Text>banks.xml structure
Section titled “banks.xml structure”A root <Banks> holds one or more named <Bank id="..."> elements; each is a pool of <Text> fragments. Fragments use the same vocabulary as work.xml lines – an id-ref into text/en.xml, a weight, and a <When> gate – plus a priority tier.
<?xml version="1.0" encoding="UTF-8"?><Banks> <Bank id="song-genre"> <!-- generic tier (priority 0, ungated): the default pool --> <Text id="barsinger.genre.rock"/> <Text id="barsinger.genre.classical"/> <Text id="barsinger.genre.country"/> <!-- ...seven in total... -->
<!-- trait override (priority 10): wins ~60% of the time when Aggressive --> <Text id="barsinger.genre.deathmetal" priority="10"> <When><Trait id="Aggressive"/><RandomChance pct="60"/></When> </Text> </Bank>
<Bank id="song-quality"> <Text id="barsinger.quality.perfectly"><When><Performance ge="245"/></When></Text> <Text id="barsinger.quality.well"><When><Performance ge="145" le="184"/></When></Text> <!-- ...one per performance tier, covering the whole range with no gap... --> </Bank></Banks>| Attribute | Notes |
|---|---|
id on <Bank> |
The bank name referenced by ${pick:<id>}. |
id on <Text> |
The fragment’s text/en.xml key, which holds its prose body. |
weight |
Relative probability within the selected priority tier. Default 1. |
priority |
Selection tier. Default 0. See Selection below. |
<When> child |
Gates the fragment, evaluated in the same shift context as the parent message, with the full when-conditions grammar. |
${pick:<bank-id>} in a body
Section titled “${pick:<bank-id>} in a body”Inside a work.xml / refuses.xml body (in text/en.xml), ${pick:<bank-id>} is replaced by one fragment chosen from that bank. The pick: prefix is required: it keeps a bank reference distinct from a <Bind> value that happens to share the name. Several slots, and repeats of the same bank, each roll independently:
<Text id="barmaid.work.mix">${name} mixed ${pick:cocktail} and ${pick:cocktail} without spilling a drop.</Text>${pick:...} is resolved first, before ${name} / ${shift} / bind tokens, so a fragment may itself contain ${name} (interpolated afterwards). A fragment may not contain another ${pick:...} – recursion is rejected at load.
Selection: priority first, then weight
Section titled “Selection: priority first, then weight”For each slot the engine:
- evaluates every fragment’s
<When>once and keeps the eligible ones; - finds the highest
priorityamong those eligible; - picks one fragment from that top tier only, by
weight.
A higher-priority fragment therefore wins outright when it is eligible, instead of being averaged into the generic pool. This is what makes “60% Death Metal if Aggressive, otherwise a normal genre” work: the override sits at priority="10", gated on <Trait id="Aggressive"/><RandomChance pct="60"/>. When the trait is present and the 60% roll passes it is the only top-tier fragment and wins; otherwise it is ineligible and selection falls back to the priority="0" generics. If nothing is eligible the slot resolves to empty – so always give a bank an ungated generic tier.
The gate syntax is exactly the runtime grammar (see when-conditions): chance is <RandomChance pct="60"/> (the attribute is pct=), performance is <Performance ge="245"/> / <Performance ge="185" le="244"/>, traits are <Trait id="..."/>.
Randomness
Section titled “Randomness”Fragment conditions and selection use the RNG supplied to job resolution – the same stream the rest of the shift draws from – not a separate global path. Seeded tests are therefore reproducible; in normal play the stream is the shared game RNG, so composition is as random as any other shift roll.
Mechanics stay on the top-level message
Section titled “Mechanics stay on the top-level message”Fragments are prose only. A fragment may not carry Updates=, Image=, or any effect; the loader rejects a bank that tries. The mechanically meaningful outcome – and its Updates=, applied exactly once – is the top-level work.xml line; the fragment only varies wording. This guarantees that re-wording, re-weighting, or localizing a fragment can never change a shift’s gameplay result. If a scored sub-event needs to vary, model it as a separate top-level outcome, not a fragment.
Validation failures
Section titled “Validation failures”banks.xml is checked when the job loads; a failure stops the job from registering and logs a [DataJobs] error rather than shipping a broken line. A bank is rejected when:
- a
${pick:<id>}in a message references a bank that does not exist; - a fragment body contains another
${pick:...}(recursion); - a fragment carries
Updates=,Image=, or an effect (not prose-only); - a declared
<Bank>has no fragments.
Worked examples
Section titled “Worked examples”- BarSinger (
resources/jobs/barsinger/) – the complete example: two banks (song-genrexsong-quality) composing${name} sang ${pick:song-genre} ${pick:song-quality}., with trait overrides and performance-gated quality, sitting alongside the job’s existing fixed lines. - Escort (
resources/jobs/escort/) – the mixed example: a single pure-flavoroutingbank fills one slot in otherwise fully-authored lines. Escort’s normal job mechanics (performance, wage, per-shift effects) live inperformance.xml/wage.xml/effects.xml– entirely outside the fragments, and untouched by which venue is named.
jobs-cookbook has a step-by-step composition recipe.
text/en.xml
Section titled “text/en.xml”Localizable text strings. Each <Text id="..."> entry provides the display string for the matching id in messages/work.xml.
<?xml version="1.0" encoding="UTF-8"?><Locale lang="en"> <Text id="barmaid.work.perfect.1">${name} was sliding drinks all over the bar without spilling a drop.</Text> <Text id="barmaid.work.ok.1">${name} made a few mistakes but none of them were lethal.</Text></Locale>Token interpolation in <Text>
Section titled “Token interpolation in <Text>”The <Text> body recognizes a small placeholder grammar evaluated at message-emission time:
| Token | Substitution |
|---|---|
${name} |
The girl’s display name. |
${shift} |
The current shift name (e.g. day, night). |
${<bind_name>} |
The integer value of any <Bind> declared earlier in the same <Group> as the message; useful for reporting a derived performance score, a tip total, etc. |
${pick:<bank-id>} |
One randomly selected fragment from the named bank in messages/banks.xml. Resolved before the tokens above, so a fragment may itself contain ${name}. See messages/banks.xml. |
$$ |
A literal $ character. |
Unknown tokens are left in place verbatim, so a typo like ${nmae} shows up in the turn summary rather than corrupting the message; this also keeps packs authored against a newer engine readable on an older one.
<Group> <Bind name="tip" expr="(Charisma + Service) / 20"/> <Text id="barmaid.work.great.1">${name} pulled in ${tip} extra coins on the ${shift} shift.</Text></Group>This file only holds strings. The logic (weights, conditions) lives entirely in messages/work.xml. Translators only need to touch text/en.xml (or add a text/fr.xml sibling).
Planned: the job expectations model
Section titled “Planned: the job expectations model”None of this is supported yet. Do not add any of it to a pack. Names and XML representation are not part of the supported schema until the corresponding job-assessment checkpoint ships, and anything you write against this section today will not load.
It is documented because the direction is decided and it changes how you will describe a job. Today performance.xml answers one question, “how good was this shift”, and everything else about suitability is implicit. The plan splits that into separate, separately explainable answers:
- Hard requirements. What makes a job outright impossible for a worker, with an optional minimum on an attribute. Distinct from being merely bad at it: low ability should produce a low rating, not a wall.
- Job fit. How well her durable attributes match the role. A job will declare its competencies once, as Key, Supporting and OutputOnly factors, rather than a bare list of weights. OutputOnly exists so that an attribute which improves the takings or the spectacle (Fame, Beauty for an arena fight) can never be mistaken for something that makes dangerous work safer.
- Preparedness. Whether she is ready right now: equipment, items, temporary effects, condition.
- Performance. The existing production score, unchanged in meaning.
- Willingness. Whether she agrees, from the job’s own danger and difficulty and her personality and relationship with the player.
Consequences worth knowing in advance:
- The ordinary authoring path is expected to need no numbers at all: naming which competencies are Key and which are Supporting is the whole statement, with numeric thresholds reserved for genuine exceptions.
- Existing
<Factor weight="N">files stay valid. Migration is opt-in, per job. - Stats, skills, traits, gender and items are all candidates for these declarations, but each needs its own accepted contract before it appears.
- Validation and editor preview are expected to follow, so a malformed declaration fails loudly at load rather than silently doing nothing.
Grade thresholds, weightings and the refusal formula are deliberately absent here. They are owner decisions taken at review checkpoints, and publishing a number that later moves would be worse than publishing none.
See also
Section titled “See also”- jobs-cookbook: worked recipes (pure-effect, performance+wage, parameterized multi-slot, composed messages)
snippets/effects.xml: copy-paste starter for common effect patterns- when-conditions: full
<When>condition language reference