Building Situations - SuIT-pub/Mind-the-School GitHub Wiki
Audience: Developers who are comfortable with Python/Ren'Py but do not know the Mind the School game system. This guide explains what Situations are, how they work, what they can do, how to define them, and how to use them in a mod.
Scope: This is exclusively about Situations. Unlockables (rules, clubs, building unlocks β which technically build on the same Situation object but add a PTA/vote layer) are a separate extension with their own document (Building Unlockables). They are mentioned briefly where needed for understanding, but never in detail.
This guide is both the design reference and the practical API/workflow reference for the Situation system β it is intended to be self-contained.
New here and just want to build one? Start with Quick start below, then read Β§1βΒ§7. The deep-dive sections Β§10βΒ§19 are reference β consult them when you need them, not up front. Troubleshooting is at the end.
- What is a Situation?
- Lifecycle of a Situation
- How bars move
- The building blocks
- The definition helpers (author API)
- Full example
- Wiring Situations into the game
- Implementing Situations in a mod
- Conventions (not enforced, but important)
- Multi-bar Situations & the combined bar
- The hold system (hysteresis)
- Hints in detail
- Controlling progress from events
- Resolutions in detail
- Stacking & chaining
- Stat coupling in depth
- Custom effects & the lifecycle registry
- Pictograms (preview marks)
- Recurring mini-Situations (pattern)
If you read nothing else first, read this. It gets one working Situation into the game; the rest of the guide explains everything it glosses over.
A valid Situation needs only one bar and resolutions that each have at least
one effect. Everything else (teasers, thresholds, passives, pools) is optional.
Add it inside the register_situations(...) call in label load_situations
(base game) β or in your own mod label (see Β§8):
Situation("my_first", "My First Situation",
"A short description of the problem the player will work on.",
Bar("main", limits=(-30, 60),
stat_weights={HAPPINESS: 0.5}), # so the bar reacts to play
PositiveResolution("ALL", DummyEffect()), # override the auto-added default
NegativeResolution("ANY", DummyEffect()), # + always add a negative one
),That is a complete, self-test-passing Situation. DummyEffect() is a legal
placeholder while you have no real resolution effect yet.
register_situations runs on every load, so just reload the game (or start a new
game). Your template is now registered β but not yet active, and an inactive
Situation with no teasers is deliberately hidden (Β§2).
Activation is normally done by a story event, but for testing use the Ren'Py
console (developer mode: press Shift+O) and run:
situation_manager.get_situation("my_first").activate()Now the start values are computed, the bar goes live, and it is fully playable.
- Open the in-game Journal β Situations page. Your Situation should appear under "Active" with its bar and (if you added thresholds) its hints.
- If it does not appear, it was almost certainly rejected by the self-test.
Open the Journal's log view and set the category filter to
situationβ every self-test error is logged there with a code (see Reference tables). Fix, reload, retry.
Read Β§1βΒ§7 in order to understand what you just did and to add real content (thresholds, hints, passives, measures, teasers, resolution effects). Sections Β§10βΒ§19 are depth you can reach for later; Troubleshooting covers the usual "why isn't it working" cases.
A Situation represents an ongoing problem or development at the school that the player can actively influence β e.g. "Cafeteria Crisis" or "Body Conflict". It replaces the classic quest system as the primary player-guidance system.
The key difference from a quest: the player does not work through a checklist. Instead of goals and tasks, they see a bidirectional progress bar and narrative hint texts. The bar can move in either direction β progress is not guaranteed to be permanent. At the end there is a positive or negative resolution with concrete consequences.
negative (red) 0 positive (green)
-100 ββββββββββββββββββββββββββ β ββββββββββββββββββββββββββββ +100
β² current position (handle)
Important: the exact numeric value is never shown to the player β only the position of the handle on the bar and the hint texts. As an author you still work with concrete numbers.
Technically a Situation is a Situation object composed of several building
blocks (bars, thresholds, passives, teasers, β¦), registered and managed through a
central situation_manager.
A Situation moves through several states (situation.state /
situation.visibility_state):
| State | Meaning | Visible in the journal? |
|---|---|---|
inactive |
Registered, but neither active nor showing teasers | No |
teaser_active |
Not active yet, but at least one teaser has unlocked | Yes β as a censored title ???????, teaser list only |
active |
Running; bar, thresholds, passives etc. are live | Yes β full view (Overview / Measures / Notes) |
completed |
Ended via a resolution | Yes (as completed) |
cancelled |
Aborted (e.g. via SituationEffectCancelSituation) |
No |
A running situation can also be paused (below). That is
not a state of its own: state stays active, situation.is_paused() is True.
-
Registration β At game start (and on every reload) all Situations are
loaded via
register_situations(...)insidelabel load_situations. This is a pure template definition: structure, texts, bounds. -
Teaser phase (optional) β Even before the Situation "properly" starts,
teasers can unlock (see Β§4). The player
then sees
???????with early observations. -
Activation β An event (or debug call) invokes
situation_manager.get_situation("key").activate(). Now each bar's start value is computed once (a snapshot), the base wear is activated, and the Situation is fully playable. - Runtime β The bar moves (see Β§3), thresholds fire, the player picks passives/measures, injected events appear in the buildings.
-
Resolution β As soon as a resolution condition is met, its effects fire and
the Situation goes to
completed.
A new teaser, the first activate(), and complete() each raise a
journal alert on topic situations (item = situation key). The
map journal button stays highlighted until the player opens that row. Cancel does
not raise. Unlockables use the same hooks but topic unlockables β see
Building Unlockables.
situation.pause() freezes an active situation without ending it;
situation.resume() continues it where it stopped. Manager shortcuts:
situation_manager.pause_situation(key) / resume_situation(key) /
get_paused_situations(). Use it for phases where the situation should simply not
happen (e.g. a trip away from campus). Both return False if nothing changed
(not active, already paused / not paused).
A situation is paused while at least one pause reason is set
(situation.get_pause_reasons()): "manual" from pause() / resume() and
"flag" from the event flag below. pause(reason) /
resume(reason) take the reason, "manual" by default. The pause starts with the
first reason and only ends when the last one is gone, so clearing the flag never
ends a manual pause and vice versa.
| While paused | Behavior |
|---|---|
| Bars (wear, stat weights, direct pushes, bar-change modifiers) | frozen |
| Thresholds (auto, blocking, timed release), threshold holds | don't fire / don't move |
| Resolutions (positive, negative, condition, deadline) | not checked |
| Tracked modifiers (base wear, passive/measure stat & bar modifiers) | hibernated via the lifecycle registry, woken on resume |
Non-modifier effects (SituationEffectSetGameData, game data of general effects) |
stay set β they are a state, not a tick |
| Measure duration & cooldown, resolution grace timers, timed-release timers | frozen: on resume every timer moves forward by the paused time |
DeadlineResolution |
pushed back by the total paused time (get_paused_daytimes()) |
Situation pools (SituationPoolCondition) |
closed β pool events don't show |
| Passives / measures in the journal | locked, can't be switched |
Events already queued in drain_situation_events
|
still run normally |
| Journal / HUD | still listed; the journal shows "Paused", the HUD drops the tendency arrow and adds "(paused)" |
For code, the rule is: anything that does something checks
situation.is_running() (= active and not paused); anything that only shows
something keeps checking state. SituationStateCondition("paused", key) checks
the pause; SituationStateCondition("active", key) is also true while paused.
activate() on a paused situation is a no-op (use resume()); complete(),
cancel() and deactivate() drop the pause. A pause survives save/load and hot
reload (the modifiers are put back to sleep after update_data). The cheat menu
has a Pause / Resume button per situation for testing (it adds / drops the
manual reason; the state text shows all reasons).
Situations follow the global event flag with the same table
as FlagCondition. A situation whose flag doesn't match the current one pauses with
reason "flag" and continues as soon as it matches again.
# runs only during the camp, pauses the rest of the time
Situation("camp_heat", "Camp Heat", ..., flag = "camp")
# runs normally AND during the camp
Situation("kiosk_rush", "Kiosk Rush", ..., flag = "camp", flag_exclusive = False)| Situation has β¦ | flag None
|
flag = "camp"
|
flag = other key |
|---|---|---|---|
no flag (default None) |
runs | paused | paused |
flag = "camp", flag_exclusive = False |
runs | runs | paused |
flag = "camp" (flag_exclusive = True, default) |
paused | runs | paused |
flag = "x" (wildcard) |
runs | runs | runs |
-
When it syncs:
set_current_flag()callssituation_manager.sync_flag_pauses();activate()syncs the situation it activates;after_loadsyncs once (a flag changed in code applies to old saves).situation.matches_current_flag()/sync_flag_pause()do it for one situation. -
Pool events follow their situation. An event with a
SituationPoolConditionand no FlagCondition of its own getsSituationFlagCondition(situation_key)instead of the automaticFlagCondition(None): it passes whenever its situation's flag matches. A camp situation's pool events therefore need no flag of their own. -
Unlockables are situations too and follow the same rule (
flag=/flag_exclusive=onUnlockable, defaultNone): during the camp they pause, so nobody can work on them there and none of them can drift into a negative resolution. - The deadline is pushed back by flag pauses too (the paused time counts the same).
- The intro flag (
"intro") pauses flag-less situations as well. Nothing is active during the intro; New Management is activated on day 10, after the flag is gone (skip_to_free_roamclears it viaupdate_intro_flag()too).
register_situations runs on every load β including loading a save. If a
Situation already exists in the situation_manager, it is not recreated; instead
update_data() is called: the definition (texts, bounds, effects) is
refreshed from the template, while the runtime state (current bar value, which
teasers are active, which thresholds were reached) is preserved.
From this follows the single most important authoring rule: never set runtime
state in the template definition. Concretely: never set bar.value in the
template, because update_data will (deliberately) not overwrite it on the next
reload and you would get inconsistencies. Define starting values via start_base
/ start_modifiers β not via value.
After all base and mod registrations finish, any Situation still in the save that
was not re-registered in this load wave is soft-invalidated: it stays in
the save with its runtime state, but is hidden from gameplay (invalid=True).
Pending threshold checks and modifiers for that Situation are hibernated via the
lifecycle registry. If the definition returns later (mod re-enabled, key
restored), load_situation revives it: update_data β uninvalidate β resume.
timed_release timers:
- Normal reload (definition still present): the running grace period is preserved β threshold checks are only re-announced to the lifecycle registry.
-
Orphan β revive (definition was missing, then came back): the grace timer
is started again with
set_timer(..., "now")β the player gets the full grace period anew. That is intentional, not a bug.
The bar is the heart of every Situation. Its value changes through four mechanisms:
Configured per bar via regular_decrease_rate + regular_decrease_interval.
While the Situation is active, this value drags the bar constantly in one
direction each interval (typically negative). This creates return pressure β
problems don't solve themselves. Rate 0 = no wear.
This is the coupling to the global school stats (Happiness, Education, Corruption, Inhibition, Charm, Reputation, β¦). When such a stat changes in the game, the change β multiplied by the weight β is transferred to the bar:
Bar("main", stat_weights={HAPPINESS: 0.5, INHIBITION: -0.8})If Happiness rises by 10, main moves by +5. If Inhibition drops by 10, main
moves by +8 (a negative weight inverts the direction). This lets the Situation
respond organically to whatever the player is already doing at the school.
The implementation runs through
situation_manager.apply_progress_change_via_stats, which is called automatically on every stat change across all active Situations. You don't have to wire anything up beyond thestat_weights.
Player-chosen strategies that act on the bar via bar-change modifiers, either slowly (passive) or strongly (measure). See Β§4.
An event can push a bar directly. The bar value is internally addressable as a
pseudo-stat with the key situation:<situation_key>:<bar_key>:
# Move bar 'main' of situation 'cafeteria_crisis' by +8:
situation_manager.apply_progress_change("situation:cafeteria_crisis:main", 8)The bar key is a first-class stat key β reuse your stat-change calls. This key is not limited to
apply_progress_change; it is recognized everywhere the global stat-change system takes a key and is routed to the bar automatically. So an event that already changes school stats can push a bar in the same call instead of adding a separate line:# module-level change_stat routes a situation key straight to the bar: $ change_stat("situation:cafeteria_crisis:main", 8) # the modifier-based stat-change labels accept it too β range operators # (range_percent, value_percent, gated_percent) resolve against the bar's range: call change_stats_with_modifier(**{"situation:cafeteria_crisis:main": 8})A special bar key
ALLfans a change out to every bar of the situation:situation:cafeteria_crisis:ALL(this is how the Unlockable Persuade measure nudges all three faction bars at once). Keys may contain colons β for an Unlockable the situation key is itselfrule:level:3, and the last segment is always the bar key.Caveat: the character-scoped
StatEffectdoes not route here β it targets a character's stat viachar_obj. Use the module-levelchange_stator the modifier stat-change labels for bar pushes.
Direct bar pushes (apply_progress_change, change_stat("situation:β¦"), and
the modifier stat-change labels) are a no-op while the Situation is not
active. Thresholds, resolutions, pools, and passives are likewise skipped until
activation. Only teasers evaluate in the inactive / teaser phase.
Before activation (e.g. when a pre-event should influence the starting value) use
shift_start_value instead (see start-value calculation).
Each bar remembers its last 5 changes and averages them into a tendency. This
direction decides whether the hint system shows hints "upward" or "downward". You
don't have to steer this as an author β but it explains why hints follow the
player's most recent direction of movement.
A Situation is assembled from building blocks, all passed as elements to the
constructor. Order doesn't matter β the constructor sorts them by type.
The progress bar. A Situation needs at least one bar. Multiple bars make a
multi-bar Situation (e.g. three stakeholder bars teachers / parents /
students for PTA-style Situations). The player only ever sees a single
weighted combined bar (weight per bar); the individual sub-values stay
hidden.
Properties:
-
limits=(min, max)β value range, default(-100, 100). -
weightβ share of the combined bar (normalized internally to a sum of 1.0). -
stat_weightsβ coupling to school stats (see Β§3b). -
regular_decrease_rate/regular_decrease_intervalβ base wear. -
start_base/start_modifiersβ start-value calculation (see below). -
Pictograms β a bar can carry
Picto(...)preview marks as leading elements (Bar("main", Picto("teachers_support"), limits=β¦)). They are purely descriptive and never gate anything. Note: they are only rendered in the Unlockable journal view, not the plain Situation view β see Β§18.
Each bar's start value is computed once, at activation β a snapshot of the
world state. Formula: start_base, then modifiers in the fixed order
* β value_percent (%) β range_percent β gated_percent β +, then clamp to
limits (see operators).
Bar("main",
limits=(-50, 60),
start_base=-20, # base: the problem starts at -20
start_modifiers=[
StartModifier("+", -5), # flat -5
StartModifier("+", 0.05, stat=HAPPINESS), # + (Happiness Γ 0.05)
],
)A StartModifier with stat reads the stat once at start (snapshot), one
without stat acts directly on the running start value. Note: on a
zero-centered bar (start_base=0), * and value_percent (%) fizzle β they
scale the base, which is 0. range_percent and gated_percent, however, scale the
bar's range, not the base, so they still contribute at base 0 β as do + and
stat contributions.
A defined point on the bar where something happens. Two types:
-
Auto-fire (
AutoThreshold): when the bar reaches the value, the attached effects fire automatically. Nothreshold_hint. Use: moments that happen as a reaction to overall progress ("a teacher changes their mind"). Fires once by default, but can be made repeatable viadefault_holdβ see Β§11. -
Blocking (
BlockingThreshold): the bar cannot rise past this point until aConditionis met. Use: narratively critical beats that MUST happen in a certain order ("the PTA vote must take place"). Effectively a quest goal that doesn't feel like one.
Every threshold carries two hint texts:
-
approach_hintβ shown while the bar is below the threshold. Vague direction, no exact step. Always required. -
threshold_hintβ shown when the threshold is reached. Concrete, what to do. Blocking only; empty ("") for auto-fire.
Other fields:
-
directionβ1(upward, "value β₯ bound") or-1(downward, "value β€ bound"). Downward thresholds serve as escalation warnings on the negative side. -
visible_rangeβ how close the bar must get before the threshold becomes visible. - Bounds β set per bar via keyword:
main=20orteachers=25, parents=30. -
timed_releaseβ aTimerConditiongives the player a grace period: when the bar reaches a blocking threshold, they have the timer span to meet the condition before the effects fire automatically.
Multi-bar: a threshold belongs to the Situation, not to a bar. It can set bounds for several bars (AND-linked:
teachers=40, parents=60fires only once teacher β₯ 40 AND parent β₯ 60). Its displayed position on the combined bar is the weighted average and can drift; the trigger logic, however, checks the individual bounds β display and fulfillment are separate.
The three-layer model: wear (Layer 1, above) drags the bar back; passives (Layer 2) and measures (Layer 3) are the player-chosen countermeasures.
-
Passive (
PassiveOption): permanent but weak. It only modulates β it dampens the wear or contributes a small amount of its own. A Situation starts without an active passive; the player picks one in the journal. There is exactly one slot (active_passive); re-selecting the same passive toggles it off again. -
Measure (
MeasureOption): started by the player, time-limited, limited availability. The source of a strong push. It has an active duration (TimerCondition), optionally a cooldown and a hard quota (ManualCounterCondition). Two kinds of effect: instant effects (applied once on start, no revert) and lasting effects (active over the duration, then automatically reverted). One slot (active_measure), usable in parallel with the passive slot; no swap β the slot frees when the duration expires.duration=Noneis instant: instant effects fire, then the slot is freed immediately (a cooldown still starts on that deactivate). Exception:open_ended=Trueholds the slot until something else deactivates it β used by Unlockable Schedule Vote, which stays active until the Friday PTA vote resolves. Instant effects are not reverted on close, so the queued proposal survives.
Balance principle: wear + passive should not net out clearly positive. Real positive progress comes only from active play (measures and events). This keeps the return pressure structurally intact. Tutorial Situations often have neither passives nor measures.
Passives and measures act through SituationEffect objects. Available types:
| Effect | Behavior |
|---|---|
SituationEffectSetGameData(key, value, desc) |
Sets a GameData value (restored on revert). Also usable as a no-op placeholder. |
SituationEffectStatChangeModifier(stat, value, op) |
Modifies a school stat. op is the full modifier set (+, *, value_percent, range_percent, gated_percent; % = legacy value_percent; range ops resolve against the stat's own range β see operators). |
SituationEffectBarChangeModifier(bar, value, op, interval) |
Moves one of its own bars per interval (daytime_change, daily, β¦). op is the full modifier set (+, *, value_percent, range_percent, gated_percent; % = legacy value_percent). The standard tool for passive/measure drift. |
SituationEffectRegularStatChange(stat, value, rhythm) |
Recurring stat change per rhythm. |
SituationEffectCancelSituation() |
Aborts the Situation. |
SituationEffectGeneral(key, effects, descriptions, revert=True) |
Bridge to any regular Effect. Wraps a list of ordinary game effects (money, level, building open/close, β¦) behind the description layer β see below. |
Where no real effect exists yet, use DummyEffect() as a placeholder (needed
because the self-test requires at least one effect β see below).
The five types above are the only "native" SituationEffects. They exist because a
plain Effect cannot describe itself in player-facing terms β the SituationEffect
is the description layer. The price is that passives/measures could otherwise
only use those five (a passive silently drops any element that is not a
SituationEffect).
SituationEffectGeneral removes that limit. It wraps any ordinary Effect
objects and runs them through the same apply/revert lifecycle, while you supply
the text to show:
PassiveOption("bribe_the_board", "Grease the right palms",
SituationEffectGeneral(
"board_bribe", # stable key, unique in this option
[MoneyEffect("board_bribe", -500, "ADD"), # any regular effects
ProgressEffect("board_favor", 1)],
["Money: -500", "The board owes you a favor"], # author-written lines
revert=False, # never undo these
),
)-
keyβ a stable, author-chosen identity, unique among the effects of this passive/measure. This is what the reload sync matches on β the wrapped effect set is not enough, because twoSituationEffectGeneralcan wrap the same effect types and would otherwise collide. Usesnake_case, and never rename it (same key-stability rule as everywhere else). -
effectsβ a list of regularEffectobjects (not SituationEffects). They areapply()-ed when the passive/measure starts.ModifierEffectis rejected: it registers a persistent modifier viaset_modifier()into the global modifier collections β outside the Situation lifecycle register β so routed through here it would escape lifecycle cleanup and orphan once the Situation is torn down. The constructor filters anyModifierEffectout and logs an error (categorysituation); if that empties the list, self-test 761 ("needs at least one effect") then fires. Need a lifecycle-tracked modifier? UseSituationEffectStatChangeModifier/SituationEffectBarChangeModifier/SituationEffectRegularStatChangeinstead. -
descriptionsβ a list of lines you write. They are shown comma-joined in the selection list and one per row in the detail view (that's why it's a list, not a single string). The engine cannot derive these from the effects, so this is on you β keep them accurate. -
revertβTrue(default): the wrapped effects arerevert()-ed when the passive is switched off / the measure expires.False: revert is a no-op, so the effects are never undone β use this for one-way changes or costs that must not be refunded. (Instant measure effects are never reverted anyway, sorevertonly matters for passive and lasting-measure use.)
Mind the revert semantics below: reverting a wrapped effect does whatever that effect's own
revert()does. A stateless effect (e.g. a stat add) undoes itself; a modifier-style one only stops. If in doubt, setrevert=Falseand make the effect one-way by design.
This is a common misunderstanding, so be precise about it. Passive and lasting
measure effects apply() when they start and revert() when they end (passive
switched off, measure duration expired). For the modifier-based effects β
SituationEffectBarChangeModifier, SituationEffectStatChangeModifier,
SituationEffectRegularStatChange β revert removes the modifier from the
modifier system; it does not roll back the value the modifier already produced.
Concretely: a SituationEffectBarChangeModifier("main", 3, "+", "daytime_change")
registers a modifier that adds +3 to the bar every daytime change. While it is
active, the bar climbs 3 per tick. On revert, that per-tick modifier is pulled out
of the system β so the bar simply stops gaining +3 per tick. The points it
already accumulated stay on the bar. Revert is "stop the ongoing effect", not
"undo the accumulated change".
The same holds for stat effects: reverting a SituationEffectStatChangeModifier
removes the modifier contributing to that stat, it does not subtract back the
change the player benefited from while it was running.
The one effect that behaves like a true undo is SituationEffectSetGameData: it
remembers the previous GameData value on apply and restores it on revert (or
removes the key if none existed). That is a genuine value restore, because it
writes a value directly rather than registering an ongoing modifier.
Situations temporarily enlarge the buildings' event pools: while the Situation is active and the bar is within the given range, additional events can appear in the buildings. This fills the sandbox without permanently needing more events.
SituationPool("cafeteria_look_around_delivery", 35, 54) # active while 35 β€ value β€ 54A pool is a "window" on the bar. Positive events in high ranges, negative escalation events in low ranges β so good and bad players see different (not fewer) events. The pool key references the concrete event definition; optionally a pool can be bound to a specific bar.
A Teaser is an observation β narratively a case note by the headmaster (who
is a psychologist in the story). Teasers serve a dual role:
-
Before activation: they appear under the censored title
???????and give the player a hunch that something is brewing β without spelling it out. Detective feeling instead of a progress bar. - After activation: the same list moves into the Notes tab and becomes an ongoing chronicle.
Pull architecture: teasers observe the game, not the other way around. An event
knows nothing about triggering a teaser β the teaser defines for itself, via
Conditions, when it unlocks:
Teaser("miwa_crying",
"Miwa stood crying in the hallway today. Wouldn't say why.",
EventSeenCondition("sb_event_3"),
interpretation="Classic avoidance behavior.",
note_type="observation",
)This makes the full condition toolkit (AND/OR/NOT, StatCondition, TimeCondition, β¦) usable as unlock logic, and lets a mod add teasers to existing Situations without touching their code.
Options: interpretation (reading line), note_type (observation /
suspicion / insight / setback, with color/label), image (instant photo, 4:3 ratio),
layout (layout variant, otherwise random). Once unlocked, a teaser stays
unlocked (the ink is dry).
The end of a Situation. Every resolution has a ConditionStorage (optional grace
gates) and an EffectStorage (what happens when it fires). Types:
| Helper | Trigger | Special |
|---|---|---|
PositiveResolution(mode, *elements, delta_lock=False) |
Bars at max (ALL/ANY) |
delta_lock: discards negative deltas while reached |
NegativeResolution(mode, *elements, grace_count=None) |
Bars at min (ALL/ANY) |
grace_count: latch β X times of grace, then immediate |
DeadlineResolution(deadline, *elements) |
Time deadline passed |
no latch, no delta lock |
ConditionResolution(key, *elements) |
pure conditions met (bars ignored) | ignores bar fill/deadline |
mode: ALL = all bars must reach the end, ANY = one is enough. Positive is
usually ALL, negative usually ANY. Every resolution needs at least one
effect (otherwise self-test error 780) β use DummyEffect() as a placeholder.
The
Situationconstructor automatically adds an emptyPositiveResolution("ALL"). Since that fails the self-test without an effect, you must override it with your ownPositiveResolution("ALL", β¦effectβ¦)(same keypositive_resolution) and, as a rule, add aNegativeResolution.
-
thumbnail="images/..."β image in the journal (also possible per threshold). -
SituationDescription(text, *conditions)β conditional description text. -
add_comments(...)β comments (used e.g. for PTA vote context). -
add_deadline(Time(...))β deadline, usually paired withDeadlineResolution.
Situations are defined declaratively. Instead of instantiating the classes
directly, use the definition helpers β they reduce boilerplate (no manual
direction=1 or empty threshold_hint) and are the recommended style.
| Helper | Creates |
|---|---|
Situation(key, name, description, *elements, thumbnail=None) |
the Situation itself |
Bar(key, *pictos, weight=None, limits=(-100,100), stat_weights=None, regular_decrease_rate=0, regular_decrease_interval="daytime_change", start_base=0, start_modifiers=None) |
a bar (leading Picto(...) args attach preview marks β see Β§18) |
StartModifier(op, value, name=None, stat=None) |
a start-value modifier |
AutoThreshold(approach_hint, *effects, direction=1, visible_range=100, default_hold=-1, **bounds) |
auto-fire threshold (one-shot by default; default_hold>=0 makes it repeatable) |
BlockingThreshold(approach_hint, threshold_hint, *conditions, direction=1, visible_range=100, default_hold=-1, **bounds) |
blocking threshold (no hysteresis by default) |
PassiveOption(key, description, *effects) |
passive (Layer 2) |
MeasureOption(key, description, duration, *limits, instant=None, permanent=None, open_ended=False) |
measure (Layer 3) |
SituationPool(key, bar_min, bar_max) |
event pool |
Teaser(key, text, *conditions, interpretation=None, note_type=None, image=None, layout=None) |
teaser |
PositiveResolution / NegativeResolution / DeadlineResolution / ConditionResolution
|
resolutions |
register_situations(*situations) |
loads/updates the templates |
Important β bounds via keywords: for
AutoThreshold/BlockingThresholdyou give the bounds as keyword arguments per bar, not positionally:AutoThreshold("...", main=10)orBlockingThreshold("...", "...", cond, teachers=25, parents=30).
Important β thresholds are top-level elements. Even though a threshold logically belongs to a bar (via its bounds), it is passed directly to
Situation(...), not nested insideBar(...). The association happens solely through the bound keys.
Here is a real single-bar Situation (abridged from load_situations):
Situation("cafeteria_crisis", "Cafeteria Crisis",
"The school doesn't have a proper cafeteria. Adelaide Hall has agreed to "
"help, but she has no experience managing a commercial kitchen.",
# --- Teasers (chronicle / pre-activation) ---
Teaser("kiosk_complaints",
"There's a heated discussion about prices at the snack bar.",
EventSeenCondition("kiosk_price_event"),
interpretation="Money pressure shows first at the lunch counter.",
note_type="observation"),
# --- Bar ---
Bar("main",
limits=(-30, 60),
stat_weights={HAPPINESS: 0.5, EDUCATION: 0.2, REPUTATION: 0.2},
regular_decrease_rate=-0.5),
# --- Thresholds (top-level, bound via kwarg 'main') ---
BlockingThreshold(
"The students need a permanent place to eat lunch.", # approach
"Inspect the vacant building next to the courtyard.", # reached
EventSeenCondition("inspect_kitchen"),
main=-5),
AutoThreshold(
"If I push forward, someone from the PTA will reach out.",
main=10, visible_range=10),
BlockingThreshold(
"A cafeteria isn't something I can do alone. The PTA must approve.",
"Plan a PTA vote and gather support.",
ProgressCondition("pta_cafeteria_vote", "1"),
main=20, visible_range=10),
AutoThreshold("With approval, the renovation can begin.", main=35),
AutoThreshold("The cafeteria is up and running.", main=60),
# --- Passives (Layer 2) ---
PassiveOption("leave_adelaide", "Leave Adelaide alone",
SituationEffectSetGameData("cc_leave_adelaide_noop", 0, "No-op")),
PassiveOption("hire_staff", "Hire additional staff",
SituationEffectBarChangeModifier("main", 0.3, "+", "daytime_change")),
# --- Event pools (sandbox filling) ---
SituationPool("cafeteria_look_around_delivery", 35, 54),
SituationPool("kiosk_talk_complaints", -10, 54),
# --- Resolutions ---
PositiveResolution("ALL", DummyEffect()),
NegativeResolution("ANY", DummyEffect()),
thumbnail="images/journal/cafeteria.webp",
),For a multi-bar Situation you simply define several bars with weight and
thresholds with multiple bound keys, plus optional measures:
Bar("teachers", weight=0.4, limits=(-40, 60), stat_weights={REPUTATION: 0.4}),
Bar("parents", weight=0.4, limits=(-40, 60), stat_weights={REPUTATION: 0.5}),
Bar("students", weight=0.2, limits=(-40, 60), stat_weights={HAPPINESS: 0.5}),
BlockingThreshold(
"A PTA only works if staff and parents both commit.",
"Schedule a PTA vote and secure teacher and parent support.",
PlaceholderCondition(),
teachers=25, parents=30, visible_range=15),
MeasureOption(
"faculty_briefing",
"Call a short faculty briefing. Steady teacher support for a few periods.",
TimerCondition("fb_duration", daytime=3), # active duration
TimerCondition("fb_cooldown", daytime=2), # cooldown
ManualCounterCondition("fb_count", 3), # at most 3x
instant=[SituationEffectSetGameData("fb_ping", 1, "PTA on today's agenda")],
permanent=[SituationEffectBarChangeModifier("teachers", 3, "+", "daytime_change")],
),The definition alone shows nothing yet. Four connection points:
A Situation starts when an event (or a debug call) activates it:
$ situation_manager.get_situation("cafeteria_crisis").activate()This sets state="active", computes the start values and activates the base
wear. Typically this call sits at the end of a story event that narratively
introduces the Situation.
Inside a Situation event you move the bar directly:
$ situation_manager.apply_progress_change("situation:cafeteria_crisis:main", 8)Or β more commonly β indirectly: the event changes a school stat (Happiness,
Education, β¦) and the bar follows automatically via its stat_weights. Both are
valid; the direct variant is used for dedicated Situation events meant to act
specifically on this one Situation.
Before activation (pre-events that influence the start value):
$ situation_manager.shift_start_value("cafeteria_crisis", "main", "+", -10)The manager is ticked automatically in several places β on event end, on
daytime/day change, and in the map overview: check_all_thresholds,
check_passives, check_resolutions. You don't need to call these yourself; they
ensure that thresholds with a timer grace period, expiring measures, and
resolutions are checked in time.
An event that should only appear during a Situation is hung into a building pool
via the pool mechanism: the event carries a ProgressPoolCondition (or similar),
which checks situation_manager.check_pool(situation_key, pool_key), and you
declare the matching SituationPool(pool_key, min, max) on the Situation. While
the Situation is active and the bar is in range, the event can fire.
Situations are fully mod-capable β nothing is hard-wired except through keys and conditions. There are two paths, and you can combine them.
In your mod file you register your Situations exactly like the base game, just
with your mod key instead of 'base':
# Queue your registration label so it runs inside the lifecycle wave (see below).
init python:
register_start_method("load_situations_mymod")
label load_situations_mymod:
$ set_current_mod('my_mod') # marks assets/IDs as belonging to your mod
$ register_situations(
Situation("mymod_library_crisis", "Library Crisis",
"The old library is falling apart β¦",
Bar("main", limits=(-30, 60), stat_weights={EDUCATION: 0.6}),
AutoThreshold("Something must be done.", main=0),
BlockingThreshold("The board must approve funding.",
"Secure a budget vote.", PlaceholderCondition(), main=20),
PositiveResolution("ALL", DummyEffect()),
NegativeResolution("ANY", DummyEffect()),
),
)set_current_mod(key) tells the system that the following registrations (and
referenced assets/images) belong to your mod β mirroring the pattern used in all
base scripts (set_current_mod('base')). register_situations is idempotent and
reload-safe (see hot reload): you can
re-register the same Situation on every load; if it already exists, only the
definition is updated, the save state is preserved.
Image paths are auto-redirected to your mod folder. While
set_current_modpoints at your mod, every image path captured at construction β a Situationthumbnail, a thresholdthumbnail, aTeaser(image=β¦), and aPicto(...)icon β is prefixed with your mod's path. So write plain paths relative to your mod root (thumbnail="images/journal/my_thing.webp"), exactly as the base game does; do not hand-writemods/MyMod/.... Base registrations (prefix"") are unaffected. The redirect is baked in at construction, so the mod context must be set (via yourset_current_modat the top of the load label) before theSituation(...)is built.
Register your label into
start_methodsβ do not register Situations from the init path. The lifecycle registry (which owns Situation modifiers and pending threshold checks) runs a wave: thestart/ after-load flow callslifecycle_registry.begin_check(), then the base loaders (includingload_situations), then all queuedstart_methods, thensituation_manager.reconcile_orphan_situations(), and only afterwardlifecycle_registry.finalize_check(). Orphan reconcile runs before the sweep so hibernated resources for missing definitions survive. Registering Situations at init time (outside that window) can leave modifiers/checks unswept or incorrectly swept.Queue your label with
register_start_method("load_situations_mymod")from aninitblock (as above). It only registers the label name; the label itself runs during the start sequence, inside thebegin_checkβfinalize_checkwindow. If you instead register Situations directly at init time (outside that window), the wave that reconciles the registry has already been set up without your entries, and your modifiers may not register or update properly β hibernated or swept as ghosts. Thesituation_managerexists by then; it is created in the baseload_situations, which runs before yourstart_methodslabel.If the mod is missing or deactivated, its Situations are soft-invalidated on load (state kept). When the mod returns and re-registers them, they revive automatically β see Missing definitions.
Thanks to the pull architecture of teasers, you can add your own teasers to an existing Situation without touching base code β e.g. a mod event as another observation for "Cafeteria Crisis":
$ situation_manager.get_situation("cafeteria_crisis").add_teaser(
Teaser("mymod_extra_hint",
"A supplier left a flyer β cheaper ingredients, if someone asks.",
EventSeenCondition("mymod_supplier_event"),
note_type="insight")
)Conversely, a base event can serve as a teaser for your mod Situation β the direction doesn't matter, because nothing is coupled except through keys and conditions.
Note that teasers added at runtime directly may be synced away on a subsequent
register_situationspass of the base template (becauseupdate_dataaligns the teaser list with the template). If you want a permanent extension, register it through the same reload-safe path that loads the base Situation.
- New Situations (with everything: bars, thresholds, passives, measures, teasers, pools).
- New injected events into existing or your own buildings (via pools).
- New teasers for base Situations.
- New effects in passives/measures (the
SituationEffecttypes are reusable; custom subclasses are possible but rarely necessary).
These rules cannot (or will not) be enforced by the system β follow them anyway, or you will break saves or the game feel.
The key of a Situation, bar, threshold bounds, passive, measure, teaser and pool
are identity across saves. Use snake_case and descriptive names
(leave_adelaide, not option_a). Never rename a key afterward β the save
references it; a renamed key effectively creates a new object and loses the old
progress. Prefix mod keys (mymod_...) to avoid collisions with the base game or
other mods.
Never set bar.value, threshold.reached, teaser.active etc. in the
definition. These fields belong to the save. update_data deliberately does not
overwrite them β so your template value would either be ignored or would destroy
running progress. Starting values belong in start_base / start_modifiers.
The builder pattern relies on add_*/set_* methods explicitly returning self,
so calls can be chained (bar.set_limits(...).add_stat_weight(...)). If you write
your own building blocks, keep that.
The __init__ method is different, and it's easy to phrase this wrong.
Constructing an object (Bar(...), SituationBar(...)) naturally yields the new
instance β that's just how a constructor call works, nothing special to do. What
you must not do is put an explicit return <value> inside the __init__
method body: Python forbids a constructor from returning anything other than
None and will raise TypeError if you try. So __init__ sets up the object and
returns nothing itself; chaining is provided by the separate add_*/set_*
methods, not by __init__.
approach_hint is the headmaster's diary voice β vague, suggestive, never the
exact click path ("The teachers seem more open."). threshold_hint states the
concrete what, never the where/how ("Work on the curriculum draft in the
office." rather than "Go to the office, pick work, trigger event X."). The appeal
lies in the illusion of the player's own discovery.
Calibrate so that wear + passive does not net out clearly positive. Positive progress should come from active play (measures, events). A passive that carries the bar to the goal on its own turns the Situation into a set-and-forget and devalues the whole system.
The narrative order emerges from the bound values. Don't build blocking thresholds whose bounds cross between bars (on bar X, A is before B, on bar Y after B) β that can softlock each other. The self-test catches some of this (error 791), but don't rely on it.
Every resolution needs at least one effect (self-test 780). Even the
automatically-added positive default resolution must be overridden with your own
(with an effect, DummyEffect() at worst), and add a negative one.
Not every trigger needs a teaser. Rule of thumb: at most two to five unsolved
??????? teasers at once, otherwise "mysterious" tips over into "messy". Every
teaser needs at least one condition (self-test 700), otherwise it would never or
immediately fire.
Name pool keys by {building}_{action}_{event} (e.g.
cafeteria_look_around_delivery) and make sure the referenced action exists in
the building. Anchor min/max to the narrative phases (setup, operation,
resolution).
register_situations calls run_self_test() per Situation. If it fails, the
Situation is invalidated (not loaded) and the errors land in the log (category
situation). Check the log after every change β an invalid Situation silently
disappears from the game.
A Situation with several bars (the PTA pattern: teachers / parents /
students) hides its sub-values entirely β the player only ever sees one
combined bar. Understanding how that combined bar and its thresholds are computed
is essential for authoring multi-bar Situations.
Each bar's weight is its share of the combined bar. Weights are normalized to
sum 1.0 at load β you can pass raw numbers (0.4, 0.4, 0.2 or 2, 2, 1), they
mean the same thing. If you give no weights, bars are distributed evenly (1/n).
Invalid weights (β€ 0 or unknown bar keys) are dropped with a non-blocking log and
the rest is re-normalized.
stat_weights(stat coupling, Β§16) is a separate concept β it is not the combined-bar weight. One decides stat influence, the other the share on the combined bar.
combined_handle = Ξ£ (weight_bar Γ value_bar) over all bars
Always weighted; there is no unweighted variant. For a single-bar Situation the combined handle equals that bar's value (projection = identity).
A multi-bar threshold sets bounds for one or more bars via keywords
(teachers=40, parents=60); bars it omits are unbounded for that threshold.
Two things must be kept apart:
Projected position (where the gate marker sits on the combined bar):
projected_position = Ξ£ (weight_bar Γ reference_value_bar) over all bars
reference_value = bound (for bounded bars)
= current value (for unbounded bars)
So projected β combined_handle = Ξ£ weight_bar Γ (bound β value) over the bounded
bars. Consequences:
- A bounded bar reaching its bound β its gap contribution vanishes.
- All bounded bars met β handle and marker coincide (gap 0).
- An unbounded bar moving β handle and marker shift by the same amount; the gap stays constant.
- A bounded bar moving toward its bound β the marker stays put (w.r.t. that bar), the handle closes in; the gap shrinks.
Fulfillment is checked per bounded bar, AND-linked, and ignores the projection entirely:
-
teachers=40, parents=60fires only once teacher β₯ 40 AND parent β₯ 60 (direction1; direction-1uses β€). - teacher 80, parent 20 β the average of the bounds is "reached" but the parent bound is not β the threshold does not fire.
A blocking threshold caps each of its bounded bars individually at that bar's bound until it is completed. Unbounded bars keep growing freely. Author rule: for each bar, the bounds of consecutive blocking thresholds must be monotonic in the growth direction β non-monotonic (crossing) bounds risk a softlock and trip the load-time check (error 791; the offending threshold is skipped with a non-blocking log, the rest of the Situation still loads).
The next threshold in a direction is the un-reached one with the smallest
distance on the combined bar (|projected β combined|, directed). This resolves
the case where two bars suggest different next thresholds β the one nearer on the
combined bar wins, so journal markers, hints, and engine checks stay consistent. A
threshold's visible_range is likewise measured as the combined-bar gap, so
approaches via bounded and unbounded movement count consistently.
Bar values, projections, and visibility only update at the time-segment change after an event ends (the game has 7 rotating time segments); nothing moves while the journal is open. The display is static within a view β no smoothing needed.
Both threshold types share one re-arming mechanism, controlled by default_hold.
The value is a dead zone in bar points: after the threshold fires, the bar
must travel back past the bound by that many points (in the direction opposite to
direction) before it can fire again. The re-arm check runs automatically after
every bar change β you don't wire anything up.
default_hold=-1 fire once, latch forever (no re-arm) β default for both types
default_hold=0 re-arm on any re-crossing of the bound
default_hold=5 re-arm only after the bar recovers 5 points past the bound
Give an AutoThreshold a default_hold of 0 or higher and it fires every time
the bar crosses the bound, instead of once:
AutoThreshold(
"When students don't clean the faculty, the school's reputation suffers.",
EventEffect("event_enforce_trash_collection"),
default_hold=0, # repeatable; omit or -1 = one-shot
main=-90, direction=-1,
)Three things to keep in mind β none are bugs, they're the nature of an auto-fire:
-
A repeatable Auto is not a Blocking. It fires as the bar passes the bound but
never halts progression; the bar keeps moving. If you need progress to stop until
something is resolved, use a
BlockingThresholdwith a condition. -
It's an edge trigger, not a state trigger. Because the bar isn't clamped,
it can settle far past the bound. If a
regular_decrease_rateor an effect parks the bar below the bound, it never climbs back over the re-arm line, so the event won't fire again even though the "bad state" persists. Right for "fire each time we fall through the line"; wrong for "keep reminding while things are bad" (use a timer or a passive for that). One big overshooting jump still fires exactly once β never a burst. - Multi-bound Autos re-arm conservatively. With bounds on several bars, all of them must leave their dead zones before it re-arms, not just one.
β οΈ Flutter warning: withdefault_hold=0, a bar that oscillates right around the bound will re-fire the threshold on every crossing β spamming the event. If the bar tends to sit near the bound, give it adefault_holdlarge enough to clear the noise (e.g.3β5).
When a blocking threshold with a timer grace period is cleared or times out, it
may enter a hold state that pins the bar at its bound and prevents an
immediate re-trigger. While held, the threshold is skipped in all threshold
searches and does not fire again. The hold releases only once the bar has clearly
moved hold points beyond the bound (direction-aware), using the same
default_hold dead zone described above.
Plain blocking thresholds without a timer set reached = True on success and
never enter hold β for them default_hold currently has no effect. You only need
an explicit default_hold=β¦ on a blocking threshold when a TimerCondition is
involved and you want reactivation hysteresis (e.g. default_hold=5). One-shot
quest gates keep the -1 default.
This hysteresis is deliberately separate from resolution grace (Β§14): thresholds carry hint obligations and a visible bar position; resolutions do not.
The journal shows, instead of goals: the combined bar, a list of narrative hint texts, and the active passive/measure. The hint list β not a single hint β matters because several thresholds can be approached at once (on different bars, or an auto-fire alongside a blocking gate).
-
Per relevant threshold: if its bar value is reached, its
threshold_hintis shown; otherwise itsapproach_hint. Empty hints are omitted. - Sorted by the proximity of the projected threshold position to the combined value (closest first).
-
Cap: at most
number_of_bars Γ 2hints are active at once (per bar, one blocking gate plus a passing auto-fire); the Notes/Hints display scrolls, so there is no overflow.
On a bidirectional bar the system must decide whether to show hints upward or
downward. Each bar averages its last 5 changes into a tendency; the combined
tendency returns 1 / -1 / 0. Hints follow that direction; a neutral tendency
defaults to upward (1). You don't steer this β it just makes hints follow the
player's recent direction of movement. (See Β§9
for the hint-text voice rules.)
An event has three distinct ways to affect a Situation's bar β pick by intent:
Move a bar directly through its pseudo-stat key:
$ situation_manager.apply_progress_change("situation:cafeteria_crisis:main", 8)situation:<key>:<bar> (or situation:<key> β bar main). This is the manual push
for dedicated Situation events. It is a no-op until the Situation is active.
The same key also works in the general stat-change
calls (change_stat, the modifier stat-change labels) and supports the ALL bar
key β so you can fold a bar push into an existing stat change instead of a separate
line; see the note in Β§3d.
For a pre-event that should influence where the bar starts:
$ situation_manager.shift_start_value("cafeteria_crisis", "main", "+", -10)start_shifts survive definition sync and are applied at activation alongside the
author's start_modifiers.
Every stat change auto-drifts all active Situations via their stat_weights
(Β§3b). Sometimes an event must suppress that
auto-drift β e.g. a Situation event that sets its own bar explicitly and doesn't
want the stat side effects doubled in. The manager keeps a rule set:
| Rule | Blocks |
|---|---|
("situation", key) |
all auto-drift for one Situation ("*" = all) |
("stat", key) |
all auto-drift through one stat ("*" = all) |
("pair", situation, stat) |
only that SituationΓstat combination (wildcards on both axes) |
API: block_progress(situations=None, stats=None) (only situations β situation
axis; only stats β stat axis; both β pair rules; "all" as wildcard),
unblock_progress(...) (mirror the block), clear_progress_blocks(). Typically you
block_progress(...) at the start of a Situation event and rely on
clear_progress_blocks() at end_event to reset everything.
A resolution ends a Situation. It works in two levels:
-
Reached (
is_reached): the intrinsic trigger β bars at min/max (perANY/ALL) or a deadline passed. -
Fire (
evaluateβfire): only if reached and the gating conditions (if any) are met. No conditions β fires immediately on reaching.
Each resolution carries a ConditionStorage (optional gates) and an EffectStorage
(at least one effect β self-test 780). The Situation constructor auto-adds a
default PositiveResolution("ALL") and NegativeResolution("ANY"); override them
with your own (with effects).
If a resolution has gate conditions, they act as a grace period:
- On entering "reached", every
TimerConditiongate is started (set_timer(id, "now")). - While reached, the gates are checked; all fulfilled β
fire(). - If the bar leaves the end before the gates pass, the checks stop and timers are removed β grace ends without firing.
-
PositiveResolution(mode, *elements, delta_lock=False)β bars at max.delta_lock: while positively reached, negative bar deltas are discarded (holds the win against slippage during grace). -
NegativeResolution(mode, *elements, grace_count=None)β bars at min.grace_count: a latch β the resolution supplies its ownLatchCounterCondition(you give only the count); each time the bar reaches min and then leaves counts once, and when the latch is exhausted it fires immediately on the next reach. -
DeadlineResolution(deadline, *elements)β fires after theTimedeadline, immediately or after its conditions; no latch, no delta lock. -
ConditionResolution(key, *elements)β pure conditions, bars ignored.
mode is ALL (all bars must reach the end) or ANY (one suffices); positive is
usually ALL, negative usually ANY (self-test 781/782). Resolutions are checked
on bar change, daytime/day change, map overview and event end; the first one to fire
wins.
A resolution's effects fire once, but a ModifierEffect among them installs a
persistent modifier. Unlike SituationEffectGeneral (which rejects ModifierEffect),
a resolution's EffectStorage accepts a raw ModifierEffect β because the resolution
gives it the same lifecycle handling the managed-modifier path uses:
-
On fire, each
ModifierEffectis applied (by the effect) and its modifier is registered with the lifecycle registry under the effect's own modifier key. -
Every load wave, the resolution's
update_datare-affirms (KEEP-pings) those modifiers β and a completed Situation still runs this as long as its template is registered, so the buff survives across saves. - If the Situation (or its mod) goes away, nothing re-affirms the modifier and the next lifecycle sweep removes it β no orphan.
So a resolution may hand out a lasting stat/bar buff via ModifierEffect without you
managing its removal. Give the ModifierEffect a unique key (it is the modifier's
registry key). See Modifiers for the orphan model.
No level regression. Situation consequences are stat changes, flags, and content access β never a level step-back.
There is no hard limit on simultaneously active Situations. Unsolved ones pile up and press on each other, which is the intended difficulty curve: more active Situations mean more events competing for attention, more passive/measure costs adding up, and negative drift in neglected Situations that can cascade. Design safety valves keep this fair:
- Stat floors β Situation penalties alone cannot push a stat below a minimum.
-
Cascading resolution breather β when one Situation resolves negatively,
remaining active Situations pause base wear only (
regular_decrease). Duration ismin(4, remaining active count)after the failed Situation completes. Day-change checks the counter before decrementing, so N=1 covers the current day plus the next full day. A second failure extends viamax(remaining, new)β never stacks. Events, passives, measures, and stat-weights keep running. Shown on the Situations journal page above the list. - Story-driven pacing β Situations are introduced by already-balanced story events, so a normal playthrough doesn't stack extremes.
Situations feed each other through flags and start values, not hard links:
- A resolution sets a flag (
SituationEffectSetGameData("body_conflict_resolved", "positive", β¦)). - A later Situation reads that flag β as an activation condition, or to bias its
start via
start_modifiers/ a pre-activationshift_start_value. - A positive earlier resolution can improve a later Situation's starting bar; a negative one can worsen it or add a blocking threshold.
Example: "Social Sorting" resolves positively β "Body Conflict" starts at -5
instead of -20, because the social groundwork is laid.
stat_weights couples a bar to the school stats so the Situation reacts to whatever
the player is already doing (see Β§3b). Three rules
for using it well:
-
Keep the scaling small. The conversion from stat change to bar movement must
feel like background noise β a small Happiness boost should move the bar maybe
+0.5β¦+1. It accumulates over many events but is never the main driver. The intended player feeling: "I taught and patrolled a lot this week and the Situation somehow improved, even though I did nothing specific for it." Real positive progress still comes from active play (measures, events). -
Invert where it makes sense. A negative weight flips direction β e.g. falling
Inhibition helping a conflict defuse:
stat_weights={INHIBITION: -0.8}. -
Differentiate per bar in multi-bar Situations. Education can weigh more on the
teachersbar than onparents, etc., so different stats pull different factions.
Manual per-event Situation influence would be absurd across ~45+ regular events,
teaching subjects, office work and counseling β stat_weights is the mechanism that
lets all of that ambiently move the right Situations without hand-wiring each event.
When an event must opt out of this auto-drift, use the progress-blocker system
(Β§13c).
The built-in SituationEffect types (Β§4) cover common cases,
and SituationEffectGeneral already bridges to any regular Effect β so before
subclassing, check whether wrapping existing effects with SituationEffectGeneral
does the job. When you do need bespoke behavior you can still subclass
SituationEffect. The ABC contract every effect
implements: clone(), update_data(other), apply(**kwargs), revert(**kwargs),
run_self_test(), plus local_key (stable identity within its passive/measure,
without the Situation prefix) and the fully-qualified key
(<situation>:passive:<name>:effect:<local_key>). The apply/revert contract is
what lets passives toggle and measure durations expire cleanly β effects are
reverted before new ones take hold or the slot frees. Remember the revert semantics
from Β§4: modifier-based effects stop, they don't
roll back accumulated value; only game-data effects truly restore.
Situation modifiers live on in the global stat/modifier system even after their
origin Situation is gone (e.g. a disabled mod). A global lifecycle_registry tracks
ownership so orphaned modifiers can be swept:
| Level | Meaning | Example |
|---|---|---|
owner |
system | "situations" |
category |
instance | "cafeteria_crisis" |
key |
resource | "cafeteria_crisis:passive:β¦:effect:β¦" |
Ping states: KEEP (hold / revive from hibernation), HIBERNATE (suspend,
keep metadata), UPGRADE (remove + recreate), REMOVE (final). A resource that
stays silent until the sweep is a ghost β removed.
The wave barrier: during the init/reload wave β begin_check() β systems load
(each track/ping marks its resources alive) β finalize_check() β ghosts (any
tracked resource not pinged this wave) are removed only in finalize_check,
so a missing or disabled system never blocks the sweep. This is exactly why mod
Situations must register inside the wave (see
Β§8): track_modifier writes into this
registry (owner="situations", category=situation.key) and marks the resource
pinged, and an invalid Situation (failed self-test) has its category hibernated
rather than lost.
Pictograms are small descriptive preview marks β "persuade the teachers", "raise education". They are purely descriptive: they check nothing, gate nothing, unlock nothing. They replace the old condition-icons.
In practice, pictograms are an Unlockables feature. The data model lives on
Situation/Bar, andUnlockableinherits it (class Unlockable(Situation)), so you can attachPicto(...)to any Situation or bar β but only the Unlockable journal view renders them. The plain Situation journal view has no pictogram display, so aPicto(...)on a non-Unlockable Situation is stored but never shown. See Building Unlockables for where they actually appear.
Reference, not object. A pictogram definition (icon pattern + label + tooltip
templates) lives once in a central registry under a key. A bar or Situation stores
only the key β via the Picto("key") vehicle in its element list:
Bar("teachers", Picto("teachers_support"), limits=(-40, 60)),
# or at situation level (identity-only, no bar value):
Situation("...", Picto("factions_all"), ...),Resolution happens at display time: the key is looked up, the owner's local context
(a bar's key/value/name, or the Situation's identity) fills the templates, and a
fresh icon+label+tooltip is produced (so a live bar value in a tooltip is always
current). A bar-bound pictogram has access to bar_value and can show a live
value; a situation-bound one has only identity keys and stays purely
descriptive.
-
Self-declared needs / load check. Each pictogram derives from its templates
which context keys it needs (e.g.
<bar_value>). At load the owner's offered keys are compared against the needed keys; a mismatch means the pictogram simply isn't loaded, with a concrete log entry ("expects<x>, owner offers β¦"). - Soft-fail. A missing or broken pictogram never invalidates the Situation β it is skipped at render, everything else keeps working. This is deliberate: a decorative mark is not worth failing a Situation over.
-
Mod / late attach. Add a pictogram to an existing Situation/bar without
touching its definition:
situation_manager.add_pictogram(situation_key, picto_key, bar_key=β¦)(omitbar_keyto attach at Situation level). Theadd_pictogramcall must run after the target Situation is loaded.
Not every Situation is a long arc. A useful lightweight pattern (used e.g. for potion/serum testing) is the mini-Situation: a short Situation opened on demand that tracks a single quick outcome.
- Short runtime β a few events over a few days.
-
Small bar range β e.g.
limits=(-30, 30). - No passives/measures β too short to matter.
- Parameters shape the bar β a willing test subject β a narrower, lower-risk bar; a riskier setup β a wider bar with larger swings. Early attempts get wider bars (more uncertainty), later ones narrower (the character has learned).
- Both resolutions carry meaning β a negative resolution is a learning beat (the next attempt accounts for the mistake), a positive one unlocks the next stage.
Mechanically these are ordinary Situations β the pattern is just a calibration recipe (short, narrow, passiveless) plus activating a fresh instance per occurrence.
Symptom-first list of the mistakes newcomers actually hit. When in doubt, check
the log (Journal log view, category filter situation) β a rejected Situation
always logs why.
| Symptom | Likely cause | Fix |
|---|---|---|
| It doesn't appear in the journal at all | The self-test rejected it (invalidated, not loaded). | Check the log (category situation) for an error code (table below) and fix it. |
| It was never registered. | Ensure it's inside a register_situations(...) call that actually runs β base load_situations, or a mod label queued via register_start_method (Β§8). |
|
| It's inactive and has no teasers. | Inactive teaser-less Situations are hidden by design (Β§2). Activate it, or give it a teaser. | |
It shows as ??????? and never becomes the real title |
It's in teaser_active β teasers unlocked but the Situation was never activated. |
Call β¦activate() (from the triggering event, or the console). |
| The bar never moves | No stat_weights, no events pushing it, and regular_decrease_rate is 0. |
Add stat_weights, a wear rate, or move it from an event (Β§3, Β§13). |
| A threshold never fires | Blocking condition unmet; or (multi-bar) not all bounds met; or it's on hold; or bounds cross. | Verify the condition; remember multi-bar bounds are AND-linked (Β§10); check for a softlock (error 791). |
| A repeatable AutoThreshold only fires once | Either default_hold is left at -1 (one-shot), or the bar parked past the bound and never recovered past the re-arm line. |
Set default_hold=0 (or higher) for repeat; if the bar sits below the bound, it's an edge trigger and won't re-fire on a persistent state β use a timer/passive instead (Β§11). |
| A threshold event fires from a cheat-menu test / while the Situation is still inactive | Direct apply_progress_change used to move bars and fire AutoThresholds regardless of state. |
Bars, thresholds, pools, passives, and resolutions no-op until activate(). Use shift_start_value before activation, or activate first. |
| It never resolves / never ends | A resolution has no effect (rejected, 780); or bars can't reach their limits; or gate conditions are unmet. |
Give every resolution an effect; check bar limits vs. the resolution mode; review gates (Β§14). |
| Edits to my template don't take effect / progress reset | You set runtime state (bar.value, reached, β¦) in the template. update_data deliberately keeps save state and won't overwrite it β and template runtime state corrupts progress. |
Set starting values via start_base / start_modifiers, never value (Β§9). |
| A passive/measure effect "won't undo" | Revert stops an ongoing modifier; it does not roll back accumulated value. | Expected behavior β only SituationEffectSetGameData truly restores (Β§4). |
| A measure stays active forever |
duration=None without open_ended should auto-close after apply. |
Instant is the default (Β§4). Use open_ended=True only when something else must close the slot (Unlockable Schedule Vote). |
| Bar frozen, measures greyed out, no situation events | The situation is paused (is_paused(); HUD shows "(paused)"). The cheat menu shows the reason (manual / flag). |
manual: resume() it, or the cheat menu's Resume button (Β§2). flag: its flag doesn't match the current event flag (Situation flags). |
| Mod Situation vanishes after disabling/re-enabling the mod | Orphan soft-invalidation. | Expected β it revives on re-registration; timers may restart (Β§2). |
daytime_change Β· daily Β· weekly Β· monthly Β· yearly
The single percent operator was split and extended. % is kept as a legacy alias
for value_percent (normalized automatically).
| Operator | Change applied to the value | At base = 0
|
|---|---|---|
+ |
flat add: + value
|
contributes |
* |
multiply: value Γ base
|
fizzles (0 Γ anything) |
value_percent (%) |
percent of the current value: + base/100 Γ value
|
fizzles |
range_percent |
percent of the whole minβmax range: + range/100 Γ value
|
contributes |
gated_percent |
percent of the gated range (up to the current cap): + gated_range/100 Γ value
|
contributes |
- All three modifier effects β bar-change (
SituationEffectBarChangeModifier), stat-change (SituationEffectStatChangeModifier), and start-value (StartModifier) β accept the full set above. - For
range_percent/gated_percent, the range resolves against the target: a bar modifier uses that bar's range, a stat modifier uses that stat's own range. -
Start-value application order:
*βvalue_percent/%βrange_percentβgated_percentβ+, then clamp tolimits.
observation (blue) Β· suspicion (purple) Β· insight (green) Β· setback (red)
Text: text_full, text_aside Β· Photo: photo_left, photo_right, photo_top
(only with image). If omitted: random on activation.
| Goal | Condition |
|---|---|
| Event was seen | EventSeenCondition("event_key") |
| Building unlocked | BuildingCondition("cafeteria") |
| Progress step | ProgressCondition("key", "2") |
| Timer (duration/cooldown/grace) | TimerCondition("id", daytime=3) |
| Hard quota | ManualCounterCondition("id", 3) |
| Placeholder / WIP | PlaceholderCondition() |
More in game/scripts/conditions.rpy.
| Code | Meaning |
|---|---|
| 700 | Teaser without a condition |
| 718/719 | Blocking threshold without threshold_hint+condition, or empty approach_hint
|
| 730/731 Β· 740β744 Β· 750β752 | SituationEffect field errors (game-data / stat / bar / rhythm) |
| 760β764 |
SituationEffectGeneral: invalid key, no effect, non-Effect item, bad/empty descriptions, non-bool revert
|
| 780 | Resolution without an effect |
| 781/782 | Resolution mode not ALL/ANY
|
| 784 |
NegativeResolution grace_count invalid |
| 790 | Situation without a bar |
| 791 | Threshold bounds cross (softlock risk) |
| 792/793 | Situation key or name empty |
-
game/scripts/situations/situations.rpyβ classes, helpers,load_situations -
game/scripts/conditions.rpyβ condition toolkit -
game/scripts/effects.rpyβ effect types -
game/scripts/journal/pictograms.rpyβ pictogram registry (Β§18) - Building Unlockables β the Unlockable extension
-
.cursor/skills/build-situation/β skill/reference for building Situations