# Tutorial System Architecture This document describes how the Unity client's tutorial system is wired together: the classes involved, the trigger catalog, the step lifecycle, and the expected first-session flow. It complements two existing docs: - **`TUTORIAL_CONTENT.md`** — human-facing copy for tutorial steps and dialogues - **`TUTORIAL_BATTLE_SYSTEM.md`** — the scripted opening battle at Onmaa It also overlaps slightly with the in-tree `Assets/Tutorial/TUTORIAL_PLAN.md`, which is an older implementation plan. All paths below are relative to `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/`. --- ## Current State (important) There are **two parallel subsystems** that share the same trigger plumbing: | Subsystem | Status | Lives in | |-----------|--------|----------| | Step-based tutorial UI (modals, overlays, hints) | **Dormant** | `Tutorial/TutorialManager.cs`, `Tutorial/UI/*`, `Tutorial/Content/*` | | Narrative dialogue system | **Live** | `Tutorial/Dialogue/*`, `Resources/Dialogues/*.json` | `TutorialContentDefinitions.RegisterAll()` returns early with the comment *"Old tutorial content suppressed — replaced by narrative dialogue system."* As a result: - `OnboardingSequence` is never assigned, so `StartOnboarding()` no-ops at the "No onboarding sequence assigned" branch. - No contextual tutorial sequences are registered with the trigger registry. - The trigger registry still fires events normally, but nothing on the step-UI side is listening. - `DialogueManager` consumes those same events and matches them against scripts in `Resources/Dialogues/` (`tutorial_strategic.json`, `tutorial_battle.json`). - Dialogue completion is persisted per game through `TutorialDialogueProgressStore`, so completed scripts do not replay after a reconnect or scene reload for the same game. **In practice today, the entire active tutorial experience runs through the dialogue system.** The step-UI machinery is preserved for future use — content definitions still exist below the early return in `TutorialContentDefinitions.cs` for reference. --- ## Class Map ### Orchestration - **`Tutorial/TutorialManager.cs`** — Singleton. Owns `OnboardingSequence`, the active step queue, the trigger registry, and the dialogue manager handle. Initialized by `EagleGameController.SetUpGame()` and `ShardokGameController.SetUpGame()`. - **`Tutorial/TutorialState.cs`** — PlayerPrefs persistence. Tracks `OnboardingCompleted`, `OnboardingStepReached`, completed sequence IDs (HashSet for O(1) lookup), dismissed hints, and a global "tutorials disabled" preference. - **`Tutorial/TutorialTargetRegistry.cs`** — Maps string IDs to `RectTransform`s for highlighting. Static targets are Inspector-assigned (`ProvinceInfoPanel`, `SupportField`, `CommitButton`, etc.); dynamic targets register at runtime (e.g., hero rows from `HeroesAndBattalionsPanelController`). - **`Tutorial/TutorialDialogueCoordinator.cs`** — Recomputes durable dialogue triggers from the current strategic model and sends them directly to `DialogueManager`. This covers reconnects, phase-start tutorial games, and strategic beats that should be recoverable from state. ### Triggers - **`Tutorial/Triggers/TutorialTriggerRegistry.cs`** (~1100 lines) — Routes game events to both the step UI (when sequences are registered) and `DialogueManager`. Maintains one-shot session flags so the same first-encounter trigger doesn't fire twice. ### Content (step UI — dormant) - **`Tutorial/Content/TutorialStep.cs`** — Per-step record: `DisplayMode`, `CompletionType`, target path, copy, panel anchor, highlight options. - **`Tutorial/Content/TutorialSequence.cs`** — `ScriptableObject` holding ordered `TutorialStep`s plus `IsOnboarding` flag and lifecycle callbacks. - **`Tutorial/Content/TutorialContentDefinitions.cs`** — Static class that *would* register all sequences. Currently short-circuits before any registration. ### UI (step UI — dormant) - **`Tutorial/UI/TutorialUIManager.cs`** — Coordinates which presenter renders each step. - **`Tutorial/UI/TutorialCanvasBuilder.cs`** — Builds the Canvas at runtime (no prefab dependency). - **`Tutorial/UI/TutorialModalPanel.cs`** — Full-screen blocking modal. - **`Tutorial/UI/TutorialOverlayController.cs`** + **`TutorialOverlayBuilder.cs`** — Dimmer with highlighted cutout, gold border, pulsing animation, and adjacent tooltip text. - **`Tutorial/UI/TutorialHintIndicator.cs`** — Stub for pulsing-dot mode. ### Dialogue (live) - **`Tutorial/Dialogue/DialogueManager.cs`** — Loads all `Resources/Dialogues/*.json` scripts at startup, indexes by trigger ID, drives the panel. - **`Tutorial/Dialogue/DialogueScript.cs`** / **`DialogueStep.cs`** — JSON-shaped records for scripts and steps. - **`Tutorial/Dialogue/DialoguePanelController.cs`** — Renders the speaker headshot, body text, instruction line, optional highlight target, and Continue button. - **Scripts:** `Assets/Resources/Dialogues/tutorial_strategic.json`, `tutorial_battle.json`. --- ## Step Lifecycle (Step-UI System) 1. **Triggered** — `TutorialTriggerRegistry` raises an event (e.g., `game_started`, `battle_entered`). 2. **Queued or shown** — If no active sequence, show immediately. If an active step is hidden (`DisplayMode.None`), interrupt it. If both new and active are command tutorials, interrupt for responsiveness. Otherwise queue. 3. **Rendered** — `TutorialUIManager.ShowCurrentStep()` dispatches to the modal/overlay/hint presenter based on `DisplayMode`. 4. **Awaiting completion** — One of: - `ButtonClick` — user dismisses - `GameEvent` — wait for a named event (e.g., `province_selected`) - `Timer` — auto-advance after delay - `UIInteraction` — wait for the highlighted target to be interacted with - `Condition` — custom predicate (rare) 5. **Advance** — `AdvanceStep()` moves to the next step or completes the sequence; completion writes to `TutorialState`. Hidden steps (`DisplayMode.None`) deliberately don't block — they exist so contextual tutorials can fire while the onboarding sequence is waiting on an async event. --- ## Display Modes | Mode | Renderer | Notes | |------|----------|-------| | **Modal** | `TutorialModalPanel` | Full blocking dialog with dim background | | **Overlay** | `TutorialOverlayController` | Dimmer with cutout + tooltip near a target | | **Tooltip** | `TutorialOverlayController` | Currently same renderer as overlay | | **Hint** | `TutorialHintIndicator` | Pulsing dot only (stub) | | **None** | (invisible) | Waits for a completion event | Panels can be anchored via `TutorialPanelAnchor` (`Center` / `Left` / `Right` / `Top` / `Bottom`). --- ## Trigger Catalog Most event-style triggers are raised via `TutorialTriggerRegistry`. Durable strategic dialogue beats are also recomputed by `TutorialDialogueCoordinator` on each model update and sent directly to `DialogueManager.TriggerDialogue()`. Both paths match against the dialogue JSON; if step-UI sequences are re-registered, only the registry path will route to them. File:line citations are for the registry unless noted; line numbers may drift. ### Bootstrap / first-session | Trigger | Fires from | When | |---------|-----------|------| | `game_started` | `TutorialDialogueCoordinator.EligibleTriggers()` | Any tutorial strategic model update; dialogue progress keeps it from replaying | | `first_battle_available` | `OnModelUpdated()` ~L204 | A `RunningShardokGameModel` first appears | | `battle_entered` | `OnBattleEntered()` ~L793 | User clicks **Fight!** | | `tutorial_battle_ended` | `CheckTutorialBattleEnded()` ~L594 | Tutorial battle removed from running models | | `tutorial_rebuild_support` | `CheckTutorialRebuildSupport()` ~L616 | Captured-heroes phase done | | `tutorial_taxes_collected` | `CheckTaxesCollected()` ~L680 | New Year action with positive tax delta | | `tutorial_in_town` | `CheckInTown()` ~L500 | Return command becomes available | ### Strategic-map contextual | Trigger | Site | When | |---------|------|------| | `province_selected` | `EagleGameController.ProvinceWasSelected()` | Province click | | `command_issued` | `EagleGameController.PostCommittedCommand()` | User commits | | `diplomacy_available` | `CheckStrategicCommandsAvailable()` ~L243 | Diplomacy command appears | | `weather_control_available` | `CheckStrategicCommandsAvailable()` ~L249 | Weather command appears | | `hero_recruitment_available` | `CheckHeroRecruitmentAvailable()` ~L266 | Free heroes detected | | `tutorial_ready_to_join` | `CheckReadyToJoinHero()` ~L479 | Free hero with `WouldJoin` | | `profession__encountered` | `CheckProfessionTutorial()` ~L726 | First time seeing each profession | ### Strategic guidance (state-derived, conditions checked each model update) | Trigger | Site | Condition | |---------|------|-----------| | `guidance_loyalty_danger` | `CheckLoyaltyDanger()` ~L339 | November + hero loyalty < 70 | | `guidance_neighbor_danger` | `CheckNeighborDanger()` ~L383 | Hostile faction adjacent | | `guidance_recruit_heroes` | `CheckRecruitHeroesGuidance()` ~L420 | Province with support ≥40 has free heroes | | `guidance_expand` | `CheckExpandGuidance()` ~L449 | One stable province, hasn't expanded | | `guidance_sworn_kinship` | `CheckSwornKinshipGuidance()` ~L549 | Good candidate or 4+ provinces | | `tutorial_loyalty_warning` | `CheckTutorialLoyaltyWarning()` ~L634 | November + hero loyalty < 70 | | `tutorial_support_deadline` | `CheckTutorialSupportDeadline()` ~L654 | December + province support < 40 | ### Strategic action-result triggers (fired from `OnStrategicActionResult()`) | Trigger | Site | When | |---------|------|------| | `hero_stat_gained` | ~L281 | `HeroStatGained` action result | | `hero_profession_gained` | ~L284 | `ProfessionGained` action result | | `tutorial_faction_appears` | ~L287 | `TutorialFactionAppears` action — the Fracture Covenant landing | | `tutorial_hero_faction_appears` | ~L290 | `TutorialHeroFactionAppears` action; has a four-step strategic dialogue today | | `tutorial_hero_departed` | ~L299 | First `HeroesDeparted` action against another player (King's hero abandons service) | | `tutorial_hero_departed_again` | ~L302 | Second `HeroesDeparted` in a *different* month than the first | ### Tactical (battle) — abilities and spells Combat triggers are *paced*: `_combatTutorialPending` ensures only one fires per action, with priority `archery > duel > melee > charge > fire`. | Trigger | Site | When | |---------|------|------| | `shardok_placement_started` | `OnBattleEntered()` ~L796 | Battle in setup phase | | `shardok_battle_started` | `OnBattleEntered()` ~L793 | Battle begins | | `shardok_battle_running` | `OnTacticalCommandsAvailable()` ~L948 | Player's first turn | | `archery_available` | `OnTacticalCommandsAvailable()` ~L990 | Archery available | | `melee_available` | `OnTacticalCommandsAvailable()` ~L991 | Melee available | | `duel_available` | `OnTacticalCommandsAvailable()` ~L1005 | Duel available (non-Tarn target) | | `ability_charge_available` | `OnTacticalCommandsAvailable()` ~L1001 | Charge for Old Marek | | `start_fire_available` | `OnTacticalCommandsAvailable()` ~L1021 | Start Fire on enemy | | `hide_available` | `OnTacticalCommandsAvailable()` ~L985 | Hedrick can hide | | `thunderstorm` | `OnTacticalCommandsAvailable()` ~L1056 | Weather is thunderstorm | | `spell_lightning_available` | `OnTacticalCommandsAvailable()` ~L960 | Lightning available | | `spell_meteor_available` | `OnTacticalCommandsAvailable()` ~L963 | Meteor available | | `spell_holywave_available` | `OnTacticalCommandsAvailable()` ~L965 | Holy Wave available | | `spell_raisedead_available` | `OnTacticalCommandsAvailable()` ~L969 | Raise Dead available | | `spell_lightning_cast` / `spell_meteor_cast` / `spell_holywave_cast` / `spell_raisedead_cast` | `CheckTacticalActionType()` ~L830-838 | Spell action observed | | `ability_charge_used` | `CheckTacticalActionType()` ~L842 | Charge attack executed | | `terrain_fire_encountered` | `CheckTacticalActionType()` ~L864 | Fire damage / spread | | `terrain_water_encountered` | `CheckTacticalActionType()` ~L867 | Water crossing | | `engineer_near_enemy` | `CheckEngineerNearEnemy()` ~L1105 | John Ranil within 3 hexes of enemy | | `tutorial_reinforcement_engineer` / `tutorial_reinforcement_paladin` | `CheckTacticalActionType()` ~L857 | Reinforcement arrival | | `friendly_hero_captured` | `CheckHeroRemovals()` ~L919 | Friendly hero unit removed | | `enemy_hero_captured` | `CheckHeroRemovals()` ~L928 | Enemy hero unit removed | | `battle_action` | `ShardokGameController.OnBattleAction()` | Any action result | | `turn_ended` | `ShardokGameController.OnTurnEnded()` ~L1137 | Battle turn ends | | `shardok_battle_reset` | `ShardokGameController.cs:627` (fired *directly* to `DialogueManager`, bypassing the registry) | Battle retry; the registry's battle flags and the battle dialogue scripts' completed-set are reset alongside, so per-battle tutorials re-fire | ### One-shot semantics Most contextual triggers are guarded by booleans on the registry (e.g., a `_diplomacyShown` flag). These are session-local — they don't persist via `TutorialState`, but the registry has `ResetBattleTriggerFlags()` for replays. The strategic completion list *does* persist across sessions. --- ## Highlight Targets Steps reference UI by string ID through `TutorialTargetRegistry`: 1. Static targets are wired in the Inspector on the registry. 2. Dynamic targets register at runtime (`RegisterTarget(id, rectTransform)`). 3. Lookup falls back to `GameObject.Find()` if not registered. Step options: - `TargetGameObjectPath` — primary highlight - `AdditionalHighlightTargets[]` — multiple at once - `HighlightBoundsFromChildren` — use children's bounding box - `HighlightPulsing` — strobe animation Dialogue scripts use the same registry via `highlightTarget` (and `persistHighlight: true` to keep the highlight after the dialogue closes). --- ## Sequences vs. Contextual Dispatch - **Onboarding sequence** (`IsOnboarding = true`) — Single linear sequence, started by `StartOnboarding()`. Resumes from `OnboardingStepReached`. Counts only visible steps for progress. - **Contextual** (`IsOnboarding = false`) — Single- or multi-step, dispatched on demand by `TriggerContextualTutorial()`. - Dispatch rules in `TriggerContextualTutorial()`: 1. No active sequence → show immediately. 2. Active step is hidden (`None`) → interrupt. 3. Both are command tutorials → interrupt. 4. Otherwise → queue. --- ## Dialogue System (Currently Live) `DialogueManager` is a parallel system, not a step-UI subclass. It loads every `TextAsset` in `Resources/Dialogues/` at startup, parses them as `DialogueScript` objects, and indexes by `trigger`. Script shape (see `tutorial_strategic.json`): ```json { "scripts": [{ "id": "tutorial_opening", "trigger": "game_started", "panelPosition": null, "steps": [{ "speakerName": "Old Marek the Learned", "speakerImagePath": "fixed/old_marek_the_learned.png", "dialogueText": "...", "instructionText": "Click Fight! to enter tactical combat.", "highlightTarget": "FightButton", "persistHighlight": true, "highlightProvince": "Onmaa", "completionEvent": null }] }] } ``` When a registry trigger fires, `OnGameEvent()` calls `DialogueManager.TriggerDialogue(triggerId)`. If a script matches and hasn't completed, it queues (or shows immediately) and the panel renders speaker headshot + body + instruction. Steps can pause for a `completionEvent` (e.g., wait for the player to click the highlighted button), allowing the dialogue to chain across game state changes. Dialogue panel position defaults sensibly per scene (`top` during combat) and can be overridden per script. --- ## Persistence `TutorialState` (PlayerPrefs JSON): - `OnboardingCompleted` (bool) - `OnboardingStepReached` (int) - `CompletedTutorials` (List → HashSet at load) - `DismissedHints` (List → HashSet at load) - `AllTutorialsDisabled` (bool) Reset paths: - `TutorialState.Reset()` clears everything. - Entering a tutorial game (`GameType.Tutorial`) resets `TutorialState`; dialogue replay is controlled separately by the per-game dialogue progress key. - Settings → "Reset Tutorials" calls `TutorialManager.ResetAllProgress()`, which also calls `DialogueManager.ResetCompletedScripts()`. `TutorialDialogueProgressStore` separately stores completed dialogue script IDs per game under PlayerPrefs keys named `Eagle0_TutorialDialogueProgress_`. `DialogueManager` keeps the active game's completed script IDs in memory and saves them through `TutorialDialogueProgressStore`. `ResetCompletedScripts()` clears the current game's dialogue progress; `ClearCurrentGameProgress()` deletes the current game's persisted dialogue key. --- ## Bootstrap 1. `TutorialManager` is in the scene as a singleton; `Awake` loads state and creates the trigger registry. `RegisterAll()` is called here but currently no-ops. 2. `ConnectionHandler` sets `IsTutorialGame` and `IsMultiplayerGame` when the game is created/joined. 3. `EagleGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(this, null)`. If `IsTutorialGame && !OnboardingCompleted`, it would call `StartOnboarding()` — currently a no-op because `OnboardingSequence` is null. 4. The first model update raises `game_started`. `DialogueManager` matches `tutorial_opening` from `tutorial_strategic.json` and the dialogue runs. 5. `ShardokGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(null, this)` when battle starts; raises `battle_entered` and battle-only triggers from there. --- ## Expected Tutorial Flow (today) Driven by `tutorial_strategic.json` + `tutorial_battle.json`. Old Marek the Learned narrates almost everything. ### Narrative arc You play **Sadar Rakon**, formerly Ikhaan Tarn's most trusted lieutenant. Tarn's behavior grew erratic, the King wouldn't listen, so Sadar broke off and raised the **Reclamation**. He's been pushed back to **Onmaa** for a last stand against Tarn's superior royal army. After surviving, the real twist arrives: a new invasion — **The Fracture Covenant**, led by a mysterious figure called *The Eagle* — lands ships on the western coast and starts taking provinces. Heroes from the King's own service mysteriously begin to desert. The Reclamation's framing pivots from "rebellion" to "the only force that can stop the actual invasion." ### Strategic-map flow (`tutorial_strategic.json`) | # | Trigger | What happens | |---|---------|--------------| | 1 | `game_started` | **Opening monologue** (3 steps): Sadar's backstory, the Reclamation, last stand at Onmaa. Ends highlighting the **Fight!** button (`FightButton`, `persistHighlight`) | | 2 | `tutorial_battle_ended` | **Aftermath**: Tarn has *vanished*; you've captured his lieutenants. Recruit them or hold them | | 3 | `tutorial_rebuild_support` | **Rebuild Onmaa** (3 steps): Marek frames it; **John Ranil** introduces *Improve* (engineer bonus); **Elena Fyar** introduces *Give Alms* (paladin bonus). Goal: 40 support by January | | 4 | `tutorial_loyalty_warning` | November-ish, low-loyalty hero may leave at year end → use *Give Gold* / *Feast* | | 5 | `tutorial_support_deadline` | December nag if support < 40: have Elena give alms now | | 6 | `guidance_expand` (script `tutorial_expansion`) | Once support is stable: keep developing, taxes coming in January | | 7 | `tutorial_taxes_collected` | January: gold/food collected. Now expand — March your warlord + most heroes to a neighbor, leave 1–2 behind | | 8 | `tutorial_ready_to_join` | A free hero in `{provinceName}` is ready to recruit → Visit Town + Recruit | | 9 | `tutorial_in_town` | When you Visit Town: explains Trade / Arm Troops / Divine / Recruit / Manage Prisoners; *Return* ends the turn | | 10 | `tutorial_faction_appears` | **The twist**: Fracture Covenant lands at Ingia and Soria. *The Eagle* commands them. Tarn may be with them | | 11 | `tutorial_hero_departed` | First King's hero abandons service — "something deeper is at work" | | 12 | `tutorial_hero_departed_again` | Second one in a different month — "something is wrong, I can feel it" (foreshadowing) | | 13 | `tutorial_hero_faction_appears` | John Ranil and Elena Fyar leave Sadar's service and reappear as independent tutorial factions | ### Tactical battle flow (`tutorial_battle.json`) | # | Trigger | What happens | |---|---------|--------------| | 1 | `shardok_placement_started` | **Placement** (3 steps): identify Tarn's units (knights, heavy infantry, dragoons), color legend (red/blue/black), placement instructions, highlight Commit | | 2 | `shardok_battle_running` | **Turn 1 strategy** (2 steps): victory conditions for both sides, advice to hold castles and End Turn | | 3 | `archery_available` | First archery opportunity → purple outlines, longbows vs. armor | | 4 | `melee_available` | First melee opportunity → right-click adjacent enemy | | 5 | `ability_charge_available` | Charge mechanics (Marek hams it up: "old scholar without a horse") | | 6 | `start_fire_available` | Start Fire on enemy hex, fire spread mechanics | | 7 | `thunderstorm` | Weather: archery disabled, fires extinguished | | 8 | `tutorial_reinforcement_paladin` | **Elena Fyar arrives** mid-battle (2-step exchange) → Paladin profession intro (Holy Wave) | | 9 | `tutorial_reinforcement_engineer` | **John Ranil arrives** (2-step exchange) → Engineer profession intro | | 10 | `engineer_near_enemy` | Once Ranil is close to enemy: Fortify + Reduce (siege bombardment) | | 11 | `duel_available` | Champion duel mechanics (Marek warns *not* to duel Tarn himself) | | 12 | `hide_available` | Hedrick the Hedge-merchant: forest/swamp Hide + ambush | | 13 | `enemy_hero_captured` / `friendly_hero_captured` | Capture mechanics, with `{heroName}` substitution | | 14 | `shardok_battle_reset` | If you lose and replay: Marek "had the strangest sensation… as though we'd already fought this battle, and lost" — fourth-wall-adjacent retry framing | Most remaining "Tutorial / first-session onboarding" items in `SMALL_EAGLE_TODO.md` (Shardok tutorial, narrative hook, first-session goal, early small victory, guided first scenario vs. sandbox) map to *adding new dialogue scripts* against existing trigger IDs, or — if the step-UI is revived — to populating `TutorialContentDefinitions.RegisterAll()` and removing the early return. --- ## Notable Behaviors and Gotchas - **`IsTutorialGame` gates everything.** Outside tutorial games, both subsystems short-circuit early. - **Combat tutorials are deliberately paced.** Don't expect every available-ability trigger to fire on its first eligible turn — the registry intentionally spaces them. - **One-shot flags are session-local.** A trigger that "already fired" will not re-fire even if `TutorialState` is reset, until the registry is recreated. - **`TutorialTargetRegistry` lookups silently fall back to `GameObject.Find`.** Typos in target IDs will render with no highlight rather than throwing. - **`TutorialContentDefinitions.RegisterAll()` returning early is load-bearing.** If the step UI is revived without auditing trigger overlap, dialogue and step-UI tutorials may double-fire on the same trigger. - **`Tutorial/TUTORIAL_PLAN.md`** in the Unity project is an older implementation plan and partially overlaps this doc.