diff --git a/bridge/tests/test_tutorial_flow.py b/bridge/tests/test_tutorial_flow.py new file mode 100644 index 0000000..a088f16 --- /dev/null +++ b/bridge/tests/test_tutorial_flow.py @@ -0,0 +1,229 @@ +"""Tests for tutorial / campaign flow state machine + scenario JSONs. + +PRD acceptance #9: Tutorial completes <10 min, transitions to long-running campaign. +PRD acceptance #20: Tutorial→campaign handoff works in same session. +""" + +import json +from pathlib import Path + +import pytest + +from bridge.tutorial import ( + CAMPAIGN_ACTIVE, + DEFAULT_TUTORIAL_FILENAME, + NOT_STARTED, + TUTORIAL_ACTIVE, + TUTORIAL_COMPLETE, + TutorialFlow, + TutorialFlowError, + TutorialStore, + scenario_to_load, +) + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCENARIO_ROOT = REPO_ROOT / "mod" / "ClaudeKingdoms" / "scenarios" + + +# ──────────────────────────────────────────────────────────────────────── +# State machine +# ──────────────────────────────────────────────────────────────────────── + +def test_initial_state_is_not_started(): + flow = TutorialFlow() + assert flow.state == NOT_STARTED + assert flow.turns_in_tutorial == 0 + assert flow.is_ready_for_campaign() is False + + +def test_full_happy_path_transitions(): + flow = TutorialFlow() + flow.transition(TUTORIAL_ACTIVE) + assert flow.started_at is not None + flow.record_tutorial_turn() + assert flow.turns_in_tutorial == 1 + flow.transition(TUTORIAL_COMPLETE) + assert flow.completed_at is not None + assert flow.is_ready_for_campaign() is True + flow.transition(CAMPAIGN_ACTIVE) + assert flow.state == CAMPAIGN_ACTIVE + + +def test_skip_tutorial_path(): + flow = TutorialFlow() + flow.transition(CAMPAIGN_ACTIVE) + assert flow.state == CAMPAIGN_ACTIVE + assert flow.completed_at is not None # set even on skip + + +def test_illegal_transition_raises(): + flow = TutorialFlow() + with pytest.raises(TutorialFlowError): + flow.transition(TUTORIAL_COMPLETE) # not_started → complete is illegal + + +def test_record_turn_only_in_tutorial_active(): + flow = TutorialFlow() + flow.record_tutorial_turn() # NOT_STARTED — should be a no-op + assert flow.turns_in_tutorial == 0 + flow.transition(TUTORIAL_ACTIVE) + flow.record_tutorial_turn() + flow.record_tutorial_turn() + assert flow.turns_in_tutorial == 2 + flow.transition(TUTORIAL_COMPLETE) + flow.record_tutorial_turn() # past the tutorial — no-op + assert flow.turns_in_tutorial == 2 + + +def test_replay_tutorial_after_complete(): + flow = TutorialFlow() + flow.transition(TUTORIAL_ACTIVE) + flow.transition(TUTORIAL_COMPLETE) + flow.transition(TUTORIAL_ACTIVE) # replay allowed + assert flow.state == TUTORIAL_ACTIVE + + +def test_campaign_is_terminal_no_back_to_tutorial(): + flow = TutorialFlow() + flow.transition(CAMPAIGN_ACTIVE) + with pytest.raises(TutorialFlowError): + flow.transition(TUTORIAL_ACTIVE) + + +# ──────────────────────────────────────────────────────────────────────── +# Persistence +# ──────────────────────────────────────────────────────────────────────── + +def test_tutorial_store_round_trip(tmp_path): + store = TutorialStore(tmp_path / "state") + flow = TutorialFlow() + flow.transition(TUTORIAL_ACTIVE) + flow.record_tutorial_turn() + store.save(flow) + + reloaded = TutorialStore(tmp_path / "state").load() + assert reloaded.state == TUTORIAL_ACTIVE + assert reloaded.turns_in_tutorial == 1 + assert reloaded.started_at is not None + + +def test_tutorial_store_default_filename_when_dir(tmp_path): + store = TutorialStore(tmp_path / "state") + assert store.path.name == DEFAULT_TUTORIAL_FILENAME + + +def test_tutorial_store_explicit_path(tmp_path): + custom = tmp_path / "custom.json" + store = TutorialStore(custom) + assert store.path == custom + + +def test_tutorial_store_load_missing_returns_default(tmp_path): + store = TutorialStore(tmp_path / "state") + flow = store.load() + assert flow.state == NOT_STARTED + + +def test_tutorial_store_load_corrupt_returns_default(tmp_path): + path = tmp_path / "tutorial_state.json" + path.write_text("not json", encoding="utf-8") + flow = TutorialStore(path).load() + assert flow.state == NOT_STARTED + + +def test_tutorial_store_reset_removes_file(tmp_path): + store = TutorialStore(tmp_path / "state") + flow = TutorialFlow() + flow.transition(TUTORIAL_ACTIVE) + store.save(flow) + assert store.path.exists() + store.reset() + assert not store.path.exists() + + +def test_atomic_write_no_temp_files_on_disk(tmp_path): + store = TutorialStore(tmp_path / "state") + flow = TutorialFlow() + flow.transition(TUTORIAL_ACTIVE) + store.save(flow) + leftover = list(store.path.parent.glob(".tutorial_*")) + assert leftover == [] + + +# ──────────────────────────────────────────────────────────────────────── +# scenario_to_load +# ──────────────────────────────────────────────────────────────────────── + +def test_scenario_loader_returns_tutorial_when_not_yet_done(): + flow = TutorialFlow() + assert scenario_to_load(flow) == "Tutorial" + flow.transition(TUTORIAL_ACTIVE) + assert scenario_to_load(flow) == "Tutorial" + + +def test_scenario_loader_returns_campaign_after_completion(): + flow = TutorialFlow() + flow.transition(TUTORIAL_ACTIVE) + flow.transition(TUTORIAL_COMPLETE) + assert scenario_to_load(flow) == "LongRunningCampaign" + + +def test_scenario_loader_returns_campaign_after_skip(): + flow = TutorialFlow() + flow.transition(CAMPAIGN_ACTIVE) + assert scenario_to_load(flow) == "LongRunningCampaign" + + +# ──────────────────────────────────────────────────────────────────────── +# Scenario JSON files (mod-side content) +# ──────────────────────────────────────────────────────────────────────── + +def test_tutorial_scenario_files_parse(): + for name in ("scenario.json", "Map.json"): + path = SCENARIO_ROOT / "Tutorial" / name + assert path.exists(), f"missing {path}" + json.loads(path.read_text(encoding="utf-8")) + + +def test_campaign_scenario_files_parse(): + for name in ("scenario.json", "Map.json"): + path = SCENARIO_ROOT / "LongRunningCampaign" / name + assert path.exists(), f"missing {path}" + json.loads(path.read_text(encoding="utf-8")) + + +def test_tutorial_scenario_specifies_under_ten_minutes(): + """PRD acceptance #9 anchor.""" + data = json.loads((SCENARIO_ROOT / "Tutorial" / "scenario.json").read_text()) + assert data["tutorialFlags"]["expectedDurationMinutes"] <= 10 + + +def test_tutorial_scenario_handoff_target_is_campaign(): + data = json.loads((SCENARIO_ROOT / "Tutorial" / "scenario.json").read_text()) + assert data["claudeKingdomsMeta"]["handoffTarget"] == "LongRunningCampaign" + + +def test_campaign_scenario_has_no_forced_end_condition(): + """PRD acceptance #18: no forced end.""" + data = json.loads((SCENARIO_ROOT / "LongRunningCampaign" / "scenario.json").read_text()) + assert data["victoryConditions"] == [] + assert data["campaignFlags"]["noForcedEnd"] is True + + +def test_campaign_scenario_supports_no_decay(): + """PRD acceptance #16, BS-6: state preserved on close.""" + data = json.loads((SCENARIO_ROOT / "LongRunningCampaign" / "scenario.json").read_text()) + assert data["campaignFlags"]["noDecayOnClose"] is True + + +def test_tutorial_map_has_a_starting_city(): + """The tutorial must place exactly one city — PRD: 'one city, one session, one turn'.""" + data = json.loads((SCENARIO_ROOT / "Tutorial" / "Map.json").read_text()) + cities = [] + for row in data["tiles"]: + for tile in row: + if tile.get("city"): + cities.append(tile["city"]) + assert len(cities) == 1 + assert cities[0] == "Camelot" diff --git a/bridge/tutorial.py b/bridge/tutorial.py new file mode 100644 index 0000000..65d3547 --- /dev/null +++ b/bridge/tutorial.py @@ -0,0 +1,173 @@ +"""Tutorial / campaign handoff state machine (PRD acceptance #9, #20). + +Tracks where a player is in the tutorial-to-campaign flow: + + not_started → tutorial_active → tutorial_complete → campaign_active + +The PRD pins the tutorial deliverable as: 'Under ten minutes. One city, +one session, one turn, one reward. Shows the emotional payoff before +explaining mechanics.' Once the player achieves the first scripted +turn-reward, the flow advances to `tutorial_complete`. The next time +the bridge daemon runs (or the user explicitly transitions), the flow +moves to `campaign_active` and stays there. + +The state is persisted as a small JSON file (separate from the save +exchange) so: + * tutorial completion survives close/reopen (PRD §BS-6 no-decay), + * the Unciv mod can poll it at scenario-load time to decide which + map to load. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + + +DEFAULT_TUTORIAL_FILENAME = "tutorial_state.json" + + +# ──────────────────────────────────────────────────────────────────────── +# State machine +# ──────────────────────────────────────────────────────────────────────── + +NOT_STARTED = "not_started" +TUTORIAL_ACTIVE = "tutorial_active" +TUTORIAL_COMPLETE = "tutorial_complete" +CAMPAIGN_ACTIVE = "campaign_active" + + +VALID_TRANSITIONS = { + NOT_STARTED: {TUTORIAL_ACTIVE, CAMPAIGN_ACTIVE}, # can skip tutorial + TUTORIAL_ACTIVE: {TUTORIAL_COMPLETE, NOT_STARTED}, # complete or restart + TUTORIAL_COMPLETE: {CAMPAIGN_ACTIVE, TUTORIAL_ACTIVE}, # graduate or replay + CAMPAIGN_ACTIVE: {CAMPAIGN_ACTIVE}, # terminal — campaign is the long-running mode +} + + +class TutorialFlowError(Exception): + """Raised on illegal state transitions.""" + + +@dataclass +class TutorialFlow: + """In-memory tutorial flow state — persisted via TutorialStore.""" + state: str = NOT_STARTED + turns_in_tutorial: int = 0 + completed_at: Optional[str] = None + started_at: Optional[str] = None + + def transition(self, new_state: str) -> None: + if new_state not in VALID_TRANSITIONS.get(self.state, set()): + raise TutorialFlowError( + f"illegal transition: {self.state!r} → {new_state!r}" + ) + self.state = new_state + now = datetime.now().astimezone().isoformat() + if new_state == TUTORIAL_ACTIVE and self.started_at is None: + self.started_at = now + if new_state == TUTORIAL_COMPLETE: + self.completed_at = now + if new_state == CAMPAIGN_ACTIVE and self.completed_at is None: + # Skipped tutorial — record completion so reset() works cleanly. + self.completed_at = now + + def record_tutorial_turn(self) -> None: + if self.state != TUTORIAL_ACTIVE: + return + self.turns_in_tutorial += 1 + + def is_ready_for_campaign(self) -> bool: + """True when the player has finished the tutorial and is ready + to be handed off to the long-running campaign map.""" + return self.state == TUTORIAL_COMPLETE + + def to_dict(self) -> dict: + return { + "state": self.state, + "turns_in_tutorial": self.turns_in_tutorial, + "started_at": self.started_at, + "completed_at": self.completed_at, + } + + @classmethod + def from_dict(cls, data: dict) -> "TutorialFlow": + return cls( + state=data.get("state", NOT_STARTED), + turns_in_tutorial=int(data.get("turns_in_tutorial", 0) or 0), + started_at=data.get("started_at"), + completed_at=data.get("completed_at"), + ) + + +# ──────────────────────────────────────────────────────────────────────── +# Persistent store +# ──────────────────────────────────────────────────────────────────────── + +class TutorialStore: + """File-backed persistence for TutorialFlow. + + Atomic writes via temp-file + rename — the same pattern as + SaveExchange so a crash mid-write never corrupts the state file. + """ + + def __init__(self, path: str | Path): + p = Path(path) + if p.suffix == "": + p.mkdir(parents=True, exist_ok=True) + p = p / DEFAULT_TUTORIAL_FILENAME + else: + p.parent.mkdir(parents=True, exist_ok=True) + self.path: Path = p + + def load(self) -> TutorialFlow: + if not self.path.exists(): + return TutorialFlow() + try: + with open(self.path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return TutorialFlow() + return TutorialFlow.from_dict(data) + + def save(self, flow: TutorialFlow) -> Path: + body = json.dumps(flow.to_dict(), indent=2) + fd, tmp_path = tempfile.mkstemp(prefix=".tutorial_", suffix=".tmp", + dir=str(self.path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(body) + os.replace(tmp_path, self.path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + return self.path + + def reset(self) -> None: + """Forget tutorial state entirely (back to NOT_STARTED on next load).""" + if self.path.exists(): + self.path.unlink() + + +# ──────────────────────────────────────────────────────────────────────── +# Helper: scenario the Unciv mod should load +# ──────────────────────────────────────────────────────────────────────── + +def scenario_to_load(flow: TutorialFlow) -> str: + """Returns the scenario filename the Unciv mod should load. + + Maps each flow state to one of two scenarios shipped in + `mod/ClaudeKingdoms/scenarios/`. Fallback is the campaign map + (no-tutorial = direct-to-campaign). + """ + if flow.state in (NOT_STARTED, TUTORIAL_ACTIVE): + return "Tutorial" + return "LongRunningCampaign" diff --git a/mod/ClaudeKingdoms/scenarios/LongRunningCampaign/Map.json b/mod/ClaudeKingdoms/scenarios/LongRunningCampaign/Map.json new file mode 100644 index 0000000..26c9d1c --- /dev/null +++ b/mod/ClaudeKingdoms/scenarios/LongRunningCampaign/Map.json @@ -0,0 +1,20 @@ +{ + "mapParameters": { + "name": "Albion", + "type": "Custom", + "size": {"width": 12, "height": 8}, + "shape": "Rectangular", + "noNaturalWonders": false, + "noBarbarians": false + }, + "tiles": [ + [{"terrain": "Coast"}, {"terrain": "Coast"}, {"terrain": "Plains"}, {"terrain": "Hill"}, {"terrain": "Plains"}, {"terrain": "Forest"}, {"terrain": "Plains"}, {"terrain": "Hill"}, {"terrain": "Plains"}, {"terrain": "Mountain"},{"terrain": "Hill"}, {"terrain": "Coast"}], + [{"terrain": "Coast"}, {"terrain": "Plains"}, {"terrain": "Grassland"},{"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Forest"}, {"terrain": "Plains"}, {"terrain": "Hill"}, {"terrain": "Hill"}, {"terrain": "Plains"}, {"terrain": "Coast"}], + [{"terrain": "Plains"}, {"terrain": "Grassland"},{"terrain": "Grassland", "city": "Camelot", "owner": "The Operators", "startingPosition": true}, {"terrain": "Grassland"},{"terrain": "Plains"}, {"terrain": "Forest"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Hill"}, {"terrain": "Plains"}, {"terrain": "Plains"}], + [{"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Grassland"},{"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Forest"}, {"terrain": "Plains"}], + [{"terrain": "Plains"}, {"terrain": "Forest"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Hill"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}], + [{"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Hill"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Forest"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}], + [{"terrain": "Coast"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Coast"}], + [{"terrain": "Coast"}, {"terrain": "Coast"}, {"terrain": "Coast"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Coast"}, {"terrain": "Coast"}, {"terrain": "Coast"}] + ] +} diff --git a/mod/ClaudeKingdoms/scenarios/LongRunningCampaign/scenario.json b/mod/ClaudeKingdoms/scenarios/LongRunningCampaign/scenario.json new file mode 100644 index 0000000..e534de8 --- /dev/null +++ b/mod/ClaudeKingdoms/scenarios/LongRunningCampaign/scenario.json @@ -0,0 +1,20 @@ +{ + "name": "LongRunningCampaign", + "description": "Persistent medieval world that grows across days and weeks. No decay on close. No forced end condition. The primary mode.", + "mapSize": "Standard", + "civilizations": [ + {"civ": "The Operators", "playerType": "Human", "startCity": "Camelot"} + ], + "startingTechs": ["Pottery", "Animal Husbandry"], + "startingTurn": 1, + "victoryConditions": [], + "campaignFlags": { + "noForcedEnd": true, + "noDecayOnClose": true, + "supportMultipleSessions": true + }, + "claudeKingdomsMeta": { + "scenarioRole": "campaign", + "prdAnchor": "PRD §Game Modes (Long-Running Campaign — Primary Mode)" + } +} diff --git a/mod/ClaudeKingdoms/scenarios/Tutorial/Map.json b/mod/ClaudeKingdoms/scenarios/Tutorial/Map.json new file mode 100644 index 0000000..b215439 --- /dev/null +++ b/mod/ClaudeKingdoms/scenarios/Tutorial/Map.json @@ -0,0 +1,18 @@ +{ + "mapParameters": { + "name": "Tutorial Hill", + "type": "Custom", + "size": {"width": 6, "height": 6}, + "shape": "Rectangular", + "noNaturalWonders": true, + "noBarbarians": true + }, + "tiles": [ + [{"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Hill"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Forest"}], + [{"terrain": "Plains"}, {"terrain": "Grassland"},{"terrain": "Grassland", "city": "Camelot", "owner": "The Operators", "startingPosition": true}, {"terrain": "Grassland"},{"terrain": "Plains"}, {"terrain": "Plains"}], + [{"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Hill"}], + [{"terrain": "Forest"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}], + [{"terrain": "Hill"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Forest"}], + [{"terrain": "Plains"}, {"terrain": "Forest"}, {"terrain": "Plains"}, {"terrain": "Plains"}, {"terrain": "Hill"}, {"terrain": "Plains"}] + ] +} diff --git a/mod/ClaudeKingdoms/scenarios/Tutorial/scenario.json b/mod/ClaudeKingdoms/scenarios/Tutorial/scenario.json new file mode 100644 index 0000000..d0b1f9b --- /dev/null +++ b/mod/ClaudeKingdoms/scenarios/Tutorial/scenario.json @@ -0,0 +1,21 @@ +{ + "name": "Tutorial", + "description": "One city, one session, one turn, one reward. Under ten minutes. Shows the emotional payoff before explaining mechanics.", + "mapSize": "Tiny", + "civilizations": [ + {"civ": "The Operators", "playerType": "Human", "startCity": "Camelot"} + ], + "startingTechs": ["Pottery"], + "startingTurn": 1, + "victoryConditions": ["Tutorial Complete"], + "tutorialFlags": { + "graduateAfterTurn": 1, + "showOnboardingFirst": true, + "expectedDurationMinutes": 10 + }, + "claudeKingdomsMeta": { + "scenarioRole": "tutorial", + "handoffTarget": "LongRunningCampaign", + "prdAnchor": "acceptance #9 (Tutorial completes <10 min, transitions to long-running campaign)" + } +}