The rules of городская мафия (city mafia) as an explicit graph, so every interaction — and every overturn — is visible in one place instead of buried in imperative code.
The game is not one graph. It is a small stack of structures, each the right shape for its job. Forcing them into one picture is itself a modeling error. The five layers:
The only win logic that exists today is a text cue card in phase_script.dart:132 that reads "Black wins when blacks ≥ reds." Its own header comment calls the script "canonical Russian sport-mafia (FSM) script." For city mafia that rule is wrong on two counts: it counts the Maniac as black-for-parity, and it compares against reds instead of all other living players. This is the class of mistake that mis-rules a live game. The corrected predicate is in Layer 4.
| Card | Team | Wakes | Night action → host records |
|---|---|---|---|
| Citizen | RED | No | None. Speaks + votes by day. |
| Sheriff | RED | Yes | Checks one player → host signals RED / BLACK. |
| Doctor | RED | Yes | Heals one player (self OK, not 2 nights running) → full-night protection. |
| Mistress (Lover / Любовница) | RED | Yes | Visits one player (self OK, not 2 nights running) → grants vote-immunity; drags that player into death if she dies (immunity does not stop the drag). |
| Mafia | BLACK | Yes | Collective kill (no misses — one number, always a kill). |
| Don | BLACK | Yes ×2 | Final say on the kill; then checks one player → host confirms Sheriff / not. |
| Maniac | BLACK solo faction | Yes | Kills one player, indiscriminately. Does NOT count as mafia for parity. |
A strict sequence each night. The two checks (Don, Sheriff) are information only: they change no state. Only a live target can be killed or visited; a stale/dead target is a no-op (no phantom death).
Nodes are effects and states; a directed edge A → B means "A overturns / negates B." The Morning resolve applies effects in a fixed precedence so higher effects cancel lower ones — the same pattern every serious card-game engine uses. This is the picture that answers "how can one thing overturn another."
healCancelsManiacKill. loverDragSavable (or the house-rule immunitySavesFromDrag).| A overturns B | Condition | Flag | Code |
|---|---|---|---|
| Doctor heal → Mafia kill | heal target = victim | always | night_resolver.dart:86–95 |
| Doctor heal → Maniac kill | heal target = victim | healCancelsManiacKill (true) | :96 |
| 2nd unsaved kill → Doctor save | saved target still in deaths → save voided | always | :99–100 |
| Immunity → mafia / Maniac kill | player.immune (a night KILL only) | always | night_resolver.dart:116–123 |
| Immunity → Doctor save (credit) | saved player was actually immune → save voided | always | :124 |
| Immunity → Lover-drag | immune partner is still dragged — immunity does NOT negate the drag | immunitySavesFromDrag (false) | :150–157 |
| Doctor heal → Lover-drag | heal target = dragged partner | loverDragSavable (false) | :127–130 |
| Vote-immunity → Day vote-out | visited survived; Mistress survived | loverGrantsVoteImmunity (true) | :136–142 |
Player-to-player death-spread edges. Today there is exactly one: the directional Lover link mistress → visited. It fires only when the Mistress herself dies (not when the visited dies — adversarial #12 confirmed the direction). If chained links ever appear, this becomes a reachability walk; the imperative if would not survive that, the graph does.
Evaluated after every night-morning and every day vote, over the surviving roster. Not a graph: a boolean over counts. Let M = living Mafia + Don (the Maniac is excluded), K = living Maniac (0/1), R = living reds, and "others" = everyone alive who is not Mafia/Don.
| Outcome | Condition (correct, city mafia) | What the old cue got wrong |
|---|---|---|
| RED wins | all black are out: M = 0 AND K = 0 | (was roughly right) |
| MAFIA wins | M ≥ (alive − M) — mafia ≥ all other living players (reds and the Maniac). Includes the Maniac-vs-Mafia 1-on-1 standoff: a draw scored as a black win (Nicholas), reason mafia-maniac-standoff. | old: blacks ≥ reds — counted the Maniac as mafia, and used reds (not all others) as the denominator |
| MANIAC wins | last black standing, 1-on-1 with a single civilian (or sole survivor): M = 0, K = 1, R ≤ 1. A Maniac 1-on-1 with the Mafia does NOT win — that is the black-win draw above. | old: not modeled at all |
| continue | none of the above | — |
M resolves the coupled cases (adv. #8/#9). The rare Maniac-vs-Mafia 1-on-1 is a draw scored as a black win (P1 resolved, Nicholas 2026-07-01).The teleprompter loop. Sequential, not an override graph. (Day-first: the town speaks before the first night.)
Every previously-open edge is now pinned by the game master. The flags stay in AppConfig as house-rule switches, but the defaults below are the club's confirmed rules.
| # | Decision | Flag → value | Nicholas's ruling |
|---|---|---|---|
| P1 | Maniac-solo vs Mafia-parity in coupled end-states | maniacWinBeatsParity = false | The Maniac wins ONLY 1-on-1 with a single civilian. A Maniac 1-on-1 with the Mafia is a draw scored as a black (Mafia) win. |
| P2 | No-repeat cooldown scope + who it binds | repeatTargetScope = anyTarget; doctorNoRepeatTarget & mistressNoRepeatTarget = true | Both the Doctor and the Mistress may not act on the same player two nights running, self included. |
| Q4r | Immunity card scope vs the day vote and the drag | immunityBlocksVote = false; immunitySavesFromDrag = false | Immunity saves ONLY from a night kill. A player with immunity can still be voted out by day, and immunity does not stop the Lover-drag. |
The adversarial stress cases (12 original + 3 from Nicholas's 2026-07-01 rulings). OK = the model produces the correct city-mafia outcome; FIX = a defect/clarification folded into this model.
| # | Scenario | Correct outcome | Status |
|---|---|---|---|
| 1 | Mafia kills Mistress; she visited live B | both die (kill + drag) | OK |
| 2 | Mafia kills Mistress; she is immune | nobody dies (immunity before drag) | OK |
| 3 | Mafia + Maniac both hit A; Doctor heals A | A lives — heal = full-night protection | FIX |
| 4 | Maniac hits A; Doctor heals A | flag-dependent (healCancelsManiacKill) | OK |
| 5 | Doctor heals A two nights; Mafia kills A night 2 | 2nd heal is void (anyTarget) → A dies | OK |
| 6 | Mafia kills Mistress; she visited B; B healed | Mistress dies; drag savable per flag | FIX |
| 7 | Visited B survives; village votes B; immune B? | Mistress vote-immunity stops it; the immunity card does NOT block the day vote | OK |
| 8 | Endgame: Maniac + 1 red | Maniac wins | FIX |
| 9 | Endgame: Maniac + 1 mafia + 1 civ | no win yet — parity excludes Maniac | FIX |
| 10 | Maniac kills a mafia member | legal; mafioso dies | OK |
| 11 | Mafia kills A (immune); Doctor heals B | nobody dies | OK |
| 12 | Mistress visits B; Maniac kills B | B dies; no drag (link is mistress→visited) | OK |
| 13 | Mafia kills Mistress; visited B is immune | both die — immunity does NOT save the drag partner | OK |
| 14 | Endgame: Maniac 1-on-1 with the last Mafia | draw scored as a black (Mafia) win | OK |
| 15 | Mistress visits B two nights running | 2nd visit is void — no vote-immunity, no drag | OK |
| Flag | Controls | Default |
|---|---|---|
healCancelsManiacKill | can a Doctor heal cancel a Maniac kill | true |
loverGrantsVoteImmunity | Mistress visit → next-day vote-immunity | true |
loverDeathDrag | Mistress death drags the visited player | true |
loverDragSavable | can a heal stop the drag | false |
doctorNoRepeatTarget | Doctor can't heal the same player 2 nights running | true |
mistressNoRepeatTarget | Mistress can't visit the same player 2 nights running | true |
repeatTargetScope | no-repeat scope: selfOnly vs anyTarget | anyTarget |
maniacWinBeatsParity | a living Maniac blocks the Mafia parity win (else the standoff is a black-win draw) | false |
immunityBlocksVote | immunity card also blocks a day vote-out | false |
immunitySavesFromDrag | immunity card saves the dragged Lover partner | false |
lib/services/night_resolver.dart (pure, returns NightResolution). Folds in the documented precedence. The no-repeat cooldown binds both the Doctor (doctorNoRepeatTarget) and the Mistress (mistressNoRepeatTarget); the immunity card is scoped to night kills only (it no longer negates the Lover-drag or the day vote).lib/services/win_checker.dart returning WinResult { winner: red|mafia|maniac|none, reason }, plus the corrected cue text in phase_script.dart:130–133 (EN+RU).NightSlot order in host_card.dart, the drag in the resolver, the phase list in phase_script.dart).test/win_checker_test.dart (truth table) + the 12 scenarios against NightResolver. Gate: flutter analyze + flutter test clean.