Recent updates to the Shogun Exodus development pipeline introduce fully automated social media integration and image generation for devlog publishing. The automation workflow now utilizes AI models to produce custom sumi-e portrait assets on Washi paper textures, which are automatically stored in the media library and cross-posted to platforms such as X and Instagram alongside formatted summaries and update-specific hashtags.
On the technical front, several backend performance optimizations and diagnostic enhancements focus on frame rate stability for scaling castles. Pawn wander AI destination selection now employs tiered sampling to cap calculation costs as room counts grow. Furthermore, diagnostic profiler improvements helped isolate and eliminate a significant night-time performance hitch, replacing room-by-room actor scans in lighting and smoke systems with an optimized single-pass lookup utility.
Raw Patch Notes
UPDATE 2026-08-02 #159 - Social Devlog Images for X and Future Instagram Posts
- After WordPress publication and the Discord posting attempt, the devlog automation now generates a separate 4:5 1K portrait social image with `gemini-3.1-flash-image`. Its prompt includes the complete WordPress title, generated description, and raw patch notes, and requests title-bearing abstract sumi-e artwork on an old Washi-paper texture for the specific update.
- The generated image is converted to a high-quality JPEG, uploaded as a public WordPress Media Library item associated with the devlog for future Instagram publishing, then uploaded directly to X and attached to the normal text-and-link post. The existing 8:1 website featured-image flow and its intentionally selected model remain unchanged.
- Image generation, WordPress media storage, and X media upload each degrade independently: failures are logged and X falls back to the existing text-only post instead of blocking publication.
- Files: `.github/scripts/post_devlog.py`, `.github/workflows/devlog-automation.yml`, `DeveloperFiles/update-log.txt`.
UPDATE 2026-08-02 #160 - Wander Destination Tiered Sampling (Change 2, Scaling Headroom)
- Not a fix for an active bug: after #158 (room lookup deep-copy removal), a 60-second profiler run at 25 pawns / 47 rooms showed zero frames >16ms. This is proactive headroom for a larger late-game castle (2-3x current size, up to ~100 pawns), where `BasicWanderAI._pick_new_destination_impl`'s remaining cost — still a full scan of every room whenever an idle pawn picks a new wander target — would again scale linearly with room count and reintroduce the same class of stutter with no new feature to blame. Full design and reasoning in `DeveloperFiles/briefs/implemented_briefs/wander-destination-sampling-brief.txt` (moved there from `briefs/` now that it's shipped).
- Replaced the full-room scan with tiered, bounded random sampling: check `wander_sample_tier1_rooms` (default 3) random distinct rooms; if that tier is COMPLETELY empty of valid candidates, escalate to `wander_sample_tier2_extra_rooms` more (default 5, 8 cumulative); if still empty, `wander_sample_tier3_extra_rooms` more (default 10, 18 cumulative); if still empty, give up exactly like today's empty-candidate-list case (`_enter_idle()`, relying on the pawn's existing 0.4-1.4s per-pawn-jittered idle retry — no separate fallback mechanism was added on purpose, since that retry loop already is the safety net). All three are `@export` so they're tunable from the Inspector without a code change. New helpers: `_sample_wander_candidates` (the tier loop), `_partial_shuffled_room_indices` (a partial Fisher-Yates over room indices using a sparse swap-map so it costs O(rooms actually sampled), never O(total rooms) — critical, since materializing a full index array per pick would reintroduce the exact linear-in-castle-size cost this change exists to remove), `_evaluate_room_indices_for_wander` (the original per-room/per-point validity check, unchanged, now scoped to a subset of rooms instead of all of them).
- Distribution proof (not assumed — verified by tracing every validity check): `_candidate_walk_points_for_room` always returns exactly 3 points per room regardless of size, and every one of them passes validity for any unlocked room (the adjustment step and the clearance step use the identical margin formula). So today's "flat pool of every point in the castle" was ALREADY mathematically identical to "pick a random unlocked room, then a point in it" — every room contributes the same 3 tickets. Sampling a random subset therefore does not favour any room over another: by symmetry, each valid room's probability of being the final choice, aggregated over many picks, is unchanged regardless of sample size. Escalation is deliberately all-or-nothing per tier (only triggers when a WHOLE tier is empty, never "found fewer than desired") — this is what keeps that fairness argument intact, since each tier is itself an unbiased random draw picked from uniformly.
- Zero cost in the common case: with nothing locked (the normal state of the game), tier 1 always succeeds immediately — no unlocked room can ever fail validity, so escalation never fires unless something is actually under construction. Measured baseline (~0.12ms/room from the #158 test) puts tier 1 at ~0.36ms, and even a fully-escalated tier 3 (18 rooms) at ~2.16ms — comfortably under the 5ms spike threshold regardless of how large the castle grows, because 18 is a fixed ceiling, not a percentage of room count.
- Per-pawn independence is load-bearing and deliberate: the shuffle draws from each pawn's own pre-existing per-instance `_rng` (already independently seeded in `_ready`), never a fixed slice of the room array — a fixed slice would make every pawn on the same floor set evaluate the identical rooms, which is the one way this design could visibly misbehave (pawns dogpiling instead of spreading naturally) without showing up as a crash or an obvious short-test symptom.
- Gameplay unaffected by design, confirmed unchanged: `_pick_new_destination` still has only two callers (idle-timer expiry, spawn-time initial destination); GOAP, jobs, eating, sleeping, socialising, and corpse hauling all route through `_request_move_to_world`, untouched. The reactive mid-walk locked-room block (`_would_enter_locked_room`, checked ~48px ahead while already walking) is a completely separate mechanism from destination picking and is not touched by this change either way — a pawn can still be sent toward a room that turns out unreachable because an intervening room is locked, and will still stop and re-idle exactly as before. Small buildings (fewer rooms than tier 1) fall out for free: `needed` is capped at the room count, so a 1-2 room building behaves identically to today's full scan, not a special case.
- Diagnostics: added zero-cost occurrence counters via the existing `record_named_tick` "calls" mechanism (no new report plumbing) — `pawn_pick_dest_tier2_used`, `pawn_pick_dest_tier3_used`, `pawn_pick_dest_gave_up` — so a future large-castle test can directly confirm escalation stays rare rather than relying on the theoretical argument above.
- Files: `Scripts/BasicWanderAI.gd`, `DeveloperFiles/current-game-setup.txt`, `DeveloperFiles/briefs/wander-destination-sampling-brief.txt` (moved to `implemented_briefs/`), `DeveloperFiles/update-log.txt`.
UPDATE 2026-08-02 #161 - Automated Instagram Devlog Publishing
- The devlog automation now prepares an Instagram caption after the X posting attempt when both the published WordPress URL and the public WordPress-hosted social-image URL are available. It makes two separate Gemini calls from this repository: `gemini-3.5-flash-lite` produces the two-sentence summary from the website summary only, and `gemini-3.6-flash` produces two update-specific hashtags from the same summary. Two distinct random hashtags are then selected from the configured pool without duplicating either AI hashtag, and `#ShogunExodus` is appended last.
- The completed caption and existing 4:5 social-image URL are dispatched through GitHub's workflow-dispatch API to the separate `shogun-exodus-instagramposter` repository using `INSTAGRAM_POSTER_TOKEN`. The target repository's default branch is discovered through its metadata instead of being hardcoded. Instagram remains independent of X success, but is deliberately skipped when the public image URL is unavailable.
- Added flat staging files in `DeveloperFiles` for the separate repository. Its workflow reads `INSTAGRAM_ACCESS_TOKEN` and `INSTAGRAM_USER_ID`, then publishes through the Instagram API with Instagram Login at `graph.instagram.com`: create the image container, wait for processing to finish, and publish the finished container. The accompanying setup note documents the exact destination paths and required permissions.
- Files: `.github/scripts/post_devlog.py`, `.github/workflows/devlog-automation.yml`, `DeveloperFiles/instagram_poster.py` (new), `DeveloperFiles/instagram-poster-workflow.yml` (new), `DeveloperFiles/instagram-poster-setup.md` (new), `DeveloperFiles/update-log.txt`.
UPDATE 2026-08-02 #162 - Stutter Diagnostics: Frame Attribution Gaps (stats_dispatch + Tick Channels)
- Diagnostic-only changes, prompted by analysing the #160 night-crossing test. That run confirmed the wander sampling change was fully clean (pawn_pick_dest flat ~0.5ms, zero tier escalations the entire session) and that the lighting tick cost had already dropped ~4-5x from #158's fix (~99ms -> ~20-24ms) — but it also surfaced a SEPARATE, previously invisible cost: `pawn_physics`/`process_total` climbed sharply overnight (avg 2.9ms -> 7-11ms, one window hitting 690/900 frames >16ms) in a way none of the named per-frame labels explained — `placeables`/`reflections`/`parallax`/`camera`/`hud`/`prompts` summed to ~2-3ms against a measured `process_total` of ~25-27ms on the same frames.
- Root cause of the BLIND SPOT (not the cost itself): `StatsTickScheduler._process()` — which deliberately spreads batched Stats dispatch across frames (the #143 fix) — reported its per-frame cost only via a standalone `record_named_tick("stats_dispatch", ...)` call, never through `TickProfiler.add_frame_sample()`. `process_total`'s wall-clock measurement structurally included this cost (real, correctly measured), but it never appeared in the per-frame "who ran this frame" breakdown on big-hitch lines, since that breakdown is built from `_frame_accum`, which `record_named_tick` never touches. The gap could only be found by elimination (summing the visible labels and noticing they fell short), exactly the failure mode AGENTS.md already warns against.
- Fixed by routing `stats_dispatch` through `add_frame_sample()` instead of a standalone `record_named_tick()` call. Since `StatsTickScheduler._process()` runs exactly once per rendered frame, the aggregate numbers (calls/avg/max/spikes) are unchanged — only the reporting path changed, so no duplicate line was created. `TickProfiler.end_frame()` already converts every `_frame_accum` entry into a `record_named_tick(label + "/frame", ...)` call, so this now reports as `stats_dispatch/frame` (consistent with every other frame-aggregate label) and — critically — is included in the "who" breakdown string on any big-hitch line during which a Stats drain ran, closing the blind spot directly instead of requiring inference.
- Working theory this set up to confirm next: the same growth in per-actor status effects that drives the night-time lighting-tick cost may ALSO be inflating `StatsTickScheduler`'s per-actor dispatch cost, and its adaptive quota (a floor computed from delta that explicitly overrides its own frame_budget_usec ceiling, by design, to guarantee an on-time drain) could let that inflated cost feed back into a bigger quota next frame under sustained load.
- The follow-up night-crossing re-test showed the stats_dispatch fix worked (small, ~1.6-2.7ms, correctly ruling out the working theory above) but the same GAP remained: big-hitch lines still showed `process_total` ~24-27ms against labeled costs summing to ~3ms. Cross-referencing against the SAME report's "channel: lighting" section (avg ~21ms, max ~25ms) showed an almost exact magnitude match, and tracing the code confirmed why: `TickClock._on_timer_timeout()` times each channel's entire synchronous `emit_signal("tick", ...)` — which runs every connected listener's work (LightingSystem, SmokeSystem, etc.) — and reports it ONLY via `record_channel_tick()`, never `add_frame_sample()`. Since this fires from a `Timer.timeout` callback during Godot's idle-process phase (the same span FrameProbe measures as `process_total`), the cost was real and already visible in the "channel: " section, but invisible in the per-frame "who ran this frame" breakdown — the identical blind spot as `stats_dispatch`, just at the TickClock level instead of StatsTickScheduler, and affecting all four channels (`stats`, `stats_half`, `smoke`, `lighting`) uniformly rather than one system.
- Also investigated and ruled out as an alternative explanation for a downstream `ai_decide` cost cluster observed in the same re-test: bowl-contention GOAP replanning (multiple pawns simultaneously targeting the same empty food bowl with no reservation system — `_pick_best_empty_bowl()` picks independently per pawn, and the conflict is only discovered on arrival via `_is_valid_empty_bowl()` inside `_tick_action_fill_empty_bowl()`, forcing a cooldown + replan for every pawn but the one that got there first). Confirmed real in code, but left untouched — per-user, this is expected/intentional given the current test always spawns 25 pawns simultaneously with synchronized need timers, unlikely to reflect the shipped game's staggered-recruitment pacing, and explicitly out of scope for this investigation.
- Fixed the tick-channel gap by having `_on_timer_timeout()` feed the SAME measured `elapsed_usec` to both `record_channel_tick(channel_id, ...)` (unchanged) and `add_frame_sample(channel_id, ...)` (new). `TickProfiler.end_frame()` already converts every `_frame_accum` entry into `record_named_tick(label + "/frame", ...)`, so each channel now also reports as `stats/frame`, `stats_half/frame`, `smoke/frame`, `lighting/frame` — consistent with every other frame-aggregate label — and, critically, is included directly in the "who" breakdown string on any big-hitch line during which that channel ticked. Checked all ~20 other labels already in use across the codebase (BasicWanderAI, FrameProbe, StatsTickScheduler, and the various `_process`-timed UI/visual systems) for a name collision with `stats`/`stats_half`/`smoke`/`lighting` — none found.
- Files: `Autoload/StatsTickScheduler.gd`, `Autoload/TickClock.gd`, `DeveloperFiles/current-game-setup.txt`, `DeveloperFiles/update-log.txt`.
UPDATE 2026-08-02 #163 - Room Status Effect Generalization (Lighting + Smoke Fix)
- Fixes the night-time stutter precisely isolated by #162's TickClock instrumentation: `LightingSystem`'s debuff pass cost ~19-25ms every tick once enough rooms went dark (30/30 calls spiking), and `SmokeSystem` shared the identical code shape (cheap only because it hadn't been triggered by a real fire yet). Root cause: both systems looped over every room currently needing an effect and called `WorldIndex.get_actors_in_room(room_id)` per room — cost = (rooms needing an effect) x (actor count) x (total rooms, scanned inside each `is_actor_in_room` call). At night, rooms-needing-an-effect approaches total rooms, so cost approaches actors x rooms^2 — invisible in daytime testing (zero rooms need anything) and invisible at low darkness (few rooms need anything), then appearing sharply once a threshold of dark/smoky rooms is crossed. Full analysis, verified design, and an explicit multi-room tie-break re-check in `DeveloperFiles/briefs/implemented_briefs/room-status-effect-generalization-brief.txt`.
- Added `WorldBuildingRegistry.get_actors_by_room(actors)` / `WorldIndex.get_actors_by_room()`: computes "which actor(s) are in which room" ONCE for every room at once (cost actors x rooms, not rooms-needing-lookup x actors x rooms), reusing the existing `_actor_room_probe`/`_actor_overlaps_room` helpers unchanged so it cannot disagree with `is_actor_in_room` about who is in a room. Multi-room overlap semantics preserved exactly: an actor straddling a boundary appears in every room it overlaps, same as today. `get_actors_in_room` itself is untouched and remains the right tool for `ConstructionScaffold`'s single-room evacuation checks.
- Added `Scripts/World/RoomEffectApplier.gd`, a small `RefCounted` utility (not a new autoload — it only holds per-owner bookkeeping, no cross-system state) that owns the apply/diff/cleanup dance both systems had duplicated nearly line-for-line: given a `room_id -> effect_id` map (the caller's own domain decision) and the actors-by-room index, it applies new effects, leaves unchanged ones alone, and clears actors no longer in an affected room — calling `apply_status_effect`/`remove_status_effect` only on actual transitions, exactly as before. Each system (`LightingSystem`, `SmokeSystem`) owns one instance; instances never share state, so an actor can carry both a lighting AND a smoke debuff simultaneously without either clearing the other (each applier only ever touches its own source_id).
- `_update_lighting_debuffs` / `_update_smoke_status_effects` now: decide which rooms need an effect first (cheap, room-count only, decision logic byte-for-byte unchanged), short-circuit to `clear_all()` without ever calling `get_actors_by_room()` if no room needs anything (preserves and generalizes the existing "a fine room costs nothing" property to "nothing needing attention costs nothing"), then fetch `WorldIndex.get_actors_by_room()` once and hand both maps to the applier. `_lit_affected_actor_ids`/`_lit_effect_ids_by_actor_id`/`_set_actor_lighting_status`/`_remove_actor_lighting_status`/`_clear_all_lighting_effects` and their smoke equivalents are fully removed, replaced by the shared applier — confirmed zero other references anywhere in the codebase before deletion.
- Verified (not assumed) preservation of an incidental pre-existing edge case: an actor overlapping two rooms that both currently want a debuff has its final effect decided by whichever room is LAST in iteration order (each room visit unconditionally overwrites the actor's recorded effect, and — because each apply call fully removes-then-applies — that is also what lands on `Stats`). `desired_by_room` is built by iterating rooms in the exact same order the old combined loop did (skipping non-qualifying rooms via `continue`, not reordered for convenience), and `RoomEffectApplier.apply_desired()` iterates `desired_by_room` in that same order with the same unconditional overwrite — so this tie-break comes out identical to before.
- Generalization achieved: any future room-driven status effect only needs to decide its own room->effect_id map and own one `RoomEffectApplier` instance — no re-deriving the actor lookup or the apply/diff/cleanup bookkeeping. Explicitly NOT generalized, and written down for later per the brief: (1) the "what condition triggers an effect" decision stays hand-coded per system — a fully declarative/data-driven version (a modder defines a new room effect in JSON with no GDScript) is a real rule engine, not a refactor, and is better designed once 3+ real examples exist to generalize from; (2) `lighting` and `smoke` are not phase-staggered and may both call `get_actors_by_room()` independently on the same frame — trivially cheap at current scale (25 actors x 47 rooms), but a shared per-tick cache is a candidate future refinement if a third system or much larger scale ever makes the redundant computation matter.
- Files: `Scripts/World/WorldBuildingRegistry.gd`, `Autoload/WorldIndex.gd`, `Scripts/World/RoomEffectApplier.gd` (new), `Autoload/LightingSystem.gd`, `Autoload/SmokeSystem.gd`, `DeveloperFiles/current-game-setup.txt`, `DeveloperFiles/briefs/room-status-effect-generalization-brief.txt` (moved to `implemented_briefs/`), `DeveloperFiles/update-log.txt`.
*This is an automated post, using Python and LLMs to summarize game updates. Mistakes, while rare, may happen.

Leave a Reply