DangerousRoads
[ALPHA] Wandering encounters for Outward: out in an overworld region, 1-3 hostiles native to that region turn up out of sight, 40-200m away, and walk in. Vanilla loot, never in a town or dungeon. Toast + compass blips. Co-op is not yet verified.
| Date uploaded | a month ago |
| Version | 0.1.13 |
| Download link | CeruleanCutlass-DangerousRoads-0.1.13.zip |
| Downloads | 143 |
| Dependency string | CeruleanCutlass-DangerousRoads-0.1.13 |
This mod requires the following mods to function
CeruleanCutlass-ForgeKit
Dependency-free dev-tooling for Outward BepInEx mods: file-driven dev command loop, self-test harness, on-screen toasts, player-ready lifecycle wait, embedded/override table loaders, and a shared dev-verb pack (movement/combat/skill/status probes).
Preferred version: 0.4.13CeruleanCutlass-SpawnKit
[ALPHA] Runtime enemy spawner for Outward: spawn any donor-scene creature beside the player as a real vanilla hostile, with full AI, aggro, combat, and vanilla death/loot. Co-op supported. Early software — see README for known issues.
Preferred version: 0.6.2CeruleanCutlass-CompanionKit
Persistent-creature companion mechanics for Outward mods: clone/brain-strip body factory, navmesh follower, invisible combat anchor, manual combat. Donor-scene harvesting and template caching moved to DonorKit; aggro control to AggroKit.
Preferred version: 0.4.29CeruleanCutlass-StoryKit
Add an NPC to Outward as plain data: StoryKit builds the character, spawns it at a fixed spot, wires its dialogue, and compiles a trainer skill tree that sells from the vanilla Trainer UI. Requires SideLoader at runtime. A library dependency.
Preferred version: 0.1.12CeruleanCutlass-NetKit
Shared Photon co-op transport layer for Outward BepInEx mods: channels over one relay, a hello/peer ledger, per-channel counters/heartbeat, PUN diagnostics, and a replicated-record store for consumer state mirroring.
Preferred version: 0.2.10CeruleanCutlass-DonorKit
The donor-scene harvest engine for Outward BepInEx mods: additively load any area, clone a live creature into a dormant template, and unload cleanly. Library dependency, installed automatically when another mod (SpawnKit, CompanionKit) needs it.
Preferred version: 0.1.9CeruleanCutlass-AggroKit
Reusable aggro/threat control tooling for Outward BepInEx mods: per-pair targetability gates, detection veto/redirect, taunt and force-target primitives. Inert until a mod uses it — a library dependency, installed automatically.
Preferred version: 0.1.6README
DangerousRoads — Aurai's wilds are not empty anymore
⚠️ Alpha, and single-player only for now. Multiplayer is not tested — see Known issues. Back up saves you care about.
Aurai's overworld is hand-placed and finite. Once you've cleared the road between Cierzo and the Vigil Pillar it stays cleared, and the walk back is a walk through an empty map.
DangerousRoads repopulates it. While you're travelling out in the wilds, hostile creatures can turn up anywhere — out of sight, off the road, behind you. Every stretch of country is country you have to watch again.
They're ordinary wild creatures native to the region you're in: Enmerkar sends beast golems and bandits, Abrassar sends bugs and bandits. They wander, they notice you, they fight, and they drop their normal loot.
What it won't do
- Never in towns or villages. Settlements stay safe.
- Never in dungeons, caves or interiors. Only out in the open.
- Never touches your save. These creatures are temporary — they're gone when you leave the area, and nothing of them is written to your save file.
- No bosses, no story characters, no custom content. Everything it puts in the world is vanilla.
Installing
Install from Thunderstore and the dependencies come with it. By hand: drop ForgeKit/,
NetKit/, DonorKit/, CompanionKit/, SpawnKit/ and DangerousRoads/ side by side into
BepInEx/plugins/. All five are hard dependencies — BepInEx refuses to load a plugin whose
hard dependency is missing, so leaving one out means DangerousRoads silently never starts.
(DangerousRoads names ForgeKit, SpawnKit and CompanionKit directly; NetKit and DonorKit come in
underneath those two.)
Then just play. Nothing to turn on, no key to press — walk out of town and give it a minute.
Settings
BepInEx/config/cobalt.dangerousroads.cfg, created on first launch. The handful worth touching:
[General]
Enabled = true ## false = mod off
[Schedule]
MinIntervalSeconds = 20 ## shortest gap between encounters
MaxIntervalSeconds = 60 ## longest gap — raise both for a quieter world
[Wave]
MinCount = 1 ## how many creatures show up at once
MaxCount = 3
SkipWhileInCombat = true ## hold off while you're already fighting
[Placement]
MinDistanceMeters = 40 ## how close they can appear
MaxDistanceMeters = 200 ## how far away
[Notify]
ShowToast = true ## the on-screen warning
[Compass]
ShowBlips = true ## the compass markers
To turn it off: set Enabled = false and relaunch. To uninstall, delete the DangerousRoads/
folder from BepInEx/plugins/ — nothing of it lives in your save.
⚠ An existing cfg always wins over a new default. BepInEx writes the file once and never
migrates a changed default into it, so a value you (or an older version) put there keeps applying
silently. Each entry carries its shipped default in a # Default value: comment right above it —
if behaviour doesn't match this README, compare those first. Delete the file to regenerate it from
scratch (which also discards every other tweak).
Co-op: the room-wide warm predicate
SpeciesRoster used to ask Spawner.CanMintNow(key) — "is a body resident on this box". It now asks
CanMintNow(key) && Spawner.CanMintRoomWide(key), because a species the host holds and a guest
does not costs that guest a 3.6–14.7 s donor load mid-fight (the disconnect bug). CanMintRoomWide
subsumes the local half, but both are named at the call site on purpose: this is the predicate the
whole mod rests on and "local and room" is what it must be read as.
SpawnableNow()re-asks both halves every call (SpawnKit's cache is a 12-entry LRU, and the peer list moves too) and recordsLastShrink: species dropped purely because a peer is cold. A locally-cold species comes backColdLocallyand is never reported as a peer's fault.RosterOrder.ForRoomWarm/ForPrewarm(pure, inDangerousRoads.Core) rank the region by how much spawnable roster the work buys: tier 0 = the party lacks it but the host holds it (onesk.want, free), tier 1 = nobody holds it (real local harvest), tier 2 = already room-wide. Ties keep source-trust order, soForPrewarmis byte-identical to the pre-co-op order for any snapshot, not just an empty one.AmbushDirector.ArmForRegionpushesSpawner.RequestRoomWarm(top N)after resetting the prewarmer, and the wave path re-pushesLastShrinkwhen a wave is blocked. Neither is throttled here — SpawnKit's want book owns the repeat rule ([Coop] RoomWarmRequestTimeoutSecondsand the doubling backoff); a second DR timer would just be a second, differently-wrong opinion.ReportShrinkprints one[ROSTER]line per distinct (region, species+reason+actors) situation (RosterShrink.KeyForcanonicalises it) plus one degrade line per region whenRoomWarmModeisRoomDegraded, and fires oneToasts.RosterShrunkper region arm.roadsrostergains the room glyph column (● / ◐ / ○).
No DR config. The mode knob is SpawnKit's [Coop] RoomWarmMode in
BepInEx/config/cobalt.spawnkit.cfg:
[Coop]
RoomWarmMode = RoomDegraded
Built 2026-08-20, not live-verified — docs/guest-mirror-harvest-testplan.md GM10–GM19.
Event deck
Each time the timer comes due the director now draws a card instead of always running an
ambush. Card #0 is the classic hostile wave; card #1 is the wandering merchant (its runner ships
separately — until it is present, its weight is logged once, at the first draw, and ignored). Weights are
relative shares of whatever is eligible; a card on cooldown leaves the pool entirely, so the ambush
takes its draws and nothing falls through to "no event". With DeckEnabled = false, or with the
ambush as the only enabled card, no roll is pulled and the mod behaves exactly as it did before.
With every weight at 0 nothing fires at all (roadsstatus shows the soft block; roadsdeck shows
drawable=0) — the deck never falls back to an ambush the operator weighted out. A card that
throws mid-run is logged and treated as a soft outcome, so the director keeps ticking.
BepInEx/config/cobalt.dangerousroads.cfg:
[Events]
DeckEnabled = true ## false = every event is an ambush (the pre-deck mod)
AmbushWeight = 1.0 ## relative share; 1.0 vs 0.35 below is ~74% ambush
MerchantWeight = 0.35 ## 0 = no merchants
MerchantCooldownSeconds = 600 ## stamped at DRAW, not on success
Pure draw logic: core/DangerousRoads.Core/EventDeck.cs (unit-tested, incl. one-card parity).
Game side: Events/EventDirector.cs (cards from config, last draw, Register(IEventCard) seam),
Events/AmbushCard.cs (wraps WaveRunner.Run unchanged). Built 2026-08-22, not live-verified.
Wandering merchant (card #1)
A Travelling Merchant (Merchants faction, Mefino's trade backpack, friendly to the player) appears
out of sight on the same verified ground an ambush would, then walks the road to a zone exit and
despawns there. Talk to him: greeting → Shop (the vanilla shop, stocked from this region's caravan
trader table) or leave. He is a tank that barely hits back (Health 450,
Protection 20, DamageMult 0.25, targets Bandits only) so that when bandits raid him the player
has to step in; once he has been hit and the fight has ended, his greeting switches to the
thanks-for-the-rescue line. Not lootable, never saved; one on the road at a time; a region change
drops him.
The 2026-08-22 live pass (Cobalt, legion) found him fighting the player's pet instead of walking ("he kept running from me even when I tried talking"), naked, jogging, and alone. Built the same day:
- Protected.
AggroTools.Protect(merchant, "dangerousroads.caravan")at spawn,Unprotectat despawn: the player's weapons, lock-on, pet and its combat anchor, and every Player-faction ally's AI detection treat him as untargetable. The raid's bandits get an explicit re-allow pair (TargetableOverrides.Set(raider, merchant, true, owner)), so they can still hit him.[Merchant] Protected(default true;protected=falseon the card for a session that needs to hurt him by hand). - Stops to greet. While any player is within
GreetRadius(4 m) or he is in his dialogue (NpcRegistry.IsInDialogue), the carrot is parked at his own feet, the stall clock is held like combat, and he turns to face the nearest player (LateUpdate); he walks onGreetResumeSeconds(3 s) after the last player leaves. CoreGreetPause;[MERCHANT] paused to greet/resumed the walkonce per edge.WalkSpeeddefault dropped 1.1 → 0.5 — the value isAISWander.SpeedModif, a multiplier on the body's run speed (vanilla wanderers 0.3, SideLoader 1.1 = a jog). - The raid.
Merchant/RaidRunneron the body: rolled once at spawn (RaidChance0.75,RaidMin–RaidMax2–3 raiders), springs when a player comes withinRaidTriggerMeters(90 m) or afterRaidDelaySeconds(120 s) so it is witnessed. Species from the region roster (warm, not blocked), preferring theBanditsfaction, then any non-Merchants/non-Player faction, then unknown (CoreRaidPlan.PickSpecies); placed on a 15–30 m ring (ClusterPlan.Offsets→NavProbe.SnapToWalkable);Spawner.Spawnwith this mod'sOwnerTagsoMaxOwnActive, the far cache androadssweeptreat them as wave members — the count is clamped to the slots left underMaxOwnActivefirst (CoreRaidPlan.CapToRoom; a full cap = no raid, logged); each oneAggroTools.ForceTargeted onto him once its handle is alive. Toast "Bandits are closing on the merchant!" (its own throttle, so the merchant toast half a second earlier cannot swallow it). The rescue edge is unchanged (hit + combat exit). Despawn sweeps every override owned bydangerousroads.caravan(ClearOwnedBy), so a region change mid-raid — where neitherUnprotectnor the runner'sOnDestroycan see the body — leaks nothing.roadsmerchant raidsprings it now;roadsmerchant statusshowsraid=armed/RAIDING/done. - Dressed.
NpcSpec.RandomVisuals = true+OutfitPoolfromMerchant/MerchantOutfits(seven combos of Trader / Master Trader / Adventurer / Pilgrim / Padded / Worker / Scholar kit, by display name, checked againstdata/registries/items.json);outfit=<index>pins one.
The body is StoryKit's (NpcSpec → SideLoader template → Merchant graft → dialogue); DR decides
where (AnchorRegistry.FindSpots), which exit (Core/RoadRoute.ChooseExit over the scene's
InteractionSwitchArea census, preferring one ≥ MinRouteMeters away) and the route (NavMesh
spawn → exit, threaded through the caravanner's roadside RandomPositionOnStart.SpawnPoints, each
seed path-checked from the spawn and dropped when the navmesh cannot reach it). The
walk is a carrot: an empty GO the SL wander state follows (AISWander.FollowTransform), advanced
by Merchant/RoadWalker — combat states interrupt it and following resumes on their own. A stall
toward an intermediate waypoint skips it (up to 3 per walk, Core/RouteSkip); a stall toward
the exit gets one last straight try; only then does the walk end as stuck, and the warning carries
the agent's own path state (pathStatus, hasPath, remainingDistance, isOnNavMesh, velocity,
AI state, carrot position). The live stall of 2026-08-22 — "no progress for 90s toward waypoint 1/6
(16m away)", despawn at 134 s — is what this replaces.
Finding him. The toast names the bearing; the HUD compass also carries a green dot for him
from the toast until he despawns, at any distance, drawn a little larger than the red hostile blips.
That dot is this mod's. The other marker you may see in co-op is vanilla's own other-player
compass element (OtherPlayersCompass), which points at your partner's character — not at the
merchant, and not drawn by DangerousRoads.
BepInEx/config/cobalt.dangerousroads.cfg:
[Compass]
ShowMerchantBlip = true ## live; the merchant's compass dot, independent of ShowBlips
MerchantBlipColor = #34C759 ## live; green. Unparseable = green, never red
BepInEx/config/cobalt.dangerousroads.cfg:
[Merchant]
Health = 450 ## next spawn; he must survive a raid until you arrive
Protection = 20 ## next spawn
DamageMult = 0.25 ## next spawn; if he clears a raid alone there is no rescue
WalkSpeed = 0.5 ## live; SpeedModif multiplier — 0.3 vanilla amble, 1.1 SideLoader jog
ExitRadius = 6 ## live; "arrived" = despawn. <= 0 never arrives
StuckSeconds = 90 ## live; no progress outside combat for this long = despawn. 0 = never
MinRouteMeters = 150 ## next spawn; prefer an exit at least this far from the spawn spot
DefendsHimself = true ## next spawn; false = fully passive
GreetRadius = 4 ## live; stops + faces a player this close (0 = never; dialogue still holds him)
GreetResumeSeconds = 3 ## live; walks on this long after the last player leaves
Protected = true ## next spawn; the player side cannot hurt/target him; bandits can
RaidChance = 0.75 ## next spawn; 0 = the lone walker, 1 = always raided
RaidMin = 2 ## next spawn
RaidMax = 3 ## next spawn; each raider counts against [Wave] MaxOwnActive
RaidTriggerMeters = 90 ## live; springs when a player is this close (0 = delay only)
RaidDelaySeconds = 120 ## live; springs this long after spawn regardless (0 = proximity only)
Files: Events/MerchantCard.cs (spec, spawn + roadscard overrides, route validation, rescue
greeting swap, despawn, LivePosition for the dot; attaches the walker and the raid runner),
Merchant/RoadWalker.cs (carrot, skip/last-try/stuck policy, stop-to-greet, diagnostics, rescue edge),
Merchant/RaidRunner.cs (the raid), Merchant/MerchantOutfits.cs (the outfit pool),
Merchant/RoadExits.cs (exit census + roadside seeds), Merchant/CaravanStock.cs (stock-table name +
roadsstock census); pure Core/WalkProgress.cs (stuck tracker + rescue edge), Core/RouteSkip.cs
(the skip policy), Core/GreetPause.cs, Core/RaidPlan.cs, Core/CardArgs.cs (the key=value parser)
and Core/MerchantText.cs (the lines), all unit-tested. Log tag [MERCHANT]. Built 2026-08-22, not live-verified — docs/road-merchant-testplan.md.
Card #2: the town-guard patrol
Two or three town guards in the region's colours fighting four to six troglodytes on the road, with
the guards in REAL danger without the player — that danger is the card, and it is a tuning number,
printed on every [PATROL] RESOLVED line as guards N/M alive at X% health.
- The guards are StoryKit human NPCs, not the game's town guards: those are static
SNPCprops with noCharacterand no AI (Monsoon's are the lone exception), so there is nothing to clone. Per-spawn specsdr_guard_<n>, Merchants faction,TargetableFactions = [Bandits, Mercs](the two buckets the trogs live in), dressed fromPatrol/GuardOutfits— a pool perCore/GuardLook.GuardTown, by display name, every one checked againstdata/registries/items.jsonand re-checked against ForgeKit's live item index once per session (roadspatrol looks).AggroTools.Protect(guard, "dangerousroads.patrol")shields them from the player side, exactly as the merchant is shielded and for the same live reason (a pet's anchor dragged him into combat). - The troglodytes are ordinary DR spawns: same
OwnerTag, so[Wave] MaxOwnActivecaps them (RaidPlan.CapToRoom),roadssweepclears them and the far cache parks them. Species come fromCore/PatrolPlan.TrogSpecies— the seven spawnable keys, spelled exactly asSpeciesFactions.txtspells them (a unit test reads the embedded resource back) — intersected with the warm roster, with the full list as a logged fallback. The Queen is refused by GameObject name on the resolved handle (PatrolPlan.IsQueenBody): she has no species key of her own and sharesCharacter.Namewith the Mana Troglodyte, so nothing earlier in the pipeline can exclude her. - The wiring is pairwise, not by faction:
TargetableOverrides.Setboth ways per (trog, guard) plusAggroTools.ForceTargetin both directions with the RaidRunner 10 × 0.5 s retry, re-run every 5 s while Fighting for anything that lost its target to a death. - The phases are pure (
Core/PatrolState): Spawning → Fighting → Resolved → Leaving → Gone, with a stalemate window and one hard rule — Resolved never re-enters Fighting, because the far cache can make a "dead" trog read alive again minutes later and the reward handshake must not re-run. - The reward is
Core/PatrolLedger+PatrolPlan.Reward: uid-keyed spawned/dead/helped sets, a rate rolled once per encounter, andMarkPaidso a patrol pays at most once. "Helped" is Cobalt's rule — ANY player-side damage to ANY of the patrol's trogs — observed byPatrol/HelpWatchthrough three deliberately overlapping taps (Character.HasHit,Projectile.OnProjectileHit, and a per-trogOnHitPreResistancebackup). Each tap is counted separately and the resolve summary prints the disagreement, which is the diagnostic if a real hit ever reads as no help. The row is a StoryKitChoice.Actionon ONE surviving guard, computed at pay time, paid withInventory.AddMoney(MP-safe: it routes an RPC for a guest). - Leaving and removal: after
LingerSeconds(extended while anyone is in dialogue) the survivors walk to an exit chosen AWAY from the nearest player (dot-product over the census), on routes built by the sharedMerchant/RoadRouteBuilder. Each guard is removed —Unprotect, pairwise overrides cleared,NpcRegistry.Unregister(which also drops itsChoiceActions) — the first tick it is farther thanRemoveRadiusMetersfrom EVERY player (FarCache.FarFromAll, which returns false on an empty player list, so an unknown situation keeps the guard rather than blinking it out).
The runner (Patrol/GuardParty) lives on its own dr_patrol GameObject, NOT on a guard: unlike the
merchant's RaidRunner, here the body it would ride is the one in danger, and a runner destroyed
mid-fight would leave live trogs, live overrides and a registry entry with nobody to clean them.
BepInEx/config/cobalt.dangerousroads.cfg:
[Events]
PatrolWeight = 0.25 ## relative draw weight; 0 = never
PatrolCooldownSeconds = 900 ## stamped at DRAW, not on success
[Patrol]
GuardMin = 2 ## next spawn
GuardMax = 3 ## next spawn
TrogMin = 4 ## next spawn
TrogMax = 6 ## next spawn; counts against [Wave] MaxOwnActive
GuardHealth = 450 ## next spawn (template-baked)
GuardProtection = 12 ## next spawn
GuardDamageMult = 0.9 ## next spawn; under 1 so the party cannot wipe them before you can help
GuardChanceToAttack = 85 ## next spawn; percent
GuardProtected = true ## at spawn; the player side cannot hurt/target them, the trogs can
RewardSilverMin = 5 ## live; per spawned-and-now-dead trog, IF you helped
RewardSilverMax = 6 ## live; rate rolled once per encounter
TrogRingMin = 12 ## live
TrogRingMax = 18 ## live
GuardRingMeters = 4 ## live
StalemateSeconds = 90 ## live; 0 = a fight must end by a wipe
LingerSeconds = 20 ## live; extended while a guard is in dialogue
LeaveSpeed = 1.4 ## live, read when the walk starts
LeaveTimeoutSeconds = 300 ## live; removal still waits for "far from every player"
RemoveRadiusMeters = 120 ## live; keep in step with [FarCache] DespawnRadius
Verbs: roadspatrol [status|spawn [key=value …]|resolve|help|leave|despawn|looks [town]], and
roadscard patrol [key=value …].
Files: Events/PatrolCard.cs (town, placement, guard specs, exit choice),
Patrol/GuardParty.cs (trogs, aggro, phases, reward, leaving, removal), Patrol/HelpWatch.cs
(the three help taps), Patrol/GuardOutfits.cs (the per-town looks + the name check);
pure Core/PatrolPlan.cs, Core/PatrolState.cs, Core/PatrolLedger.cs, Core/GuardLook.cs,
Core/PatrolText.cs, all unit-tested. Log tag [PATROL]. Built 2026-08-26, not live-verified —
docs/road-patrol-testplan.md.
Forcing a card with parameters: roadscard
roadscard <id> [key=value …] forces one card through the real Fire path with optional overrides.
Every key is optional — an absent key is the card's normal random/config default, so
roadscard merchant is exactly a deck draw of the merchant and roadscard alone is a real deck
draw. roadsdeck <id> … and roadsmerchant spawn … accept the same tail. Unknown keys are warned
once ([EVENT]/[MERCHANT] ignoring unknown key(s)) and the card still runs.
| Card | Key | Meaning |
|---|---|---|
merchant |
exit=<n> |
walk to exit n from roadsexits (out of range = refused, not defaulted) |
dest=<text> |
walk to the first exit whose name contains text (no match = refused) |
|
health= protection= damage=<mult> |
the body's numbers for this spawn (config defaults otherwise) | |
speed= |
walk speed for this merchant (overrides the live WalkSpeed for him only) |
|
defends=true|false |
fights bandits / fully passive | |
at=here |
spawn 3 m in front of you instead of a hidden verified spot — "show me" mode | |
name=<text> |
display name | |
raid=0|1 |
forbid / force the bandit raid for this merchant (absent = roll RaidChance) |
|
raiders=<n> |
raider count (absent = roll RaidMin–RaidMax) |
|
raidspecies=<key> |
raider species (absent = roster pick, Bandits preferred) | |
outfit=<n> |
pin outfit n from the pool (0–6); out of range warns and stays random |
|
protected=true|false |
override [Merchant] Protected for this spawn |
|
patrol |
at=here |
spawn 8 m in front of you instead of a hidden verified spot |
region=<town or region> |
dress the guards for that town (Berg, Abrassar, New Sirocco…) |
|
guards=<n> trogs=<n> |
party sizes (absent = roll the config bands) | |
species=<key> |
pin the troglodyte species (absent = a warm one from the seven) | |
health= protection= damage=<mult> atk=<percent> |
the guards' numbers for this spawn | |
outfit=<n> |
pin outfit n from that town's pool; out of range warns and stays random |
|
protected=true|false |
override [Patrol] GuardProtected for this spawn |
|
exit=<n> |
the exit they leave by, from roadsexits (out of range = refused) |
|
reward=<silver> |
pin the per-dead-trog rate instead of rolling RewardSilverMin–Max |
|
ambush |
count= species= |
exactly roadsnow's forced count / species |
roadscard merchant at=here health=50 # in front of you, fragile
roadscard merchant exit=0 # walk to roadsexits #0
roadscard merchant dest=Cierzo defends=false
roadscard merchant at=here raid=1 raiders=2 # the raid, guaranteed, two bandits
roadscard ambush count=2 species=Hyena
roadscard patrol at=here trogs=4 guards=2 # a patrol in front of you
roadscard patrol species="Troglodyte Archmage" region=Berg
Dev verbs
Every verb below is read from BepInEx/config/DangerousRoads_cmd.txt — write the verb on a line of
its own and it runs on the next poll; results go to BepInEx/LogOutput.log. Meant for debugging,
not for play.
| Verb | What it does |
|---|---|
roadsstatus |
director state: armed/disarmed, next-wave timer, last wave, last deck draw, what's blocking |
roadsdeck |
the event deck: cards, live weights, cooldown remaining, last draw. roadsdeck draw forces a draw through the real path; roadsdeck <id> [key=value …] forces that card |
roadscard [id] [key=value …] |
force one card with optional overrides (key table above); no id = a real deck draw |
roadsmerchant [status|spawn [key=value …]|despawn|rescue|raid|goto <exit>] |
the wandering merchant: where he is (walk, greet pause, raid state) / put one on the road now (same keys as roadscard merchant) / remove him / fake the rescue edge (greeting swap) / spring the raid now / re-route to an exit index from roadsexits |
roadsexits |
census of this scene's zone exits: index, destination, distance, bearing |
roadsstock |
which live Merchant carries the caravan-trader stock table the road merchant copies |
roadsroster |
this region's species roster, with anything BLOCKED marked |
roadsfactions |
the faction each rostered species actually reports |
roadsledger |
the anchor ledger — where waves have been placed and from what source |
roadsanchors |
candidate anchors near you |
roadssquadpoints |
vanilla AISquadSpawnPoints in range |
roadsprobe / roadsmark |
probe a placement position / mark one for inspection |
roadsui / roadsblips |
toast and compass-blip state (roadsblips counts hostile and merchant dots separately) |
roadsnow |
force a wave immediately |
roadsarm / roadsdisarm |
arm the director so the next wave is due now / stop the timer |
roadswarm |
pre-warm species templates |
roadssweep |
despawn everything this mod has out |
selftest |
run the offline self-test ([SELFTEST] PASS/FAIL … DONE) |
Known gap: roadsstatus throws a NullReferenceException if called on a fresh boot before the
first wave has been computed. It self-heals once any wave fires.
⚠️ Known issues
- A Wolfgang can turn up on the wrong side. The Wolfgang family's allegiance is driven by your
plot and relationship progression in the base game, not by a fixed table value, so an ambush wave
that draws one can spawn it in an allegiance the mod didn't intend — including friendly. The fix
is to drop the whole family from the encounter roster (not to hard-code a faction for them, which
would just be wrong at a different point in the story); that isn't in 0.1.0. If you hit it and
want it gone now, add the names to
[Species] ExtraBlocklistin the config and relaunch —Wolfgang Captainis already blocked by default,Wolfgang VeteranandWolfgang Mercenaryare not. - A few other species report an unstable faction. The Mantis family (Rock/Mana Mantis, Mantis Shrimp), Veaber and Caldera's Boreo have each been seen reporting different factions on different spawns. Under investigation; the practical symptom is the same as above — an encounter that doesn't fight you.
- Multiplayer is untested. Only the host would ever run encounters, which is the right shape, but no real two-machine session has been played. Treat co-op as unsupported for now.
- Encounter frequency varies by region. Some parts of the map give the mod more good places to put things than others, so the wilds are livelier in some regions than in others.
- Three of the six regions have had a deep test pass; the rest have had less.
Compatibility
Outward must be on its Mono Steam beta branch, not the default IL2CPP build (Properties → Betas →
select mono). If the game runs but no mods load and there's no crash log, this is almost always why.
Works alongside Beastwhispering and the rest of this mod family.
License
Apache License 2.0 — see LICENSE.
CHANGELOG
DangerousRoads changelog
0.1.13 — 2026-09-05
- Release prep 2026-09-05: body-ladder order gate, bundle-origin yaw, six-stage release protocol + whole-release r2modman check
- Static-analysis wave 5: release prep for Beastwhispering 0.2.18 + DangerousRoads 0.1.13
- Merge branch 'worktree-agent-adc24fd1bf3071fa0'
- StoryKit 0.1.12: complete the partial bump (manifest/thunderstore/csproj + consumer pins)
- Merge branch 'worktree-agent-a70757d0f1c5d341c'
- Merge branch 'worktree-agent-a3de361a4d0f73193'
- DangerousRoads: cards request species be warmed — the wish seam replaces the patrol special case
- DangerousRoads: warm variety is a target, not a floor — a patrol fields min(warm, slots) distinct species
- DangerousRoads: friendly dots rebase — enrolment is ground truth, probe demoted to diagnostic
- Merge branch 'worktree-agent-afec089b8479d9db0'
- DangerousRoads: warm species and faction coherence agree, so patrols field a varied cast
- DangerousRoads: green compass dots for every event friendly (guiding stars)
- DangerousRoads: ShieldWard review fixes — per-shield scoping, kill switch, [DRWARD] tag
- DangerousRoads: ShieldWard — the for-all guard shield no longer disarms our own hostiles
- DangerousRoads: TrogCast counts locally-resident templates as warm
- DangerousRoads: C2 prewarm the patrol cast so the trog stall is paid during travel
- DangerousRoads: patrol guards defend the player from nearby hostiles
- ForgeKit 0.4.13 / DonorKit 0.1.9 / SpawnKit 0.6.1: session-3 fixes
- Fix release-blocking version skew: NetKit 0.2.10, Beastwhispering 0.2.17
- DangerousRoads: roof placement veto — never spawn inside a structure
- … and 51 more (see git history)
0.1.13 — 2026-09-04 (static-analysis wave 5; BUILT, not live-verified — retest rows SA-0904-DR* in docs/static-analysis-fixes-testplan.md)
Findings from docs/static-analysis-2026-09-04.md (lanes 5 and 6). Every item below is a code change
that passed the unit suite; none has been through a live session yet.
- Re-enabling the mod mid-load no longer arms against the scene being left. The
Disabled → armededge requires a sane player position (same readiness rule as the region guard); retestSA-0904-DR11. - The stranger card can no longer wedge for the rest of a region (L5-16, P1).
StrangerFight.TickRemovalreads.C.transform.positionbefore establishing the body still existed — the exact shape the B2 guard fixed inGuardParty.TickLeavingon 2026-08-30, never ported. A stranger body destroyed out from under the row threw on every tick,Finish()was never reached,_fightstayed live and every later stranger draw refused with "strangers are already on the road". The row is classified before any transform read (Core.StrangerRemoval, unit-tested), a gone body is pruned withbody was destroyed out from under the encounter, and the two fight-phase transform reads carry the same guard. - A region change whose player-ready wait times out no longer leaves the director armed on the
previous region's roster (L5-01, P2).
OnRegionReadywas the only rebuild path and its 30 s wait ended in one warning line; the 0.5 s poll only disarmed when the new area was not a zone at all. Now the timeout callsOnRegionAbandoned(stand down to Idle) and the poll runsCore.RegionArmGateon the area-changed edge and on every poll while Idle: roster built for another overworld scene + a READY player (sane position, the same test the wait uses) → re-arm, superseding the pending scene-load wait so a load never arms twice; no ready player → stand down; city/dungeon → keep (no roster needed).SpeciesRoster.BuiltForScenefinally gates something. (Same-tier review tightened the readiness test and the Idle parking — a plainDisarmin an active zone is undone by the re-enable block next frame.) - Far cache: the cap never evicts a restore in flight (L5-03).
Addevicted the longest-parked entry, which is precisely the one most likely mid-restore; the ledger's count went wrong while the creature still landed. Eviction skipsInFlight; with every entry in flight the park is admitted over the cap (bounded by one restore tick) rather than refused. - Far cache: one room-warm decision per species per tick (L5-02, perf). A room-warm HOLD spends no
budget, so the restore loop asked
RoomWarmDecisiononce per parked ENTRY at 2 Hz — N template component walks for a cache full of one cold species.Core.WarmGate.Memocollapses it per tick. - The quiet-roads ledger now reports "the room warmed to X — no longer refused here" (L5-07). Flush ran only inside a refusal, so once the peers warmed a species nothing refused and the line could never print; the director now flushes at 1 Hz while anything is pending.
- Merchant raid: a WIN ends the raid (L5-17, live FAIL RM24).
RaidRunnernever leftRaidingwhen every raider died;roadsmerchantprintedraid=RAIDING … 0/N alivefor the rest of his walk.Core.RaidPlan.IsWon+EndRaid. Safe only because raiders now carryNeverFarPark(L5-18) like every other event-owned body — a far-parked raider used to read as a dead one — released when the raid ends or the runner is destroyed. - Deck diagnostics (L5-04/L5-05/L5-06):
EventDirector.Bypassedno longer overwrites the last-draw record as a side effect of a read; the roll is pulled lazily insideEventDeck.Draw(Func<double>)so a one-drawable non-ambush deck consumes noRandom.value(the header's parity claim now holds for every one-card deck); the[ROSTER] warm requestsline saysno runner in this buildinstead ofdisabledfor a weighted card with no runner. - DoT cleanse only strips maluses (L5-20): a
poison/bleed/burnsubstring in a BUFF's identifier (a resistance, a modded status) is no longer cleansed off a surviving guard;IsMalusEffectgates it. - Fourteen bug-hiding
catch { }blocks now log (L6-11, control flow unchanged): the room-warm ask and active-scene reads inRoomWarmGate, the AI/frozen-cull/health reads inFarCacheDriver, the shield-drop / targetable-pair / hit-tap teardown paths inGuardParty,StrangerFightandShieldWard;WarmCast.Plan.ResidentReadFailurescounts a throwing residency probe andDescribesays so. - DR declares the CompanionKit consumer contract (L6-05) — the SHAPE handshake BW and Hireling had
and DR did not, with the same NoInlining isolation. A
[CONTRACT] 'DangerousRoads' built against CompanionKit consumer contract …boot line;Kits.Integration.Testsnow gates every CompanionKit dependant for it. - Refactors, no behaviour change: the director's own-cap reads use the non-allocating
Spawner.Active(tag, list)(L5-08);WaveRunner's deadh == nullbranch is gone (L5-09);Core.RosterFilter.Keysreplaces two hand-copied roster projections (L5-10);Placement/SpotPicker.PickOnereplaces the duplicatedat=here/ verified-spot preamble in the patrol and stranger cards (L5-11);SpeciesRosterreadsDonorKit.DonorHarvest.DonorScenesinstead of the deprecated CompanionKit forwarder shim (L6-06). - Docs: the ten
[Patrol]keys with no wiki mention (L6-10) are now ondocs/wiki/mods/dangerous-roads.md.
0.1.12 — 2026-09-04 (live-tested on legion; see per-item status)
The 2026-09-04 live session. Requires SpawnKit >= 0.6.2.
- The trog spawn stall is paid during travel, not at the encounter (C2). Measured for the first
time in four sessions:
[ALIVE] worstMs=3585.5— a single 3.59-second frame, isolated to the additive donor scene load (donor scene loaded in 3.87s). Harvests serialize, so a two-cold-species encounter paid ~9.7s back to back, which is very likely the "9" in the tester's "2-9 seconds". Frame-slicing cannot fix that; the cost is inside Unity's scene load, not our code. SoPatrolPlan.TrogSpeciesnow reaches the host's LOCAL prewarm queue — it previously only ever reached the peer-facing room-warm ask — and is harvested during the ~60s of quiet travel the prewarmer already has. Seeded at depth 2 viaPrewarmSlate, round-robin with the region roster so seven trog names cannot starve the ambush card. Live: 3585ms -> 756ms. TrogCastcounts locally-resident templates as warm. The prewarm above warmed a species the card could not see:TrogCastbuilt its warm set only fromRoster.SpawnableNow(), so a resident template that is not in the region roster read as cold. Live proof: the patrol loggedno troglodyte species is warm herewhile[TEMPLATE] cache hit for 'Troglodyte'fired three times in the same session. Now unions roster ∪ resident (viaSpawner.CanMintNow), withWarmCastholding aMinVariety = 2floor — Cobalt's live note was that a uniform cast "is a setback", so warmth alone must not collapse the party to one species; at most one cold species is admitted to meet the floor. The peer-facingRoomWarmGatefilter is unchanged and still applied after.ShieldWard: the for-all guard shield no longer disarms our own hostiles.[Patrol] GuardProtectedwrites a for-allIsTargetable=falseon each guard, and AggroKit's three prefixes then veto every vanilla acquisition route against it — includingCharHurt, so a DR mob beaten by guards could never even retaliate. Observed live: a Marsh Archer Captain atstate=AISSuspicious target=- hp=38/300, damaged and permanently passive. DR-spawned hostiles now get pair re-allows onto shielded guards, refreshed at 1 Hz. Anti-cheese is preserved and was re-verified in review: candidates come only fromSpawner.Active(Ident.OWNER_TAG)(DR-minted bodies, never a player, pet, anchor or partner) andWardPlan.Classifyrefuses player / Player-faction / non-AI as its first three rules. Scoped per-shield: only the patrol wards its spawns — the caravan merchant and the stranger keep their deliberate per-attacker pairing (RaidRunnerhand-picks its raiders), because an early version let an unrelated ambush kill the merchant.- New:
[Patrol] WardOwnSpawns(default true, live viareloadcfg) — stops the pairing without lowering the shield.[Species] HarvestLoadingPriority(default off) — an A/B knob forApplication.backgroundLoadingPriorityaround a harvest; unset in the repo until tonight, and a 3585ms frame is ~900x its 4ms budget, so the case for it is an argument rather than a measurement. - Log tag
[WARD]->[DRWARD]; the bare tag already belonged to Beastwhispering's Mana Ward. - Retests:
road-patrol-testplan.md,dr-farcache-testplan.md,road-merchant-testplan.md(WD1-WD7 plus the merchant-bystander row).
0.1.11 — 2026-08-30 (built, not live-verified)
The bug swarm off the 2026-08-30 live session. Requires StoryKit >= 0.1.11.
- Patrol trog casts are faction-coherent (BUG-TROGVSTROG; Cobalt watched two trogs fight each
other live). The roster spans two hostility buckets (Bandits vs Mercs), each trog rolled its own
species, so mixed parties brawled with themselves. The cast now picks one faction bucket per party
(size-weighted, so Bandits stays the common outcome), via the live FactionBook so a mis-shipped row
self-heals. A trog↔trog truce via AggroKit's deny overrides was mechanically possible but rejected
on cost/coupling grounds — and a Grenadier volley would land through a truce anyway
(RaycastProjectile checks faction only). Data correction:
SpeciesFactions.txtTroglodyte Knight=Mercsper the live re-read. Retest:road-patrol-testplan.mdBUG-TROGVSTROG. - A distance-culled body no longer reads as fighting forever (FC13 FAIL → fixed). Vanilla's
CharAIDisable culler freezes the AI state machine mid-state, so a body frozen in combat pinned the
far cache at its cap.
ReadsAsFightinggains a frozen-cull discriminator (CharacterAI disabled OR AIStatesRoot inactive, gameplay-live only); a confirmed-frozen body can no longer hide behind the unreadable-body fail-safe. Live fights are untouched (FC12's exemption stands). Retest: FC13; unblocks FC15 leg 2. - The stall/stuck leg of the shop-despawn test was unreachable by design — a greet-paused
merchant holds the progress clock, so "stuck with a shop open" cannot occur; the SK-D8 row is
re-staged via the arrival leg.
roadsmerchant despawnno longer prints a spuriousnone on the road.after its own HELD line, androadsmerchant statusalways reportsdespawnHeld=.
0.1.10 — 2026-08-30 (built, not live-verified)
The pre-session follow-up wave, so the three deliberate behavior shifts in 0.1.9 don't read as regressions in the field. Requires SpawnKit >= 0.6.0.
[FarCache] ParkMidFightBeyond(default 0 = never park a fighting creature, i.e. the B1 fix untouched): a hard distance past which even an active fight is parked — the bounded relief valve for players who find roads too persistent after B1. Values belowDespawnRadiusare raised to it (a bind-time warning names the effective figure);roadscacheand the config summary print the effective radius, and a mid-fight park line names the knob so it can never be misread as B1 regressing. Retest:dr-farcache-testplan.mdFC14.- Patrol trogs and stranger mobs are never far-parked while their encounter is unresolved
(
SpawnOptions.NeverFarPark; released in Teardown so survivors go back to being ordinary road creatures). Retest: FC15. - Quiet roads now say why. Every room-warm refusal is recorded in a pruning ledger
(
QuietRoads); the host logs ONE dense, rate-limited line naming the refused species, the runner, the reason and the cold peer actor(s) — with "THIS IS NOT A REGRESSION" in as many words — and[Coop] AnnounceQuietRoads(default false) adds one toast per region for testers. Species that warm are pruned (with their own "no longer refused" line), so the report never goes stale. Retest:road-patrol-testplan.mdPT-PR2c/PT-PR2d. - Warm asks are batched (one list per refusal/tick instead of a per-key loop that multiplied
RoomWarmRequestCap), and the draw-timeroomwarm:condition is mode-faithful: under the defaultRoomDegradeda card sits out only when its species are TERMINAL dead ends on a peer — mirroring the B12 spawn gate — so no more drawn-then-refused blank moments; underRoomStrictnothing changed. Retests: PR2-BATCH1,dangerous-roads-testplan.mdPR3-5.
0.1.9 — 2026-08-30 (built, not live-verified)
- The far cache's "never park a creature that is fighting" rule now actually works (B1). It was
asking
Character.InCombat, which ism_engagedCharacters.Count > 0— and the only writer of that list opens withif (!IsAI && …), so it is permanently false for every AI body. The exemption the config has promised since the feature shipped had therefore never once fired, in solo either: any creature fighting more than[FarCache] DespawnRadius(120 m) from every player was parked mid-swing. Downstream, a town patrol whose troglodytes were parked reported itself resolved in about a second withalive=0 dead=0, because a park isDespawn(kill: false)and fires noOnDied. The read is nowCharacterAI.IsFightingOR a non-nullTargetingSystem.LockedCharacter(the transition frames), with the deadInCombatkept only as belt-and-braces; a throw reads as FIGHTING, because the fail-safe direction is keeping a creature we could not interrogate, never parking one. New pureCore.ParkRule. Note the balance change, which applies in solo too: a far-away fight now continues instead of quietly ending, so it keeps holding its[Wave] MaxOwnActiveslot until it disengages. That is intended. The[FarCache] DespawnRadiusdescription, which said something false for the whole life of the feature, is rewritten to name the signal it actually uses. RowsFC12/FC13indocs/dr-farcache-testplan.md,PT-B1indocs/road-patrol-testplan.md. - A guard destroyed while the party was walking away no longer throws every tick (B2). 3,779
NullReferenceExceptions out ofGuardParty.TickLeavingin one session — one per 0.5 s tick, forever, because the loop readg.C.transform.positionbefore anything established the body still existed and the throw happened BEFORE the prune, so the dead row could never be removed.TickLeavingnow establishesbodyGonefirst (Unity== nullon both the Character and its transform, in a try/catch), asks for distance only when there is something to measure, and routes a gone body through the ordinaryRemovepath so the walker, the shield, the targetable pairs and the registry entry all come off with it. New pureCore.LeaveRule. RowPT-B2. - Co-op: five more spawn paths consult the room-warm intersection before spawning (PR2). The wave,
the merchant raid, the far-cache restore, the stranger mobs and the town-patrol troglodytes now check
what the ambush card already checked, and refuse before spawning rather than showing a guest a ghost.
The gate is deliberately PEER-only:
ColdLocallyis not a refusal, because paying a template mint is what these runners have always done, so in solo the predicate is constant-false and every gate is the identity function (asserted inWarmGateTests.SoloIsANoOp). The wave refuses all-or-nothing rather than dropping a member, since composition was chosen against the spot count. A far-parked creature is no longer dropped when a peer is cold — under the old unconditional drop, a guest joining mid-session silently deleted every parked creature in one 0.5 s tick, with no death, no corpse and no line naming a cause. A room-warm refusal is now a WAIT: the entry stays parked, the party is asked to warm the species, and[FarCache] MaxAgeMinutesis what eventually forgets it. NewCore.WarmGate(predicate- injected, so DR.Core gains no SpawnKit reference) plus the one adapterRoomWarmGate, which fails OPEN on a throw. The patrol's full-TrogSpeciesfallback is peer-gated rather than deleted: in solo the whole list survives untouched, and in a room only peer-showable species come through — if that leaves nothing, the card refuses with a string, which is an outcome the old code had no way to express (it just spawned cold species anyway, which is what killed the tester's guard encounter). In co-op that IS an encounter-density change: expect fewer patrols in a room where no peer can show a troglodyte. RowsDR-PR2W/DR-PR2F,PT-PR2a/PT-PR2b,ST-PR2,RM-PR2. - A card that cannot run in the current co-op room sits out the draw instead of winning it and burning
its cooldown (PR3). New
roomwarm:NAME[,NAME…]condition leaf, and the room-warm push now asks the party for the patrol's troglodytes too, not only the ambush roster —WarmSlate.Mergeinterleaves rather than concatenates, becauseRequestRoomWarmsends an ORDERED list that SpawnKit caps per peer, so appending the trogs would have put them permanently past the cap. The no-stamp is scoped to the co-op-cold reason BY CONSTRUCTION rather than by a conditional: an ineligible card never entersEligibleDeck.Cards, so it cannot be drawn andMarkFiredis never reached for it. Solo is unchanged. ⚠ The leaf is INERT at the shipped default. Its predicate is SpawnKit'sCanMintRoomWide, and[Coop] RoomWarmMode=RoomDegraded— the default — rewrites every verdict to Spawnable on the way out, so at defaults the patrol is dealt exactly as before. That is kept deliberately: RoomDegraded means "the host spawns anyway, the guest pays and warms", a product choice already made, and DangerousRoads keeps ONE opinion about the room — SpawnKit's. RowsPR3-1andPR3-2therefore carryneeds [Coop] RoomWarmMode=RoomStrictas a precondition and FAIL as written on a default install;PR3-3(the push) andPR3-4(solo) are mode-independent. - The road merchant is never despawned out from under an open shop (SK-D8).
RoadWalkernow asksEndOrDefer(reason)between the walk andEnd: thearrivedandstuckbranches defer while the shop is busy (dead/loststill end directly), the held end is re-asked at the top of every 0.25 s tick, and nothing else about the walk runs while it is held — the agent is parked and the progress clock stops.Describe()reportsdespawnHeld=. The hold has a ceiling: new[Merchant] DespawnDeferSeconds(180 s; 0 = no ceiling), and an Overdue verdict is carried intoMerchantCard.OnEndedas a forcing despawn, so Overdue means what it says. New pureCore.ShopDefer, the same shape as the existingCombatDefer. This protects a host-side shopper only until StoryKit's SK-D12 is live —IsInDialoguewas blind to guests, so a guest's open shop read as "not busy" on the host; SK-D12 ships in StoryKit 0.1.9 alongside this. RowSK-D8indocs/road-merchant-testplan.md. - Every other despawn call site was audited against StoryKit's new in-registry busy guard (SK-D13
follow-up):
MerchantCard.ClearLivedecides busy FIRST and keeps every handle on a refusal (loggingdespawn … HELD),GuardParty.RemoveandStrangerFight.Removeskip a busy NPC and let the tick loop retry, and the teardown paths — region change,SlumbushCard.Clear— force, because the body dies with the scene either way. - Needs StoryKit 0.1.9 and SpawnKit 0.5.5; pins raised across the sweep floors.
0.1.8 — 2026-08-30 (built, not live-verified)
- Backup save folders are no longer offered as familiar-stranger candidates (ST12). A renamed
Save_<uid>.bak-<date>-<label>folder scanned as a whole extra "character" — an empty-handed near-duplicate of a live candidate.roadsstranger savesnow reports it underexcluded, naming the original it copies, rather than filtering it silently. The predicate is shared, pure and tested (core/StoryKit.Core/Saves/SaveArtifact.cs); Echoes carried the same defect and is fixed with it. - Decks per kind of place.
ZoneKind {Overworld, City, Dungeon}foldsRegionGate's verdict;WorldSnapshot.Zone+ azone:condition term. The director now arms and draws in cities and dungeons too (RegionWatch.IsActiveZone); the roster/prewarm/room-warm androadswavestay overworld-only. The four road cards carryzone:overworldas their own rule. - The blank card (
blank): does nothing, counts as fired so the clock re-arms. Weight per zone:[Events] BlankWeightOverworld=0 / BlankWeightCity=0.97 / BlankWeightDungeon=1.0— the overworld deck is byte-identical to before, cities are mostly blanks, dungeons all blanks. - New card: Slumbush (
slumbush, ~3% of Levant city draws; own rulezone:city & region:Levant). 1–3 tattered thugs with iron are ring-spawned ~7 m behind the player (StoryKitRingSpawner), the player is halted (ForgeKitPlayerHalt), the lead's dialogue opens on them: pay 200 silver (Inventory.RemoveMoney) or fight (faction Bandits + AggroKit pairwise locks). One thug's pouch holds 60 silver for the loot path.[Slumbush]section +[Events] SlumbushWeight/Cooldown/When. Log tag[SLUM], AggroKit ownerdangerousroads.slumbush. DeckEnabled=falsebypasses to the ambush ONLY in the overworld (elsewhere the draw is null,LastNullReasonsays "deck disabled") — the legacy switch never dealt anything in a city.- Fable review (2026-08-30) folded in: the card closes its own dialogue tree after the answer
(root replies loop back to the greeting, so the rows stayed clickable — double-pay), pay/fight
callbacks are idempotent, the graph is probed one frame after
RequestStartDialogue(a silent non-start no longer holds the player forDecisionSeconds), the card is Soft while the player is in a menu/dialogue (it would have yanked a trade), corpses no longer block the next draw, a region change aborts the wait loops. Manifest floors: ForgeKit 0.4.12, StoryKit 0.1.8. - Needs ForgeKit 0.4.12 (
PlayerHalt) and StoryKit 0.1.8 (RingSpawner).
0.1.7 — 2026-08-30 (built, not live-verified)
- Cards decide whether they join the deck before anything is drawn. Each due tick the director
takes ONE
WorldSnapshot(WorldProbe: game hour,IsNight, rain, region, story faction via theTagSourceManager.*Factionquest signatures, lazy quest-flag lookup) andEligibleDeck.Buildasks every card'sICardCondition; cards that refuse sit out — no roll share, no cooldown stamp — and the draw runs over what joined. A card sitting out that leaves the ambush alone takes the legacy no-roll bypass, so parity holds per world. [Events] AmbushWhen / MerchantWhen / PatrolWhen / StrangerWhen(empty = always): a one-line rule grammar —always | night | day | hour=A-B | rain>X | rain<X | flag:UID | faction:NAME | region:NAME, joined by&/|,!negates. Unparseable text is logged once and treated as always.IEventCard.Conditionis the code-level rule a card can add; the two are ANDed.roadsdeckprints the world line, per-cardwhen=andSITS OUT — needs …; a forced card that would have sat out still runs and says so.LastNullReasonnames the exclusions when nothing was eligible.- Core:
WorldSnapshot,CardCondition(leaves, composites, parser,And),EligibleDeck;EventCardgained an optionalCondition. Tests:CardConditionTests,EligibleDeckTests.
0.1.6 — 2026-08-29 (built, not live-verified)
- Both spawn soft-blocks read the kit gate.
AmbushDirector.SoftBlockchecked onlyIsOverallLoadingDone(it missed the loader's paused window after it); the stranger card carried its own two-flag check. Both areForgeKit.Lifecycle.IsGameplayLive()now — reason linesgameplay not live (level loading, or still paused by the loader)/gameplay still paused by the loader(ST11's key, unchanged). Behind them, StoryKit'sSpawnAtAndGetand SpawnKit'sPreflightrefuse on the same predicate, so no card can put a T-posed body on the road. - The stranger card's wander speed is
NpcSpec.WalkSpeedby name (was the 0.3f literal).
0.1.5 — 2026-08-29
- New event card: the familiar stranger (
stranger, deck card #3 — built, not live-verified;docs/stranger-testplan.mdST0–ST10). One or two of YOUR OTHER saved characters are found on the road fighting 2–4 of the region's mobs; they die honestly. Save them and the survivor says the line and offers "Look in the backpack": that character's real bag + pouch in the vanilla container panel, one take, and the item is moved into your inventory AND written out of the saved character's save as a new snapshot (StoryKitSaveScan/SavedCharacterSpec/SaveHandoff, lifted from Echoes). Master-only; the offer refuses on a co-op guest. - Config:
[Events] StrangerWeight 0.25 / StrangerCooldownSeconds 900; new[Stranger]section (MobsMin 2 / MobsMax 4 / MaxStrangers 2 / StalemateSeconds 90 / RewardEnabled true). - Verbs:
roadsstranger [status|saves|spawn [at=here count= mobs=]|resolve|help|despawn],roadscard stranger [at=here count= mobs=]. Log tag[STRANGER]; AggroKit owner tagdangerousroads.stranger. - With no other saved character on the machine the card is a soft block, warned once at boot.
0.1.4 — 2026-08-28
-
Fix stale dependency pins across the fleet; DonorKit 0.1.7
-
DangerousRoads: town-guard patrol card (deck card #2)
-
DangerousRoads: stop the merchant thrashing on a stale combat lock
-
road merchant: halve the health pool (900 -> 450) and trim dialogue to the shop
-
RM18b PASS: torso look-at guard verified live by A/B
-
RM18b: root cause CONFIRMED at the clamp rail — restore the guard, add a pose sampler
-
RM18b: root-cause the merchant's backward spine arch + guard behind a live toggle
-
SpawnKit + DR: guest voluntary warm (GM22–GM28, built, not verified)
-
DangerousRoads: stop the road merchant's greet-pose lean (RM18)
-
Merchant raid: hold the timed spring while the merchant is far-parked
-
Merchant walk: lift the vanilla distance cull off the road merchant
-
Merge branch 'main' into fix/sa-0823-f2
-
AF2-12: the shrink ledger records a degraded veto too, keyed on the reason
-
AF2-8: say "sent this sweep" — the count is the whole want book, not my species
-
Merge pull request #28 from cobalt-craft/fix/sa-0823-o1
-
AF2-4: a degraded room verdict is not "already warm" — hand it to the want book
-
AO1-13: refuse an unknown card id before recording or stamping it
-
AF2-2: read "dead end on a peer" off the Reason, not the downgraded Verdict
-
AO1-12: cache the parsed blip colours
-
AO1-11: refuse every forced path on a co-op guest
-
… and 19 more (see git history)
-
New event card: the town-guard patrol (
patrol, 2026-08-26 — built, not live-verified;docs/road-patrol-testplan.md). Two or three guards in the region's town colours fight four to six troglodytes on the road, and they are in real danger without you. Help — any damage to any of the trogs, yours or your pet's or an arrow's — and the guard left standing pays the town's bounty once: five or six silver per troglodyte that fell. Watch instead and you get thanks. Afterwards the survivors walk off toward the far end of the road and are removed once they are out of everyone's render range, which is what gives their spawn slots back. You cannot hurt the guards ([Patrol] GuardProtected); the trogs can. The Troglodyte Queen is explicitly refused. New config[Events] PatrolWeight/PatrolCooldownSecondsand a[Patrol]section; new verbroadspatrolandroadscard patrol [key=value …]; log tag[PATROL]. -
Road merchant tuning (Cobalt 2026-08-26):
[Merchant] Healthdefault 900 → 450 — still enough to outlast a raid until you arrive, half the tank he was. Note a changed default does not migrate into an existingBepInEx/config/cobalt.dangerousroads.cfg; hand-edit the deployed cfg on each host. His dialogue is the shop only — the "Where are you headed?" reply is retired (MerchantText.RoadFlavourand its tests stay, unwired, if it comes back). -
Road merchant, RM18b (the backward spine arch, live 2026-08-24):
roadsmerchant posedumps the three candidate causes in one read — vanilla's torso look-at pitch (locked/weapon/torsoLookAt), a latched additive KnockHurt layer (layer6), and the player-only slope+IK paths an SL body runs becauseCharacter.IsAIis false (isAI/ik/slopeSmooth).roadsmerchant rebindcallsAnimator.Rebind()as the frozen-vs-live discriminator, androadsmerchant torsofix on|offtoggles StoryKit's guard so the whole A/B runs in one session without a rebuild. The old RM18 covered two unrelated defects and is now split: RM18a (themoveForward/moveSidelocomotion residual,HoldAgentStill) and RM18b (the spine). -
Road-merchant live-bug pass (2026-08-22, legion — he fought the pet, jogged off, spawned naked, nobody raided him): Protected —
AggroTools.Protectat spawn /Unprotectat despawn, the player side (weapons, lock-on, pet + anchor, Player-faction AI) cannot touch him, raiders re-allowed pairwise ([Merchant] Protected, card keyprotected=). Stops to greet — withinGreetRadius(4 m) or in dialogue the carrot parks at his feet, the stall clock holds, he faces the nearest player; resumesGreetResumeSeconds(3 s) after the last player leaves (CoreGreetPause, tested;[MERCHANT] paused to greet/resumed the walk).WalkSpeeddefault 1.1 → 0.5 (a SpeedModif multiplier; 1.1 is a jog). The raid —Merchant/RaidRunner: rolled at spawn (RaidChance0.75,RaidMin/RaidMax2–3), springs withinRaidTriggerMeters(90 m) of a player or afterRaidDelaySeconds(120 s); roster species preferring Bandits (CoreRaidPlan, tested), 15–30 m ring on walkable navmesh,Spawner.Spawnunder our OwnerTag,ForceTargetonce alive; toast "Bandits are closing on the merchant!"; card keysraid=0|1 raiders= raidspecies=;roadsmerchant raidforces it,statusshows it. Dressed —NpcSpec.RandomVisuals+OutfitPoolfromMerchant/MerchantOutfits(seven display-name combos), card keyoutfit=. Testplan RM14–RM17; RM1 FAIL / RM5 partial evidence recorded. Review pass (same day): the raid is clamped to the slots left under[Wave] MaxOwnActive(CoreRaidPlan.CapToRoom, tested; full cap = no raid, logged);ClearLivesweeps everydangerousroads.caravanoverride by owner so a region change mid-raid cannot leak the shield or the pairs; the raid toast has its own throttle (a merchant spawned insideRaidTriggerMeterssprings 0.5 s after the merchant toast); a spawn callback landing after the runner was destroyed no longer tries to start a coroutine on it. -
roadscard <id> [key=value …]: force any card with optional overrides (CoreCardArgs, tested;EventContext.Args). Merchant keysexit= dest= health= protection= damage= speed= defends= at=here name=; ambush keyscount= species=. Absent keys = the card's defaults, so a bareroadscard merchantis a deck draw; unknown keys warn and run.roadsdeck <id> …androadsmerchant spawn …take the same tail; the forge catalog completes the card id. -
Green merchant compass dot:
CompassBlips.Tick(hostile, friendly)— one pool, two colours; the merchant's dot is[Compass] MerchantBlipColor(default#34C759), 1.4× size, no range cap, shown from the toast until despawn;[Compass] ShowMerchantBlipturns it off independently ofShowBlips.MerchantCard.LivePositionfeeds it;roadsblips/roadsstatuscount both kinds. -
Walk stall fix (live 2026-08-22: "no progress for 90s toward waypoint 1/6 (16m away)" → despawn at 134 s): roadside seeds are path-checked from the spawn at route build and unreachable ones dropped; the carrot is sampled with the walkable mask (was
AllAreas); a stall toward an intermediate waypoint SKIPS it (CoreRouteSkip, max 3 per walk, tested), a stall toward the exit gets one last straight try, only thenstuck; every stall warning carries the agent'spathStatus/hasPath/remainingDistance/isOnNavMesh/velocity, the AI state and the carrot. -
Fallback stock names fixed against the item registry (
Bandages,Makeshift Torch;Bandage/Torchmatched nothing) and verified once per session at the first spawn ([MERCHANT]warning names any that fail). StoryKit'snpcmerchanttestcarried the same names — fixed too. -
Wandering merchant (card #1):
Events/MerchantCardbuilds a per-spawn-unique StoryKitNpcSpec(Merchants faction, Mefino's trade backpack, caravan-trader shop, greeting/Shop/road-flavour dialogue), spawns it on a verified out-of-sight spot andMerchant/RoadWalkerwalks it to a zone exit via a carrot GO onAISWander.FollowTransform; despawns on arrival, stall (StuckSeconds), death or region change. Rescue edge (hit + combat ended) swaps the greeting to the thanks line.[Merchant]config section; verbsroadsmerchant,roadsexits,roadsstock;[MERCHANT]log tag;IEventCard.OnRegionChangedseam; CoreWalkProgress/RescueEdge+ tests. -
Event deck: each due tick draws a card (
[Events]DeckEnabled / AmbushWeight / MerchantWeight / MerchantCooldownSeconds). Ambush = card #0 viaEvents/AmbushCardover the unchangedWaveRunner.Run; merchant card registers later throughEventDirector.Register. Deck off or ambush-only = byte-identical to the old timer path (no roll pulled).roadsdeck [draw|<id>]verb;roadsstatusshows the last draw. Core:EventDeck,RoadRoute,MerchantText+ tests. -
Merchant review fixes: the SideLoader AI is built by a spawn COROUTINE frames after
Spawn()returns, soRoadWalkernow bindsAISWanderlazily on its tick (was: looked once at attach, found nothing, and the merchant never walked); the stuck clock is held during combat instead of merely skipped (a long fight no longer ends in an instant "stuck" despawn); every finished or refused merchant spec isNpcRegistry.Unregistered (no per-event registry/template growth). -
Deck review fixes: a card that throws (or ends without reporting) is routed to a soft outcome via
Core/EventGuardinstead of leavingRunningstuck; every weight at 0 now fires nothing (was: silently ran the ambush anyway); a lone card honours its own cooldown (ambush's is 0, so parity holds);RoadRoute.ThreadSeedstie order is stable (List.Sortis not, above 16 items).
0.1.3 — 2026-08-19
- Static-analysis wave F3c: leaf-kit fixes (A7-1/2/4/8/9/13/14/15/16/20/23/24/25/27)
- Coercion wave: every ForgeKit integration completes cleanly through the TUI seam
- Forge shell: catalog dump + response protocol + set/cfgdump in ForgeKit; forge CLI/REPL + completion packs; wiki-enriched name db
- DangerousRoads: re-enabling the kill-switch mid-region arms without a scene change
0.1.2 — 2026-08-11
- Fable-review fixes: viewID watermark reads outstanding ids (latched), corpse-release fallback never parks a neutralized view, FarCache InFlight can't leak, visual-pass retries count as busy, Notify header placement
- DangerousRoads far-cache: distance park + in-place restore with HP, hysteresis, roadscache verb + testplans for all three waves
- SpawnKit perf wave: hot-loop allocs removed, adaptive replica enforce, prune throttle, viewID watermark + opt-in corpse release, AI sleep radius, caps 8->12
- Merge branch 'main' into fix/sa-0808-kits
- Static-analysis 2026-08-08 wave D-kits: P2-13/P2-15 + the kit P3 group
- W4: fixes for everything D1 found, plus the join-race P1
- DangerousRoads 'stops at 6': MaxOwnActive default 6->8 + owner-visible caps
- DangerousRoads: fake-null cleanup in the squad scans and the UI dump
- A1: the fake-null accessors, and every Plugin.cs cleared
0.1.1 — 2026-08-02
- DangerousRoads 0.1.1 + SpawnKit 0.4.2: re-pin off CompanionKit 0.4.2
- Logging: answer "why did nothing happen?" without needing LogVerbose on in advance
- Cleanup: one anchor-collect loop, one ownership side-table
- DangerousRoads: exclude the whole Wolfgang family, and stop a fresh boot crashing roadsstatus
- Docs sweep: archive, condense, and validate the whole documentation tree
0.1.0 — 2026-08-02
- DangerousRoads: say what the creatures actually do, and cut the README to size
- DangerousRoads: Thunderstore release prep + icon
- Live-session findings + a DangerousRoads ambush-interval tweak
- DangerousRoads: cluster waves, compass blips, and stop combat starving the timer
- DangerousRoads: wandering encounters on SpawnKit
0.1.0 — initial release
- Wandering encounters in the six overworld regions. While you are out in Chersonese, Enmerkar Forest, Hallowed Marsh, Abrassar, Antique Plateau or Caldera, 1–3 hostile creatures turn up every 20–60 seconds, out of sight, 40–200 m away, and walk in. They fight, they die, and they drop their normal vanilla loot, because they are the ordinary wild version of that creature.
- Region-native species only, composed where possible from a real vanilla squad's roster, so a wave looks like something that already belonged there. Every member of a wave shares one species and therefore one faction — the thing that stops an "ambush" from fighting itself.
- Never in a town, never in a dungeon, never indoors. Cities, villages, dungeons, interiors and New Sirocco are all excluded, and an unrecognised area fails closed (no spawns) rather than open.
- Placement is the whole feature. Candidates come from a configurable chain of anchor sources — live creatures, gathering spots, vanilla's own hand-placed squad markers, world interactables, and a procedural fallback — and every one is put through the same verification: real navmesh ground, a flat plateau check that rejects tent roofs and boulder tops, a walking-vs-straight-line path ratio that rejects the far side of a gorge, and a line-of-sight test against every player.
- A throttled toast names the species, the count and the direction, and live spawns are marked on the game's HUD compass while they are within range.
- Never stalls the game. Only species whose body is already loaded are used; the rest are warmed quietly in the background between waves.
- Cooldown floor after each wave, a cap on how many creatures the mod may have alive at once, and a bounded hold-off while you are already in combat.
- Master kill-switch
[General] Enabled, and every knob is live-reloadable withreloadcfg— no relaunch needed to retune. - Dev/diagnostic verbs on
BepInEx/config/DangerousRoads_cmd.txt:roadsstatus,roadsroster,roadsledger,roadsanchors,roadsprobe,roadsmark,roadsnow,roadssweepand more. - Alpha. First live session passed 22 checks across three regions; the remaining three regions, co-op, and a couple of known issues are still open — see the README's Known issues.