Four processors, one shared job: deciding what happens between a day ending and the next one starting, before any of it becomes a pack. The Apply Layer already traced what a NewTurn pack does once it lands. This is where it comes from — and what never makes it into the pack at all.
NewTurnNewTurnProcessor::onNewTurn() fills one pack, sends it, and only then does most of the actual work — work that never goes in the pack at all.
sendAndApply() call, decided from the same calendar check (newWeek, newMonth) but never carried inside n itself. A modder reading only the NewTurn pack’s fields would never learn that wandering monsters can spawn on the same day — that decision is made and executed entirely outside it.Two more things worth naming from generateNewTurnPack() itself: on the very first turn of the game (firstTurn), income and mana/movement regen are skipped entirely — heroes start with whatever the map or template already gave them, not a day-zero paycheck. And pickWeekType() is where Deity of Fire, Plague, Double Growth, and ordinary Bonus Growth weeks are rolled, each gated behind its own configured probability (EGameSettings::CREATURES_MONTH_PLAGUE_PROBABILITY and siblings) — a Deity of Fire bonus on the map, if one exists, always wins over the random roll.
Whether two players can act at the same time isn’t a flag. It’s answered by actually pathfinding every hero on the map, once a day.
TurnOrderProcessor::computeCanActSimultaneously() checks, in order: whether mixing one human and one AI at the same connection is allowed at all (simturnsInfo.allowHumanWithAI); whether both players share a connection (hotseat, or two AIs — never simultaneous); whether the current day is still inside the always-simultaneous window (simturnsTurnsMinLimit(), from simturnsInfo.requiredTurns) or past the never-simultaneous one (simturnsTurnsMaxLimit(), from optionalTurns); and whether allied players are exempted from the last check (ignoreAlliedContacts). Only if none of those settle it does it fall through to playersInContact().
The standout part:playersInContact()runs a realCPathfinderpass — one turn deep, guards ignored — for every hero either player owns, marks every tile each hero could reach this turn, and returns true the moment the two players’ reachable-tile sets overlap anywhere on the map, any level. Two players stay on simultaneous turns for as long as none of their heroes could possibly meet; the moment reachability says they could, simultaneity between exactly that pair stops. It’s recomputed once, fromdoStartNewDay()— not on every move — which is what keeps a full per-hero pathfind affordable.
Results are cached per pair in blockedContacts, and isContactAllowed() just checks membership. When contact status changes, players are told: a single broadcast when every pair is clear again, or a named message per pair (vcmi.broadcast.simturn.endBetween) when only some are.
PlayerMessageProcessor::playerMessage() is the one entry point for everything a player types — ordinary chat, a !command, or a bare cheat word — and it’s validated server-side, not trusted from the client.
gated entirely behind extraOptionsInfo.cheatsAllowed
cheatConfig is loaded once from config/cheats.json into four categories — localCheats, townTargetedCheats, playerTargetedCheats, heroTargetedCheats. handleCheatCode() lowercases the first word, checks it against every category’s alias list, and only then dispatches to one of roughly twenty cheatXxx() handlers (give army, level up, reveal map, teleport…). Adding a cheat alias is a JSON edit, not a rebuild — the same instinct this atlas keeps finding everywhere else.
ECurrentChatVote, resolved by every other human agreeing
A !vote command can propose SIMTURNS_ALLOW, SIMTURNS_FORCE, SIMTURNS_ABORT, or TIMER_PROLONG; every other human player is added to awaitingPlayers, and finishVoting() only fires once that set is empty. A successful vote calls straight into the section above — gameHandler->turnOrder->setMinSimturnsDuration() / setMaxSimturnsDuration() — overriding the same day-window limits computeCanActSimultaneously() reads, at runtime, for the rest of the game.
NewTurnProcessor::onPlayerTurnEnded() calls gameHandler->heroPool->onNewWeek() on exactly the same newWeek check that gates most of the diagram above. HeroPoolProcessor keeps one CRandomGenerator per player (playerSeed), so which heroes appear in whose tavern is decided by a stream of rolls that belongs to that player alone — one player hiring a hero, or refreshing their pool, never perturbs what any other player is about to see. pickClassFor() and pickHeroFor() draw from that stream; hireHero() is the net-pack handler on the other end.