From 8315fa51c95d8696ffb7db0f6613f878f4c80db0 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 24 Nov 2025 23:10:55 -0600 Subject: [PATCH] first commit --- .gitignore | 37 + CLAUDE.md | 284 ++ README.md | 363 +++ api/.env.example | 64 + api/CLAUDE.md | 366 +++ api/Dockerfile | 35 + api/README.md | 204 ++ api/app/__init__.py | 171 ++ api/app/ai/__init__.py | 61 + api/app/ai/model_selector.py | 226 ++ api/app/ai/narrative_generator.py | 540 ++++ api/app/ai/prompt_templates.py | 318 +++ api/app/ai/replicate_client.py | 450 ++++ api/app/ai/response_parser.py | 160 ++ api/app/ai/templates/combat_action.j2 | 81 + api/app/ai/templates/npc_dialogue.j2 | 138 + api/app/ai/templates/quest_offering.j2 | 61 + api/app/ai/templates/story_action.j2 | 112 + api/app/api/__init__.py | 0 api/app/api/auth.py | 529 ++++ api/app/api/characters.py | 898 +++++++ api/app/api/game_mechanics.py | 302 +++ api/app/api/health.py | 60 + api/app/api/jobs.py | 71 + api/app/api/npcs.py | 429 +++ api/app/api/sessions.py | 604 +++++ api/app/api/travel.py | 306 +++ api/app/config.py | 319 +++ api/app/data/abilities/README.md | 141 + api/app/data/abilities/basic_attack.yaml | 16 + api/app/data/abilities/fireball.yaml | 25 + api/app/data/abilities/heal.yaml | 26 + api/app/data/abilities/shield_bash.yaml | 25 + api/app/data/action_prompts.yaml | 295 +++ api/app/data/classes/arcanist.yaml | 264 ++ api/app/data/classes/assassin.yaml | 265 ++ api/app/data/classes/lorekeeper.yaml | 273 ++ api/app/data/classes/luminary.yaml | 266 ++ api/app/data/classes/necromancer.yaml | 275 ++ api/app/data/classes/oathkeeper.yaml | 265 ++ api/app/data/classes/vanguard.yaml | 264 ++ api/app/data/classes/wildstrider.yaml | 275 ++ api/app/data/generic_items.yaml | 269 ++ .../crossville/crossville_crypt.yaml | 46 + .../crossville/crossville_dungeon.yaml | 47 + .../crossville/crossville_forest.yaml | 45 + .../crossville/crossville_tavern.yaml | 47 + .../crossville/crossville_village.yaml | 44 + .../data/locations/regions/crossville.yaml | 16 + api/app/data/loot_tables.yaml | 281 ++ .../npcs/crossville/npc_blacksmith_hilda.yaml | 93 + .../npcs/crossville/npc_grom_ironbeard.yaml | 95 + .../npcs/crossville/npc_mayor_aldric.yaml | 83 + .../npcs/crossville/npc_mira_swiftfoot.yaml | 90 + api/app/data/origins.yaml | 158 ++ api/app/game_logic/__init__.py | 34 + api/app/game_logic/dice.py | 247 ++ api/app/models/__init__.py | 87 + api/app/models/abilities.py | 237 ++ api/app/models/action_prompt.py | 296 +++ api/app/models/ai_usage.py | 211 ++ api/app/models/character.py | 452 ++++ api/app/models/combat.py | 414 +++ api/app/models/effects.py | 208 ++ api/app/models/enums.py | 113 + api/app/models/items.py | 196 ++ api/app/models/location.py | 181 ++ api/app/models/marketplace.py | 401 +++ api/app/models/npc.py | 477 ++++ api/app/models/origins.py | 148 ++ api/app/models/session.py | 411 +++ api/app/models/skills.py | 290 +++ api/app/models/stats.py | 140 + api/app/services/__init__.py | 0 api/app/services/action_prompt_loader.py | 320 +++ api/app/services/appwrite_service.py | 588 +++++ api/app/services/character_service.py | 1049 ++++++++ api/app/services/class_loader.py | 277 ++ api/app/services/database_init.py | 709 +++++ api/app/services/database_service.py | 441 ++++ api/app/services/item_validator.py | 351 +++ api/app/services/location_loader.py | 326 +++ api/app/services/npc_loader.py | 385 +++ api/app/services/origin_service.py | 236 ++ api/app/services/outcome_service.py | 373 +++ api/app/services/rate_limiter_service.py | 602 +++++ api/app/services/redis_service.py | 505 ++++ api/app/services/session_service.py | 705 +++++ api/app/services/usage_tracking_service.py | 528 ++++ api/app/tasks/__init__.py | 156 ++ api/app/tasks/ai_tasks.py | 1314 ++++++++++ api/app/utils/__init__.py | 1 + api/app/utils/auth.py | 444 ++++ api/app/utils/logging.py | 272 ++ api/app/utils/response.py | 337 +++ api/config/development.yaml | 127 + api/config/production.yaml | 126 + api/config/rq_config.py | 77 + api/docker-compose.yml | 63 + api/docs/ACTION_PROMPTS.md | 563 ++++ api/docs/AI_INTEGRATION.md | 538 ++++ api/docs/API_REFERENCE.md | 2309 +++++++++++++++++ api/docs/API_TESTING.md | 1864 +++++++++++++ api/docs/APPWRITE_SETUP.md | 351 +++ api/docs/DATA_MODELS.md | 1182 +++++++++ api/docs/GAME_SYSTEMS.md | 587 +++++ api/docs/MULTIPLAYER.md | 807 ++++++ api/docs/PHASE4_IMPLEMENTATION.md | 940 +++++++ api/docs/PROMPT_TEMPLATES.md | 530 ++++ api/docs/QUEST_SYSTEM.md | 927 +++++++ api/docs/SESSION_MANAGEMENT.md | 435 ++++ api/docs/STORY_PROGRESSION.md | 985 +++++++ api/docs/USAGE_TRACKING.md | 614 +++++ api/requirements.txt | 57 + api/scripts/README.md | 124 + api/scripts/clear_char_daily_limit.sh | 2 + api/scripts/clear_worker_queues.sh | 2 + api/scripts/init_database.py | 106 + api/scripts/queue_info.py | 146 ++ api/scripts/setup.sh | 76 + api/scripts/start_workers.sh | 98 + api/scripts/verify_ai_models.py | 238 ++ api/scripts/verify_e2e_ai_generation.py | 757 ++++++ api/scripts/verify_session_persistence.py | 428 +++ api/tests/__init__.py | 3 + api/tests/test_action_prompt.py | 311 +++ api/tests/test_action_prompt_loader.py | 314 +++ api/tests/test_ai_tasks.py | 571 ++++ api/tests/test_api_characters_integration.py | 579 +++++ api/tests/test_character.py | 454 ++++ api/tests/test_character_service.py | 547 ++++ api/tests/test_class_loader.py | 256 ++ api/tests/test_combat_simulation.py | 509 ++++ api/tests/test_effects.py | 361 +++ api/tests/test_model_selector.py | 294 +++ api/tests/test_narrative_generator.py | 583 +++++ api/tests/test_origin_service.py | 200 ++ api/tests/test_prompt_templates.py | 321 +++ api/tests/test_rate_limiter_service.py | 342 +++ api/tests/test_redis_service.py | 573 ++++ api/tests/test_replicate_client.py | 462 ++++ api/tests/test_session_model.py | 390 +++ api/tests/test_session_service.py | 566 ++++ api/tests/test_stats.py | 198 ++ api/tests/test_usage_tracking_service.py | 460 ++++ api/wsgi.py | 15 + docs/ARCHITECTURE.md | 921 +++++++ docs/DEPLOYMENT.md | 580 +++++ docs/PLAYSCREEN.md | 272 ++ docs/ROADMAP.md | 633 +++++ docs/WEB_VS_CLIENT_SYSTEMS.md | 926 +++++++ godot_client/.gitignore | 44 + godot_client/CLAUDE.md | 558 ++++ godot_client/README.md | 220 ++ .../assets/fonts/Cinzel-VariableFont_wght.ttf | Bin 0 -> 126348 bytes .../assets/fonts/Cinzel_Heading_Large.tres | 8 + .../assets/fonts/Cinzel_Heading_Medium.tres | 8 + .../assets/fonts/Cinzel_Heading_Small.tres | 7 + godot_client/assets/fonts/Lato-Black.ttf | Bin 0 -> 69500 bytes .../assets/fonts/Lato-BlackItalic.ttf | Bin 0 -> 72000 bytes godot_client/assets/fonts/Lato-Bold.ttf | Bin 0 -> 73332 bytes godot_client/assets/fonts/Lato-BoldItalic.ttf | Bin 0 -> 77732 bytes godot_client/assets/fonts/Lato-Italic.ttf | Bin 0 -> 75792 bytes godot_client/assets/fonts/Lato-Light.ttf | Bin 0 -> 77208 bytes .../assets/fonts/Lato-LightItalic.ttf | Bin 0 -> 49080 bytes godot_client/assets/fonts/Lato-Regular.ttf | Bin 0 -> 75152 bytes godot_client/assets/fonts/Lato-Thin.ttf | Bin 0 -> 69976 bytes godot_client/assets/fonts/Lato-ThinItalic.ttf | Bin 0 -> 48864 bytes godot_client/assets/fonts/Lato_Body.tres | 7 + godot_client/assets/fonts/Lato_Body_Bold.tres | 8 + .../assets/fonts/MedievalSharp-Regular.ttf | Bin 0 -> 146624 bytes godot_client/assets/fonts/README.md | 67 + godot_client/assets/images/main_menu | Bin 0 -> 2173818 bytes godot_client/assets/themes/main_theme.tres | 114 + godot_client/assets/ui/icon.png | Bin 0 -> 1698154 bytes godot_client/assets/ui/main_menu.png | Bin 0 -> 2173818 bytes godot_client/docs/ARCHITECTURE.md | 767 ++++++ godot_client/docs/EXPORT.md | 413 +++ godot_client/docs/GETTING_STARTED.md | 381 +++ godot_client/docs/MULTIPLAYER.md | 823 ++++++ godot_client/docs/README.md | 103 + godot_client/docs/THEME_SETUP.md | 361 +++ godot_client/docs/scene_char_list.md | 452 ++++ godot_client/export_presets_template.cfg | 53 + godot_client/project.godot | 60 + godot_client/scenes/auth/login.gd | 209 ++ godot_client/scenes/auth/login.gd.uid | 1 + godot_client/scenes/auth/login.tscn | 241 ++ .../scenes/character/character_card.tscn | 65 + .../scenes/character/character_list.tscn | 156 ++ godot_client/scenes/components/README.md | 335 +++ godot_client/scenes/main.tscn | 183 ++ godot_client/scenes/test_services.tscn | 21 + .../scripts/character/character_card.gd | 96 + .../scripts/character/character_card.gd.uid | 1 + .../scripts/character/character_list.gd | 200 ++ .../scripts/character/character_list.gd.uid | 1 + godot_client/scripts/components/card.gd | 202 ++ godot_client/scripts/components/card.gd.uid | 1 + godot_client/scripts/components/form_field.gd | 280 ++ .../scripts/components/form_field.gd.uid | 1 + godot_client/scripts/main.gd | 171 ++ godot_client/scripts/main.gd.uid | 1 + godot_client/scripts/models/api_response.gd | 56 + .../scripts/models/api_response.gd.uid | 1 + godot_client/scripts/services/http_client.gd | 364 +++ .../scripts/services/http_client.gd.uid | 1 + godot_client/scripts/services/settings.gd | 204 ++ godot_client/scripts/services/settings.gd.uid | 1 + .../scripts/services/state_manager.gd | 422 +++ .../scripts/services/state_manager.gd.uid | 1 + godot_client/scripts/test_services.gd | 25 + godot_client/scripts/test_services.gd.uid | 1 + godot_client/scripts/utils/theme_colors.gd | 121 + .../scripts/utils/theme_colors.gd.uid | 1 + public_web/.env.example | 13 + public_web/.flaskenv | 7 + public_web/CLAUDE.md | 464 ++++ public_web/README.md | 220 ++ public_web/app/__init__.py | 83 + public_web/app/config.py | 87 + public_web/app/utils/__init__.py | 1 + public_web/app/utils/api_client.py | 336 +++ public_web/app/utils/auth.py | 146 ++ public_web/app/utils/logging.py | 47 + public_web/app/views/__init__.py | 3 + public_web/app/views/auth_views.py | 193 ++ public_web/app/views/character_views.py | 666 +++++ public_web/app/views/dev.py | 382 +++ public_web/app/views/game_views.py | 796 ++++++ public_web/config/development.yaml | 46 + public_web/config/production.yaml | 43 + public_web/docs/.gitkeep | 0 public_web/docs/HTMX_PATTERNS.md | 651 +++++ public_web/docs/MULTIPLAYER.md | 738 ++++++ public_web/docs/README.md | 25 + public_web/docs/TEMPLATES.md | 431 +++ public_web/docs/TESTING.md | 710 +++++ public_web/requirements.txt | 30 + public_web/static/css/main.css | 608 +++++ public_web/static/css/play.css | 1691 ++++++++++++ .../templates/auth/forgot_password.html | 93 + public_web/templates/auth/login.html | 68 + public_web/templates/auth/register.html | 230 ++ public_web/templates/auth/reset_password.html | 203 ++ public_web/templates/auth/verify_email.html | 26 + public_web/templates/base.html | 69 + .../templates/character/create_class.html | 364 +++ .../templates/character/create_confirm.html | 597 +++++ .../templates/character/create_customize.html | 425 +++ .../templates/character/create_origin.html | 387 +++ public_web/templates/character/detail.html | 485 ++++ public_web/templates/character/list.html | 455 ++++ public_web/templates/dev/index.html | 110 + .../templates/dev/partials/dm_response.html | 27 + .../templates/dev/partials/history.html | 21 + .../templates/dev/partials/job_status.html | 29 + .../templates/dev/partials/npc_dialogue.html | 129 + .../templates/dev/partials/session_state.html | 32 + .../templates/dev/partials/travel_modal.html | 31 + public_web/templates/dev/story.html | 199 ++ public_web/templates/dev/story_session.html | 669 +++++ public_web/templates/errors/404.html | 147 ++ public_web/templates/errors/500.html | 201 ++ .../game/partials/character_panel.html | 186 ++ .../templates/game/partials/dm_response.html | 26 + .../game/partials/equipment_modal.html | 108 + .../templates/game/partials/job_polling.html | 27 + .../game/partials/narrative_panel.html | 66 + .../game/partials/npc_chat_modal.html | 127 + .../game/partials/npc_dialogue_response.html | 59 + .../game/partials/sidebar_history.html | 30 + .../templates/game/partials/sidebar_map.html | 51 + .../templates/game/partials/sidebar_npcs.html | 29 + .../game/partials/sidebar_quests.html | 36 + .../templates/game/partials/travel_modal.html | 51 + public_web/templates/game/play.html | 152 ++ public_web/wsgi.py | 24 + requirements.txt | 57 + 279 files changed, 74600 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 api/.env.example create mode 100644 api/CLAUDE.md create mode 100644 api/Dockerfile create mode 100644 api/README.md create mode 100644 api/app/__init__.py create mode 100644 api/app/ai/__init__.py create mode 100644 api/app/ai/model_selector.py create mode 100644 api/app/ai/narrative_generator.py create mode 100644 api/app/ai/prompt_templates.py create mode 100644 api/app/ai/replicate_client.py create mode 100644 api/app/ai/response_parser.py create mode 100644 api/app/ai/templates/combat_action.j2 create mode 100644 api/app/ai/templates/npc_dialogue.j2 create mode 100644 api/app/ai/templates/quest_offering.j2 create mode 100644 api/app/ai/templates/story_action.j2 create mode 100644 api/app/api/__init__.py create mode 100644 api/app/api/auth.py create mode 100644 api/app/api/characters.py create mode 100644 api/app/api/game_mechanics.py create mode 100644 api/app/api/health.py create mode 100644 api/app/api/jobs.py create mode 100644 api/app/api/npcs.py create mode 100644 api/app/api/sessions.py create mode 100644 api/app/api/travel.py create mode 100644 api/app/config.py create mode 100644 api/app/data/abilities/README.md create mode 100644 api/app/data/abilities/basic_attack.yaml create mode 100644 api/app/data/abilities/fireball.yaml create mode 100644 api/app/data/abilities/heal.yaml create mode 100644 api/app/data/abilities/shield_bash.yaml create mode 100644 api/app/data/action_prompts.yaml create mode 100644 api/app/data/classes/arcanist.yaml create mode 100644 api/app/data/classes/assassin.yaml create mode 100644 api/app/data/classes/lorekeeper.yaml create mode 100644 api/app/data/classes/luminary.yaml create mode 100644 api/app/data/classes/necromancer.yaml create mode 100644 api/app/data/classes/oathkeeper.yaml create mode 100644 api/app/data/classes/vanguard.yaml create mode 100644 api/app/data/classes/wildstrider.yaml create mode 100644 api/app/data/generic_items.yaml create mode 100644 api/app/data/locations/crossville/crossville_crypt.yaml create mode 100644 api/app/data/locations/crossville/crossville_dungeon.yaml create mode 100644 api/app/data/locations/crossville/crossville_forest.yaml create mode 100644 api/app/data/locations/crossville/crossville_tavern.yaml create mode 100644 api/app/data/locations/crossville/crossville_village.yaml create mode 100644 api/app/data/locations/regions/crossville.yaml create mode 100644 api/app/data/loot_tables.yaml create mode 100644 api/app/data/npcs/crossville/npc_blacksmith_hilda.yaml create mode 100644 api/app/data/npcs/crossville/npc_grom_ironbeard.yaml create mode 100644 api/app/data/npcs/crossville/npc_mayor_aldric.yaml create mode 100644 api/app/data/npcs/crossville/npc_mira_swiftfoot.yaml create mode 100644 api/app/data/origins.yaml create mode 100644 api/app/game_logic/__init__.py create mode 100644 api/app/game_logic/dice.py create mode 100644 api/app/models/__init__.py create mode 100644 api/app/models/abilities.py create mode 100644 api/app/models/action_prompt.py create mode 100644 api/app/models/ai_usage.py create mode 100644 api/app/models/character.py create mode 100644 api/app/models/combat.py create mode 100644 api/app/models/effects.py create mode 100644 api/app/models/enums.py create mode 100644 api/app/models/items.py create mode 100644 api/app/models/location.py create mode 100644 api/app/models/marketplace.py create mode 100644 api/app/models/npc.py create mode 100644 api/app/models/origins.py create mode 100644 api/app/models/session.py create mode 100644 api/app/models/skills.py create mode 100644 api/app/models/stats.py create mode 100644 api/app/services/__init__.py create mode 100644 api/app/services/action_prompt_loader.py create mode 100644 api/app/services/appwrite_service.py create mode 100644 api/app/services/character_service.py create mode 100644 api/app/services/class_loader.py create mode 100644 api/app/services/database_init.py create mode 100644 api/app/services/database_service.py create mode 100644 api/app/services/item_validator.py create mode 100644 api/app/services/location_loader.py create mode 100644 api/app/services/npc_loader.py create mode 100644 api/app/services/origin_service.py create mode 100644 api/app/services/outcome_service.py create mode 100644 api/app/services/rate_limiter_service.py create mode 100644 api/app/services/redis_service.py create mode 100644 api/app/services/session_service.py create mode 100644 api/app/services/usage_tracking_service.py create mode 100644 api/app/tasks/__init__.py create mode 100644 api/app/tasks/ai_tasks.py create mode 100644 api/app/utils/__init__.py create mode 100644 api/app/utils/auth.py create mode 100644 api/app/utils/logging.py create mode 100644 api/app/utils/response.py create mode 100644 api/config/development.yaml create mode 100644 api/config/production.yaml create mode 100644 api/config/rq_config.py create mode 100644 api/docker-compose.yml create mode 100644 api/docs/ACTION_PROMPTS.md create mode 100644 api/docs/AI_INTEGRATION.md create mode 100644 api/docs/API_REFERENCE.md create mode 100644 api/docs/API_TESTING.md create mode 100644 api/docs/APPWRITE_SETUP.md create mode 100644 api/docs/DATA_MODELS.md create mode 100644 api/docs/GAME_SYSTEMS.md create mode 100644 api/docs/MULTIPLAYER.md create mode 100644 api/docs/PHASE4_IMPLEMENTATION.md create mode 100644 api/docs/PROMPT_TEMPLATES.md create mode 100644 api/docs/QUEST_SYSTEM.md create mode 100644 api/docs/SESSION_MANAGEMENT.md create mode 100644 api/docs/STORY_PROGRESSION.md create mode 100644 api/docs/USAGE_TRACKING.md create mode 100644 api/requirements.txt create mode 100644 api/scripts/README.md create mode 100755 api/scripts/clear_char_daily_limit.sh create mode 100755 api/scripts/clear_worker_queues.sh create mode 100755 api/scripts/init_database.py create mode 100755 api/scripts/queue_info.py create mode 100755 api/scripts/setup.sh create mode 100755 api/scripts/start_workers.sh create mode 100644 api/scripts/verify_ai_models.py create mode 100755 api/scripts/verify_e2e_ai_generation.py create mode 100755 api/scripts/verify_session_persistence.py create mode 100644 api/tests/__init__.py create mode 100644 api/tests/test_action_prompt.py create mode 100644 api/tests/test_action_prompt_loader.py create mode 100644 api/tests/test_ai_tasks.py create mode 100644 api/tests/test_api_characters_integration.py create mode 100644 api/tests/test_character.py create mode 100644 api/tests/test_character_service.py create mode 100644 api/tests/test_class_loader.py create mode 100644 api/tests/test_combat_simulation.py create mode 100644 api/tests/test_effects.py create mode 100644 api/tests/test_model_selector.py create mode 100644 api/tests/test_narrative_generator.py create mode 100644 api/tests/test_origin_service.py create mode 100644 api/tests/test_prompt_templates.py create mode 100644 api/tests/test_rate_limiter_service.py create mode 100644 api/tests/test_redis_service.py create mode 100644 api/tests/test_replicate_client.py create mode 100644 api/tests/test_session_model.py create mode 100644 api/tests/test_session_service.py create mode 100644 api/tests/test_stats.py create mode 100644 api/tests/test_usage_tracking_service.py create mode 100644 api/wsgi.py create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/PLAYSCREEN.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/WEB_VS_CLIENT_SYSTEMS.md create mode 100644 godot_client/.gitignore create mode 100644 godot_client/CLAUDE.md create mode 100644 godot_client/README.md create mode 100644 godot_client/assets/fonts/Cinzel-VariableFont_wght.ttf create mode 100644 godot_client/assets/fonts/Cinzel_Heading_Large.tres create mode 100644 godot_client/assets/fonts/Cinzel_Heading_Medium.tres create mode 100644 godot_client/assets/fonts/Cinzel_Heading_Small.tres create mode 100644 godot_client/assets/fonts/Lato-Black.ttf create mode 100644 godot_client/assets/fonts/Lato-BlackItalic.ttf create mode 100644 godot_client/assets/fonts/Lato-Bold.ttf create mode 100644 godot_client/assets/fonts/Lato-BoldItalic.ttf create mode 100644 godot_client/assets/fonts/Lato-Italic.ttf create mode 100644 godot_client/assets/fonts/Lato-Light.ttf create mode 100644 godot_client/assets/fonts/Lato-LightItalic.ttf create mode 100644 godot_client/assets/fonts/Lato-Regular.ttf create mode 100644 godot_client/assets/fonts/Lato-Thin.ttf create mode 100644 godot_client/assets/fonts/Lato-ThinItalic.ttf create mode 100644 godot_client/assets/fonts/Lato_Body.tres create mode 100644 godot_client/assets/fonts/Lato_Body_Bold.tres create mode 100644 godot_client/assets/fonts/MedievalSharp-Regular.ttf create mode 100644 godot_client/assets/fonts/README.md create mode 100644 godot_client/assets/images/main_menu create mode 100644 godot_client/assets/themes/main_theme.tres create mode 100644 godot_client/assets/ui/icon.png create mode 100644 godot_client/assets/ui/main_menu.png create mode 100644 godot_client/docs/ARCHITECTURE.md create mode 100644 godot_client/docs/EXPORT.md create mode 100644 godot_client/docs/GETTING_STARTED.md create mode 100644 godot_client/docs/MULTIPLAYER.md create mode 100644 godot_client/docs/README.md create mode 100644 godot_client/docs/THEME_SETUP.md create mode 100644 godot_client/docs/scene_char_list.md create mode 100644 godot_client/export_presets_template.cfg create mode 100644 godot_client/project.godot create mode 100644 godot_client/scenes/auth/login.gd create mode 100644 godot_client/scenes/auth/login.gd.uid create mode 100644 godot_client/scenes/auth/login.tscn create mode 100644 godot_client/scenes/character/character_card.tscn create mode 100644 godot_client/scenes/character/character_list.tscn create mode 100644 godot_client/scenes/components/README.md create mode 100644 godot_client/scenes/main.tscn create mode 100644 godot_client/scenes/test_services.tscn create mode 100644 godot_client/scripts/character/character_card.gd create mode 100644 godot_client/scripts/character/character_card.gd.uid create mode 100644 godot_client/scripts/character/character_list.gd create mode 100644 godot_client/scripts/character/character_list.gd.uid create mode 100644 godot_client/scripts/components/card.gd create mode 100644 godot_client/scripts/components/card.gd.uid create mode 100644 godot_client/scripts/components/form_field.gd create mode 100644 godot_client/scripts/components/form_field.gd.uid create mode 100644 godot_client/scripts/main.gd create mode 100644 godot_client/scripts/main.gd.uid create mode 100644 godot_client/scripts/models/api_response.gd create mode 100644 godot_client/scripts/models/api_response.gd.uid create mode 100644 godot_client/scripts/services/http_client.gd create mode 100644 godot_client/scripts/services/http_client.gd.uid create mode 100644 godot_client/scripts/services/settings.gd create mode 100644 godot_client/scripts/services/settings.gd.uid create mode 100644 godot_client/scripts/services/state_manager.gd create mode 100644 godot_client/scripts/services/state_manager.gd.uid create mode 100644 godot_client/scripts/test_services.gd create mode 100644 godot_client/scripts/test_services.gd.uid create mode 100644 godot_client/scripts/utils/theme_colors.gd create mode 100644 godot_client/scripts/utils/theme_colors.gd.uid create mode 100644 public_web/.env.example create mode 100644 public_web/.flaskenv create mode 100644 public_web/CLAUDE.md create mode 100644 public_web/README.md create mode 100644 public_web/app/__init__.py create mode 100644 public_web/app/config.py create mode 100644 public_web/app/utils/__init__.py create mode 100644 public_web/app/utils/api_client.py create mode 100644 public_web/app/utils/auth.py create mode 100644 public_web/app/utils/logging.py create mode 100644 public_web/app/views/__init__.py create mode 100644 public_web/app/views/auth_views.py create mode 100644 public_web/app/views/character_views.py create mode 100644 public_web/app/views/dev.py create mode 100644 public_web/app/views/game_views.py create mode 100644 public_web/config/development.yaml create mode 100644 public_web/config/production.yaml create mode 100644 public_web/docs/.gitkeep create mode 100644 public_web/docs/HTMX_PATTERNS.md create mode 100644 public_web/docs/MULTIPLAYER.md create mode 100644 public_web/docs/README.md create mode 100644 public_web/docs/TEMPLATES.md create mode 100644 public_web/docs/TESTING.md create mode 100644 public_web/requirements.txt create mode 100644 public_web/static/css/main.css create mode 100644 public_web/static/css/play.css create mode 100644 public_web/templates/auth/forgot_password.html create mode 100644 public_web/templates/auth/login.html create mode 100644 public_web/templates/auth/register.html create mode 100644 public_web/templates/auth/reset_password.html create mode 100644 public_web/templates/auth/verify_email.html create mode 100644 public_web/templates/base.html create mode 100644 public_web/templates/character/create_class.html create mode 100644 public_web/templates/character/create_confirm.html create mode 100644 public_web/templates/character/create_customize.html create mode 100644 public_web/templates/character/create_origin.html create mode 100644 public_web/templates/character/detail.html create mode 100644 public_web/templates/character/list.html create mode 100644 public_web/templates/dev/index.html create mode 100644 public_web/templates/dev/partials/dm_response.html create mode 100644 public_web/templates/dev/partials/history.html create mode 100644 public_web/templates/dev/partials/job_status.html create mode 100644 public_web/templates/dev/partials/npc_dialogue.html create mode 100644 public_web/templates/dev/partials/session_state.html create mode 100644 public_web/templates/dev/partials/travel_modal.html create mode 100644 public_web/templates/dev/story.html create mode 100644 public_web/templates/dev/story_session.html create mode 100644 public_web/templates/errors/404.html create mode 100644 public_web/templates/errors/500.html create mode 100644 public_web/templates/game/partials/character_panel.html create mode 100644 public_web/templates/game/partials/dm_response.html create mode 100644 public_web/templates/game/partials/equipment_modal.html create mode 100644 public_web/templates/game/partials/job_polling.html create mode 100644 public_web/templates/game/partials/narrative_panel.html create mode 100644 public_web/templates/game/partials/npc_chat_modal.html create mode 100644 public_web/templates/game/partials/npc_dialogue_response.html create mode 100644 public_web/templates/game/partials/sidebar_history.html create mode 100644 public_web/templates/game/partials/sidebar_map.html create mode 100644 public_web/templates/game/partials/sidebar_npcs.html create mode 100644 public_web/templates/game/partials/sidebar_quests.html create mode 100644 public_web/templates/game/partials/travel_modal.html create mode 100644 public_web/templates/game/play.html create mode 100644 public_web/wsgi.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9945a2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Output files (scan reports and screenshots) +output/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +#AI helpers +.claude/ + +# OS +.DS_Store +Thumbs.db + +# Docker +.dockerignore + +# Secrets +.env +# Logs +logs/ +app/logs/ +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b7d3cf1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,284 @@ +# CLAUDE.md + +## Project Overview +"Code Of Conquest" is a web-based AI-powered Dungeons & Dragons style game where Claude acts as the Dungeon Master. Players create characters, explore worlds, engage in turn-based combat, and interact with an AI-driven narrative system. + +**Tech Stack:** Flask + Jinja2 + HTMX + Appwrite + RQ + Redis + Anthropic/Replicate APIs +**Target Delivery:** Progressive Web App (PWA) + +--- + +## Repository Structure + +This repository contains three independent deployable components: + +- **[/api](api/)** - Flask REST API backend (all business logic, models, services) +- **[/public_web](public_web/)** - Flask web frontend (HTML/HTMX UI, calls API) +- **[/godot_client](godot_client/)** - Godot game client (native cross-platform) + +## Documentation Index + +**Project-Wide:** +- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture, tech stack details, component design +- **[ROADMAP.md](docs/ROADMAP.md)** - Development roadmap and phases +- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Testing, deployment, monitoring, security +- **[WEB_VS_CLIENT_SYSTEMS.md](docs/WEB_VS_CLIENT_SYSTEMS.md)** - Feature distribution between web and Godot clients + +**API Backend:** +- **[API_REFERENCE.md](api/docs/API_REFERENCE.md)** - API endpoints and response formats +- **[DATA_MODELS.md](api/docs/DATA_MODELS.md)** - Character system, items, skills, effects, sessions +- **[GAME_SYSTEMS.md](api/docs/GAME_SYSTEMS.md)** - Combat mechanics, marketplace, NPCs +- **[QUEST_SYSTEM.md](api/docs/QUEST_SYSTEM.md)** - Quest mechanics and data structures +- **[STORY_PROGRESSION.md](api/docs/STORY_PROGRESSION.md)** - Story progression system +- **[MULTIPLAYER.md](api/docs/MULTIPLAYER.md)** - Multiplayer session backend logic +- **[API_TESTING.md](api/docs/API_TESTING.md)** - API testing guide +- **[APPWRITE_SETUP.md](api/docs/APPWRITE_SETUP.md)** - Database setup +- **[PHASE4_IMPLEMENTATION.md](api/docs/PHASE4_IMPLEMENTATION.md)** - Phase 4 detailed implementation tasks + +> **Documentation Hierarchy:** `/docs/ROADMAP.md` is the single source for project progress. Service-specific docs (`/api/docs/`, `/public_web/docs/`, `/godot_client/docs/`) contain implementation details. + +**Web Frontend:** +- **[TEMPLATES.md](public_web/docs/TEMPLATES.md)** - Template structure and conventions +- **[HTMX_PATTERNS.md](public_web/docs/HTMX_PATTERNS.md)** - HTMX integration patterns +- **[TESTING.md](public_web/docs/TESTING.md)** - Manual testing guide +- **[MULTIPLAYER.md](public_web/docs/MULTIPLAYER.md)** - Multiplayer UI implementation + +**Godot Client:** +- **[ARCHITECTURE.md](godot_client/docs/ARCHITECTURE.md)** - Client architecture +- **[GETTING_STARTED.md](godot_client/docs/GETTING_STARTED.md)** - Setup and usage +- **[EXPORT.md](godot_client/docs/EXPORT.md)** - Platform export guide +- **[THEME_SETUP.md](godot_client/docs/THEME_SETUP.md)** - UI theming guide +- **[MULTIPLAYER.md](godot_client/docs/MULTIPLAYER.md)** - Multiplayer client implementation +- **[scene_char_list.md](godot_client/docs/scene_char_list.md)** - Character list scene implementation + +--- + +## Development Guidelines + +### Project Structure + +**Microservices Architecture:** + +The repository is organized into three independent components: + +1. **`/api`** - REST API Backend + - Source code in `/api/app` + - All business logic, models, services + - Modular organization: + - `/api/app/api/` - API endpoint blueprints + - `/api/app/models/` - Data models (dataclasses) + - `/api/app/services/` - Business logic & integrations + - `/api/app/utils/` - Utilities + - `/api/app/data/` - Game data (YAML) + - Independent deployment with own `requirements.txt`, `config/`, `tests/` + +2. **`/public_web`** - Web Frontend + - Source code in `/public_web/app` + - Lightweight view layer (makes HTTP calls to API) + - Structure: + - `/public_web/app/views/` - View blueprints + - `/public_web/templates/` - Jinja2 templates + - `/public_web/static/` - CSS, JS, images + - Independent deployment with own `requirements.txt`, `config/` + +3. **`/godot_client`** - Game Client + - Godot 4.5 project + - Makes HTTP calls to API + - Independent deployment (exports to Desktop/Mobile/Web) + +**Each component:** +- Has its own virtual environment +- Deploys independently +- Shares no code (API is single source of truth) +- Typed config loaders (YAML-driven) + +### Coding Standards + +**Style & Structure** +- Prefer longer, explicit code over compact one-liners +- Always include docstrings for functions/classes + inline comments +- Strongly prefer OOP-style code (classes over functional/nested functions) +- Strong typing throughout (dataclasses, TypedDict, Enums, type hints) +- Value future-proofing and expanded usage insights + +**Data Design** +- Use dataclasses for internal data modeling +- Typed JSON structures +- Functions return fully typed objects (no loose dicts) +- Snapshot files in JSON or YAML +- Human-readable fields (e.g., `scan_duration`) + +**Templates & UI** +- Don't mix large HTML/CSS blocks in Python code +- Prefer Jinja templates for HTML rendering +- Clean CSS, minimal inline clutter, readable template logic + +**Writing & Documentation** +- Markdown documentation +- Clear section headers +- Roadmap/Phase/Feature-Session style documents +- Boilerplate templates first, then refinements + +**Logging** +- Use structlog (pip package) +- Setup logging at app start: `logger = logging.get_logger(__file__)` + +**Preferred Pip Packages** +- API/Web Server: Flask +- HTTP: Requests +- Logging: Structlog +- Scheduling: APScheduler + +### Error Handling +- Custom exception classes for domain-specific errors +- Consistent error response formats (JSON structure) +- Logging severity levels (ERROR vs WARNING) + +### Configuration +- Each component has environment-specific configs in its own `/config/*.yaml` + - API: `/api/config/development.yaml`, `/api/config/production.yaml` + - Web: `/public_web/config/development.yaml`, `/public_web/config/production.yaml` +- `.env` for secrets (never committed) +- Maintain `.env.example` in each component for documentation +- Typed config loaders using dataclasses +- Validation on startup + +### Containerization & Deployment +- Explicit Dockerfiles +- Production-friendly hardening (distroless/slim when meaningful) +- Clear build/push scripts that: + - Use git branch as tag + - Ask whether to tag `:latest` + - Ask whether to push + - Support private registries + +### API Design +- RESTful conventions +- Versioning strategy (`/api/v1/...`) +- Standardized response format: + +```json +{ + "app": "", + "version": "", + "status": , + "timestamp": "", + "request_id": "", + "result": , + "error": { + "code": "", + "message": "", + "details": {} + }, + "meta": {} +} +``` + +### Dependency Management +- Use `requirements.txt` and virtual environments (`python3 -m venv venv`) +- Use path `venv` for all virtual environments +- Pin versions to version ranges +- Activate venv before running code (unless in Docker) + +### Testing Standards +- Manual testing preferred for applications +- **API Backend:** Maintain `api/docs/API_TESTING.md` with endpoint examples, curl/httpie commands, expected responses +- **Unit tests:** Use pytest for API backend (`api/tests/`) +- **Web Frontend:** Manual testing checklist in `public_web/README.md` +- **Godot Client:** Manual testing via Godot editor + +### Git Standards + +**Branch Strategy:** +- `master` - Production-ready code only +- `dev` - Main development branch, integration point +- `beta` - (Optional) Public pre-release testing + +**Workflow:** +- Feature work branches off `dev` (e.g., `feature/add-scheduler`) +- Merge features back to `dev` for testing +- Promote `dev` → `beta` for public testing (when applicable) +- Promote `beta` (or `dev`) → `master` for production + +**Commit Messages:** +- Use conventional commit format: `feat:`, `fix:`, `docs:`, `refactor:`, etc. +- Keep commits atomic and focused +- Write clear, descriptive messages + +**Tagging:** +- Tag releases on `master` with semantic versioning (e.g., `v1.2.3`) +- Optionally tag beta releases (e.g., `v1.2.3-beta.1`) + +--- + +## Workflow Preference +I follow a pattern: **brainstorm → design → code → revise** + +--- + +## ⚠️ CLAUDE WORKSPACE BELOW ⚠️ + +**The sections above define development preferences and standards.** +**Everything below is working context for Claude to track project-specific information, decisions, and progress.** + +--- + +## Current Project Status + +**Status:** Repository Reorganization Complete +**Last Updated:** November 17, 2025 +**Document Version:** 3.0 + +### Recent Changes + +**Repository Reorganization (Nov 17, 2025):** +- Split monolithic Flask app into microservices architecture +- Created three independent components: `/api`, `/public_web`, `/godot_client` +- Each component has separate dependencies, configs, and deployment +- API backend contains all business logic +- Web frontend and Godot client are thin clients that call API + +### Active Decisions Log + +- Using Flask over FastAPI (team familiarity, RQ handles async) +- Using RQ over Celery (simpler setup, adequate for scale) +- Using Appwrite (reduces infrastructure overhead, built-in auth/realtime) +- Using Dataclasses over ORM (flexibility, no migrations, JSON storage) +- Turn-based combat (simpler AI prompts, better for multiplayer, classic D&D) +- **Microservices architecture** (independent deployment, API as single source of truth) + +### Known Technical Debt + +~~**Public Web Frontend Service Dependencies:** RESOLVED (Nov 21, 2025)~~ +- All views now use `APIClient` for HTTP requests to API backend +- Stub service modules removed +- Proper error handling with typed exceptions (`APIError`, `APINotFoundError`, etc.) +- Session cookies forwarded to API for authentication + +**Remaining Minor Items:** +- Auth decorator doesn't re-validate expired API sessions (low priority) +- Origin/class validation fetches full lists instead of single-item lookups (optimization opportunity) + +### Next Steps + +1. ~~Refactor `public_web` views to use HTTP API calls~~ ✅ Complete +2. Test both API and web frontend independently +3. Update Godot client to use new API structure (if needed) +4. Continue Phase 4 development (quests, story progression, multiplayer) + +--- + +## Notes for Claude Code + +When implementing features: + +1. **Start with models** - Define dataclasses first +2. **Write tests** - TDD approach for game logic +3. **API then UI** - Backend endpoints before frontend +4. **Security first** - Validate inputs, check permissions +5. **Cost conscious** - Monitor AI usage, implement limits +6. **Keep it simple** - Prefer straightforward solutions +7. **Document as you go** - Update documentation with decisions + +**Remember:** Developer has strong security expertise (don't compromise security for convenience) and extensive infrastructure experience (focus on application logic). +- memorize our godot workflow for the frontend. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..01f247f --- /dev/null +++ b/README.md @@ -0,0 +1,363 @@ +# Code of Conquest + +An AI-powered Dungeons & Dragons style game where Claude acts as the Dungeon Master. + +**Status:** Active Development +**Version:** 0.2.0 +**Architecture:** Microservices (API Backend + Web Frontend + Godot Client) + +--- + +## Overview + +Code of Conquest is a multi-platform RPG game powered by AI. Players create characters, explore worlds, engage in turn-based combat, and experience dynamic narratives generated by Claude AI. + +**Available Clients:** +- **Web Frontend** - Browser-based play via HTMX and Jinja2 templates +- **Godot Client** - Native desktop/mobile application + +Both clients communicate with a centralized REST API backend that handles all game logic, AI integration, and data persistence. + +--- + +## Repository Structure + +This repository contains three independent, deployable components: + +### 1. API Backend (`/api`) + +REST API that serves as the single source of truth for all game logic. + +**Technology:** Flask + Appwrite + RQ + Redis + Anthropic/Replicate APIs + +**Responsibilities:** +- All business logic and game mechanics +- Character, session, and combat management +- AI narrative generation +- Authentication and authorization +- Background job processing +- Database operations + +**Quick Start:** +```bash +cd api +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # Configure your API keys +python wsgi.py # → http://localhost:5000 +``` + +**Documentation:** See [`/api/CLAUDE.md`](api/CLAUDE.md) and [`/api/docs/`](api/docs/) + +--- + +### 2. Web Frontend (`/public_web`) + +Lightweight Flask web application that provides browser-based gameplay. + +**Technology:** Flask + Jinja2 + HTMX + Appwrite Realtime + +**Responsibilities:** +- UI/UX rendering (HTML templates) +- User interactions (HTMX for AJAX) +- Realtime updates (WebSocket subscriptions) +- HTTP API calls to backend + +**Quick Start:** +```bash +cd public_web +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # Configure API URL +python wsgi.py # → http://localhost:8000 +``` + +**Documentation:** See [`/public_web/CLAUDE.md`](public_web/CLAUDE.md) and [`/public_web/docs/`](public_web/docs/) + +--- + +### 3. Godot Client (`/godot_client`) + +Native cross-platform game client built with Godot Engine 4.5. + +**Technology:** Godot 4.5 + GDScript + +**Responsibilities:** +- Rich game UI with animations +- Local game rendering +- HTTP API calls to backend +- Realtime WebSocket subscriptions +- Cross-platform exports (Desktop, Mobile, Web) + +**Quick Start:** +```bash +# Open in Godot Editor +godot --editor godot_client/project.godot + +# Or run directly +godot godot_client/project.godot +``` + +**Documentation:** See [`/godot_client/CLAUDE.md`](godot_client/CLAUDE.md) and [`/godot_client/docs/`](godot_client/docs/) + +--- + +## Documentation Index + +### Project-Wide Documentation (`/docs`) +- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture and tech stack +- **[ROADMAP.md](docs/ROADMAP.md)** - Development roadmap and phases +- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Deployment, testing, and operations +- **[WEB_VS_CLIENT_SYSTEMS.md](docs/WEB_VS_CLIENT_SYSTEMS.md)** - Feature distribution between web and Godot clients + +### API Backend Documentation (`/api/docs`) +- **[API_REFERENCE.md](api/docs/API_REFERENCE.md)** - API endpoints and response formats +- **[DATA_MODELS.md](api/docs/DATA_MODELS.md)** - Character system, items, skills, effects +- **[GAME_SYSTEMS.md](api/docs/GAME_SYSTEMS.md)** - Combat mechanics, marketplace, NPCs +- **[QUEST_SYSTEM.md](api/docs/QUEST_SYSTEM.md)** - Quest mechanics and data structures +- **[STORY_PROGRESSION.md](api/docs/STORY_PROGRESSION.md)** - Story progression system +- **[MULTIPLAYER.md](api/docs/MULTIPLAYER.md)** - Multiplayer session backend logic +- **[API_TESTING.md](api/docs/API_TESTING.md)** - API testing guide +- **[APPWRITE_SETUP.md](api/docs/APPWRITE_SETUP.md)** - Database setup + +### Web Frontend Documentation (`/public_web/docs`) +- **[TEMPLATES.md](public_web/docs/TEMPLATES.md)** - Template structure and conventions +- **[HTMX_PATTERNS.md](public_web/docs/HTMX_PATTERNS.md)** - HTMX integration patterns +- **[TESTING.md](public_web/docs/TESTING.md)** - Manual testing guide +- **[MULTIPLAYER.md](public_web/docs/MULTIPLAYER.md)** - Multiplayer UI implementation + +### Godot Client Documentation (`/godot_client/docs`) +- **[ARCHITECTURE.md](godot_client/docs/ARCHITECTURE.md)** - Client architecture +- **[GETTING_STARTED.md](godot_client/docs/GETTING_STARTED.md)** - Setup and usage +- **[EXPORT.md](godot_client/docs/EXPORT.md)** - Platform export guide +- **[THEME_SETUP.md](godot_client/docs/THEME_SETUP.md)** - UI theming guide +- **[MULTIPLAYER.md](godot_client/docs/MULTIPLAYER.md)** - Multiplayer client implementation + +--- + +## Prerequisites + +### All Components +- Git +- Docker & Docker Compose (for Redis and Appwrite) + +### API Backend & Web Frontend +- Python 3.11+ +- pip +- virtualenv + +### Godot Client +- Godot 4.5+ + +--- + +## Quick Start (Full Stack) + +### 1. Start Shared Services + +Both the API backend and web frontend require Redis and Appwrite: + +```bash +# Start Redis +docker-compose up -d redis + +# Start Appwrite (or use Appwrite Cloud) +# Follow api/docs/APPWRITE_SETUP.md for configuration +``` + +### 2. Start API Backend + +```bash +cd api +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# Edit .env with your credentials +python scripts/init_database.py # Initialize Appwrite collections +python wsgi.py # Runs on http://localhost:5000 +``` + +### 3. Start Web Frontend (Optional) + +```bash +cd public_web +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# Configure API_BASE_URL=http://localhost:5000 +python wsgi.py # Runs on http://localhost:8000 +``` + +### 4. Start Godot Client (Optional) + +```bash +# Open in Godot Editor +godot --editor godot_client/project.godot + +# Configure API endpoint in project settings +# Run the project from editor or export to platform +``` + +### 5. Start Background Workers (Optional) + +For AI tasks, combat processing, etc.: + +```bash +cd api +source venv/bin/activate +rq worker ai_tasks combat_tasks marketplace_tasks --url redis://localhost:6379 +``` + +--- + +## Architecture Overview + +``` +┌─────────────────────┐ ┌─────────────────────┐ +│ Web Browser │ │ Godot Client │ +│ (Public Web) │ │ (Desktop/Mobile) │ +└──────────┬──────────┘ └──────────┬──────────┘ + │ │ + │ HTTP (REST API) │ HTTP (REST API) + │ WebSocket (Realtime) │ WebSocket (Realtime) + │ │ + └───────────────┬───────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ API Backend │ + │ (Flask REST API) │ + │ - Business Logic │ + │ - Game Mechanics │ + │ - AI Integration │ + │ - Auth & Sessions │ + └──────────┬───────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Appwrite │ │ Redis │ │ Claude AI│ + │ Database │ │ Queue │ │ Replicate│ + └──────────┘ └──────────┘ └──────────┘ +``` + +**Key Principles:** +- **API Backend** is the single source of truth for all game logic +- **Web Frontend** and **Godot Client** are thin clients (no business logic) +- All clients communicate with API via REST endpoints +- Realtime updates via Appwrite Realtime (WebSocket) +- Each component deploys independently + +--- + +## Tech Stack Summary + +### API Backend (`/api`) +- Flask 3.0+ +- Python 3.11+ +- Appwrite (database, auth, realtime) +- RQ (Redis Queue) for background jobs +- Anthropic Claude API +- Replicate API + +### Web Frontend (`/public_web`) +- Flask 3.0+ (view layer only) +- Jinja2 templates +- HTMX (AJAX interactions) +- Appwrite JavaScript SDK (realtime) +- Vanilla JavaScript + +### Godot Client (`/godot_client`) +- Godot Engine 4.5 +- GDScript +- HTTP requests (via HTTPRequest node) +- WebSocket (via WebSocketPeer) + +--- + +## Current Development Phase + +**Phase 3:** ✅ Complete - AI Integration & Story Progression +- AI-powered narrative generation +- Story progression system +- Quest system +- Combat AI + +**Phase 4:** 🚧 In Progress - Quests, Story Progression, Multiplayer +- Quest offering and tracking +- Story arc progression +- Multiplayer sessions +- Godot client implementation + +See [docs/ROADMAP.md](docs/ROADMAP.md) for full development plan. + +--- + +## Development Guidelines + +Each component follows its own development guidelines: + +- **API Backend:** See [`api/CLAUDE.md`](api/CLAUDE.md) for backend development standards +- **Web Frontend:** See [`public_web/CLAUDE.md`](public_web/CLAUDE.md) for frontend development standards +- **Godot Client:** See [`godot_client/CLAUDE.md`](godot_client/CLAUDE.md) for client development standards +- **Project-Wide:** See [`CLAUDE.md`](CLAUDE.md) for overall project guidelines + +**Key Standards:** +- Microservices architecture (no shared code) +- API is the single source of truth +- Strong typing throughout (dataclasses, type hints) +- Security first (authentication, validation, sanitization) +- Cost-conscious AI usage +- Comprehensive documentation + +--- + +## Testing + +### API Backend +```bash +cd api +pytest +# See api/docs/API_TESTING.md +``` + +### Web Frontend +Manual testing preferred. See [`public_web/docs/TESTING.md`](public_web/docs/TESTING.md) + +### Godot Client +Manual testing via Godot editor. See [`godot_client/docs/README.md`](godot_client/docs/README.md) + +--- + +## Deployment + +Each component deploys independently: + +- **API Backend:** Docker container, Gunicorn, Nginx +- **Web Frontend:** Docker container, Gunicorn, Nginx +- **Godot Client:** Platform-specific exports (Windows, macOS, Linux, Android, iOS, Web) + +See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for detailed deployment instructions. + +--- + +## Contributing + +This is a personal project. Contributions are not currently being accepted. + +--- + +## License + +Proprietary. All rights reserved. + +--- + +## Contact + +For questions or feedback, see project documentation in [`/docs`](docs/). diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..c06f14c --- /dev/null +++ b/api/.env.example @@ -0,0 +1,64 @@ +# Flask Configuration +FLASK_ENV=development +FLASK_APP=app +SECRET_KEY=your-secret-key-here-change-in-production + +# Application Configuration +APP_NAME=Code of Conquest +APP_VERSION=0.1.0 +DEBUG=True + +# Redis Configuration +REDIS_URL=redis://localhost:6379/0 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 + +# Appwrite Configuration +APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +APPWRITE_PROJECT_ID=your-project-id-here +APPWRITE_API_KEY=your-api-key-here +APPWRITE_DATABASE_ID=main +# Required Appwrite Collections: +# - characters +# - game_sessions +# - ai_usage_logs (for usage tracking - Task 7.13) + +# AI Configuration (Replicate API - all models) +# All AI models (Llama-3, Claude Haiku/Sonnet/Opus) are accessed via Replicate +REPLICATE_API_TOKEN=your-replicate-token-here +REPLICATE_MODEL=meta/meta-llama-3-8b-instruct +# Available models: +# - meta/meta-llama-3-8b-instruct (Free tier) +# - anthropic/claude-3-haiku (Basic tier) +# - anthropic/claude-3.5-sonnet (Premium tier) +# - anthropic/claude-3-opus (Elite tier) + +# Logging Configuration +LOG_LEVEL=DEBUG +LOG_FORMAT=json + +# Rate Limiting +RATE_LIMIT_ENABLED=True +RATE_LIMIT_STORAGE_URL=redis://localhost:6379/1 + +# CORS Configuration +CORS_ORIGINS=http://localhost:5000,http://127.0.0.1:5000 + +# Session Configuration +SESSION_TIMEOUT_MINUTES=30 +AUTO_SAVE_INTERVAL=5 + +# AI Configuration +AI_DEFAULT_TIMEOUT=30 +AI_MAX_RETRIES=3 +AI_COST_ALERT_THRESHOLD=100.00 + +# Marketplace Configuration +MARKETPLACE_AUCTION_CHECK_INTERVAL=300 + +# Security +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Testing (optional) +TESTING=False diff --git a/api/CLAUDE.md b/api/CLAUDE.md new file mode 100644 index 0000000..fa09d1b --- /dev/null +++ b/api/CLAUDE.md @@ -0,0 +1,366 @@ +# CLAUDE.md - API Backend + +## Service Overview +**API Backend** for Code of Conquest - The single source of truth for all business logic, game mechanics, and data operations. + +**Tech Stack:** Flask + Appwrite + RQ + Redis + Replicate API (Llama-3/Claude models) +**Port:** 5000 (development), 5000 (production internal) +**Location:** `/api` + +--- + +## Architecture Role + +This API backend is the **single source of truth** for all game logic: +- ✅ All business logic +- ✅ Data models and validation +- ✅ Database operations (Appwrite) +- ✅ Authentication & authorization +- ✅ Game mechanics calculations +- ✅ AI orchestration (all models via Replicate API) +- ✅ Background job processing (RQ) + +**What this service does NOT do:** +- ❌ No UI rendering (that's `/public_web` and `/godot_client`) +- ❌ No direct user interaction (only via API endpoints) + +--- + +## Documentation Index + +**API Backend Documentation:** +- **[API_REFERENCE.md](docs/API_REFERENCE.md)** - API endpoints and response formats +- **[DATA_MODELS.md](docs/DATA_MODELS.md)** - Character system, items, skills, effects +- **[GAME_SYSTEMS.md](docs/GAME_SYSTEMS.md)** - Combat mechanics, marketplace, NPCs +- **[QUEST_SYSTEM.md](docs/QUEST_SYSTEM.md)** - Quest mechanics and data structures +- **[STORY_PROGRESSION.md](docs/STORY_PROGRESSION.md)** - Story progression system +- **[MULTIPLAYER.md](docs/MULTIPLAYER.md)** - Multiplayer session backend logic +- **[API_TESTING.md](docs/API_TESTING.md)** - API testing guide with examples +- **[APPWRITE_SETUP.md](docs/APPWRITE_SETUP.md)** - Database setup guide +- **[PHASE4_IMPLEMENTATION.md](docs/PHASE4_IMPLEMENTATION.md)** - Phase 4 detailed implementation tasks + +**Project-Wide Documentation:** +- **[../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md)** - System architecture overview +- **[../docs/ROADMAP.md](../docs/ROADMAP.md)** - Development roadmap and progress tracking +- **[../docs/DEPLOYMENT.md](../docs/DEPLOYMENT.md)** - Deployment guide +- **[../docs/WEB_VS_CLIENT_SYSTEMS.md](../docs/WEB_VS_CLIENT_SYSTEMS.md)** - Feature distribution between frontends + +**Documentation Hierarchy:** +- `/docs/` = Project-wide roadmap, architecture, and cross-service documentation +- `/api/docs/` = API-specific implementation details (endpoints, data models, game systems) + +> **Note:** For overall project progress, always check `/docs/ROADMAP.md`. Service-specific docs contain implementation details. + +--- + +## Development Guidelines + +### Project Structure + +``` +api/ +├── app/ # Application code +│ ├── api/ # API endpoint blueprints +│ ├── models/ # Data models (dataclasses) +│ ├── services/ # Business logic & integrations +│ ├── utils/ # Utilities +│ ├── tasks/ # Background jobs (RQ) +│ ├── ai/ # AI integration +│ ├── game_logic/ # Game mechanics +│ └── data/ # Game data (YAML) +├── config/ # Configuration files +├── tests/ # Test suite +├── scripts/ # Utility scripts +└── docs/ # API documentation +``` + +### Coding Standards + +**Style & Structure** +- Prefer longer, explicit code over compact one-liners +- Always include docstrings for functions/classes + inline comments +- Strongly prefer OOP-style code (classes over functional/nested functions) +- Strong typing throughout (dataclasses, TypedDict, Enums, type hints) +- Value future-proofing and expanded usage insights + +**Data Design** +- Use dataclasses for internal data modeling +- Typed JSON structures +- Functions return fully typed objects (no loose dicts) +- Snapshot files in JSON or YAML +- Human-readable fields (e.g., `scan_duration`) + +**Logging** +- Use structlog (pip package) +- Setup logging at app start: `logger = structlog.get_logger(__file__)` + +**Preferred Pip Packages** +- API/Web Server: Flask +- HTTP: Requests +- Logging: Structlog +- Job Queue: RQ +- Testing: Pytest + +### API Design Standards + +**RESTful Conventions:** +- Use proper HTTP methods (GET, POST, PUT, DELETE) +- Resource-based URLs: `/api/v1/characters`, not `/api/v1/get_character` +- Versioning: `/api/v1/...` + +**Standardized Response Format:** +```json +{ + "app": "Code of Conquest API", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-01-15T10:30:00Z", + "request_id": "optional-request-id", + "result": { + "data": "..." + }, + "error": null, + "meta": {} +} +``` + +**Error Responses:** +```json +{ + "app": "Code of Conquest API", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-01-15T10:30:00Z", + "result": null, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid character name", + "details": { + "field": "name", + "issue": "Name must be between 3 and 50 characters" + } + } +} +``` + +### Error Handling +- Custom exception classes for domain-specific errors +- Consistent error response formats (use `app.utils.response`) +- Logging severity levels (ERROR vs WARNING) +- Never expose internal errors to clients (sanitize stack traces) + +### Security Standards + +**Authentication & Authorization** +- Use Appwrite Auth for user management +- HTTP-only cookies for session storage +- Session validation on every protected API call +- User ID verification (users can only access their own data) +- Use `@require_auth` decorator for protected endpoints + +**Input Validation** +- Validate ALL JSON payloads against schemas +- Sanitize user inputs (character names, chat messages) +- Prevent injection attacks (SQL, NoSQL, command injection) +- Use bleach for HTML sanitization + +**Rate Limiting** +- AI endpoint limits based on subscription tier +- Use Flask-Limiter with Redis backend +- Configure limits in `config/*.yaml` + +**API Security** +- CORS properly configured (only allow frontend domains) +- API keys stored in environment variables (never in code) +- Appwrite permissions set correctly on all collections +- HTTPS only in production + +**Cost Control (AI)** +- Daily limits on AI calls per tier +- Max tokens per request type +- Cost logging for analytics and alerts +- Graceful degradation if limits exceeded + +### Configuration +- Environment-specific configs in `/config/*.yaml` + - `development.yaml` - Local dev settings + - `production.yaml` - Production settings +- `.env` for secrets (never committed) +- Maintain `.env.example` for documentation +- Typed config loaders using dataclasses +- Validation on startup + +### Testing Standards + +**Unit Tests (Pytest):** +- Test all models, services, game logic +- Test files in `/tests` +- Run with: `pytest` +- Coverage: `pytest --cov=app tests/` + +**Integration Tests:** +- Test API endpoints end-to-end +- Use test database/fixtures +- Example: `tests/test_api_characters_integration.py` + +**API Testing:** +- Maintain `docs/API_TESTING.md` with curl/httpie examples +- Document expected responses +- Test all endpoints manually before commit + +### Dependency Management +- Use `requirements.txt` in `/api` directory +- Use virtual environment: `python3 -m venv venv` +- Pin versions to version ranges +- Activate venv before running: `source venv/bin/activate` + +### Background Jobs (RQ) + +**Queue Types:** +- `ai_tasks` - AI narrative generation +- `combat_tasks` - Combat processing +- `marketplace_tasks` - Auction cleanup, periodic tasks + +**Job Implementation:** +- Define jobs in `app/tasks/` +- Use `@job` decorator from RQ +- Jobs should be idempotent (safe to retry) +- Log job start, completion, errors + +**Running Workers:** +```bash +rq worker ai_tasks combat_tasks marketplace_tasks --url redis://localhost:6379 +``` + +--- + +## Workflow for API Development + +When implementing new API features: + +1. **Start with models** - Define dataclasses in `app/models/` +2. **Write tests** - TDD approach (test first, then implement) +3. **Implement service** - Business logic in `app/services/` +4. **Create endpoint** - API blueprint in `app/api/` +5. **Test manually** - Use curl/httpie, update `docs/API_TESTING.md` +6. **Security review** - Check auth, validation, rate limiting +7. **Document** - Update `docs/API_REFERENCE.md` + +**Example Flow:** +```bash +# 1. Create model +# app/models/quest.py - Define Quest dataclass + +# 2. Write test +# tests/test_quest.py - Test quest creation, validation + +# 3. Implement service +# app/services/quest_service.py - Quest CRUD operations + +# 4. Create endpoint +# app/api/quests.py - REST endpoints + +# 5. Test +curl -X POST http://localhost:5000/api/v1/quests \ + -H "Content-Type: application/json" \ + -d '{"title": "Find the Ancient Relic"}' + +# 6. Document +# Update docs/API_REFERENCE.md and docs/API_TESTING.md +``` + +--- + +## Running the API Backend + +### Development + +**Prerequisites:** +- Python 3.11+ +- Redis running (via Docker Compose) +- Appwrite instance configured + +**Setup:** +```bash +cd api +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# Edit .env with your credentials +``` + +**Initialize Database:** +```bash +python scripts/init_database.py +``` + +**Run Development Server:** +```bash +# Terminal 1: Redis +docker-compose up + +# Terminal 2: API Server +source venv/bin/activate +export FLASK_ENV=development +python wsgi.py # → http://localhost:5000 + +# Terminal 3: RQ Worker (optional) +source venv/bin/activate +rq worker ai_tasks --url redis://localhost:6379 +``` + +### Production + +**Run with Gunicorn:** +```bash +gunicorn --bind 0.0.0.0:5000 --workers 4 wsgi:app +``` + +**Environment Variables:** +``` +FLASK_ENV=production +APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +APPWRITE_PROJECT_ID=... +APPWRITE_API_KEY=... +APPWRITE_DATABASE_ID=... +ANTHROPIC_API_KEY=... +REPLICATE_API_TOKEN=... +REDIS_URL=redis://redis:6379 +SECRET_KEY=... +``` + +--- + +## Git Standards + +**Commit Messages:** +- Use conventional commit format: `feat:`, `fix:`, `docs:`, `refactor:`, etc. +- Examples: + - `feat(api): add quest endpoints` + - `fix(models): character stat calculation bug` + - `docs(api): update API_REFERENCE with quest endpoints` + +**Branch Strategy:** +- Branch off `dev` for features +- Merge back to `dev` for testing +- Promote to `master` for production + +--- + +## Notes for Claude Code + +When working on the API backend: + +1. **Business logic lives here** - This is the single source of truth +2. **No UI code** - Don't create templates or frontend code in this service +3. **Security first** - Validate inputs, check permissions, sanitize outputs +4. **Cost conscious** - Monitor AI usage, implement limits +5. **Test thoroughly** - Use pytest, write integration tests +6. **Document as you go** - Update API_REFERENCE.md and API_TESTING.md +7. **Think about frontends** - Design endpoints that work for both web and Godot clients + +**Remember:** +- Developer has strong security expertise (don't compromise security for convenience) +- Developer has extensive infrastructure experience (focus on application logic) +- This API serves multiple frontends (web and Godot) - keep it generic diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..12d83f9 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,35 @@ +# Code of Conquest API Backend +# Production-ready Dockerfile with Gunicorn + +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Set work directory +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create non-root user for security +RUN useradd --create-home --shell /bin/bash appuser && \ + chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 5000 + +# Run with Gunicorn +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "wsgi:app"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..5b12b1b --- /dev/null +++ b/api/README.md @@ -0,0 +1,204 @@ +# Code of Conquest - API Backend + +Flask-based REST API backend for Code of Conquest, an AI-powered D&D-style game. + +## Overview + +This is the **API backend** component of Code of Conquest. It provides: + +- RESTful API endpoints for game functionality +- Business logic and game mechanics +- Database operations (Appwrite) +- AI integration (Claude, Replicate) +- Background job processing (RQ + Redis) +- Authentication and authorization + +## Architecture + +**Tech Stack:** +- **Framework:** Flask 3.x +- **Database:** Appwrite (NoSQL) +- **Job Queue:** RQ (Redis Queue) +- **Cache:** Redis +- **AI:** Anthropic Claude, Replicate +- **Logging:** Structlog +- **WSGI Server:** Gunicorn + +**Key Components:** +- `/app/api` - API endpoint blueprints +- `/app/models` - Data models (dataclasses) +- `/app/services` - Business logic and external integrations +- `/app/utils` - Helper functions +- `/app/tasks` - Background job handlers +- `/app/data` - Game data (YAML files) + +## Setup + +### Prerequisites +- Python 3.11+ +- Redis server +- Appwrite instance (cloud or self-hosted) + +### Installation + +1. Create virtual environment: +```bash +python3 -m venv venv +source venv/bin/activate +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + +3. Configure environment: +```bash +cp .env.example .env +# Edit .env with your credentials +``` + +4. Initialize database: +```bash +python scripts/init_database.py +``` + +### Running Locally + +**Development mode:** +```bash +# Make sure Redis is running +docker-compose up -d + +# Activate virtual environment +source venv/bin/activate + +# Set environment +export FLASK_ENV=development + +# Run development server +python wsgi.py +``` + +The API will be available at `http://localhost:5000` + +**Production mode:** +```bash +gunicorn --bind 0.0.0.0:5000 --workers 4 wsgi:app +``` + +## Configuration + +Environment-specific configs are in `/config`: +- `development.yaml` - Local development settings +- `production.yaml` - Production settings + +Key settings: +- **Server:** Port, workers +- **Redis:** Connection settings +- **RQ:** Queue configuration +- **AI:** Model settings, rate limits +- **Auth:** Session, password requirements +- **CORS:** Allowed origins + +## API Documentation + +See [API_REFERENCE.md](docs/API_REFERENCE.md) for complete endpoint documentation. + +### Base URL +- **Development:** `http://localhost:5000` +- **Production:** `https://api.codeofconquest.com` + +### Authentication +Uses Appwrite session-based authentication with HTTP-only cookies. + +### Response Format +All endpoints return standardized JSON responses: +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-01-15T10:30:00Z", + "result": {...}, + "error": null +} +``` + +## Testing + +Run tests with pytest: +```bash +# Activate virtual environment +source venv/bin/activate + +# Run all tests +pytest + +# Run with coverage +pytest --cov=app tests/ + +# Run specific test file +pytest tests/test_character.py +``` + +## Background Jobs + +The API uses RQ for background processing: + +**Start RQ worker:** +```bash +rq worker ai_tasks combat_tasks marketplace_tasks --url redis://localhost:6379 +``` + +**Monitor jobs:** +```bash +rq info --url redis://localhost:6379 +``` + +## Directory Structure + +``` +api/ +├── app/ # Application code +│ ├── api/ # API endpoint blueprints +│ ├── models/ # Data models +│ ├── services/ # Business logic +│ ├── utils/ # Utilities +│ ├── tasks/ # Background jobs +│ ├── ai/ # AI integration +│ ├── game_logic/ # Game mechanics +│ └── data/ # Game data (YAML) +├── config/ # Configuration files +├── tests/ # Test suite +├── scripts/ # Utility scripts +├── logs/ # Application logs +├── docs/ # API documentation +├── requirements.txt # Python dependencies +├── wsgi.py # WSGI entry point +├── docker-compose.yml # Redis service +└── .env.example # Environment template +``` + +## Development + +See [CLAUDE.md](../CLAUDE.md) in the project root for: +- Coding standards +- Development workflow +- Project structure guidelines +- Git conventions + +## Deployment + +See [DEPLOYMENT.md](../docs/DEPLOYMENT.md) for production deployment instructions. + +## Related Components + +- **Public Web:** `/public_web` - Traditional web frontend +- **Godot Client:** `/godot_client` - Native game client + +Both frontends consume this API backend. + +## License + +Proprietary - All rights reserved diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..cf48a3c --- /dev/null +++ b/api/app/__init__.py @@ -0,0 +1,171 @@ +""" +Flask application factory for Code of Conquest. + +Creates and configures the Flask application instance. +""" + +import os +from flask import Flask +from flask_cors import CORS +from app.config import get_config +from app.utils.logging import setup_logging, get_logger + + +def create_app(environment: str = None) -> Flask: + """ + Application factory pattern for creating Flask app. + + Args: + environment: Environment name (development, production, etc.) + If None, uses FLASK_ENV from environment variables. + + Returns: + Flask: Configured Flask application instance + + Example: + >>> app = create_app('development') + >>> app.run(debug=True) + """ + # Get the path to the project root (parent of 'app' package) + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + # Create Flask app with correct template and static folders + app = Flask( + __name__, + template_folder=os.path.join(project_root, 'templates'), + static_folder=os.path.join(project_root, 'static') + ) + + # Load configuration + config = get_config(environment) + + # Configure Flask from config object + app.config['SECRET_KEY'] = config.secret_key + app.config['DEBUG'] = config.app.debug + + # Set up logging + setup_logging( + log_level=config.logging.level, + log_format=config.logging.format, + log_file=config.logging.file_path if 'file' in config.logging.handlers else None + ) + + logger = get_logger(__name__) + logger.info( + "Starting Code of Conquest", + version=config.app.version, + environment=config.app.environment + ) + + # Configure CORS + CORS(app, origins=config.cors.origins) + + # Store config in app context + app.config['COC_CONFIG'] = config + + # Register error handlers + register_error_handlers(app) + + # Register blueprints (when created) + register_blueprints(app) + + logger.info("Application initialized successfully") + + return app + + +def register_error_handlers(app: Flask) -> None: + """ + Register global error handlers for the application. + + Args: + app: Flask application instance + """ + from app.utils.response import ( + error_response, + internal_error_response, + not_found_response + ) + + logger = get_logger(__name__) + + @app.errorhandler(404) + def handle_404(error): + """Handle 404 Not Found errors.""" + logger.warning("404 Not Found", path=error.description) + return not_found_response() + + @app.errorhandler(500) + def handle_500(error): + """Handle 500 Internal Server errors.""" + logger.error("500 Internal Server Error", error=str(error), exc_info=True) + return internal_error_response() + + @app.errorhandler(Exception) + def handle_exception(error): + """Handle uncaught exceptions.""" + logger.error( + "Uncaught exception", + error=str(error), + error_type=type(error).__name__, + exc_info=True + ) + return internal_error_response() + + +def register_blueprints(app: Flask) -> None: + """ + Register Flask blueprints (API routes and web UI views). + + Args: + app: Flask application instance + """ + logger = get_logger(__name__) + + # ===== API Blueprints ===== + + # Import and register health check API blueprint + from app.api.health import health_bp + app.register_blueprint(health_bp) + logger.info("Health API blueprint registered") + + # Import and register auth API blueprint + from app.api.auth import auth_bp + app.register_blueprint(auth_bp) + logger.info("Auth API blueprint registered") + + # Import and register characters API blueprint + from app.api.characters import characters_bp + app.register_blueprint(characters_bp) + logger.info("Characters API blueprint registered") + + # Import and register sessions API blueprint + from app.api.sessions import sessions_bp + app.register_blueprint(sessions_bp) + logger.info("Sessions API blueprint registered") + + # Import and register jobs API blueprint + from app.api.jobs import jobs_bp + app.register_blueprint(jobs_bp) + logger.info("Jobs API blueprint registered") + + # Import and register game mechanics API blueprint + from app.api.game_mechanics import game_mechanics_bp + app.register_blueprint(game_mechanics_bp) + logger.info("Game Mechanics API blueprint registered") + + # Import and register travel API blueprint + from app.api.travel import travel_bp + app.register_blueprint(travel_bp) + logger.info("Travel API blueprint registered") + + # Import and register NPCs API blueprint + from app.api.npcs import npcs_bp + app.register_blueprint(npcs_bp) + logger.info("NPCs API blueprint registered") + + # TODO: Register additional blueprints as they are created + # from app.api import combat, marketplace, shop + # app.register_blueprint(combat.bp, url_prefix='/api/v1/combat') + # app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace') + # app.register_blueprint(shop.bp, url_prefix='/api/v1/shop') diff --git a/api/app/ai/__init__.py b/api/app/ai/__init__.py new file mode 100644 index 0000000..876a406 --- /dev/null +++ b/api/app/ai/__init__.py @@ -0,0 +1,61 @@ +""" +AI integration module for Code of Conquest. + +This module contains clients and utilities for AI-powered features +including narrative generation, quest selection, and NPC dialogue. +""" + +from app.ai.replicate_client import ( + ReplicateClient, + ReplicateResponse, + ReplicateClientError, + ReplicateAPIError, + ReplicateRateLimitError, + ReplicateTimeoutError, + ModelType, +) + +from app.ai.model_selector import ( + ModelSelector, + ModelConfig, + UserTier, + ContextType, +) + +from app.ai.prompt_templates import ( + PromptTemplates, + PromptTemplateError, + get_prompt_templates, + render_prompt, +) + +from app.ai.narrative_generator import ( + NarrativeGenerator, + NarrativeResponse, + NarrativeGeneratorError, +) + +__all__ = [ + # Replicate client + "ReplicateClient", + "ReplicateResponse", + "ReplicateClientError", + "ReplicateAPIError", + "ReplicateRateLimitError", + "ReplicateTimeoutError", + "ModelType", + # Model selector + "ModelSelector", + "ModelConfig", + "UserTier", + "ContextType", + # Prompt templates + "PromptTemplates", + "PromptTemplateError", + "get_prompt_templates", + "render_prompt", + # Narrative generator + "NarrativeGenerator", + "NarrativeResponse", + "NarrativeGeneratorError", +] diff --git a/api/app/ai/model_selector.py b/api/app/ai/model_selector.py new file mode 100644 index 0000000..3b7a630 --- /dev/null +++ b/api/app/ai/model_selector.py @@ -0,0 +1,226 @@ +""" +Model selector for tier-based AI model routing. + +This module provides intelligent model selection based on user subscription tiers +and context types to optimize cost and quality. +""" + +from dataclasses import dataclass +from enum import Enum, auto + +import structlog + +from app.ai.replicate_client import ModelType + +logger = structlog.get_logger(__name__) + + +class UserTier(str, Enum): + """User subscription tiers.""" + FREE = "free" + BASIC = "basic" + PREMIUM = "premium" + ELITE = "elite" + + +class ContextType(str, Enum): + """Types of AI generation contexts.""" + STORY_PROGRESSION = "story_progression" + COMBAT_NARRATION = "combat_narration" + QUEST_SELECTION = "quest_selection" + NPC_DIALOGUE = "npc_dialogue" + SIMPLE_RESPONSE = "simple_response" + + +@dataclass +class ModelConfig: + """Configuration for a selected model.""" + model_type: ModelType + max_tokens: int + temperature: float + + @property + def model(self) -> str: + """Get the model identifier string.""" + return self.model_type.value + + +class ModelSelector: + """ + Selects appropriate AI models based on user tier and context. + + This class implements tier-based routing to ensure: + - Free users get Llama-3 (no cost) + - Basic users get Claude Haiku (low cost) + - Premium users get Claude Sonnet (medium cost) + - Elite users get Claude Opus (high cost) + + Context-specific optimizations adjust token limits and temperature + for different use cases. + """ + + # Tier to model mapping + TIER_MODELS = { + UserTier.FREE: ModelType.LLAMA_3_8B, + UserTier.BASIC: ModelType.CLAUDE_HAIKU, + UserTier.PREMIUM: ModelType.CLAUDE_SONNET, + UserTier.ELITE: ModelType.CLAUDE_SONNET_4, + } + + # Base token limits by tier + BASE_TOKEN_LIMITS = { + UserTier.FREE: 256, + UserTier.BASIC: 512, + UserTier.PREMIUM: 1024, + UserTier.ELITE: 2048, + } + + # Temperature settings by context type + CONTEXT_TEMPERATURES = { + ContextType.STORY_PROGRESSION: 0.9, # Creative, varied + ContextType.COMBAT_NARRATION: 0.8, # Exciting but coherent + ContextType.QUEST_SELECTION: 0.5, # More deterministic + ContextType.NPC_DIALOGUE: 0.85, # Natural conversation + ContextType.SIMPLE_RESPONSE: 0.7, # Balanced + } + + # Token multipliers by context (relative to base) + CONTEXT_TOKEN_MULTIPLIERS = { + ContextType.STORY_PROGRESSION: 1.0, # Full allocation + ContextType.COMBAT_NARRATION: 0.75, # Shorter, punchier + ContextType.QUEST_SELECTION: 0.5, # Brief selection + ContextType.NPC_DIALOGUE: 0.75, # Conversational + ContextType.SIMPLE_RESPONSE: 0.5, # Quick responses + } + + def __init__(self): + """Initialize the model selector.""" + logger.info("ModelSelector initialized") + + def select_model( + self, + user_tier: UserTier, + context_type: ContextType = ContextType.SIMPLE_RESPONSE + ) -> ModelConfig: + """ + Select the appropriate model configuration for a user and context. + + Args: + user_tier: The user's subscription tier. + context_type: The type of content being generated. + + Returns: + ModelConfig with model type, token limit, and temperature. + + Example: + >>> selector = ModelSelector() + >>> config = selector.select_model(UserTier.PREMIUM, ContextType.STORY_PROGRESSION) + >>> config.model_type + + """ + # Get model for tier + model_type = self.TIER_MODELS[user_tier] + + # Calculate max tokens + base_tokens = self.BASE_TOKEN_LIMITS[user_tier] + multiplier = self.CONTEXT_TOKEN_MULTIPLIERS.get(context_type, 1.0) + max_tokens = int(base_tokens * multiplier) + + # Get temperature for context + temperature = self.CONTEXT_TEMPERATURES.get(context_type, 0.7) + + config = ModelConfig( + model_type=model_type, + max_tokens=max_tokens, + temperature=temperature + ) + + logger.debug( + "Model selected", + user_tier=user_tier.value, + context_type=context_type.value, + model=model_type.value, + max_tokens=max_tokens, + temperature=temperature + ) + + return config + + def get_model_for_tier(self, user_tier: UserTier) -> ModelType: + """ + Get the default model for a user tier. + + Args: + user_tier: The user's subscription tier. + + Returns: + The ModelType for this tier. + """ + return self.TIER_MODELS[user_tier] + + def get_tier_info(self, user_tier: UserTier) -> dict: + """ + Get information about a tier's AI capabilities. + + Args: + user_tier: The user's subscription tier. + + Returns: + Dictionary with tier information. + """ + model_type = self.TIER_MODELS[user_tier] + + # Map models to friendly names + model_names = { + ModelType.LLAMA_3_8B: "Llama 3 8B", + ModelType.CLAUDE_HAIKU: "Claude 3 Haiku", + ModelType.CLAUDE_SONNET: "Claude 3.5 Sonnet", + ModelType.CLAUDE_SONNET_4: "Claude Sonnet 4", + } + + # Model quality descriptions + quality_descriptions = { + ModelType.LLAMA_3_8B: "Good quality, optimized for speed", + ModelType.CLAUDE_HAIKU: "High quality, fast responses", + ModelType.CLAUDE_SONNET: "Excellent quality, detailed narratives", + ModelType.CLAUDE_SONNET_4: "Best quality, most creative and nuanced", + } + + return { + "tier": user_tier.value, + "model": model_type.value, + "model_name": model_names.get(model_type, model_type.value), + "base_tokens": self.BASE_TOKEN_LIMITS[user_tier], + "quality": quality_descriptions.get(model_type, "Standard quality"), + } + + def estimate_cost_per_request(self, user_tier: UserTier) -> float: + """ + Estimate the cost per AI request for a tier. + + Args: + user_tier: The user's subscription tier. + + Returns: + Estimated cost in USD per request. + + Note: + These are rough estimates based on typical usage. + Actual costs depend on input/output tokens. + """ + # Approximate cost per 1K tokens (input + output average) + COST_PER_1K_TOKENS = { + ModelType.LLAMA_3_8B: 0.0, # Free tier + ModelType.CLAUDE_HAIKU: 0.001, # $0.25/1M input, $1.25/1M output + ModelType.CLAUDE_SONNET: 0.006, # $3/1M input, $15/1M output + ModelType.CLAUDE_SONNET_4: 0.015, # Claude Sonnet 4 pricing + } + + model_type = self.TIER_MODELS[user_tier] + base_tokens = self.BASE_TOKEN_LIMITS[user_tier] + cost_per_1k = COST_PER_1K_TOKENS.get(model_type, 0.0) + + # Estimate: base tokens for output + ~50% for input tokens + estimated_tokens = base_tokens * 1.5 + + return (estimated_tokens / 1000) * cost_per_1k diff --git a/api/app/ai/narrative_generator.py b/api/app/ai/narrative_generator.py new file mode 100644 index 0000000..1104153 --- /dev/null +++ b/api/app/ai/narrative_generator.py @@ -0,0 +1,540 @@ +""" +Narrative generator wrapper for AI content generation. + +This module provides a high-level API for generating narrative content +using the appropriate AI models based on user tier and context. +""" + +from dataclasses import dataclass +from typing import Any + +import structlog + +from app.ai.replicate_client import ( + ReplicateClient, + ReplicateResponse, + ReplicateClientError, +) +from app.ai.model_selector import ( + ModelSelector, + ModelConfig, + UserTier, + ContextType, +) +from app.ai.prompt_templates import ( + PromptTemplates, + PromptTemplateError, + get_prompt_templates, +) + +logger = structlog.get_logger(__name__) + + +@dataclass +class NarrativeResponse: + """Response from narrative generation.""" + narrative: str + tokens_used: int + tokens_input: int + tokens_output: int + model: str + context_type: str + generation_time: float + + +class NarrativeGeneratorError(Exception): + """Base exception for narrative generator errors.""" + pass + + +class NarrativeGenerator: + """ + High-level wrapper for AI narrative generation. + + This class coordinates between the model selector, prompt templates, + and AI clients to generate narrative content for the game. + + It provides specialized methods for different narrative contexts: + - Story progression responses + - Combat narration + - Quest selection + - NPC dialogue + """ + + # System prompts for different contexts + SYSTEM_PROMPTS = { + ContextType.STORY_PROGRESSION: ( + "You are an expert Dungeon Master running a solo D&D-style adventure. " + "Create immersive, engaging narratives that respond to player actions. " + "Be descriptive but concise. Always end with a clear opportunity for the player to act. " + "CRITICAL: NEVER give the player items, gold, equipment, or any rewards unless the action " + "instructions explicitly state they should receive them. Only narrate what the template " + "describes - do not improvise rewards or discoveries." + ), + ContextType.COMBAT_NARRATION: ( + "You are a combat narrator for a fantasy RPG. " + "Describe actions with visceral, cinematic detail. " + "Keep narration punchy and exciting. Never include game mechanics in prose." + ), + ContextType.QUEST_SELECTION: ( + "You are a quest selection system. " + "Analyze the context and select the most narratively appropriate quest. " + "Respond only with the quest_id - no explanation." + ), + ContextType.NPC_DIALOGUE: ( + "You are a skilled voice actor portraying NPCs in a fantasy world. " + "Stay in character at all times. Give each NPC a distinct voice and personality. " + "Provide useful information while maintaining immersion." + ), + } + + def __init__( + self, + model_selector: ModelSelector | None = None, + replicate_client: ReplicateClient | None = None, + prompt_templates: PromptTemplates | None = None + ): + """ + Initialize the narrative generator. + + Args: + model_selector: Optional custom model selector. + replicate_client: Optional custom Replicate client. + prompt_templates: Optional custom prompt templates. + """ + self.model_selector = model_selector or ModelSelector() + self.replicate_client = replicate_client + self.prompt_templates = prompt_templates or get_prompt_templates() + + logger.info("NarrativeGenerator initialized") + + def _get_client(self, model_config: ModelConfig) -> ReplicateClient: + """ + Get or create a Replicate client for the given model configuration. + + Args: + model_config: The model configuration to use. + + Returns: + ReplicateClient configured for the specified model. + """ + # If a client was provided at init, use it + if self.replicate_client: + return self.replicate_client + + # Otherwise create a new client with the specified model + return ReplicateClient(model=model_config.model_type) + + def generate_story_response( + self, + character: dict[str, Any], + action: str, + game_state: dict[str, Any], + user_tier: UserTier, + conversation_history: list[dict[str, Any]] | None = None, + world_context: str | None = None, + action_instructions: str | None = None + ) -> NarrativeResponse: + """ + Generate a DM response to a player's story action. + + Args: + character: Character data dictionary with name, level, player_class, stats, etc. + action: The action the player wants to take. + game_state: Current game state with location, quests, etc. + user_tier: The user's subscription tier. + conversation_history: Optional list of recent conversation entries. + world_context: Optional additional world information. + action_instructions: Optional action-specific instructions for the AI from + the dm_prompt_template field in action_prompts.yaml. + + Returns: + NarrativeResponse with the generated narrative and metadata. + + Raises: + NarrativeGeneratorError: If generation fails. + + Example: + >>> generator = NarrativeGenerator() + >>> response = generator.generate_story_response( + ... character={"name": "Aldric", "level": 3, "player_class": "Fighter", ...}, + ... action="I search the room for hidden doors", + ... game_state={"current_location": "Ancient Library", ...}, + ... user_tier=UserTier.PREMIUM + ... ) + >>> print(response.narrative) + """ + context_type = ContextType.STORY_PROGRESSION + + logger.info( + "Generating story response", + character_name=character.get("name"), + action=action[:50], + user_tier=user_tier.value, + location=game_state.get("current_location") + ) + + # Get model configuration for this tier and context + model_config = self.model_selector.select_model(user_tier, context_type) + + # Build the prompt from template + try: + prompt = self.prompt_templates.render( + "story_action.j2", + character=character, + action=action, + game_state=game_state, + conversation_history=conversation_history or [], + world_context=world_context, + max_tokens=model_config.max_tokens, + action_instructions=action_instructions + ) + except PromptTemplateError as e: + logger.error("Failed to render story prompt", error=str(e)) + raise NarrativeGeneratorError(f"Prompt template error: {e}") + + # Debug: Log the full prompt being sent + logger.debug( + "Full prompt being sent to AI", + prompt_length=len(prompt), + conversation_history_count=len(conversation_history) if conversation_history else 0, + prompt_preview=prompt[:500] + "..." if len(prompt) > 500 else prompt + ) + # For detailed debugging, uncomment the line below: + print(f"\n{'='*60}\nFULL PROMPT:\n{'='*60}\n{prompt}\n{'='*60}\n") + + # Get system prompt + system_prompt = self.SYSTEM_PROMPTS[context_type] + + # Generate response + try: + client = self._get_client(model_config) + response = client.generate( + prompt=prompt, + system_prompt=system_prompt, + max_tokens=model_config.max_tokens, + temperature=model_config.temperature, + model=model_config.model_type + ) + except ReplicateClientError as e: + logger.error( + "AI generation failed", + error=str(e), + context_type=context_type.value + ) + raise NarrativeGeneratorError(f"AI generation failed: {e}") + + logger.info( + "Story response generated", + tokens_used=response.tokens_used, + model=response.model, + generation_time=f"{response.generation_time:.2f}s" + ) + + return NarrativeResponse( + narrative=response.text, + tokens_used=response.tokens_used, + tokens_input=response.tokens_input, + tokens_output=response.tokens_output, + model=response.model, + context_type=context_type.value, + generation_time=response.generation_time + ) + + def generate_combat_narration( + self, + character: dict[str, Any], + combat_state: dict[str, Any], + action: str, + action_result: dict[str, Any], + user_tier: UserTier, + is_critical: bool = False, + is_finishing_blow: bool = False + ) -> NarrativeResponse: + """ + Generate narration for a combat action. + + Args: + character: Character data dictionary. + combat_state: Current combat state with enemies, round number, etc. + action: Description of the combat action taken. + action_result: Result of the action (hit, damage, effects, etc.). + user_tier: The user's subscription tier. + is_critical: Whether this was a critical hit/miss. + is_finishing_blow: Whether this defeats the enemy. + + Returns: + NarrativeResponse with combat narration. + + Raises: + NarrativeGeneratorError: If generation fails. + + Example: + >>> response = generator.generate_combat_narration( + ... character={"name": "Aldric", ...}, + ... combat_state={"round_number": 3, "enemies": [...], ...}, + ... action="swings their sword at the goblin", + ... action_result={"hit": True, "damage": 12, ...}, + ... user_tier=UserTier.BASIC + ... ) + """ + context_type = ContextType.COMBAT_NARRATION + + logger.info( + "Generating combat narration", + character_name=character.get("name"), + action=action[:50], + is_critical=is_critical, + is_finishing_blow=is_finishing_blow + ) + + # Get model configuration + model_config = self.model_selector.select_model(user_tier, context_type) + + # Build the prompt + try: + prompt = self.prompt_templates.render( + "combat_action.j2", + character=character, + combat_state=combat_state, + action=action, + action_result=action_result, + is_critical=is_critical, + is_finishing_blow=is_finishing_blow, + max_tokens=model_config.max_tokens + ) + except PromptTemplateError as e: + logger.error("Failed to render combat prompt", error=str(e)) + raise NarrativeGeneratorError(f"Prompt template error: {e}") + + # Generate response + system_prompt = self.SYSTEM_PROMPTS[context_type] + + try: + client = self._get_client(model_config) + response = client.generate( + prompt=prompt, + system_prompt=system_prompt, + max_tokens=model_config.max_tokens, + temperature=model_config.temperature, + model=model_config.model_type + ) + except ReplicateClientError as e: + logger.error("Combat narration generation failed", error=str(e)) + raise NarrativeGeneratorError(f"AI generation failed: {e}") + + logger.info( + "Combat narration generated", + tokens_used=response.tokens_used, + generation_time=f"{response.generation_time:.2f}s" + ) + + return NarrativeResponse( + narrative=response.text, + tokens_used=response.tokens_used, + tokens_input=response.tokens_input, + tokens_output=response.tokens_output, + model=response.model, + context_type=context_type.value, + generation_time=response.generation_time + ) + + def generate_quest_selection( + self, + character: dict[str, Any], + eligible_quests: list[dict[str, Any]], + game_context: dict[str, Any], + user_tier: UserTier, + recent_actions: list[str] | None = None + ) -> str: + """ + Use AI to select the most contextually appropriate quest. + + Args: + character: Character data dictionary. + eligible_quests: List of quest data dictionaries that can be offered. + game_context: Current game context (location, events, etc.). + user_tier: The user's subscription tier. + recent_actions: Optional list of recent player actions. + + Returns: + The quest_id of the selected quest. + + Raises: + NarrativeGeneratorError: If generation fails or response is invalid. + + Example: + >>> quest_id = generator.generate_quest_selection( + ... character={"name": "Aldric", "level": 3, ...}, + ... eligible_quests=[{"quest_id": "goblin_cave", ...}, ...], + ... game_context={"current_location": "Tavern", ...}, + ... user_tier=UserTier.FREE + ... ) + >>> print(quest_id) # "goblin_cave" + """ + context_type = ContextType.QUEST_SELECTION + + logger.info( + "Generating quest selection", + character_name=character.get("name"), + num_eligible_quests=len(eligible_quests), + location=game_context.get("current_location") + ) + + if not eligible_quests: + raise NarrativeGeneratorError("No eligible quests provided") + + # Get model configuration + model_config = self.model_selector.select_model(user_tier, context_type) + + # Build the prompt + try: + prompt = self.prompt_templates.render( + "quest_offering.j2", + character=character, + eligible_quests=eligible_quests, + game_context=game_context, + recent_actions=recent_actions or [] + ) + except PromptTemplateError as e: + logger.error("Failed to render quest selection prompt", error=str(e)) + raise NarrativeGeneratorError(f"Prompt template error: {e}") + + # Generate response + system_prompt = self.SYSTEM_PROMPTS[context_type] + + try: + client = self._get_client(model_config) + response = client.generate( + prompt=prompt, + system_prompt=system_prompt, + max_tokens=model_config.max_tokens, + temperature=model_config.temperature, + model=model_config.model_type + ) + except ReplicateClientError as e: + logger.error("Quest selection generation failed", error=str(e)) + raise NarrativeGeneratorError(f"AI generation failed: {e}") + + # Parse the response to get quest_id + quest_id = response.text.strip().lower() + + # Validate the response is a valid quest_id + valid_quest_ids = {q.get("quest_id", "").lower() for q in eligible_quests} + if quest_id not in valid_quest_ids: + logger.warning( + "AI returned invalid quest_id, using first eligible quest", + returned_id=quest_id, + valid_ids=list(valid_quest_ids) + ) + quest_id = eligible_quests[0].get("quest_id", "") + + logger.info( + "Quest selected", + quest_id=quest_id, + tokens_used=response.tokens_used, + generation_time=f"{response.generation_time:.2f}s" + ) + + return quest_id + + def generate_npc_dialogue( + self, + character: dict[str, Any], + npc: dict[str, Any], + conversation_topic: str, + game_state: dict[str, Any], + user_tier: UserTier, + npc_relationship: str | None = None, + previous_dialogue: list[dict[str, Any]] | None = None, + npc_knowledge: list[str] | None = None + ) -> NarrativeResponse: + """ + Generate NPC dialogue in response to player conversation. + + Args: + character: Character data dictionary. + npc: NPC data with name, role, personality, etc. + conversation_topic: What the player said or wants to discuss. + game_state: Current game state. + user_tier: The user's subscription tier. + npc_relationship: Optional description of relationship with NPC. + previous_dialogue: Optional list of previous exchanges. + npc_knowledge: Optional list of things this NPC knows about. + + Returns: + NarrativeResponse with NPC dialogue. + + Raises: + NarrativeGeneratorError: If generation fails. + + Example: + >>> response = generator.generate_npc_dialogue( + ... character={"name": "Aldric", ...}, + ... npc={"name": "Old Barkeep", "role": "Tavern Owner", ...}, + ... conversation_topic="What rumors have you heard lately?", + ... game_state={"current_location": "The Rusty Anchor", ...}, + ... user_tier=UserTier.PREMIUM + ... ) + """ + context_type = ContextType.NPC_DIALOGUE + + logger.info( + "Generating NPC dialogue", + character_name=character.get("name"), + npc_name=npc.get("name"), + topic=conversation_topic[:50] + ) + + # Get model configuration + model_config = self.model_selector.select_model(user_tier, context_type) + + # Build the prompt + try: + prompt = self.prompt_templates.render( + "npc_dialogue.j2", + character=character, + npc=npc, + conversation_topic=conversation_topic, + game_state=game_state, + npc_relationship=npc_relationship, + previous_dialogue=previous_dialogue or [], + npc_knowledge=npc_knowledge or [], + max_tokens=model_config.max_tokens + ) + except PromptTemplateError as e: + logger.error("Failed to render NPC dialogue prompt", error=str(e)) + raise NarrativeGeneratorError(f"Prompt template error: {e}") + + # Generate response + system_prompt = self.SYSTEM_PROMPTS[context_type] + + try: + client = self._get_client(model_config) + response = client.generate( + prompt=prompt, + system_prompt=system_prompt, + max_tokens=model_config.max_tokens, + temperature=model_config.temperature, + model=model_config.model_type + ) + except ReplicateClientError as e: + logger.error("NPC dialogue generation failed", error=str(e)) + raise NarrativeGeneratorError(f"AI generation failed: {e}") + + logger.info( + "NPC dialogue generated", + npc_name=npc.get("name"), + tokens_used=response.tokens_used, + generation_time=f"{response.generation_time:.2f}s" + ) + + return NarrativeResponse( + narrative=response.text, + tokens_used=response.tokens_used, + tokens_input=response.tokens_input, + tokens_output=response.tokens_output, + model=response.model, + context_type=context_type.value, + generation_time=response.generation_time + ) diff --git a/api/app/ai/prompt_templates.py b/api/app/ai/prompt_templates.py new file mode 100644 index 0000000..d8c39b8 --- /dev/null +++ b/api/app/ai/prompt_templates.py @@ -0,0 +1,318 @@ +""" +Jinja2 prompt template system for AI generation. + +This module provides a templating system for building AI prompts +with consistent structure and context injection. +""" + +import os +from pathlib import Path +from typing import Any + +import structlog +from jinja2 import Environment, FileSystemLoader, select_autoescape + +logger = structlog.get_logger(__name__) + + +class PromptTemplateError(Exception): + """Error in prompt template processing.""" + pass + + +class PromptTemplates: + """ + Manages Jinja2 templates for AI prompt generation. + + Provides caching, helper functions, and consistent template rendering + for all AI prompt types. + """ + + # Template directory relative to this module + TEMPLATE_DIR = Path(__file__).parent / "templates" + + def __init__(self, template_dir: Path | str | None = None): + """ + Initialize the prompt template system. + + Args: + template_dir: Optional custom template directory path. + """ + self.template_dir = Path(template_dir) if template_dir else self.TEMPLATE_DIR + + # Ensure template directory exists + if not self.template_dir.exists(): + self.template_dir.mkdir(parents=True, exist_ok=True) + logger.warning( + "Template directory created", + path=str(self.template_dir) + ) + + # Set up Jinja2 environment with caching + self.env = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=select_autoescape(['html', 'xml']), + trim_blocks=True, + lstrip_blocks=True, + ) + + # Register custom filters + self._register_filters() + + # Register custom globals + self._register_globals() + + logger.info( + "PromptTemplates initialized", + template_dir=str(self.template_dir) + ) + + def _register_filters(self): + """Register custom Jinja2 filters.""" + self.env.filters['format_inventory'] = self._format_inventory + self.env.filters['format_stats'] = self._format_stats + self.env.filters['format_skills'] = self._format_skills + self.env.filters['format_effects'] = self._format_effects + self.env.filters['truncate_text'] = self._truncate_text + self.env.filters['format_gold'] = self._format_gold + + def _register_globals(self): + """Register global functions available in templates.""" + self.env.globals['len'] = len + self.env.globals['min'] = min + self.env.globals['max'] = max + self.env.globals['enumerate'] = enumerate + + # Custom filters + @staticmethod + def _format_inventory(items: list[dict], max_items: int = 10) -> str: + """ + Format inventory items for prompt context. + + Args: + items: List of item dictionaries with 'name' and 'quantity'. + max_items: Maximum number of items to display. + + Returns: + Formatted inventory string. + """ + if not items: + return "Empty inventory" + + formatted = [] + for item in items[:max_items]: + name = item.get('name', 'Unknown') + qty = item.get('quantity', 1) + if qty > 1: + formatted.append(f"{name} (x{qty})") + else: + formatted.append(name) + + result = ", ".join(formatted) + if len(items) > max_items: + result += f", and {len(items) - max_items} more items" + + return result + + @staticmethod + def _format_stats(stats: dict) -> str: + """ + Format character stats for prompt context. + + Args: + stats: Dictionary of stat names to values. + + Returns: + Formatted stats string. + """ + if not stats: + return "No stats available" + + formatted = [] + for stat, value in stats.items(): + # Convert snake_case to Title Case + display_name = stat.replace('_', ' ').title() + formatted.append(f"{display_name}: {value}") + + return ", ".join(formatted) + + @staticmethod + def _format_skills(skills: list[dict], max_skills: int = 5) -> str: + """ + Format character skills for prompt context. + + Args: + skills: List of skill dictionaries with 'name' and 'level'. + max_skills: Maximum number of skills to display. + + Returns: + Formatted skills string. + """ + if not skills: + return "No skills" + + formatted = [] + for skill in skills[:max_skills]: + name = skill.get('name', 'Unknown') + level = skill.get('level', 1) + formatted.append(f"{name} (Lv.{level})") + + result = ", ".join(formatted) + if len(skills) > max_skills: + result += f", and {len(skills) - max_skills} more skills" + + return result + + @staticmethod + def _format_effects(effects: list[dict]) -> str: + """ + Format active effects/buffs/debuffs for prompt context. + + Args: + effects: List of effect dictionaries. + + Returns: + Formatted effects string. + """ + if not effects: + return "No active effects" + + formatted = [] + for effect in effects: + name = effect.get('name', 'Unknown') + duration = effect.get('remaining_turns') + if duration: + formatted.append(f"{name} ({duration} turns)") + else: + formatted.append(name) + + return ", ".join(formatted) + + @staticmethod + def _truncate_text(text: str, max_length: int = 100) -> str: + """ + Truncate text to maximum length with ellipsis. + + Args: + text: Text to truncate. + max_length: Maximum character length. + + Returns: + Truncated text with ellipsis if needed. + """ + if len(text) <= max_length: + return text + return text[:max_length - 3] + "..." + + @staticmethod + def _format_gold(amount: int) -> str: + """ + Format gold amount with commas. + + Args: + amount: Gold amount. + + Returns: + Formatted gold string. + """ + return f"{amount:,} gold" + + def render(self, template_name: str, **context: Any) -> str: + """ + Render a template with the given context. + + Args: + template_name: Name of the template file (e.g., 'story_action.j2'). + **context: Variables to pass to the template. + + Returns: + Rendered template string. + + Raises: + PromptTemplateError: If template not found or rendering fails. + """ + try: + template = self.env.get_template(template_name) + rendered = template.render(**context) + + logger.debug( + "Template rendered", + template=template_name, + context_keys=list(context.keys()), + output_length=len(rendered) + ) + + return rendered.strip() + + except Exception as e: + logger.error( + "Template rendering failed", + template=template_name, + error=str(e) + ) + raise PromptTemplateError(f"Failed to render {template_name}: {e}") + + def render_string(self, template_string: str, **context: Any) -> str: + """ + Render a template string directly. + + Args: + template_string: Jinja2 template string. + **context: Variables to pass to the template. + + Returns: + Rendered string. + + Raises: + PromptTemplateError: If rendering fails. + """ + try: + template = self.env.from_string(template_string) + rendered = template.render(**context) + return rendered.strip() + except Exception as e: + logger.error( + "String template rendering failed", + error=str(e) + ) + raise PromptTemplateError(f"Failed to render template string: {e}") + + def get_template_names(self) -> list[str]: + """ + Get list of available template names. + + Returns: + List of template file names. + """ + return self.env.list_templates(extensions=['j2']) + + +# Global instance for convenience +_templates: PromptTemplates | None = None + + +def get_prompt_templates() -> PromptTemplates: + """ + Get the global PromptTemplates instance. + + Returns: + Singleton PromptTemplates instance. + """ + global _templates + if _templates is None: + _templates = PromptTemplates() + return _templates + + +def render_prompt(template_name: str, **context: Any) -> str: + """ + Convenience function to render a prompt template. + + Args: + template_name: Name of the template file. + **context: Variables to pass to the template. + + Returns: + Rendered template string. + """ + return get_prompt_templates().render(template_name, **context) diff --git a/api/app/ai/replicate_client.py b/api/app/ai/replicate_client.py new file mode 100644 index 0000000..83585c7 --- /dev/null +++ b/api/app/ai/replicate_client.py @@ -0,0 +1,450 @@ +""" +Replicate API client for AI model integration. + +This module provides a client for interacting with the Replicate API +to generate text using various models including Llama-3 and Claude models. +All AI generation goes through Replicate for unified billing and management. +""" + +import time +from dataclasses import dataclass +from enum import Enum +from typing import Any + +import replicate +import structlog + +from app.config import get_config + +logger = structlog.get_logger(__name__) + + +class ModelType(str, Enum): + """Supported model types on Replicate.""" + # Free tier - Llama models + LLAMA_3_8B = "meta/meta-llama-3-8b-instruct" + + # Paid tiers - Claude models via Replicate + CLAUDE_HAIKU = "anthropic/claude-3.5-haiku" + CLAUDE_SONNET = "anthropic/claude-3.5-sonnet" + CLAUDE_SONNET_4 = "anthropic/claude-4.5-sonnet" + + +@dataclass +class ReplicateResponse: + """Response from Replicate API generation.""" + text: str + tokens_used: int # Deprecated: use tokens_output instead + tokens_input: int + tokens_output: int + model: str + generation_time: float + + +class ReplicateClientError(Exception): + """Base exception for Replicate client errors.""" + pass + + +class ReplicateAPIError(ReplicateClientError): + """Error from Replicate API.""" + pass + + +class ReplicateRateLimitError(ReplicateClientError): + """Rate limit exceeded on Replicate API.""" + pass + + +class ReplicateTimeoutError(ReplicateClientError): + """Timeout waiting for Replicate response.""" + pass + + +class ReplicateClient: + """ + Client for interacting with Replicate API. + + Supports multiple models including Llama-3 and Claude models. + Implements retry logic with exponential backoff for rate limits. + """ + + # Default model for free tier + DEFAULT_MODEL = ModelType.LLAMA_3_8B + + # Retry configuration + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 1.0 # seconds + + # Default generation parameters + DEFAULT_MAX_TOKENS = 256 + DEFAULT_TEMPERATURE = 0.7 + DEFAULT_TOP_P = 0.9 + DEFAULT_TIMEOUT = 30 # seconds + + # Model-specific defaults + MODEL_DEFAULTS = { + ModelType.LLAMA_3_8B: { + "max_tokens": 256, + "temperature": 0.7, + }, + ModelType.CLAUDE_HAIKU: { + "max_tokens": 512, + "temperature": 0.8, + }, + ModelType.CLAUDE_SONNET: { + "max_tokens": 1024, + "temperature": 0.9, + }, + ModelType.CLAUDE_SONNET_4: { + "max_tokens": 2048, + "temperature": 0.9, + }, + } + + def __init__(self, api_token: str | None = None, model: str | ModelType | None = None): + """ + Initialize the Replicate client. + + Args: + api_token: Replicate API token. If not provided, reads from config. + model: Model identifier or ModelType enum. Defaults to Llama-3 8B Instruct. + + Raises: + ReplicateClientError: If API token is not configured. + """ + config = get_config() + + # Get API token from parameter or config + self.api_token = api_token or getattr(config, 'replicate_api_token', None) + if not self.api_token: + raise ReplicateClientError( + "Replicate API token not configured. " + "Set REPLICATE_API_TOKEN in environment or config." + ) + + # Get model from parameter, config, or default + if model is None: + model = getattr(config, 'REPLICATE_MODEL', None) or self.DEFAULT_MODEL + + # Convert string to ModelType if needed, or keep as string for custom models + if isinstance(model, ModelType): + self.model = model.value + self.model_type = model + elif isinstance(model, str): + # Try to match to ModelType + self.model = model + self.model_type = self._get_model_type(model) + else: + self.model = self.DEFAULT_MODEL.value + self.model_type = self.DEFAULT_MODEL + + # Set the API token for the replicate library + import os + os.environ['REPLICATE_API_TOKEN'] = self.api_token + + logger.info( + "Replicate client initialized", + model=self.model, + model_type=self.model_type.name if self.model_type else "custom" + ) + + def _get_model_type(self, model_string: str) -> ModelType | None: + """Get ModelType enum from model string.""" + for model_type in ModelType: + if model_type.value == model_string: + return model_type + return None + + def _is_claude_model(self) -> bool: + """Check if current model is a Claude model.""" + return self.model_type in [ + ModelType.CLAUDE_HAIKU, + ModelType.CLAUDE_SONNET, + ModelType.CLAUDE_SONNET_4 + ] + + def generate( + self, + prompt: str, + system_prompt: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + top_p: float | None = None, + timeout: int | None = None, + model: str | ModelType | None = None + ) -> ReplicateResponse: + """ + Generate text using the configured model. + + Args: + prompt: The user prompt to send to the model. + system_prompt: Optional system prompt for context setting. + max_tokens: Maximum tokens to generate. Uses model defaults if not specified. + temperature: Sampling temperature (0.0-1.0). Uses model defaults if not specified. + top_p: Top-p sampling parameter. Defaults to 0.9. + timeout: Timeout in seconds. Defaults to 30. + model: Override the default model for this request. + + Returns: + ReplicateResponse with generated text and metadata. + + Raises: + ReplicateAPIError: For API errors. + ReplicateRateLimitError: When rate limited. + ReplicateTimeoutError: When request times out. + """ + # Handle model override + if model: + if isinstance(model, ModelType): + current_model = model.value + current_model_type = model + else: + current_model = model + current_model_type = self._get_model_type(model) + else: + current_model = self.model + current_model_type = self.model_type + + # Get model-specific defaults + model_defaults = self.MODEL_DEFAULTS.get(current_model_type, {}) + + # Apply defaults (parameter > model default > class default) + max_tokens = max_tokens or model_defaults.get("max_tokens", self.DEFAULT_MAX_TOKENS) + temperature = temperature or model_defaults.get("temperature", self.DEFAULT_TEMPERATURE) + top_p = top_p or self.DEFAULT_TOP_P + timeout = timeout or self.DEFAULT_TIMEOUT + + # Format prompt based on model type + is_claude = current_model_type in [ + ModelType.CLAUDE_HAIKU, + ModelType.CLAUDE_SONNET, + ModelType.CLAUDE_SONNET_4 + ] + + if is_claude: + input_params = self._build_claude_params( + prompt, system_prompt, max_tokens, temperature, top_p + ) + else: + # Llama-style formatting + formatted_prompt = self._format_llama_prompt(prompt, system_prompt) + input_params = { + "prompt": formatted_prompt, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, + } + + logger.debug( + "Generating text with Replicate", + model=current_model, + max_tokens=max_tokens, + temperature=temperature, + is_claude=is_claude + ) + + # Execute with retry logic + start_time = time.time() + output = self._execute_with_retry(current_model, input_params, timeout) + generation_time = time.time() - start_time + + # Parse response + text = self._parse_response(output) + + # Estimate tokens (rough approximation: ~4 chars per token) + # Calculate input tokens from the actual prompt sent + prompt_text = input_params.get("prompt", "") + system_text = input_params.get("system_prompt", "") + total_input_text = prompt_text + system_text + tokens_input = len(total_input_text) // 4 + + # Calculate output tokens from response + tokens_output = len(text) // 4 + + # Total for backwards compatibility + tokens_used = tokens_input + tokens_output + + logger.info( + "Replicate generation complete", + model=current_model, + tokens_input=tokens_input, + tokens_output=tokens_output, + tokens_used=tokens_used, + generation_time=f"{generation_time:.2f}s", + response_length=len(text) + ) + + return ReplicateResponse( + text=text.strip(), + tokens_used=tokens_used, + tokens_input=tokens_input, + tokens_output=tokens_output, + model=current_model, + generation_time=generation_time + ) + + def _build_claude_params( + self, + prompt: str, + system_prompt: str | None, + max_tokens: int, + temperature: float, + top_p: float + ) -> dict[str, Any]: + """ + Build input parameters for Claude models on Replicate. + + Args: + prompt: User prompt. + system_prompt: Optional system prompt. + max_tokens: Maximum tokens to generate. + temperature: Sampling temperature. + top_p: Top-p sampling parameter. + + Returns: + Dictionary of input parameters for Replicate API. + """ + params = { + "prompt": prompt, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, + } + + if system_prompt: + params["system_prompt"] = system_prompt + + return params + + def _format_llama_prompt(self, prompt: str, system_prompt: str | None = None) -> str: + """ + Format prompt for Llama-3 Instruct model. + + Llama-3 Instruct uses a specific format with special tokens. + + Args: + prompt: User prompt. + system_prompt: Optional system prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + if system_prompt: + parts.append(f"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n{system_prompt}<|eot_id|>") + else: + parts.append("<|begin_of_text|>") + + parts.append(f"<|start_header_id|>user<|end_header_id|>\n\n{prompt}<|eot_id|>") + parts.append("<|start_header_id|>assistant<|end_header_id|>\n\n") + + return "".join(parts) + + def _parse_response(self, output: Any) -> str: + """ + Parse response from Replicate API. + + Handles both streaming iterators and direct string responses. + + Args: + output: Raw output from Replicate API. + + Returns: + Parsed text string. + """ + if hasattr(output, '__iter__') and not isinstance(output, str): + return "".join(output) + return str(output) + + def _execute_with_retry( + self, + model: str, + input_params: dict[str, Any], + timeout: int + ) -> Any: + """ + Execute Replicate API call with retry logic. + + Implements exponential backoff for rate limit errors. + + Args: + model: Model identifier to run. + input_params: Input parameters for the model. + timeout: Timeout in seconds. + + Returns: + API response output. + + Raises: + ReplicateAPIError: For API errors after retries. + ReplicateRateLimitError: When rate limit persists after retries. + ReplicateTimeoutError: When request times out. + """ + last_error = None + retry_delay = self.INITIAL_RETRY_DELAY + + for attempt in range(self.MAX_RETRIES): + try: + output = replicate.run( + model, + input=input_params + ) + return output + + except replicate.exceptions.ReplicateError as e: + error_message = str(e).lower() + + if "rate limit" in error_message or "429" in error_message: + last_error = ReplicateRateLimitError(f"Rate limited: {e}") + + if attempt < self.MAX_RETRIES - 1: + logger.warning( + "Rate limited, retrying", + attempt=attempt + 1, + retry_delay=retry_delay + ) + time.sleep(retry_delay) + retry_delay *= 2 + continue + else: + raise last_error + + elif "timeout" in error_message: + raise ReplicateTimeoutError(f"Request timed out: {e}") + + else: + raise ReplicateAPIError(f"API error: {e}") + + except Exception as e: + error_message = str(e).lower() + + if "timeout" in error_message: + raise ReplicateTimeoutError(f"Request timed out: {e}") + + raise ReplicateAPIError(f"Unexpected error: {e}") + + if last_error: + raise last_error + raise ReplicateAPIError("Max retries exceeded") + + def validate_api_key(self) -> bool: + """ + Validate that the API key is valid. + + Makes a minimal API call to check credentials. + + Returns: + True if API key is valid, False otherwise. + """ + try: + model_name = self.model.split(':')[0] + model = replicate.models.get(model_name) + return model is not None + except Exception as e: + logger.warning( + "API key validation failed", + error=str(e) + ) + return False diff --git a/api/app/ai/response_parser.py b/api/app/ai/response_parser.py new file mode 100644 index 0000000..e68872e --- /dev/null +++ b/api/app/ai/response_parser.py @@ -0,0 +1,160 @@ +""" +Response parser for AI narrative responses. + +This module handles AI response parsing. Game state changes (items, gold, XP) +are now handled exclusively through predetermined dice check outcomes in +action templates, not through AI-generated JSON. +""" + +from dataclasses import dataclass, field +from typing import Any, Optional + +import structlog + +logger = structlog.get_logger(__name__) + + +@dataclass +class ItemGrant: + """ + Represents an item granted by the AI during gameplay. + + The AI can grant items in two ways: + 1. By item_id - References an existing item from game data + 2. By name/type/description - Creates a generic item + """ + item_id: Optional[str] = None # For existing items + name: Optional[str] = None # For generic items + item_type: Optional[str] = None # consumable, weapon, armor, quest_item + description: Optional[str] = None + value: int = 0 + quantity: int = 1 + + def is_existing_item(self) -> bool: + """Check if this references an existing item by ID.""" + return self.item_id is not None + + def is_generic_item(self) -> bool: + """Check if this is a generic item created by the AI.""" + return self.item_id is None and self.name is not None + + +@dataclass +class GameStateChanges: + """ + Structured game state changes extracted from AI response. + + These changes are validated and applied to the character after + the AI generates its narrative response. + """ + items_given: list[ItemGrant] = field(default_factory=list) + items_taken: list[str] = field(default_factory=list) # item_ids to remove + gold_given: int = 0 + gold_taken: int = 0 + experience_given: int = 0 + quest_offered: Optional[str] = None # quest_id + quest_completed: Optional[str] = None # quest_id + location_change: Optional[str] = None + + +@dataclass +class ParsedAIResponse: + """ + Complete parsed AI response with narrative and game state changes. + + Attributes: + narrative: The narrative text to display to the player + game_changes: Structured game state changes to apply + raw_response: The original unparsed response from AI + parse_success: Whether parsing succeeded + parse_errors: Any errors encountered during parsing + """ + narrative: str + game_changes: GameStateChanges + raw_response: str + parse_success: bool = True + parse_errors: list[str] = field(default_factory=list) + + +class ResponseParserError(Exception): + """Exception raised when response parsing fails critically.""" + pass + + +def parse_ai_response(response_text: str) -> ParsedAIResponse: + """ + Parse an AI response to extract the narrative text. + + Game state changes (items, gold, XP) are now handled exclusively through + predetermined dice check outcomes, not through AI-generated structured data. + + Args: + response_text: The raw AI response text + + Returns: + ParsedAIResponse with narrative (game_changes will be empty) + """ + logger.debug("Parsing AI response", response_length=len(response_text)) + + # Return the full response as narrative + # Game state changes come from predetermined check_outcomes, not AI + return ParsedAIResponse( + narrative=response_text.strip(), + game_changes=GameStateChanges(), + raw_response=response_text, + parse_success=True, + parse_errors=[] + ) + + +def _parse_game_actions(data: dict[str, Any]) -> GameStateChanges: + """ + Parse the game actions dictionary into a GameStateChanges object. + + Args: + data: Dictionary from parsed JSON + + Returns: + GameStateChanges object with parsed data + """ + changes = GameStateChanges() + + # Parse items_given + if "items_given" in data and data["items_given"]: + for item_data in data["items_given"]: + if isinstance(item_data, dict): + item_grant = ItemGrant( + item_id=item_data.get("item_id"), + name=item_data.get("name"), + item_type=item_data.get("type"), + description=item_data.get("description"), + value=item_data.get("value", 0), + quantity=item_data.get("quantity", 1) + ) + changes.items_given.append(item_grant) + elif isinstance(item_data, str): + # Simple string format - treat as item_id + changes.items_given.append(ItemGrant(item_id=item_data)) + + # Parse items_taken + if "items_taken" in data and data["items_taken"]: + changes.items_taken = [ + item_id for item_id in data["items_taken"] + if isinstance(item_id, str) + ] + + # Parse gold changes + changes.gold_given = int(data.get("gold_given", 0)) + changes.gold_taken = int(data.get("gold_taken", 0)) + + # Parse experience + changes.experience_given = int(data.get("experience_given", 0)) + + # Parse quest changes + changes.quest_offered = data.get("quest_offered") + changes.quest_completed = data.get("quest_completed") + + # Parse location change + changes.location_change = data.get("location_change") + + return changes diff --git a/api/app/ai/templates/combat_action.j2 b/api/app/ai/templates/combat_action.j2 new file mode 100644 index 0000000..c2e9f8f --- /dev/null +++ b/api/app/ai/templates/combat_action.j2 @@ -0,0 +1,81 @@ +{# +Combat Action Prompt Template +Used for narrating combat actions and outcomes. + +Required context: +- character: Character object +- combat_state: Current combat information +- action: The combat action being taken +- action_result: Outcome of the action (damage, effects, etc.) + +Optional context: +- is_critical: Whether this was a critical hit/miss +- is_finishing_blow: Whether this defeats the enemy +#} +You are the Dungeon Master narrating an exciting combat encounter. + +## Combatants +**{{ character.name }}** (Level {{ character.level }} {{ character.player_class }}) +- Health: {{ character.current_hp }}/{{ character.max_hp }} HP +{% if character.effects %} +- Active Effects: {{ character.effects | format_effects }} +{% endif %} + +**vs** + +{% for enemy in combat_state.enemies %} +**{{ enemy.name }}** +- Health: {{ enemy.current_hp }}/{{ enemy.max_hp }} HP +{% if enemy.effects %} +- Status: {{ enemy.effects | format_effects }} +{% endif %} +{% endfor %} + +## Combat Round {{ combat_state.round_number }} +Turn: {{ combat_state.current_turn }} + +## Action Taken +{{ character.name }} {{ action }} + +## Action Result +{% if action_result.hit %} +- **Hit!** {{ action_result.damage }} damage dealt +{% if is_critical %} +- **CRITICAL HIT!** +{% endif %} +{% if action_result.effects_applied %} +- Applied: {{ action_result.effects_applied | join(', ') }} +{% endif %} +{% else %} +- **Miss!** The attack fails to connect +{% endif %} + +{% if is_finishing_blow %} +**{{ action_result.target }} has been defeated!** +{% endif %} + +## Your Task +Narrate this combat action: +1. Describe the action with visceral, cinematic detail +2. Show the result - the impact, the enemy's reaction +{% if is_finishing_blow %} +3. Describe the enemy's defeat dramatically +{% else %} +3. Hint at the enemy's remaining threat or weakness +{% endif %} + +{% if max_tokens %} +**IMPORTANT: Your response must be under {{ (max_tokens * 0.6) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.** +{% if max_tokens <= 150 %} +Keep it to 2-3 punchy sentences. +{% elif max_tokens <= 300 %} +Keep it to 1 short paragraph. +{% else %} +Keep it to 1-2 exciting paragraphs. +{% endif %} +{% endif %} + +Keep it punchy and action-packed. Use active voice and dynamic verbs. +Don't include game mechanics in the narrative - just the story. + +Respond only with the narrative - no dice rolls or damage numbers. diff --git a/api/app/ai/templates/npc_dialogue.j2 b/api/app/ai/templates/npc_dialogue.j2 new file mode 100644 index 0000000..aec928e --- /dev/null +++ b/api/app/ai/templates/npc_dialogue.j2 @@ -0,0 +1,138 @@ +{# +NPC Dialogue Prompt Template - Enhanced with persistent NPC data. +Used for generating contextual NPC conversations with rich personality. + +Required context: +- character: Player character information (name, level, player_class) +- npc: NPC information with personality, appearance, dialogue_hooks +- conversation_topic: What the player wants to discuss +- game_state: Current game state + +Optional context: +- npc_knowledge: List of information the NPC can share +- revealed_secrets: Secrets being revealed this conversation +- interaction_count: Number of times player has talked to this NPC +- relationship_level: 0-100 relationship score (50 is neutral) +- previous_dialogue: Previous exchanges with this NPC +#} +You are roleplaying as an NPC in a fantasy world, having a conversation with a player character. + +## The NPC +**{{ npc.name }}** - {{ npc.role }} + +{% if npc.appearance %} +- **Appearance:** {{ npc.appearance if npc.appearance is string else npc.appearance.brief if npc.appearance.brief else npc.appearance }} +{% endif %} + +{% if npc.personality %} +{% if npc.personality.traits %} +- **Personality Traits:** {{ npc.personality.traits | join(', ') }} +{% elif npc.personality is string %} +- **Personality:** {{ npc.personality }} +{% endif %} +{% if npc.personality.speech_style %} +- **Speaking Style:** {{ npc.personality.speech_style }} +{% endif %} +{% if npc.personality.quirks %} +- **Quirks:** {{ npc.personality.quirks | join('; ') }} +{% endif %} +{% endif %} + +{% if npc.dialogue_hooks and npc.dialogue_hooks.greeting %} +- **Typical Greeting:** "{{ npc.dialogue_hooks.greeting }}" +{% endif %} + +{% if npc.goals %} +- **Current Goals:** {{ npc.goals }} +{% endif %} + +## The Player Character +**{{ character.name }}** - Level {{ character.level }} {{ character.player_class }} +{% if interaction_count and interaction_count > 1 %} +- **Familiarity:** This is conversation #{{ interaction_count }} - the NPC recognizes {{ character.name }} +{% endif %} +{% if relationship_level %} +{% if relationship_level >= 80 %} +- **Relationship:** Close friend ({{ relationship_level }}/100) - treats player warmly +{% elif relationship_level >= 60 %} +- **Relationship:** Friendly acquaintance ({{ relationship_level }}/100) - helpful and open +{% elif relationship_level >= 40 %} +- **Relationship:** Neutral ({{ relationship_level }}/100) - professional but guarded +{% elif relationship_level >= 20 %} +- **Relationship:** Distrustful ({{ relationship_level }}/100) - wary and curt +{% else %} +- **Relationship:** Hostile ({{ relationship_level }}/100) - dismissive or antagonistic +{% endif %} +{% endif %} + +## Current Setting +- **Location:** {{ game_state.current_location }} +{% if game_state.time_of_day %} +- **Time:** {{ game_state.time_of_day }} +{% endif %} +{% if game_state.active_quests %} +- **Player's Active Quests:** {{ game_state.active_quests | length }} +{% endif %} + +{% if npc_knowledge %} +## Knowledge the NPC May Share +The NPC knows about the following (share naturally as relevant to conversation): +{% for info in npc_knowledge %} +- {{ info }} +{% endfor %} +{% endif %} + +{% if revealed_secrets %} +## IMPORTANT: Secrets to Reveal This Conversation +Based on the player's relationship with this NPC, naturally reveal the following: +{% for secret in revealed_secrets %} +- {{ secret }} +{% endfor %} +Work these into the dialogue naturally - don't dump all information at once. +Make it feel earned, like the NPC is opening up to someone they trust. +{% endif %} + +{% if npc.relationships %} +## NPC Relationships (for context) +{% for rel in npc.relationships %} +- Feels {{ rel.attitude }} toward {{ rel.npc_id }}{% if rel.reason %} ({{ rel.reason }}){% endif %} + +{% endfor %} +{% endif %} + +{% if previous_dialogue %} +## Previous Conversation +{% for exchange in previous_dialogue[-2:] %} +- **{{ character.name }}:** {{ exchange.player_line | truncate_text(100) }} +- **{{ npc.name }}:** {{ exchange.npc_response | truncate_text(100) }} +{% endfor %} +{% endif %} + +## Player Says +"{{ conversation_topic }}" + +## Your Task +Respond as {{ npc.name }} in character. Generate dialogue that: +1. **Matches the NPC's personality and speech style exactly** - use their quirks, accent, and manner +2. **Acknowledges the relationship** - be warmer to friends, cooler to strangers +3. **Shares relevant knowledge naturally** - don't info-dump, weave it into conversation +4. **Reveals secrets if specified** - make it feel like earned trust, not random exposition +5. **Feels alive and memorable** - give this NPC a distinct voice + +{% if max_tokens %} +**IMPORTANT: Your response must be under {{ (max_tokens * 0.6) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.** +{% if max_tokens <= 150 %} +Keep it to 1-2 sentences of dialogue. +{% elif max_tokens <= 300 %} +Keep it to 2-3 sentences of dialogue. +{% else %} +Keep it to 2-4 sentences of dialogue. +{% endif %} +{% else %} +Keep the response to 2-4 sentences of dialogue. +{% endif %} + +You may include brief action/emotion tags in *asterisks* to show gestures and expressions. + +Respond only as the NPC - no narration or out-of-character text. +Format: *action/emotion* "Dialogue goes here." diff --git a/api/app/ai/templates/quest_offering.j2 b/api/app/ai/templates/quest_offering.j2 new file mode 100644 index 0000000..e027702 --- /dev/null +++ b/api/app/ai/templates/quest_offering.j2 @@ -0,0 +1,61 @@ +{# +Quest Offering Prompt Template +Used for AI to select the most contextually appropriate quest. + +Required context: +- eligible_quests: List of quest objects that can be offered +- game_context: Current game state information +- character: Character information + +Optional context: +- recent_actions: Recent player actions +#} +You are selecting the most appropriate quest to offer to a player based on their current context. + +## Player Character +**{{ character.name }}** - Level {{ character.level }} {{ character.player_class }} +{% if character.completed_quests %} +- Completed Quests: {{ character.completed_quests | length }} +{% endif %} + +## Current Context +- **Location:** {{ game_context.current_location }} ({{ game_context.location_type }}) +{% if recent_actions %} +- **Recent Actions:** +{% for action in recent_actions[-3:] %} + - {{ action }} +{% endfor %} +{% endif %} +{% if game_context.active_quests %} +- **Active Quests:** {{ game_context.active_quests | length }} in progress +{% endif %} +{% if game_context.world_events %} +- **Current Events:** {{ game_context.world_events | join(', ') }} +{% endif %} + +## Available Quests +{% for quest in eligible_quests %} +### {{ loop.index }}. {{ quest.name }} +- **Quest ID:** {{ quest.quest_id }} +- **Difficulty:** {{ quest.difficulty }} +- **Quest Giver:** {{ quest.quest_giver }} +- **Description:** {{ quest.description | truncate_text(200) }} +- **Narrative Hooks:** +{% for hook in quest.narrative_hooks %} + - {{ hook }} +{% endfor %} +{% endfor %} + +## Your Task +Select the ONE quest that best fits the current narrative context. + +Consider: +1. Which quest's narrative hooks connect best to the player's recent actions? +2. Which quest giver makes sense for this location? +3. Which difficulty is appropriate for the character's level and situation? +4. Which quest would feel most natural to discover right now? + +Respond with ONLY the quest_id of your selection on a single line. +Example response: quest_goblin_cave + +Do not include any explanation - just the quest_id. diff --git a/api/app/ai/templates/story_action.j2 b/api/app/ai/templates/story_action.j2 new file mode 100644 index 0000000..572bb93 --- /dev/null +++ b/api/app/ai/templates/story_action.j2 @@ -0,0 +1,112 @@ +{# +Story Action Prompt Template +Used for generating DM responses to player story actions. + +Required context: +- character: Character object with name, level, player_class, stats +- game_state: GameState with current_location, location_type, active_quests +- action: String describing the player's action +- conversation_history: List of recent conversation entries (optional) + +Optional context: +- custom_topic: For specific queries +- world_context: Additional world information +#} +You are the Dungeon Master for {{ character.name }}, a level {{ character.level }} {{ character.player_class }}. + +## Character Status +- **Health:** {{ character.current_hp }}/{{ character.max_hp }} HP +- **Stats:** {{ character.stats | format_stats }} +{% if character.skills %} +- **Skills:** {{ character.skills | format_skills }} +{% endif %} +{% if character.effects %} +- **Active Effects:** {{ character.effects | format_effects }} +{% endif %} + +## Current Situation +- **Location:** {{ game_state.current_location }} ({{ game_state.location_type }}) +{% if game_state.discovered_locations %} +- **Known Locations:** {{ game_state.discovered_locations | join(', ') }} +{% endif %} +{% if game_state.active_quests %} +- **Active Quests:** {{ game_state.active_quests | length }} quest(s) in progress +{% endif %} +{% if game_state.time_of_day %} +- **Time:** {{ game_state.time_of_day }} +{% endif %} + +{% if location %} +## Location Details +- **Place:** {{ location.name }} +- **Type:** {{ location.type if location.type else game_state.location_type }} +{% if location.description %} +- **Description:** {{ location.description | truncate_text(300) }} +{% endif %} +{% if location.ambient %} +- **Atmosphere:** {{ location.ambient | truncate_text(200) }} +{% endif %} +{% if location.lore %} +- **Lore:** {{ location.lore | truncate_text(150) }} +{% endif %} +{% endif %} + +{% if npcs_present %} +## NPCs Present +{% for npc in npcs_present %} +- **{{ npc.name }}** ({{ npc.role }}): {{ npc.appearance if npc.appearance is string else npc.appearance.brief if npc.appearance else 'No description' }} +{% endfor %} +These NPCs are available for conversation. Include them naturally in the scene if relevant. +{% endif %} + +{% if conversation_history %} +## Recent History +{% for entry in conversation_history[-3:] %} +- **Turn {{ entry.turn }}:** {{ entry.action }} + > {{ entry.dm_response }} +{% endfor %} +{% endif %} + +## Player Action +{{ action }} + +{% if action_instructions %} +## Action-Specific Instructions +{{ action_instructions }} +{% endif %} + +## Your Task +Generate a narrative response that: +1. Acknowledges the player's action and describes their attempt +2. Describes what happens as a result, including any discoveries or consequences +3. Sets up the next decision point or opportunity for action + +{% if max_tokens %} +**IMPORTANT: Your response must be under {{ (max_tokens * 0.7) | int }} words (approximately {{ max_tokens }} tokens). Complete all sentences - do not get cut off mid-sentence.** +{% if max_tokens <= 150 %} +Keep it to 1 short paragraph (2-3 sentences). +{% elif max_tokens <= 300 %} +Keep it to 1 paragraph (4-5 sentences). +{% elif max_tokens <= 600 %} +Keep it to 1-2 paragraphs. +{% else %} +Keep it to 2-3 paragraphs. +{% endif %} +{% endif %} + +Keep the tone immersive and engaging. Use vivid descriptions but stay concise. +If the action involves NPCs, give them personality and realistic reactions. +If the action could fail or succeed, describe the outcome based on the character's abilities. + +**CRITICAL RULES - Player Agency:** +- NEVER make decisions for the player (no auto-purchasing, no automatic commitments) +- NEVER complete transactions without explicit player consent +- NEVER take items or spend gold without the player choosing to do so +- Present options, choices, or discoveries - then let the player decide +- End with clear options or a question about what they want to do next +- If items/services have costs, always state prices and ask if they want to proceed + +{% if world_context %} +## World Context +{{ world_context }} +{% endif %} diff --git a/api/app/api/__init__.py b/api/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/api/auth.py b/api/app/api/auth.py new file mode 100644 index 0000000..1fa737a --- /dev/null +++ b/api/app/api/auth.py @@ -0,0 +1,529 @@ +""" +Authentication API Blueprint + +This module provides API endpoints for user authentication and management: +- User registration +- User login/logout +- Email verification +- Password reset + +All endpoints follow the standard API response format defined in app.utils.response. +""" + +import re +from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash +from appwrite.exception import AppwriteException + +from app.services.appwrite_service import AppwriteService +from app.utils.response import ( + success_response, + created_response, + error_response, + unauthorized_response, + validation_error_response +) +from app.utils.auth import require_auth, get_current_user, extract_session_token +from app.utils.logging import get_logger +from app.config import get_config + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +auth_bp = Blueprint('auth', __name__) + + +# ===== VALIDATION FUNCTIONS ===== + +def validate_email(email: str) -> tuple[bool, str]: + """ + Validate email address format. + + Args: + email: Email address to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not email: + return False, "Email is required" + + config = get_config() + max_length = config.auth.email_max_length + + if len(email) > max_length: + return False, f"Email must be no more than {max_length} characters" + + # Email regex pattern + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(pattern, email): + return False, "Invalid email format" + + return True, "" + + +def validate_password(password: str) -> tuple[bool, str]: + """ + Validate password strength. + + Args: + password: Password to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not password: + return False, "Password is required" + + config = get_config() + + min_length = config.auth.password_min_length + if len(password) < min_length: + return False, f"Password must be at least {min_length} characters long" + + if len(password) > 128: + return False, "Password must be no more than 128 characters" + + errors = [] + + if config.auth.password_require_uppercase and not re.search(r'[A-Z]', password): + errors.append("at least one uppercase letter") + + if config.auth.password_require_lowercase and not re.search(r'[a-z]', password): + errors.append("at least one lowercase letter") + + if config.auth.password_require_number and not re.search(r'[0-9]', password): + errors.append("at least one number") + + if config.auth.password_require_special and not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password): + errors.append("at least one special character") + + if errors: + return False, f"Password must contain {', '.join(errors)}" + + return True, "" + + +def validate_name(name: str) -> tuple[bool, str]: + """ + Validate user name. + + Args: + name: Name to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not name: + return False, "Name is required" + + config = get_config() + + min_length = config.auth.name_min_length + max_length = config.auth.name_max_length + + if len(name) < min_length: + return False, f"Name must be at least {min_length} characters" + + if len(name) > max_length: + return False, f"Name must be no more than {max_length} characters" + + # Allow letters, spaces, hyphens, apostrophes + if not re.match(r"^[a-zA-Z\s\-']+$", name): + return False, "Name can only contain letters, spaces, hyphens, and apostrophes" + + return True, "" + + +# ===== API ENDPOINTS ===== + +@auth_bp.route('/api/v1/auth/register', methods=['POST']) +def api_register(): + """ + Register a new user account. + + Request Body: + { + "email": "user@example.com", + "password": "SecurePass123!", + "name": "Player Name" + } + + Returns: + 201: User created successfully + 400: Validation error or email already exists + 500: Internal server error + """ + try: + # Get request data + data = request.get_json() + + if not data: + return validation_error_response({"error": "Request body is required"}) + + email = data.get('email', '').strip().lower() + password = data.get('password', '') + name = data.get('name', '').strip() + + # Validate inputs + validation_errors = {} + + email_valid, email_error = validate_email(email) + if not email_valid: + validation_errors['email'] = email_error + + password_valid, password_error = validate_password(password) + if not password_valid: + validation_errors['password'] = password_error + + name_valid, name_error = validate_name(name) + if not name_valid: + validation_errors['name'] = name_error + + if validation_errors: + return validation_error_response(validation_errors) + + # Register user + appwrite = AppwriteService() + user_data = appwrite.register_user(email=email, password=password, name=name) + + logger.info("User registered successfully", user_id=user_data.id, email=email) + + return created_response( + result={ + "user": user_data.to_dict(), + "message": "Registration successful. Please check your email to verify your account." + } + ) + + except AppwriteException as e: + logger.error("Registration failed", error=str(e), code=e.code) + + # Check for specific error codes + if e.code == 409: # Conflict - user already exists + return validation_error_response({"email": "An account with this email already exists"}) + + return error_response(message="Registration failed. Please try again.", code="REGISTRATION_ERROR") + + except Exception as e: + logger.error("Unexpected error during registration", error=str(e)) + return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR") + + +@auth_bp.route('/api/v1/auth/login', methods=['POST']) +def api_login(): + """ + Authenticate a user and create a session. + + Request Body: + { + "email": "user@example.com", + "password": "SecurePass123!", + "remember_me": false + } + + Returns: + 200: Login successful, session cookie set + 401: Invalid credentials + 400: Validation error + 500: Internal server error + """ + try: + # Get request data + data = request.get_json() + + if not data: + return validation_error_response({"error": "Request body is required"}) + + email = data.get('email', '').strip().lower() + password = data.get('password', '') + remember_me = data.get('remember_me', False) + + # Validate inputs + if not email: + return validation_error_response({"email": "Email is required"}) + + if not password: + return validation_error_response({"password": "Password is required"}) + + # Authenticate user + appwrite = AppwriteService() + session_data, user_data = appwrite.login_user(email=email, password=password) + + logger.info("User logged in successfully", user_id=user_data.id, email=email) + + # Set session cookie + config = get_config() + duration = config.auth.duration_remember_me if remember_me else config.auth.duration_normal + + response = make_response(success_response( + result={ + "user": user_data.to_dict(), + "message": "Login successful" + } + )) + + response.set_cookie( + key=config.auth.cookie_name, + value=session_data.session_id, + max_age=duration, + httponly=config.auth.http_only, + secure=config.auth.secure, + samesite=config.auth.same_site, + path=config.auth.path + ) + + return response + + except AppwriteException as e: + logger.warning("Login failed", email=email if 'email' in locals() else 'unknown', error=str(e), code=e.code) + + # Generic error message for security (don't reveal if email exists) + return unauthorized_response(message="Invalid email or password") + + except Exception as e: + logger.error("Unexpected error during login", error=str(e)) + return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR") + + +@auth_bp.route('/api/v1/auth/logout', methods=['POST']) +@require_auth +def api_logout(): + """ + Log out the current user by deleting their session. + + Returns: + 200: Logout successful, session cookie cleared + 401: Not authenticated + 500: Internal server error + """ + try: + # Get session token + token = extract_session_token() + + if not token: + return unauthorized_response(message="No active session") + + # Logout user + appwrite = AppwriteService() + appwrite.logout_user(session_id=token) + + user = get_current_user() + logger.info("User logged out successfully", user_id=user.id if user else 'unknown') + + # Clear session cookie + config = get_config() + + response = make_response(success_response( + result={"message": "Logout successful"} + )) + + response.set_cookie( + key=config.auth.cookie_name, + value='', + max_age=0, + httponly=config.auth.http_only, + secure=config.auth.secure, + samesite=config.auth.same_site, + path=config.auth.path + ) + + return response + + except AppwriteException as e: + logger.error("Logout failed", error=str(e), code=e.code) + return error_response(message="Logout failed", code="LOGOUT_ERROR") + + except Exception as e: + logger.error("Unexpected error during logout", error=str(e)) + return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR") + + +@auth_bp.route('/api/v1/auth/verify-email', methods=['GET']) +def api_verify_email(): + """ + Verify a user's email address. + + Query Parameters: + userId: User ID from verification link + secret: Verification secret from verification link + + Returns: + Redirects to login page with success/error message + """ + try: + user_id = request.args.get('userId') + secret = request.args.get('secret') + + if not user_id or not secret: + flash("Invalid verification link", "error") + return redirect(url_for('auth.login_page')) + + # Verify email + appwrite = AppwriteService() + appwrite.verify_email(user_id=user_id, secret=secret) + + logger.info("Email verified successfully", user_id=user_id) + + flash("Email verified successfully! You can now log in.", "success") + return redirect(url_for('auth.login_page')) + + except AppwriteException as e: + logger.error("Email verification failed", error=str(e), code=e.code) + flash("Email verification failed. The link may be invalid or expired.", "error") + return redirect(url_for('auth.login_page')) + + except Exception as e: + logger.error("Unexpected error during email verification", error=str(e)) + flash("An unexpected error occurred", "error") + return redirect(url_for('auth.login_page')) + + +@auth_bp.route('/api/v1/auth/forgot-password', methods=['POST']) +def api_forgot_password(): + """ + Request a password reset email. + + Request Body: + { + "email": "user@example.com" + } + + Returns: + 200: Always returns success (for security, don't reveal if email exists) + 400: Validation error + 500: Internal server error + """ + try: + # Get request data + data = request.get_json() + + if not data: + return validation_error_response({"error": "Request body is required"}) + + email = data.get('email', '').strip().lower() + + # Validate email + email_valid, email_error = validate_email(email) + if not email_valid: + return validation_error_response({"email": email_error}) + + # Request password reset + appwrite = AppwriteService() + appwrite.request_password_reset(email=email) + + logger.info("Password reset requested", email=email) + + # Always return success for security + return success_response( + result={ + "message": "If an account exists with this email, you will receive a password reset link shortly." + } + ) + + except Exception as e: + logger.error("Unexpected error during password reset request", error=str(e)) + # Still return success for security + return success_response( + result={ + "message": "If an account exists with this email, you will receive a password reset link shortly." + } + ) + + +@auth_bp.route('/api/v1/auth/reset-password', methods=['POST']) +def api_reset_password(): + """ + Confirm password reset and update password. + + Request Body: + { + "user_id": "user_id_from_link", + "secret": "secret_from_link", + "password": "NewSecurePass123!" + } + + Returns: + 200: Password reset successful + 400: Validation error or invalid/expired link + 500: Internal server error + """ + try: + # Get request data + data = request.get_json() + + if not data: + return validation_error_response({"error": "Request body is required"}) + + user_id = data.get('user_id', '').strip() + secret = data.get('secret', '').strip() + password = data.get('password', '') + + # Validate inputs + validation_errors = {} + + if not user_id: + validation_errors['user_id'] = "User ID is required" + + if not secret: + validation_errors['secret'] = "Reset secret is required" + + password_valid, password_error = validate_password(password) + if not password_valid: + validation_errors['password'] = password_error + + if validation_errors: + return validation_error_response(validation_errors) + + # Confirm password reset + appwrite = AppwriteService() + appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password) + + logger.info("Password reset successfully", user_id=user_id) + + return success_response( + result={ + "message": "Password reset successful. You can now log in with your new password." + } + ) + + except AppwriteException as e: + logger.error("Password reset failed", error=str(e), code=e.code) + return error_response( + message="Password reset failed. The link may be invalid or expired.", + code="PASSWORD_RESET_ERROR" + ) + + except Exception as e: + logger.error("Unexpected error during password reset", error=str(e)) + return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR") + + +# ===== TEMPLATE ROUTES (for rendering HTML pages) ===== + +@auth_bp.route('/login', methods=['GET']) +def login_page(): + """Render the login page.""" + return render_template('auth/login.html') + + +@auth_bp.route('/register', methods=['GET']) +def register_page(): + """Render the registration page.""" + return render_template('auth/register.html') + + +@auth_bp.route('/forgot-password', methods=['GET']) +def forgot_password_page(): + """Render the forgot password page.""" + return render_template('auth/forgot_password.html') + + +@auth_bp.route('/reset-password', methods=['GET']) +def reset_password_page(): + """Render the reset password page.""" + user_id = request.args.get('userId', '') + secret = request.args.get('secret', '') + + return render_template('auth/reset_password.html', user_id=user_id, secret=secret) diff --git a/api/app/api/characters.py b/api/app/api/characters.py new file mode 100644 index 0000000..4ad16bc --- /dev/null +++ b/api/app/api/characters.py @@ -0,0 +1,898 @@ +""" +Character API Blueprint + +This module provides API endpoints for character management: +- List user's characters +- Get character details +- Create new character +- Delete character +- Unlock skills +- Respec skills + +All endpoints require authentication and enforce ownership validation. +""" + +from flask import Blueprint, request + +from app.services.character_service import ( + get_character_service, + CharacterLimitExceeded, + CharacterNotFound, + SkillUnlockError, + InsufficientGold +) +from app.services.class_loader import get_class_loader +from app.services.origin_service import get_origin_service +from app.utils.response import ( + success_response, + created_response, + error_response, + not_found_response, + validation_error_response +) +from app.utils.auth import require_auth, get_current_user +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +characters_bp = Blueprint('characters', __name__) + + +# ===== VALIDATION FUNCTIONS ===== + +def validate_character_name(name: str) -> tuple[bool, str]: + """ + Validate character name. + + Args: + name: Character name to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not name: + return False, "Character name is required" + + if len(name) < 2: + return False, "Character name must be at least 2 characters" + + if len(name) > 50: + return False, "Character name must be no more than 50 characters" + + # Allow letters, spaces, hyphens, apostrophes, and common fantasy characters + if not all(c.isalnum() or c in " -'" for c in name): + return False, "Character name can only contain letters, numbers, spaces, hyphens, and apostrophes" + + return True, "" + + +def validate_class_id(class_id: str) -> tuple[bool, str]: + """ + Validate class ID. + + Args: + class_id: Class ID to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not class_id: + return False, "Class ID is required" + + valid_classes = [ + 'vanguard', 'assassin', 'arcanist', 'luminary', + 'wildstrider', 'oathkeeper', 'necromancer', 'lorekeeper' + ] + + if class_id not in valid_classes: + return False, f"Invalid class ID. Must be one of: {', '.join(valid_classes)}" + + return True, "" + + +def validate_origin_id(origin_id: str) -> tuple[bool, str]: + """ + Validate origin ID. + + Args: + origin_id: Origin ID to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not origin_id: + return False, "Origin ID is required" + + valid_origins = [ + 'soul_revenant', 'memory_thief', 'shadow_apprentice', 'escaped_captive' + ] + + if origin_id not in valid_origins: + return False, f"Invalid origin ID. Must be one of: {', '.join(valid_origins)}" + + return True, "" + + +def validate_skill_id(skill_id: str) -> tuple[bool, str]: + """ + Validate skill ID. + + Args: + skill_id: Skill ID to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not skill_id: + return False, "Skill ID is required" + + if len(skill_id) > 100: + return False, "Skill ID is too long" + + return True, "" + + +# ===== API ENDPOINTS ===== + +@characters_bp.route('/api/v1/characters', methods=['GET']) +@require_auth +def list_characters(): + """ + List all characters owned by the current user. + + Returns: + 200: List of characters + 401: Not authenticated + 500: Internal server error + + Example Response: + { + "result": { + "characters": [ + { + "character_id": "char_001", + "name": "Thorin Ironheart", + "class": "warrior", + "level": 5, + "gold": 1000 + } + ], + "count": 1, + "tier": "free", + "limit": 1 + } + } + """ + try: + user = get_current_user() + logger.info("Listing characters", user_id=user.id) + + # Get character service + char_service = get_character_service() + + # Get user's characters + characters = char_service.get_user_characters(user.id) + + # Get tier information + tier = user.tier + from app.services.character_service import CHARACTER_LIMITS + limit = CHARACTER_LIMITS.get(tier, 1) + + # Convert characters to dict format + character_list = [ + { + "character_id": char.character_id, + "name": char.name, + "class": char.player_class.class_id, + "class_name": char.player_class.name, + "level": char.level, + "experience": char.experience, + "gold": char.gold, + "current_location": char.current_location, + "origin": char.origin.id + } + for char in characters + ] + + logger.info("Characters listed successfully", + user_id=user.id, + count=len(characters)) + + return success_response( + result={ + "characters": character_list, + "count": len(characters), + "tier": tier, + "limit": limit + } + ) + + except Exception as e: + logger.error("Failed to list characters", + user_id=user.id if user else 'unknown', + error=str(e)) + return error_response( + code="CHARACTER_LIST_ERROR", + message="Failed to retrieve characters", + status=500 + ) + + +@characters_bp.route('/api/v1/characters/', methods=['GET']) +@require_auth +def get_character(character_id: str): + """ + Get detailed information about a specific character. + + Args: + character_id: Character ID + + Returns: + 200: Character details + 401: Not authenticated + 404: Character not found or not owned by user + 500: Internal server error + + Example Response: + { + "result": { + "character_id": "char_001", + "name": "Thorin Ironheart", + "class": {...}, + "origin": {...}, + "level": 5, + "experience": 250, + "base_stats": {...}, + "unlocked_skills": [...], + "inventory": [...], + "equipped": {...}, + "gold": 1000 + } + } + """ + try: + user = get_current_user() + logger.info("Getting character", + user_id=user.id, + character_id=character_id) + + # Get character service + char_service = get_character_service() + + # Get character (ownership validated in service) + character = char_service.get_character(character_id, user.id) + + logger.info("Character retrieved successfully", + user_id=user.id, + character_id=character_id) + + return success_response(result=character.to_dict()) + + except CharacterNotFound as e: + logger.warning("Character not found", + user_id=user.id, + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except Exception as e: + logger.error("Failed to get character", + user_id=user.id, + character_id=character_id, + error=str(e)) + return error_response( + code="CHARACTER_GET_ERROR", + message="Failed to retrieve character", + status=500 + ) + + +@characters_bp.route('/api/v1/characters', methods=['POST']) +@require_auth +def create_character(): + """ + Create a new character for the current user. + + Request Body: + { + "name": "Thorin Ironheart", + "class_id": "warrior", + "origin_id": "soul_revenant" + } + + Returns: + 201: Character created successfully + 400: Validation error or character limit exceeded + 401: Not authenticated + 500: Internal server error + + Example Response: + { + "result": { + "character_id": "char_001", + "name": "Thorin Ironheart", + "class": "warrior", + "level": 1, + "message": "Character created successfully" + } + } + """ + try: + user = get_current_user() + + # Get request data + data = request.get_json() + + if not data: + return validation_error_response( + message="Request body is required", + details={"error": "Request body is required"} + ) + + name = data.get('name', '').strip() + class_id = data.get('class_id', '').strip().lower() + origin_id = data.get('origin_id', '').strip().lower() + + # Validate inputs + validation_errors = {} + + name_valid, name_error = validate_character_name(name) + if not name_valid: + validation_errors['name'] = name_error + + class_valid, class_error = validate_class_id(class_id) + if not class_valid: + validation_errors['class_id'] = class_error + + origin_valid, origin_error = validate_origin_id(origin_id) + if not origin_valid: + validation_errors['origin_id'] = origin_error + + if validation_errors: + return validation_error_response( + message="Validation failed", + details=validation_errors + ) + + logger.info("Creating character", + user_id=user.id, + name=name, + class_id=class_id, + origin_id=origin_id) + + # Get character service + char_service = get_character_service() + + # Create character + character = char_service.create_character( + user_id=user.id, + name=name, + class_id=class_id, + origin_id=origin_id + ) + + logger.info("Character created successfully", + user_id=user.id, + character_id=character.character_id, + name=name) + + return created_response( + result={ + "character_id": character.character_id, + "name": character.name, + "class": character.player_class.class_id, + "class_name": character.player_class.name, + "origin": character.origin.id, + "origin_name": character.origin.name, + "level": character.level, + "gold": character.gold, + "current_location": character.current_location, + "message": "Character created successfully" + } + ) + + except CharacterLimitExceeded as e: + logger.warning("Character limit exceeded", + user_id=user.id, + error=str(e)) + return error_response( + code="CHARACTER_LIMIT_EXCEEDED", + message=str(e), + status=400 + ) + + except ValueError as e: + logger.warning("Invalid class or origin", + user_id=user.id, + error=str(e)) + return validation_error_response( + message=str(e), + details={"error": str(e)} + ) + + except Exception as e: + logger.error("Failed to create character", + user_id=user.id, + error=str(e)) + return error_response( + code="CHARACTER_CREATE_ERROR", + message="Failed to create character", + status=500 + ) + + +@characters_bp.route('/api/v1/characters/', methods=['DELETE']) +@require_auth +def delete_character(character_id: str): + """ + Delete a character (soft delete - marks as inactive). + + Args: + character_id: Character ID + + Returns: + 200: Character deleted successfully + 401: Not authenticated + 404: Character not found or not owned by user + 500: Internal server error + + Example Response: + { + "result": { + "message": "Character deleted successfully", + "character_id": "char_001" + } + } + """ + try: + user = get_current_user() + logger.info("Deleting character", + user_id=user.id, + character_id=character_id) + + # Get character service + char_service = get_character_service() + + # Delete character (ownership validated in service) + char_service.delete_character(character_id, user.id) + + logger.info("Character deleted successfully", + user_id=user.id, + character_id=character_id) + + return success_response( + result={ + "message": "Character deleted successfully", + "character_id": character_id + } + ) + + except CharacterNotFound as e: + logger.warning("Character not found for deletion", + user_id=user.id, + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except Exception as e: + logger.error("Failed to delete character", + user_id=user.id, + character_id=character_id, + error=str(e)) + return error_response( + code="CHARACTER_DELETE_ERROR", + message="Failed to delete character", + status=500 + ) + + +@characters_bp.route('/api/v1/characters//skills/unlock', methods=['POST']) +@require_auth +def unlock_skill(character_id: str): + """ + Unlock a skill for a character. + + Args: + character_id: Character ID + + Request Body: + { + "skill_id": "power_strike" + } + + Returns: + 200: Skill unlocked successfully + 400: Validation error or unlock requirements not met + 401: Not authenticated + 404: Character not found or not owned by user + 500: Internal server error + + Example Response: + { + "result": { + "message": "Skill unlocked successfully", + "character_id": "char_001", + "skill_id": "power_strike", + "unlocked_skills": ["power_strike"], + "available_points": 0 + } + } + """ + try: + user = get_current_user() + + # Get request data + data = request.get_json() + + if not data: + return validation_error_response( + message="Request body is required", + details={"error": "Request body is required"} + ) + + skill_id = data.get('skill_id', '').strip() + + # Validate skill_id + skill_valid, skill_error = validate_skill_id(skill_id) + if not skill_valid: + return validation_error_response( + message="Validation failed", + details={"skill_id": skill_error} + ) + + logger.info("Unlocking skill", + user_id=user.id, + character_id=character_id, + skill_id=skill_id) + + # Get character service + char_service = get_character_service() + + # Unlock skill (validates ownership, prerequisites, skill points) + character = char_service.unlock_skill(character_id, user.id, skill_id) + + # Calculate available skill points + available_points = character.level - len(character.unlocked_skills) + + logger.info("Skill unlocked successfully", + user_id=user.id, + character_id=character_id, + skill_id=skill_id, + available_points=available_points) + + return success_response( + result={ + "message": "Skill unlocked successfully", + "character_id": character_id, + "skill_id": skill_id, + "unlocked_skills": character.unlocked_skills, + "available_points": available_points + } + ) + + except CharacterNotFound as e: + logger.warning("Character not found for skill unlock", + user_id=user.id, + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except SkillUnlockError as e: + logger.warning("Skill unlock failed", + user_id=user.id, + character_id=character_id, + skill_id=skill_id if 'skill_id' in locals() else 'unknown', + error=str(e)) + return error_response( + code="SKILL_UNLOCK_ERROR", + message=str(e), + status=400 + ) + + except Exception as e: + logger.error("Failed to unlock skill", + user_id=user.id, + character_id=character_id, + error=str(e)) + return error_response( + code="SKILL_UNLOCK_ERROR", + message="Failed to unlock skill", + status=500 + ) + + +@characters_bp.route('/api/v1/characters//skills/respec', methods=['POST']) +@require_auth +def respec_skills(character_id: str): + """ + Reset all unlocked skills for a character. + + Cost: level × 100 gold + + Args: + character_id: Character ID + + Returns: + 200: Skills reset successfully + 400: Insufficient gold + 401: Not authenticated + 404: Character not found or not owned by user + 500: Internal server error + + Example Response: + { + "result": { + "message": "Skills reset successfully", + "character_id": "char_001", + "cost": 500, + "remaining_gold": 500, + "available_points": 5 + } + } + """ + try: + user = get_current_user() + logger.info("Respecing character skills", + user_id=user.id, + character_id=character_id) + + # Get character service + char_service = get_character_service() + + # Get character to calculate cost + character = char_service.get_character(character_id, user.id) + respec_cost = character.level * 100 + + # Respec skills (validates ownership and gold) + character = char_service.respec_skills(character_id, user.id) + + # Calculate available skill points + available_points = character.level - len(character.unlocked_skills) + + logger.info("Skills respeced successfully", + user_id=user.id, + character_id=character_id, + cost=respec_cost, + remaining_gold=character.gold, + available_points=available_points) + + return success_response( + result={ + "message": "Skills reset successfully", + "character_id": character_id, + "cost": respec_cost, + "remaining_gold": character.gold, + "available_points": available_points + } + ) + + except CharacterNotFound as e: + logger.warning("Character not found for respec", + user_id=user.id, + character_id=character_id, + error=str(e)) + return not_found_response(message=str(e)) + + except InsufficientGold as e: + logger.warning("Insufficient gold for respec", + user_id=user.id, + character_id=character_id, + error=str(e)) + return error_response( + code="INSUFFICIENT_GOLD", + message=str(e), + status=400 + ) + + except Exception as e: + logger.error("Failed to respec skills", + user_id=user.id, + character_id=character_id, + error=str(e)) + return error_response( + code="RESPEC_ERROR", + message="Failed to reset skills", + status=500 + ) + + +# ===== CLASSES & ORIGINS ENDPOINTS (Reference Data) ===== + +@characters_bp.route('/api/v1/classes', methods=['GET']) +def list_classes(): + """ + List all available player classes. + + This endpoint provides reference data for character creation. + No authentication required. + + Returns: + 200: List of all classes with basic info + 500: Internal server error + + Example Response: + { + "result": { + "classes": [ + { + "class_id": "vanguard", + "name": "Vanguard", + "description": "Armored warrior...", + "base_stats": {...}, + "skill_trees": ["Shield Bearer", "Weapon Master"] + } + ], + "count": 8 + } + } + """ + try: + logger.info("Listing all classes") + + # Get class loader + class_loader = get_class_loader() + + # Get all class IDs + class_ids = class_loader.get_all_class_ids() + + # Load all classes + classes = [] + for class_id in class_ids: + player_class = class_loader.load_class(class_id) + if player_class: + classes.append({ + "class_id": player_class.class_id, + "name": player_class.name, + "description": player_class.description, + "base_stats": player_class.base_stats.to_dict(), + "skill_trees": [tree.name for tree in player_class.skill_trees], + "starting_equipment": player_class.starting_equipment, + "starting_abilities": player_class.starting_abilities + }) + + logger.info("Classes listed successfully", count=len(classes)) + + return success_response( + result={ + "classes": classes, + "count": len(classes) + } + ) + + except Exception as e: + logger.error("Failed to list classes", error=str(e)) + return error_response( + code="CLASS_LIST_ERROR", + message="Failed to retrieve classes", + status=500 + ) + + +@characters_bp.route('/api/v1/classes/', methods=['GET']) +def get_class(class_id: str): + """ + Get detailed information about a specific class. + + This endpoint provides full class data including skill trees. + No authentication required. + + Args: + class_id: Class ID (e.g., "vanguard", "assassin") + + Returns: + 200: Full class details with skill trees + 404: Class not found + 500: Internal server error + + Example Response: + { + "result": { + "class_id": "vanguard", + "name": "Vanguard", + "description": "Armored warrior...", + "base_stats": {...}, + "skill_trees": [ + { + "tree_id": "shield_bearer", + "name": "Shield Bearer", + "nodes": [...] + } + ], + "starting_equipment": [...], + "starting_abilities": [...] + } + } + """ + try: + logger.info("Getting class details", class_id=class_id) + + # Get class loader + class_loader = get_class_loader() + + # Load class + player_class = class_loader.load_class(class_id) + + if not player_class: + logger.warning("Class not found", class_id=class_id) + return not_found_response(message=f"Class not found: {class_id}") + + logger.info("Class retrieved successfully", class_id=class_id) + + # Return full class data + return success_response(result=player_class.to_dict()) + + except Exception as e: + logger.error("Failed to get class", + class_id=class_id, + error=str(e)) + return error_response( + code="CLASS_GET_ERROR", + message="Failed to retrieve class", + status=500 + ) + + +@characters_bp.route('/api/v1/origins', methods=['GET']) +def list_origins(): + """ + List all available character origins. + + This endpoint provides reference data for character creation. + No authentication required. + + Returns: + 200: List of all origins + 500: Internal server error + + Example Response: + { + "result": { + "origins": [ + { + "id": "soul_revenant", + "name": "Soul Revenant", + "description": "Returned from death...", + "starting_location": {...}, + "narrative_hooks": [...], + "starting_bonus": {...} + } + ], + "count": 4 + } + } + """ + try: + logger.info("Listing all origins") + + # Get origin service + origin_service = get_origin_service() + + # Get all origin IDs + origin_ids = origin_service.get_all_origin_ids() + + # Load all origins + origins = [] + for origin_id in origin_ids: + origin = origin_service.load_origin(origin_id) + if origin: + origins.append(origin.to_dict()) + + logger.info("Origins listed successfully", count=len(origins)) + + return success_response( + result={ + "origins": origins, + "count": len(origins) + } + ) + + except Exception as e: + logger.error("Failed to list origins", error=str(e)) + return error_response( + code="ORIGIN_LIST_ERROR", + message="Failed to retrieve origins", + status=500 + ) diff --git a/api/app/api/game_mechanics.py b/api/app/api/game_mechanics.py new file mode 100644 index 0000000..0151bb0 --- /dev/null +++ b/api/app/api/game_mechanics.py @@ -0,0 +1,302 @@ +""" +Game Mechanics API Blueprint + +This module provides API endpoints for game mechanics that determine +outcomes before AI narration: +- Skill checks (perception, persuasion, stealth, etc.) +- Search/loot actions +- Dice rolls + +These endpoints return structured results that can be used for UI +dice animations and then passed to AI for narrative description. +""" + +from flask import Blueprint, request + +from app.services.outcome_service import outcome_service +from app.services.character_service import get_character_service, CharacterNotFound +from app.game_logic.dice import SkillType, Difficulty +from app.utils.response import ( + success_response, + error_response, + not_found_response, + validation_error_response +) +from app.utils.auth import require_auth, get_current_user +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +game_mechanics_bp = Blueprint('game_mechanics', __name__, url_prefix='/api/v1/game') + + +# Valid skill types for API validation +VALID_SKILL_TYPES = [skill.name.lower() for skill in SkillType] + +# Valid difficulty names +VALID_DIFFICULTIES = ["trivial", "easy", "medium", "hard", "very_hard", "nearly_impossible"] + + +@game_mechanics_bp.route('/check', methods=['POST']) +@require_auth +def perform_check(): + """ + Perform a skill check or search action. + + This endpoint determines the outcome of chance-based actions before + they are passed to AI for narration. The result includes all dice + roll details for UI display. + + Request JSON: + { + "character_id": "...", + "check_type": "search" | "skill", + "skill": "perception", // Required for skill checks + "dc": 15, // Optional, can use difficulty instead + "difficulty": "medium", // Optional, alternative to dc + "location_type": "forest", // For search checks + "context": {} // Optional additional context + } + + Returns: + For search checks: + { + "check_result": { + "roll": 14, + "modifier": 3, + "total": 17, + "dc": 15, + "success": true, + "margin": 2 + }, + "items_found": [...], + "gold_found": 5 + } + + For skill checks: + { + "check_result": {...}, + "context": { + "skill_used": "persuasion", + "stat_used": "charisma", + ... + } + } + """ + user = get_current_user() + data = request.get_json() + + if not data: + return validation_error_response( + message="Request body is required", + details={"field": "body", "issue": "Missing JSON body"} + ) + + # Validate required fields + character_id = data.get("character_id") + check_type = data.get("check_type") + + if not character_id: + return validation_error_response( + message="Character ID is required", + details={"field": "character_id", "issue": "Missing required field"} + ) + + if not check_type: + return validation_error_response( + message="Check type is required", + details={"field": "check_type", "issue": "Missing required field"} + ) + + if check_type not in ["search", "skill"]: + return validation_error_response( + message="Invalid check type", + details={"field": "check_type", "issue": "Must be 'search' or 'skill'"} + ) + + # Get character and verify ownership + try: + character_service = get_character_service() + character = character_service.get_character(character_id) + + if character.user_id != user["user_id"]: + return error_response( + status_code=403, + message="You don't have permission to access this character", + error_code="FORBIDDEN" + ) + except CharacterNotFound: + return not_found_response( + message=f"Character not found: {character_id}" + ) + except Exception as e: + logger.error("character_fetch_error", error=str(e), character_id=character_id) + return error_response( + status_code=500, + message="Failed to fetch character", + error_code="CHARACTER_FETCH_ERROR" + ) + + # Determine DC from difficulty name or direct value + dc = data.get("dc") + difficulty = data.get("difficulty") + + if dc is None and difficulty: + if difficulty.lower() not in VALID_DIFFICULTIES: + return validation_error_response( + message="Invalid difficulty", + details={ + "field": "difficulty", + "issue": f"Must be one of: {', '.join(VALID_DIFFICULTIES)}" + } + ) + dc = outcome_service.get_dc_for_difficulty(difficulty) + elif dc is None: + # Default to medium difficulty + dc = Difficulty.MEDIUM.value + + # Validate DC range + if not isinstance(dc, int) or dc < 1 or dc > 35: + return validation_error_response( + message="Invalid DC value", + details={"field": "dc", "issue": "DC must be an integer between 1 and 35"} + ) + + # Get optional bonus + bonus = data.get("bonus", 0) + if not isinstance(bonus, int): + bonus = 0 + + # Perform the check based on type + try: + if check_type == "search": + # Search check uses perception + location_type = data.get("location_type", "default") + outcome = outcome_service.determine_search_outcome( + character=character, + location_type=location_type, + dc=dc, + bonus=bonus + ) + + logger.info( + "search_check_performed", + user_id=user["user_id"], + character_id=character_id, + location_type=location_type, + success=outcome.check_result.success + ) + + return success_response(result=outcome.to_dict()) + + else: # skill check + skill = data.get("skill") + if not skill: + return validation_error_response( + message="Skill is required for skill checks", + details={"field": "skill", "issue": "Missing required field"} + ) + + skill_lower = skill.lower() + if skill_lower not in VALID_SKILL_TYPES: + return validation_error_response( + message="Invalid skill type", + details={ + "field": "skill", + "issue": f"Must be one of: {', '.join(VALID_SKILL_TYPES)}" + } + ) + + # Convert to SkillType enum + skill_type = SkillType[skill.upper()] + + # Get additional context + context = data.get("context", {}) + + outcome = outcome_service.determine_skill_check_outcome( + character=character, + skill_type=skill_type, + dc=dc, + bonus=bonus, + context=context + ) + + logger.info( + "skill_check_performed", + user_id=user["user_id"], + character_id=character_id, + skill=skill_lower, + success=outcome.check_result.success + ) + + return success_response(result=outcome.to_dict()) + + except Exception as e: + logger.error( + "check_error", + error=str(e), + check_type=check_type, + character_id=character_id + ) + return error_response( + status_code=500, + message="Failed to perform check", + error_code="CHECK_ERROR" + ) + + +@game_mechanics_bp.route('/skills', methods=['GET']) +def list_skills(): + """ + List all available skill types. + + Returns the skill types available for skill checks, + along with their associated base stats. + + Returns: + { + "skills": [ + { + "name": "perception", + "stat": "wisdom", + "description": "..." + }, + ... + ] + } + """ + skills = [] + for skill in SkillType: + skills.append({ + "name": skill.name.lower(), + "stat": skill.value, + }) + + return success_response(result={"skills": skills}) + + +@game_mechanics_bp.route('/difficulties', methods=['GET']) +def list_difficulties(): + """ + List all difficulty levels and their DC values. + + Returns: + { + "difficulties": [ + {"name": "trivial", "dc": 5}, + {"name": "easy", "dc": 10}, + ... + ] + } + """ + difficulties = [] + for diff in Difficulty: + difficulties.append({ + "name": diff.name.lower(), + "dc": diff.value, + }) + + return success_response(result={"difficulties": difficulties}) diff --git a/api/app/api/health.py b/api/app/api/health.py new file mode 100644 index 0000000..905a47c --- /dev/null +++ b/api/app/api/health.py @@ -0,0 +1,60 @@ +""" +Health Check API Blueprint + +This module provides a simple health check endpoint for monitoring +and testing API connectivity. +""" + +from flask import Blueprint +from app.utils.response import success_response +from app.utils.logging import get_logger +from app.config import get_config + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +health_bp = Blueprint('health', __name__, url_prefix='/api/v1') + + +@health_bp.route('/health', methods=['GET']) +def health_check(): + """ + Health check endpoint. + + Returns basic service status and version information. + Useful for monitoring, load balancers, and testing API connectivity. + + Returns: + JSON response with status "ok" and version info + + Example: + GET /api/v1/health + + Response: + { + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-16T...", + "result": { + "status": "ok", + "service": "Code of Conquest API", + "version": "0.1.0" + }, + "error": null, + "meta": {} + } + """ + config = get_config() + + logger.debug("Health check requested") + + return success_response( + result={ + "status": "ok", + "service": "Code of Conquest API", + "version": config.app.version + } + ) diff --git a/api/app/api/jobs.py b/api/app/api/jobs.py new file mode 100644 index 0000000..c01d43e --- /dev/null +++ b/api/app/api/jobs.py @@ -0,0 +1,71 @@ +""" +Jobs API Blueprint + +This module provides API endpoints for job status polling: +- Get job status +- Get job result + +All endpoints require authentication. +""" + +from flask import Blueprint + +from app.tasks.ai_tasks import get_job_status, get_job_result +from app.utils.response import ( + success_response, + not_found_response, + error_response +) +from app.utils.auth import require_auth +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +jobs_bp = Blueprint('jobs', __name__) + + +@jobs_bp.route('/api/v1/jobs//status', methods=['GET']) +@require_auth +def job_status(job_id: str): + """ + Get the status of an AI job. + + Args: + job_id: The job ID returned from action submission + + Returns: + JSON response with job status and result if completed + """ + try: + status_info = get_job_status(job_id) + + if not status_info or status_info.get('status') == 'not_found': + return not_found_response(f"Job not found: {job_id}") + + # If completed, include the result + if status_info.get('status') == 'completed': + result = get_job_result(job_id) + if result: + status_info['dm_response'] = result.get('dm_response', '') + status_info['tokens_used'] = result.get('tokens_used', 0) + status_info['model'] = result.get('model', '') + + logger.debug("Job status retrieved", + job_id=job_id, + status=status_info.get('status')) + + return success_response(status_info) + + except Exception as e: + logger.error("Failed to get job status", + job_id=job_id, + error=str(e), + exc_info=True) + return error_response( + status=500, + code="JOB_STATUS_ERROR", + message="Failed to get job status" + ) diff --git a/api/app/api/npcs.py b/api/app/api/npcs.py new file mode 100644 index 0000000..b4834b9 --- /dev/null +++ b/api/app/api/npcs.py @@ -0,0 +1,429 @@ +""" +NPC API Blueprint + +This module provides API endpoints for NPC interactions: +- Get NPC details +- Talk to NPC (queues AI dialogue generation) +- Get NPCs at location + +All endpoints require authentication and enforce ownership validation. +""" + +from datetime import datetime, timezone +from flask import Blueprint, request + +from app.services.session_service import get_session_service, SessionNotFound +from app.services.character_service import get_character_service, CharacterNotFound +from app.services.npc_loader import get_npc_loader +from app.services.location_loader import get_location_loader +from app.tasks.ai_tasks import enqueue_ai_task, TaskType +from app.utils.response import ( + success_response, + accepted_response, + error_response, + not_found_response, + validation_error_response +) +from app.utils.auth import require_auth, get_current_user +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +npcs_bp = Blueprint('npcs', __name__) + + +@npcs_bp.route('/api/v1/npcs/', methods=['GET']) +@require_auth +def get_npc_details(npc_id: str): + """ + Get NPC details with knowledge filtered by character interaction state. + + Path params: + npc_id: NPC ID to get details for + + Query params: + character_id: Optional character ID for filtering revealed secrets + + Returns: + JSON response with NPC details + """ + try: + user = get_current_user() + character_id = request.args.get('character_id') + + # Load NPC + npc_loader = get_npc_loader() + npc = npc_loader.load_npc(npc_id) + + if not npc: + return not_found_response("NPC not found") + + npc_data = npc.to_dict() + + # Filter knowledge based on character interaction state + if character_id: + try: + character_service = get_character_service() + character = character_service.get_character(character_id, user.id) + + if character: + # Get revealed secrets based on conditions + revealed = character_service.check_npc_secret_conditions(character, npc) + + # Build available knowledge (public + revealed) + available_knowledge = [] + if npc.knowledge: + available_knowledge.extend(npc.knowledge.public) + available_knowledge.extend(revealed) + + npc_data["available_knowledge"] = available_knowledge + + # Remove secret knowledge from response + if npc_data.get("knowledge"): + npc_data["knowledge"]["secret"] = [] + npc_data["knowledge"]["will_share_if"] = [] + + # Add interaction summary + interaction = character.npc_interactions.get(npc_id, {}) + npc_data["interaction_summary"] = { + "interaction_count": interaction.get("interaction_count", 0), + "relationship_level": interaction.get("relationship_level", 50), + "first_met": interaction.get("first_met"), + } + + except CharacterNotFound: + logger.debug("Character not found for NPC filter", character_id=character_id) + + return success_response(npc_data) + + except Exception as e: + logger.error("Failed to get NPC details", + npc_id=npc_id, + error=str(e)) + return error_response("Failed to get NPC", 500) + + +@npcs_bp.route('/api/v1/npcs//talk', methods=['POST']) +@require_auth +def talk_to_npc(npc_id: str): + """ + Initiate conversation with an NPC. + + Validates NPC is at current location, updates interaction state, + and queues AI dialogue generation task. + + Path params: + npc_id: NPC ID to talk to + + Request body: + session_id: Active session ID + topic: Conversation topic/opener (default: "greeting") + player_response: What the player says to the NPC (overrides topic if provided) + + Returns: + JSON response with job_id for polling result + """ + try: + user = get_current_user() + data = request.get_json() + + session_id = data.get('session_id') + # player_response overrides topic for bidirectional dialogue + player_response = data.get('player_response') + topic = player_response if player_response else data.get('topic', 'greeting') + + if not session_id: + return validation_error_response("session_id is required") + + # Get session + session_service = get_session_service() + session = session_service.get_session(session_id, user.id) + + # Load NPC + npc_loader = get_npc_loader() + npc = npc_loader.load_npc(npc_id) + + if not npc: + return not_found_response("NPC not found") + + # Validate NPC is at current location + if npc.location_id != session.game_state.current_location: + logger.warning("NPC not at current location", + npc_id=npc_id, + npc_location=npc.location_id, + current_location=session.game_state.current_location) + return error_response("NPC is not at your current location", 400) + + # Get character + character_service = get_character_service() + character = character_service.get_character(session.solo_character_id, user.id) + + # Get or create interaction state + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + interaction = character.npc_interactions.get(npc_id, {}) + + if not interaction: + # First meeting + interaction = { + "npc_id": npc_id, + "first_met": now, + "last_interaction": now, + "interaction_count": 1, + "revealed_secrets": [], + "relationship_level": 50, + "custom_flags": {}, + } + else: + # Update existing interaction + interaction["last_interaction"] = now + interaction["interaction_count"] = interaction.get("interaction_count", 0) + 1 + + # Check for newly revealed secrets + revealed = character_service.check_npc_secret_conditions(character, npc) + + # Update character with new interaction state + character_service.update_npc_interaction( + character.character_id, + user.id, + npc_id, + interaction + ) + + # Build NPC knowledge for AI context + npc_knowledge = [] + if npc.knowledge: + npc_knowledge.extend(npc.knowledge.public) + npc_knowledge.extend(revealed) + + # Get previous dialogue history for context (last 3 exchanges) + previous_dialogue = character_service.get_npc_dialogue_history( + character.character_id, + user.id, + npc_id, + limit=3 + ) + + # Prepare AI context + task_context = { + "session_id": session_id, + "character_id": character.character_id, + "character": character.to_story_dict(), + "npc": npc.to_story_dict(), + "npc_full": npc.to_dict(), # Full NPC data for reference + "conversation_topic": topic, + "game_state": session.game_state.to_dict(), + "npc_knowledge": npc_knowledge, + "revealed_secrets": revealed, + "interaction_count": interaction["interaction_count"], + "relationship_level": interaction.get("relationship_level", 50), + "previous_dialogue": previous_dialogue, # Pass conversation history + } + + # Enqueue AI task + result = enqueue_ai_task( + task_type=TaskType.NPC_DIALOGUE, + user_id=user.id, + context=task_context, + priority="normal", + session_id=session_id, + character_id=character.character_id + ) + + logger.info("NPC dialogue task queued", + user_id=user.id, + npc_id=npc_id, + job_id=result.get('job_id'), + interaction_count=interaction["interaction_count"]) + + return accepted_response({ + "job_id": result.get('job_id'), + "status": "queued", + "message": f"Starting conversation with {npc.name}...", + "npc_name": npc.name, + "npc_role": npc.role, + }) + + except SessionNotFound: + return not_found_response("Session not found") + except CharacterNotFound: + return not_found_response("Character not found") + except Exception as e: + logger.error("Failed to talk to NPC", + npc_id=npc_id, + error=str(e)) + return error_response("Failed to start conversation", 500) + + +@npcs_bp.route('/api/v1/npcs/at-location/', methods=['GET']) +@require_auth +def get_npcs_at_location(location_id: str): + """ + Get all NPCs at a specific location. + + Path params: + location_id: Location ID to get NPCs for + + Returns: + JSON response with list of NPCs at location + """ + try: + npc_loader = get_npc_loader() + npcs = npc_loader.get_npcs_at_location(location_id) + + npcs_list = [] + for npc in npcs: + npcs_list.append({ + "npc_id": npc.npc_id, + "name": npc.name, + "role": npc.role, + "appearance": npc.appearance.brief, + "tags": npc.tags, + }) + + return success_response({ + "location_id": location_id, + "npcs": npcs_list, + }) + + except Exception as e: + logger.error("Failed to get NPCs at location", + location_id=location_id, + error=str(e)) + return error_response("Failed to get NPCs", 500) + + +@npcs_bp.route('/api/v1/npcs//relationship', methods=['POST']) +@require_auth +def adjust_npc_relationship(npc_id: str): + """ + Adjust relationship level with an NPC. + + Path params: + npc_id: NPC ID + + Request body: + character_id: Character ID + adjustment: Amount to add/subtract (can be negative) + + Returns: + JSON response with updated relationship level + """ + try: + user = get_current_user() + data = request.get_json() + + character_id = data.get('character_id') + adjustment = data.get('adjustment', 0) + + if not character_id: + return validation_error_response("character_id is required") + + if not isinstance(adjustment, int): + return validation_error_response("adjustment must be an integer") + + # Validate NPC exists + npc_loader = get_npc_loader() + npc = npc_loader.load_npc(npc_id) + + if not npc: + return not_found_response("NPC not found") + + # Adjust relationship + character_service = get_character_service() + character = character_service.adjust_npc_relationship( + character_id, + user.id, + npc_id, + adjustment + ) + + new_level = character.npc_interactions.get(npc_id, {}).get("relationship_level", 50) + + logger.info("NPC relationship adjusted", + npc_id=npc_id, + character_id=character_id, + adjustment=adjustment, + new_level=new_level) + + return success_response({ + "npc_id": npc_id, + "relationship_level": new_level, + }) + + except CharacterNotFound: + return not_found_response("Character not found") + except Exception as e: + logger.error("Failed to adjust NPC relationship", + npc_id=npc_id, + error=str(e)) + return error_response("Failed to adjust relationship", 500) + + +@npcs_bp.route('/api/v1/npcs//flag', methods=['POST']) +@require_auth +def set_npc_flag(npc_id: str): + """ + Set a custom flag on NPC interaction (e.g., "helped_with_rats": true). + + Path params: + npc_id: NPC ID + + Request body: + character_id: Character ID + flag_name: Name of the flag + flag_value: Value to set + + Returns: + JSON response confirming flag was set + """ + try: + user = get_current_user() + data = request.get_json() + + character_id = data.get('character_id') + flag_name = data.get('flag_name') + flag_value = data.get('flag_value') + + if not character_id: + return validation_error_response("character_id is required") + if not flag_name: + return validation_error_response("flag_name is required") + + # Validate NPC exists + npc_loader = get_npc_loader() + npc = npc_loader.load_npc(npc_id) + + if not npc: + return not_found_response("NPC not found") + + # Set flag + character_service = get_character_service() + character_service.set_npc_custom_flag( + character_id, + user.id, + npc_id, + flag_name, + flag_value + ) + + logger.info("NPC flag set", + npc_id=npc_id, + character_id=character_id, + flag_name=flag_name) + + return success_response({ + "npc_id": npc_id, + "flag_name": flag_name, + "flag_value": flag_value, + }) + + except CharacterNotFound: + return not_found_response("Character not found") + except Exception as e: + logger.error("Failed to set NPC flag", + npc_id=npc_id, + error=str(e)) + return error_response("Failed to set flag", 500) diff --git a/api/app/api/sessions.py b/api/app/api/sessions.py new file mode 100644 index 0000000..5f5d599 --- /dev/null +++ b/api/app/api/sessions.py @@ -0,0 +1,604 @@ +""" +Sessions API Blueprint + +This module provides API endpoints for story session management: +- Create new solo session +- Get session state +- Take action (async AI processing) +- Get conversation history + +All endpoints require authentication and enforce ownership validation. +""" + +from flask import Blueprint, request, g + +from app.services.session_service import ( + get_session_service, + SessionNotFound, + SessionLimitExceeded, + SessionValidationError +) +from app.services.character_service import CharacterNotFound, get_character_service +from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded +from app.services.action_prompt_loader import ActionPromptLoader, ActionPromptNotFoundError +from app.services.outcome_service import outcome_service +from app.tasks.ai_tasks import enqueue_ai_task, TaskType, get_job_status, get_job_result +from app.ai.model_selector import UserTier +from app.models.action_prompt import LocationType +from app.game_logic.dice import SkillType +from app.utils.response import ( + success_response, + created_response, + accepted_response, + error_response, + not_found_response, + validation_error_response, + rate_limit_exceeded_response +) +from app.utils.auth import require_auth, get_current_user +from app.utils.logging import get_logger +from app.config import get_config + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +sessions_bp = Blueprint('sessions', __name__) + + +# ===== VALIDATION FUNCTIONS ===== + +def validate_character_id(character_id: str) -> tuple[bool, str]: + """ + Validate character ID format. + + Args: + character_id: Character ID to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not character_id: + return False, "Character ID is required" + + if not isinstance(character_id, str): + return False, "Character ID must be a string" + + if len(character_id) > 100: + return False, "Character ID is too long" + + return True, "" + + +def validate_action_request(data: dict, user_tier: UserTier) -> tuple[bool, str]: + """ + Validate action request data. + + Args: + data: Request JSON data + user_tier: User's subscription tier + + Returns: + Tuple of (is_valid, error_message) + """ + action_type = data.get('action_type') + + if action_type != 'button': + return False, "action_type must be 'button'" + + if not data.get('prompt_id'): + return False, "prompt_id is required for button actions" + + return True, "" + + +def get_user_tier_from_user(user) -> UserTier: + """ + Get UserTier enum from user object. + + Args: + user: User object from auth + + Returns: + UserTier enum value + """ + # Map user tier string to UserTier enum + tier_mapping = { + 'free': UserTier.FREE, + 'basic': UserTier.BASIC, + 'premium': UserTier.PREMIUM, + 'elite': UserTier.ELITE + } + + user_tier_str = getattr(user, 'tier', 'free').lower() + return tier_mapping.get(user_tier_str, UserTier.FREE) + + +# ===== API ENDPOINTS ===== + +@sessions_bp.route('/api/v1/sessions', methods=['GET']) +@require_auth +def list_sessions(): + """ + List user's active game sessions. + + Returns all active sessions for the authenticated user with basic session info. + + Returns: + JSON response with list of sessions + """ + try: + user = get_current_user() + user_id = user.id + session_service = get_session_service() + + # Get user's active sessions + sessions = session_service.get_user_sessions(user_id, active_only=True) + + # Build response with basic session info + sessions_list = [] + for session in sessions: + sessions_list.append({ + 'session_id': session.session_id, + 'character_id': session.solo_character_id, + 'turn_number': session.turn_number, + 'status': session.status.value, + 'created_at': session.created_at, + 'last_activity': session.last_activity, + 'game_state': { + 'current_location': session.game_state.current_location, + 'location_type': session.game_state.location_type.value + } + }) + + logger.info("Sessions listed successfully", + user_id=user_id, + count=len(sessions_list)) + + return success_response(sessions_list) + + except Exception as e: + logger.error("Failed to list sessions", error=str(e)) + return error_response(f"Failed to list sessions: {str(e)}", 500) + + +@sessions_bp.route('/api/v1/sessions', methods=['POST']) +@require_auth +def create_session(): + """ + Create a new solo game session. + + Request Body: + { + "character_id": "char_456" + } + + Returns: + 201: Session created with initial state + 400: Validation error + 401: Not authenticated + 404: Character not found + 409: Session limit exceeded + 500: Internal server error + """ + logger.info("Creating new session") + + try: + # Get current user + user = get_current_user() + user_id = user.id + + # Parse and validate request + data = request.get_json() + if not data: + return validation_error_response("Request body is required") + + character_id = data.get('character_id') + is_valid, error_msg = validate_character_id(character_id) + if not is_valid: + return validation_error_response(error_msg) + + # Create session + session_service = get_session_service() + session = session_service.create_solo_session( + user_id=user_id, + character_id=character_id + ) + + logger.info("Session created successfully", + session_id=session.session_id, + user_id=user_id, + character_id=character_id) + + # Return session data + return created_response({ + "session_id": session.session_id, + "character_id": session.solo_character_id, + "turn_number": session.turn_number, + "game_state": { + "current_location": session.game_state.current_location, + "location_type": session.game_state.location_type.value, + "active_quests": session.game_state.active_quests + } + }) + + except CharacterNotFound as e: + logger.warning("Character not found for session creation", + error=str(e)) + return not_found_response("Character not found") + + except SessionLimitExceeded as e: + logger.warning("Session limit exceeded", + user_id=user_id if 'user_id' in locals() else 'unknown', + error=str(e)) + return error_response( + status=409, + code="SESSION_LIMIT_EXCEEDED", + message="Maximum active sessions limit reached (5). Please end an existing session first." + ) + + except Exception as e: + logger.error("Failed to create session", + error=str(e), + exc_info=True) + return error_response( + status=500, + code="SESSION_CREATE_ERROR", + message="Failed to create session" + ) + + +@sessions_bp.route('/api/v1/sessions//action', methods=['POST']) +@require_auth +def take_action(session_id: str): + """ + Submit an action for AI processing (async). + + Request Body: + { + "action_type": "button", + "prompt_id": "ask_locals" + } + + Returns: + 202: Action queued for processing + 400: Validation error + 401: Not authenticated + 403: Action not available for tier/location + 404: Session not found + 429: Rate limit exceeded + 500: Internal server error + """ + logger.info("Processing action request", session_id=session_id) + + try: + # Get current user + user = get_current_user() + user_id = user.id + user_tier = get_user_tier_from_user(user) + + # Verify session ownership and get session + session_service = get_session_service() + session = session_service.get_session(session_id, user_id) + + # Parse and validate request + data = request.get_json() + if not data: + return validation_error_response("Request body is required") + + is_valid, error_msg = validate_action_request(data, user_tier) + if not is_valid: + return validation_error_response(error_msg) + + # Check rate limit + rate_limiter = RateLimiterService() + + try: + rate_limiter.check_rate_limit(user_id, user_tier) + except RateLimitExceeded as e: + logger.warning("Rate limit exceeded", + user_id=user_id, + tier=user_tier.value) + return rate_limit_exceeded_response( + message=f"Daily turn limit reached ({e.limit} turns). Resets at {e.reset_time.strftime('%H:%M UTC')}" + ) + + # Build action context for AI task + prompt_id = data.get('prompt_id') + + # Validate prompt exists and is available + loader = ActionPromptLoader() + try: + action_prompt = loader.get_action_by_id(prompt_id) + except ActionPromptNotFoundError: + return validation_error_response(f"Invalid prompt_id: {prompt_id}") + + # Check if action is available for user's tier and location + location_type = session.game_state.location_type + if not action_prompt.is_available(user_tier, location_type): + return error_response( + status=403, + code="ACTION_NOT_AVAILABLE", + message="This action is not available for your tier or location" + ) + + action_text = action_prompt.display_text + dm_prompt_template = action_prompt.dm_prompt_template + + # Fetch character data for AI context + character_service = get_character_service() + character = character_service.get_character(session.solo_character_id, user_id) + if not character: + return not_found_response(f"Character {session.solo_character_id} not found") + + # Perform dice check if action requires it + check_outcome = None + if action_prompt.requires_check: + check_req = action_prompt.requires_check + location_type_str = session.game_state.location_type.value if hasattr(session.game_state.location_type, 'value') else str(session.game_state.location_type) + + # Get DC from difficulty + dc = outcome_service.get_dc_for_difficulty(check_req.difficulty) + + if check_req.check_type == "search": + # Search check - uses perception and returns items/gold + outcome = outcome_service.determine_search_outcome( + character=character, + location_type=location_type_str, + dc=dc + ) + check_outcome = outcome.to_dict() + + logger.info( + "Search check performed", + character_id=character.character_id, + success=outcome.check_result.success, + items_found=len(outcome.items_found), + gold_found=outcome.gold_found + ) + elif check_req.check_type == "skill" and check_req.skill: + # Skill check - generic skill vs DC + try: + skill_type = SkillType[check_req.skill.upper()] + outcome = outcome_service.determine_skill_check_outcome( + character=character, + skill_type=skill_type, + dc=dc + ) + check_outcome = outcome.to_dict() + + logger.info( + "Skill check performed", + character_id=character.character_id, + skill=check_req.skill, + success=outcome.check_result.success + ) + except (KeyError, ValueError) as e: + logger.warning( + "Invalid skill type in action prompt", + prompt_id=action_prompt.prompt_id, + skill=check_req.skill, + error=str(e) + ) + + # Queue AI task + # Use trimmed character data for AI prompts (reduces tokens, focuses on story-relevant info) + task_context = { + "session_id": session_id, + "character_id": session.solo_character_id, + "action": action_text, + "prompt_id": prompt_id, + "dm_prompt_template": dm_prompt_template, + "character": character.to_story_dict(), + "game_state": session.game_state.to_dict(), + "turn_number": session.turn_number, + "conversation_history": [entry.to_dict() for entry in session.conversation_history], + "world_context": None, # TODO: Add world context source when available + "check_outcome": check_outcome # Dice check result for predetermined outcomes + } + + result = enqueue_ai_task( + task_type=TaskType.NARRATIVE, + user_id=user_id, + context=task_context, + priority="normal", + session_id=session_id, + character_id=session.solo_character_id + ) + + # Increment rate limit counter + rate_limiter.increment_usage(user_id) + + logger.info("Action queued for processing", + session_id=session_id, + job_id=result.get('job_id'), + prompt_id=prompt_id) + + return accepted_response({ + "job_id": result.get('job_id'), + "status": result.get('status', 'queued'), + "message": "Your action is being processed..." + }) + + except SessionNotFound as e: + logger.warning("Session not found for action", + session_id=session_id, + error=str(e)) + return not_found_response("Session not found") + + except Exception as e: + logger.error("Failed to process action", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status=500, + code="ACTION_PROCESS_ERROR", + message="Failed to process action" + ) + + +@sessions_bp.route('/api/v1/sessions/', methods=['GET']) +@require_auth +def get_session_state(session_id: str): + """ + Get current session state with available actions. + + Returns: + 200: Session state + 401: Not authenticated + 404: Session not found + 500: Internal server error + """ + logger.info("Getting session state", session_id=session_id) + + try: + # Get current user + user = get_current_user() + user_id = user.id + user_tier = get_user_tier_from_user(user) + + # Get session + session_service = get_session_service() + session = session_service.get_session(session_id, user_id) + + # Get available actions based on location and tier + loader = ActionPromptLoader() + location_type = session.game_state.location_type + + available_actions = [] + for action in loader.get_available_actions(user_tier, location_type): + available_actions.append({ + "prompt_id": action.prompt_id, + "display_text": action.display_text, + "description": action.description, + "category": action.category.value + }) + + logger.debug("Session state retrieved", + session_id=session_id, + turn_number=session.turn_number) + + return success_response({ + "session_id": session.session_id, + "character_id": session.get_character_id(), + "turn_number": session.turn_number, + "status": session.status.value, + "game_state": { + "current_location": session.game_state.current_location, + "location_type": session.game_state.location_type.value, + "active_quests": session.game_state.active_quests + }, + "available_actions": available_actions + }) + + except SessionNotFound as e: + logger.warning("Session not found", + session_id=session_id, + error=str(e)) + return not_found_response("Session not found") + + except Exception as e: + logger.error("Failed to get session state", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status=500, + code="SESSION_STATE_ERROR", + message="Failed to get session state" + ) + + +@sessions_bp.route('/api/v1/sessions//history', methods=['GET']) +@require_auth +def get_history(session_id: str): + """ + Get conversation history for a session. + + Query Parameters: + limit: Number of entries to return (default 20) + offset: Number of entries to skip (default 0) + + Returns: + 200: Paginated conversation history + 401: Not authenticated + 404: Session not found + 500: Internal server error + """ + logger.info("Getting conversation history", session_id=session_id) + + try: + # Get current user + user = get_current_user() + user_id = user.id + + # Get pagination params + limit = request.args.get('limit', 20, type=int) + offset = request.args.get('offset', 0, type=int) + + # Clamp values + limit = max(1, min(limit, 100)) # 1-100 + offset = max(0, offset) + + # Verify session ownership + session_service = get_session_service() + session = session_service.get_session(session_id, user_id) + + # Get total history + total_history = session.conversation_history + total_turns = len(total_history) + + # Apply pagination (from beginning) + paginated_history = total_history[offset:offset + limit] + + # Format history entries + history_data = [] + for entry in paginated_history: + # Handle timestamp - could be datetime object or already a string + timestamp = None + if hasattr(entry, 'timestamp') and entry.timestamp: + if isinstance(entry.timestamp, str): + timestamp = entry.timestamp + else: + timestamp = entry.timestamp.isoformat() + + history_data.append({ + "turn": entry.turn, + "action": entry.action, + "dm_response": entry.dm_response, + "timestamp": timestamp + }) + + logger.debug("Conversation history retrieved", + session_id=session_id, + total=total_turns, + returned=len(history_data)) + + return success_response({ + "total_turns": total_turns, + "history": history_data, + "pagination": { + "limit": limit, + "offset": offset, + "has_more": (offset + limit) < total_turns + } + }) + + except SessionNotFound as e: + logger.warning("Session not found for history", + session_id=session_id, + error=str(e)) + return not_found_response("Session not found") + + except Exception as e: + logger.error("Failed to get conversation history", + session_id=session_id, + error=str(e), + exc_info=True) + return error_response( + status=500, + code="HISTORY_ERROR", + message="Failed to get conversation history" + ) diff --git a/api/app/api/travel.py b/api/app/api/travel.py new file mode 100644 index 0000000..eff3106 --- /dev/null +++ b/api/app/api/travel.py @@ -0,0 +1,306 @@ +""" +Travel API Blueprint + +This module provides API endpoints for location-based travel: +- Get available destinations +- Travel to a location +- Get current location details + +All endpoints require authentication and enforce ownership validation. +""" + +from flask import Blueprint, request + +from app.services.session_service import get_session_service, SessionNotFound +from app.services.character_service import get_character_service, CharacterNotFound +from app.services.location_loader import get_location_loader +from app.services.npc_loader import get_npc_loader +from app.utils.response import ( + success_response, + error_response, + not_found_response, + validation_error_response +) +from app.utils.auth import require_auth, get_current_user +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +travel_bp = Blueprint('travel', __name__) + + +@travel_bp.route('/api/v1/travel/available', methods=['GET']) +@require_auth +def get_available_destinations(): + """ + Get all locations the character can travel to. + + Query params: + session_id: Active session ID + + Returns: + JSON response with list of available destinations + """ + try: + user = get_current_user() + session_id = request.args.get('session_id') + + if not session_id: + return validation_error_response("session_id query parameter is required") + + # Get session and verify ownership + session_service = get_session_service() + session = session_service.get_session(session_id, user.id) + + # Get character for discovered locations + character_service = get_character_service() + character = character_service.get_character(session.solo_character_id, user.id) + + # Load location details for each discovered location + location_loader = get_location_loader() + destinations = [] + + for loc_id in character.discovered_locations: + # Skip current location + if loc_id == session.game_state.current_location: + continue + + location = location_loader.load_location(loc_id) + if location: + destinations.append({ + "location_id": location.location_id, + "name": location.name, + "location_type": location.location_type.value, + "region_id": location.region_id, + "description": location.description[:200] + "..." if len(location.description) > 200 else location.description, + }) + + logger.info("Retrieved available destinations", + user_id=user.id, + session_id=session_id, + destination_count=len(destinations)) + + return success_response({ + "current_location": session.game_state.current_location, + "destinations": destinations + }) + + except SessionNotFound: + return not_found_response("Session not found") + except CharacterNotFound: + return not_found_response("Character not found") + except Exception as e: + logger.error("Failed to get available destinations", + error=str(e)) + return error_response("Failed to get destinations", 500) + + +@travel_bp.route('/api/v1/travel', methods=['POST']) +@require_auth +def travel_to_location(): + """ + Travel to a discovered location. + + Request body: + session_id: Active session ID + location_id: Target location ID + + Returns: + JSON response with new location details and NPCs present + """ + try: + user = get_current_user() + data = request.get_json() + + session_id = data.get('session_id') + location_id = data.get('location_id') + + # Validate required fields + if not session_id: + return validation_error_response("session_id is required") + if not location_id: + return validation_error_response("location_id is required") + + # Get session and verify ownership + session_service = get_session_service() + session = session_service.get_session(session_id, user.id) + + # Get character and verify location is discovered + character_service = get_character_service() + character = character_service.get_character(session.solo_character_id, user.id) + + if location_id not in character.discovered_locations: + logger.warning("Attempted travel to undiscovered location", + user_id=user.id, + character_id=character.character_id, + location_id=location_id) + return error_response("Location not discovered", 403) + + # Load location details + location_loader = get_location_loader() + location = location_loader.load_location(location_id) + + if not location: + logger.error("Location not found in data files", + location_id=location_id) + return not_found_response("Location not found") + + # Update session with new location + session = session_service.update_location( + session_id, + location_id, + location.location_type + ) + + # Get NPCs at new location + npc_loader = get_npc_loader() + npcs = npc_loader.get_npcs_at_location(location_id) + + # Build NPC summary list + npcs_present = [] + for npc in npcs: + npcs_present.append({ + "npc_id": npc.npc_id, + "name": npc.name, + "role": npc.role, + "appearance": npc.appearance.brief, + }) + + logger.info("Character traveled to location", + user_id=user.id, + session_id=session_id, + location_id=location_id) + + return success_response({ + "location": location.to_dict(), + "npcs_present": npcs_present, + "game_state": session.game_state.to_dict(), + }) + + except SessionNotFound: + return not_found_response("Session not found") + except CharacterNotFound: + return not_found_response("Character not found") + except Exception as e: + logger.error("Failed to travel to location", + error=str(e)) + return error_response("Failed to travel", 500) + + +@travel_bp.route('/api/v1/travel/location/', methods=['GET']) +@require_auth +def get_location_details(location_id: str): + """ + Get details about a specific location. + + Path params: + location_id: Location ID to get details for + + Query params: + session_id: Active session ID (optional, for context) + + Returns: + JSON response with location details and NPCs + """ + try: + user = get_current_user() + + # Load location + location_loader = get_location_loader() + location = location_loader.load_location(location_id) + + if not location: + return not_found_response("Location not found") + + # Get NPCs at location + npc_loader = get_npc_loader() + npcs = npc_loader.get_npcs_at_location(location_id) + + npcs_present = [] + for npc in npcs: + npcs_present.append({ + "npc_id": npc.npc_id, + "name": npc.name, + "role": npc.role, + "appearance": npc.appearance.brief, + }) + + return success_response({ + "location": location.to_dict(), + "npcs_present": npcs_present, + }) + + except Exception as e: + logger.error("Failed to get location details", + location_id=location_id, + error=str(e)) + return error_response("Failed to get location", 500) + + +@travel_bp.route('/api/v1/travel/current', methods=['GET']) +@require_auth +def get_current_location(): + """ + Get details about the current location in a session. + + Query params: + session_id: Active session ID + + Returns: + JSON response with current location details and NPCs + """ + try: + user = get_current_user() + session_id = request.args.get('session_id') + + if not session_id: + return validation_error_response("session_id query parameter is required") + + # Get session + session_service = get_session_service() + session = session_service.get_session(session_id, user.id) + + current_location_id = session.game_state.current_location + + # Load location + location_loader = get_location_loader() + location = location_loader.load_location(current_location_id) + + if not location: + # Location not in data files - return basic info from session + return success_response({ + "location": { + "location_id": current_location_id, + "name": current_location_id, + "location_type": session.game_state.location_type.value, + }, + "npcs_present": [], + }) + + # Get NPCs at location + npc_loader = get_npc_loader() + npcs = npc_loader.get_npcs_at_location(current_location_id) + + npcs_present = [] + for npc in npcs: + npcs_present.append({ + "npc_id": npc.npc_id, + "name": npc.name, + "role": npc.role, + "appearance": npc.appearance.brief, + }) + + return success_response({ + "location": location.to_dict(), + "npcs_present": npcs_present, + }) + + except SessionNotFound: + return not_found_response("Session not found") + except Exception as e: + logger.error("Failed to get current location", + error=str(e)) + return error_response("Failed to get current location", 500) diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..19cd411 --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,319 @@ +""" +Configuration loader for Code of Conquest. + +Loads configuration from YAML files and environment variables, +providing typed access to all configuration values. +""" + +import os +from dataclasses import dataclass, field +from typing import Dict, List, Optional +import yaml +from dotenv import load_dotenv + + +@dataclass +class AppConfig: + """Application configuration.""" + name: str + version: str + environment: str + debug: bool + + +@dataclass +class ServerConfig: + """Server configuration.""" + host: str + port: int + workers: int + + +@dataclass +class RedisConfig: + """Redis configuration.""" + host: str + port: int + db: int + max_connections: int + + @property + def url(self) -> str: + """Generate Redis URL.""" + return f"redis://{self.host}:{self.port}/{self.db}" + + +@dataclass +class RQConfig: + """RQ (Redis Queue) configuration.""" + queues: List[str] + worker_timeout: int + job_timeout: int + + +@dataclass +class AIModelConfig: + """AI model configuration.""" + provider: str + model: str + max_tokens: int + temperature: float + + +@dataclass +class AIConfig: + """AI service configuration.""" + timeout: int + max_retries: int + cost_alert_threshold: float + models: Dict[str, AIModelConfig] = field(default_factory=dict) + + +@dataclass +class RateLimitTier: + """Rate limit configuration for a subscription tier.""" + requests_per_minute: int + ai_calls_per_day: int + custom_actions_per_day: int # -1 for unlimited + custom_action_char_limit: int + + +@dataclass +class RateLimitingConfig: + """Rate limiting configuration.""" + enabled: bool + storage_url: str + tiers: Dict[str, RateLimitTier] = field(default_factory=dict) + + +@dataclass +class AuthConfig: + """Authentication configuration.""" + cookie_name: str + duration_normal: int + duration_remember_me: int + http_only: bool + secure: bool + same_site: str + path: str + password_min_length: int + password_require_uppercase: bool + password_require_lowercase: bool + password_require_number: bool + password_require_special: bool + name_min_length: int + name_max_length: int + email_max_length: int + + +@dataclass +class SessionConfig: + """Game session configuration.""" + timeout_minutes: int + auto_save_interval: int + min_players: int + max_players_by_tier: Dict[str, int] = field(default_factory=dict) + + +@dataclass +class MarketplaceConfig: + """Marketplace configuration.""" + auction_check_interval: int + max_listings_by_tier: Dict[str, int] = field(default_factory=dict) + + +@dataclass +class CORSConfig: + """CORS configuration.""" + origins: List[str] + + +@dataclass +class LoggingConfig: + """Logging configuration.""" + level: str + format: str + handlers: List[str] + file_path: str + + +@dataclass +class Config: + """ + Main configuration container. + + Loads configuration from YAML file based on environment, + with overrides from environment variables. + """ + app: AppConfig + server: ServerConfig + redis: RedisConfig + rq: RQConfig + ai: AIConfig + rate_limiting: RateLimitingConfig + auth: AuthConfig + session: SessionConfig + marketplace: MarketplaceConfig + cors: CORSConfig + logging: LoggingConfig + + # Environment variables (loaded from .env) + secret_key: str = "" + appwrite_endpoint: str = "" + appwrite_project_id: str = "" + appwrite_api_key: str = "" + appwrite_database_id: str = "" + anthropic_api_key: str = "" + replicate_api_token: str = "" + + @classmethod + def load(cls, environment: Optional[str] = None) -> 'Config': + """ + Load configuration from YAML file and environment variables. + + Args: + environment: Environment name (development, production, etc.). + If not provided, uses FLASK_ENV from environment. + + Returns: + Config: Loaded configuration object. + + Raises: + FileNotFoundError: If config file not found. + ValueError: If required environment variables missing. + """ + # Load environment variables from .env file + load_dotenv() + + # Determine environment + if environment is None: + environment = os.getenv('FLASK_ENV', 'development') + + # Load YAML configuration + config_path = os.path.join('config', f'{environment}.yaml') + + if not os.path.exists(config_path): + raise FileNotFoundError( + f"Configuration file not found: {config_path}" + ) + + with open(config_path, 'r') as f: + config_data = yaml.safe_load(f) + + # Parse configuration sections + app_config = AppConfig(**config_data['app']) + server_config = ServerConfig(**config_data['server']) + redis_config = RedisConfig(**config_data['redis']) + rq_config = RQConfig(**config_data['rq']) + + # Parse AI models + ai_models = {} + for tier, model_data in config_data['ai']['models'].items(): + ai_models[tier] = AIModelConfig(**model_data) + + ai_config = AIConfig( + timeout=config_data['ai']['timeout'], + max_retries=config_data['ai']['max_retries'], + cost_alert_threshold=config_data['ai']['cost_alert_threshold'], + models=ai_models + ) + + # Parse rate limiting tiers + rate_limit_tiers = {} + for tier, tier_data in config_data['rate_limiting']['tiers'].items(): + rate_limit_tiers[tier] = RateLimitTier(**tier_data) + + rate_limiting_config = RateLimitingConfig( + enabled=config_data['rate_limiting']['enabled'], + storage_url=config_data['rate_limiting']['storage_url'], + tiers=rate_limit_tiers + ) + + auth_config = AuthConfig(**config_data['auth']) + session_config = SessionConfig(**config_data['session']) + marketplace_config = MarketplaceConfig(**config_data['marketplace']) + cors_config = CORSConfig(**config_data['cors']) + logging_config = LoggingConfig(**config_data['logging']) + + # Load environment variables (secrets) + secret_key = os.getenv('SECRET_KEY') + if not secret_key: + raise ValueError("SECRET_KEY environment variable is required") + + appwrite_endpoint = os.getenv('APPWRITE_ENDPOINT', '') + appwrite_project_id = os.getenv('APPWRITE_PROJECT_ID', '') + appwrite_api_key = os.getenv('APPWRITE_API_KEY', '') + appwrite_database_id = os.getenv('APPWRITE_DATABASE_ID', 'main') + anthropic_api_key = os.getenv('ANTHROPIC_API_KEY', '') + replicate_api_token = os.getenv('REPLICATE_API_TOKEN', '') + + # Create and return config object + return cls( + app=app_config, + server=server_config, + redis=redis_config, + rq=rq_config, + ai=ai_config, + rate_limiting=rate_limiting_config, + auth=auth_config, + session=session_config, + marketplace=marketplace_config, + cors=cors_config, + logging=logging_config, + secret_key=secret_key, + appwrite_endpoint=appwrite_endpoint, + appwrite_project_id=appwrite_project_id, + appwrite_api_key=appwrite_api_key, + appwrite_database_id=appwrite_database_id, + anthropic_api_key=anthropic_api_key, + replicate_api_token=replicate_api_token + ) + + def validate(self) -> None: + """ + Validate configuration values. + + Raises: + ValueError: If configuration is invalid. + """ + # Validate AI API keys if needed + if self.app.environment == 'production': + if not self.anthropic_api_key: + raise ValueError( + "ANTHROPIC_API_KEY required in production environment" + ) + if not self.appwrite_endpoint or not self.appwrite_project_id: + raise ValueError( + "Appwrite configuration required in production environment" + ) + + # Validate logging level + valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] + if self.logging.level not in valid_log_levels: + raise ValueError( + f"Invalid log level: {self.logging.level}. " + f"Must be one of {valid_log_levels}" + ) + + +# Global config instance (loaded lazily) +_config: Optional[Config] = None + + +def get_config(environment: Optional[str] = None) -> Config: + """ + Get the global configuration instance. + + Args: + environment: Optional environment override. + + Returns: + Config: Configuration object. + """ + global _config + + if _config is None or environment is not None: + _config = Config.load(environment) + _config.validate() + + return _config diff --git a/api/app/data/abilities/README.md b/api/app/data/abilities/README.md new file mode 100644 index 0000000..1afdc8a --- /dev/null +++ b/api/app/data/abilities/README.md @@ -0,0 +1,141 @@ +# Ability Configuration Files + +This directory contains YAML configuration files that define all abilities in the game. + +## Format + +Each ability is defined in a separate `.yaml` file with the following structure: + +```yaml +ability_id: "unique_identifier" +name: "Display Name" +description: "What the ability does" +ability_type: "attack|spell|skill|item_use|defend" +base_power: 0 # Base damage or healing +damage_type: "physical|fire|ice|lightning|holy|shadow|poison" +scaling_stat: "strength|dexterity|constitution|intelligence|wisdom|charisma" +scaling_factor: 0.5 # Multiplier for scaling stat +mana_cost: 0 # MP required to use +cooldown: 0 # Turns before can be used again +is_aoe: false # Whether it affects multiple targets +target_count: 1 # Number of targets (0 = all enemies) +effects_applied: [] # List of effects to apply on hit +``` + +## Effect Format + +Effects applied by abilities use this structure: + +```yaml +effects_applied: + - effect_id: "unique_id" + name: "Effect Name" + effect_type: "buff|debuff|dot|hot|stun|shield" + duration: 3 # Turns before expiration + power: 5 # Damage/healing/modifier per turn + stat_affected: "strength" # For buffs/debuffs only (null otherwise) + stacks: 1 # Initial stack count + max_stacks: 5 # Maximum stacks allowed + source: "ability_id" # Which ability applied this +``` + +## Effect Types + +| Type | Power Usage | Example | +|------|-------------|---------| +| `buff` | Stat modifier (×stacks) | +5 strength per stack | +| `debuff` | Stat modifier (×stacks) | -3 defense per stack | +| `dot` | Damage per turn (×stacks) | 5 poison damage per turn | +| `hot` | Healing per turn (×stacks) | 8 HP regeneration per turn | +| `stun` | Not used | Prevents actions for duration | +| `shield` | Shield strength (×stacks) | 50 damage absorption | + +## Damage Calculation + +Abilities calculate their final power using this formula: + +``` +Final Power = base_power + (scaling_stat × scaling_factor) +Minimum power is always 1 +``` + +**Examples:** +- Fireball with 30 base_power, INT scaling 0.5, caster has 16 INT: + - 30 + (16 × 0.5) = 38 power +- Shield Bash with 10 base_power, STR scaling 0.5, caster has 20 STR: + - 10 + (20 × 0.5) = 20 power + +## Loading Abilities + +Abilities are loaded via the `AbilityLoader` class: + +```python +from app.models.abilities import AbilityLoader + +loader = AbilityLoader() +fireball = loader.load_ability("fireball") +power = fireball.calculate_power(caster_stats) +``` + +## Example Abilities + +### basic_attack.yaml +- Simple physical attack +- No mana cost or cooldown +- Available to all characters + +### fireball.yaml +- Offensive spell +- Deals fire damage + applies burning DoT +- Costs 15 MP, no cooldown + +### shield_bash.yaml +- Vanguard class skill +- Deals damage + stuns for 1 turn +- Costs 5 MP, 2 turn cooldown + +### heal.yaml +- Luminary class spell +- Restores health + applies regeneration HoT +- Costs 10 MP, no cooldown + +## Creating New Abilities + +1. Create a new `.yaml` file in this directory +2. Follow the format above +3. Set appropriate values for your ability +4. Ability will be automatically available via `AbilityLoader` +5. No code changes required! + +## Guidelines + +**Power Scaling:** +- Basic attacks: 5-10 base power +- Spells: 20-40 base power +- Skills: 10-25 base power +- Scaling factor typically 0.5 (50% of stat) + +**Mana Costs:** +- Basic attacks: 0 MP +- Low-tier spells: 5-10 MP +- Mid-tier spells: 15-20 MP +- High-tier spells: 25-30 MP +- Ultimate abilities: 40-50 MP + +**Cooldowns:** +- No cooldown (0): Most spells and basic attacks +- Short (1-2 turns): Common skills +- Medium (3-5 turns): Powerful skills +- Long (5-10 turns): Ultimate abilities + +**Effect Duration:** +- Instant effects (stun): 1 turn +- Short DoT/HoT: 2-3 turns +- Long DoT/HoT: 4-5 turns +- Buffs/debuffs: 2-4 turns + +**Effect Power:** +- Weak DoT: 3-5 damage per turn +- Medium DoT: 8-12 damage per turn +- Strong DoT: 15-20 damage per turn +- Stat modifiers: 3-10 points per stack diff --git a/api/app/data/abilities/basic_attack.yaml b/api/app/data/abilities/basic_attack.yaml new file mode 100644 index 0000000..d9b5894 --- /dev/null +++ b/api/app/data/abilities/basic_attack.yaml @@ -0,0 +1,16 @@ +# Basic Attack - Default melee attack +# Available to all characters, no mana cost, no cooldown + +ability_id: "basic_attack" +name: "Basic Attack" +description: "A standard melee attack with your equipped weapon" +ability_type: "attack" +base_power: 5 +damage_type: "physical" +scaling_stat: "strength" +scaling_factor: 0.5 +mana_cost: 0 +cooldown: 0 +is_aoe: false +target_count: 1 +effects_applied: [] diff --git a/api/app/data/abilities/fireball.yaml b/api/app/data/abilities/fireball.yaml new file mode 100644 index 0000000..592850d --- /dev/null +++ b/api/app/data/abilities/fireball.yaml @@ -0,0 +1,25 @@ +# Fireball - Offensive spell for Arcanist class +# Deals fire damage and applies burning DoT + +ability_id: "fireball" +name: "Fireball" +description: "Hurl a ball of fire at your enemies, dealing damage and burning them" +ability_type: "spell" +base_power: 30 +damage_type: "fire" +scaling_stat: "intelligence" +scaling_factor: 0.5 +mana_cost: 15 +cooldown: 0 +is_aoe: false +target_count: 1 +effects_applied: + - effect_id: "burn" + name: "Burning" + effect_type: "dot" + duration: 3 + power: 5 + stat_affected: null + stacks: 1 + max_stacks: 3 + source: "fireball" diff --git a/api/app/data/abilities/heal.yaml b/api/app/data/abilities/heal.yaml new file mode 100644 index 0000000..9b0c6b7 --- /dev/null +++ b/api/app/data/abilities/heal.yaml @@ -0,0 +1,26 @@ +# Heal - Luminary class ability +# Restores health to target ally + +ability_id: "heal" +name: "Heal" +description: "Channel divine energy to restore an ally's health" +ability_type: "spell" +base_power: 25 +damage_type: "holy" +scaling_stat: "intelligence" +scaling_factor: 0.5 +mana_cost: 10 +cooldown: 0 +is_aoe: false +target_count: 1 +effects_applied: + # Healing is represented as negative DOT (HOT) + - effect_id: "regeneration" + name: "Regeneration" + effect_type: "hot" + duration: 2 + power: 5 + stat_affected: null + stacks: 1 + max_stacks: 3 + source: "heal" diff --git a/api/app/data/abilities/shield_bash.yaml b/api/app/data/abilities/shield_bash.yaml new file mode 100644 index 0000000..f2775d2 --- /dev/null +++ b/api/app/data/abilities/shield_bash.yaml @@ -0,0 +1,25 @@ +# Shield Bash - Vanguard class ability +# Deals damage and stuns the target + +ability_id: "shield_bash" +name: "Shield Bash" +description: "Bash your enemy with your shield, dealing damage and stunning them briefly" +ability_type: "skill" +base_power: 10 +damage_type: "physical" +scaling_stat: "strength" +scaling_factor: 0.5 +mana_cost: 5 +cooldown: 2 +is_aoe: false +target_count: 1 +effects_applied: + - effect_id: "stun" + name: "Stunned" + effect_type: "stun" + duration: 1 + power: 0 + stat_affected: null + stacks: 1 + max_stacks: 1 + source: "shield_bash" diff --git a/api/app/data/action_prompts.yaml b/api/app/data/action_prompts.yaml new file mode 100644 index 0000000..f53b402 --- /dev/null +++ b/api/app/data/action_prompts.yaml @@ -0,0 +1,295 @@ +# Action Prompts Configuration +# +# Defines the predefined actions available to players during story progression. +# Actions are filtered by user tier and location type. +# +# Tier hierarchy: FREE < BASIC < PREMIUM < ELITE +# Location types: town, tavern, wilderness, dungeon, safe_area, library, any + +action_prompts: + # ============================================================================= + # FREE TIER ACTIONS (4) + # Available to all players + # ============================================================================= + + - prompt_id: ask_locals + category: ask_question + display_text: Ask locals for information + description: Talk to NPCs to learn about quests, rumors, and local lore + tier_required: free + context_filter: [town, tavern] + icon: chat + cooldown_turns: 0 + dm_prompt_template: | + The player approaches locals in {{ game_state.current_location }} and asks for information. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + + Generate realistic NPC dialogue where locals share: + - Local rumors or gossip + - Information about nearby points of interest + - Hints about potential quests or dangers + - Useful tips for adventurers + + The NPCs should have distinct personalities and speak naturally. Include 1-2 NPCs in the response. + End with a hook that encourages further exploration or action. + + - prompt_id: explore_area + category: explore + display_text: Explore the area + description: Search your surroundings for points of interest, hidden paths, or useful items + tier_required: free + context_filter: [wilderness, dungeon] + icon: compass + cooldown_turns: 0 + dm_prompt_template: | + The player explores the area around {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + Perception modifier: {{ character.stats.wisdom | default(10) }} + + Describe what the player discovers: + - Environmental details and atmosphere + - Points of interest (paths, structures, natural features) + - Any items, tracks, or clues found + - Potential dangers or opportunities + + Based on their Wisdom score, they may notice hidden details. + + IMPORTANT: Do NOT automatically move the player to a new location. + Present 2-3 options of what they can investigate or where they can go. + Ask: "What would you like to investigate?" or "Which path do you take?" + + - prompt_id: search_supplies + category: gather_info + display_text: Search for supplies + description: Look for useful items, herbs, or materials in the environment + tier_required: free + context_filter: [any] + icon: search + cooldown_turns: 2 + requires_check: + check_type: search + difficulty: medium + dm_prompt_template: | + The player searches for supplies in {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + + {% if check_outcome %} + DICE CHECK RESULT: {{ check_outcome.check_result.success | string | upper }} + - Roll: {{ check_outcome.check_result.roll }} + {{ check_outcome.check_result.modifier }} = {{ check_outcome.check_result.total }} vs DC {{ check_outcome.check_result.dc }} + {% if check_outcome.check_result.success %} + - Items found: {% for item in check_outcome.items_found %}{{ item.name }}{% if not loop.last %}, {% endif %}{% endfor %} + - Gold found: {{ check_outcome.gold_found }} + + The player SUCCEEDED in their search. Narrate how they found these specific items. + The items will be automatically added to their inventory - describe the discovery. + {% else %} + The player FAILED their search. Narrate the unsuccessful search attempt. + They find nothing of value this time. Describe what they checked but came up empty. + {% endif %} + {% else %} + Describe what supply sources or items they find based on location. + {% endif %} + + Keep the narration immersive and match the location type. + + - prompt_id: rest_recover + category: rest + display_text: Rest and recover + description: Take a short rest to recover health and stamina in a safe location + tier_required: free + context_filter: [town, tavern, safe_area] + icon: bed + cooldown_turns: 3 + dm_prompt_template: | + The player wants to rest in {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + Current HP: {{ character.current_hp }}/{{ character.max_hp }} + + For PAID rest (taverns/inns): + - Describe the establishment and available rooms WITH PRICES + - Ask which option they want before resting + - DO NOT automatically spend their gold + + For FREE rest (safe areas, campsites): + - Describe finding a suitable spot + - Describe the rest atmosphere and any ambient details + - Dreams, thoughts, or reflections the character has + + After they choose to rest: + - The player recovers some health and feels refreshed + - End with them ready to continue their adventure + + # ============================================================================= + # PREMIUM TIER ACTIONS (+3) + # Available to Premium and Elite subscribers + # ============================================================================= + + - prompt_id: investigate_suspicious + category: gather_info + display_text: Investigate suspicious activity + description: Look deeper into something that seems out of place or dangerous + tier_required: premium + context_filter: [any] + icon: magnifying_glass + cooldown_turns: 0 + dm_prompt_template: | + The player investigates suspicious activity in {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + Intelligence: {{ character.stats.intelligence | default(10) }} + + Based on the location and recent events, describe: + - What draws their attention + - Clues or evidence they discover + - Connections to larger mysteries or threats + - Potential leads to follow + + Higher Intelligence reveals more detailed observations. + This should advance the story or reveal hidden plot elements. + End with a clear lead or decision point. + + - prompt_id: follow_lead + category: travel + display_text: Follow a lead + description: Pursue information or tracks that could lead to your goal + tier_required: premium + context_filter: [any] + icon: footprints + cooldown_turns: 0 + dm_prompt_template: | + The player follows a lead from {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + + Based on recent discoveries or conversations, describe: + - What lead they're following (information, tracks, rumors) + - The journey or investigation process + - What they find at the end of the trail + - New information or locations discovered + + This should move the story forward significantly. + May lead to new areas, NPCs, or quest opportunities. + End with a meaningful discovery or encounter. + + - prompt_id: make_camp + category: rest + display_text: Make camp + description: Set up a campsite in the wilderness for rest and preparation + tier_required: premium + context_filter: [wilderness] + icon: campfire + cooldown_turns: 5 + dm_prompt_template: | + The player sets up camp in {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + Survival skill: {{ character.stats.wisdom | default(10) }} + + Describe the camping experience: + - Finding a suitable spot and setting up + - Building a fire, preparing food + - The night watch and any nocturnal events + - Dreams or visions during sleep + + Higher Wisdom means better campsite selection and awareness. + May include random encounters (friendly travelers, animals, or threats). + Player recovers health and is ready for the next day. + + # ============================================================================= + # ELITE TIER ACTIONS (+3) + # Available only to Elite subscribers + # ============================================================================= + + - prompt_id: consult_texts + category: special + display_text: Consult ancient texts + description: Study rare manuscripts and tomes for hidden knowledge and lore + tier_required: elite + context_filter: [library, town] + icon: book + cooldown_turns: 3 + dm_prompt_template: | + The player consults ancient texts in {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + Intelligence: {{ character.stats.intelligence | default(10) }} + + Describe the research session: + - The library or collection they access + - Specific tomes or scrolls they study + - Ancient knowledge they uncover + - Connections to current quests or mysteries + + Higher Intelligence allows deeper understanding. + May reveal: + - Monster weaknesses or strategies + - Hidden location details + - Historical context for current events + - Magical item properties or crafting recipes + + End with actionable knowledge that helps their quest. + + - prompt_id: commune_nature + category: special + display_text: Commune with nature + description: Attune to the natural world to gain insights and guidance + tier_required: elite + context_filter: [wilderness] + icon: leaf + cooldown_turns: 4 + dm_prompt_template: | + The player communes with nature in {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + Wisdom: {{ character.stats.wisdom | default(10) }} + + Describe the mystical experience: + - The ritual or meditation performed + - Visions, sounds, or sensations received + - Messages from the natural world + - Animal messengers or nature spirits encountered + + Higher Wisdom provides clearer visions. + May reveal: + - Danger ahead or safe paths + - Weather changes or natural disasters + - Animal behavior patterns + - Locations of rare herbs or resources + - Environmental quest hints + + End with prophetic or practical guidance. + + - prompt_id: seek_audience + category: special + display_text: Seek audience with authorities + description: Request a meeting with local leaders, nobles, or officials + tier_required: elite + context_filter: [town] + icon: crown + cooldown_turns: 5 + dm_prompt_template: | + The player seeks an audience with authorities in {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + Charisma: {{ character.stats.charisma | default(10) }} + Reputation: {{ character.reputation | default('unknown') }} + + Describe the audience: + - The authority figure (mayor, lord, guild master, etc.) + - The setting and formality of the meeting + - The conversation and requests made + - The authority's response and any tasks given + + Higher Charisma and reputation improve reception. + May result in: + - Official quests with better rewards + - Access to restricted areas + - Political information or alliances + - Resources or equipment grants + - Letters of introduction + + End with a clear outcome and next steps. diff --git a/api/app/data/classes/arcanist.yaml b/api/app/data/classes/arcanist.yaml new file mode 100644 index 0000000..d52c1b1 --- /dev/null +++ b/api/app/data/classes/arcanist.yaml @@ -0,0 +1,264 @@ +# Arcanist - Magic Burst +# Flexible hybrid class: Choose Pyromancy (fire AoE) or Cryomancy (ice control) + +class_id: arcanist +name: Arcanist +description: > + A master of elemental magic who bends the forces of fire and ice to their will. Arcanists + excel in devastating spell damage, capable of incinerating groups of foes or freezing + enemies in place. Choose your element: embrace the flames or command the frost. + +# Base stats (total: 65) +base_stats: + strength: 8 # Low physical power + dexterity: 10 # Average agility + constitution: 9 # Below average endurance + intelligence: 15 # Exceptional magical power + wisdom: 12 # Above average perception + charisma: 11 # Above average social + +starting_equipment: + - worn_staff + - cloth_armor + - rusty_knife + +starting_abilities: + - basic_attack + +skill_trees: + # ==================== PYROMANCY (Fire AoE) ==================== + - tree_id: pyromancy + name: Pyromancy + description: > + The path of flame. Master destructive fire magic to incinerate your enemies + with overwhelming area damage and burning DoTs. + + nodes: + # --- TIER 1 --- + - skill_id: fireball + name: Fireball + description: Hurl a ball of flame at an enemy, dealing fire damage and igniting them. + tier: 1 + prerequisites: [] + effects: + abilities: + - fireball + + - skill_id: flame_attunement + name: Flame Attunement + description: Your affinity with fire magic increases your magical power. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + intelligence: 5 + + # --- TIER 2 --- + - skill_id: flame_burst + name: Flame Burst + description: Release a burst of fire around you, damaging all nearby enemies. + tier: 2 + prerequisites: + - fireball + effects: + abilities: + - flame_burst + + - skill_id: burning_soul + name: Burning Soul + description: Your inner fire burns brighter, increasing fire damage. + tier: 2 + prerequisites: + - flame_attunement + effects: + stat_bonuses: + intelligence: 5 + combat_bonuses: + fire_damage_bonus: 0.15 # +15% fire damage + + # --- TIER 3 --- + - skill_id: inferno + name: Inferno + description: Summon a raging inferno that burns all enemies for 3 turns. + tier: 3 + prerequisites: + - flame_burst + effects: + abilities: + - inferno + + - skill_id: combustion + name: Combustion + description: Your fire spells can cause targets to explode on death, damaging nearby enemies. + tier: 3 + prerequisites: + - burning_soul + effects: + passive_effects: + - burning_enemies_explode_on_death + + # --- TIER 4 --- + - skill_id: firestorm + name: Firestorm + description: Call down a storm of meteors on all enemies, dealing massive fire damage. + tier: 4 + prerequisites: + - inferno + effects: + abilities: + - firestorm + + - skill_id: pyroclasm + name: Pyroclasm + description: Your mastery of flame makes all fire spells more devastating. + tier: 4 + prerequisites: + - combustion + effects: + stat_bonuses: + intelligence: 10 + combat_bonuses: + fire_damage_bonus: 0.25 # Additional +25% fire damage + + # --- TIER 5 (Ultimate) --- + - skill_id: sun_burst + name: Sun Burst + description: Channel the power of the sun itself, dealing catastrophic fire damage to all enemies. + tier: 5 + prerequisites: + - firestorm + effects: + abilities: + - sun_burst + + - skill_id: master_of_flame + name: Master of Flame + description: You are flame incarnate. Incredible fire magic bonuses. + tier: 5 + prerequisites: + - pyroclasm + effects: + stat_bonuses: + intelligence: 20 + combat_bonuses: + fire_damage_bonus: 0.50 # Additional +50% fire damage + + # ==================== CRYOMANCY (Ice Control) ==================== + - tree_id: cryomancy + name: Cryomancy + description: > + The path of frost. Master ice magic to freeze and slow enemies, + controlling the battlefield with chilling precision. + + nodes: + # --- TIER 1 --- + - skill_id: ice_shard + name: Ice Shard + description: Launch a shard of ice at an enemy, dealing damage and slowing them. + tier: 1 + prerequisites: [] + effects: + abilities: + - ice_shard + + - skill_id: frost_attunement + name: Frost Attunement + description: Your affinity with ice magic increases your magical power. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + intelligence: 5 + + # --- TIER 2 --- + - skill_id: frozen_orb + name: Frozen Orb + description: Summon an orb of ice that explodes, freezing enemies in place for 1 turn. + tier: 2 + prerequisites: + - ice_shard + effects: + abilities: + - frozen_orb + + - skill_id: cold_embrace + name: Cold Embrace + description: The cold empowers you, increasing ice damage and mana. + tier: 2 + prerequisites: + - frost_attunement + effects: + stat_bonuses: + intelligence: 5 + combat_bonuses: + ice_damage_bonus: 0.15 # +15% ice damage + + # --- TIER 3 --- + - skill_id: blizzard + name: Blizzard + description: Summon a raging blizzard that damages and slows all enemies. + tier: 3 + prerequisites: + - frozen_orb + effects: + abilities: + - blizzard + + - skill_id: permafrost + name: Permafrost + description: Your ice magic becomes more potent, with longer freeze durations. + tier: 3 + prerequisites: + - cold_embrace + effects: + stat_bonuses: + wisdom: 5 + combat_bonuses: + freeze_duration_bonus: 1 # +1 turn to freeze effects + + # --- TIER 4 --- + - skill_id: glacial_spike + name: Glacial Spike + description: Impale an enemy with a massive ice spike, dealing heavy damage and freezing them. + tier: 4 + prerequisites: + - blizzard + effects: + abilities: + - glacial_spike + + - skill_id: ice_mastery + name: Ice Mastery + description: Your command of ice magic reaches new heights. + tier: 4 + prerequisites: + - permafrost + effects: + stat_bonuses: + intelligence: 10 + combat_bonuses: + ice_damage_bonus: 0.25 # Additional +25% ice damage + + # --- TIER 5 (Ultimate) --- + - skill_id: absolute_zero + name: Absolute Zero + description: Freeze all enemies solid for 2 turns while dealing massive damage over time. + tier: 5 + prerequisites: + - glacial_spike + effects: + abilities: + - absolute_zero + + - skill_id: winter_incarnate + name: Winter Incarnate + description: You become the embodiment of winter itself. Incredible ice magic bonuses. + tier: 5 + prerequisites: + - ice_mastery + effects: + stat_bonuses: + intelligence: 20 + wisdom: 10 + combat_bonuses: + ice_damage_bonus: 0.50 # Additional +50% ice damage diff --git a/api/app/data/classes/assassin.yaml b/api/app/data/classes/assassin.yaml new file mode 100644 index 0000000..d2da51f --- /dev/null +++ b/api/app/data/classes/assassin.yaml @@ -0,0 +1,265 @@ +# Assassin - Critical/Stealth +# Flexible hybrid class: Choose Shadow Dancer (stealth/evasion) or Blade Specialist (critical damage) + +class_id: assassin +name: Assassin +description: > + A deadly operative who strikes from the shadows. Assassins excel in precise, devastating attacks, + capable of becoming an elusive phantom or a master of critical strikes. Choose your path: embrace + the shadows or perfect the killing blow. + +# Base stats (total: 65) +base_stats: + strength: 11 # Above average physical power + dexterity: 15 # Exceptional agility + constitution: 10 # Average endurance + intelligence: 9 # Below average magic + wisdom: 10 # Average perception + charisma: 10 # Average social + +starting_equipment: + - rusty_dagger + - cloth_armor + - rusty_knife + +starting_abilities: + - basic_attack + +skill_trees: + # ==================== SHADOW DANCER (Stealth/Evasion) ==================== + - tree_id: shadow_dancer + name: Shadow Dancer + description: > + The path of the phantom. Master stealth and evasion to become untouchable, + striking from darkness and vanishing before retaliation. + + nodes: + # --- TIER 1 --- + - skill_id: shadowstep + name: Shadowstep + description: Teleport behind an enemy and strike, dealing bonus damage from behind. + tier: 1 + prerequisites: [] + effects: + abilities: + - shadowstep + + - skill_id: nimble + name: Nimble + description: Your natural agility is enhanced through training. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + dexterity: 5 + + # --- TIER 2 --- + - skill_id: smoke_bomb + name: Smoke Bomb + description: Throw a smoke bomb, becoming untargetable for 1 turn and gaining evasion bonus. + tier: 2 + prerequisites: + - shadowstep + effects: + abilities: + - smoke_bomb + + - skill_id: evasion_training + name: Evasion Training + description: Learn to anticipate and dodge incoming attacks. + tier: 2 + prerequisites: + - nimble + effects: + combat_bonuses: + evasion_chance: 0.15 # +15% chance to evade attacks + + # --- TIER 3 --- + - skill_id: vanish + name: Vanish + description: Disappear from the battlefield for 2 turns, removing all threat and repositioning. + tier: 3 + prerequisites: + - smoke_bomb + effects: + abilities: + - vanish + + - skill_id: shadow_form + name: Shadow Form + description: Your body becomes harder to hit, permanently increasing evasion. + tier: 3 + prerequisites: + - evasion_training + effects: + combat_bonuses: + evasion_chance: 0.10 # Additional +10% evasion + stat_bonuses: + dexterity: 5 + + # --- TIER 4 --- + - skill_id: death_mark + name: Death Mark + description: Mark an enemy from stealth. Your next attack on them deals 200% damage. + tier: 4 + prerequisites: + - vanish + effects: + abilities: + - death_mark + + - skill_id: untouchable + name: Untouchable + description: Your mastery of evasion makes you extremely difficult to hit. + tier: 4 + prerequisites: + - shadow_form + effects: + combat_bonuses: + evasion_chance: 0.15 # Additional +15% evasion + stat_bonuses: + dexterity: 10 + + # --- TIER 5 (Ultimate) --- + - skill_id: shadow_assault + name: Shadow Assault + description: Strike all enemies in rapid succession from the shadows, guaranteed critical hits. + tier: 5 + prerequisites: + - death_mark + effects: + abilities: + - shadow_assault + + - skill_id: ghost + name: Ghost + description: Become one with the shadows. Massive evasion and dexterity bonuses. + tier: 5 + prerequisites: + - untouchable + effects: + combat_bonuses: + evasion_chance: 0.20 # Additional +20% evasion (total can reach ~60%) + stat_bonuses: + dexterity: 15 + + # ==================== BLADE SPECIALIST (Critical Damage) ==================== + - tree_id: blade_specialist + name: Blade Specialist + description: > + The path of precision. Master the art of the killing blow to deliver devastating + critical strikes that end fights in seconds. + + nodes: + # --- TIER 1 --- + - skill_id: precise_strike + name: Precise Strike + description: A carefully aimed attack with increased critical hit chance. + tier: 1 + prerequisites: [] + effects: + abilities: + - precise_strike + + - skill_id: keen_edge + name: Keen Edge + description: Sharpen your weapons to a razor edge, increasing critical chance. + tier: 1 + prerequisites: [] + effects: + combat_bonuses: + crit_chance: 0.10 # +10% base crit + + # --- TIER 2 --- + - skill_id: vital_strike + name: Vital Strike + description: Target vital points to deal massive critical damage. + tier: 2 + prerequisites: + - precise_strike + effects: + abilities: + - vital_strike + + - skill_id: deadly_precision + name: Deadly Precision + description: Your strikes become even more lethal. + tier: 2 + prerequisites: + - keen_edge + effects: + combat_bonuses: + crit_chance: 0.10 # Additional +10% crit + crit_multiplier: 0.3 # +0.3 to crit multiplier + + # --- TIER 3 --- + - skill_id: hemorrhage + name: Hemorrhage + description: Critical hits cause bleeding for 3 turns, dealing heavy damage over time. + tier: 3 + prerequisites: + - vital_strike + effects: + passive_effects: + - crit_applies_bleed + + - skill_id: surgical_strikes + name: Surgical Strikes + description: Every attack is a calculated execution. + tier: 3 + prerequisites: + - deadly_precision + effects: + combat_bonuses: + crit_chance: 0.15 # Additional +15% crit + stat_bonuses: + dexterity: 5 + + # --- TIER 4 --- + - skill_id: coup_de_grace + name: Coup de Grace + description: Execute targets below 25% HP instantly with a guaranteed critical. + tier: 4 + prerequisites: + - hemorrhage + effects: + abilities: + - coup_de_grace + + - skill_id: master_assassin + name: Master Assassin + description: Your expertise with blades reaches perfection. + tier: 4 + prerequisites: + - surgical_strikes + effects: + combat_bonuses: + crit_chance: 0.10 # Additional +10% crit + crit_multiplier: 0.5 # +0.5 to crit multiplier + stat_bonuses: + strength: 5 + + # --- TIER 5 (Ultimate) --- + - skill_id: thousand_cuts + name: Thousand Cuts + description: Unleash a flurry of blade strikes on a single target, each hit has 50% crit chance. + tier: 5 + prerequisites: + - coup_de_grace + effects: + abilities: + - thousand_cuts + + - skill_id: perfect_assassination + name: Perfect Assassination + description: Your mastery of the blade is unmatched. Incredible critical bonuses. + tier: 5 + prerequisites: + - master_assassin + effects: + combat_bonuses: + crit_chance: 0.20 # Additional +20% crit (total can reach ~75%) + crit_multiplier: 1.0 # +1.0 to crit multiplier + stat_bonuses: + dexterity: 10 + strength: 10 diff --git a/api/app/data/classes/lorekeeper.yaml b/api/app/data/classes/lorekeeper.yaml new file mode 100644 index 0000000..2e4c01a --- /dev/null +++ b/api/app/data/classes/lorekeeper.yaml @@ -0,0 +1,273 @@ +# Lorekeeper - Support/Control +# Flexible hybrid class: Choose Arcane Weaving (buffs/debuffs) or Illusionist (crowd control) + +class_id: lorekeeper +name: Lorekeeper +description: > + A master of arcane knowledge who manipulates reality through words and illusions. Lorekeepers + excel in supporting allies and controlling enemies through clever magic and mental manipulation. + Choose your art: weave arcane power or bend reality itself. + +# Base stats (total: 67) +base_stats: + strength: 8 # Low physical power + dexterity: 11 # Above average agility + constitution: 10 # Average endurance + intelligence: 13 # Above average magical power + wisdom: 11 # Above average perception + charisma: 14 # High social/performance + +starting_equipment: + - tome + - cloth_armor + - rusty_knife + +starting_abilities: + - basic_attack + +skill_trees: + # ==================== ARCANE WEAVING (Buffs/Debuffs) ==================== + - tree_id: arcane_weaving + name: Arcane Weaving + description: > + The path of the arcane weaver. Master supportive magic to enhance allies, + weaken enemies, and turn the tide of battle through clever enchantments. + + nodes: + # --- TIER 1 --- + - skill_id: arcane_brilliance + name: Arcane Brilliance + description: Grant an ally increased intelligence and magical power for 5 turns. + tier: 1 + prerequisites: [] + effects: + abilities: + - arcane_brilliance + + - skill_id: scholarly_mind + name: Scholarly Mind + description: Your extensive study enhances your magical knowledge. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + intelligence: 5 + + # --- TIER 2 --- + - skill_id: haste + name: Haste + description: Speed up an ally, granting them an extra action this turn. + tier: 2 + prerequisites: + - arcane_brilliance + effects: + abilities: + - haste + + - skill_id: arcane_mastery + name: Arcane Mastery + description: Your mastery of arcane arts increases all buff effectiveness. + tier: 2 + prerequisites: + - scholarly_mind + effects: + stat_bonuses: + intelligence: 5 + charisma: 3 + combat_bonuses: + buff_power: 0.20 # +20% buff effectiveness + + # --- TIER 3 --- + - skill_id: mass_enhancement + name: Mass Enhancement + description: Enhance all allies at once, increasing their stats for 5 turns. + tier: 3 + prerequisites: + - haste + effects: + abilities: + - mass_enhancement + + - skill_id: arcane_weakness + name: Arcane Weakness + description: Curse an enemy with weakness, reducing their stats and damage. + tier: 3 + prerequisites: + - arcane_mastery + effects: + abilities: + - arcane_weakness + + # --- TIER 4 --- + - skill_id: time_warp + name: Time Warp + description: Manipulate time itself, granting all allies bonus actions. + tier: 4 + prerequisites: + - mass_enhancement + effects: + abilities: + - time_warp + + - skill_id: master_weaver + name: Master Weaver + description: Your weaving expertise makes all enchantments far more potent. + tier: 4 + prerequisites: + - arcane_weakness + effects: + stat_bonuses: + intelligence: 15 + charisma: 10 + combat_bonuses: + buff_power: 0.35 # Additional +35% buff effectiveness + debuff_power: 0.35 # +35% debuff effectiveness + + # --- TIER 5 (Ultimate) --- + - skill_id: reality_shift + name: Reality Shift + description: Shift reality to massively empower all allies and weaken all enemies. + tier: 5 + prerequisites: + - time_warp + effects: + abilities: + - reality_shift + + - skill_id: archmage + name: Archmage + description: Achieve the rank of archmage. Incredible support magic bonuses. + tier: 5 + prerequisites: + - master_weaver + effects: + stat_bonuses: + intelligence: 25 + charisma: 20 + wisdom: 10 + combat_bonuses: + buff_power: 0.75 # Additional +75% buff effectiveness + debuff_power: 0.75 # Additional +75% debuff effectiveness + + # ==================== ILLUSIONIST (Crowd Control) ==================== + - tree_id: illusionist + name: Illusionist + description: > + The path of deception. Master illusion magic to confuse, disorient, and control + the minds of your enemies, rendering them helpless. + + nodes: + # --- TIER 1 --- + - skill_id: confuse + name: Confuse + description: Confuse an enemy's mind, causing them to attack randomly for 2 turns. + tier: 1 + prerequisites: [] + effects: + abilities: + - confuse + + - skill_id: silver_tongue + name: Silver Tongue + description: Your persuasive abilities make mind magic more effective. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + charisma: 5 + + # --- TIER 2 --- + - skill_id: mesmerize + name: Mesmerize + description: Mesmerize an enemy, stunning them for 2 turns. + tier: 2 + prerequisites: + - confuse + effects: + abilities: + - mesmerize + + - skill_id: mental_fortress + name: Mental Fortress + description: Fortify your mind and those of your allies against mental attacks. + tier: 2 + prerequisites: + - silver_tongue + effects: + stat_bonuses: + wisdom: 5 + combat_bonuses: + mental_resistance: 0.25 # +25% resistance to mind effects + + # --- TIER 3 --- + - skill_id: mass_confusion + name: Mass Confusion + description: Confuse all enemies, causing chaos on the battlefield. + tier: 3 + prerequisites: + - mesmerize + effects: + abilities: + - mass_confusion + + - skill_id: mirror_image + name: Mirror Image + description: Create illusory copies of yourself that absorb attacks. + tier: 3 + prerequisites: + - mental_fortress + effects: + abilities: + - mirror_image + + # --- TIER 4 --- + - skill_id: phantasmal_killer + name: Phantasmal Killer + description: Create a terrifying illusion that deals massive psychic damage and fears enemies. + tier: 4 + prerequisites: + - mass_confusion + effects: + abilities: + - phantasmal_killer + + - skill_id: master_illusionist + name: Master Illusionist + description: Your illusions become nearly indistinguishable from reality. + tier: 4 + prerequisites: + - mirror_image + effects: + stat_bonuses: + charisma: 15 + intelligence: 10 + combat_bonuses: + illusion_duration: 2 # +2 turns to illusion effects + cc_effectiveness: 0.35 # +35% crowd control effectiveness + + # --- TIER 5 (Ultimate) --- + - skill_id: mass_domination + name: Mass Domination + description: Dominate the minds of all enemies, forcing them to fight for you briefly. + tier: 5 + prerequisites: + - phantasmal_killer + effects: + abilities: + - mass_domination + + - skill_id: grand_illusionist + name: Grand Illusionist + description: Become a grand illusionist. Reality bends to your will. + tier: 5 + prerequisites: + - master_illusionist + effects: + stat_bonuses: + charisma: 30 + intelligence: 15 + wisdom: 10 + combat_bonuses: + illusion_duration: 5 # Additional +5 turns to illusions + cc_effectiveness: 0.75 # Additional +75% crowd control effectiveness + mental_damage_bonus: 1.0 # +100% psychic damage diff --git a/api/app/data/classes/luminary.yaml b/api/app/data/classes/luminary.yaml new file mode 100644 index 0000000..eaffec2 --- /dev/null +++ b/api/app/data/classes/luminary.yaml @@ -0,0 +1,266 @@ +# Luminary - Holy Healer/DPS +# Flexible hybrid class: Choose Divine Protection (healing/shields) or Radiant Judgment (holy damage) + +class_id: luminary +name: Luminary +description: > + A blessed warrior who channels divine power. Luminaries excel in healing and protection, + capable of becoming a guardian angel for their allies or a righteous crusader smiting evil. + Choose your calling: protect the innocent or judge the wicked. + +# Base stats (total: 68) +base_stats: + strength: 9 # Below average physical power + dexterity: 9 # Below average agility + constitution: 11 # Above average endurance + intelligence: 12 # Above average magical power + wisdom: 14 # High perception/divine power + charisma: 13 # Above average social + +starting_equipment: + - rusty_mace + - cloth_armor + - rusty_knife + +starting_abilities: + - basic_attack + +skill_trees: + # ==================== DIVINE PROTECTION (Healing/Shields) ==================== + - tree_id: divine_protection + name: Divine Protection + description: > + The path of the guardian. Channel divine energy to heal wounds, shield allies, + and protect the vulnerable from harm. + + nodes: + # --- TIER 1 --- + - skill_id: heal + name: Heal + description: Channel divine energy to restore an ally's health. + tier: 1 + prerequisites: [] + effects: + abilities: + - heal + + - skill_id: divine_grace + name: Divine Grace + description: Your connection to the divine enhances your wisdom and healing power. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + wisdom: 5 + + # --- TIER 2 --- + - skill_id: holy_shield + name: Holy Shield + description: Grant an ally a protective barrier that absorbs damage. + tier: 2 + prerequisites: + - heal + effects: + abilities: + - holy_shield + + - skill_id: blessed_aura + name: Blessed Aura + description: Emit an aura that passively regenerates nearby allies' health each turn. + tier: 2 + prerequisites: + - divine_grace + effects: + passive_effects: + - aura_healing # 5% max HP per turn to allies + + # --- TIER 3 --- + - skill_id: mass_heal + name: Mass Heal + description: Channel divine power to heal all allies at once. + tier: 3 + prerequisites: + - holy_shield + effects: + abilities: + - mass_heal + + - skill_id: guardian_angel + name: Guardian Angel + description: Place a protective blessing on an ally that prevents their next death. + tier: 3 + prerequisites: + - blessed_aura + effects: + abilities: + - guardian_angel + + # --- TIER 4 --- + - skill_id: divine_intervention + name: Divine Intervention + description: Call upon divine power to fully heal an ally and remove all debuffs. + tier: 4 + prerequisites: + - mass_heal + effects: + abilities: + - divine_intervention + + - skill_id: sanctified + name: Sanctified + description: Your divine power reaches new heights, improving all healing. + tier: 4 + prerequisites: + - guardian_angel + effects: + stat_bonuses: + wisdom: 10 + combat_bonuses: + healing_power: 0.25 # +25% healing + + # --- TIER 5 (Ultimate) --- + - skill_id: resurrection + name: Resurrection + description: Bring a fallen ally back to life with 50% health and mana. + tier: 5 + prerequisites: + - divine_intervention + effects: + abilities: + - resurrection + + - skill_id: beacon_of_hope + name: Beacon of Hope + description: You radiate divine energy. Massive wisdom and healing bonuses. + tier: 5 + prerequisites: + - sanctified + effects: + stat_bonuses: + wisdom: 20 + charisma: 10 + combat_bonuses: + healing_power: 0.50 # Additional +50% healing + + # ==================== RADIANT JUDGMENT (Holy Damage) ==================== + - tree_id: radiant_judgment + name: Radiant Judgment + description: > + The path of the crusader. Wield holy power as a weapon, smiting the wicked + with radiant damage and divine wrath. + + nodes: + # --- TIER 1 --- + - skill_id: smite + name: Smite + description: Strike an enemy with holy power, dealing radiant damage. + tier: 1 + prerequisites: [] + effects: + abilities: + - smite + + - skill_id: righteous_fury + name: Righteous Fury + description: Your righteous anger fuels your holy power. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + wisdom: 5 + + # --- TIER 2 --- + - skill_id: holy_fire + name: Holy Fire + description: Burn an enemy with holy flames, dealing damage and reducing their healing received. + tier: 2 + prerequisites: + - smite + effects: + abilities: + - holy_fire + + - skill_id: zealot + name: Zealot + description: Your devotion to righteousness increases your damage against evil. + tier: 2 + prerequisites: + - righteous_fury + effects: + stat_bonuses: + wisdom: 5 + strength: 3 + combat_bonuses: + holy_damage_bonus: 0.15 # +15% holy damage + + # --- TIER 3 --- + - skill_id: consecration + name: Consecration + description: Consecrate the ground, dealing holy damage to enemies standing in it each turn. + tier: 3 + prerequisites: + - holy_fire + effects: + abilities: + - consecration + + - skill_id: divine_wrath + name: Divine Wrath + description: Channel pure divine fury into your attacks. + tier: 3 + prerequisites: + - zealot + effects: + stat_bonuses: + wisdom: 10 + combat_bonuses: + holy_damage_bonus: 0.20 # Additional +20% holy damage + + # --- TIER 4 --- + - skill_id: hammer_of_justice + name: Hammer of Justice + description: Summon a massive holy hammer to crush your foes, stunning and damaging them. + tier: 4 + prerequisites: + - consecration + effects: + abilities: + - hammer_of_justice + + - skill_id: crusader + name: Crusader + description: You become a true crusader, dealing devastating holy damage. + tier: 4 + prerequisites: + - divine_wrath + effects: + stat_bonuses: + wisdom: 10 + strength: 5 + combat_bonuses: + holy_damage_bonus: 0.25 # Additional +25% holy damage + + # --- TIER 5 (Ultimate) --- + - skill_id: divine_storm + name: Divine Storm + description: Unleash a catastrophic storm of holy energy, damaging and stunning all enemies. + tier: 5 + prerequisites: + - hammer_of_justice + effects: + abilities: + - divine_storm + + - skill_id: avatar_of_light + name: Avatar of Light + description: Become an avatar of divine light itself. Incredible holy damage bonuses. + tier: 5 + prerequisites: + - crusader + effects: + stat_bonuses: + wisdom: 20 + strength: 10 + charisma: 10 + combat_bonuses: + holy_damage_bonus: 0.50 # Additional +50% holy damage diff --git a/api/app/data/classes/necromancer.yaml b/api/app/data/classes/necromancer.yaml new file mode 100644 index 0000000..ca70795 --- /dev/null +++ b/api/app/data/classes/necromancer.yaml @@ -0,0 +1,275 @@ +# Necromancer - DoT/Summoner +# Flexible hybrid class: Choose Dark Affliction (DoTs/debuffs) or Raise Dead (summon undead) + +class_id: necromancer +name: Necromancer +description: > + A master of death magic who manipulates life force and commands the undead. Necromancers + excel in draining enemies over time or overwhelming foes with undead minions. + Choose your dark art: curse your enemies or raise an army of the dead. + +# Base stats (total: 65) +base_stats: + strength: 8 # Low physical power + dexterity: 10 # Average agility + constitution: 10 # Average endurance + intelligence: 14 # High magical power + wisdom: 11 # Above average perception + charisma: 12 # Above average social (commands undead) + +starting_equipment: + - bone_wand + - cloth_armor + - rusty_knife + +starting_abilities: + - basic_attack + +skill_trees: + # ==================== DARK AFFLICTION (DoTs/Debuffs) ==================== + - tree_id: dark_affliction + name: Dark Affliction + description: > + The path of the curseweaver. Master dark magic to drain life, inflict agonizing + curses, and watch enemies wither away over time. + + nodes: + # --- TIER 1 --- + - skill_id: drain_life + name: Drain Life + description: Siphon life force from an enemy, damaging them and healing yourself. + tier: 1 + prerequisites: [] + effects: + abilities: + - drain_life + + - skill_id: dark_knowledge + name: Dark Knowledge + description: Study of forbidden arts enhances your dark magic power. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + intelligence: 5 + + # --- TIER 2 --- + - skill_id: plague + name: Plague + description: Infect an enemy with disease that spreads to nearby foes, dealing damage over time. + tier: 2 + prerequisites: + - drain_life + effects: + abilities: + - plague + + - skill_id: soul_harvest + name: Soul Harvest + description: Absorb the life essence of dying enemies, increasing your power. + tier: 2 + prerequisites: + - dark_knowledge + effects: + stat_bonuses: + intelligence: 5 + combat_bonuses: + lifesteal: 0.15 # Heal for 15% of damage dealt + + # --- TIER 3 --- + - skill_id: curse_of_agony + name: Curse of Agony + description: Curse an enemy with excruciating pain, dealing heavy damage over 5 turns. + tier: 3 + prerequisites: + - plague + effects: + abilities: + - curse_of_agony + + - skill_id: dark_empowerment + name: Dark Empowerment + description: Channel dark energy to enhance all damage over time effects. + tier: 3 + prerequisites: + - soul_harvest + effects: + stat_bonuses: + intelligence: 10 + combat_bonuses: + dot_damage_bonus: 0.30 # +30% DoT damage + + # --- TIER 4 --- + - skill_id: soul_rot + name: Soul Rot + description: Rot an enemy's soul, dealing massive damage over time and reducing their healing. + tier: 4 + prerequisites: + - curse_of_agony + effects: + abilities: + - soul_rot + + - skill_id: death_mastery + name: Death Mastery + description: Master the art of death magic, dramatically increasing curse potency. + tier: 4 + prerequisites: + - dark_empowerment + effects: + stat_bonuses: + intelligence: 15 + combat_bonuses: + dot_damage_bonus: 0.40 # Additional +40% DoT damage + lifesteal: 0.15 # Additional +15% lifesteal + + # --- TIER 5 (Ultimate) --- + - skill_id: epidemic + name: Epidemic + description: Unleash a deadly epidemic that afflicts all enemies with multiple DoTs. + tier: 5 + prerequisites: + - soul_rot + effects: + abilities: + - epidemic + + - skill_id: lord_of_decay + name: Lord of Decay + description: Become a lord of death and decay. Incredible DoT and drain bonuses. + tier: 5 + prerequisites: + - death_mastery + effects: + stat_bonuses: + intelligence: 25 + wisdom: 15 + combat_bonuses: + dot_damage_bonus: 1.0 # Additional +100% DoT damage + lifesteal: 0.30 # Additional +30% lifesteal + + # ==================== RAISE DEAD (Summon Undead) ==================== + - tree_id: raise_dead + name: Raise Dead + description: > + The path of the necromancer. Command armies of the undead, raising fallen + enemies and empowering your minions with dark magic. + + nodes: + # --- TIER 1 --- + - skill_id: summon_skeleton + name: Summon Skeleton + description: Raise a skeleton warrior from the ground to fight for you. + tier: 1 + prerequisites: [] + effects: + abilities: + - summon_skeleton + + - skill_id: dark_command + name: Dark Command + description: Your mastery over the undead makes your minions stronger. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + charisma: 5 + combat_bonuses: + minion_damage_bonus: 0.15 # +15% minion damage + + # --- TIER 2 --- + - skill_id: raise_ghoul + name: Raise Ghoul + description: Summon a ravenous ghoul that deals heavy melee damage. + tier: 2 + prerequisites: + - summon_skeleton + effects: + abilities: + - raise_ghoul + + - skill_id: unholy_bond + name: Unholy Bond + description: Strengthen your connection to the undead, empowering your minions. + tier: 2 + prerequisites: + - dark_command + effects: + stat_bonuses: + charisma: 5 + combat_bonuses: + minion_damage_bonus: 0.20 # Additional +20% minion damage + minion_health_bonus: 0.25 # +25% minion HP + + # --- TIER 3 --- + - skill_id: corpse_explosion + name: Corpse Explosion + description: Detonate a corpse or minion, dealing massive AoE damage. + tier: 3 + prerequisites: + - raise_ghoul + effects: + abilities: + - corpse_explosion + + - skill_id: death_pact + name: Death Pact + description: Sacrifice a minion to restore your health and mana. + tier: 3 + prerequisites: + - unholy_bond + effects: + abilities: + - death_pact + + # --- TIER 4 --- + - skill_id: summon_abomination + name: Summon Abomination + description: Raise a massive undead abomination that dominates the battlefield. + tier: 4 + prerequisites: + - corpse_explosion + effects: + abilities: + - summon_abomination + + - skill_id: legion_master + name: Legion Master + description: Command larger armies of undead with increased effectiveness. + tier: 4 + prerequisites: + - death_pact + effects: + stat_bonuses: + charisma: 15 + intelligence: 5 + combat_bonuses: + minion_damage_bonus: 0.35 # Additional +35% minion damage + minion_health_bonus: 0.50 # Additional +50% minion HP + max_minions: 2 # +2 max minions + + # --- TIER 5 (Ultimate) --- + - skill_id: army_of_the_dead + name: Army of the Dead + description: Summon a massive army of undead warriors to overwhelm your enemies. + tier: 5 + prerequisites: + - summon_abomination + effects: + abilities: + - army_of_the_dead + + - skill_id: lich_lord + name: Lich Lord + description: Transcend mortality to become a lich lord. Incredible minion bonuses. + tier: 5 + prerequisites: + - legion_master + effects: + stat_bonuses: + charisma: 25 + intelligence: 20 + combat_bonuses: + minion_damage_bonus: 1.0 # Additional +100% minion damage + minion_health_bonus: 1.0 # Additional +100% minion HP + max_minions: 3 # Additional +3 max minions diff --git a/api/app/data/classes/oathkeeper.yaml b/api/app/data/classes/oathkeeper.yaml new file mode 100644 index 0000000..fd70bd6 --- /dev/null +++ b/api/app/data/classes/oathkeeper.yaml @@ -0,0 +1,265 @@ +# Oathkeeper - Hybrid Tank/Healer +# Flexible hybrid class: Choose Aegis of Light (protection/tanking) or Redemption (healing/support) + +class_id: oathkeeper +name: Oathkeeper +description: > + A sacred warrior bound by holy oaths. Oathkeepers excel as versatile protectors, + capable of becoming an unyielding shield for their allies or a beacon of healing light. + Choose your oath: defend the weak or redeem the fallen. + +# Base stats (total: 67) +base_stats: + strength: 12 # Above average physical power + dexterity: 9 # Below average agility + constitution: 13 # High endurance + intelligence: 10 # Average magic + wisdom: 12 # Above average perception + charisma: 11 # Above average social + +starting_equipment: + - rusty_sword + - rusty_shield + - cloth_armor + - rusty_knife + +starting_abilities: + - basic_attack + +skill_trees: + # ==================== AEGIS OF LIGHT (Protection/Tanking) ==================== + - tree_id: aegis_of_light + name: Aegis of Light + description: > + The path of the protector. Become an unyielding guardian who shields allies + from harm and draws enemy attention through divine resilience. + + nodes: + # --- TIER 1 --- + - skill_id: taunt + name: Taunt + description: Challenge enemies to attack you instead of your allies. + tier: 1 + prerequisites: [] + effects: + abilities: + - taunt + + - skill_id: blessed_armor + name: Blessed Armor + description: Divine power enhances your natural toughness. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + constitution: 5 + + # --- TIER 2 --- + - skill_id: shield_of_faith + name: Shield of Faith + description: Conjure a holy shield that absorbs damage for you and nearby allies. + tier: 2 + prerequisites: + - taunt + effects: + abilities: + - shield_of_faith + + - skill_id: sacred_resilience + name: Sacred Resilience + description: Your oath grants you resistance to harm. + tier: 2 + prerequisites: + - blessed_armor + effects: + stat_bonuses: + constitution: 5 + resistance: 5 + + # --- TIER 3 --- + - skill_id: consecrated_ground + name: Consecrated Ground + description: Bless the ground beneath you, providing damage reduction to allies standing in it. + tier: 3 + prerequisites: + - shield_of_faith + effects: + abilities: + - consecrated_ground + + - skill_id: unbreakable_oath + name: Unbreakable Oath + description: Your oath makes you incredibly difficult to bring down. + tier: 3 + prerequisites: + - sacred_resilience + effects: + stat_bonuses: + constitution: 10 + defense: 10 + + # --- TIER 4 --- + - skill_id: divine_aegis + name: Divine Aegis + description: Summon a massive divine shield that protects all allies for 3 turns. + tier: 4 + prerequisites: + - consecrated_ground + effects: + abilities: + - divine_aegis + + - skill_id: indomitable + name: Indomitable + description: Nothing can break your will or body. Massive defensive bonuses. + tier: 4 + prerequisites: + - unbreakable_oath + effects: + stat_bonuses: + constitution: 15 + defense: 15 + combat_bonuses: + damage_reduction: 0.15 # Reduce all damage by 15% + + # --- TIER 5 (Ultimate) --- + - skill_id: last_stand + name: Last Stand + description: Become invulnerable for 3 turns while taunting all enemies. Cannot be canceled. + tier: 5 + prerequisites: + - divine_aegis + effects: + abilities: + - last_stand + + - skill_id: eternal_guardian + name: Eternal Guardian + description: You are an eternal bastion of protection. Incredible defensive bonuses. + tier: 5 + prerequisites: + - indomitable + effects: + stat_bonuses: + constitution: 25 + defense: 20 + resistance: 15 + combat_bonuses: + damage_reduction: 0.30 # Additional 30% damage reduction + + # ==================== REDEMPTION (Healing/Support) ==================== + - tree_id: redemption + name: Redemption + description: > + The path of the redeemer. Channel divine power to heal wounds, cleanse corruption, + and grant your allies second chances through sacred intervention. + + nodes: + # --- TIER 1 --- + - skill_id: lay_on_hands + name: Lay on Hands + description: Touch an ally to restore their health through divine power. + tier: 1 + prerequisites: [] + effects: + abilities: + - lay_on_hands + + - skill_id: divine_wisdom + name: Divine Wisdom + description: Your wisdom grows through devotion to your oath. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + wisdom: 5 + + # --- TIER 2 --- + - skill_id: cleanse + name: Cleanse + description: Remove all debuffs and negative effects from an ally. + tier: 2 + prerequisites: + - lay_on_hands + effects: + abilities: + - cleanse + + - skill_id: aura_of_mercy + name: Aura of Mercy + description: Emit a merciful aura that slowly heals all nearby allies each turn. + tier: 2 + prerequisites: + - divine_wisdom + effects: + passive_effects: + - healing_aura # 3% max HP per turn + + # --- TIER 3 --- + - skill_id: word_of_healing + name: Word of Healing + description: Speak a divine word that heals all allies within range. + tier: 3 + prerequisites: + - cleanse + effects: + abilities: + - word_of_healing + + - skill_id: blessed_sacrifice + name: Blessed Sacrifice + description: Transfer an ally's wounds to yourself, healing them while you take damage. + tier: 3 + prerequisites: + - aura_of_mercy + effects: + abilities: + - blessed_sacrifice + + # --- TIER 4 --- + - skill_id: divine_blessing + name: Divine Blessing + description: Grant an ally a powerful blessing that increases their stats and regenerates health. + tier: 4 + prerequisites: + - word_of_healing + effects: + abilities: + - divine_blessing + + - skill_id: martyr + name: Martyr + description: Your willingness to sacrifice yourself empowers your healing abilities. + tier: 4 + prerequisites: + - blessed_sacrifice + effects: + stat_bonuses: + wisdom: 15 + combat_bonuses: + healing_power: 0.35 # +35% healing + + # --- TIER 5 (Ultimate) --- + - skill_id: miracle + name: Miracle + description: Perform a divine miracle, fully healing all allies and removing all debuffs. + tier: 5 + prerequisites: + - divine_blessing + effects: + abilities: + - miracle + + - skill_id: sainthood + name: Sainthood + description: Achieve sainthood through your devotion. Incredible healing and support bonuses. + tier: 5 + prerequisites: + - martyr + effects: + stat_bonuses: + wisdom: 25 + charisma: 15 + combat_bonuses: + healing_power: 0.75 # Additional +75% healing + aura_radius: 2 # Increase aura range diff --git a/api/app/data/classes/vanguard.yaml b/api/app/data/classes/vanguard.yaml new file mode 100644 index 0000000..14f6a5d --- /dev/null +++ b/api/app/data/classes/vanguard.yaml @@ -0,0 +1,264 @@ +# Vanguard - Melee Tank/DPS +# Flexible hybrid class: Choose Shield Bearer (tank) or Weapon Master (DPS) + +class_id: vanguard +name: Vanguard +description: > + A seasoned warrior who stands at the front lines of battle. Vanguards excel in melee combat, + capable of becoming an unbreakable shield for their allies or a relentless damage dealer. + Choose your path: become a stalwart defender or a devastating weapon master. + +# Base stats (total: 65, average: 10.83) +base_stats: + strength: 14 # High physical power + dexterity: 10 # Average agility + constitution: 14 # High endurance for tanking + intelligence: 8 # Low magic + wisdom: 10 # Average perception + charisma: 9 # Below average social + +# Starting equipment (minimal) +starting_equipment: + - rusty_sword + - cloth_armor + - rusty_knife # Everyone gets pocket knife + +# Starting abilities +starting_abilities: + - basic_attack + +# Skill trees (mutually exclusive playstyles) +skill_trees: + # ==================== SHIELD BEARER (Tank) ==================== + - tree_id: shield_bearer + name: Shield Bearer + description: > + The path of the defender. Master the shield to become an impenetrable fortress, + protecting your allies and controlling the battlefield. + + nodes: + # --- TIER 1 --- + - skill_id: shield_bash + name: Shield Bash + description: Strike an enemy with your shield, dealing minor damage and stunning them for 1 turn. + tier: 1 + prerequisites: [] + effects: + abilities: + - shield_bash # References ability YAML + + - skill_id: fortify + name: Fortify + description: Your defensive training grants you enhanced protection. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + defense: 5 + + # --- TIER 2 --- + - skill_id: shield_wall + name: Shield Wall + description: Raise your shield to block incoming attacks, reducing damage by 50% for 3 turns. + tier: 2 + prerequisites: + - shield_bash + effects: + abilities: + - shield_wall + + - skill_id: iron_skin + name: Iron Skin + description: Your body becomes hardened through relentless training. + tier: 2 + prerequisites: + - fortify + effects: + stat_bonuses: + constitution: 5 + + # --- TIER 3 --- + - skill_id: guardians_resolve + name: Guardian's Resolve + description: Your unwavering determination makes you nearly impossible to break. + tier: 3 + prerequisites: + - shield_wall + effects: + stat_bonuses: + defense: 10 + passive_effects: + - stun_resistance # Immune to stun when shield wall active + + - skill_id: riposte + name: Riposte + description: After blocking an attack, counter with a swift strike. + tier: 3 + prerequisites: + - iron_skin + effects: + abilities: + - riposte + + # --- TIER 4 --- + - skill_id: bulwark + name: Bulwark + description: You are a living fortress, shrugging off blows that would fell lesser warriors. + tier: 4 + prerequisites: + - guardians_resolve + effects: + stat_bonuses: + constitution: 10 + resistance: 5 + + - skill_id: counter_strike + name: Counter Strike + description: Enhance your Riposte ability to deal critical damage when countering. + tier: 4 + prerequisites: + - riposte + effects: + ability_enhancements: + riposte: + crit_chance_bonus: 0.3 # +30% crit on riposte + + # --- TIER 5 (Ultimate) --- + - skill_id: unbreakable + name: Unbreakable + description: Channel your inner strength to become invulnerable, reducing all damage by 75% for 5 turns. + tier: 5 + prerequisites: + - bulwark + effects: + abilities: + - unbreakable # Ultimate defensive ability + + - skill_id: fortress + name: Fortress + description: Your defensive mastery reaches its peak. Permanently gain massive defensive bonuses. + tier: 5 + prerequisites: + - counter_strike + effects: + stat_bonuses: + defense: 20 + constitution: 10 + resistance: 10 + + # ==================== WEAPON MASTER (DPS) ==================== + - tree_id: weapon_master + name: Weapon Master + description: > + The path of destruction. Master devastating melee techniques to cut through enemies + with overwhelming physical power. + + nodes: + # --- TIER 1 --- + - skill_id: power_strike + name: Power Strike + description: A heavy attack that deals 150% weapon damage. + tier: 1 + prerequisites: [] + effects: + abilities: + - power_strike + + - skill_id: weapon_proficiency + name: Weapon Proficiency + description: Your training with weapons grants increased physical power. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + strength: 5 + + # --- TIER 2 --- + - skill_id: cleave + name: Cleave + description: Swing your weapon in a wide arc, hitting all enemies in front of you. + tier: 2 + prerequisites: + - power_strike + effects: + abilities: + - cleave # AoE attack + + - skill_id: battle_frenzy + name: Battle Frenzy + description: The heat of battle fuels your strength. + tier: 2 + prerequisites: + - weapon_proficiency + effects: + stat_bonuses: + strength: 5 + + # --- TIER 3 --- + - skill_id: rending_blow + name: Rending Blow + description: Strike with such force that your enemy bleeds for 3 turns. + tier: 3 + prerequisites: + - cleave + effects: + abilities: + - rending_blow # Applies bleed DoT + + - skill_id: brutal_force + name: Brutal Force + description: Your attacks become devastatingly powerful. + tier: 3 + prerequisites: + - battle_frenzy + effects: + stat_bonuses: + strength: 10 + + # --- TIER 4 --- + - skill_id: execute + name: Execute + description: Finish off weakened enemies. Deals bonus damage to targets below 30% HP. + tier: 4 + prerequisites: + - rending_blow + effects: + abilities: + - execute + + - skill_id: weapon_mastery + name: Weapon Mastery + description: Your expertise with weapons allows you to find weak points more easily. + tier: 4 + prerequisites: + - brutal_force + effects: + stat_bonuses: + strength: 5 + combat_bonuses: + crit_chance: 0.15 # +15% crit chance + + # --- TIER 5 (Ultimate) --- + - skill_id: titans_wrath + name: Titan's Wrath + description: Unleash a devastating attack that deals 300% weapon damage and stuns all enemies hit. + tier: 5 + prerequisites: + - execute + effects: + abilities: + - titans_wrath # Ultimate offensive ability + + - skill_id: perfect_form + name: Perfect Form + description: Your combat technique reaches perfection. Massive offensive bonuses. + tier: 5 + prerequisites: + - weapon_mastery + effects: + stat_bonuses: + strength: 20 + dexterity: 10 + combat_bonuses: + crit_chance: 0.1 # Additional +10% crit + crit_multiplier: 0.5 # +0.5 to crit multiplier diff --git a/api/app/data/classes/wildstrider.yaml b/api/app/data/classes/wildstrider.yaml new file mode 100644 index 0000000..f68bb22 --- /dev/null +++ b/api/app/data/classes/wildstrider.yaml @@ -0,0 +1,275 @@ +# Wildstrider - Ranged Physical +# Flexible hybrid class: Choose Marksmanship (precision ranged) or Beast Companion (pet damage) + +class_id: wildstrider +name: Wildstrider +description: > + A master of the wilds who excels at ranged combat and bonds with nature. Wildstriders + can become elite marksmen with unmatched accuracy or beast masters commanding powerful + animal companions. Choose your path: perfect your aim or unleash the wild. + +# Base stats (total: 66) +base_stats: + strength: 10 # Average physical power + dexterity: 14 # High agility + constitution: 11 # Above average endurance + intelligence: 9 # Below average magic + wisdom: 13 # Above average perception + charisma: 9 # Below average social + +starting_equipment: + - rusty_bow + - cloth_armor + - rusty_knife + +starting_abilities: + - basic_attack + +skill_trees: + # ==================== MARKSMANSHIP (Precision Ranged) ==================== + - tree_id: marksmanship + name: Marksmanship + description: > + The path of the sharpshooter. Master the bow to deliver devastating precision + strikes from afar, never missing your mark. + + nodes: + # --- TIER 1 --- + - skill_id: aimed_shot + name: Aimed Shot + description: Take careful aim before firing, dealing increased damage with high accuracy. + tier: 1 + prerequisites: [] + effects: + abilities: + - aimed_shot + + - skill_id: steady_hand + name: Steady Hand + description: Your ranged accuracy improves through training. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + dexterity: 5 + + # --- TIER 2 --- + - skill_id: multishot + name: Multishot + description: Fire multiple arrows at once, hitting up to 3 targets. + tier: 2 + prerequisites: + - aimed_shot + effects: + abilities: + - multishot + + - skill_id: eagle_eye + name: Eagle Eye + description: Your perception sharpens, increasing critical hit chance with ranged weapons. + tier: 2 + prerequisites: + - steady_hand + effects: + stat_bonuses: + wisdom: 5 + combat_bonuses: + ranged_crit_chance: 0.15 # +15% crit with ranged + + # --- TIER 3 --- + - skill_id: piercing_shot + name: Piercing Shot + description: Fire an arrow that pierces through enemies, hitting all in a line. + tier: 3 + prerequisites: + - multishot + effects: + abilities: + - piercing_shot + + - skill_id: deadly_aim + name: Deadly Aim + description: Your arrows find vital spots with deadly precision. + tier: 3 + prerequisites: + - eagle_eye + effects: + stat_bonuses: + dexterity: 10 + combat_bonuses: + ranged_crit_chance: 0.10 # Additional +10% crit + ranged_crit_multiplier: 0.5 # +0.5 to crit damage + + # --- TIER 4 --- + - skill_id: explosive_shot + name: Explosive Shot + description: Fire an explosive arrow that detonates on impact, damaging nearby enemies. + tier: 4 + prerequisites: + - piercing_shot + effects: + abilities: + - explosive_shot + + - skill_id: master_archer + name: Master Archer + description: Your archery skills reach legendary status. + tier: 4 + prerequisites: + - deadly_aim + effects: + stat_bonuses: + dexterity: 15 + combat_bonuses: + ranged_damage_bonus: 0.25 # +25% ranged damage + + # --- TIER 5 (Ultimate) --- + - skill_id: rain_of_arrows + name: Rain of Arrows + description: Fire countless arrows into the sky that rain down on all enemies. + tier: 5 + prerequisites: + - explosive_shot + effects: + abilities: + - rain_of_arrows + + - skill_id: true_shot + name: True Shot + description: Every arrow finds its mark. Massive ranged combat bonuses. + tier: 5 + prerequisites: + - master_archer + effects: + stat_bonuses: + dexterity: 20 + wisdom: 10 + combat_bonuses: + ranged_damage_bonus: 0.50 # Additional +50% ranged damage + ranged_crit_chance: 0.20 # Additional +20% crit + + # ==================== BEAST COMPANION (Pet Damage) ==================== + - tree_id: beast_companion + name: Beast Companion + description: > + The path of the beast master. Bond with a wild animal companion that fights + alongside you, growing stronger as your connection deepens. + + nodes: + # --- TIER 1 --- + - skill_id: summon_companion + name: Summon Companion + description: Call a loyal animal companion to fight by your side. + tier: 1 + prerequisites: [] + effects: + abilities: + - summon_companion + + - skill_id: animal_bond + name: Animal Bond + description: Your connection with nature strengthens your companion. + tier: 1 + prerequisites: [] + effects: + stat_bonuses: + wisdom: 5 + combat_bonuses: + pet_damage_bonus: 0.15 # +15% pet damage + + # --- TIER 2 --- + - skill_id: coordinated_attack + name: Coordinated Attack + description: Command your companion to attack with you for combined damage. + tier: 2 + prerequisites: + - summon_companion + effects: + abilities: + - coordinated_attack + + - skill_id: feral_instinct + name: Feral Instinct + description: Your companion becomes more ferocious and resilient. + tier: 2 + prerequisites: + - animal_bond + effects: + stat_bonuses: + wisdom: 5 + combat_bonuses: + pet_damage_bonus: 0.20 # Additional +20% pet damage + pet_health_bonus: 0.25 # +25% pet HP + + # --- TIER 3 --- + - skill_id: bestial_wrath + name: Bestial Wrath + description: Enrage your companion, drastically increasing its damage for 3 turns. + tier: 3 + prerequisites: + - coordinated_attack + effects: + abilities: + - bestial_wrath + + - skill_id: wild_empathy + name: Wild Empathy + description: Your bond with beasts allows your companion to grow stronger. + tier: 3 + prerequisites: + - feral_instinct + effects: + stat_bonuses: + wisdom: 10 + combat_bonuses: + pet_damage_bonus: 0.25 # Additional +25% pet damage + + # --- TIER 4 --- + - skill_id: primal_fury + name: Primal Fury + description: Your companion enters a primal rage, dealing massive damage to all enemies. + tier: 4 + prerequisites: + - bestial_wrath + effects: + abilities: + - primal_fury + + - skill_id: apex_predator + name: Apex Predator + description: Your companion becomes an apex predator, feared by all. + tier: 4 + prerequisites: + - wild_empathy + effects: + stat_bonuses: + wisdom: 10 + combat_bonuses: + pet_damage_bonus: 0.35 # Additional +35% pet damage + pet_health_bonus: 0.50 # Additional +50% pet HP + pet_crit_chance: 0.20 # +20% pet crit + + # --- TIER 5 (Ultimate) --- + - skill_id: stampede + name: Stampede + description: Summon a stampede of beasts that trample all enemies, dealing catastrophic damage. + tier: 5 + prerequisites: + - primal_fury + effects: + abilities: + - stampede + + - skill_id: one_with_the_wild + name: One with the Wild + description: You and your companion become one with nature. Incredible bonuses. + tier: 5 + prerequisites: + - apex_predator + effects: + stat_bonuses: + wisdom: 20 + dexterity: 10 + combat_bonuses: + pet_damage_bonus: 1.0 # Additional +100% pet damage (double damage!) + pet_health_bonus: 1.0 # Additional +100% pet HP diff --git a/api/app/data/generic_items.yaml b/api/app/data/generic_items.yaml new file mode 100644 index 0000000..27a7d60 --- /dev/null +++ b/api/app/data/generic_items.yaml @@ -0,0 +1,269 @@ +# Generic Item Templates +# These are common mundane items that the AI can give to players during gameplay. +# They serve as templates for AI-generated items, providing consistent values +# for simple items like torches, food, rope, etc. +# +# When the AI creates a generic item, the validator will look for a matching +# template to use as defaults. Items not matching a template will be created +# with the AI-provided values only. + +templates: + # Light sources + torch: + name: "Torch" + description: "A wooden torch that provides light in dark places." + value: 1 + is_tradeable: true + required_level: 1 + + lantern: + name: "Lantern" + description: "An oil lantern that provides steady light." + value: 10 + is_tradeable: true + required_level: 1 + + candle: + name: "Candle" + description: "A simple wax candle." + value: 1 + is_tradeable: true + required_level: 1 + + # Food and drink + bread: + name: "Bread" + description: "A loaf of bread, possibly stale but still edible." + value: 1 + is_tradeable: true + required_level: 1 + + apple: + name: "Apple" + description: "A fresh red apple." + value: 1 + is_tradeable: true + required_level: 1 + + cheese: + name: "Cheese" + description: "A wedge of aged cheese." + value: 2 + is_tradeable: true + required_level: 1 + + rations: + name: "Rations" + description: "A day's worth of preserved food." + value: 5 + is_tradeable: true + required_level: 1 + + water: + name: "Waterskin" + description: "A leather waterskin filled with clean water." + value: 2 + is_tradeable: true + required_level: 1 + + ale: + name: "Ale" + description: "A mug of common tavern ale." + value: 1 + is_tradeable: true + required_level: 1 + + wine: + name: "Wine" + description: "A bottle of wine." + value: 5 + is_tradeable: true + required_level: 1 + + # Tools and supplies + rope: + name: "Rope" + description: "A sturdy length of hempen rope, about 50 feet." + value: 5 + is_tradeable: true + required_level: 1 + + flint: + name: "Flint and Steel" + description: "A flint and steel for starting fires." + value: 3 + is_tradeable: true + required_level: 1 + + bedroll: + name: "Bedroll" + description: "A simple bedroll for sleeping outdoors." + value: 5 + is_tradeable: true + required_level: 1 + + backpack: + name: "Backpack" + description: "A sturdy canvas backpack." + value: 10 + is_tradeable: true + required_level: 1 + + crowbar: + name: "Crowbar" + description: "An iron crowbar for prying things open." + value: 8 + is_tradeable: true + required_level: 1 + + hammer: + name: "Hammer" + description: "A simple hammer." + value: 5 + is_tradeable: true + required_level: 1 + + pitons: + name: "Pitons" + description: "A set of iron pitons for climbing." + value: 5 + is_tradeable: true + required_level: 1 + + grappling_hook: + name: "Grappling Hook" + description: "A three-pronged iron grappling hook." + value: 10 + is_tradeable: true + required_level: 1 + + # Writing supplies + ink: + name: "Ink" + description: "A small vial of black ink." + value: 5 + is_tradeable: true + required_level: 1 + + parchment: + name: "Parchment" + description: "A sheet of parchment for writing." + value: 1 + is_tradeable: true + required_level: 1 + + quill: + name: "Quill" + description: "A feather quill for writing." + value: 1 + is_tradeable: true + required_level: 1 + + # Containers + pouch: + name: "Pouch" + description: "A small leather pouch." + value: 2 + is_tradeable: true + required_level: 1 + + sack: + name: "Sack" + description: "A burlap sack for carrying goods." + value: 1 + is_tradeable: true + required_level: 1 + + vial: + name: "Empty Vial" + description: "A small glass vial." + value: 1 + is_tradeable: true + required_level: 1 + + # Clothing + cloak: + name: "Cloak" + description: "A simple traveler's cloak." + value: 5 + is_tradeable: true + required_level: 1 + + boots: + name: "Boots" + description: "A sturdy pair of leather boots." + value: 8 + is_tradeable: true + required_level: 1 + + gloves: + name: "Gloves" + description: "A pair of leather gloves." + value: 3 + is_tradeable: true + required_level: 1 + + # Miscellaneous + mirror: + name: "Mirror" + description: "A small steel mirror." + value: 10 + is_tradeable: true + required_level: 1 + + bell: + name: "Bell" + description: "A small brass bell." + value: 2 + is_tradeable: true + required_level: 1 + + whistle: + name: "Whistle" + description: "A simple wooden whistle." + value: 1 + is_tradeable: true + required_level: 1 + + key: + name: "Key" + description: "A simple iron key." + value: 5 + is_tradeable: true + required_level: 1 + + map: + name: "Map" + description: "A rough map of the local area." + value: 15 + is_tradeable: true + required_level: 1 + + compass: + name: "Compass" + description: "A magnetic compass for navigation." + value: 20 + is_tradeable: true + required_level: 1 + + # Simple consumables + bandage: + name: "Bandage" + description: "A clean cloth bandage for basic wound care." + value: 2 + is_tradeable: true + required_level: 1 + + antidote: + name: "Antidote" + description: "A basic herbal remedy for common poisons." + value: 15 + is_tradeable: true + required_level: 1 + + herbs: + name: "Herbs" + description: "A bundle of useful herbs." + value: 3 + is_tradeable: true + required_level: 1 diff --git a/api/app/data/locations/crossville/crossville_crypt.yaml b/api/app/data/locations/crossville/crossville_crypt.yaml new file mode 100644 index 0000000..5af59b2 --- /dev/null +++ b/api/app/data/locations/crossville/crossville_crypt.yaml @@ -0,0 +1,46 @@ +# The Forgotten Crypt - Ancient burial site +location_id: crossville_crypt +name: The Forgotten Crypt +location_type: ruins +region_id: crossville + +description: | + Hidden beneath a collapsed stone circle deep in Thornwood Forest lies an + ancient crypt. The entrance, half-buried by centuries of accumulated earth + and roots, leads down into darkness. Faded carvings on the weathered stones + depict figures in robes performing unknown rituals around what appears to be + a great black sun. + +lore: | + Long before Crossville was founded, before even the elves came to these lands, + another civilization built monuments to their strange gods. The Forgotten Crypt + is one of their burial sites - a place where priest-kings were interred with + their servants and treasures. Local legends warn that the dead here do not + rest peacefully, and that disturbing their tombs invites a terrible curse. + +ambient_description: | + The air in the crypt is stale and cold, carrying the musty scent of ancient + decay. What little light enters through the broken ceiling reveals dust motes + floating in perfectly still air. Stone sarcophagi line the walls, their lids + carved with the faces of the long-dead. Some lids have been displaced, + revealing empty darkness within. The silence is absolute - even footsteps + seem muffled, as if the crypt itself absorbs sound. + +available_quests: + - quest_undead_menace + - quest_ancient_relic + - quest_necromancer_lair + +npc_ids: [] + +discoverable_locations: [] + +is_starting_location: false + +tags: + - ruins + - dangerous + - undead + - treasure + - mystery + - boss diff --git a/api/app/data/locations/crossville/crossville_dungeon.yaml b/api/app/data/locations/crossville/crossville_dungeon.yaml new file mode 100644 index 0000000..5739e50 --- /dev/null +++ b/api/app/data/locations/crossville/crossville_dungeon.yaml @@ -0,0 +1,47 @@ +# The Old Mines - Abandoned dungeon beneath the hills +location_id: crossville_dungeon +name: The Old Mines +location_type: dungeon +region_id: crossville + +description: | + A network of abandoned mine tunnels carved into the hills north of Crossville. + The mines were sealed decades ago after a cave-in killed a dozen workers, but + the entrance has recently been found open. Strange sounds echo from the depths, + and the few who have ventured inside speak of unnatural creatures lurking in + the darkness. + +lore: | + The mines were originally dug by dwarven prospectors seeking iron ore. They + found more than iron - ancient carvings deep in the tunnels suggest something + else was buried here long ago. The cave-in that sealed the mines was blamed + on unstable rock, but survivors whispered of something awakening in the deep. + The mine was sealed and the entrance forbidden, until recent earthquakes + reopened the way. + +ambient_description: | + The mine entrance yawns like a mouth in the hillside, exhaling cold air that + smells of wet stone and something older. Rotting timber supports the first + few feet of tunnel, beyond which darkness swallows everything. Somewhere + in the depths, water drips with metronomic regularity. Occasionally, other + sounds echo up from below - scraping, shuffling, or what might be whispered + voices. + +available_quests: + - quest_mine_exploration + - quest_lost_miners + - quest_ancient_artifact + +npc_ids: [] + +discoverable_locations: + - crossville_crypt + +is_starting_location: false + +tags: + - dungeon + - dangerous + - combat + - treasure + - mystery diff --git a/api/app/data/locations/crossville/crossville_forest.yaml b/api/app/data/locations/crossville/crossville_forest.yaml new file mode 100644 index 0000000..06e646e --- /dev/null +++ b/api/app/data/locations/crossville/crossville_forest.yaml @@ -0,0 +1,45 @@ +# Thornwood Forest - Wilderness area east of village +location_id: crossville_forest +name: Thornwood Forest +location_type: wilderness +region_id: crossville + +description: | + A dense woodland stretching east from Crossville, named for the thorny + undergrowth that makes travel off the main path treacherous. Ancient oaks + and twisted pines block much of the sunlight, creating an perpetual twilight + beneath the canopy. The eastern trade road cuts through here, though bandits + have made it increasingly dangerous. + +lore: | + The Thornwood is said to be as old as the mountains themselves. Local legend + speaks of an ancient elven settlement deep within the forest, though no one + has found it in living memory. What is certain is that the forest hides many + secrets - ancient ruins, hidden caves, and creatures that prefer darkness + to light. Hunters know to return before nightfall. + +ambient_description: | + Shafts of pale light filter through the canopy, illuminating swirling motes + of dust and pollen. The forest floor is carpeted with fallen leaves and + treacherous roots. Birds call from the branches above, falling silent + whenever something large moves through the underbrush. The air is thick + with the scent of damp earth and decaying vegetation. + +available_quests: + - quest_bandit_camp + - quest_herb_gathering + - quest_lost_traveler + +npc_ids: [] + +discoverable_locations: + - crossville_dungeon + - crossville_crypt + +is_starting_location: false + +tags: + - wilderness + - dangerous + - exploration + - hunting diff --git a/api/app/data/locations/crossville/crossville_tavern.yaml b/api/app/data/locations/crossville/crossville_tavern.yaml new file mode 100644 index 0000000..b8a7136 --- /dev/null +++ b/api/app/data/locations/crossville/crossville_tavern.yaml @@ -0,0 +1,47 @@ +# The Rusty Anchor Tavern - Social hub of Crossville +location_id: crossville_tavern +name: The Rusty Anchor Tavern +location_type: tavern +region_id: crossville + +description: | + A weathered two-story establishment at the heart of Crossville Village. + The wooden sign creaks in the wind, depicting a rusted ship's anchor - an + odd choice for a landlocked village. Inside, travelers and locals alike + gather around rough-hewn tables to share drinks, stories, and rumors. + +lore: | + Founded eighty years ago by a retired sailor named Captain Morgath, the + Rusty Anchor has served as Crossville's social hub for generations. + The captain's old anchor hangs above the fireplace, supposedly recovered + from a shipwreck that cost him his crew. Many adventurers have planned + expeditions over tankards of the house special - a dark ale brewed from + a secret recipe the captain brought from the coast. + +ambient_description: | + The tavern interior is warm and dimly lit by oil lamps and the glow of + the stone hearth. Pipe smoke hangs in lazy clouds above the regulars' + corner. The air smells of ale, roasted meat, and wood polish. A bard + occasionally plucks at a lute in the corner, though tonight the only + music is the murmur of conversation and the crackle of the fire. + +available_quests: + - quest_cellar_rats + - quest_missing_shipment + +npc_ids: + - npc_grom_ironbeard + - npc_mira_swiftfoot + +discoverable_locations: + - crossville_dungeon + - crossville_forest + +is_starting_location: false + +tags: + - social + - rest + - rumors + - merchant + - information diff --git a/api/app/data/locations/crossville/crossville_village.yaml b/api/app/data/locations/crossville/crossville_village.yaml new file mode 100644 index 0000000..22d40d3 --- /dev/null +++ b/api/app/data/locations/crossville/crossville_village.yaml @@ -0,0 +1,44 @@ +# Crossville Village - The main settlement +location_id: crossville_village +name: Crossville Village +location_type: town +region_id: crossville + +description: | + A modest farming village built around a central square where several roads + meet. Stone and timber buildings line the main street, with the mayor's + manor overlooking the square from a small hill. Farmers sell produce at + market stalls while merchants hawk wares from distant lands. + +lore: | + Founded two centuries ago by settlers from the eastern kingdoms, Crossville + grew from a simple waystation into a thriving village. The original stone + well in the center of the square is said to have been blessed by a traveling + cleric, and the village has never suffered drought since. + +ambient_description: | + The village square bustles with activity - farmers haggling over prices, + children running between market stalls, and the rhythmic clang of the + blacksmith's hammer echoing from his forge. The smell of fresh bread + drifts from the bakery, mixing with the earthier scents of livestock + and hay. + +available_quests: + - quest_mayors_request + - quest_missing_merchant + +npc_ids: + - npc_mayor_aldric + - npc_blacksmith_hilda + +discoverable_locations: + - crossville_tavern + - crossville_forest + +is_starting_location: true + +tags: + - town + - social + - merchant + - safe diff --git a/api/app/data/locations/regions/crossville.yaml b/api/app/data/locations/regions/crossville.yaml new file mode 100644 index 0000000..22b25c9 --- /dev/null +++ b/api/app/data/locations/regions/crossville.yaml @@ -0,0 +1,16 @@ +# Crossville Region - Starting area for new adventurers +region_id: crossville +name: Crossville Province +description: | + A quiet farming province on the frontier of the kingdom. Crossville sits at + the crossroads of several trade routes, making it a natural gathering point + for travelers, merchants, and those seeking adventure. The village has + prospered from this trade, though recent bandit activity has made the roads + less safe than they once were. + +location_ids: + - crossville_village + - crossville_tavern + - crossville_forest + - crossville_dungeon + - crossville_crypt diff --git a/api/app/data/loot_tables.yaml b/api/app/data/loot_tables.yaml new file mode 100644 index 0000000..0ede4fd --- /dev/null +++ b/api/app/data/loot_tables.yaml @@ -0,0 +1,281 @@ +# Loot Tables +# Defines what items can be found when searching in different locations. +# Items are referenced by their template key from generic_items.yaml. +# +# Rarity tiers determine selection based on check margin: +# - common: margin < 5 (just barely passed) +# - uncommon: margin 5-9 (solid success) +# - rare: margin >= 10 (excellent roll) +# +# Gold ranges are also determined by margin. + +# Default loot for unspecified locations +default: + common: + - torch + - flint + - rope + - rations + uncommon: + - lantern + - crowbar + - bandage + - herbs + rare: + - compass + - map + - antidote + gold: + min: 1 + max: 10 + bonus_per_margin: 1 # Extra gold per margin point + +# Forest/wilderness locations +forest: + common: + - herbs + - apple + - flint + - rope + uncommon: + - rations + - antidote + - bandage + - water + rare: + - map + - compass + - grappling_hook + gold: + min: 0 + max: 5 + bonus_per_margin: 0 + +# Cave/dungeon locations +cave: + common: + - torch + - flint + - rope + - pitons + uncommon: + - lantern + - crowbar + - grappling_hook + - bandage + rare: + - map + - compass + - key + gold: + min: 5 + max: 25 + bonus_per_margin: 2 + +dungeon: + common: + - torch + - key + - rope + - bandage + uncommon: + - lantern + - crowbar + - antidote + - map + rare: + - compass + - grappling_hook + - mirror + gold: + min: 10 + max: 50 + bonus_per_margin: 3 + +# Town/city locations +town: + common: + - bread + - apple + - ale + - candle + uncommon: + - cheese + - wine + - rations + - parchment + rare: + - map + - ink + - quill + gold: + min: 2 + max: 15 + bonus_per_margin: 1 + +tavern: + common: + - bread + - cheese + - ale + - candle + uncommon: + - wine + - rations + - water + - key + rare: + - map + - pouch + - mirror + gold: + min: 3 + max: 20 + bonus_per_margin: 2 + +# Ruins/ancient locations +ruins: + common: + - torch + - parchment + - vial + - rope + uncommon: + - ink + - quill + - mirror + - key + rare: + - map + - compass + - antidote + gold: + min: 10 + max: 40 + bonus_per_margin: 3 + +# Camp/outdoor locations +camp: + common: + - rations + - water + - bedroll + - flint + uncommon: + - rope + - torch + - bandage + - sack + rare: + - lantern + - backpack + - map + gold: + min: 1 + max: 10 + bonus_per_margin: 1 + +# Merchant/shop locations +shop: + common: + - pouch + - sack + - candle + - parchment + uncommon: + - ink + - quill + - vial + - key + rare: + - map + - mirror + - compass + gold: + min: 5 + max: 30 + bonus_per_margin: 2 + +# Road/path locations +road: + common: + - rope + - flint + - water + - bread + uncommon: + - bandage + - rations + - torch + - boots + rare: + - map + - compass + - cloak + gold: + min: 1 + max: 15 + bonus_per_margin: 1 + +# Castle/fortress locations +castle: + common: + - torch + - candle + - key + - parchment + uncommon: + - lantern + - ink + - quill + - mirror + rare: + - map + - compass + - crowbar + gold: + min: 15 + max: 60 + bonus_per_margin: 4 + +# Dock/port locations +dock: + common: + - rope + - water + - rations + - sack + uncommon: + - grappling_hook + - lantern + - map + - flint + rare: + - compass + - backpack + - cloak + gold: + min: 5 + max: 25 + bonus_per_margin: 2 + +# Mine locations +mine: + common: + - torch + - pitons + - rope + - hammer + uncommon: + - lantern + - crowbar + - flint + - bandage + rare: + - grappling_hook + - map + - key + gold: + min: 15 + max: 50 + bonus_per_margin: 3 diff --git a/api/app/data/npcs/crossville/npc_blacksmith_hilda.yaml b/api/app/data/npcs/crossville/npc_blacksmith_hilda.yaml new file mode 100644 index 0000000..1aa8d9e --- /dev/null +++ b/api/app/data/npcs/crossville/npc_blacksmith_hilda.yaml @@ -0,0 +1,93 @@ +# Hilda Ironforge - Village Blacksmith +npc_id: npc_blacksmith_hilda +name: Hilda Ironforge +role: blacksmith +location_id: crossville_village + +personality: + traits: + - straightforward + - hardworking + - proud of her craft + - protective of the village + - stubborn as iron + speech_style: | + Blunt and direct - says what she means without flourish. Her voice + carries the confidence of someone who knows their worth. Uses + smithing metaphors often ("hammer out the details," "strike while + hot"). Speaks slowly and deliberately, each word carrying weight. + quirks: + - Absent-mindedly hammers on things when thinking + - Inspects every weapon she sees for quality + - Refuses to sell poorly-made goods even at high prices + - Hums dwarven work songs while forging + +appearance: + brief: Muscular dwarven woman with soot-streaked red hair, burn-scarred forearms, and an appraising gaze + detailed: | + Hilda is built like a forge - solid, hot-tempered, and productive. + Her red hair is pulled back in a practical braid, streaked with + grey and permanently dusted with soot. Her forearms are a map of + old burns and calluses, badges of honor in her trade. She wears a + leather apron over practical clothes, and her hands are never far + from a hammer. Her eyes assess everything with the critical gaze of + a master craftsman, always noting quality - or its lack. + +knowledge: + public: + - The best iron ore came from the Old Mines before they were sealed + - She can repair almost anything made of metal + - Bandit attacks have increased demand for weapons + - Her family has been smithing in Crossville for four generations + secret: + - Her grandfather forged something for the previous mayor - something that was buried + - She has the original designs for that artifact + - The ore in the mines had unusual properties - made metal stronger + - She suspects the bandits are looking for her grandfather's work + will_share_if: + - condition: "interaction_count >= 4" + reveals: "Mentions her grandfather worked on a special project for the mayor's family" + - condition: "custom_flags.brought_quality_ore == true" + reveals: "Shares that the mine ore was special - almost magical" + - condition: "relationship_level >= 75" + reveals: "Shows them her grandfather's old designs" + - condition: "custom_flags.proved_worthy_warrior == true" + reveals: "Offers to forge them something special if they find the right materials" + +relationships: + - npc_id: npc_grom_ironbeard + attitude: friendly + reason: Old drinking companions and fellow dwarves + - npc_id: npc_mayor_aldric + attitude: respectful but curious + reason: The Thornwood family has secrets connected to her own + +inventory_for_sale: + - item: sword_iron + price: 50 + - item: shield_iron + price: 40 + - item: armor_chainmail + price: 150 + - item: dagger_steel + price: 25 + - item: repair_service + price: 20 + +dialogue_hooks: + greeting: "*sets down hammer* Something you need forged, or just looking?" + farewell: "May your blade stay sharp and your armor hold." + busy: "*keeps hammering* Talk while I work. Time is iron." + quest_complete: "*nods approvingly* Fine work. You've got the heart of a warrior." + +quest_giver_for: + - quest_ore_delivery + - quest_equipment_repair + +reveals_locations: [] + +tags: + - merchant + - quest_giver + - craftsman + - dwarf diff --git a/api/app/data/npcs/crossville/npc_grom_ironbeard.yaml b/api/app/data/npcs/crossville/npc_grom_ironbeard.yaml new file mode 100644 index 0000000..0635d23 --- /dev/null +++ b/api/app/data/npcs/crossville/npc_grom_ironbeard.yaml @@ -0,0 +1,95 @@ +# Grom Ironbeard - Tavern Bartender +npc_id: npc_grom_ironbeard +name: Grom Ironbeard +role: bartender +location_id: crossville_tavern + +personality: + traits: + - gruff + - secretly kind + - protective of regulars + - distrustful of strangers + - nostalgic about his adventuring days + speech_style: | + Short, clipped sentences. Heavy dwarvish accent - often drops articles + ("Need a drink?" becomes "Need drink?"). Speaks in a gravelly baritone. + Uses "lad" and "lass" frequently. Never raises his voice unless truly angry. + quirks: + - Polishes the same glass when nervous or thinking + - Tugs his beard when considering something seriously + - Refuses to serve anyone who insults his ale + - Hums old mining songs when the tavern is quiet + +appearance: + brief: Stocky dwarf with a braided grey beard, one clouded eye, and arms like tree trunks + detailed: | + Standing barely four feet tall, Grom's broad shoulders and thick arms + speak to decades of barrel-lifting and troublemaker-throwing. His grey + beard is immaculately braided with copper rings passed down from his + grandfather. A milky cataract clouds his left eye - a souvenir from his + adventuring days - but his right eye misses nothing that happens in his + tavern. His apron is always clean, though his hands bear the calluses + of hard work. + +knowledge: + public: + - Local gossip about Mayor Aldric raising taxes again + - The road east through Thornwood has been plagued by bandits + - A traveling merchant was asking about ancient ruins last week + - The blacksmith Hilda needs more iron ore but the mines are sealed + secret: + - Hidden passage behind the wine barrels leads to old smuggling tunnels + - The mayor is being blackmailed by someone - he's seen the letters + - Knows the location of a legendary dwarven forge in the mountains + - The cave-in in the mines wasn't natural - something broke through from below + will_share_if: + - condition: "interaction_count >= 3" + reveals: "Mentions he used to be an adventurer who explored the Old Mines" + - condition: "custom_flags.helped_with_rowdy_patrons == true" + reveals: "Shows them the hidden passage behind the wine barrels" + - condition: "relationship_level >= 70" + reveals: "Confides about the mayor's blackmail situation" + - condition: "relationship_level >= 85" + reveals: "Shares the location of the dwarven forge" + +relationships: + - npc_id: npc_mayor_aldric + attitude: distrustful + reason: Mayor raised tavern taxes unfairly and seems nervous lately + - npc_id: npc_mira_swiftfoot + attitude: protective + reason: She reminds him of his daughter who died young + - npc_id: npc_blacksmith_hilda + attitude: friendly + reason: Fellow dwarf and drinking companion for decades + +inventory_for_sale: + - item: ale + price: 2 + - item: dwarven_stout + price: 5 + - item: meal_hearty + price: 8 + - item: room_night + price: 15 + - item: information_local + price: 10 + +dialogue_hooks: + greeting: "*grunts* What'll it be? And don't waste my time." + farewell: "*nods* Don't cause trouble out there." + busy: "Got thirsty folk to serve. Make it quick." + quest_complete: "*actually smiles* Well done, lad. Drink's on the house." + +quest_giver_for: + - quest_cellar_rats + +reveals_locations: + - crossville_dungeon + +tags: + - merchant + - quest_giver + - information_source + - dwarf diff --git a/api/app/data/npcs/crossville/npc_mayor_aldric.yaml b/api/app/data/npcs/crossville/npc_mayor_aldric.yaml new file mode 100644 index 0000000..c71a2ec --- /dev/null +++ b/api/app/data/npcs/crossville/npc_mayor_aldric.yaml @@ -0,0 +1,83 @@ +# Mayor Aldric Thornwood - Village Leader +npc_id: npc_mayor_aldric +name: Mayor Aldric Thornwood +role: mayor +location_id: crossville_village + +personality: + traits: + - outwardly confident + - secretly terrified + - genuinely cares about the village + - increasingly desperate + - hiding something significant + speech_style: | + Speaks with the practiced cadence of a politician - measured words, + careful pauses for effect. His voice wavers slightly when stressed, + and he has a habit of clearing his throat before difficult topics. + Uses formal address even in casual conversation. + quirks: + - Constantly adjusts his mayoral chain of office + - Glances at his manor when the Old Mines are mentioned + - Keeps touching a ring on his left hand + - Offers wine to guests but never drinks himself + +appearance: + brief: Tall, thin man with receding grey hair, worry lines, and expensive but slightly disheveled clothing + detailed: | + Mayor Aldric carries himself with the posture of authority, though + lately that posture has developed a slight stoop. His grey hair, + once meticulously combed, shows signs of distracted neglect. His + clothes are fine but wrinkled, and dark circles under his eyes + suggest many sleepless nights. The heavy gold chain of his office + seems to weigh on him more than it should. His hands tremble + slightly when he thinks no one is watching. + +knowledge: + public: + - The village has prospered under his ten-year leadership + - Taxes were raised to fund road repairs and militia expansion + - He's offering a reward for clearing the bandit threat + - The Old Mines are sealed for safety reasons + secret: + - He's being blackmailed by someone who knows about the mines + - His grandfather found something in the mines that should stay buried + - The blackmailer wants access to the crypt + - He knows the earthquake that reopened the mines wasn't natural + will_share_if: + - condition: "relationship_level >= 60" + reveals: "Admits the tax increase was forced by external pressure" + - condition: "custom_flags.proved_trustworthy == true" + reveals: "Confesses he's being blackmailed but won't say by whom" + - condition: "relationship_level >= 80" + reveals: "Shares his grandfather's journal about the mines" + - condition: "custom_flags.defeated_blackmailer == true" + reveals: "Reveals everything about what's buried in the crypt" + +relationships: + - npc_id: npc_grom_ironbeard + attitude: guilty + reason: Knows the tax increase hurt the tavern unfairly + - npc_id: npc_blacksmith_hilda + attitude: respectful + reason: Her family has served the village for generations + +inventory_for_sale: [] + +dialogue_hooks: + greeting: "*straightens his chain* Ah, welcome to Crossville. How may I be of service?" + farewell: "The village thanks you. May your roads be safe." + busy: "*distracted* I have urgent matters to attend. Perhaps later?" + quest_complete: "*genuine relief* You have done Crossville a great service." + +quest_giver_for: + - quest_mayors_request + - quest_bandit_threat + +reveals_locations: + - crossville_dungeon + +tags: + - quest_giver + - authority + - human diff --git a/api/app/data/npcs/crossville/npc_mira_swiftfoot.yaml b/api/app/data/npcs/crossville/npc_mira_swiftfoot.yaml new file mode 100644 index 0000000..0163058 --- /dev/null +++ b/api/app/data/npcs/crossville/npc_mira_swiftfoot.yaml @@ -0,0 +1,90 @@ +# Mira Swiftfoot - Rogue and Information Broker +npc_id: npc_mira_swiftfoot +name: Mira Swiftfoot +role: rogue +location_id: crossville_tavern + +personality: + traits: + - curious + - street-smart + - morally flexible + - loyal once trust is earned + - haunted by her past + speech_style: | + Quick and clever, often speaking in half-sentences as if her mouth + can't keep up with her racing thoughts. Uses thieves' cant occasionally. + Tends to deflect personal questions with humor or questions of her own. + Her voice drops to a whisper when sharing secrets. + quirks: + - Always sits with her back to the wall, facing the door + - Fidgets with a coin, rolling it across her knuckles + - Sizes up everyone who enters the tavern + - Never drinks anything she didn't pour herself + +appearance: + brief: Slender half-elf with sharp green eyes, dark hair cut short, and fingers that never stop moving + detailed: | + Mira moves with the easy grace of someone used to slipping through + shadows unnoticed. Her dark hair is cut practically short, framing + an angular face with sharp green eyes that seem to catalog everything + they see. She dresses in muted colors - browns and greys that blend + into any crowd. A thin scar runs from her left ear to her jaw, and + she wears leather bracers that probably hide more than calluses. + +knowledge: + public: + - The bandits in Thornwood are more organized than simple thieves + - There's a fence in the city who buys no-questions-asked + - The mayor's been receiving mysterious visitors at night + - Several people have gone missing in the forest lately + secret: + - The bandit leader is a former soldier named Kael + - She knows a secret entrance to the crypt through the forest + - The missing people were all asking about the Old Mines + - She's actually running from a thieves' guild she betrayed + will_share_if: + - condition: "interaction_count >= 2" + reveals: "Mentions the bandits seem to be searching for something specific" + - condition: "custom_flags.shared_drink == true" + reveals: "Admits she knows more about the forest than most" + - condition: "relationship_level >= 65" + reveals: "Reveals she knows a secret path to the crypt" + - condition: "relationship_level >= 80" + reveals: "Tells them about Kael and offers to help infiltrate the bandits" + +relationships: + - npc_id: npc_grom_ironbeard + attitude: affectionate + reason: He's the closest thing to family she has + - npc_id: npc_mayor_aldric + attitude: suspicious + reason: Something about him doesn't add up + +inventory_for_sale: + - item: lockpick_set + price: 25 + - item: rope_silk + price: 15 + - item: map_local + price: 20 + +dialogue_hooks: + greeting: "*looks you over* New face. What brings you to our little crossroads?" + farewell: "Watch your back out there. Trust me on that." + busy: "*glances at the door* Not now. Later." + quest_complete: "*grins* You've got potential. Stick around." + +quest_giver_for: + - quest_bandit_camp + +reveals_locations: + - crossville_forest + - crossville_crypt + +tags: + - information_source + - merchant + - quest_giver + - rogue + - half-elf diff --git a/api/app/data/origins.yaml b/api/app/data/origins.yaml new file mode 100644 index 0000000..68f344f --- /dev/null +++ b/api/app/data/origins.yaml @@ -0,0 +1,158 @@ +# Character Origin Stories +# These are saved to the character and referenced by the AI DM throughout the game +# to create personalized narrative experiences and quest hooks. + +origins: + soul_revenant: + id: soul_revenant + name: Soul Revenant + description: | + You died centuries ago, but death was not the end. Through dark magic, divine + intervention, or a cosmic mistake, you have been returned to the world of the + living. Your memories are fragmented—flashes of a life long past, faces you once + knew now turned to dust, and deeds both noble and terrible that weigh upon your soul. + + The world has changed beyond recognition. The kingdom you served no longer exists, + the people you loved are gone, and the wrongs you committed—or suffered—can never + be undone. Yet here you stand, given a second chance you never asked for. + + You awaken in an ancient crypt, your body restored but your purpose unclear. + Are you here to atone? To finish unfinished business? Or simply to understand + why you were brought back? + + starting_location: + id: forgotten_crypt + name: The Forgotten Crypt + region: Shadowmere Necropolis + description: Ancient burial grounds beneath twisted trees, where the veil between life and death grows thin + + narrative_hooks: + - Past lives and forgotten memories that surface during gameplay + - NPCs or descendants related to your previous life + - Unfinished business from centuries ago + - Haunted by spectral visions or voices from the past + - Divine or dark entities interested in your return + - Questions about identity and purpose across lifetimes + + starting_bonus: + trait: Deathless Resolve + description: You have walked through death itself. Fear holds less power over you. + effect: +2 WIS, resistance to fear effects + + memory_thief: + id: memory_thief + name: Memory Thief + description: | + You opened your eyes in an open field with no memory of who you are, where you + came from, or how you got here. Your mind is a blank slate—no name, no past, + no identity. Even your own face in a reflection seems like a stranger's. + + The only thing you possess is an overwhelming sense that something was taken + from you. Your memories weren't simply lost—they were stolen. By whom? Why? + You don't know. But deep in your gut, you feel that discovering the truth might + be more terrifying than living in ignorance. + + As you wander the world, fragments occasionally surface—a fleeting image, a + half-remembered name, a skill you didn't know you had. Are these clues to your + real identity, or false memories planted by whoever stole your past? + + One thing is certain: you must piece together who you were, even if you discover + you were someone you'd rather not remember. + + starting_location: + id: thornfield_plains + name: Thornfield Plains + region: The Midlands + description: Vast open grasslands where merchant roads cross, a place of new beginnings for the lost + + narrative_hooks: + - Gradual memory fragments revealed during key story moments + - NPCs who seem to recognize you but you don't remember them + - Clues to your stolen past hidden in the world + - A mysterious organization or individual who took your memories + - Skills or knowledge you possess without knowing why + - Identity crisis as you discover who you might have been + + starting_bonus: + trait: Blank Slate + description: Without a past to define you, you adapt quickly to new situations. + effect: +1 to all stats, faster skill learning + + shadow_apprentice: + id: shadow_apprentice + name: Shadow Apprentice + description: | + You were raised in darkness—literally and figuratively. From childhood, you were + trained by a mysterious master who taught you the arts of stealth, deception, + and survival in the underworld. You learned to move unseen, to read people's + secrets in their eyes, and to trust no one. + + Your master never revealed why they chose you, only that you had "potential." + For years, you honed your skills in the shadows, taking on jobs that required + discretion and ruthlessness. You became a weapon in your master's hands. + + But recently, everything changed. Your master disappeared without a word, leaving + you with only your training and a single cryptic message: "Trust no one. Not even me." + + Now you walk alone, unsure if your master abandoned you, was captured, or is + testing you one final time. The underworld you once navigated so confidently + suddenly feels hostile and full of eyes watching from the darkness. + + starting_location: + id: shadowfen + name: Shadowfen + region: The Murkvale + description: A misty swamp settlement where outlaws and exiles gather, hidden from the world's judging eyes + + narrative_hooks: + - The mysterious master and their true motivations + - Dark organizations or guilds from your past + - Moral dilemmas between loyalty and self-preservation + - Rivals or enemies from your apprenticeship + - Secrets your master never told you + - The true reason you were chosen and trained + + starting_bonus: + trait: Trained in Shadows + description: Your master taught you well. The darkness is your ally. + effect: +2 DEX, +1 CHA, advantage on stealth checks in darkness + + escaped_captive: + id: escaped_captive + name: The Escaped Captive + description: | + You were a prisoner at Ironpeak Pass, one of the most notorious holding facilities + in the realm. How you ended up there, you remember all too well—whether you were + guilty or innocent, the iron bars didn't care. Days blurred into weeks, weeks into + months, and you felt yourself becoming just another forgotten soul. + + But you refused to accept that fate. Through cunning, luck, or sheer desperation, + you escaped. Now you stand on the other side of those mountain walls, breathing + free air for the first time in what feels like forever. + + Freedom tastes sweet, but it comes with a price. The authorities will be searching + for you. Your face might be on wanted posters. Anyone who learns of your past might + turn you in for a reward. You must build a new life while constantly looking over + your shoulder. + + Can you truly start fresh, or will your past always define you? That depends on + the choices you make from here. + + starting_location: + id: ironpeak_pass + name: Ironpeak Pass + region: The Frost Peaks + description: A treacherous mountain passage near the prison you escaped, where few travelers venture + + narrative_hooks: + - Bounty hunters or guards searching for you + - NPCs who recognize you from your past + - The crime you were imprisoned for (guilty or framed) + - Fellow prisoners or guards from Ironpeak + - Building a new identity while hiding your past + - Redemption arc or embracing your criminal nature + + starting_bonus: + trait: Hardened Survivor + description: Prison taught you to endure hardship and seize opportunities. + effect: +2 CON, +1 STR, bonus to survival and escape checks diff --git a/api/app/game_logic/__init__.py b/api/app/game_logic/__init__.py new file mode 100644 index 0000000..9e34f63 --- /dev/null +++ b/api/app/game_logic/__init__.py @@ -0,0 +1,34 @@ +""" +Game logic module for Code of Conquest. + +This module contains core game mechanics that determine outcomes +before they are passed to AI for narration. +""" + +from app.game_logic.dice import ( + CheckResult, + SkillType, + Difficulty, + roll_d20, + calculate_modifier, + skill_check, + get_stat_for_skill, + perception_check, + stealth_check, + persuasion_check, + lockpicking_check, +) + +__all__ = [ + "CheckResult", + "SkillType", + "Difficulty", + "roll_d20", + "calculate_modifier", + "skill_check", + "get_stat_for_skill", + "perception_check", + "stealth_check", + "persuasion_check", + "lockpicking_check", +] diff --git a/api/app/game_logic/dice.py b/api/app/game_logic/dice.py new file mode 100644 index 0000000..4b35e2d --- /dev/null +++ b/api/app/game_logic/dice.py @@ -0,0 +1,247 @@ +""" +Dice mechanics module for Code of Conquest. + +This module provides core dice rolling functionality using a D20 + modifier vs DC system. +All game chance mechanics (searches, skill checks, etc.) use these functions to determine +outcomes before passing results to AI for narration. +""" + +import random +from dataclasses import dataclass +from typing import Optional +from enum import Enum + + +class Difficulty(Enum): + """Standard difficulty classes for skill checks.""" + TRIVIAL = 5 + EASY = 10 + MEDIUM = 15 + HARD = 20 + VERY_HARD = 25 + NEARLY_IMPOSSIBLE = 30 + + +class SkillType(Enum): + """ + Skill types and their associated base stats. + + Each skill maps to a core stat for modifier calculation. + """ + # Wisdom-based + PERCEPTION = "wisdom" + INSIGHT = "wisdom" + SURVIVAL = "wisdom" + MEDICINE = "wisdom" + + # Dexterity-based + STEALTH = "dexterity" + ACROBATICS = "dexterity" + SLEIGHT_OF_HAND = "dexterity" + LOCKPICKING = "dexterity" + + # Charisma-based + PERSUASION = "charisma" + DECEPTION = "charisma" + INTIMIDATION = "charisma" + PERFORMANCE = "charisma" + + # Strength-based + ATHLETICS = "strength" + + # Intelligence-based + ARCANA = "intelligence" + HISTORY = "intelligence" + INVESTIGATION = "intelligence" + NATURE = "intelligence" + RELIGION = "intelligence" + + # Constitution-based + ENDURANCE = "constitution" + + +@dataclass +class CheckResult: + """ + Result of a dice check. + + Contains all information needed for UI display (dice roll animation) + and game logic (success/failure determination). + + Attributes: + roll: The natural d20 roll (1-20) + modifier: Total modifier from stats + total: roll + modifier + dc: Difficulty class that was checked against + success: Whether the check succeeded + margin: How much the check succeeded or failed by (total - dc) + skill_type: The skill used for this check (if applicable) + """ + roll: int + modifier: int + total: int + dc: int + success: bool + margin: int + skill_type: Optional[str] = None + + @property + def is_critical_success(self) -> bool: + """Natural 20 - only relevant for combat.""" + return self.roll == 20 + + @property + def is_critical_failure(self) -> bool: + """Natural 1 - only relevant for combat.""" + return self.roll == 1 + + def to_dict(self) -> dict: + """Serialize for API response.""" + return { + "roll": self.roll, + "modifier": self.modifier, + "total": self.total, + "dc": self.dc, + "success": self.success, + "margin": self.margin, + "skill_type": self.skill_type, + } + + +def roll_d20() -> int: + """ + Roll a standard 20-sided die. + + Returns: + Integer from 1 to 20 (inclusive) + """ + return random.randint(1, 20) + + +def calculate_modifier(stat_value: int) -> int: + """ + Calculate the D&D-style modifier from a stat value. + + Formula: (stat - 10) // 2 + + Examples: + - Stat 10 = +0 modifier + - Stat 14 = +2 modifier + - Stat 18 = +4 modifier + - Stat 8 = -1 modifier + + Args: + stat_value: The raw stat value (typically 1-20) + + Returns: + The modifier value (can be negative) + """ + return (stat_value - 10) // 2 + + +def skill_check( + stat_value: int, + dc: int, + skill_type: Optional[SkillType] = None, + bonus: int = 0 +) -> CheckResult: + """ + Perform a skill check: d20 + modifier vs DC. + + Args: + stat_value: The relevant stat value (e.g., character's wisdom for perception) + dc: Difficulty class to beat + skill_type: Optional skill type for logging/display + bonus: Additional bonus (e.g., from equipment or proficiency) + + Returns: + CheckResult with full details of the roll + """ + roll = roll_d20() + modifier = calculate_modifier(stat_value) + bonus + total = roll + modifier + success = total >= dc + margin = total - dc + + return CheckResult( + roll=roll, + modifier=modifier, + total=total, + dc=dc, + success=success, + margin=margin, + skill_type=skill_type.name if skill_type else None + ) + + +def get_stat_for_skill(skill_type: SkillType) -> str: + """ + Get the base stat name for a skill type. + + Args: + skill_type: The skill to look up + + Returns: + The stat name (e.g., "wisdom", "dexterity") + """ + return skill_type.value + + +def perception_check(wisdom: int, dc: int, bonus: int = 0) -> CheckResult: + """ + Convenience function for perception checks (searching, spotting). + + Args: + wisdom: Character's wisdom stat + dc: Difficulty class + bonus: Additional bonus + + Returns: + CheckResult + """ + return skill_check(wisdom, dc, SkillType.PERCEPTION, bonus) + + +def stealth_check(dexterity: int, dc: int, bonus: int = 0) -> CheckResult: + """ + Convenience function for stealth checks (sneaking, hiding). + + Args: + dexterity: Character's dexterity stat + dc: Difficulty class + bonus: Additional bonus + + Returns: + CheckResult + """ + return skill_check(dexterity, dc, SkillType.STEALTH, bonus) + + +def persuasion_check(charisma: int, dc: int, bonus: int = 0) -> CheckResult: + """ + Convenience function for persuasion checks (convincing, negotiating). + + Args: + charisma: Character's charisma stat + dc: Difficulty class + bonus: Additional bonus + + Returns: + CheckResult + """ + return skill_check(charisma, dc, SkillType.PERSUASION, bonus) + + +def lockpicking_check(dexterity: int, dc: int, bonus: int = 0) -> CheckResult: + """ + Convenience function for lockpicking checks. + + Args: + dexterity: Character's dexterity stat + dc: Difficulty class + bonus: Additional bonus + + Returns: + CheckResult + """ + return skill_check(dexterity, dc, SkillType.LOCKPICKING, bonus) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py new file mode 100644 index 0000000..66b9419 --- /dev/null +++ b/api/app/models/__init__.py @@ -0,0 +1,87 @@ +""" +Data models for Code of Conquest. + +This package contains all dataclass models used throughout the application. +""" + +# Enums +from app.models.enums import ( + EffectType, + DamageType, + ItemType, + StatType, + AbilityType, + CombatStatus, + SessionStatus, + ListingStatus, + ListingType, +) + +# Core models +from app.models.stats import Stats +from app.models.effects import Effect +from app.models.abilities import Ability, AbilityLoader +from app.models.items import Item + +# Progression +from app.models.skills import SkillNode, SkillTree, PlayerClass + +# Character +from app.models.character import Character + +# Combat +from app.models.combat import Combatant, CombatEncounter + +# Session +from app.models.session import ( + SessionConfig, + GameState, + ConversationEntry, + GameSession, +) + +# Marketplace +from app.models.marketplace import ( + Bid, + MarketplaceListing, + Transaction, + ShopItem, +) + +__all__ = [ + # Enums + "EffectType", + "DamageType", + "ItemType", + "StatType", + "AbilityType", + "CombatStatus", + "SessionStatus", + "ListingStatus", + "ListingType", + # Core models + "Stats", + "Effect", + "Ability", + "AbilityLoader", + "Item", + # Progression + "SkillNode", + "SkillTree", + "PlayerClass", + # Character + "Character", + # Combat + "Combatant", + "CombatEncounter", + # Session + "SessionConfig", + "GameState", + "ConversationEntry", + "GameSession", + # Marketplace + "Bid", + "MarketplaceListing", + "Transaction", + "ShopItem", +] diff --git a/api/app/models/abilities.py b/api/app/models/abilities.py new file mode 100644 index 0000000..516fc6e --- /dev/null +++ b/api/app/models/abilities.py @@ -0,0 +1,237 @@ +""" +Ability system for combat actions and spells. + +This module defines abilities (attacks, spells, skills) that can be used in combat. +Abilities are loaded from YAML configuration files for data-driven design. +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional +import yaml +import os +from pathlib import Path + +from app.models.enums import AbilityType, DamageType, EffectType, StatType +from app.models.effects import Effect +from app.models.stats import Stats + + +@dataclass +class Ability: + """ + Represents an action that can be taken in combat. + + Abilities can deal damage, apply effects, heal, or perform other actions. + They are loaded from YAML files for easy game design iteration. + + Attributes: + ability_id: Unique identifier + name: Display name + description: What the ability does + ability_type: Category (attack, spell, skill, etc.) + base_power: Base damage or healing value + damage_type: Type of damage dealt (physical, fire, etc.) + scaling_stat: Which stat scales this ability's power (if any) + scaling_factor: Multiplier for scaling stat (default 0.5) + mana_cost: MP required to use this ability + cooldown: Turns before ability can be used again + effects_applied: List of effects applied to target on hit + is_aoe: Whether this affects multiple targets + target_count: Number of targets if AoE (0 = all) + """ + + ability_id: str + name: str + description: str + ability_type: AbilityType + base_power: int = 0 + damage_type: Optional[DamageType] = None + scaling_stat: Optional[StatType] = None + scaling_factor: float = 0.5 + mana_cost: int = 0 + cooldown: int = 0 + effects_applied: List[Effect] = field(default_factory=list) + is_aoe: bool = False + target_count: int = 1 + + def calculate_power(self, caster_stats: Stats) -> int: + """ + Calculate final power based on caster's stats. + + Formula: base_power + (scaling_stat × scaling_factor) + Minimum power is always 1. + + Args: + caster_stats: The caster's effective stats + + Returns: + Final power value for damage or healing + """ + power = self.base_power + + if self.scaling_stat: + stat_value = getattr(caster_stats, self.scaling_stat.value) + power += int(stat_value * self.scaling_factor) + + return max(1, power) + + def get_effects_to_apply(self) -> List[Effect]: + """ + Get a copy of effects that should be applied to target(s). + + Creates new Effect instances to avoid sharing references. + + Returns: + List of Effect instances to apply + """ + return [ + Effect( + effect_id=f"{self.ability_id}_{effect.name}_{id(effect)}", + name=effect.name, + effect_type=effect.effect_type, + duration=effect.duration, + power=effect.power, + stat_affected=effect.stat_affected, + stacks=effect.stacks, + max_stacks=effect.max_stacks, + source=self.ability_id, + ) + for effect in self.effects_applied + ] + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize ability to a dictionary. + + Returns: + Dictionary containing all ability data + """ + data = asdict(self) + data["ability_type"] = self.ability_type.value + if self.damage_type: + data["damage_type"] = self.damage_type.value + if self.scaling_stat: + data["scaling_stat"] = self.scaling_stat.value + data["effects_applied"] = [effect.to_dict() for effect in self.effects_applied] + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Ability': + """ + Deserialize ability from a dictionary. + + Args: + data: Dictionary containing ability data + + Returns: + Ability instance + """ + # Convert string values back to enums + ability_type = AbilityType(data["ability_type"]) + damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None + scaling_stat = StatType(data["scaling_stat"]) if data.get("scaling_stat") else None + + # Deserialize effects + effects = [] + if "effects_applied" in data and data["effects_applied"]: + effects = [Effect.from_dict(e) for e in data["effects_applied"]] + + return cls( + ability_id=data["ability_id"], + name=data["name"], + description=data["description"], + ability_type=ability_type, + base_power=data.get("base_power", 0), + damage_type=damage_type, + scaling_stat=scaling_stat, + scaling_factor=data.get("scaling_factor", 0.5), + mana_cost=data.get("mana_cost", 0), + cooldown=data.get("cooldown", 0), + effects_applied=effects, + is_aoe=data.get("is_aoe", False), + target_count=data.get("target_count", 1), + ) + + def __repr__(self) -> str: + """String representation of the ability.""" + return ( + f"Ability({self.name}, {self.ability_type.value}, " + f"power={self.base_power}, cost={self.mana_cost}MP, " + f"cooldown={self.cooldown}t)" + ) + + +class AbilityLoader: + """ + Loads abilities from YAML configuration files. + + This allows game designers to define abilities without touching code. + """ + + def __init__(self, data_dir: Optional[str] = None): + """ + Initialize the ability loader. + + Args: + data_dir: Path to directory containing ability YAML files + Defaults to /app/data/abilities/ + """ + if data_dir is None: + # Default to app/data/abilities relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_dir = str(app_dir / "data" / "abilities") + + self.data_dir = Path(data_dir) + self._ability_cache: Dict[str, Ability] = {} + + def load_ability(self, ability_id: str) -> Optional[Ability]: + """ + Load a single ability by ID. + + Args: + ability_id: Unique ability identifier + + Returns: + Ability instance or None if not found + """ + # Check cache first + if ability_id in self._ability_cache: + return self._ability_cache[ability_id] + + # Load from YAML file + yaml_file = self.data_dir / f"{ability_id}.yaml" + if not yaml_file.exists(): + return None + + with open(yaml_file, 'r') as f: + data = yaml.safe_load(f) + + ability = Ability.from_dict(data) + self._ability_cache[ability_id] = ability + return ability + + def load_all_abilities(self) -> Dict[str, Ability]: + """ + Load all abilities from the data directory. + + Returns: + Dictionary mapping ability_id to Ability instance + """ + if not self.data_dir.exists(): + return {} + + abilities = {} + for yaml_file in self.data_dir.glob("*.yaml"): + with open(yaml_file, 'r') as f: + data = yaml.safe_load(f) + + ability = Ability.from_dict(data) + abilities[ability.ability_id] = ability + self._ability_cache[ability.ability_id] = ability + + return abilities + + def clear_cache(self) -> None: + """Clear the ability cache, forcing reload on next access.""" + self._ability_cache.clear() diff --git a/api/app/models/action_prompt.py b/api/app/models/action_prompt.py new file mode 100644 index 0000000..57eb9d9 --- /dev/null +++ b/api/app/models/action_prompt.py @@ -0,0 +1,296 @@ +""" +Action Prompt Model + +This module defines the ActionPrompt dataclass for button-based story actions. +Each action prompt represents a predefined action that players can take during +story progression, with tier-based availability and context filtering. + +Usage: + from app.models.action_prompt import ActionPrompt, ActionCategory, LocationType + + action = ActionPrompt( + prompt_id="ask_locals", + category=ActionCategory.ASK_QUESTION, + display_text="Ask locals for information", + description="Talk to NPCs to learn about quests and rumors", + tier_required=UserTier.FREE, + context_filter=[LocationType.TOWN, LocationType.TAVERN], + dm_prompt_template="The player asks locals about {{ topic }}..." + ) + + if action.is_available(UserTier.FREE, LocationType.TOWN): + # Show action button to player + pass +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Optional, Any, Dict + +from app.ai.model_selector import UserTier + + +@dataclass +class CheckRequirement: + """ + Defines the dice check required for an action. + + Used to determine outcomes before AI narration. + """ + check_type: str # "search" or "skill" + skill: Optional[str] = None # For skill checks: perception, persuasion, etc. + difficulty: str = "medium" # trivial, easy, medium, hard, very_hard + + def to_dict(self) -> dict: + """Serialize for API response.""" + return { + "check_type": self.check_type, + "skill": self.skill, + "difficulty": self.difficulty, + } + + @classmethod + def from_dict(cls, data: dict) -> "CheckRequirement": + """Create from dictionary.""" + return cls( + check_type=data.get("check_type", "skill"), + skill=data.get("skill"), + difficulty=data.get("difficulty", "medium"), + ) + + +class ActionCategory(str, Enum): + """Categories of story actions.""" + ASK_QUESTION = "ask_question" # Gather information from NPCs + TRAVEL = "travel" # Move to a new location + GATHER_INFO = "gather_info" # Search or investigate + REST = "rest" # Rest and recover + INTERACT = "interact" # Interact with objects/environment + EXPLORE = "explore" # Explore the area + SPECIAL = "special" # Special tier-specific actions + + +class LocationType(str, Enum): + """Types of locations in the game world.""" + TOWN = "town" # Populated settlements + TAVERN = "tavern" # Taverns and inns + WILDERNESS = "wilderness" # Outdoor areas, forests, fields + DUNGEON = "dungeon" # Dungeons and caves + SAFE_AREA = "safe_area" # Protected zones, temples + LIBRARY = "library" # Libraries and archives + ANY = "any" # Available in all locations + + +@dataclass +class ActionPrompt: + """ + Represents a predefined story action that players can select. + + Action prompts are displayed as buttons in the story UI. Each action + has tier requirements and context filters to determine availability. + + Attributes: + prompt_id: Unique identifier for the action + category: Category of action (ASK_QUESTION, TRAVEL, etc.) + display_text: Text shown on the action button + description: Tooltip/help text explaining the action + tier_required: Minimum subscription tier required + context_filter: List of location types where action is available + dm_prompt_template: Jinja2 template for generating AI prompt + icon: Optional icon name for the button + cooldown_turns: Optional cooldown in turns before action can be used again + """ + + prompt_id: str + category: ActionCategory + display_text: str + description: str + tier_required: UserTier + context_filter: List[LocationType] + dm_prompt_template: str + icon: Optional[str] = None + cooldown_turns: int = 0 + requires_check: Optional[CheckRequirement] = None + + def is_available(self, user_tier: UserTier, location_type: LocationType) -> bool: + """ + Check if this action is available for a user at a location. + + Args: + user_tier: The user's subscription tier + location_type: The current location type + + Returns: + True if the action is available, False otherwise + """ + # Check tier requirement + if not self._tier_meets_requirement(user_tier): + return False + + # Check location filter + if not self._location_matches_filter(location_type): + return False + + return True + + def _tier_meets_requirement(self, user_tier: UserTier) -> bool: + """ + Check if user tier meets the minimum requirement. + + Tier hierarchy: FREE < BASIC < PREMIUM < ELITE + + Args: + user_tier: The user's subscription tier + + Returns: + True if tier requirement is met + """ + tier_order = { + UserTier.FREE: 0, + UserTier.BASIC: 1, + UserTier.PREMIUM: 2, + UserTier.ELITE: 3, + } + + user_level = tier_order.get(user_tier, 0) + required_level = tier_order.get(self.tier_required, 0) + + return user_level >= required_level + + def _location_matches_filter(self, location_type: LocationType) -> bool: + """ + Check if location matches the context filter. + + Args: + location_type: The current location type + + Returns: + True if location matches filter + """ + # ANY location type matches everything + if LocationType.ANY in self.context_filter: + return True + + # Check if location is in the filter list + return location_type in self.context_filter + + def is_locked(self, user_tier: UserTier) -> bool: + """ + Check if this action is locked due to tier restriction. + + Used to show locked actions with upgrade prompts. + + Args: + user_tier: The user's subscription tier + + Returns: + True if the action is locked (tier too low) + """ + return not self._tier_meets_requirement(user_tier) + + def get_lock_reason(self, user_tier: UserTier) -> Optional[str]: + """ + Get the reason why an action is locked. + + Args: + user_tier: The user's subscription tier + + Returns: + Lock reason message, or None if not locked + """ + if not self._tier_meets_requirement(user_tier): + tier_names = { + UserTier.FREE: "Free", + UserTier.BASIC: "Basic", + UserTier.PREMIUM: "Premium", + UserTier.ELITE: "Elite", + } + required_name = tier_names.get(self.tier_required, "Unknown") + return f"Requires {required_name} tier or higher" + + return None + + def to_dict(self) -> dict: + """ + Convert to dictionary for JSON serialization. + + Returns: + Dictionary representation of the action prompt + """ + result = { + "prompt_id": self.prompt_id, + "category": self.category.value, + "display_text": self.display_text, + "description": self.description, + "tier_required": self.tier_required.value, + "context_filter": [loc.value for loc in self.context_filter], + "dm_prompt_template": self.dm_prompt_template, + "icon": self.icon, + "cooldown_turns": self.cooldown_turns, + } + if self.requires_check: + result["requires_check"] = self.requires_check.to_dict() + return result + + @classmethod + def from_dict(cls, data: dict) -> "ActionPrompt": + """ + Create an ActionPrompt from a dictionary. + + Args: + data: Dictionary containing action prompt data + + Returns: + ActionPrompt instance + + Raises: + ValueError: If required fields are missing or invalid + """ + # Parse category enum + category_str = data.get("category", "") + try: + category = ActionCategory(category_str) + except ValueError: + raise ValueError(f"Invalid action category: {category_str}") + + # Parse tier enum + tier_str = data.get("tier_required", "free") + try: + tier_required = UserTier(tier_str) + except ValueError: + raise ValueError(f"Invalid user tier: {tier_str}") + + # Parse location types + context_filter_raw = data.get("context_filter", ["any"]) + context_filter = [] + for loc_str in context_filter_raw: + try: + context_filter.append(LocationType(loc_str.lower())) + except ValueError: + raise ValueError(f"Invalid location type: {loc_str}") + + # Parse requires_check if present + requires_check = None + if "requires_check" in data and data["requires_check"]: + requires_check = CheckRequirement.from_dict(data["requires_check"]) + + return cls( + prompt_id=data.get("prompt_id", ""), + category=category, + display_text=data.get("display_text", ""), + description=data.get("description", ""), + tier_required=tier_required, + context_filter=context_filter, + dm_prompt_template=data.get("dm_prompt_template", ""), + icon=data.get("icon"), + cooldown_turns=data.get("cooldown_turns", 0), + requires_check=requires_check, + ) + + def __repr__(self) -> str: + """String representation for debugging.""" + return ( + f"ActionPrompt(prompt_id='{self.prompt_id}', " + f"category={self.category.value}, " + f"tier={self.tier_required.value})" + ) diff --git a/api/app/models/ai_usage.py b/api/app/models/ai_usage.py new file mode 100644 index 0000000..d1d653b --- /dev/null +++ b/api/app/models/ai_usage.py @@ -0,0 +1,211 @@ +""" +AI Usage data model for tracking AI generation costs and usage. + +This module defines the AIUsageLog dataclass which represents a single AI usage +event for tracking costs, tokens used, and generating usage analytics. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone, date +from typing import Dict, Any, Optional +from enum import Enum + + +class TaskType(str, Enum): + """Types of AI tasks that can be tracked.""" + STORY_PROGRESSION = "story_progression" + COMBAT_NARRATION = "combat_narration" + QUEST_SELECTION = "quest_selection" + NPC_DIALOGUE = "npc_dialogue" + GENERAL = "general" + + +@dataclass +class AIUsageLog: + """ + Represents a single AI usage event for cost and usage tracking. + + This dataclass captures all relevant information about an AI API call + including the user, model used, tokens consumed, and estimated cost. + Used for: + - Cost monitoring and budgeting + - Usage analytics per user/tier + - Rate limiting enforcement + - Billing and invoicing (future) + + Attributes: + log_id: Unique identifier for this usage log entry + user_id: User who made the request + timestamp: When the request was made + model: Model identifier (e.g., "meta/meta-llama-3-8b-instruct") + tokens_input: Number of input tokens (prompt) + tokens_output: Number of output tokens (response) + tokens_total: Total tokens used (input + output) + estimated_cost: Estimated cost in USD + task_type: Type of task (story, combat, quest, npc) + session_id: Optional game session ID for context + character_id: Optional character ID for context + request_duration_ms: How long the request took in milliseconds + success: Whether the request completed successfully + error_message: Error message if the request failed + """ + + log_id: str + user_id: str + timestamp: datetime + model: str + tokens_input: int + tokens_output: int + tokens_total: int + estimated_cost: float + task_type: TaskType + session_id: Optional[str] = None + character_id: Optional[str] = None + request_duration_ms: int = 0 + success: bool = True + error_message: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """ + Convert usage log to dictionary for storage. + + Returns: + Dictionary representation suitable for Appwrite storage + """ + return { + "user_id": self.user_id, + "timestamp": self.timestamp.isoformat() if isinstance(self.timestamp, datetime) else self.timestamp, + "model": self.model, + "tokens_input": self.tokens_input, + "tokens_output": self.tokens_output, + "tokens_total": self.tokens_total, + "estimated_cost": self.estimated_cost, + "task_type": self.task_type.value if isinstance(self.task_type, TaskType) else self.task_type, + "session_id": self.session_id, + "character_id": self.character_id, + "request_duration_ms": self.request_duration_ms, + "success": self.success, + "error_message": self.error_message, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AIUsageLog": + """ + Create AIUsageLog from dictionary. + + Args: + data: Dictionary with usage log data + + Returns: + AIUsageLog instance + """ + # Parse timestamp + timestamp = data.get("timestamp") + if isinstance(timestamp, str): + timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + elif timestamp is None: + timestamp = datetime.now(timezone.utc) + + # Parse task type + task_type = data.get("task_type", "general") + if isinstance(task_type, str): + try: + task_type = TaskType(task_type) + except ValueError: + task_type = TaskType.GENERAL + + return cls( + log_id=data.get("log_id", ""), + user_id=data.get("user_id", ""), + timestamp=timestamp, + model=data.get("model", ""), + tokens_input=data.get("tokens_input", 0), + tokens_output=data.get("tokens_output", 0), + tokens_total=data.get("tokens_total", 0), + estimated_cost=data.get("estimated_cost", 0.0), + task_type=task_type, + session_id=data.get("session_id"), + character_id=data.get("character_id"), + request_duration_ms=data.get("request_duration_ms", 0), + success=data.get("success", True), + error_message=data.get("error_message"), + ) + + +@dataclass +class DailyUsageSummary: + """ + Summary of AI usage for a specific day. + + Used for reporting and rate limiting checks. + + Attributes: + date: The date of this summary + user_id: User ID + total_requests: Number of AI requests made + total_tokens: Total tokens consumed + total_input_tokens: Total input tokens + total_output_tokens: Total output tokens + estimated_cost: Total estimated cost in USD + requests_by_task: Breakdown of requests by task type + """ + + date: date + user_id: str + total_requests: int + total_tokens: int + total_input_tokens: int + total_output_tokens: int + estimated_cost: float + requests_by_task: Dict[str, int] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert summary to dictionary.""" + return { + "date": self.date.isoformat() if isinstance(self.date, date) else self.date, + "user_id": self.user_id, + "total_requests": self.total_requests, + "total_tokens": self.total_tokens, + "total_input_tokens": self.total_input_tokens, + "total_output_tokens": self.total_output_tokens, + "estimated_cost": self.estimated_cost, + "requests_by_task": self.requests_by_task, + } + + +@dataclass +class MonthlyUsageSummary: + """ + Summary of AI usage for a specific month. + + Used for billing and cost projections. + + Attributes: + year: Year + month: Month (1-12) + user_id: User ID + total_requests: Number of AI requests made + total_tokens: Total tokens consumed + estimated_cost: Total estimated cost in USD + daily_breakdown: List of daily summaries + """ + + year: int + month: int + user_id: str + total_requests: int + total_tokens: int + estimated_cost: float + daily_breakdown: list = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert summary to dictionary.""" + return { + "year": self.year, + "month": self.month, + "user_id": self.user_id, + "total_requests": self.total_requests, + "total_tokens": self.total_tokens, + "estimated_cost": self.estimated_cost, + "daily_breakdown": self.daily_breakdown, + } diff --git a/api/app/models/character.py b/api/app/models/character.py new file mode 100644 index 0000000..e69c883 --- /dev/null +++ b/api/app/models/character.py @@ -0,0 +1,452 @@ +""" +Character data model - the core entity for player characters. + +This module defines the Character dataclass which represents a player's character +with all their stats, inventory, progression, and the critical get_effective_stats() +method that calculates final stats from all sources. +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional + +from app.models.stats import Stats +from app.models.items import Item +from app.models.skills import PlayerClass, SkillNode +from app.models.effects import Effect +from app.models.enums import EffectType, StatType +from app.models.origins import Origin + + +@dataclass +class Character: + """ + Represents a player's character. + + This is the central data model that ties together all character-related data: + stats, class, inventory, progression, and quests. + + The critical method is get_effective_stats() which calculates the final stats + by combining base stats + equipment bonuses + skill bonuses + active effects. + + Attributes: + character_id: Unique identifier + user_id: Owner's user ID (from Appwrite auth) + name: Character name + player_class: Character's class (determines base stats and skill trees) + origin: Character's backstory origin (saved for AI DM narrative hooks) + level: Current level + experience: Current XP points + base_stats: Base stats (from class + level-ups) + unlocked_skills: List of skill_ids that have been unlocked + inventory: All items the character owns + equipped: Currently equipped items by slot + Slots: "weapon", "armor", "helmet", "boots", "accessory", etc. + gold: Currency amount + active_quests: List of quest IDs currently in progress + discovered_locations: List of location IDs the character has visited + current_location: Current location ID (tracks character position) + """ + + character_id: str + user_id: str + name: str + player_class: PlayerClass + origin: Origin + level: int = 1 + experience: int = 0 + + # Stats and progression + base_stats: Stats = field(default_factory=Stats) + unlocked_skills: List[str] = field(default_factory=list) + + # Inventory and equipment + inventory: List[Item] = field(default_factory=list) + equipped: Dict[str, Item] = field(default_factory=dict) + gold: int = 0 + + # Quests and exploration + active_quests: List[str] = field(default_factory=list) + discovered_locations: List[str] = field(default_factory=list) + current_location: Optional[str] = None # Set to origin starting location on creation + + # NPC interaction tracking (persists across sessions) + # Each entry: {npc_id: {interaction_count, relationship_level, dialogue_history, ...}} + # dialogue_history: List[{player_line: str, npc_response: str}] + npc_interactions: Dict[str, Dict] = field(default_factory=dict) + + def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats: + """ + Calculate final effective stats from all sources. + + This is the CRITICAL METHOD that combines: + 1. Base stats (from character) + 2. Equipment bonuses (from equipped items) + 3. Skill tree bonuses (from unlocked skills) + 4. Active effect modifiers (buffs/debuffs) + + Args: + active_effects: Currently active effects on this character (from combat) + + Returns: + Stats instance with all modifiers applied + """ + # Start with a copy of base stats + effective = self.base_stats.copy() + + # Apply equipment bonuses + for item in self.equipped.values(): + for stat_name, bonus in item.stat_bonuses.items(): + if hasattr(effective, stat_name): + current_value = getattr(effective, stat_name) + setattr(effective, stat_name, current_value + bonus) + + # Apply skill tree bonuses + skill_bonuses = self._get_skill_bonuses() + for stat_name, bonus in skill_bonuses.items(): + if hasattr(effective, stat_name): + current_value = getattr(effective, stat_name) + setattr(effective, stat_name, current_value + bonus) + + # Apply active effect modifiers (buffs/debuffs) + if active_effects: + for effect in active_effects: + if effect.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: + if effect.stat_affected: + stat_name = effect.stat_affected.value + if hasattr(effective, stat_name): + current_value = getattr(effective, stat_name) + modifier = effect.power * effect.stacks + + if effect.effect_type == EffectType.BUFF: + setattr(effective, stat_name, current_value + modifier) + else: # DEBUFF + # Stats can't go below 1 + setattr(effective, stat_name, max(1, current_value - modifier)) + + return effective + + def _get_skill_bonuses(self) -> Dict[str, int]: + """ + Calculate total stat bonuses from unlocked skills. + + Returns: + Dictionary of stat bonuses from skill tree + """ + bonuses: Dict[str, int] = {} + + # Get all skill nodes from all trees + all_skills = self.player_class.get_all_skills() + + # Sum up bonuses from unlocked skills + for skill in all_skills: + if skill.skill_id in self.unlocked_skills: + skill_bonuses = skill.get_stat_bonuses() + for stat_name, bonus in skill_bonuses.items(): + bonuses[stat_name] = bonuses.get(stat_name, 0) + bonus + + return bonuses + + def get_unlocked_abilities(self) -> List[str]: + """ + Get all ability IDs unlocked by this character's skills. + + Returns: + List of ability_ids from skill tree + class starting abilities + """ + abilities = list(self.player_class.starting_abilities) + + # Get all skill nodes from all trees + all_skills = self.player_class.get_all_skills() + + # Collect abilities from unlocked skills + for skill in all_skills: + if skill.skill_id in self.unlocked_skills: + abilities.extend(skill.get_unlocked_abilities()) + + return abilities + + @property + def class_id(self) -> str: + """Get class ID for template access.""" + return self.player_class.class_id + + @property + def origin_id(self) -> str: + """Get origin ID for template access.""" + return self.origin.id + + @property + def origin_name(self) -> str: + """Get origin display name for template access.""" + return self.origin.name + + @property + def available_skill_points(self) -> int: + """Calculate available skill points (1 per level minus unlocked skills).""" + return self.level - len(self.unlocked_skills) + + @property + def max_hp(self) -> int: + """ + Calculate max HP from constitution. + Uses the Stats.hit_points property which calculates: 10 + (constitution * 2) + """ + effective_stats = self.get_effective_stats() + return effective_stats.hit_points + + @property + def current_hp(self) -> int: + """ + Get current HP. + Outside of combat, characters are at full health. + During combat, this would be tracked separately in the combat state. + """ + # For now, always return max HP (full health outside combat) + # TODO: Track combat damage separately when implementing combat system + return self.max_hp + + def can_afford(self, cost: int) -> bool: + """Check if character has enough gold.""" + return self.gold >= cost + + def add_gold(self, amount: int) -> None: + """Add gold to character's wallet.""" + self.gold += amount + + def remove_gold(self, amount: int) -> bool: + """ + Remove gold from character's wallet. + + Returns: + True if successful, False if insufficient gold + """ + if not self.can_afford(amount): + return False + self.gold -= amount + return True + + def add_item(self, item: Item) -> None: + """Add an item to character's inventory.""" + self.inventory.append(item) + + def remove_item(self, item_id: str) -> Optional[Item]: + """ + Remove an item from inventory by ID. + + Returns: + The removed Item or None if not found + """ + for i, item in enumerate(self.inventory): + if item.item_id == item_id: + return self.inventory.pop(i) + return None + + def equip_item(self, item: Item, slot: str) -> Optional[Item]: + """ + Equip an item to a specific slot. + + Args: + item: Item to equip + slot: Equipment slot ("weapon", "armor", etc.) + + Returns: + Previously equipped item in that slot (or None) + """ + # Remove from inventory + self.remove_item(item.item_id) + + # Unequip current item in slot if present + previous = self.equipped.get(slot) + if previous: + self.add_item(previous) + + # Equip new item + self.equipped[slot] = item + return previous + + def unequip_item(self, slot: str) -> Optional[Item]: + """ + Unequip an item from a slot. + + Args: + slot: Equipment slot to unequip from + + Returns: + The unequipped Item or None if slot was empty + """ + if slot not in self.equipped: + return None + + item = self.equipped.pop(slot) + self.add_item(item) + return item + + def add_experience(self, xp: int) -> bool: + """ + Add experience points and check for level up. + + Args: + xp: Amount of experience to add + + Returns: + True if character leveled up, False otherwise + """ + self.experience += xp + required_xp = self._calculate_xp_for_next_level() + + if self.experience >= required_xp: + self.level_up() + return True + + return False + + def level_up(self) -> None: + """ + Level up the character. + + - Increases level + - Resets experience to overflow amount + - Could grant stat increases (future enhancement) + """ + required_xp = self._calculate_xp_for_next_level() + overflow_xp = self.experience - required_xp + + self.level += 1 + self.experience = overflow_xp + + # Future: Apply stat increases based on class + # For now, stats are increased manually via skill points + + def _calculate_xp_for_next_level(self) -> int: + """ + Calculate XP required for next level. + + Formula: 100 * (level ^ 1.5) + This creates an exponential curve: 100, 282, 519, 800, 1118... + + Returns: + XP required for next level + """ + return int(100 * (self.level ** 1.5)) + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize character to dictionary for JSON storage. + + Returns: + Dictionary containing all character data + """ + return { + "character_id": self.character_id, + "user_id": self.user_id, + "name": self.name, + "player_class": self.player_class.to_dict(), + "origin": self.origin.to_dict(), + "level": self.level, + "experience": self.experience, + "base_stats": self.base_stats.to_dict(), + "unlocked_skills": self.unlocked_skills, + "inventory": [item.to_dict() for item in self.inventory], + "equipped": {slot: item.to_dict() for slot, item in self.equipped.items()}, + "gold": self.gold, + "active_quests": self.active_quests, + "discovered_locations": self.discovered_locations, + "current_location": self.current_location, + "npc_interactions": self.npc_interactions, + # Computed properties for AI templates + "current_hp": self.current_hp, + "max_hp": self.max_hp, + } + + def to_story_dict(self) -> Dict[str, Any]: + """ + Serialize only story-relevant character data for AI prompts. + + This trimmed version reduces token usage by excluding mechanical + details that aren't needed for narrative generation (IDs, full + inventory details, skill trees, etc.). + + Returns: + Dictionary containing story-relevant character data + """ + effective_stats = self.get_effective_stats() + + # Get equipped item names for context (not full details) + equipped_summary = {} + for slot, item in self.equipped.items(): + equipped_summary[slot] = item.name + + # Get skill names from unlocked skills + skill_names = [] + all_skills = self.player_class.get_all_skills() + for skill in all_skills: + if skill.skill_id in self.unlocked_skills: + skill_names.append({ + "name": skill.name, + "level": 1 # Skills don't have levels, but template expects this + }) + + return { + "name": self.name, + "level": self.level, + "player_class": self.player_class.name, + "origin_name": self.origin.name, + "current_hp": self.current_hp, + "max_hp": self.max_hp, + "gold": self.gold, + # Stats for display and checks + "stats": effective_stats.to_dict(), + "base_stats": self.base_stats.to_dict(), + # Simplified collections + "skills": skill_names, + "equipped": equipped_summary, + "inventory_count": len(self.inventory), + "active_quests_count": len(self.active_quests), + # Empty list for templates that check completed_quests + "effects": [], # Active effects passed separately in combat + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Character': + """ + Deserialize character from dictionary. + + Args: + data: Dictionary containing character data + + Returns: + Character instance + """ + from app.models.skills import PlayerClass + + player_class = PlayerClass.from_dict(data["player_class"]) + origin = Origin.from_dict(data["origin"]) + base_stats = Stats.from_dict(data["base_stats"]) + inventory = [Item.from_dict(item) for item in data.get("inventory", [])] + equipped = {slot: Item.from_dict(item) for slot, item in data.get("equipped", {}).items()} + + return cls( + character_id=data["character_id"], + user_id=data["user_id"], + name=data["name"], + player_class=player_class, + origin=origin, + level=data.get("level", 1), + experience=data.get("experience", 0), + base_stats=base_stats, + unlocked_skills=data.get("unlocked_skills", []), + inventory=inventory, + equipped=equipped, + gold=data.get("gold", 0), + active_quests=data.get("active_quests", []), + discovered_locations=data.get("discovered_locations", []), + current_location=data.get("current_location"), + npc_interactions=data.get("npc_interactions", {}), + ) + + def __repr__(self) -> str: + """String representation of the character.""" + return ( + f"Character({self.name}, {self.player_class.name}, " + f"Lv{self.level}, {self.gold}g)" + ) diff --git a/api/app/models/combat.py b/api/app/models/combat.py new file mode 100644 index 0000000..11e20c7 --- /dev/null +++ b/api/app/models/combat.py @@ -0,0 +1,414 @@ +""" +Combat system data models. + +This module defines the combat-related dataclasses including Combatant (a wrapper +for characters/enemies in combat) and CombatEncounter (the combat state manager). +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional +import random + +from app.models.stats import Stats +from app.models.effects import Effect +from app.models.abilities import Ability +from app.models.enums import CombatStatus, EffectType + + +@dataclass +class Combatant: + """ + Represents a character or enemy in combat. + + This wraps either a player Character or an NPC/enemy for combat purposes, + tracking combat-specific state like current HP/MP, active effects, and cooldowns. + + Attributes: + combatant_id: Unique identifier (character_id or enemy_id) + name: Display name + is_player: True if player character, False if NPC/enemy + current_hp: Current hit points + max_hp: Maximum hit points + current_mp: Current mana points + max_mp: Maximum mana points + stats: Current combat stats (use get_effective_stats() from Character) + active_effects: Effects currently applied to this combatant + abilities: Available abilities for this combatant + cooldowns: Map of ability_id to turns remaining + initiative: Turn order value (rolled at combat start) + """ + + combatant_id: str + name: str + is_player: bool + current_hp: int + max_hp: int + current_mp: int + max_mp: int + stats: Stats + active_effects: List[Effect] = field(default_factory=list) + abilities: List[str] = field(default_factory=list) # ability_ids + cooldowns: Dict[str, int] = field(default_factory=dict) + initiative: int = 0 + + def is_alive(self) -> bool: + """Check if combatant is still alive.""" + return self.current_hp > 0 + + def is_dead(self) -> bool: + """Check if combatant is dead.""" + return self.current_hp <= 0 + + def is_stunned(self) -> bool: + """Check if combatant is stunned and cannot act.""" + return any(e.effect_type == EffectType.STUN for e in self.active_effects) + + def take_damage(self, damage: int) -> int: + """ + Apply damage to this combatant. + + Damage is reduced by shields first, then HP. + + Args: + damage: Amount of damage to apply + + Returns: + Actual damage dealt to HP (after shields) + """ + remaining_damage = damage + + # Apply shield absorption + for effect in self.active_effects: + if effect.effect_type == EffectType.SHIELD and remaining_damage > 0: + remaining_damage = effect.reduce_shield(remaining_damage) + + # Apply remaining damage to HP + hp_damage = min(remaining_damage, self.current_hp) + self.current_hp -= hp_damage + + return hp_damage + + def heal(self, amount: int) -> int: + """ + Heal this combatant. + + Args: + amount: Amount to heal + + Returns: + Actual amount healed (capped at max_hp) + """ + old_hp = self.current_hp + self.current_hp = min(self.max_hp, self.current_hp + amount) + return self.current_hp - old_hp + + def restore_mana(self, amount: int) -> int: + """ + Restore mana to this combatant. + + Args: + amount: Amount to restore + + Returns: + Actual amount restored (capped at max_mp) + """ + old_mp = self.current_mp + self.current_mp = min(self.max_mp, self.current_mp + amount) + return self.current_mp - old_mp + + def can_use_ability(self, ability_id: str, ability: Ability) -> bool: + """ + Check if ability can be used right now. + + Args: + ability_id: Ability identifier + ability: Ability instance + + Returns: + True if ability can be used, False otherwise + """ + # Check if ability is available to this combatant + if ability_id not in self.abilities: + return False + + # Check mana cost + if self.current_mp < ability.mana_cost: + return False + + # Check cooldown + if ability_id in self.cooldowns and self.cooldowns[ability_id] > 0: + return False + + return True + + def use_ability_cost(self, ability: Ability, ability_id: str) -> None: + """ + Apply the costs of using an ability (mana, cooldown). + + Args: + ability: Ability being used + ability_id: Ability identifier + """ + # Consume mana + self.current_mp -= ability.mana_cost + + # Set cooldown + if ability.cooldown > 0: + self.cooldowns[ability_id] = ability.cooldown + + def tick_effects(self) -> List[Dict[str, Any]]: + """ + Process all active effects for this turn. + + Returns: + List of effect tick results + """ + results = [] + expired_effects = [] + + for effect in self.active_effects: + result = effect.tick() + + # Apply effect results + if effect.effect_type == EffectType.DOT: + self.take_damage(result["value"]) + elif effect.effect_type == EffectType.HOT: + self.heal(result["value"]) + + results.append(result) + + # Mark expired effects for removal + if result.get("expired", False): + expired_effects.append(effect) + + # Remove expired effects + for effect in expired_effects: + self.active_effects.remove(effect) + + return results + + def tick_cooldowns(self) -> None: + """Reduce all ability cooldowns by 1 turn.""" + for ability_id in list(self.cooldowns.keys()): + self.cooldowns[ability_id] -= 1 + if self.cooldowns[ability_id] <= 0: + del self.cooldowns[ability_id] + + def add_effect(self, effect: Effect) -> None: + """ + Add an effect to this combatant. + + If the same effect already exists, stack it instead. + + Args: + effect: Effect to add + """ + # Check if effect already exists + for existing in self.active_effects: + if existing.name == effect.name and existing.effect_type == effect.effect_type: + # Stack the effect + existing.apply_stack(effect.duration) + return + + # New effect, add it + self.active_effects.append(effect) + + def to_dict(self) -> Dict[str, Any]: + """Serialize combatant to dictionary.""" + return { + "combatant_id": self.combatant_id, + "name": self.name, + "is_player": self.is_player, + "current_hp": self.current_hp, + "max_hp": self.max_hp, + "current_mp": self.current_mp, + "max_mp": self.max_mp, + "stats": self.stats.to_dict(), + "active_effects": [e.to_dict() for e in self.active_effects], + "abilities": self.abilities, + "cooldowns": self.cooldowns, + "initiative": self.initiative, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Combatant': + """Deserialize combatant from dictionary.""" + stats = Stats.from_dict(data["stats"]) + active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])] + + return cls( + combatant_id=data["combatant_id"], + name=data["name"], + is_player=data["is_player"], + current_hp=data["current_hp"], + max_hp=data["max_hp"], + current_mp=data["current_mp"], + max_mp=data["max_mp"], + stats=stats, + active_effects=active_effects, + abilities=data.get("abilities", []), + cooldowns=data.get("cooldowns", {}), + initiative=data.get("initiative", 0), + ) + + +@dataclass +class CombatEncounter: + """ + Represents a combat encounter state. + + Manages turn order, combatants, combat log, and victory/defeat conditions. + + Attributes: + encounter_id: Unique identifier + combatants: All fighters in this combat + turn_order: Combatant IDs sorted by initiative (highest first) + current_turn_index: Index in turn_order for current turn + round_number: Current round (increments each full turn cycle) + combat_log: History of all actions taken + status: Current combat status (active, victory, defeat, fled) + """ + + encounter_id: str + combatants: List[Combatant] = field(default_factory=list) + turn_order: List[str] = field(default_factory=list) + current_turn_index: int = 0 + round_number: int = 1 + combat_log: List[Dict[str, Any]] = field(default_factory=list) + status: CombatStatus = CombatStatus.ACTIVE + + def initialize_combat(self) -> None: + """ + Initialize combat by rolling initiative and setting turn order. + + Initiative: d20 + dexterity bonus + """ + # Roll initiative for all combatants + for combatant in self.combatants: + # d20 + dexterity bonus + roll = random.randint(1, 20) + dex_bonus = combatant.stats.dexterity // 2 + combatant.initiative = roll + dex_bonus + + # Sort combatants by initiative (highest first) + sorted_combatants = sorted(self.combatants, key=lambda c: c.initiative, reverse=True) + self.turn_order = [c.combatant_id for c in sorted_combatants] + + self.log_action("combat_start", None, f"Combat begins! Round {self.round_number}") + + def get_current_combatant(self) -> Optional[Combatant]: + """Get the combatant whose turn it currently is.""" + if not self.turn_order: + return None + + current_id = self.turn_order[self.current_turn_index] + return self.get_combatant(current_id) + + def get_combatant(self, combatant_id: str) -> Optional[Combatant]: + """Get a combatant by ID.""" + for combatant in self.combatants: + if combatant.combatant_id == combatant_id: + return combatant + return None + + def advance_turn(self) -> None: + """Advance to the next combatant's turn.""" + self.current_turn_index += 1 + + # If we've cycled through all combatants, start a new round + if self.current_turn_index >= len(self.turn_order): + self.current_turn_index = 0 + self.round_number += 1 + self.log_action("round_start", None, f"Round {self.round_number} begins") + + def start_turn(self) -> List[Dict[str, Any]]: + """ + Process the start of a turn. + + - Tick all effects on current combatant + - Tick cooldowns + - Check for stun + + Returns: + List of effect tick results + """ + combatant = self.get_current_combatant() + if not combatant: + return [] + + # Process effects + effect_results = combatant.tick_effects() + + # Reduce cooldowns + combatant.tick_cooldowns() + + return effect_results + + def check_end_condition(self) -> CombatStatus: + """ + Check if combat should end. + + Victory: All enemy combatants dead + Defeat: All player combatants dead + + Returns: + Updated combat status + """ + players_alive = any(c.is_alive() and c.is_player for c in self.combatants) + enemies_alive = any(c.is_alive() and not c.is_player for c in self.combatants) + + if not enemies_alive and players_alive: + self.status = CombatStatus.VICTORY + self.log_action("combat_end", None, "Victory! All enemies defeated!") + elif not players_alive: + self.status = CombatStatus.DEFEAT + self.log_action("combat_end", None, "Defeat! All players have fallen!") + + return self.status + + def log_action(self, action_type: str, combatant_id: Optional[str], message: str, details: Optional[Dict[str, Any]] = None) -> None: + """ + Log a combat action. + + Args: + action_type: Type of action (attack, spell, item_use, etc.) + combatant_id: ID of acting combatant (or None for system messages) + message: Human-readable message + details: Additional action details + """ + entry = { + "round": self.round_number, + "action_type": action_type, + "combatant_id": combatant_id, + "message": message, + "details": details or {}, + } + self.combat_log.append(entry) + + def to_dict(self) -> Dict[str, Any]: + """Serialize combat encounter to dictionary.""" + return { + "encounter_id": self.encounter_id, + "combatants": [c.to_dict() for c in self.combatants], + "turn_order": self.turn_order, + "current_turn_index": self.current_turn_index, + "round_number": self.round_number, + "combat_log": self.combat_log, + "status": self.status.value, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'CombatEncounter': + """Deserialize combat encounter from dictionary.""" + combatants = [Combatant.from_dict(c) for c in data.get("combatants", [])] + status = CombatStatus(data.get("status", "active")) + + return cls( + encounter_id=data["encounter_id"], + combatants=combatants, + turn_order=data.get("turn_order", []), + current_turn_index=data.get("current_turn_index", 0), + round_number=data.get("round_number", 1), + combat_log=data.get("combat_log", []), + status=status, + ) diff --git a/api/app/models/effects.py b/api/app/models/effects.py new file mode 100644 index 0000000..5347856 --- /dev/null +++ b/api/app/models/effects.py @@ -0,0 +1,208 @@ +""" +Effect system for temporary status modifiers in combat. + +This module defines the Effect dataclass which represents temporary buffs, +debuffs, damage over time, healing over time, stuns, and shields. +""" + +from dataclasses import dataclass, asdict +from typing import Dict, Any, Optional +from app.models.enums import EffectType, StatType + + +@dataclass +class Effect: + """ + Represents a temporary effect applied to a combatant. + + Effects are processed at the start of each turn via the tick() method. + They can stack up to max_stacks, and duration refreshes on re-application. + + Attributes: + effect_id: Unique identifier for this effect instance + name: Display name of the effect + effect_type: Type of effect (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD) + duration: Turns remaining before effect expires + power: Damage/healing per turn OR stat modifier amount + stat_affected: Which stat is modified (for BUFF/DEBUFF only) + stacks: Number of times this effect has been stacked + max_stacks: Maximum number of stacks allowed (default 5) + source: Who/what applied this effect (character_id or ability_id) + """ + + effect_id: str + name: str + effect_type: EffectType + duration: int + power: int + stat_affected: Optional[StatType] = None + stacks: int = 1 + max_stacks: int = 5 + source: str = "" + + def tick(self) -> Dict[str, Any]: + """ + Process one turn of this effect. + + Returns a dictionary describing what happened this turn, including: + - effect_name: Name of the effect + - effect_type: Type of effect + - value: Damage dealt (DOT) or healing done (HOT) + - shield_remaining: Current shield strength (SHIELD only) + - stunned: True if this is a stun effect (STUN only) + - stat_modifier: Amount stats are modified (BUFF/DEBUFF only) + - expired: True if effect duration reached 0 + + Returns: + Dictionary with effect processing results + """ + result = { + "effect_name": self.name, + "effect_type": self.effect_type.value, + "value": 0, + "expired": False, + } + + # Process effect based on type + if self.effect_type == EffectType.DOT: + # Damage over time: deal damage equal to power × stacks + result["value"] = self.power * self.stacks + result["message"] = f"{self.name} deals {result['value']} damage" + + elif self.effect_type == EffectType.HOT: + # Heal over time: heal equal to power × stacks + result["value"] = self.power * self.stacks + result["message"] = f"{self.name} heals {result['value']} HP" + + elif self.effect_type == EffectType.STUN: + # Stun: prevents actions this turn + result["stunned"] = True + result["message"] = f"{self.name} prevents actions" + + elif self.effect_type == EffectType.SHIELD: + # Shield: absorbs damage (power × stacks = shield strength) + result["shield_remaining"] = self.power * self.stacks + result["message"] = f"{self.name} absorbs up to {result['shield_remaining']} damage" + + elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: + # Buff/Debuff: modify stats + result["stat_affected"] = self.stat_affected.value if self.stat_affected else None + result["stat_modifier"] = self.power * self.stacks + if self.effect_type == EffectType.BUFF: + result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}" + else: + result["message"] = f"{self.name} decreases {result['stat_affected']} by {result['stat_modifier']}" + + # Decrease duration + self.duration -= 1 + if self.duration <= 0: + result["expired"] = True + result["message"] = f"{self.name} has expired" + + return result + + def apply_stack(self, additional_duration: int = 0) -> None: + """ + Apply an additional stack of this effect. + + Increases stack count (up to max_stacks) and refreshes duration. + If additional_duration is provided, it's added to current duration. + + Args: + additional_duration: Extra turns to add (default 0 = refresh only) + """ + if self.stacks < self.max_stacks: + self.stacks += 1 + + # Refresh duration or extend it + if additional_duration > 0: + self.duration = max(self.duration, additional_duration) + else: + # Find the base duration (current + turns already consumed) + # For refresh behavior, we'd need to store original_duration + # For now, just use the provided duration + pass + + def reduce_shield(self, damage: int) -> int: + """ + Reduce shield strength by damage amount. + + Only applicable for SHIELD effects. Returns remaining damage after shield. + + Args: + damage: Amount of damage to absorb + + Returns: + Remaining damage after shield absorption + """ + if self.effect_type != EffectType.SHIELD: + return damage + + shield_strength = self.power * self.stacks + if damage >= shield_strength: + # Shield breaks completely + remaining_damage = damage - shield_strength + self.power = 0 # Shield depleted + self.duration = 0 # Effect expires + return remaining_damage + else: + # Shield partially absorbs damage + damage_per_stack = damage / self.stacks + self.power = max(0, int(self.power - damage_per_stack)) + return 0 + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize effect to a dictionary. + + Returns: + Dictionary containing all effect data + """ + data = asdict(self) + data["effect_type"] = self.effect_type.value + if self.stat_affected: + data["stat_affected"] = self.stat_affected.value + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Effect': + """ + Deserialize effect from a dictionary. + + Args: + data: Dictionary containing effect data + + Returns: + Effect instance + """ + # Convert string values back to enums + effect_type = EffectType(data["effect_type"]) + stat_affected = StatType(data["stat_affected"]) if data.get("stat_affected") else None + + return cls( + effect_id=data["effect_id"], + name=data["name"], + effect_type=effect_type, + duration=data["duration"], + power=data["power"], + stat_affected=stat_affected, + stacks=data.get("stacks", 1), + max_stacks=data.get("max_stacks", 5), + source=data.get("source", ""), + ) + + def __repr__(self) -> str: + """String representation of the effect.""" + if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: + return ( + f"Effect({self.name}, {self.effect_type.value}, " + f"{self.stat_affected.value if self.stat_affected else 'N/A'} " + f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, " + f"{self.duration}t, {self.stacks}x)" + ) + else: + return ( + f"Effect({self.name}, {self.effect_type.value}, " + f"power={self.power * self.stacks}, " + f"duration={self.duration}t, stacks={self.stacks}x)" + ) diff --git a/api/app/models/enums.py b/api/app/models/enums.py new file mode 100644 index 0000000..206e85c --- /dev/null +++ b/api/app/models/enums.py @@ -0,0 +1,113 @@ +""" +Enumeration types for the Code of Conquest game system. + +This module defines all enum types used throughout the data models to ensure +type safety and prevent invalid values. +""" + +from enum import Enum + + +class EffectType(Enum): + """Types of effects that can be applied to combatants.""" + + BUFF = "buff" # Temporarily increase stats + DEBUFF = "debuff" # Temporarily decrease stats + DOT = "dot" # Damage over time (poison, bleed, burn) + HOT = "hot" # Heal over time (regeneration) + STUN = "stun" # Prevent actions (skip turn) + SHIELD = "shield" # Absorb damage before HP loss + + +class DamageType(Enum): + """Types of damage that can be dealt in combat.""" + + PHYSICAL = "physical" # Standard weapon damage + FIRE = "fire" # Fire-based magic damage + ICE = "ice" # Ice-based magic damage + LIGHTNING = "lightning" # Lightning-based magic damage + HOLY = "holy" # Holy/divine damage + SHADOW = "shadow" # Dark/shadow magic damage + POISON = "poison" # Poison damage (usually DoT) + + +class ItemType(Enum): + """Categories of items in the game.""" + + WEAPON = "weapon" # Adds damage, may have special effects + ARMOR = "armor" # Adds defense/resistance + CONSUMABLE = "consumable" # One-time use (potions, scrolls) + QUEST_ITEM = "quest_item" # Story-related, non-tradeable + + +class StatType(Enum): + """Character attribute types.""" + + STRENGTH = "strength" # Physical power + DEXTERITY = "dexterity" # Agility and precision + CONSTITUTION = "constitution" # Endurance and health + INTELLIGENCE = "intelligence" # Magical power + WISDOM = "wisdom" # Perception and insight + CHARISMA = "charisma" # Social influence + + +class AbilityType(Enum): + """Categories of abilities that can be used in combat or exploration.""" + + ATTACK = "attack" # Basic physical attack + SPELL = "spell" # Magical spell + SKILL = "skill" # Special class ability + ITEM_USE = "item_use" # Using a consumable item + DEFEND = "defend" # Defensive action + + +class CombatStatus(Enum): + """Status of a combat encounter.""" + + ACTIVE = "active" # Combat is ongoing + VICTORY = "victory" # Player(s) won + DEFEAT = "defeat" # Player(s) lost + FLED = "fled" # Player(s) escaped + + +class SessionStatus(Enum): + """Status of a game session.""" + + ACTIVE = "active" # Session is ongoing + COMPLETED = "completed" # Session ended normally + TIMEOUT = "timeout" # Session ended due to inactivity + + +class ListingStatus(Enum): + """Status of a marketplace listing.""" + + ACTIVE = "active" # Listing is live + SOLD = "sold" # Item has been sold + EXPIRED = "expired" # Listing time ran out + REMOVED = "removed" # Seller cancelled listing + + +class ListingType(Enum): + """Type of marketplace listing.""" + + AUCTION = "auction" # Bidding system + FIXED_PRICE = "fixed_price" # Immediate purchase at set price + + +class SessionType(Enum): + """Type of game session.""" + + SOLO = "solo" # Single-player session + MULTIPLAYER = "multiplayer" # Multi-player party session + + +class LocationType(Enum): + """Types of locations in the game world.""" + + TOWN = "town" # Town or city + TAVERN = "tavern" # Tavern or inn + WILDERNESS = "wilderness" # Outdoor wilderness areas + DUNGEON = "dungeon" # Underground dungeons/caves + RUINS = "ruins" # Ancient ruins + LIBRARY = "library" # Library or archive + SAFE_AREA = "safe_area" # Safe rest areas diff --git a/api/app/models/items.py b/api/app/models/items.py new file mode 100644 index 0000000..10a9bce --- /dev/null +++ b/api/app/models/items.py @@ -0,0 +1,196 @@ +""" +Item system for equipment, consumables, and quest items. + +This module defines the Item dataclass representing all types of items in the game, +including weapons, armor, consumables, and quest items. +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional + +from app.models.enums import ItemType, DamageType +from app.models.effects import Effect + + +@dataclass +class Item: + """ + Represents an item in the game (weapon, armor, consumable, or quest item). + + Items can provide passive stat bonuses when equipped, have weapon/armor stats, + or provide effects when consumed. + + Attributes: + item_id: Unique identifier + name: Display name + item_type: Category (weapon, armor, consumable, quest_item) + description: Item lore and information + value: Gold value for buying/selling + is_tradeable: Whether item can be sold on marketplace + stat_bonuses: Passive bonuses to stats when equipped + Example: {"strength": 5, "constitution": 3} + effects_on_use: Effects applied when consumed (consumables only) + + Weapon-specific attributes: + damage: Base weapon damage + damage_type: Type of damage (physical, fire, etc.) + crit_chance: Probability of critical hit (0.0 to 1.0) + crit_multiplier: Damage multiplier on critical hit + + Armor-specific attributes: + defense: Physical defense bonus + resistance: Magical resistance bonus + + Requirements (future): + required_level: Minimum character level to use + required_class: Class restriction (if any) + """ + + item_id: str + name: str + item_type: ItemType + description: str + value: int = 0 + is_tradeable: bool = True + + # Passive bonuses (equipment) + stat_bonuses: Dict[str, int] = field(default_factory=dict) + + # Active effects (consumables) + effects_on_use: List[Effect] = field(default_factory=list) + + # Weapon-specific + damage: int = 0 + damage_type: Optional[DamageType] = None + crit_chance: float = 0.05 # 5% default critical hit chance + crit_multiplier: float = 2.0 # 2x damage on critical hit + + # Armor-specific + defense: int = 0 + resistance: int = 0 + + # Requirements (future expansion) + required_level: int = 1 + required_class: Optional[str] = None + + def is_weapon(self) -> bool: + """Check if this item is a weapon.""" + return self.item_type == ItemType.WEAPON + + def is_armor(self) -> bool: + """Check if this item is armor.""" + return self.item_type == ItemType.ARMOR + + def is_consumable(self) -> bool: + """Check if this item is a consumable.""" + return self.item_type == ItemType.CONSUMABLE + + def is_quest_item(self) -> bool: + """Check if this item is a quest item.""" + return self.item_type == ItemType.QUEST_ITEM + + def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool: + """ + Check if a character can equip this item. + + Args: + character_level: Character's current level + character_class: Character's class (if class restrictions exist) + + Returns: + True if item can be equipped, False otherwise + """ + # Check level requirement + if character_level < self.required_level: + return False + + # Check class requirement + if self.required_class and character_class != self.required_class: + return False + + return True + + def get_total_stat_bonus(self, stat_name: str) -> int: + """ + Get the total bonus for a specific stat from this item. + + Args: + stat_name: Name of the stat (e.g., "strength", "intelligence") + + Returns: + Bonus value for that stat (0 if not present) + """ + return self.stat_bonuses.get(stat_name, 0) + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize item to a dictionary. + + Returns: + Dictionary containing all item data + """ + data = asdict(self) + data["item_type"] = self.item_type.value + if self.damage_type: + data["damage_type"] = self.damage_type.value + data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use] + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Item': + """ + Deserialize item from a dictionary. + + Args: + data: Dictionary containing item data + + Returns: + Item instance + """ + # Convert string values back to enums + item_type = ItemType(data["item_type"]) + damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None + + # Deserialize effects + effects = [] + if "effects_on_use" in data and data["effects_on_use"]: + effects = [Effect.from_dict(e) for e in data["effects_on_use"]] + + return cls( + item_id=data["item_id"], + name=data["name"], + item_type=item_type, + description=data["description"], + value=data.get("value", 0), + is_tradeable=data.get("is_tradeable", True), + stat_bonuses=data.get("stat_bonuses", {}), + effects_on_use=effects, + damage=data.get("damage", 0), + damage_type=damage_type, + crit_chance=data.get("crit_chance", 0.05), + crit_multiplier=data.get("crit_multiplier", 2.0), + defense=data.get("defense", 0), + resistance=data.get("resistance", 0), + required_level=data.get("required_level", 1), + required_class=data.get("required_class"), + ) + + def __repr__(self) -> str: + """String representation of the item.""" + if self.is_weapon(): + return ( + f"Item({self.name}, weapon, dmg={self.damage}, " + f"crit={self.crit_chance*100:.0f}%, value={self.value}g)" + ) + elif self.is_armor(): + return ( + f"Item({self.name}, armor, def={self.defense}, " + f"res={self.resistance}, value={self.value}g)" + ) + elif self.is_consumable(): + return ( + f"Item({self.name}, consumable, " + f"effects={len(self.effects_on_use)}, value={self.value}g)" + ) + else: + return f"Item({self.name}, quest_item)" diff --git a/api/app/models/location.py b/api/app/models/location.py new file mode 100644 index 0000000..4ac4020 --- /dev/null +++ b/api/app/models/location.py @@ -0,0 +1,181 @@ +""" +Location data models for the world exploration system. + +This module defines Location and Region dataclasses that represent structured +game world data. Locations are loaded from YAML files and provide rich context +for AI narrative generation. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Any, Optional + +from app.models.enums import LocationType + + +@dataclass +class Location: + """ + Represents a defined location in the game world. + + Locations are persistent world entities with NPCs, quests, and connections + to other locations. They are loaded from YAML files at runtime. + + Attributes: + location_id: Unique identifier (e.g., "crossville_tavern") + name: Display name (e.g., "The Rusty Anchor Tavern") + location_type: Type of location (town, tavern, wilderness, dungeon, etc.) + region_id: Parent region this location belongs to + description: Full description for AI narrative context + lore: Optional historical/background information + ambient_description: Atmospheric details for AI narration + available_quests: Quest IDs that can be discovered at this location + npc_ids: List of NPC IDs present at this location + discoverable_locations: Location IDs that can be revealed from here + is_starting_location: Whether this is a valid origin starting point + tags: Additional metadata tags for filtering/categorization + """ + + location_id: str + name: str + location_type: LocationType + region_id: str + description: str + lore: Optional[str] = None + ambient_description: Optional[str] = None + available_quests: List[str] = field(default_factory=list) + npc_ids: List[str] = field(default_factory=list) + discoverable_locations: List[str] = field(default_factory=list) + is_starting_location: bool = False + tags: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize location to dictionary for JSON responses. + + Returns: + Dictionary containing all location data + """ + return { + "location_id": self.location_id, + "name": self.name, + "location_type": self.location_type.value, + "region_id": self.region_id, + "description": self.description, + "lore": self.lore, + "ambient_description": self.ambient_description, + "available_quests": self.available_quests, + "npc_ids": self.npc_ids, + "discoverable_locations": self.discoverable_locations, + "is_starting_location": self.is_starting_location, + "tags": self.tags, + } + + def to_story_dict(self) -> Dict[str, Any]: + """ + Serialize location for AI narrative context. + + Returns a trimmed version with only narrative-relevant data + to reduce token usage in AI prompts. + + Returns: + Dictionary containing story-relevant location data + """ + return { + "name": self.name, + "type": self.location_type.value, + "description": self.description, + "ambient": self.ambient_description, + "lore": self.lore, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Location': + """ + Deserialize location from dictionary. + + Args: + data: Dictionary containing location data (from YAML or JSON) + + Returns: + Location instance + """ + # Handle location_type - can be string or LocationType enum + location_type = data.get("location_type", "town") + if isinstance(location_type, str): + location_type = LocationType(location_type) + + return cls( + location_id=data["location_id"], + name=data["name"], + location_type=location_type, + region_id=data["region_id"], + description=data["description"], + lore=data.get("lore"), + ambient_description=data.get("ambient_description"), + available_quests=data.get("available_quests", []), + npc_ids=data.get("npc_ids", []), + discoverable_locations=data.get("discoverable_locations", []), + is_starting_location=data.get("is_starting_location", False), + tags=data.get("tags", []), + ) + + def __repr__(self) -> str: + """String representation of the location.""" + return f"Location({self.location_id}, {self.name}, {self.location_type.value})" + + +@dataclass +class Region: + """ + Represents a geographical region containing multiple locations. + + Regions group related locations together for organizational purposes + and can contain region-wide lore or events. + + Attributes: + region_id: Unique identifier (e.g., "crossville") + name: Display name (e.g., "Crossville Province") + description: Region overview and atmosphere + location_ids: List of all location IDs in this region + """ + + region_id: str + name: str + description: str + location_ids: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize region to dictionary. + + Returns: + Dictionary containing all region data + """ + return { + "region_id": self.region_id, + "name": self.name, + "description": self.description, + "location_ids": self.location_ids, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Region': + """ + Deserialize region from dictionary. + + Args: + data: Dictionary containing region data + + Returns: + Region instance + """ + return cls( + region_id=data["region_id"], + name=data["name"], + description=data["description"], + location_ids=data.get("location_ids", []), + ) + + def __repr__(self) -> str: + """String representation of the region.""" + return f"Region({self.region_id}, {self.name}, {len(self.location_ids)} locations)" diff --git a/api/app/models/marketplace.py b/api/app/models/marketplace.py new file mode 100644 index 0000000..195f6c7 --- /dev/null +++ b/api/app/models/marketplace.py @@ -0,0 +1,401 @@ +""" +Marketplace and economy data models. + +This module defines the marketplace-related dataclasses including +MarketplaceListing, Bid, Transaction, and ShopItem for the player economy. +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional +from datetime import datetime + +from app.models.items import Item +from app.models.enums import ListingType, ListingStatus + + +@dataclass +class Bid: + """ + Represents a bid on an auction listing. + + Attributes: + bidder_id: User ID of the bidder + bidder_name: Character name of the bidder + amount: Bid amount in gold + timestamp: ISO timestamp of when bid was placed + """ + + bidder_id: str + bidder_name: str + amount: int + timestamp: str = "" + + def __post_init__(self): + """Initialize timestamp if not provided.""" + if not self.timestamp: + self.timestamp = datetime.utcnow().isoformat() + + def to_dict(self) -> Dict[str, Any]: + """Serialize bid to dictionary.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Bid': + """Deserialize bid from dictionary.""" + return cls( + bidder_id=data["bidder_id"], + bidder_name=data["bidder_name"], + amount=data["amount"], + timestamp=data.get("timestamp", ""), + ) + + +@dataclass +class MarketplaceListing: + """ + Represents an item listing on the player marketplace. + + Supports both fixed-price and auction-style listings. + + Attributes: + listing_id: Unique identifier + seller_id: User ID of the seller + character_id: Character ID of the seller + item_data: Full item details being sold + listing_type: "auction" or "fixed_price" + price: For fixed_price listings + starting_bid: Minimum bid for auction listings + current_bid: Current highest bid for auction listings + buyout_price: Optional instant-buy price for auctions + bids: Bid history for auction listings + auction_end: ISO timestamp when auction ends + status: Listing status (active, sold, expired, removed) + created_at: ISO timestamp of listing creation + """ + + listing_id: str + seller_id: str + character_id: str + item_data: Item + listing_type: ListingType + status: ListingStatus = ListingStatus.ACTIVE + created_at: str = "" + + # Fixed price fields + price: int = 0 + + # Auction fields + starting_bid: int = 0 + current_bid: int = 0 + buyout_price: int = 0 + bids: List[Bid] = field(default_factory=list) + auction_end: str = "" + + def __post_init__(self): + """Initialize timestamps if not provided.""" + if not self.created_at: + self.created_at = datetime.utcnow().isoformat() + + def is_auction(self) -> bool: + """Check if this is an auction listing.""" + return self.listing_type == ListingType.AUCTION + + def is_fixed_price(self) -> bool: + """Check if this is a fixed-price listing.""" + return self.listing_type == ListingType.FIXED_PRICE + + def is_active(self) -> bool: + """Check if listing is active.""" + return self.status == ListingStatus.ACTIVE + + def has_ended(self) -> bool: + """Check if auction has ended (for auction listings).""" + if not self.is_auction() or not self.auction_end: + return False + + end_time = datetime.fromisoformat(self.auction_end) + return datetime.utcnow() >= end_time + + def can_bid(self, bid_amount: int) -> bool: + """ + Check if a bid amount is valid. + + Args: + bid_amount: Proposed bid amount + + Returns: + True if bid is valid, False otherwise + """ + if not self.is_auction() or not self.is_active(): + return False + + if self.has_ended(): + return False + + # First bid must meet starting bid + if not self.bids and bid_amount < self.starting_bid: + return False + + # Subsequent bids must exceed current bid + if self.bids and bid_amount <= self.current_bid: + return False + + return True + + def place_bid(self, bidder_id: str, bidder_name: str, amount: int) -> bool: + """ + Place a bid on this auction. + + Args: + bidder_id: User ID of bidder + bidder_name: Character name of bidder + amount: Bid amount + + Returns: + True if bid was accepted, False otherwise + """ + if not self.can_bid(amount): + return False + + bid = Bid( + bidder_id=bidder_id, + bidder_name=bidder_name, + amount=amount, + ) + + self.bids.append(bid) + self.current_bid = amount + return True + + def buyout(self) -> bool: + """ + Attempt to buy out the auction immediately. + + Returns: + True if buyout is available and successful, False otherwise + """ + if not self.is_auction() or not self.buyout_price: + return False + + if not self.is_active() or self.has_ended(): + return False + + self.current_bid = self.buyout_price + self.status = ListingStatus.SOLD + return True + + def get_winning_bidder(self) -> Optional[Bid]: + """ + Get the current winning bid. + + Returns: + Winning Bid or None if no bids + """ + if not self.bids: + return None + + # Bids are added chronologically, last one is highest + return self.bids[-1] + + def cancel_listing(self) -> bool: + """ + Cancel this listing (seller action). + + Returns: + True if successfully cancelled, False if cannot be cancelled + """ + if not self.is_active(): + return False + + # Cannot cancel auction with bids + if self.is_auction() and self.bids: + return False + + self.status = ListingStatus.REMOVED + return True + + def complete_sale(self) -> None: + """Mark listing as sold.""" + self.status = ListingStatus.SOLD + + def expire_listing(self) -> None: + """Mark listing as expired.""" + self.status = ListingStatus.EXPIRED + + def to_dict(self) -> Dict[str, Any]: + """Serialize listing to dictionary.""" + return { + "listing_id": self.listing_id, + "seller_id": self.seller_id, + "character_id": self.character_id, + "item_data": self.item_data.to_dict(), + "listing_type": self.listing_type.value, + "status": self.status.value, + "created_at": self.created_at, + "price": self.price, + "starting_bid": self.starting_bid, + "current_bid": self.current_bid, + "buyout_price": self.buyout_price, + "bids": [bid.to_dict() for bid in self.bids], + "auction_end": self.auction_end, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'MarketplaceListing': + """Deserialize listing from dictionary.""" + item_data = Item.from_dict(data["item_data"]) + listing_type = ListingType(data["listing_type"]) + status = ListingStatus(data.get("status", "active")) + bids = [Bid.from_dict(b) for b in data.get("bids", [])] + + return cls( + listing_id=data["listing_id"], + seller_id=data["seller_id"], + character_id=data["character_id"], + item_data=item_data, + listing_type=listing_type, + status=status, + created_at=data.get("created_at", ""), + price=data.get("price", 0), + starting_bid=data.get("starting_bid", 0), + current_bid=data.get("current_bid", 0), + buyout_price=data.get("buyout_price", 0), + bids=bids, + auction_end=data.get("auction_end", ""), + ) + + +@dataclass +class Transaction: + """ + Record of a completed transaction. + + Tracks all sales for auditing and analytics. + + Attributes: + transaction_id: Unique identifier + buyer_id: User ID of buyer + seller_id: User ID of seller + listing_id: Marketplace listing ID (if from marketplace) + item_data: Item that was sold + price: Final sale price in gold + timestamp: ISO timestamp of transaction + transaction_type: "marketplace_sale", "shop_purchase", etc. + """ + + transaction_id: str + buyer_id: str + seller_id: str + item_data: Item + price: int + transaction_type: str + listing_id: str = "" + timestamp: str = "" + + def __post_init__(self): + """Initialize timestamp if not provided.""" + if not self.timestamp: + self.timestamp = datetime.utcnow().isoformat() + + def to_dict(self) -> Dict[str, Any]: + """Serialize transaction to dictionary.""" + return { + "transaction_id": self.transaction_id, + "buyer_id": self.buyer_id, + "seller_id": self.seller_id, + "listing_id": self.listing_id, + "item_data": self.item_data.to_dict(), + "price": self.price, + "timestamp": self.timestamp, + "transaction_type": self.transaction_type, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Transaction': + """Deserialize transaction from dictionary.""" + item_data = Item.from_dict(data["item_data"]) + + return cls( + transaction_id=data["transaction_id"], + buyer_id=data["buyer_id"], + seller_id=data["seller_id"], + listing_id=data.get("listing_id", ""), + item_data=item_data, + price=data["price"], + timestamp=data.get("timestamp", ""), + transaction_type=data["transaction_type"], + ) + + +@dataclass +class ShopItem: + """ + Item sold by NPC shops. + + Attributes: + item_id: Item identifier + item: Item details + stock: Available quantity (-1 = unlimited) + price: Fixed gold price + """ + + item_id: str + item: Item + stock: int = -1 # -1 = unlimited + price: int = 0 + + def is_in_stock(self) -> bool: + """Check if item is available for purchase.""" + return self.stock != 0 + + def purchase(self, quantity: int = 1) -> bool: + """ + Attempt to purchase from stock. + + Args: + quantity: Number of items to purchase + + Returns: + True if purchase successful, False if insufficient stock + """ + if self.stock == -1: # Unlimited stock + return True + + if self.stock < quantity: + return False + + self.stock -= quantity + return True + + def restock(self, quantity: int) -> None: + """ + Add stock to this shop item. + + Args: + quantity: Amount to add to stock + """ + if self.stock == -1: # Unlimited, no need to restock + return + + self.stock += quantity + + def to_dict(self) -> Dict[str, Any]: + """Serialize shop item to dictionary.""" + return { + "item_id": self.item_id, + "item": self.item.to_dict(), + "stock": self.stock, + "price": self.price, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ShopItem': + """Deserialize shop item from dictionary.""" + item = Item.from_dict(data["item"]) + + return cls( + item_id=data["item_id"], + item=item, + stock=data.get("stock", -1), + price=data.get("price", 0), + ) diff --git a/api/app/models/npc.py b/api/app/models/npc.py new file mode 100644 index 0000000..05510ce --- /dev/null +++ b/api/app/models/npc.py @@ -0,0 +1,477 @@ +""" +NPC data models for persistent non-player characters. + +This module defines NPC and related dataclasses that represent structured +NPC definitions loaded from YAML files. NPCs have rich personality, knowledge, +and interaction data that the AI uses for dialogue generation. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Any, Optional + + +@dataclass +class NPCPersonality: + """ + NPC personality definition for AI dialogue generation. + + Provides the AI with guidance on how to roleplay the NPC's character, + including their general traits, speaking patterns, and distinctive behaviors. + + Attributes: + traits: List of personality descriptors (e.g., "gruff", "kind", "suspicious") + speech_style: Description of how the NPC speaks (accent, vocabulary, patterns) + quirks: List of distinctive behaviors or habits + """ + + traits: List[str] + speech_style: str + quirks: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Serialize personality to dictionary.""" + return { + "traits": self.traits, + "speech_style": self.speech_style, + "quirks": self.quirks, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPCPersonality': + """Deserialize personality from dictionary.""" + return cls( + traits=data.get("traits", []), + speech_style=data.get("speech_style", ""), + quirks=data.get("quirks", []), + ) + + +@dataclass +class NPCAppearance: + """ + NPC physical description. + + Provides visual context for AI narration and player information. + + Attributes: + brief: Short one-line description for lists and quick reference + detailed: Optional longer description for detailed encounters + """ + + brief: str + detailed: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Serialize appearance to dictionary.""" + return { + "brief": self.brief, + "detailed": self.detailed, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPCAppearance': + """Deserialize appearance from dictionary.""" + if isinstance(data, str): + # Handle simple string format + return cls(brief=data) + return cls( + brief=data.get("brief", ""), + detailed=data.get("detailed"), + ) + + +@dataclass +class NPCKnowledgeCondition: + """ + Condition for NPC to reveal secret knowledge. + + Defines when and how an NPC will share information they normally keep hidden. + Conditions are evaluated against the character's interaction state. + + Attributes: + condition: Expression describing what triggers the reveal + (e.g., "interaction_count >= 3", "relationship_level >= 75") + reveals: The information that gets revealed when condition is met + """ + + condition: str + reveals: str + + def to_dict(self) -> Dict[str, Any]: + """Serialize condition to dictionary.""" + return { + "condition": self.condition, + "reveals": self.reveals, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPCKnowledgeCondition': + """Deserialize condition from dictionary.""" + return cls( + condition=data.get("condition", ""), + reveals=data.get("reveals", ""), + ) + + +@dataclass +class NPCKnowledge: + """ + Knowledge an NPC possesses - public and secret. + + Organizes what information an NPC knows and under what circumstances + they will share it with players. + + Attributes: + public: Knowledge the NPC will freely share with anyone + secret: Knowledge the NPC keeps hidden (for AI reference only) + will_share_if: Conditional reveals based on character interaction state + """ + + public: List[str] = field(default_factory=list) + secret: List[str] = field(default_factory=list) + will_share_if: List[NPCKnowledgeCondition] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Serialize knowledge to dictionary.""" + return { + "public": self.public, + "secret": self.secret, + "will_share_if": [c.to_dict() for c in self.will_share_if], + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPCKnowledge': + """Deserialize knowledge from dictionary.""" + conditions = [ + NPCKnowledgeCondition.from_dict(c) + for c in data.get("will_share_if", []) + ] + return cls( + public=data.get("public", []), + secret=data.get("secret", []), + will_share_if=conditions, + ) + + +@dataclass +class NPCRelationship: + """ + NPC's relationship with another NPC. + + Defines how this NPC feels about other NPCs in the world, + providing context for dialogue and interactions. + + Attributes: + npc_id: The other NPC's identifier + attitude: How this NPC feels (e.g., "friendly", "distrustful", "romantic") + reason: Optional explanation for the attitude + """ + + npc_id: str + attitude: str + reason: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Serialize relationship to dictionary.""" + return { + "npc_id": self.npc_id, + "attitude": self.attitude, + "reason": self.reason, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPCRelationship': + """Deserialize relationship from dictionary.""" + return cls( + npc_id=data["npc_id"], + attitude=data["attitude"], + reason=data.get("reason"), + ) + + +@dataclass +class NPCInventoryItem: + """ + Item an NPC has for sale. + + Defines items available for purchase from merchant NPCs. + + Attributes: + item_id: Reference to item definition + price: Cost in gold + quantity: Stock count (None = unlimited) + """ + + item_id: str + price: int + quantity: Optional[int] = None + + def to_dict(self) -> Dict[str, Any]: + """Serialize inventory item to dictionary.""" + return { + "item_id": self.item_id, + "price": self.price, + "quantity": self.quantity, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPCInventoryItem': + """Deserialize inventory item from dictionary.""" + # Handle shorthand format: { item: "ale", price: 2 } + item_id = data.get("item_id") or data.get("item", "") + return cls( + item_id=item_id, + price=data.get("price", 0), + quantity=data.get("quantity"), + ) + + +@dataclass +class NPCDialogueHooks: + """ + Pre-defined dialogue snippets for AI context. + + Provides example phrases the AI can use or adapt to maintain + consistent NPC voice across conversations. + + Attributes: + greeting: What NPC says when first addressed + farewell: What NPC says when conversation ends + busy: What NPC says when occupied or dismissive + quest_complete: What NPC says when player completes their quest + """ + + greeting: Optional[str] = None + farewell: Optional[str] = None + busy: Optional[str] = None + quest_complete: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Serialize dialogue hooks to dictionary.""" + return { + "greeting": self.greeting, + "farewell": self.farewell, + "busy": self.busy, + "quest_complete": self.quest_complete, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPCDialogueHooks': + """Deserialize dialogue hooks from dictionary.""" + return cls( + greeting=data.get("greeting"), + farewell=data.get("farewell"), + busy=data.get("busy"), + quest_complete=data.get("quest_complete"), + ) + + +@dataclass +class NPC: + """ + Persistent NPC definition. + + NPCs are fixed to locations and have rich personality, knowledge, + and interaction data used by the AI for dialogue generation. + + Attributes: + npc_id: Unique identifier (e.g., "npc_grom_001") + name: Display name (e.g., "Grom Ironbeard") + role: NPC's job/title (e.g., "bartender", "blacksmith") + location_id: ID of location where this NPC resides + personality: Personality traits and speech patterns + appearance: Physical description + knowledge: What the NPC knows (public and secret) + relationships: How NPC feels about other NPCs + inventory_for_sale: Items NPC sells (if merchant) + dialogue_hooks: Pre-defined dialogue snippets + quest_giver_for: Quest IDs this NPC can give + reveals_locations: Location IDs this NPC can unlock through conversation + tags: Metadata tags for filtering (e.g., "merchant", "quest_giver") + """ + + npc_id: str + name: str + role: str + location_id: str + personality: NPCPersonality + appearance: NPCAppearance + knowledge: Optional[NPCKnowledge] = None + relationships: List[NPCRelationship] = field(default_factory=list) + inventory_for_sale: List[NPCInventoryItem] = field(default_factory=list) + dialogue_hooks: Optional[NPCDialogueHooks] = None + quest_giver_for: List[str] = field(default_factory=list) + reveals_locations: List[str] = field(default_factory=list) + tags: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize NPC to dictionary for JSON responses. + + Returns: + Dictionary containing all NPC data + """ + return { + "npc_id": self.npc_id, + "name": self.name, + "role": self.role, + "location_id": self.location_id, + "personality": self.personality.to_dict(), + "appearance": self.appearance.to_dict(), + "knowledge": self.knowledge.to_dict() if self.knowledge else None, + "relationships": [r.to_dict() for r in self.relationships], + "inventory_for_sale": [i.to_dict() for i in self.inventory_for_sale], + "dialogue_hooks": self.dialogue_hooks.to_dict() if self.dialogue_hooks else None, + "quest_giver_for": self.quest_giver_for, + "reveals_locations": self.reveals_locations, + "tags": self.tags, + } + + def to_story_dict(self) -> Dict[str, Any]: + """ + Serialize NPC for AI dialogue context. + + Returns a trimmed version focused on roleplay-relevant data + to reduce token usage in AI prompts. + + Returns: + Dictionary containing story-relevant NPC data + """ + result = { + "name": self.name, + "role": self.role, + "personality": { + "traits": self.personality.traits, + "speech_style": self.personality.speech_style, + "quirks": self.personality.quirks, + }, + "appearance": self.appearance.brief, + } + + # Include dialogue hooks if available + if self.dialogue_hooks: + result["dialogue_hooks"] = self.dialogue_hooks.to_dict() + + return result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPC': + """ + Deserialize NPC from dictionary. + + Args: + data: Dictionary containing NPC data (from YAML or JSON) + + Returns: + NPC instance + """ + # Parse personality + personality_data = data.get("personality", {}) + personality = NPCPersonality.from_dict(personality_data) + + # Parse appearance + appearance_data = data.get("appearance", {"brief": ""}) + appearance = NPCAppearance.from_dict(appearance_data) + + # Parse knowledge (optional) + knowledge = None + if data.get("knowledge"): + knowledge = NPCKnowledge.from_dict(data["knowledge"]) + + # Parse relationships + relationships = [ + NPCRelationship.from_dict(r) + for r in data.get("relationships", []) + ] + + # Parse inventory + inventory = [ + NPCInventoryItem.from_dict(i) + for i in data.get("inventory_for_sale", []) + ] + + # Parse dialogue hooks (optional) + dialogue_hooks = None + if data.get("dialogue_hooks"): + dialogue_hooks = NPCDialogueHooks.from_dict(data["dialogue_hooks"]) + + return cls( + npc_id=data["npc_id"], + name=data["name"], + role=data["role"], + location_id=data["location_id"], + personality=personality, + appearance=appearance, + knowledge=knowledge, + relationships=relationships, + inventory_for_sale=inventory, + dialogue_hooks=dialogue_hooks, + quest_giver_for=data.get("quest_giver_for", []), + reveals_locations=data.get("reveals_locations", []), + tags=data.get("tags", []), + ) + + def __repr__(self) -> str: + """String representation of the NPC.""" + return f"NPC({self.npc_id}, {self.name}, {self.role})" + + +@dataclass +class NPCInteractionState: + """ + Tracks a character's interaction history with an NPC. + + Stored on the Character record to persist relationship data + across multiple game sessions. + + Attributes: + npc_id: The NPC this state tracks + first_met: ISO timestamp of first interaction + last_interaction: ISO timestamp of most recent interaction + interaction_count: Total number of conversations + revealed_secrets: Indices of secrets that have been revealed + relationship_level: 0-100 scale (50 is neutral) + custom_flags: Arbitrary flags for special conditions + (e.g., {"helped_with_rats": true}) + """ + + npc_id: str + first_met: str + last_interaction: str + interaction_count: int = 0 + revealed_secrets: List[int] = field(default_factory=list) + relationship_level: int = 50 + custom_flags: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Serialize interaction state to dictionary.""" + return { + "npc_id": self.npc_id, + "first_met": self.first_met, + "last_interaction": self.last_interaction, + "interaction_count": self.interaction_count, + "revealed_secrets": self.revealed_secrets, + "relationship_level": self.relationship_level, + "custom_flags": self.custom_flags, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'NPCInteractionState': + """Deserialize interaction state from dictionary.""" + return cls( + npc_id=data["npc_id"], + first_met=data["first_met"], + last_interaction=data["last_interaction"], + interaction_count=data.get("interaction_count", 0), + revealed_secrets=data.get("revealed_secrets", []), + relationship_level=data.get("relationship_level", 50), + custom_flags=data.get("custom_flags", {}), + ) + + def __repr__(self) -> str: + """String representation of the interaction state.""" + return ( + f"NPCInteractionState({self.npc_id}, " + f"interactions={self.interaction_count}, " + f"relationship={self.relationship_level})" + ) diff --git a/api/app/models/origins.py b/api/app/models/origins.py new file mode 100644 index 0000000..e7c47cb --- /dev/null +++ b/api/app/models/origins.py @@ -0,0 +1,148 @@ +""" +Origin data models - character backstory and starting conditions. + +Origins are saved to the character and referenced by the AI DM throughout +the game to create personalized narrative experiences, quest hooks, and +story-driven interactions. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Any + + +@dataclass +class StartingLocation: + """ + Represents where a character begins their journey. + + Attributes: + id: Unique location identifier + name: Display name of the location + region: Larger geographical area this location belongs to + description: Brief description of the location + """ + id: str + name: str + region: str + description: str + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dictionary.""" + return { + "id": self.id, + "name": self.name, + "region": self.region, + "description": self.description, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'StartingLocation': + """Deserialize from dictionary.""" + return cls( + id=data["id"], + name=data["name"], + region=data["region"], + description=data["description"], + ) + + +@dataclass +class StartingBonus: + """ + Represents mechanical benefits from an origin choice. + + Attributes: + trait: Name of the trait/ability granted + description: What the trait represents narratively + effect: Mechanical game effect (stat bonuses, special abilities, etc.) + """ + trait: str + description: str + effect: str + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dictionary.""" + return { + "trait": self.trait, + "description": self.description, + "effect": self.effect, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'StartingBonus': + """Deserialize from dictionary.""" + return cls( + trait=data["trait"], + description=data["description"], + effect=data["effect"], + ) + + +@dataclass +class Origin: + """ + Represents a character's backstory and starting conditions. + + Origins are permanent character attributes that the AI DM uses to + create personalized narratives, generate relevant quest hooks, and + tailor NPC interactions throughout the game. + + Attributes: + id: Unique origin identifier (e.g., "soul_revenant") + name: Display name (e.g., "Soul Revenant") + description: Full backstory text that explains the origin + starting_location: Where the character begins their journey + narrative_hooks: List of story elements the AI can reference + starting_bonus: Mechanical benefits from this origin + """ + id: str + name: str + description: str + starting_location: StartingLocation + narrative_hooks: List[str] = field(default_factory=list) + starting_bonus: StartingBonus = None + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize origin to dictionary for JSON storage. + + Returns: + Dictionary containing all origin data + """ + return { + "id": self.id, + "name": self.name, + "description": self.description, + "starting_location": self.starting_location.to_dict(), + "narrative_hooks": self.narrative_hooks, + "starting_bonus": self.starting_bonus.to_dict() if self.starting_bonus else None, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Origin': + """ + Deserialize origin from dictionary. + + Args: + data: Dictionary containing origin data + + Returns: + Origin instance + """ + starting_location = StartingLocation.from_dict(data["starting_location"]) + starting_bonus = None + if data.get("starting_bonus"): + starting_bonus = StartingBonus.from_dict(data["starting_bonus"]) + + return cls( + id=data["id"], + name=data["name"], + description=data["description"], + starting_location=starting_location, + narrative_hooks=data.get("narrative_hooks", []), + starting_bonus=starting_bonus, + ) + + def __repr__(self) -> str: + """String representation of the origin.""" + return f"Origin({self.name}, starts at {self.starting_location.name})" diff --git a/api/app/models/session.py b/api/app/models/session.py new file mode 100644 index 0000000..cab046c --- /dev/null +++ b/api/app/models/session.py @@ -0,0 +1,411 @@ +""" +Game session data models. + +This module defines the session-related dataclasses including SessionConfig, +GameState, and GameSession which manage multiplayer party sessions. +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional +from datetime import datetime, timezone + +from app.models.combat import CombatEncounter +from app.models.enums import SessionStatus, SessionType +from app.models.action_prompt import LocationType + + +@dataclass +class SessionConfig: + """ + Configuration settings for a game session. + + Attributes: + min_players: Minimum players required (session ends if below this) + timeout_minutes: Inactivity timeout in minutes + auto_save_interval: Turns between automatic saves + """ + + min_players: int = 1 + timeout_minutes: int = 30 + auto_save_interval: int = 5 + + def to_dict(self) -> Dict[str, Any]: + """Serialize configuration to dictionary.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'SessionConfig': + """Deserialize configuration from dictionary.""" + return cls( + min_players=data.get("min_players", 1), + timeout_minutes=data.get("timeout_minutes", 30), + auto_save_interval=data.get("auto_save_interval", 5), + ) + + +@dataclass +class GameState: + """ + Current world/quest state for a game session. + + Attributes: + current_location: Current location name/ID + location_type: Type of current location (town, tavern, wilderness, etc.) + discovered_locations: All location IDs the party has visited + active_quests: Quest IDs currently in progress + world_events: Server-wide events affecting this session + """ + + current_location: str = "crossville_village" + location_type: LocationType = LocationType.TOWN + discovered_locations: List[str] = field(default_factory=list) + active_quests: List[str] = field(default_factory=list) + world_events: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Serialize game state to dictionary.""" + return { + "current_location": self.current_location, + "location_type": self.location_type.value, + "discovered_locations": self.discovered_locations, + "active_quests": self.active_quests, + "world_events": self.world_events, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'GameState': + """Deserialize game state from dictionary.""" + # Handle location_type as either string or enum + location_type_value = data.get("location_type", "town") + if isinstance(location_type_value, str): + location_type = LocationType(location_type_value) + else: + location_type = location_type_value + + return cls( + current_location=data.get("current_location", "crossville_village"), + location_type=location_type, + discovered_locations=data.get("discovered_locations", []), + active_quests=data.get("active_quests", []), + world_events=data.get("world_events", []), + ) + + +@dataclass +class ConversationEntry: + """ + Single entry in the conversation history. + + Attributes: + turn: Turn number + character_id: Acting character's ID + character_name: Acting character's name + action: Player's action/input text + dm_response: AI Dungeon Master's response + timestamp: ISO timestamp of when entry was created + combat_log: Combat actions if any occurred this turn + quest_offered: Quest offering info if a quest was offered this turn + """ + + turn: int + character_id: str + character_name: str + action: str + dm_response: str + timestamp: str = "" + combat_log: List[Dict[str, Any]] = field(default_factory=list) + quest_offered: Optional[Dict[str, Any]] = None + + def __post_init__(self): + """Initialize timestamp if not provided.""" + if not self.timestamp: + self.timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + def to_dict(self) -> Dict[str, Any]: + """Serialize conversation entry to dictionary.""" + result = { + "turn": self.turn, + "character_id": self.character_id, + "character_name": self.character_name, + "action": self.action, + "dm_response": self.dm_response, + "timestamp": self.timestamp, + "combat_log": self.combat_log, + } + if self.quest_offered: + result["quest_offered"] = self.quest_offered + return result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ConversationEntry': + """Deserialize conversation entry from dictionary.""" + return cls( + turn=data["turn"], + character_id=data.get("character_id", ""), + character_name=data.get("character_name", ""), + action=data["action"], + dm_response=data["dm_response"], + timestamp=data.get("timestamp", ""), + combat_log=data.get("combat_log", []), + quest_offered=data.get("quest_offered"), + ) + + +@dataclass +class GameSession: + """ + Represents a game session (solo or multiplayer). + + A session can have one or more players (party) and tracks the entire + game state including conversation history, combat encounters, and + turn order. + + Attributes: + session_id: Unique identifier + session_type: Type of session (solo or multiplayer) + solo_character_id: Character ID for single-player sessions (None for multiplayer) + user_id: Owner of the session + party_member_ids: Character IDs in this party (multiplayer only) + config: Session configuration settings + combat_encounter: Current combat (None if not in combat) + conversation_history: Turn-by-turn log of actions and DM responses + game_state: Current world/quest state + turn_order: Character turn order + current_turn: Index in turn_order for current turn + turn_number: Global turn counter + created_at: ISO timestamp of session creation + last_activity: ISO timestamp of last action + status: Current session status (active, completed, timeout) + """ + + session_id: str + session_type: SessionType = SessionType.SOLO + solo_character_id: Optional[str] = None + user_id: str = "" + party_member_ids: List[str] = field(default_factory=list) + config: SessionConfig = field(default_factory=SessionConfig) + combat_encounter: Optional[CombatEncounter] = None + conversation_history: List[ConversationEntry] = field(default_factory=list) + game_state: GameState = field(default_factory=GameState) + turn_order: List[str] = field(default_factory=list) + current_turn: int = 0 + turn_number: int = 0 + created_at: str = "" + last_activity: str = "" + status: SessionStatus = SessionStatus.ACTIVE + + def __post_init__(self): + """Initialize timestamps if not provided.""" + if not self.created_at: + self.created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + if not self.last_activity: + self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + def is_in_combat(self) -> bool: + """Check if session is currently in combat.""" + return self.combat_encounter is not None + + def start_combat(self, encounter: CombatEncounter) -> None: + """ + Start a combat encounter. + + Args: + encounter: The combat encounter to begin + """ + self.combat_encounter = encounter + self.update_activity() + + def end_combat(self) -> None: + """End the current combat encounter.""" + self.combat_encounter = None + self.update_activity() + + def advance_turn(self) -> str: + """ + Advance to the next player's turn. + + Returns: + Character ID whose turn it now is + """ + if not self.turn_order: + return "" + + self.current_turn = (self.current_turn + 1) % len(self.turn_order) + self.turn_number += 1 + self.update_activity() + + return self.turn_order[self.current_turn] + + def get_current_character_id(self) -> Optional[str]: + """Get the character ID whose turn it currently is.""" + if not self.turn_order: + return None + return self.turn_order[self.current_turn] + + def add_conversation_entry(self, entry: ConversationEntry) -> None: + """ + Add an entry to the conversation history. + + Args: + entry: Conversation entry to add + """ + self.conversation_history.append(entry) + self.update_activity() + + def update_activity(self) -> None: + """Update the last activity timestamp to now.""" + self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + def add_party_member(self, character_id: str) -> None: + """ + Add a character to the party. + + Args: + character_id: Character ID to add + """ + if character_id not in self.party_member_ids: + self.party_member_ids.append(character_id) + self.update_activity() + + def remove_party_member(self, character_id: str) -> None: + """ + Remove a character from the party. + + Args: + character_id: Character ID to remove + """ + if character_id in self.party_member_ids: + self.party_member_ids.remove(character_id) + # Also remove from turn order + if character_id in self.turn_order: + self.turn_order.remove(character_id) + self.update_activity() + + def check_timeout(self) -> bool: + """ + Check if session has timed out due to inactivity. + + Returns: + True if session should be marked as timed out + """ + if self.status != SessionStatus.ACTIVE: + return False + + # Calculate time since last activity + last_activity_str = self.last_activity.replace("Z", "+00:00") + last_activity_time = datetime.fromisoformat(last_activity_str) + now = datetime.now(timezone.utc) + elapsed_minutes = (now - last_activity_time).total_seconds() / 60 + + if elapsed_minutes >= self.config.timeout_minutes: + self.status = SessionStatus.TIMEOUT + return True + + return False + + def check_min_players(self) -> bool: + """ + Check if session still has minimum required players. + + Returns: + True if session should continue, False if it should end + """ + if len(self.party_member_ids) < self.config.min_players: + if self.status == SessionStatus.ACTIVE: + self.status = SessionStatus.COMPLETED + return False + return True + + def is_solo(self) -> bool: + """Check if this is a solo session.""" + return self.session_type == SessionType.SOLO + + def get_character_id(self) -> Optional[str]: + """ + Get the primary character ID for the session. + + For solo sessions, returns solo_character_id. + For multiplayer, returns the current character in turn order. + """ + if self.is_solo(): + return self.solo_character_id + return self.get_current_character_id() + + def to_dict(self) -> Dict[str, Any]: + """Serialize game session to dictionary.""" + return { + "session_id": self.session_id, + "session_type": self.session_type.value, + "solo_character_id": self.solo_character_id, + "user_id": self.user_id, + "party_member_ids": self.party_member_ids, + "config": self.config.to_dict(), + "combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None, + "conversation_history": [entry.to_dict() for entry in self.conversation_history], + "game_state": self.game_state.to_dict(), + "turn_order": self.turn_order, + "current_turn": self.current_turn, + "turn_number": self.turn_number, + "created_at": self.created_at, + "last_activity": self.last_activity, + "status": self.status.value, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'GameSession': + """Deserialize game session from dictionary.""" + config = SessionConfig.from_dict(data.get("config", {})) + game_state = GameState.from_dict(data.get("game_state", {})) + conversation_history = [ + ConversationEntry.from_dict(entry) + for entry in data.get("conversation_history", []) + ] + + combat_encounter = None + if data.get("combat_encounter"): + combat_encounter = CombatEncounter.from_dict(data["combat_encounter"]) + + status = SessionStatus(data.get("status", "active")) + + # Handle session_type as either string or enum + session_type_value = data.get("session_type", "solo") + if isinstance(session_type_value, str): + session_type = SessionType(session_type_value) + else: + session_type = session_type_value + + return cls( + session_id=data["session_id"], + session_type=session_type, + solo_character_id=data.get("solo_character_id"), + user_id=data.get("user_id", ""), + party_member_ids=data.get("party_member_ids", []), + config=config, + combat_encounter=combat_encounter, + conversation_history=conversation_history, + game_state=game_state, + turn_order=data.get("turn_order", []), + current_turn=data.get("current_turn", 0), + turn_number=data.get("turn_number", 0), + created_at=data.get("created_at", ""), + last_activity=data.get("last_activity", ""), + status=status, + ) + + def __repr__(self) -> str: + """String representation of the session.""" + if self.is_solo(): + return ( + f"GameSession({self.session_id}, " + f"type=solo, " + f"char={self.solo_character_id}, " + f"turn={self.turn_number}, " + f"status={self.status.value})" + ) + return ( + f"GameSession({self.session_id}, " + f"type=multiplayer, " + f"party={len(self.party_member_ids)}, " + f"turn={self.turn_number}, " + f"status={self.status.value})" + ) diff --git a/api/app/models/skills.py b/api/app/models/skills.py new file mode 100644 index 0000000..90d3c68 --- /dev/null +++ b/api/app/models/skills.py @@ -0,0 +1,290 @@ +""" +Skill tree and character class system. + +This module defines the progression system including skill nodes, skill trees, +and player classes. Characters unlock skills by spending skill points earned +through leveling. +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any, List, Optional + +from app.models.stats import Stats + + +@dataclass +class SkillNode: + """ + Represents a single skill in a skill tree. + + Skills can provide passive bonuses, unlock active abilities, or grant + access to new features (like equipment types). + + Attributes: + skill_id: Unique identifier + name: Display name + description: What this skill does + tier: Skill tier (1-5, where 1 is basic and 5 is master) + prerequisites: List of skill_ids that must be unlocked first + effects: Dictionary of effects this skill provides + Examples: + - Passive bonuses: {"strength": 5, "defense": 10} + - Ability unlocks: {"unlocks_ability": "shield_bash"} + - Feature access: {"unlocks_equipment": "heavy_armor"} + unlocked: Current unlock status (used during gameplay) + """ + + skill_id: str + name: str + description: str + tier: int # 1-5 + prerequisites: List[str] = field(default_factory=list) + effects: Dict[str, Any] = field(default_factory=dict) + unlocked: bool = False + + def has_prerequisites_met(self, unlocked_skills: List[str]) -> bool: + """ + Check if all prerequisites for this skill are met. + + Args: + unlocked_skills: List of skill_ids the character has unlocked + + Returns: + True if all prerequisites are met, False otherwise + """ + return all(prereq in unlocked_skills for prereq in self.prerequisites) + + def get_stat_bonuses(self) -> Dict[str, int]: + """ + Extract stat bonuses from this skill's effects. + + Returns: + Dictionary of stat bonuses (e.g., {"strength": 5, "defense": 3}) + """ + bonuses = {} + for key, value in self.effects.items(): + # Look for stat names in effects + if key in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]: + bonuses[key] = value + elif key == "defense" or key == "resistance" or key == "hit_points" or key == "mana_points": + bonuses[key] = value + return bonuses + + def get_unlocked_abilities(self) -> List[str]: + """ + Extract ability IDs unlocked by this skill. + + Returns: + List of ability_ids this skill unlocks + """ + abilities = [] + if "unlocks_ability" in self.effects: + ability = self.effects["unlocks_ability"] + if isinstance(ability, list): + abilities.extend(ability) + else: + abilities.append(ability) + return abilities + + def to_dict(self) -> Dict[str, Any]: + """Serialize skill node to dictionary.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'SkillNode': + """Deserialize skill node from dictionary.""" + return cls( + skill_id=data["skill_id"], + name=data["name"], + description=data["description"], + tier=data["tier"], + prerequisites=data.get("prerequisites", []), + effects=data.get("effects", {}), + unlocked=data.get("unlocked", False), + ) + + +@dataclass +class SkillTree: + """ + Represents a complete skill tree for a character class. + + Each class has 2+ skill trees representing different specializations. + + Attributes: + tree_id: Unique identifier + name: Display name (e.g., "Shield Bearer", "Pyromancy") + description: Theme and purpose of this tree + nodes: All skill nodes in this tree (organized by tier) + """ + + tree_id: str + name: str + description: str + nodes: List[SkillNode] = field(default_factory=list) + + def can_unlock(self, skill_id: str, unlocked_skills: List[str]) -> bool: + """ + Check if a specific skill can be unlocked. + + Validates: + 1. Skill exists in this tree + 2. Prerequisites are met + 3. Tier progression rules (must unlock tier N before tier N+1) + + Args: + skill_id: The skill to check + unlocked_skills: Currently unlocked skill_ids + + Returns: + True if skill can be unlocked, False otherwise + """ + # Find the skill node + skill_node = None + for node in self.nodes: + if node.skill_id == skill_id: + skill_node = node + break + + if not skill_node: + return False # Skill not in this tree + + # Check if already unlocked + if skill_id in unlocked_skills: + return False + + # Check prerequisites + if not skill_node.has_prerequisites_met(unlocked_skills): + return False + + # Check tier progression + # Must have at least one skill from previous tier unlocked + # (except for tier 1 which is always available) + if skill_node.tier > 1: + has_previous_tier = False + for node in self.nodes: + if node.tier == skill_node.tier - 1 and node.skill_id in unlocked_skills: + has_previous_tier = True + break + if not has_previous_tier: + return False + + return True + + def get_nodes_by_tier(self, tier: int) -> List[SkillNode]: + """ + Get all skill nodes for a specific tier. + + Args: + tier: Tier number (1-5) + + Returns: + List of SkillNodes at that tier + """ + return [node for node in self.nodes if node.tier == tier] + + def to_dict(self) -> Dict[str, Any]: + """Serialize skill tree to dictionary.""" + data = asdict(self) + data["nodes"] = [node.to_dict() for node in self.nodes] + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'SkillTree': + """Deserialize skill tree from dictionary.""" + nodes = [SkillNode.from_dict(n) for n in data.get("nodes", [])] + return cls( + tree_id=data["tree_id"], + name=data["name"], + description=data["description"], + nodes=nodes, + ) + + +@dataclass +class PlayerClass: + """ + Represents a character class (Vanguard, Assassin, Arcanist, etc.). + + Each class has unique base stats, multiple skill trees, and starting equipment. + + Attributes: + class_id: Unique identifier + name: Display name + description: Class theme and playstyle + base_stats: Starting stats for this class + skill_trees: List of skill trees (2+ per class) + starting_equipment: List of item_ids for initial equipment + starting_abilities: List of ability_ids available from level 1 + """ + + class_id: str + name: str + description: str + base_stats: Stats + skill_trees: List[SkillTree] = field(default_factory=list) + starting_equipment: List[str] = field(default_factory=list) + starting_abilities: List[str] = field(default_factory=list) + + def get_skill_tree(self, tree_id: str) -> Optional[SkillTree]: + """ + Get a specific skill tree by ID. + + Args: + tree_id: Skill tree identifier + + Returns: + SkillTree instance or None if not found + """ + for tree in self.skill_trees: + if tree.tree_id == tree_id: + return tree + return None + + def get_all_skills(self) -> List[SkillNode]: + """ + Get all skill nodes from all trees. + + Returns: + Flat list of all SkillNodes across all trees + """ + all_skills = [] + for tree in self.skill_trees: + all_skills.extend(tree.nodes) + return all_skills + + def to_dict(self) -> Dict[str, Any]: + """Serialize player class to dictionary.""" + return { + "class_id": self.class_id, + "name": self.name, + "description": self.description, + "base_stats": self.base_stats.to_dict(), + "skill_trees": [tree.to_dict() for tree in self.skill_trees], + "starting_equipment": self.starting_equipment, + "starting_abilities": self.starting_abilities, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'PlayerClass': + """Deserialize player class from dictionary.""" + base_stats = Stats.from_dict(data["base_stats"]) + skill_trees = [SkillTree.from_dict(t) for t in data.get("skill_trees", [])] + + return cls( + class_id=data["class_id"], + name=data["name"], + description=data["description"], + base_stats=base_stats, + skill_trees=skill_trees, + starting_equipment=data.get("starting_equipment", []), + starting_abilities=data.get("starting_abilities", []), + ) + + def __repr__(self) -> str: + """String representation of the player class.""" + return ( + f"PlayerClass({self.name}, " + f"trees={len(self.skill_trees)}, " + f"total_skills={len(self.get_all_skills())})" + ) diff --git a/api/app/models/stats.py b/api/app/models/stats.py new file mode 100644 index 0000000..083c571 --- /dev/null +++ b/api/app/models/stats.py @@ -0,0 +1,140 @@ +""" +Character statistics data model. + +This module defines the Stats dataclass which represents a character's core +attributes and provides computed properties for derived values like HP and MP. +""" + +from dataclasses import dataclass, field, asdict +from typing import Dict, Any + + +@dataclass +class Stats: + """ + Character statistics representing core attributes. + + Attributes: + strength: Physical power, affects melee damage + dexterity: Agility and precision, affects initiative and evasion + constitution: Endurance and health, affects HP and defense + intelligence: Magical power, affects spell damage and MP + wisdom: Perception and insight, affects magical resistance + charisma: Social influence, affects NPC interactions + + Computed Properties: + hit_points: Maximum HP = 10 + (constitution × 2) + mana_points: Maximum MP = 10 + (intelligence × 2) + defense: Physical defense = constitution // 2 + resistance: Magical resistance = wisdom // 2 + """ + + strength: int = 10 + dexterity: int = 10 + constitution: int = 10 + intelligence: int = 10 + wisdom: int = 10 + charisma: int = 10 + + @property + def hit_points(self) -> int: + """ + Calculate maximum hit points based on constitution. + + Formula: 10 + (constitution × 2) + + Returns: + Maximum HP value + """ + return 10 + (self.constitution * 2) + + @property + def mana_points(self) -> int: + """ + Calculate maximum mana points based on intelligence. + + Formula: 10 + (intelligence × 2) + + Returns: + Maximum MP value + """ + return 10 + (self.intelligence * 2) + + @property + def defense(self) -> int: + """ + Calculate physical defense from constitution. + + Formula: constitution // 2 + + Returns: + Physical defense value (damage reduction) + """ + return self.constitution // 2 + + @property + def resistance(self) -> int: + """ + Calculate magical resistance from wisdom. + + Formula: wisdom // 2 + + Returns: + Magical resistance value (spell damage reduction) + """ + return self.wisdom // 2 + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize stats to a dictionary. + + Returns: + Dictionary containing all stat values + """ + return asdict(self) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Stats': + """ + Deserialize stats from a dictionary. + + Args: + data: Dictionary containing stat values + + Returns: + Stats instance + """ + return cls( + strength=data.get("strength", 10), + dexterity=data.get("dexterity", 10), + constitution=data.get("constitution", 10), + intelligence=data.get("intelligence", 10), + wisdom=data.get("wisdom", 10), + charisma=data.get("charisma", 10), + ) + + def copy(self) -> 'Stats': + """ + Create a deep copy of this Stats instance. + + Returns: + New Stats instance with same values + """ + return Stats( + strength=self.strength, + dexterity=self.dexterity, + constitution=self.constitution, + intelligence=self.intelligence, + wisdom=self.wisdom, + charisma=self.charisma, + ) + + def __repr__(self) -> str: + """String representation showing all stats and computed properties.""" + return ( + f"Stats(STR={self.strength}, DEX={self.dexterity}, " + f"CON={self.constitution}, INT={self.intelligence}, " + f"WIS={self.wisdom}, CHA={self.charisma}, " + f"HP={self.hit_points}, MP={self.mana_points}, " + f"DEF={self.defense}, RES={self.resistance})" + ) diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/services/action_prompt_loader.py b/api/app/services/action_prompt_loader.py new file mode 100644 index 0000000..4c3f678 --- /dev/null +++ b/api/app/services/action_prompt_loader.py @@ -0,0 +1,320 @@ +""" +Action Prompt Loader Service + +This module provides a service for loading and filtering action prompts from YAML. +It implements a singleton pattern to cache loaded prompts in memory. + +Usage: + from app.services.action_prompt_loader import ActionPromptLoader + + loader = ActionPromptLoader() + loader.load_from_yaml("app/data/action_prompts.yaml") + + # Get available actions for a user at a location + actions = loader.get_available_actions( + user_tier=UserTier.FREE, + location_type=LocationType.TOWN + ) + + # Get specific action + action = loader.get_action_by_id("ask_locals") +""" + +import os +from typing import List, Optional, Dict + +import yaml + +from app.models.action_prompt import ActionPrompt, LocationType +from app.ai.model_selector import UserTier +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + + +class ActionPromptLoaderError(Exception): + """Base exception for action prompt loader errors.""" + pass + + +class ActionPromptNotFoundError(ActionPromptLoaderError): + """Raised when a requested action prompt is not found.""" + pass + + +class ActionPromptLoader: + """ + Service for loading and filtering action prompts. + + This class loads action prompts from YAML files and provides methods + to filter them based on user tier and location type. + + Uses singleton pattern to cache loaded prompts in memory. + + Attributes: + _prompts: Dictionary of loaded action prompts keyed by prompt_id + _loaded: Flag indicating if prompts have been loaded + """ + + _instance = None + _prompts: Dict[str, ActionPrompt] = {} + _loaded: bool = False + + def __new__(cls): + """Implement singleton pattern.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._prompts = {} + cls._instance._loaded = False + return cls._instance + + def load_from_yaml(self, filepath: str) -> int: + """ + Load action prompts from a YAML file. + + Args: + filepath: Path to the YAML file + + Returns: + Number of prompts loaded + + Raises: + ActionPromptLoaderError: If file cannot be read or parsed + """ + if not os.path.exists(filepath): + logger.error("Action prompts file not found", filepath=filepath) + raise ActionPromptLoaderError(f"File not found: {filepath}") + + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + + except yaml.YAMLError as e: + logger.error("Failed to parse YAML", filepath=filepath, error=str(e)) + raise ActionPromptLoaderError(f"Invalid YAML in {filepath}: {e}") + + except IOError as e: + logger.error("Failed to read file", filepath=filepath, error=str(e)) + raise ActionPromptLoaderError(f"Cannot read {filepath}: {e}") + + if not data or 'action_prompts' not in data: + logger.error("No action_prompts key in YAML", filepath=filepath) + raise ActionPromptLoaderError(f"Missing 'action_prompts' key in {filepath}") + + # Clear existing prompts + self._prompts = {} + + # Parse each prompt + prompts_data = data['action_prompts'] + errors = [] + + for i, prompt_data in enumerate(prompts_data): + try: + prompt = ActionPrompt.from_dict(prompt_data) + self._prompts[prompt.prompt_id] = prompt + + except (ValueError, KeyError) as e: + errors.append(f"Prompt {i}: {e}") + logger.warning( + "Failed to parse action prompt", + index=i, + error=str(e) + ) + + if errors: + logger.warning( + "Some action prompts failed to load", + error_count=len(errors), + errors=errors + ) + + self._loaded = True + loaded_count = len(self._prompts) + + logger.info( + "Action prompts loaded", + filepath=filepath, + count=loaded_count, + errors=len(errors) + ) + + return loaded_count + + def get_all_actions(self) -> List[ActionPrompt]: + """ + Get all loaded action prompts. + + Returns: + List of all action prompts + """ + self._ensure_loaded() + return list(self._prompts.values()) + + def get_action_by_id(self, prompt_id: str) -> ActionPrompt: + """ + Get a specific action prompt by ID. + + Args: + prompt_id: The unique identifier of the action + + Returns: + The ActionPrompt object + + Raises: + ActionPromptNotFoundError: If action not found + """ + self._ensure_loaded() + + if prompt_id not in self._prompts: + logger.warning("Action prompt not found", prompt_id=prompt_id) + raise ActionPromptNotFoundError(f"Action prompt '{prompt_id}' not found") + + return self._prompts[prompt_id] + + def get_available_actions( + self, + user_tier: UserTier, + location_type: LocationType + ) -> List[ActionPrompt]: + """ + Get actions available to a user at a specific location. + + Args: + user_tier: The user's subscription tier + location_type: The current location type + + Returns: + List of available action prompts + """ + self._ensure_loaded() + + available = [] + for prompt in self._prompts.values(): + if prompt.is_available(user_tier, location_type): + available.append(prompt) + + logger.debug( + "Filtered available actions", + user_tier=user_tier.value, + location_type=location_type.value, + count=len(available) + ) + + return available + + def get_actions_by_tier(self, user_tier: UserTier) -> List[ActionPrompt]: + """ + Get all actions available to a user tier (ignoring location). + + Args: + user_tier: The user's subscription tier + + Returns: + List of action prompts available to the tier + """ + self._ensure_loaded() + + available = [] + for prompt in self._prompts.values(): + if prompt._tier_meets_requirement(user_tier): + available.append(prompt) + + return available + + def get_actions_by_category(self, category: str) -> List[ActionPrompt]: + """ + Get all actions in a specific category. + + Args: + category: The action category (e.g., "ask_question", "explore") + + Returns: + List of action prompts in the category + """ + self._ensure_loaded() + + return [ + prompt for prompt in self._prompts.values() + if prompt.category.value == category + ] + + def get_locked_actions( + self, + user_tier: UserTier, + location_type: LocationType + ) -> List[ActionPrompt]: + """ + Get actions that are locked due to tier restrictions. + + Used to show locked actions with upgrade prompts in UI. + + Args: + user_tier: The user's subscription tier + location_type: The current location type + + Returns: + List of locked action prompts + """ + self._ensure_loaded() + + locked = [] + for prompt in self._prompts.values(): + # Must match location but be tier-locked + if prompt._location_matches_filter(location_type) and prompt.is_locked(user_tier): + locked.append(prompt) + + return locked + + def reload(self, filepath: str) -> int: + """ + Force reload prompts from YAML file. + + Args: + filepath: Path to the YAML file + + Returns: + Number of prompts loaded + """ + self._loaded = False + return self.load_from_yaml(filepath) + + def is_loaded(self) -> bool: + """Check if prompts have been loaded.""" + return self._loaded + + def get_prompt_count(self) -> int: + """Get the number of loaded prompts.""" + return len(self._prompts) + + def _ensure_loaded(self) -> None: + """ + Ensure prompts are loaded, auto-load from default path if not. + + Raises: + ActionPromptLoaderError: If prompts cannot be loaded + """ + if not self._loaded: + # Try default path + default_path = os.path.join( + os.path.dirname(__file__), + '..', 'data', 'action_prompts.yaml' + ) + default_path = os.path.normpath(default_path) + + if os.path.exists(default_path): + self.load_from_yaml(default_path) + else: + raise ActionPromptLoaderError( + "Action prompts not loaded. Call load_from_yaml() first." + ) + + @classmethod + def reset_instance(cls) -> None: + """ + Reset the singleton instance. + + Primarily for testing purposes. + """ + cls._instance = None diff --git a/api/app/services/appwrite_service.py b/api/app/services/appwrite_service.py new file mode 100644 index 0000000..4a9d34e --- /dev/null +++ b/api/app/services/appwrite_service.py @@ -0,0 +1,588 @@ +""" +Appwrite Service Wrapper + +This module provides a wrapper around the Appwrite SDK for handling user authentication, +session management, and user data operations. It abstracts Appwrite's API to provide +a clean interface for the application. + +Usage: + from app.services.appwrite_service import AppwriteService + + # Initialize service + service = AppwriteService() + + # Register a new user + user = service.register_user( + email="player@example.com", + password="SecurePass123!", + name="Brave Adventurer" + ) + + # Login + session = service.login_user( + email="player@example.com", + password="SecurePass123!" + ) +""" + +import os +from typing import Optional, Dict, Any +from dataclasses import dataclass +from datetime import datetime, timezone + +from appwrite.client import Client +from appwrite.services.account import Account +from appwrite.services.users import Users +from appwrite.exception import AppwriteException +from appwrite.id import ID + +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + + +@dataclass +class UserData: + """ + Data class representing a user in the system. + + Attributes: + id: Unique user identifier + email: User's email address + name: User's display name + email_verified: Whether email has been verified + tier: User's subscription tier (free, basic, premium, elite) + created_at: When the user account was created + updated_at: When the user account was last updated + """ + id: str + email: str + name: str + email_verified: bool + tier: str + created_at: datetime + updated_at: datetime + + def to_dict(self) -> Dict[str, Any]: + """Convert user data to dictionary.""" + return { + "id": self.id, + "email": self.email, + "name": self.name, + "email_verified": self.email_verified, + "tier": self.tier, + "created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at, + "updated_at": self.updated_at.isoformat() if isinstance(self.updated_at, datetime) else self.updated_at, + } + + +@dataclass +class SessionData: + """ + Data class representing a user session. + + Attributes: + session_id: Unique session identifier + user_id: User ID associated with this session + provider: Authentication provider (email, oauth, etc.) + expire: When the session expires + """ + session_id: str + user_id: str + provider: str + expire: datetime + + def to_dict(self) -> Dict[str, Any]: + """Convert session data to dictionary.""" + return { + "session_id": self.session_id, + "user_id": self.user_id, + "provider": self.provider, + "expire": self.expire.isoformat() if isinstance(self.expire, datetime) else self.expire, + } + + +class AppwriteService: + """ + Service class for interacting with Appwrite authentication and user management. + + This class provides methods for: + - User registration and email verification + - User login and logout + - Session management + - Password reset + - User tier management + """ + + def __init__(self): + """ + Initialize the Appwrite service. + + Reads configuration from environment variables: + - APPWRITE_ENDPOINT: Appwrite API endpoint + - APPWRITE_PROJECT_ID: Appwrite project ID + - APPWRITE_API_KEY: Appwrite API key (for server-side operations) + """ + self.endpoint = os.getenv('APPWRITE_ENDPOINT') + self.project_id = os.getenv('APPWRITE_PROJECT_ID') + self.api_key = os.getenv('APPWRITE_API_KEY') + + if not all([self.endpoint, self.project_id, self.api_key]): + logger.error("Missing Appwrite configuration in environment variables") + raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.") + + # Initialize Appwrite client + self.client = Client() + self.client.set_endpoint(self.endpoint) + self.client.set_project(self.project_id) + self.client.set_key(self.api_key) + + # Initialize services + self.account = Account(self.client) + self.users = Users(self.client) + + logger.info("Appwrite service initialized", endpoint=self.endpoint, project_id=self.project_id) + + def register_user(self, email: str, password: str, name: str) -> UserData: + """ + Register a new user account. + + This method: + 1. Creates a new user in Appwrite Auth + 2. Sets the user's tier to 'free' in preferences + 3. Triggers email verification + 4. Returns user data + + Args: + email: User's email address + password: User's password (will be hashed by Appwrite) + name: User's display name + + Returns: + UserData object with user information + + Raises: + AppwriteException: If registration fails (e.g., email already exists) + """ + try: + logger.info("Attempting to register new user", email=email, name=name) + + # Generate unique user ID + user_id = ID.unique() + + # Create user account + user = self.users.create( + user_id=user_id, + email=email, + password=password, + name=name + ) + + logger.info("User created successfully", user_id=user['$id'], email=email) + + # Set default tier to 'free' in user preferences + self.users.update_prefs( + user_id=user['$id'], + prefs={ + 'tier': 'free', + 'tier_updated_at': datetime.now(timezone.utc).isoformat() + } + ) + + logger.info("User tier set to 'free'", user_id=user['$id']) + + # Note: Email verification is handled by Appwrite automatically + # when email templates are configured in the Appwrite console. + # For server-side user creation, verification emails are sent + # automatically if the email provider is configured. + # + # To manually trigger verification, users can use the Account service + # (client-side) after logging in, or configure email verification + # settings in the Appwrite console. + + logger.info("User created, email verification handled by Appwrite", user_id=user['$id'], email=email) + + # Return user data + return self._user_to_userdata(user) + + except AppwriteException as e: + logger.error("Failed to register user", email=email, error=str(e), code=e.code) + raise + + def login_user(self, email: str, password: str) -> tuple[SessionData, UserData]: + """ + Authenticate a user and create a session. + + For server-side authentication, we create a temporary client with user + credentials to verify them, then create a session using the server SDK. + + Args: + email: User's email address + password: User's password + + Returns: + Tuple of (SessionData, UserData) + + Raises: + AppwriteException: If login fails (invalid credentials, etc.) + """ + try: + logger.info("Attempting user login", email=email) + + # Use admin client (with API key) to create session + # This is required to get the session secret in the response + from appwrite.services.account import Account + + admin_account = Account(self.client) # self.client already has API key set + + # Create email/password session using admin client + # When using admin client, the 'secret' field is populated in the response + user_session = admin_account.create_email_password_session( + email=email, + password=password + ) + + logger.info("Session created successfully", + user_id=user_session['userId'], + session_id=user_session['$id']) + + # Extract session secret from response + # Admin client populates this field, unlike regular client + session_secret = user_session.get('secret', '') + + if not session_secret: + logger.error("Session secret not found in response - this should not happen with admin client") + raise AppwriteException("Failed to get session secret", code=500) + + # Get user data using server SDK + user = self.users.get(user_id=user_session['userId']) + + # Convert to our data classes + session_data = SessionData( + session_id=session_secret, # Use the secret, not the session ID + user_id=user_session['userId'], + provider=user_session['provider'], + expire=datetime.fromisoformat(user_session['expire'].replace('Z', '+00:00')) + ) + + user_data = self._user_to_userdata(user) + + return session_data, user_data + + except AppwriteException as e: + logger.error("Failed to login user", email=email, error=str(e), code=e.code) + raise + except Exception as e: + logger.error("Unexpected error during login", email=email, error=str(e), exc_info=True) + raise AppwriteException(str(e), code=500) + + def logout_user(self, session_id: str) -> bool: + """ + Log out a user by deleting their session. + + Args: + session_id: The session ID to delete + + Returns: + True if logout successful + + Raises: + AppwriteException: If logout fails + """ + try: + logger.info("Attempting to logout user", session_id=session_id) + + # For server-side, we need to delete the session using Users service + # First get the session to find the user_id + # Note: Appwrite doesn't have a direct server-side session delete by session_id + # We'll use a workaround by creating a client with the session and deleting it + + from appwrite.client import Client + from appwrite.services.account import Account + + # Create client with the session + session_client = Client() + session_client.set_endpoint(self.endpoint) + session_client.set_project(self.project_id) + session_client.set_session(session_id) + + session_account = Account(session_client) + + # Delete the current session + session_account.delete_session('current') + + logger.info("User logged out successfully", session_id=session_id) + return True + + except AppwriteException as e: + logger.error("Failed to logout user", session_id=session_id, error=str(e), code=e.code) + raise + + def verify_email(self, user_id: str, secret: str) -> bool: + """ + Verify a user's email address. + + Note: Email verification with server-side SDK requires updating + the user's emailVerification status directly, or using Appwrite's + built-in verification flow through the Account service (client-side). + + Args: + user_id: User ID + secret: Verification secret from email link (not validated server-side) + + Returns: + True if verification successful + + Raises: + AppwriteException: If verification fails (invalid/expired secret) + """ + try: + logger.info("Attempting to verify email", user_id=user_id, secret_provided=bool(secret)) + + # For server-side verification, we update the user's email verification status + # The secret validation should be done by Appwrite's verification flow + # For now, we'll mark the email as verified + # In production, you should validate the secret token before updating + self.users.update_email_verification(user_id=user_id, email_verification=True) + + logger.info("Email verified successfully", user_id=user_id) + return True + + except AppwriteException as e: + logger.error("Failed to verify email", user_id=user_id, error=str(e), code=e.code) + raise + + def request_password_reset(self, email: str) -> bool: + """ + Request a password reset for a user. + + This sends a password reset email to the user. For security, + it always returns True even if the email doesn't exist. + + Note: Password reset is handled through Appwrite's built-in Account + service recovery flow. For server-side operations, we would need to + create a password recovery token manually. + + Args: + email: User's email address + + Returns: + Always True (for security - don't reveal if email exists) + """ + try: + logger.info("Password reset requested", email=email) + + # Note: Password reset with server-side SDK requires creating + # a recovery token. For now, we'll log this and return success. + # In production, configure Appwrite's email templates and use + # client-side Account.createRecovery() or implement custom token + # generation and email sending. + + logger.warning("Password reset not fully implemented - requires Appwrite email configuration", email=email) + + except Exception as e: + # Log the error but still return True for security + # Don't reveal whether the email exists + logger.warning("Password reset request encountered error", email=email, error=str(e)) + + # Always return True to not reveal if email exists + return True + + def confirm_password_reset(self, user_id: str, secret: str, password: str) -> bool: + """ + Confirm a password reset and update the user's password. + + Note: For server-side operations, we update the password directly + using the Users service. Secret validation would be handled separately. + + Args: + user_id: User ID + secret: Reset secret from email link (should be validated before calling) + password: New password + + Returns: + True if password reset successful + + Raises: + AppwriteException: If reset fails + """ + try: + logger.info("Attempting to reset password", user_id=user_id, secret_provided=bool(secret)) + + # For server-side password reset, update the password directly + # In production, you should validate the secret token first before calling this + # The secret parameter is kept for API compatibility but not validated here + self.users.update_password(user_id=user_id, password=password) + + logger.info("Password reset successfully", user_id=user_id) + return True + + except AppwriteException as e: + logger.error("Failed to reset password", user_id=user_id, error=str(e), code=e.code) + raise + + def get_user(self, user_id: str) -> UserData: + """ + Get user data by user ID. + + Args: + user_id: User ID + + Returns: + UserData object + + Raises: + AppwriteException: If user not found + """ + try: + user = self.users.get(user_id=user_id) + + return self._user_to_userdata(user) + + except AppwriteException as e: + logger.error("Failed to fetch user", user_id=user_id, error=str(e), code=e.code) + raise + + def get_session(self, session_id: str) -> SessionData: + """ + Get session data and validate it's still active. + + Args: + session_id: Session ID + + Returns: + SessionData object + + Raises: + AppwriteException: If session invalid or expired + """ + try: + # Create a client with the session to validate it + from appwrite.client import Client + from appwrite.services.account import Account + + session_client = Client() + session_client.set_endpoint(self.endpoint) + session_client.set_project(self.project_id) + session_client.set_session(session_id) + + session_account = Account(session_client) + + # Get the current session (this validates it exists and is active) + session = session_account.get_session('current') + + # Check if session is expired + expire_time = datetime.fromisoformat(session['expire'].replace('Z', '+00:00')) + if expire_time < datetime.now(timezone.utc): + logger.warning("Session expired", session_id=session_id, expired_at=expire_time) + raise AppwriteException("Session expired", code=401) + + return SessionData( + session_id=session['$id'], + user_id=session['userId'], + provider=session['provider'], + expire=expire_time + ) + + except AppwriteException as e: + logger.error("Failed to validate session", session_id=session_id, error=str(e), code=e.code) + raise + + def get_user_tier(self, user_id: str) -> str: + """ + Get the user's subscription tier. + + Args: + user_id: User ID + + Returns: + Tier string (free, basic, premium, elite) + """ + try: + logger.debug("Fetching user tier", user_id=user_id) + + user = self.users.get(user_id=user_id) + prefs = user.get('prefs', {}) + tier = prefs.get('tier', 'free') + + logger.debug("User tier retrieved", user_id=user_id, tier=tier) + return tier + + except AppwriteException as e: + logger.error("Failed to fetch user tier", user_id=user_id, error=str(e), code=e.code) + # Default to free tier on error + return 'free' + + def set_user_tier(self, user_id: str, tier: str) -> bool: + """ + Update the user's subscription tier. + + Args: + user_id: User ID + tier: New tier (free, basic, premium, elite) + + Returns: + True if update successful + + Raises: + AppwriteException: If update fails + ValueError: If tier is invalid + """ + valid_tiers = ['free', 'basic', 'premium', 'elite'] + if tier not in valid_tiers: + raise ValueError(f"Invalid tier: {tier}. Must be one of {valid_tiers}") + + try: + logger.info("Updating user tier", user_id=user_id, new_tier=tier) + + # Get current preferences + user = self.users.get(user_id=user_id) + prefs = user.get('prefs', {}) + + # Update tier + prefs['tier'] = tier + prefs['tier_updated_at'] = datetime.now(timezone.utc).isoformat() + + self.users.update_prefs(user_id=user_id, prefs=prefs) + + logger.info("User tier updated successfully", user_id=user_id, tier=tier) + return True + + except AppwriteException as e: + logger.error("Failed to update user tier", user_id=user_id, tier=tier, error=str(e), code=e.code) + raise + + def _user_to_userdata(self, user: Dict[str, Any]) -> UserData: + """ + Convert Appwrite user object to UserData dataclass. + + Args: + user: Appwrite user dictionary + + Returns: + UserData object + """ + # Get tier from preferences, default to 'free' + prefs = user.get('prefs', {}) + tier = prefs.get('tier', 'free') + + # Parse timestamps + created_at = user.get('$createdAt', datetime.now(timezone.utc).isoformat()) + updated_at = user.get('$updatedAt', datetime.now(timezone.utc).isoformat()) + + if isinstance(created_at, str): + created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + if isinstance(updated_at, str): + updated_at = datetime.fromisoformat(updated_at.replace('Z', '+00:00')) + + return UserData( + id=user['$id'], + email=user['email'], + name=user['name'], + email_verified=user.get('emailVerification', False), + tier=tier, + created_at=created_at, + updated_at=updated_at + ) diff --git a/api/app/services/character_service.py b/api/app/services/character_service.py new file mode 100644 index 0000000..519de16 --- /dev/null +++ b/api/app/services/character_service.py @@ -0,0 +1,1049 @@ +""" +Character Service - CRUD operations for player characters. + +This service handles creating, reading, updating, and deleting characters, +with enforcement of tier-based character limits, skill unlock validation, +and integration with Appwrite database. +""" + +import json +from typing import List, Optional, Dict, Any +from datetime import datetime + +from appwrite.query import Query +from appwrite.exception import AppwriteException +from appwrite.id import ID + +from app.models.character import Character +from app.models.skills import PlayerClass +from app.models.origins import Origin +from app.services.database_service import get_database_service +from app.services.appwrite_service import AppwriteService +from app.services.class_loader import get_class_loader +from app.services.origin_service import get_origin_service +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +# Character limits by tier +CHARACTER_LIMITS = { + 'free': 1, + 'basic': 3, + 'premium': 5, + 'elite': 10 +} + + +class CharacterLimitExceeded(Exception): + """Raised when user tries to create more characters than their tier allows.""" + pass + + +class CharacterNotFound(Exception): + """Raised when character ID doesn't exist or user doesn't own it.""" + pass + + +class SkillUnlockError(Exception): + """Raised when skill unlock validation fails.""" + pass + + +class InsufficientGold(Exception): + """Raised when character doesn't have enough gold for an operation.""" + pass + + +class CharacterService: + """ + Service for managing player characters. + + This service provides: + - Character creation with tier limit enforcement + - Character retrieval (single and list) + - Character deletion + - Skill unlock/respec functionality + """ + + def __init__(self): + """Initialize the character service with dependencies.""" + self.db = get_database_service() + self.appwrite = AppwriteService() + self.class_loader = get_class_loader() + self.origin_service = get_origin_service() + self.collection_id = "characters" + + logger.info("CharacterService initialized") + + def create_character( + self, + user_id: str, + name: str, + class_id: str, + origin_id: str + ) -> Character: + """ + Create a new character for a user. + + This method: + 1. Validates user hasn't exceeded tier character limit + 2. Loads class and origin data + 3. Creates character with default starting state + 4. Stores in Appwrite database + + Args: + user_id: Owner's user ID (from Appwrite auth) + name: Character name + class_id: PlayerClass ID (e.g., "warrior", "arcanist") + origin_id: Origin ID (e.g., "soul_revenant") + + Returns: + Created Character instance + + Raises: + CharacterLimitExceeded: If user has reached their tier limit + ValueError: If class or origin not found + AppwriteException: If database operation fails + """ + try: + logger.info("Creating character", + user_id=user_id, + name=name, + class_id=class_id, + origin_id=origin_id) + + # Check character limit for user's tier + tier = self.appwrite.get_user_tier(user_id) + current_count = self.count_user_characters(user_id) + limit = CHARACTER_LIMITS.get(tier, 1) + + if current_count >= limit: + logger.warning("Character limit exceeded", + user_id=user_id, + tier=tier, + current=current_count, + limit=limit) + raise CharacterLimitExceeded( + f"Character limit reached for {tier} tier ({current_count}/{limit}). " + f"Upgrade your subscription to create more characters." + ) + + # Load class and origin data + player_class = self.class_loader.load_class(class_id) + if not player_class: + raise ValueError(f"Class not found: {class_id}") + + origin = self.origin_service.load_origin(origin_id) + if not origin: + raise ValueError(f"Origin not found: {origin_id}") + + # Generate unique character ID + character_id = ID.unique() + + # Determine starting location - use location system if available + from app.services.location_loader import get_location_loader + location_loader = get_location_loader() + starting_locations = location_loader.get_starting_locations() + + if starting_locations: + # Use first starting location from location data (crossville_village) + start_loc = starting_locations[0] + starting_location_id = start_loc.location_id + else: + # Fallback to origin's starting location + starting_location_id = origin.starting_location.id + + # Create character instance with starting state + character = Character( + character_id=character_id, + user_id=user_id, + name=name, + player_class=player_class, + origin=origin, + level=1, + experience=0, + base_stats=player_class.base_stats.copy(), + unlocked_skills=[], + inventory=[], + equipped={}, + gold=0, + active_quests=[], + discovered_locations=[starting_location_id], # Initialize with starting location + current_location=starting_location_id # Set starting location + ) + + # Serialize character to JSON + character_dict = character.to_dict() + character_json = json.dumps(character_dict) + + # Store in database + document_data = { + 'userId': user_id, + 'characterData': character_json, + 'is_active': True + } + + self.db.create_document( + collection_id=self.collection_id, + data=document_data, + document_id=character_id + ) + + logger.info("Character created successfully", + character_id=character_id, + user_id=user_id, + class_id=class_id) + + return character + + except CharacterLimitExceeded: + raise + except Exception as e: + logger.error("Failed to create character", + user_id=user_id, + error=str(e)) + raise + + def get_character(self, character_id: str, user_id: str) -> Optional[Character]: + """ + Get a character by ID. + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + + Returns: + Character instance or None if not found + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + """ + try: + logger.debug("Fetching character", character_id=character_id, user_id=user_id) + + # Get document from database + document = self.db.get_row(self.collection_id, character_id) + + if not document: + logger.warning("Character not found", character_id=character_id) + raise CharacterNotFound(f"Character not found: {character_id}") + + # Verify ownership + if document.data.get('userId') != user_id: + logger.warning("Character ownership mismatch", + character_id=character_id, + expected_user=user_id, + actual_user=document.data.get('userId')) + raise CharacterNotFound(f"Character not found: {character_id}") + + # Parse character data + character_json = document.data.get('characterData') + character_dict = json.loads(character_json) + character = Character.from_dict(character_dict) + + logger.debug("Character fetched successfully", character_id=character_id) + return character + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to fetch character", + character_id=character_id, + error=str(e)) + raise + + def get_user_characters(self, user_id: str) -> List[Character]: + """ + Get all characters owned by a user. + + Args: + user_id: User ID + + Returns: + List of Character instances (may be empty) + """ + try: + logger.debug("Fetching user characters", user_id=user_id) + + # Query for active characters owned by user + queries = [ + Query.equal('userId', user_id), + Query.equal('is_active', True) + ] + + documents = self.db.list_rows( + table_id=self.collection_id, + queries=queries, + limit=100 # Max characters per user is 10 (elite tier) + ) + + # Parse all character data + characters = [] + for document in documents: + try: + character_json = document.data.get('characterData') + character_dict = json.loads(character_json) + character = Character.from_dict(character_dict) + characters.append(character) + except Exception as e: + logger.error("Failed to parse character", + document_id=document.id, + error=str(e)) + continue + + logger.debug("User characters fetched", + user_id=user_id, + count=len(characters)) + + return characters + + except Exception as e: + logger.error("Failed to fetch user characters", + user_id=user_id, + error=str(e)) + raise + + def count_user_characters(self, user_id: str) -> int: + """ + Count active characters owned by a user. + + Args: + user_id: User ID + + Returns: + Number of active characters + """ + try: + queries = [ + Query.equal('userId', user_id), + Query.equal('is_active', True) + ] + + count = self.db.count_documents( + collection_id=self.collection_id, + queries=queries + ) + + logger.debug("Character count", user_id=user_id, count=count) + return count + + except Exception as e: + logger.error("Failed to count characters", user_id=user_id, error=str(e)) + return 0 + + def delete_character(self, character_id: str, user_id: str) -> bool: + """ + Delete a character (soft delete by marking inactive). + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + + Returns: + True if deletion successful + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + """ + try: + logger.info("Deleting character", character_id=character_id, user_id=user_id) + + # Verify ownership first + character = self.get_character(character_id, user_id) + if not character: + raise CharacterNotFound(f"Character not found: {character_id}") + + # Soft delete by marking inactive + self.db.update_document( + collection_id=self.collection_id, + document_id=character_id, + data={'is_active': False} + ) + + logger.info("Character deleted successfully", character_id=character_id) + return True + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to delete character", + character_id=character_id, + error=str(e)) + raise + + def unlock_skill(self, character_id: str, user_id: str, skill_id: str) -> Character: + """ + Unlock a skill for a character. + + This method: + 1. Validates user owns the character + 2. Validates skill exists in character's class + 3. Validates prerequisites are met + 4. Validates character has skill points available + 5. Unlocks the skill + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + skill_id: Skill ID to unlock + + Returns: + Updated Character instance + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + SkillUnlockError: If skill unlock validation fails + """ + try: + logger.info("Unlocking skill", + character_id=character_id, + skill_id=skill_id) + + # Get character + character = self.get_character(character_id, user_id) + + # Check if skill already unlocked + if skill_id in character.unlocked_skills: + raise SkillUnlockError(f"Skill already unlocked: {skill_id}") + + # Get skill node from class + all_skills = character.player_class.get_all_skills() + skill_node = next((s for s in all_skills if s.skill_id == skill_id), None) + + if not skill_node: + raise SkillUnlockError(f"Skill not found in class: {skill_id}") + + # Check prerequisites + if skill_node.prerequisites: + for prereq in skill_node.prerequisites: + if prereq not in character.unlocked_skills: + raise SkillUnlockError( + f"Prerequisite not met: {prereq} required for {skill_id}" + ) + + # Calculate available skill points (1 per level, minus already unlocked) + available_points = character.level - len(character.unlocked_skills) + if available_points <= 0: + raise SkillUnlockError( + f"No skill points available (Level {character.level}, " + f"{len(character.unlocked_skills)} skills unlocked)" + ) + + # Unlock skill + character.unlocked_skills.append(skill_id) + + # Save to database + self._save_character(character) + + logger.info("Skill unlocked successfully", + character_id=character_id, + skill_id=skill_id) + + return character + + except (CharacterNotFound, SkillUnlockError): + raise + except Exception as e: + logger.error("Failed to unlock skill", + character_id=character_id, + skill_id=skill_id, + error=str(e)) + raise + + def respec_skills(self, character_id: str, user_id: str) -> Character: + """ + Reset all unlocked skills for a character. + + Cost: level × 100 gold + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + + Returns: + Updated Character instance + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + InsufficientGold: If character can't afford respec + """ + try: + logger.info("Respecing character skills", character_id=character_id) + + # Get character + character = self.get_character(character_id, user_id) + + # Calculate cost + respec_cost = character.level * 100 + + # Check gold + if character.gold < respec_cost: + raise InsufficientGold( + f"Insufficient gold for respec. Cost: {respec_cost}, Available: {character.gold}" + ) + + # Deduct gold + character.gold -= respec_cost + + # Clear all unlocked skills + character.unlocked_skills = [] + + # Save to database + self._save_character(character) + + logger.info("Skills respeced successfully", + character_id=character_id, + cost=respec_cost) + + return character + + except (CharacterNotFound, InsufficientGold): + raise + except Exception as e: + logger.error("Failed to respec skills", + character_id=character_id, + error=str(e)) + raise + + def update_character(self, character: Character, user_id: str) -> Character: + """ + Update a character's data. + + Args: + character: Character instance with updated data + user_id: User ID (for ownership validation) + + Returns: + Updated Character instance + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + """ + try: + logger.info("Updating character", character_id=character.character_id) + + # Verify ownership + existing = self.get_character(character.character_id, user_id) + if not existing: + raise CharacterNotFound(f"Character not found: {character.character_id}") + + # Save to database + self._save_character(character) + + logger.info("Character updated successfully", character_id=character.character_id) + return character + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to update character", + character_id=character.character_id, + error=str(e)) + raise + + # ==================== Location and NPC Tracking ==================== + + def unlock_location( + self, + character_id: str, + user_id: str, + location_id: str + ) -> Character: + """ + Add a location to character's discovered_locations. + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + location_id: Location ID to unlock + + Returns: + Updated Character instance + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + """ + try: + logger.info("Unlocking location", + character_id=character_id, + location_id=location_id) + + character = self.get_character(character_id, user_id) + + if location_id not in character.discovered_locations: + character.discovered_locations.append(location_id) + self._save_character(character) + logger.info("Location unlocked", + character_id=character_id, + location_id=location_id) + else: + logger.debug("Location already unlocked", + character_id=character_id, + location_id=location_id) + + return character + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to unlock location", + character_id=character_id, + location_id=location_id, + error=str(e)) + raise + + def update_npc_interaction( + self, + character_id: str, + user_id: str, + npc_id: str, + interaction_data: Dict[str, Any] + ) -> Character: + """ + Update NPC interaction state on character record. + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + npc_id: NPC ID to update interaction for + interaction_data: Dict containing interaction state fields + + Returns: + Updated Character instance + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + """ + try: + logger.info("Updating NPC interaction", + character_id=character_id, + npc_id=npc_id) + + character = self.get_character(character_id, user_id) + character.npc_interactions[npc_id] = interaction_data + self._save_character(character) + + logger.info("NPC interaction updated", + character_id=character_id, + npc_id=npc_id, + interaction_count=interaction_data.get('interaction_count', 0)) + + return character + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to update NPC interaction", + character_id=character_id, + npc_id=npc_id, + error=str(e)) + raise + + def get_npc_interaction( + self, + character_id: str, + user_id: str, + npc_id: str + ) -> Optional[Dict[str, Any]]: + """ + Get interaction state for a specific NPC. + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + npc_id: NPC ID to get interaction for + + Returns: + Interaction state dict or None if no interactions yet + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + """ + try: + character = self.get_character(character_id, user_id) + return character.npc_interactions.get(npc_id) + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to get NPC interaction", + character_id=character_id, + npc_id=npc_id, + error=str(e)) + raise + + def check_npc_secret_conditions( + self, + character: Character, + npc: Any # NPC type from models.npc + ) -> List[str]: + """ + Check which secrets the NPC would reveal based on character state. + + Evaluates the NPC's will_share_if conditions against the character's + interaction state and returns a list of secrets that should be revealed. + + Args: + character: Character instance + npc: NPC instance with knowledge and will_share_if conditions + + Returns: + List of secret strings that should be revealed this conversation + """ + if not npc.knowledge or not npc.knowledge.will_share_if: + return [] + + interaction = character.npc_interactions.get(npc.npc_id, {}) + revealed_indices = interaction.get("revealed_secrets", []) + reveals = [] + + for i, condition in enumerate(npc.knowledge.will_share_if): + # Skip already revealed secrets + if i in revealed_indices: + continue + + # Evaluate condition + if self._evaluate_npc_condition(condition.condition, interaction): + reveals.append(condition.reveals) + logger.debug("Secret condition met", + npc_id=npc.npc_id, + condition_index=i, + condition=condition.condition) + + return reveals + + def _evaluate_npc_condition( + self, + condition: str, + interaction: Dict[str, Any] + ) -> bool: + """ + Evaluate a condition string against interaction state. + + Supports simple condition patterns: + - "interaction_count >= N" + - "relationship_level >= N" + - "custom_flags.key == true/false" + + Args: + condition: Condition string to evaluate + interaction: Character's interaction state with this NPC + + Returns: + True if condition is met, False otherwise + """ + try: + condition = condition.strip() + + # Pattern: interaction_count >= N + if "interaction_count" in condition: + if ">=" in condition: + required = int(condition.split(">=")[1].strip()) + return interaction.get("interaction_count", 0) >= required + elif ">" in condition: + required = int(condition.split(">")[1].strip()) + return interaction.get("interaction_count", 0) > required + + # Pattern: relationship_level >= N + if "relationship_level" in condition: + if ">=" in condition: + required = int(condition.split(">=")[1].strip()) + return interaction.get("relationship_level", 50) >= required + elif ">" in condition: + required = int(condition.split(">")[1].strip()) + return interaction.get("relationship_level", 50) > required + + # Pattern: custom_flags.key == true/false + if "custom_flags." in condition: + if "==" in condition: + parts = condition.split("==") + flag_path = parts[0].strip().replace("custom_flags.", "") + expected_str = parts[1].strip().lower() + expected = expected_str == "true" + flags = interaction.get("custom_flags", {}) + return flags.get(flag_path) == expected + + # Unknown condition pattern - log warning and return False + logger.warning("Unknown condition pattern", condition=condition) + return False + + except Exception as e: + logger.error("Failed to evaluate condition", + condition=condition, + error=str(e)) + return False + + def mark_secret_revealed( + self, + character_id: str, + user_id: str, + npc_id: str, + secret_index: int + ) -> Character: + """ + Mark a secret as revealed so it won't be revealed again. + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + npc_id: NPC ID + secret_index: Index of the secret in will_share_if list + + Returns: + Updated Character instance + """ + try: + character = self.get_character(character_id, user_id) + + interaction = character.npc_interactions.get(npc_id, {}) + revealed = interaction.get("revealed_secrets", []) + + if secret_index not in revealed: + revealed.append(secret_index) + interaction["revealed_secrets"] = revealed + character.npc_interactions[npc_id] = interaction + self._save_character(character) + + return character + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to mark secret revealed", + character_id=character_id, + npc_id=npc_id, + secret_index=secret_index, + error=str(e)) + raise + + def set_npc_custom_flag( + self, + character_id: str, + user_id: str, + npc_id: str, + flag_name: str, + flag_value: Any + ) -> Character: + """ + Set a custom flag on an NPC interaction (e.g., "helped_with_rats": true). + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + npc_id: NPC ID + flag_name: Name of the flag + flag_value: Value to set + + Returns: + Updated Character instance + """ + try: + character = self.get_character(character_id, user_id) + + interaction = character.npc_interactions.get(npc_id, {}) + custom_flags = interaction.get("custom_flags", {}) + custom_flags[flag_name] = flag_value + interaction["custom_flags"] = custom_flags + character.npc_interactions[npc_id] = interaction + self._save_character(character) + + logger.info("NPC custom flag set", + character_id=character_id, + npc_id=npc_id, + flag_name=flag_name) + + return character + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to set NPC custom flag", + character_id=character_id, + npc_id=npc_id, + flag_name=flag_name, + error=str(e)) + raise + + def adjust_npc_relationship( + self, + character_id: str, + user_id: str, + npc_id: str, + adjustment: int + ) -> Character: + """ + Adjust relationship level with an NPC. + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + npc_id: NPC ID + adjustment: Amount to add/subtract from relationship (can be negative) + + Returns: + Updated Character instance + """ + try: + character = self.get_character(character_id, user_id) + + interaction = character.npc_interactions.get(npc_id, {}) + current_level = interaction.get("relationship_level", 50) + new_level = max(0, min(100, current_level + adjustment)) # Clamp 0-100 + interaction["relationship_level"] = new_level + character.npc_interactions[npc_id] = interaction + self._save_character(character) + + logger.info("NPC relationship adjusted", + character_id=character_id, + npc_id=npc_id, + old_level=current_level, + new_level=new_level) + + return character + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to adjust NPC relationship", + character_id=character_id, + npc_id=npc_id, + error=str(e)) + raise + + def add_npc_dialogue_exchange( + self, + character_id: str, + user_id: str, + npc_id: str, + player_line: str, + npc_response: str, + max_history: int = 10 + ) -> Character: + """ + Add a dialogue exchange to the NPC conversation history. + + Stores the player's message and NPC's response for context in future + conversations. History is capped at max_history entries per NPC. + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + npc_id: NPC ID + player_line: What the player said + npc_response: What the NPC responded + max_history: Maximum number of exchanges to keep per NPC (default 10) + + Returns: + Updated Character instance + """ + try: + character = self.get_character(character_id, user_id) + + interaction = character.npc_interactions.get(npc_id, {}) + dialogue_history = interaction.get("dialogue_history", []) + + # Add the new exchange + exchange = { + "player_line": player_line, + "npc_response": npc_response + } + dialogue_history.append(exchange) + + # Trim to max_history (keep most recent) + if len(dialogue_history) > max_history: + dialogue_history = dialogue_history[-max_history:] + + interaction["dialogue_history"] = dialogue_history + character.npc_interactions[npc_id] = interaction + self._save_character(character) + + logger.debug("NPC dialogue exchange added", + character_id=character_id, + npc_id=npc_id, + history_length=len(dialogue_history)) + + return character + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to add NPC dialogue exchange", + character_id=character_id, + npc_id=npc_id, + error=str(e)) + raise + + def get_npc_dialogue_history( + self, + character_id: str, + user_id: str, + npc_id: str, + limit: int = 5 + ) -> List[Dict[str, str]]: + """ + Get recent dialogue history with an NPC. + + Args: + character_id: Character ID + user_id: User ID (for ownership validation) + npc_id: NPC ID + limit: Maximum number of recent exchanges to return (default 5) + + Returns: + List of dialogue exchanges [{player_line: str, npc_response: str}, ...] + """ + try: + character = self.get_character(character_id, user_id) + + interaction = character.npc_interactions.get(npc_id, {}) + dialogue_history = interaction.get("dialogue_history", []) + + # Return most recent exchanges (up to limit) + return dialogue_history[-limit:] if dialogue_history else [] + + except CharacterNotFound: + raise + except Exception as e: + logger.error("Failed to get NPC dialogue history", + character_id=character_id, + npc_id=npc_id, + error=str(e)) + raise + + # ==================== End Location and NPC Tracking ==================== + + def _save_character(self, character: Character) -> None: + """ + Internal method to save character to database. + + Args: + character: Character instance to save + """ + # Serialize character to JSON + character_dict = character.to_dict() + character_json = json.dumps(character_dict) + + # Update in database + self.db.update_document( + collection_id=self.collection_id, + document_id=character.character_id, + data={'characterData': character_json} + ) + + +# Global instance for convenience +_service_instance: Optional[CharacterService] = None + + +def get_character_service() -> CharacterService: + """ + Get the global CharacterService instance. + + Returns: + Singleton CharacterService instance + """ + global _service_instance + if _service_instance is None: + _service_instance = CharacterService() + return _service_instance diff --git a/api/app/services/class_loader.py b/api/app/services/class_loader.py new file mode 100644 index 0000000..f5d1cee --- /dev/null +++ b/api/app/services/class_loader.py @@ -0,0 +1,277 @@ +""" +ClassLoader service for loading player class definitions from YAML files. + +This service reads class configuration files and converts them into PlayerClass +dataclass instances, providing caching for performance. +""" + +import yaml +from pathlib import Path +from typing import Dict, List, Optional +import structlog + +from app.models.skills import PlayerClass, SkillTree, SkillNode +from app.models.stats import Stats + +logger = structlog.get_logger(__name__) + + +class ClassLoader: + """ + Loads player class definitions from YAML configuration files. + + This allows game designers to define classes and skill trees without touching code. + All class definitions are stored in /app/data/classes/ as YAML files. + """ + + def __init__(self, data_dir: Optional[str] = None): + """ + Initialize the class loader. + + Args: + data_dir: Path to directory containing class YAML files. + Defaults to /app/data/classes/ + """ + if data_dir is None: + # Default to app/data/classes relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_dir = str(app_dir / "data" / "classes") + + self.data_dir = Path(data_dir) + self._class_cache: Dict[str, PlayerClass] = {} + + logger.info("ClassLoader initialized", data_dir=str(self.data_dir)) + + def load_class(self, class_id: str) -> Optional[PlayerClass]: + """ + Load a single player class by ID. + + Args: + class_id: Unique class identifier (e.g., "vanguard") + + Returns: + PlayerClass instance or None if not found + """ + # Check cache first + if class_id in self._class_cache: + logger.debug("Class loaded from cache", class_id=class_id) + return self._class_cache[class_id] + + # Construct file path + file_path = self.data_dir / f"{class_id}.yaml" + + if not file_path.exists(): + logger.warning("Class file not found", class_id=class_id, file_path=str(file_path)) + return None + + try: + # Load YAML file + with open(file_path, 'r') as f: + data = yaml.safe_load(f) + + # Parse into PlayerClass + player_class = self._parse_class_data(data) + + # Cache the result + self._class_cache[class_id] = player_class + + logger.info("Class loaded successfully", class_id=class_id) + return player_class + + except Exception as e: + logger.error("Failed to load class", class_id=class_id, error=str(e)) + return None + + def load_all_classes(self) -> List[PlayerClass]: + """ + Load all player classes from the data directory. + + Returns: + List of PlayerClass instances + """ + classes = [] + + # Find all YAML files in the directory + if not self.data_dir.exists(): + logger.error("Class data directory does not exist", data_dir=str(self.data_dir)) + return classes + + for file_path in self.data_dir.glob("*.yaml"): + class_id = file_path.stem # Get filename without extension + player_class = self.load_class(class_id) + if player_class: + classes.append(player_class) + + logger.info("All classes loaded", count=len(classes)) + return classes + + def get_class_by_id(self, class_id: str) -> Optional[PlayerClass]: + """ + Get a player class by ID (alias for load_class). + + Args: + class_id: Unique class identifier + + Returns: + PlayerClass instance or None if not found + """ + return self.load_class(class_id) + + def get_all_class_ids(self) -> List[str]: + """ + Get a list of all available class IDs. + + Returns: + List of class IDs (e.g., ["vanguard", "assassin", "arcanist"]) + """ + if not self.data_dir.exists(): + return [] + + return [file_path.stem for file_path in self.data_dir.glob("*.yaml")] + + def reload_class(self, class_id: str) -> Optional[PlayerClass]: + """ + Force reload a class from disk, bypassing cache. + + Useful for development/testing when class definitions change. + + Args: + class_id: Unique class identifier + + Returns: + PlayerClass instance or None if not found + """ + # Remove from cache if present + if class_id in self._class_cache: + del self._class_cache[class_id] + + return self.load_class(class_id) + + def clear_cache(self): + """Clear the class cache. Useful for testing.""" + self._class_cache.clear() + logger.info("Class cache cleared") + + def _parse_class_data(self, data: Dict) -> PlayerClass: + """ + Parse YAML data into a PlayerClass dataclass. + + Args: + data: Dictionary loaded from YAML file + + Returns: + PlayerClass instance + + Raises: + ValueError: If data is invalid or missing required fields + """ + # Validate required fields + required_fields = ["class_id", "name", "description", "base_stats", "skill_trees"] + for field in required_fields: + if field not in data: + raise ValueError(f"Missing required field: {field}") + + # Parse base stats + base_stats = Stats(**data["base_stats"]) + + # Parse skill trees + skill_trees = [] + for tree_data in data["skill_trees"]: + skill_tree = self._parse_skill_tree(tree_data) + skill_trees.append(skill_tree) + + # Get optional fields + starting_equipment = data.get("starting_equipment", []) + starting_abilities = data.get("starting_abilities", []) + + # Create PlayerClass instance + player_class = PlayerClass( + class_id=data["class_id"], + name=data["name"], + description=data["description"], + base_stats=base_stats, + skill_trees=skill_trees, + starting_equipment=starting_equipment, + starting_abilities=starting_abilities + ) + + return player_class + + def _parse_skill_tree(self, tree_data: Dict) -> SkillTree: + """ + Parse a skill tree from YAML data. + + Args: + tree_data: Dictionary containing skill tree data + + Returns: + SkillTree instance + """ + # Validate required fields + required_fields = ["tree_id", "name", "description", "nodes"] + for field in required_fields: + if field not in tree_data: + raise ValueError(f"Missing required field in skill tree: {field}") + + # Parse skill nodes + nodes = [] + for node_data in tree_data["nodes"]: + skill_node = self._parse_skill_node(node_data) + nodes.append(skill_node) + + # Create SkillTree instance + skill_tree = SkillTree( + tree_id=tree_data["tree_id"], + name=tree_data["name"], + description=tree_data["description"], + nodes=nodes + ) + + return skill_tree + + def _parse_skill_node(self, node_data: Dict) -> SkillNode: + """ + Parse a skill node from YAML data. + + Args: + node_data: Dictionary containing skill node data + + Returns: + SkillNode instance + """ + # Validate required fields + required_fields = ["skill_id", "name", "description", "tier", "effects"] + for field in required_fields: + if field not in node_data: + raise ValueError(f"Missing required field in skill node: {field}") + + # Create SkillNode instance + skill_node = SkillNode( + skill_id=node_data["skill_id"], + name=node_data["name"], + description=node_data["description"], + tier=node_data["tier"], + prerequisites=node_data.get("prerequisites", []), + effects=node_data.get("effects", {}), + unlocked=False # Always start locked + ) + + return skill_node + + +# Global instance for convenience +_loader_instance: Optional[ClassLoader] = None + + +def get_class_loader() -> ClassLoader: + """ + Get the global ClassLoader instance. + + Returns: + Singleton ClassLoader instance + """ + global _loader_instance + if _loader_instance is None: + _loader_instance = ClassLoader() + return _loader_instance diff --git a/api/app/services/database_init.py b/api/app/services/database_init.py new file mode 100644 index 0000000..1c95a13 --- /dev/null +++ b/api/app/services/database_init.py @@ -0,0 +1,709 @@ +""" +Database Initialization Service. + +This service handles programmatic creation of Appwrite database tables, +including schema definition, column creation, and index setup. +""" + +import os +import time +from typing import List, Dict, Any, Optional + +from appwrite.client import Client +from appwrite.services.tables_db import TablesDB +from appwrite.exception import AppwriteException + +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +class DatabaseInitService: + """ + Service for initializing Appwrite database tables. + + This service provides methods to: + - Create tables if they don't exist + - Define table schemas (columns/attributes) + - Create indexes for efficient querying + - Validate existing table structures + """ + + def __init__(self): + """ + Initialize the database initialization service. + + Reads configuration from environment variables: + - APPWRITE_ENDPOINT: Appwrite API endpoint + - APPWRITE_PROJECT_ID: Appwrite project ID + - APPWRITE_API_KEY: Appwrite API key + - APPWRITE_DATABASE_ID: Appwrite database ID + """ + self.endpoint = os.getenv('APPWRITE_ENDPOINT') + self.project_id = os.getenv('APPWRITE_PROJECT_ID') + self.api_key = os.getenv('APPWRITE_API_KEY') + self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main') + + if not all([self.endpoint, self.project_id, self.api_key]): + logger.error("Missing Appwrite configuration in environment variables") + raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.") + + # Initialize Appwrite client + self.client = Client() + self.client.set_endpoint(self.endpoint) + self.client.set_project(self.project_id) + self.client.set_key(self.api_key) + + # Initialize TablesDB service + self.tables_db = TablesDB(self.client) + + logger.info("DatabaseInitService initialized", database_id=self.database_id) + + def init_all_tables(self) -> Dict[str, bool]: + """ + Initialize all application tables. + + Returns: + Dictionary mapping table names to success status + """ + results = {} + + logger.info("Initializing all database tables") + + # Initialize characters table + try: + self.init_characters_table() + results['characters'] = True + logger.info("Characters table initialized successfully") + except Exception as e: + logger.error("Failed to initialize characters table", error=str(e)) + results['characters'] = False + + # Initialize game_sessions table + try: + self.init_game_sessions_table() + results['game_sessions'] = True + logger.info("Game sessions table initialized successfully") + except Exception as e: + logger.error("Failed to initialize game_sessions table", error=str(e)) + results['game_sessions'] = False + + # Initialize ai_usage_logs table + try: + self.init_ai_usage_logs_table() + results['ai_usage_logs'] = True + logger.info("AI usage logs table initialized successfully") + except Exception as e: + logger.error("Failed to initialize ai_usage_logs table", error=str(e)) + results['ai_usage_logs'] = False + + success_count = sum(1 for v in results.values() if v) + total_count = len(results) + + logger.info("Table initialization complete", + success=success_count, + total=total_count, + results=results) + + return results + + def init_characters_table(self) -> bool: + """ + Initialize the characters table. + + Table schema: + - userId (string, required): Owner's user ID + - characterData (string, required): JSON-serialized character data + - is_active (boolean, default=True): Soft delete flag + - created_at (datetime): Auto-managed creation timestamp + - updated_at (datetime): Auto-managed update timestamp + + Indexes: + - userId: For general user queries + - userId + is_active: Composite index for efficiently fetching active characters + + Returns: + True if successful + + Raises: + AppwriteException: If table creation fails + """ + table_id = 'characters' + + logger.info("Initializing characters table", table_id=table_id) + + try: + # Check if table already exists + try: + self.tables_db.get_table( + database_id=self.database_id, + table_id=table_id + ) + logger.info("Characters table already exists", table_id=table_id) + return True + except AppwriteException as e: + if e.code != 404: + raise + logger.info("Characters table does not exist, creating...") + + # Create table + logger.info("Creating characters table") + table = self.tables_db.create_table( + database_id=self.database_id, + table_id=table_id, + name='Characters' + ) + logger.info("Characters table created", table_id=table['$id']) + + # Create columns + self._create_column( + table_id=table_id, + column_id='userId', + column_type='string', + size=255, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='characterData', + column_type='string', + size=65535, # Large text field for JSON data + required=True + ) + + self._create_column( + table_id=table_id, + column_id='is_active', + column_type='boolean', + required=False, # Cannot be required if we want a default value + default=True + ) + + # Note: created_at and updated_at are auto-managed by DatabaseService + # through the _parse_row method and timestamp updates + + # Wait for columns to fully propagate in Appwrite before creating indexes + logger.info("Waiting for columns to propagate before creating indexes...") + time.sleep(2) + + # Create indexes for efficient querying + # Note: Individual userId index for general user queries + self._create_index( + table_id=table_id, + index_id='idx_userId', + index_type='key', + attributes=['userId'] + ) + + # Composite index for the most common query pattern: + # Query.equal('userId', user_id) + Query.equal('is_active', True) + # This single composite index covers both conditions efficiently + self._create_index( + table_id=table_id, + index_id='idx_userId_is_active', + index_type='key', + attributes=['userId', 'is_active'] + ) + + logger.info("Characters table initialized successfully", table_id=table_id) + return True + + except AppwriteException as e: + logger.error("Failed to initialize characters table", + table_id=table_id, + error=str(e), + code=e.code) + raise + + def init_game_sessions_table(self) -> bool: + """ + Initialize the game_sessions table. + + Table schema: + - userId (string, required): Owner's user ID + - characterId (string, required): Character ID for this session + - sessionData (string, required): JSON-serialized session data + - status (string, required): Session status (active, completed, abandoned) + - sessionType (string, required): Session type (solo, multiplayer) + + Indexes: + - userId: For user session queries + - userId + status: For active session queries + - characterId: For character session lookups + + Returns: + True if successful + + Raises: + AppwriteException: If table creation fails + """ + table_id = 'game_sessions' + + logger.info("Initializing game_sessions table", table_id=table_id) + + try: + # Check if table already exists + try: + self.tables_db.get_table( + database_id=self.database_id, + table_id=table_id + ) + logger.info("Game sessions table already exists", table_id=table_id) + return True + except AppwriteException as e: + if e.code != 404: + raise + logger.info("Game sessions table does not exist, creating...") + + # Create table + logger.info("Creating game_sessions table") + table = self.tables_db.create_table( + database_id=self.database_id, + table_id=table_id, + name='Game Sessions' + ) + logger.info("Game sessions table created", table_id=table['$id']) + + # Create columns + self._create_column( + table_id=table_id, + column_id='userId', + column_type='string', + size=255, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='characterId', + column_type='string', + size=255, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='sessionData', + column_type='string', + size=65535, # Large text field for JSON data + required=True + ) + + self._create_column( + table_id=table_id, + column_id='status', + column_type='string', + size=50, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='sessionType', + column_type='string', + size=50, + required=True + ) + + # Wait for columns to fully propagate + logger.info("Waiting for columns to propagate before creating indexes...") + time.sleep(2) + + # Create indexes + self._create_index( + table_id=table_id, + index_id='idx_userId', + index_type='key', + attributes=['userId'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_userId_status', + index_type='key', + attributes=['userId', 'status'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_characterId', + index_type='key', + attributes=['characterId'] + ) + + logger.info("Game sessions table initialized successfully", table_id=table_id) + return True + + except AppwriteException as e: + logger.error("Failed to initialize game_sessions table", + table_id=table_id, + error=str(e), + code=e.code) + raise + + def init_ai_usage_logs_table(self) -> bool: + """ + Initialize the ai_usage_logs table for tracking AI API usage and costs. + + Table schema: + - user_id (string, required): User who made the request + - timestamp (string, required): ISO timestamp of the request + - model (string, required): Model identifier + - tokens_input (integer, required): Input token count + - tokens_output (integer, required): Output token count + - tokens_total (integer, required): Total token count + - estimated_cost (float, required): Estimated cost in USD + - task_type (string, required): Type of task + - session_id (string, optional): Game session ID + - character_id (string, optional): Character ID + - request_duration_ms (integer): Request duration in milliseconds + - success (boolean): Whether request succeeded + - error_message (string, optional): Error message if failed + + Indexes: + - user_id: For user usage queries + - timestamp: For date range queries + - user_id + timestamp: Composite for user date range queries + + Returns: + True if successful + + Raises: + AppwriteException: If table creation fails + """ + table_id = 'ai_usage_logs' + + logger.info("Initializing ai_usage_logs table", table_id=table_id) + + try: + # Check if table already exists + try: + self.tables_db.get_table( + database_id=self.database_id, + table_id=table_id + ) + logger.info("AI usage logs table already exists", table_id=table_id) + return True + except AppwriteException as e: + if e.code != 404: + raise + logger.info("AI usage logs table does not exist, creating...") + + # Create table + logger.info("Creating ai_usage_logs table") + table = self.tables_db.create_table( + database_id=self.database_id, + table_id=table_id, + name='AI Usage Logs' + ) + logger.info("AI usage logs table created", table_id=table['$id']) + + # Create columns + self._create_column( + table_id=table_id, + column_id='user_id', + column_type='string', + size=255, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='timestamp', + column_type='string', + size=50, # ISO timestamp format + required=True + ) + + self._create_column( + table_id=table_id, + column_id='model', + column_type='string', + size=255, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='tokens_input', + column_type='integer', + required=True + ) + + self._create_column( + table_id=table_id, + column_id='tokens_output', + column_type='integer', + required=True + ) + + self._create_column( + table_id=table_id, + column_id='tokens_total', + column_type='integer', + required=True + ) + + self._create_column( + table_id=table_id, + column_id='estimated_cost', + column_type='float', + required=True + ) + + self._create_column( + table_id=table_id, + column_id='task_type', + column_type='string', + size=50, + required=True + ) + + self._create_column( + table_id=table_id, + column_id='session_id', + column_type='string', + size=255, + required=False + ) + + self._create_column( + table_id=table_id, + column_id='character_id', + column_type='string', + size=255, + required=False + ) + + self._create_column( + table_id=table_id, + column_id='request_duration_ms', + column_type='integer', + required=False, + default=0 + ) + + self._create_column( + table_id=table_id, + column_id='success', + column_type='boolean', + required=False, + default=True + ) + + self._create_column( + table_id=table_id, + column_id='error_message', + column_type='string', + size=1000, + required=False + ) + + # Wait for columns to fully propagate + logger.info("Waiting for columns to propagate before creating indexes...") + time.sleep(2) + + # Create indexes + self._create_index( + table_id=table_id, + index_id='idx_user_id', + index_type='key', + attributes=['user_id'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_timestamp', + index_type='key', + attributes=['timestamp'] + ) + + self._create_index( + table_id=table_id, + index_id='idx_user_id_timestamp', + index_type='key', + attributes=['user_id', 'timestamp'] + ) + + logger.info("AI usage logs table initialized successfully", table_id=table_id) + return True + + except AppwriteException as e: + logger.error("Failed to initialize ai_usage_logs table", + table_id=table_id, + error=str(e), + code=e.code) + raise + + def _create_column( + self, + table_id: str, + column_id: str, + column_type: str, + size: Optional[int] = None, + required: bool = False, + default: Optional[Any] = None, + array: bool = False + ) -> Dict[str, Any]: + """ + Create a column in a table. + + Args: + table_id: Table ID + column_id: Column ID + column_type: Column type (string, integer, float, boolean, datetime, email, ip, url) + size: Column size (for string types) + required: Whether column is required + default: Default value + array: Whether column is an array + + Returns: + Column creation response + + Raises: + AppwriteException: If column creation fails + """ + try: + logger.info("Creating column", + table_id=table_id, + column_id=column_id, + column_type=column_type) + + # Build column parameters (Appwrite SDK uses 'key' not 'column_id') + params = { + 'database_id': self.database_id, + 'table_id': table_id, + 'key': column_id, + 'required': required, + 'array': array + } + + if size is not None: + params['size'] = size + + if default is not None: + params['default'] = default + + # Create column using the appropriate method based on type + if column_type == 'string': + result = self.tables_db.create_string_column(**params) + elif column_type == 'integer': + result = self.tables_db.create_integer_column(**params) + elif column_type == 'float': + result = self.tables_db.create_float_column(**params) + elif column_type == 'boolean': + result = self.tables_db.create_boolean_column(**params) + elif column_type == 'datetime': + result = self.tables_db.create_datetime_column(**params) + elif column_type == 'email': + result = self.tables_db.create_email_column(**params) + else: + raise ValueError(f"Unsupported column type: {column_type}") + + logger.info("Column created successfully", + table_id=table_id, + column_id=column_id) + + return result + + except AppwriteException as e: + # If column already exists, log warning but don't fail + if e.code == 409: # Conflict - column already exists + logger.warning("Column already exists", + table_id=table_id, + column_id=column_id) + return {} + logger.error("Failed to create column", + table_id=table_id, + column_id=column_id, + error=str(e), + code=e.code) + raise + + def _create_index( + self, + table_id: str, + index_id: str, + index_type: str, + attributes: List[str], + orders: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Create an index on a table. + + Args: + table_id: Table ID + index_id: Index ID + index_type: Index type (key, fulltext, unique) + attributes: List of column IDs to index + orders: List of sort orders (ASC, DESC) for each attribute + + Returns: + Index creation response + + Raises: + AppwriteException: If index creation fails + """ + try: + logger.info("Creating index", + table_id=table_id, + index_id=index_id, + attributes=attributes) + + result = self.tables_db.create_index( + database_id=self.database_id, + table_id=table_id, + key=index_id, + type=index_type, + columns=attributes, # SDK uses 'columns', not 'attributes' + orders=orders or ['ASC'] * len(attributes) + ) + + logger.info("Index created successfully", + table_id=table_id, + index_id=index_id) + + return result + + except AppwriteException as e: + # If index already exists, log warning but don't fail + if e.code == 409: # Conflict - index already exists + logger.warning("Index already exists", + table_id=table_id, + index_id=index_id) + return {} + logger.error("Failed to create index", + table_id=table_id, + index_id=index_id, + error=str(e), + code=e.code) + raise + + +# Global instance for convenience +_init_service_instance: Optional[DatabaseInitService] = None + + +def get_database_init_service() -> DatabaseInitService: + """ + Get the global DatabaseInitService instance. + + Returns: + Singleton DatabaseInitService instance + """ + global _init_service_instance + if _init_service_instance is None: + _init_service_instance = DatabaseInitService() + return _init_service_instance + + +def init_database() -> Dict[str, bool]: + """ + Convenience function to initialize all database tables. + + Returns: + Dictionary mapping table names to success status + """ + service = get_database_init_service() + return service.init_all_tables() diff --git a/api/app/services/database_service.py b/api/app/services/database_service.py new file mode 100644 index 0000000..c19facb --- /dev/null +++ b/api/app/services/database_service.py @@ -0,0 +1,441 @@ +""" +Database Service for Appwrite database operations. + +This service wraps the Appwrite Databases SDK to provide a clean interface +for CRUD operations on collections. It handles JSON serialization, error handling, +and provides structured logging. +""" + +import os +from typing import Dict, Any, List, Optional +from datetime import datetime, timezone +from dataclasses import dataclass + +from appwrite.client import Client +from appwrite.services.tables_db import TablesDB +from appwrite.exception import AppwriteException +from appwrite.id import ID + +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +@dataclass +class DatabaseRow: + """ + Represents a row in an Appwrite table. + + Attributes: + id: Row ID + table_id: Table ID + data: Row data (parsed from JSON) + created_at: Creation timestamp + updated_at: Last update timestamp + """ + id: str + table_id: str + data: Dict[str, Any] + created_at: datetime + updated_at: datetime + + def to_dict(self) -> Dict[str, Any]: + """Convert row to dictionary.""" + return { + "id": self.id, + "table_id": self.table_id, + "data": self.data, + "created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at, + "updated_at": self.updated_at.isoformat() if isinstance(self.updated_at, datetime) else self.updated_at, + } + + +class DatabaseService: + """ + Service for interacting with Appwrite database tables. + + This service provides methods for: + - Creating rows + - Reading rows by ID or query + - Updating rows + - Deleting rows + - Querying with filters + """ + + def __init__(self): + """ + Initialize the database service. + + Reads configuration from environment variables: + - APPWRITE_ENDPOINT: Appwrite API endpoint + - APPWRITE_PROJECT_ID: Appwrite project ID + - APPWRITE_API_KEY: Appwrite API key + - APPWRITE_DATABASE_ID: Appwrite database ID + """ + self.endpoint = os.getenv('APPWRITE_ENDPOINT') + self.project_id = os.getenv('APPWRITE_PROJECT_ID') + self.api_key = os.getenv('APPWRITE_API_KEY') + self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main') + + if not all([self.endpoint, self.project_id, self.api_key]): + logger.error("Missing Appwrite configuration in environment variables") + raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.") + + # Initialize Appwrite client + self.client = Client() + self.client.set_endpoint(self.endpoint) + self.client.set_project(self.project_id) + self.client.set_key(self.api_key) + + # Initialize TablesDB service + self.tables_db = TablesDB(self.client) + + logger.info("DatabaseService initialized", database_id=self.database_id) + + def create_row( + self, + table_id: str, + data: Dict[str, Any], + row_id: Optional[str] = None, + permissions: Optional[List[str]] = None + ) -> DatabaseRow: + """ + Create a new row in a table. + + Args: + table_id: Table ID (e.g., "characters") + data: Row data (will be JSON-serialized if needed) + row_id: Optional custom row ID (auto-generated if None) + permissions: Optional permissions array + + Returns: + DatabaseRow with created row + + Raises: + AppwriteException: If creation fails + """ + try: + logger.info("Creating row", table_id=table_id, has_custom_id=bool(row_id)) + + # Generate ID if not provided + if row_id is None: + row_id = ID.unique() + + # Create row (Appwrite manages timestamps automatically via $createdAt/$updatedAt) + result = self.tables_db.create_row( + database_id=self.database_id, + table_id=table_id, + row_id=row_id, + data=data, + permissions=permissions or [] + ) + + logger.info("Row created successfully", + table_id=table_id, + row_id=result['$id']) + + return self._parse_row(result, table_id) + + except AppwriteException as e: + logger.error("Failed to create row", + table_id=table_id, + error=str(e), + code=e.code) + raise + + def get_row(self, table_id: str, row_id: str) -> Optional[DatabaseRow]: + """ + Get a row by ID. + + Args: + table_id: Table ID + row_id: Row ID + + Returns: + DatabaseRow or None if not found + + Raises: + AppwriteException: If retrieval fails (except 404) + """ + try: + logger.debug("Fetching row", table_id=table_id, row_id=row_id) + + result = self.tables_db.get_row( + database_id=self.database_id, + table_id=table_id, + row_id=row_id + ) + + return self._parse_row(result, table_id) + + except AppwriteException as e: + if e.code == 404: + logger.warning("Row not found", + table_id=table_id, + row_id=row_id) + return None + logger.error("Failed to fetch row", + table_id=table_id, + row_id=row_id, + error=str(e), + code=e.code) + raise + + def update_row( + self, + table_id: str, + row_id: str, + data: Dict[str, Any], + permissions: Optional[List[str]] = None + ) -> DatabaseRow: + """ + Update an existing row. + + Args: + table_id: Table ID + row_id: Row ID + data: New row data (partial updates supported) + permissions: Optional permissions array + + Returns: + DatabaseRow with updated row + + Raises: + AppwriteException: If update fails + """ + try: + logger.info("Updating row", table_id=table_id, row_id=row_id) + + # Update row (Appwrite manages timestamps automatically via $updatedAt) + result = self.tables_db.update_row( + database_id=self.database_id, + table_id=table_id, + row_id=row_id, + data=data, + permissions=permissions + ) + + logger.info("Row updated successfully", + table_id=table_id, + row_id=row_id) + + return self._parse_row(result, table_id) + + except AppwriteException as e: + logger.error("Failed to update row", + table_id=table_id, + row_id=row_id, + error=str(e), + code=e.code) + raise + + def delete_row(self, table_id: str, row_id: str) -> bool: + """ + Delete a row. + + Args: + table_id: Table ID + row_id: Row ID + + Returns: + True if deletion successful + + Raises: + AppwriteException: If deletion fails + """ + try: + logger.info("Deleting row", table_id=table_id, row_id=row_id) + + self.tables_db.delete_row( + database_id=self.database_id, + table_id=table_id, + row_id=row_id + ) + + logger.info("Row deleted successfully", + table_id=table_id, + row_id=row_id) + return True + + except AppwriteException as e: + logger.error("Failed to delete row", + table_id=table_id, + row_id=row_id, + error=str(e), + code=e.code) + raise + + def list_rows( + self, + table_id: str, + queries: Optional[List[str]] = None, + limit: int = 25, + offset: int = 0 + ) -> List[DatabaseRow]: + """ + List rows in a table with optional filtering. + + Args: + table_id: Table ID + queries: Optional Appwrite query filters + limit: Maximum rows to return (default 25, max 100) + offset: Number of rows to skip + + Returns: + List of DatabaseRow instances + + Raises: + AppwriteException: If query fails + """ + try: + logger.debug("Listing rows", + table_id=table_id, + has_queries=bool(queries), + limit=limit, + offset=offset) + + result = self.tables_db.list_rows( + database_id=self.database_id, + table_id=table_id, + queries=queries or [] + ) + + rows = [self._parse_row(row, table_id) for row in result['rows']] + + logger.debug("Rows listed successfully", + table_id=table_id, + count=len(rows), + total=result.get('total', len(rows))) + + return rows + + except AppwriteException as e: + logger.error("Failed to list rows", + table_id=table_id, + error=str(e), + code=e.code) + raise + + def count_rows(self, table_id: str, queries: Optional[List[str]] = None) -> int: + """ + Count rows in a table with optional filtering. + + Args: + table_id: Table ID + queries: Optional Appwrite query filters + + Returns: + Row count + + Raises: + AppwriteException: If query fails + """ + try: + logger.debug("Counting rows", table_id=table_id, has_queries=bool(queries)) + + result = self.tables_db.list_rows( + database_id=self.database_id, + table_id=table_id, + queries=queries or [] + ) + + count = result.get('total', len(result.get('rows', []))) + logger.debug("Rows counted", table_id=table_id, count=count) + return count + + except AppwriteException as e: + logger.error("Failed to count rows", + table_id=table_id, + error=str(e), + code=e.code) + raise + + def _parse_row(self, row: Dict[str, Any], table_id: str) -> DatabaseRow: + """ + Parse Appwrite row into DatabaseRow. + + Args: + row: Appwrite row dictionary + table_id: Table ID + + Returns: + DatabaseRow instance + """ + # Extract metadata + row_id = row['$id'] + created_at = row.get('$createdAt', datetime.now(timezone.utc).isoformat()) + updated_at = row.get('$updatedAt', datetime.now(timezone.utc).isoformat()) + + # Parse timestamps + if isinstance(created_at, str): + created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + if isinstance(updated_at, str): + updated_at = datetime.fromisoformat(updated_at.replace('Z', '+00:00')) + + # Remove Appwrite metadata from data + data = {k: v for k, v in row.items() if not k.startswith('$')} + + return DatabaseRow( + id=row_id, + table_id=table_id, + data=data, + created_at=created_at, + updated_at=updated_at + ) + + # Backward compatibility aliases (deprecated, use new methods) + def create_document(self, collection_id: str, data: Dict[str, Any], + document_id: Optional[str] = None, + permissions: Optional[List[str]] = None) -> DatabaseRow: + """Deprecated: Use create_row() instead.""" + logger.warning("create_document() is deprecated, use create_row() instead") + return self.create_row(collection_id, data, document_id, permissions) + + def get_document(self, collection_id: str, document_id: str) -> Optional[DatabaseRow]: + """Deprecated: Use get_row() instead.""" + logger.warning("get_document() is deprecated, use get_row() instead") + return self.get_row(collection_id, document_id) + + def update_document(self, collection_id: str, document_id: str, + data: Dict[str, Any], + permissions: Optional[List[str]] = None) -> DatabaseRow: + """Deprecated: Use update_row() instead.""" + logger.warning("update_document() is deprecated, use update_row() instead") + return self.update_row(collection_id, document_id, data, permissions) + + def delete_document(self, collection_id: str, document_id: str) -> bool: + """Deprecated: Use delete_row() instead.""" + logger.warning("delete_document() is deprecated, use delete_row() instead") + return self.delete_row(collection_id, document_id) + + def list_documents(self, collection_id: str, queries: Optional[List[str]] = None, + limit: int = 25, offset: int = 0) -> List[DatabaseRow]: + """Deprecated: Use list_rows() instead.""" + logger.warning("list_documents() is deprecated, use list_rows() instead") + return self.list_rows(collection_id, queries, limit, offset) + + def count_documents(self, collection_id: str, queries: Optional[List[str]] = None) -> int: + """Deprecated: Use count_rows() instead.""" + logger.warning("count_documents() is deprecated, use count_rows() instead") + return self.count_rows(collection_id, queries) + + +# Backward compatibility alias +DatabaseDocument = DatabaseRow + + +# Global instance for convenience +_service_instance: Optional[DatabaseService] = None + + +def get_database_service() -> DatabaseService: + """ + Get the global DatabaseService instance. + + Returns: + Singleton DatabaseService instance + """ + global _service_instance + if _service_instance is None: + _service_instance = DatabaseService() + return _service_instance diff --git a/api/app/services/item_validator.py b/api/app/services/item_validator.py new file mode 100644 index 0000000..3f17e2f --- /dev/null +++ b/api/app/services/item_validator.py @@ -0,0 +1,351 @@ +""" +Item validation service for AI-granted items. + +This module validates and resolves items that the AI grants to players during +gameplay, ensuring they meet character requirements and game balance rules. +""" + +import uuid +from pathlib import Path +from typing import Optional + +import structlog +import yaml + +from app.models.items import Item +from app.models.enums import ItemType +from app.models.character import Character +from app.ai.response_parser import ItemGrant + +logger = structlog.get_logger(__name__) + + +class ItemValidationError(Exception): + """ + Exception raised when an item fails validation. + + Attributes: + message: Human-readable error message + item_grant: The ItemGrant that failed validation + reason: Machine-readable reason code + """ + + def __init__(self, message: str, item_grant: ItemGrant, reason: str): + super().__init__(message) + self.message = message + self.item_grant = item_grant + self.reason = reason + + +class ItemValidator: + """ + Validates and resolves items granted by the AI. + + This service: + 1. Resolves item references (by ID or creates generic items) + 2. Validates items against character requirements + 3. Logs validation failures for review + """ + + # Map of generic item type strings to ItemType enums + TYPE_MAP = { + "weapon": ItemType.WEAPON, + "armor": ItemType.ARMOR, + "consumable": ItemType.CONSUMABLE, + "quest_item": ItemType.QUEST_ITEM, + } + + def __init__(self, data_path: Optional[Path] = None): + """ + Initialize the item validator. + + Args: + data_path: Path to game data directory. Defaults to app/data/ + """ + if data_path is None: + # Default to api/app/data/ + data_path = Path(__file__).parent.parent / "data" + + self.data_path = data_path + self._item_registry: dict[str, dict] = {} + self._generic_templates: dict[str, dict] = {} + self._load_data() + + logger.info( + "ItemValidator initialized", + items_loaded=len(self._item_registry), + generic_templates_loaded=len(self._generic_templates) + ) + + def _load_data(self) -> None: + """Load item data from YAML files.""" + # Load main item registry if it exists + items_file = self.data_path / "items.yaml" + if items_file.exists(): + with open(items_file) as f: + data = yaml.safe_load(f) or {} + self._item_registry = data.get("items", {}) + + # Load generic item templates + generic_file = self.data_path / "generic_items.yaml" + if generic_file.exists(): + with open(generic_file) as f: + data = yaml.safe_load(f) or {} + self._generic_templates = data.get("templates", {}) + + def resolve_item(self, item_grant: ItemGrant) -> Item: + """ + Resolve an ItemGrant to an actual Item instance. + + For existing items (by item_id), looks up from item registry. + For generic items (by name/type), creates a new Item. + + Args: + item_grant: The ItemGrant from AI response + + Returns: + Resolved Item instance + + Raises: + ItemValidationError: If item cannot be resolved + """ + if item_grant.is_existing_item(): + return self._resolve_existing_item(item_grant) + elif item_grant.is_generic_item(): + return self._create_generic_item(item_grant) + else: + raise ItemValidationError( + "ItemGrant has neither item_id nor name", + item_grant, + "INVALID_ITEM_GRANT" + ) + + def _resolve_existing_item(self, item_grant: ItemGrant) -> Item: + """ + Look up an existing item by ID. + + Args: + item_grant: ItemGrant with item_id set + + Returns: + Item instance from registry + + Raises: + ItemValidationError: If item not found + """ + item_id = item_grant.item_id + + if item_id not in self._item_registry: + logger.warning( + "Item not found in registry", + item_id=item_id + ) + raise ItemValidationError( + f"Unknown item_id: {item_id}", + item_grant, + "ITEM_NOT_FOUND" + ) + + item_data = self._item_registry[item_id] + + # Convert to Item instance + return Item.from_dict({ + "item_id": item_id, + **item_data + }) + + def _create_generic_item(self, item_grant: ItemGrant) -> Item: + """ + Create a generic item from AI-provided details. + + Generic items are simple items with no special stats, + suitable for mundane objects like torches, food, etc. + + Args: + item_grant: ItemGrant with name, type, description + + Returns: + New Item instance + + Raises: + ItemValidationError: If item type is invalid + """ + # Validate item type + item_type_str = (item_grant.item_type or "consumable").lower() + if item_type_str not in self.TYPE_MAP: + logger.warning( + "Invalid item type from AI", + item_type=item_type_str, + item_name=item_grant.name + ) + # Default to consumable for unknown types + item_type_str = "consumable" + + item_type = self.TYPE_MAP[item_type_str] + + # Generate unique ID for this item instance + item_id = f"generic_{uuid.uuid4().hex[:8]}" + + # Check if we have a template for this item name + template = self._find_template(item_grant.name or "") + + if template: + # Use template values as defaults + return Item( + item_id=item_id, + name=item_grant.name or template.get("name", "Unknown Item"), + item_type=item_type, + description=item_grant.description or template.get("description", ""), + value=item_grant.value or template.get("value", 0), + is_tradeable=template.get("is_tradeable", True), + required_level=template.get("required_level", 1), + ) + else: + # Create with provided values only + return Item( + item_id=item_id, + name=item_grant.name or "Unknown Item", + item_type=item_type, + description=item_grant.description or "A simple item.", + value=item_grant.value, + is_tradeable=True, + required_level=1, + ) + + def _find_template(self, item_name: str) -> Optional[dict]: + """ + Find a generic item template by name. + + Uses case-insensitive partial matching. + + Args: + item_name: Name of the item to find + + Returns: + Template dict or None if not found + """ + name_lower = item_name.lower() + + # Exact match first + if name_lower in self._generic_templates: + return self._generic_templates[name_lower] + + # Partial match + for template_name, template in self._generic_templates.items(): + if template_name in name_lower or name_lower in template_name: + return template + + return None + + def validate_item_for_character( + self, + item: Item, + character: Character + ) -> tuple[bool, Optional[str]]: + """ + Validate that a character can receive an item. + + Checks: + - Level requirements + - Class restrictions + + Args: + item: The Item to validate + character: The Character to receive the item + + Returns: + Tuple of (is_valid, error_message) + """ + # Check level requirement + if item.required_level > character.level: + error_msg = ( + f"Item '{item.name}' requires level {item.required_level}, " + f"but character is level {character.level}" + ) + logger.warning( + "Item validation failed: level requirement", + item_name=item.name, + required_level=item.required_level, + character_level=character.level, + character_name=character.name + ) + return False, error_msg + + # Check class restriction + if item.required_class: + character_class = character.player_class.class_id + if item.required_class.lower() != character_class.lower(): + error_msg = ( + f"Item '{item.name}' requires class {item.required_class}, " + f"but character is {character_class}" + ) + logger.warning( + "Item validation failed: class restriction", + item_name=item.name, + required_class=item.required_class, + character_class=character_class, + character_name=character.name + ) + return False, error_msg + + return True, None + + def validate_and_resolve_item( + self, + item_grant: ItemGrant, + character: Character + ) -> tuple[Optional[Item], Optional[str]]: + """ + Resolve an item grant and validate it for a character. + + This is the main entry point for processing AI-granted items. + + Args: + item_grant: The ItemGrant from AI response + character: The Character to receive the item + + Returns: + Tuple of (Item if valid else None, error_message if invalid else None) + """ + try: + # Resolve the item + item = self.resolve_item(item_grant) + + # Validate for character + is_valid, error_msg = self.validate_item_for_character(item, character) + + if not is_valid: + return None, error_msg + + logger.info( + "Item validated successfully", + item_name=item.name, + item_id=item.item_id, + character_name=character.name + ) + return item, None + + except ItemValidationError as e: + logger.warning( + "Item resolution failed", + error=e.message, + reason=e.reason + ) + return None, e.message + + +# Global instance for convenience +_validator_instance: Optional[ItemValidator] = None + + +def get_item_validator() -> ItemValidator: + """ + Get or create the global ItemValidator instance. + + Returns: + ItemValidator singleton instance + """ + global _validator_instance + if _validator_instance is None: + _validator_instance = ItemValidator() + return _validator_instance diff --git a/api/app/services/location_loader.py b/api/app/services/location_loader.py new file mode 100644 index 0000000..821452e --- /dev/null +++ b/api/app/services/location_loader.py @@ -0,0 +1,326 @@ +""" +LocationLoader service for loading location definitions from YAML files. + +This service reads location configuration files and converts them into Location +dataclass instances, providing caching for performance. Locations are organized +by region subdirectories. +""" + +import yaml +from pathlib import Path +from typing import Dict, List, Optional +import structlog + +from app.models.location import Location, Region +from app.models.enums import LocationType + +logger = structlog.get_logger(__name__) + + +class LocationLoader: + """ + Loads location definitions from YAML configuration files. + + Locations are organized in region subdirectories: + /app/data/locations/ + regions/ + crossville.yaml + crossville/ + crossville_village.yaml + crossville_tavern.yaml + + This allows game designers to define world locations without touching code. + """ + + def __init__(self, data_dir: Optional[str] = None): + """ + Initialize the location loader. + + Args: + data_dir: Path to directory containing location YAML files. + Defaults to /app/data/locations/ + """ + if data_dir is None: + # Default to app/data/locations relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_dir = str(app_dir / "data" / "locations") + + self.data_dir = Path(data_dir) + self._location_cache: Dict[str, Location] = {} + self._region_cache: Dict[str, Region] = {} + + logger.info("LocationLoader initialized", data_dir=str(self.data_dir)) + + def load_location(self, location_id: str) -> Optional[Location]: + """ + Load a single location by ID. + + Searches all region subdirectories for the location file. + + Args: + location_id: Unique location identifier (e.g., "crossville_tavern") + + Returns: + Location instance or None if not found + """ + # Check cache first + if location_id in self._location_cache: + logger.debug("Location loaded from cache", location_id=location_id) + return self._location_cache[location_id] + + # Search in region subdirectories + if not self.data_dir.exists(): + logger.error("Location data directory does not exist", data_dir=str(self.data_dir)) + return None + + for region_dir in self.data_dir.iterdir(): + # Skip non-directories and the regions folder + if not region_dir.is_dir() or region_dir.name == "regions": + continue + + file_path = region_dir / f"{location_id}.yaml" + if file_path.exists(): + return self._load_location_file(file_path) + + logger.warning("Location not found", location_id=location_id) + return None + + def _load_location_file(self, file_path: Path) -> Optional[Location]: + """ + Load a location from a specific file. + + Args: + file_path: Path to the YAML file + + Returns: + Location instance or None if loading fails + """ + try: + with open(file_path, 'r') as f: + data = yaml.safe_load(f) + + location = self._parse_location_data(data) + self._location_cache[location.location_id] = location + + logger.info("Location loaded successfully", location_id=location.location_id) + return location + + except Exception as e: + logger.error("Failed to load location", file=str(file_path), error=str(e)) + return None + + def _parse_location_data(self, data: Dict) -> Location: + """ + Parse YAML data into a Location dataclass. + + Args: + data: Dictionary loaded from YAML file + + Returns: + Location instance + + Raises: + ValueError: If data is invalid or missing required fields + """ + # Validate required fields + required_fields = ["location_id", "name", "region_id", "description"] + for field in required_fields: + if field not in data: + raise ValueError(f"Missing required field: {field}") + + # Parse location type - default to town + location_type_str = data.get("location_type", "town") + try: + location_type = LocationType(location_type_str) + except ValueError: + logger.warning( + "Invalid location type, defaulting to town", + location_id=data["location_id"], + invalid_type=location_type_str + ) + location_type = LocationType.TOWN + + return Location( + location_id=data["location_id"], + name=data["name"], + location_type=location_type, + region_id=data["region_id"], + description=data["description"], + lore=data.get("lore"), + ambient_description=data.get("ambient_description"), + available_quests=data.get("available_quests", []), + npc_ids=data.get("npc_ids", []), + discoverable_locations=data.get("discoverable_locations", []), + is_starting_location=data.get("is_starting_location", False), + tags=data.get("tags", []), + ) + + def load_all_locations(self) -> List[Location]: + """ + Load all locations from all region directories. + + Returns: + List of Location instances + """ + locations = [] + + if not self.data_dir.exists(): + logger.error("Location data directory does not exist", data_dir=str(self.data_dir)) + return locations + + for region_dir in self.data_dir.iterdir(): + # Skip non-directories and the regions folder + if not region_dir.is_dir() or region_dir.name == "regions": + continue + + for file_path in region_dir.glob("*.yaml"): + location = self._load_location_file(file_path) + if location: + locations.append(location) + + logger.info("All locations loaded", count=len(locations)) + return locations + + def load_region(self, region_id: str) -> Optional[Region]: + """ + Load a region definition. + + Args: + region_id: Unique region identifier (e.g., "crossville") + + Returns: + Region instance or None if not found + """ + # Check cache first + if region_id in self._region_cache: + logger.debug("Region loaded from cache", region_id=region_id) + return self._region_cache[region_id] + + file_path = self.data_dir / "regions" / f"{region_id}.yaml" + + if not file_path.exists(): + logger.warning("Region file not found", region_id=region_id) + return None + + try: + with open(file_path, 'r') as f: + data = yaml.safe_load(f) + + region = Region.from_dict(data) + self._region_cache[region_id] = region + + logger.info("Region loaded successfully", region_id=region_id) + return region + + except Exception as e: + logger.error("Failed to load region", region_id=region_id, error=str(e)) + return None + + def get_locations_in_region(self, region_id: str) -> List[Location]: + """ + Get all locations belonging to a specific region. + + Args: + region_id: Region identifier + + Returns: + List of Location instances in this region + """ + # Load all locations if cache is empty + if not self._location_cache: + self.load_all_locations() + + return [ + loc for loc in self._location_cache.values() + if loc.region_id == region_id + ] + + def get_starting_locations(self) -> List[Location]: + """ + Get all locations that can be starting points. + + Returns: + List of Location instances marked as starting locations + """ + # Load all locations if cache is empty + if not self._location_cache: + self.load_all_locations() + + return [ + loc for loc in self._location_cache.values() + if loc.is_starting_location + ] + + def get_location_by_type(self, location_type: LocationType) -> List[Location]: + """ + Get all locations of a specific type. + + Args: + location_type: Type to filter by + + Returns: + List of Location instances of this type + """ + # Load all locations if cache is empty + if not self._location_cache: + self.load_all_locations() + + return [ + loc for loc in self._location_cache.values() + if loc.location_type == location_type + ] + + def get_all_location_ids(self) -> List[str]: + """ + Get a list of all available location IDs. + + Returns: + List of location IDs + """ + # Load all locations if cache is empty + if not self._location_cache: + self.load_all_locations() + + return list(self._location_cache.keys()) + + def reload_location(self, location_id: str) -> Optional[Location]: + """ + Force reload a location from disk, bypassing cache. + + Useful for development/testing when location definitions change. + + Args: + location_id: Unique location identifier + + Returns: + Location instance or None if not found + """ + # Remove from cache if present + if location_id in self._location_cache: + del self._location_cache[location_id] + + return self.load_location(location_id) + + def clear_cache(self) -> None: + """Clear all cached data. Useful for testing.""" + self._location_cache.clear() + self._region_cache.clear() + logger.info("Location cache cleared") + + +# Global singleton instance +_loader_instance: Optional[LocationLoader] = None + + +def get_location_loader() -> LocationLoader: + """ + Get the global LocationLoader instance. + + Returns: + Singleton LocationLoader instance + """ + global _loader_instance + if _loader_instance is None: + _loader_instance = LocationLoader() + return _loader_instance diff --git a/api/app/services/npc_loader.py b/api/app/services/npc_loader.py new file mode 100644 index 0000000..0b1054a --- /dev/null +++ b/api/app/services/npc_loader.py @@ -0,0 +1,385 @@ +""" +NPCLoader service for loading NPC definitions from YAML files. + +This service reads NPC configuration files and converts them into NPC +dataclass instances, providing caching for performance. NPCs are organized +by region subdirectories. +""" + +import yaml +from pathlib import Path +from typing import Dict, List, Optional +import structlog + +from app.models.npc import ( + NPC, + NPCPersonality, + NPCAppearance, + NPCKnowledge, + NPCKnowledgeCondition, + NPCRelationship, + NPCInventoryItem, + NPCDialogueHooks, +) + +logger = structlog.get_logger(__name__) + + +class NPCLoader: + """ + Loads NPC definitions from YAML configuration files. + + NPCs are organized in region subdirectories: + /app/data/npcs/ + crossville/ + npc_grom_001.yaml + npc_mira_001.yaml + + This allows game designers to define NPCs without touching code. + """ + + def __init__(self, data_dir: Optional[str] = None): + """ + Initialize the NPC loader. + + Args: + data_dir: Path to directory containing NPC YAML files. + Defaults to /app/data/npcs/ + """ + if data_dir is None: + # Default to app/data/npcs relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_dir = str(app_dir / "data" / "npcs") + + self.data_dir = Path(data_dir) + self._npc_cache: Dict[str, NPC] = {} + self._location_npc_cache: Dict[str, List[str]] = {} + + logger.info("NPCLoader initialized", data_dir=str(self.data_dir)) + + def load_npc(self, npc_id: str) -> Optional[NPC]: + """ + Load a single NPC by ID. + + Searches all region subdirectories for the NPC file. + + Args: + npc_id: Unique NPC identifier (e.g., "npc_grom_001") + + Returns: + NPC instance or None if not found + """ + # Check cache first + if npc_id in self._npc_cache: + logger.debug("NPC loaded from cache", npc_id=npc_id) + return self._npc_cache[npc_id] + + # Search in region subdirectories + if not self.data_dir.exists(): + logger.error("NPC data directory does not exist", data_dir=str(self.data_dir)) + return None + + for region_dir in self.data_dir.iterdir(): + if not region_dir.is_dir(): + continue + + file_path = region_dir / f"{npc_id}.yaml" + if file_path.exists(): + return self._load_npc_file(file_path) + + logger.warning("NPC not found", npc_id=npc_id) + return None + + def _load_npc_file(self, file_path: Path) -> Optional[NPC]: + """ + Load an NPC from a specific file. + + Args: + file_path: Path to the YAML file + + Returns: + NPC instance or None if loading fails + """ + try: + with open(file_path, 'r') as f: + data = yaml.safe_load(f) + + npc = self._parse_npc_data(data) + self._npc_cache[npc.npc_id] = npc + + # Update location cache + if npc.location_id not in self._location_npc_cache: + self._location_npc_cache[npc.location_id] = [] + if npc.npc_id not in self._location_npc_cache[npc.location_id]: + self._location_npc_cache[npc.location_id].append(npc.npc_id) + + logger.info("NPC loaded successfully", npc_id=npc.npc_id) + return npc + + except Exception as e: + logger.error("Failed to load NPC", file=str(file_path), error=str(e)) + return None + + def _parse_npc_data(self, data: Dict) -> NPC: + """ + Parse YAML data into an NPC dataclass. + + Args: + data: Dictionary loaded from YAML file + + Returns: + NPC instance + + Raises: + ValueError: If data is invalid or missing required fields + """ + # Validate required fields + required_fields = ["npc_id", "name", "role", "location_id"] + for field in required_fields: + if field not in data: + raise ValueError(f"Missing required field: {field}") + + # Parse personality + personality_data = data.get("personality", {}) + personality = NPCPersonality( + traits=personality_data.get("traits", []), + speech_style=personality_data.get("speech_style", ""), + quirks=personality_data.get("quirks", []), + ) + + # Parse appearance + appearance_data = data.get("appearance", {}) + if isinstance(appearance_data, str): + appearance = NPCAppearance(brief=appearance_data) + else: + appearance = NPCAppearance( + brief=appearance_data.get("brief", ""), + detailed=appearance_data.get("detailed"), + ) + + # Parse knowledge (optional) + knowledge = None + if data.get("knowledge"): + knowledge_data = data["knowledge"] + conditions = [ + NPCKnowledgeCondition( + condition=c.get("condition", ""), + reveals=c.get("reveals", ""), + ) + for c in knowledge_data.get("will_share_if", []) + ] + knowledge = NPCKnowledge( + public=knowledge_data.get("public", []), + secret=knowledge_data.get("secret", []), + will_share_if=conditions, + ) + + # Parse relationships + relationships = [ + NPCRelationship( + npc_id=r["npc_id"], + attitude=r["attitude"], + reason=r.get("reason"), + ) + for r in data.get("relationships", []) + ] + + # Parse inventory + inventory = [] + for item_data in data.get("inventory_for_sale", []): + # Handle shorthand format: { item: "ale", price: 2 } + item_id = item_data.get("item_id") or item_data.get("item", "") + inventory.append(NPCInventoryItem( + item_id=item_id, + price=item_data.get("price", 0), + quantity=item_data.get("quantity"), + )) + + # Parse dialogue hooks (optional) + dialogue_hooks = None + if data.get("dialogue_hooks"): + hooks_data = data["dialogue_hooks"] + dialogue_hooks = NPCDialogueHooks( + greeting=hooks_data.get("greeting"), + farewell=hooks_data.get("farewell"), + busy=hooks_data.get("busy"), + quest_complete=hooks_data.get("quest_complete"), + ) + + return NPC( + npc_id=data["npc_id"], + name=data["name"], + role=data["role"], + location_id=data["location_id"], + personality=personality, + appearance=appearance, + knowledge=knowledge, + relationships=relationships, + inventory_for_sale=inventory, + dialogue_hooks=dialogue_hooks, + quest_giver_for=data.get("quest_giver_for", []), + reveals_locations=data.get("reveals_locations", []), + tags=data.get("tags", []), + ) + + def load_all_npcs(self) -> List[NPC]: + """ + Load all NPCs from all region directories. + + Returns: + List of NPC instances + """ + npcs = [] + + if not self.data_dir.exists(): + logger.error("NPC data directory does not exist", data_dir=str(self.data_dir)) + return npcs + + for region_dir in self.data_dir.iterdir(): + if not region_dir.is_dir(): + continue + + for file_path in region_dir.glob("*.yaml"): + npc = self._load_npc_file(file_path) + if npc: + npcs.append(npc) + + logger.info("All NPCs loaded", count=len(npcs)) + return npcs + + def get_npcs_at_location(self, location_id: str) -> List[NPC]: + """ + Get all NPCs at a specific location. + + Args: + location_id: Location identifier + + Returns: + List of NPC instances at this location + """ + # Ensure all NPCs are loaded + if not self._npc_cache: + self.load_all_npcs() + + npc_ids = self._location_npc_cache.get(location_id, []) + return [ + self._npc_cache[npc_id] + for npc_id in npc_ids + if npc_id in self._npc_cache + ] + + def get_npc_ids_at_location(self, location_id: str) -> List[str]: + """ + Get NPC IDs at a specific location. + + Args: + location_id: Location identifier + + Returns: + List of NPC IDs at this location + """ + # Ensure all NPCs are loaded + if not self._npc_cache: + self.load_all_npcs() + + return self._location_npc_cache.get(location_id, []) + + def get_npcs_by_tag(self, tag: str) -> List[NPC]: + """ + Get all NPCs with a specific tag. + + Args: + tag: Tag to filter by (e.g., "merchant", "quest_giver") + + Returns: + List of NPC instances with this tag + """ + # Ensure all NPCs are loaded + if not self._npc_cache: + self.load_all_npcs() + + return [ + npc for npc in self._npc_cache.values() + if tag in npc.tags + ] + + def get_quest_givers(self, quest_id: str) -> List[NPC]: + """ + Get all NPCs that can give a specific quest. + + Args: + quest_id: Quest identifier + + Returns: + List of NPC instances that give this quest + """ + # Ensure all NPCs are loaded + if not self._npc_cache: + self.load_all_npcs() + + return [ + npc for npc in self._npc_cache.values() + if quest_id in npc.quest_giver_for + ] + + def get_all_npc_ids(self) -> List[str]: + """ + Get a list of all available NPC IDs. + + Returns: + List of NPC IDs + """ + # Ensure all NPCs are loaded + if not self._npc_cache: + self.load_all_npcs() + + return list(self._npc_cache.keys()) + + def reload_npc(self, npc_id: str) -> Optional[NPC]: + """ + Force reload an NPC from disk, bypassing cache. + + Useful for development/testing when NPC definitions change. + + Args: + npc_id: Unique NPC identifier + + Returns: + NPC instance or None if not found + """ + # Remove from caches if present + if npc_id in self._npc_cache: + old_npc = self._npc_cache[npc_id] + # Remove from location cache + if old_npc.location_id in self._location_npc_cache: + self._location_npc_cache[old_npc.location_id] = [ + n for n in self._location_npc_cache[old_npc.location_id] + if n != npc_id + ] + del self._npc_cache[npc_id] + + return self.load_npc(npc_id) + + def clear_cache(self) -> None: + """Clear all cached data. Useful for testing.""" + self._npc_cache.clear() + self._location_npc_cache.clear() + logger.info("NPC cache cleared") + + +# Global singleton instance +_loader_instance: Optional[NPCLoader] = None + + +def get_npc_loader() -> NPCLoader: + """ + Get the global NPCLoader instance. + + Returns: + Singleton NPCLoader instance + """ + global _loader_instance + if _loader_instance is None: + _loader_instance = NPCLoader() + return _loader_instance diff --git a/api/app/services/origin_service.py b/api/app/services/origin_service.py new file mode 100644 index 0000000..352de56 --- /dev/null +++ b/api/app/services/origin_service.py @@ -0,0 +1,236 @@ +""" +OriginService for loading character origin definitions from YAML files. + +This service reads origin configuration and converts it into Origin +dataclass instances, providing caching for performance. +""" + +import yaml +from pathlib import Path +from typing import Dict, List, Optional +import structlog + +from app.models.origins import Origin, StartingLocation, StartingBonus + +logger = structlog.get_logger(__name__) + + +class OriginService: + """ + Loads character origin definitions from YAML configuration. + + Origins define character backstories, starting locations, and narrative + hooks that the AI DM uses to create personalized gameplay experiences. + All origin definitions are stored in /app/data/origins.yaml. + """ + + def __init__(self, data_file: Optional[str] = None): + """ + Initialize the origin service. + + Args: + data_file: Path to origins YAML file. + Defaults to /app/data/origins.yaml + """ + if data_file is None: + # Default to app/data/origins.yaml relative to this file + current_file = Path(__file__) + app_dir = current_file.parent.parent # Go up to /app + data_file = str(app_dir / "data" / "origins.yaml") + + self.data_file = Path(data_file) + self._origins_cache: Dict[str, Origin] = {} + self._all_origins_loaded = False + + logger.info("OriginService initialized", data_file=str(self.data_file)) + + def load_origin(self, origin_id: str) -> Optional[Origin]: + """ + Load a single origin by ID. + + Args: + origin_id: Unique origin identifier (e.g., "soul_revenant") + + Returns: + Origin instance or None if not found + """ + # Check cache first + if origin_id in self._origins_cache: + logger.debug("Origin loaded from cache", origin_id=origin_id) + return self._origins_cache[origin_id] + + # Load all origins if not already loaded + if not self._all_origins_loaded: + self._load_all_origins() + + # Return from cache after loading + origin = self._origins_cache.get(origin_id) + if origin: + logger.info("Origin loaded successfully", origin_id=origin_id) + else: + logger.warning("Origin not found", origin_id=origin_id) + + return origin + + def load_all_origins(self) -> List[Origin]: + """ + Load all origins from the data file. + + Returns: + List of Origin instances + """ + if self._all_origins_loaded and self._origins_cache: + logger.debug("All origins loaded from cache") + return list(self._origins_cache.values()) + + return self._load_all_origins() + + def _load_all_origins(self) -> List[Origin]: + """ + Internal method to load all origins from YAML. + + Returns: + List of Origin instances + """ + if not self.data_file.exists(): + logger.error("Origins data file does not exist", data_file=str(self.data_file)) + return [] + + try: + # Load YAML file + with open(self.data_file, 'r') as f: + data = yaml.safe_load(f) + + origins_data = data.get("origins", {}) + origins = [] + + # Parse each origin + for origin_id, origin_data in origins_data.items(): + try: + origin = self._parse_origin_data(origin_id, origin_data) + self._origins_cache[origin_id] = origin + origins.append(origin) + except Exception as e: + logger.error("Failed to parse origin", origin_id=origin_id, error=str(e)) + continue + + self._all_origins_loaded = True + logger.info("All origins loaded successfully", count=len(origins)) + return origins + + except Exception as e: + logger.error("Failed to load origins file", error=str(e)) + return [] + + def get_origin_by_id(self, origin_id: str) -> Optional[Origin]: + """ + Get an origin by ID (alias for load_origin). + + Args: + origin_id: Unique origin identifier + + Returns: + Origin instance or None if not found + """ + return self.load_origin(origin_id) + + def get_all_origin_ids(self) -> List[str]: + """ + Get a list of all available origin IDs. + + Returns: + List of origin IDs (e.g., ["soul_revenant", "memory_thief"]) + """ + if not self._all_origins_loaded: + self._load_all_origins() + + return list(self._origins_cache.keys()) + + def reload_origins(self) -> List[Origin]: + """ + Force reload all origins from disk, bypassing cache. + + Useful for development/testing when origin definitions change. + + Returns: + List of Origin instances + """ + self.clear_cache() + return self._load_all_origins() + + def clear_cache(self): + """Clear the origins cache. Useful for testing.""" + self._origins_cache.clear() + self._all_origins_loaded = False + logger.info("Origins cache cleared") + + def _parse_origin_data(self, origin_id: str, data: Dict) -> Origin: + """ + Parse YAML data into an Origin dataclass. + + Args: + origin_id: The origin's unique identifier + data: Dictionary loaded from YAML file + + Returns: + Origin instance + + Raises: + ValueError: If data is invalid or missing required fields + """ + # Validate required fields + required_fields = ["name", "description", "starting_location"] + for field in required_fields: + if field not in data: + raise ValueError(f"Missing required field in origin '{origin_id}': {field}") + + # Parse starting location + location_data = data["starting_location"] + starting_location = StartingLocation( + id=location_data.get("id", ""), + name=location_data.get("name", ""), + region=location_data.get("region", ""), + description=location_data.get("description", "") + ) + + # Parse starting bonus (optional) + starting_bonus = None + if "starting_bonus" in data: + bonus_data = data["starting_bonus"] + starting_bonus = StartingBonus( + trait=bonus_data.get("trait", ""), + description=bonus_data.get("description", ""), + effect=bonus_data.get("effect", "") + ) + + # Parse narrative hooks (optional) + narrative_hooks = data.get("narrative_hooks", []) + + # Create Origin instance + origin = Origin( + id=data.get("id", origin_id), # Use provided ID or fall back to key + name=data["name"], + description=data["description"], + starting_location=starting_location, + narrative_hooks=narrative_hooks, + starting_bonus=starting_bonus + ) + + return origin + + +# Global instance for convenience +_service_instance: Optional[OriginService] = None + + +def get_origin_service() -> OriginService: + """ + Get the global OriginService instance. + + Returns: + Singleton OriginService instance + """ + global _service_instance + if _service_instance is None: + _service_instance = OriginService() + return _service_instance diff --git a/api/app/services/outcome_service.py b/api/app/services/outcome_service.py new file mode 100644 index 0000000..423d2eb --- /dev/null +++ b/api/app/services/outcome_service.py @@ -0,0 +1,373 @@ +""" +Outcome determination service for Code of Conquest. + +This service handles all code-determined game outcomes before they're passed +to AI for narration. It uses the dice mechanics system to determine success/failure +and selects appropriate rewards from loot tables. +""" + +import random +import yaml +import structlog +from pathlib import Path +from dataclasses import dataclass +from typing import Optional, List, Dict, Any + +from app.models.character import Character +from app.game_logic.dice import ( + CheckResult, SkillType, Difficulty, + skill_check, get_stat_for_skill, perception_check +) + + +logger = structlog.get_logger(__name__) + + +@dataclass +class ItemFound: + """ + Represents an item found during a search. + + Uses template key from generic_items.yaml. + """ + template_key: str + name: str + description: str + value: int + + def to_dict(self) -> dict: + """Serialize for API response.""" + return { + "template_key": self.template_key, + "name": self.name, + "description": self.description, + "value": self.value, + } + + +@dataclass +class SearchOutcome: + """ + Complete result of a search action. + + Includes the dice check result and any items/gold found. + """ + check_result: CheckResult + items_found: List[ItemFound] + gold_found: int + + def to_dict(self) -> dict: + """Serialize for API response.""" + return { + "check_result": self.check_result.to_dict(), + "items_found": [item.to_dict() for item in self.items_found], + "gold_found": self.gold_found, + } + + +@dataclass +class SkillCheckOutcome: + """ + Result of a generic skill check. + + Used for persuasion, lockpicking, stealth, etc. + """ + check_result: CheckResult + context: Dict[str, Any] # Additional context for AI narration + + def to_dict(self) -> dict: + """Serialize for API response.""" + return { + "check_result": self.check_result.to_dict(), + "context": self.context, + } + + +class OutcomeService: + """ + Service for determining game action outcomes. + + Handles all dice rolls and loot selection before passing to AI. + """ + + def __init__(self): + """Initialize the outcome service with loot tables and item templates.""" + self._loot_tables: Dict[str, Any] = {} + self._item_templates: Dict[str, Any] = {} + self._load_data() + + def _load_data(self) -> None: + """Load loot tables and item templates from YAML files.""" + data_dir = Path(__file__).parent.parent / "data" + + # Load loot tables + loot_path = data_dir / "loot_tables.yaml" + if loot_path.exists(): + with open(loot_path, "r") as f: + self._loot_tables = yaml.safe_load(f) + logger.info("loaded_loot_tables", count=len(self._loot_tables)) + else: + logger.warning("loot_tables_not_found", path=str(loot_path)) + + # Load generic item templates + items_path = data_dir / "generic_items.yaml" + if items_path.exists(): + with open(items_path, "r") as f: + data = yaml.safe_load(f) + self._item_templates = data.get("templates", {}) + logger.info("loaded_item_templates", count=len(self._item_templates)) + else: + logger.warning("item_templates_not_found", path=str(items_path)) + + def determine_search_outcome( + self, + character: Character, + location_type: str, + dc: int = 12, + bonus: int = 0 + ) -> SearchOutcome: + """ + Determine the outcome of a search action. + + Uses a perception check to determine success, then selects items + from the appropriate loot table based on the roll margin. + + Args: + character: The character performing the search + location_type: Type of location (forest, cave, town, etc.) + dc: Difficulty class (default 12 = easy-medium) + bonus: Additional bonus to the check + + Returns: + SearchOutcome with check result, items found, and gold found + """ + # Get character's effective wisdom for perception + effective_stats = character.get_effective_stats() + wisdom = effective_stats.wisdom + + # Perform the perception check + check_result = perception_check(wisdom, dc, bonus) + + # Determine loot based on result + items_found: List[ItemFound] = [] + gold_found: int = 0 + + if check_result.success: + # Get loot table for this location (fall back to default) + loot_table = self._loot_tables.get( + location_type.lower(), + self._loot_tables.get("default", {}) + ) + + # Select item rarity based on margin + if check_result.margin >= 10: + rarity = "rare" + elif check_result.margin >= 5: + rarity = "uncommon" + else: + rarity = "common" + + # Get items for this rarity + item_keys = loot_table.get(rarity, []) + if item_keys: + # Select 1-2 items based on margin + num_items = 1 if check_result.margin < 8 else 2 + selected_keys = random.sample( + item_keys, + min(num_items, len(item_keys)) + ) + + for key in selected_keys: + template = self._item_templates.get(key) + if template: + items_found.append(ItemFound( + template_key=key, + name=template.get("name", key.title()), + description=template.get("description", ""), + value=template.get("value", 1), + )) + + # Calculate gold found + gold_config = loot_table.get("gold", {}) + if gold_config: + min_gold = gold_config.get("min", 0) + max_gold = gold_config.get("max", 10) + bonus_per_margin = gold_config.get("bonus_per_margin", 0) + + base_gold = random.randint(min_gold, max_gold) + margin_bonus = check_result.margin * bonus_per_margin + gold_found = base_gold + margin_bonus + + logger.info( + "search_outcome_determined", + character_id=character.character_id, + location_type=location_type, + dc=dc, + success=check_result.success, + margin=check_result.margin, + items_count=len(items_found), + gold_found=gold_found + ) + + return SearchOutcome( + check_result=check_result, + items_found=items_found, + gold_found=gold_found + ) + + def determine_skill_check_outcome( + self, + character: Character, + skill_type: SkillType, + dc: int, + bonus: int = 0, + context: Optional[Dict[str, Any]] = None + ) -> SkillCheckOutcome: + """ + Determine the outcome of a generic skill check. + + Args: + character: The character performing the check + skill_type: The type of skill check (PERSUASION, STEALTH, etc.) + dc: Difficulty class to beat + bonus: Additional bonus to the check + context: Optional context for AI narration (e.g., NPC name, door type) + + Returns: + SkillCheckOutcome with check result and context + """ + # Get the appropriate stat for this skill + stat_name = get_stat_for_skill(skill_type) + effective_stats = character.get_effective_stats() + stat_value = getattr(effective_stats, stat_name, 10) + + # Perform the check + check_result = skill_check(stat_value, dc, skill_type, bonus) + + # Build outcome context + outcome_context = context or {} + outcome_context["skill_used"] = skill_type.name.lower() + outcome_context["stat_used"] = stat_name + + logger.info( + "skill_check_outcome_determined", + character_id=character.character_id, + skill=skill_type.name, + stat=stat_name, + dc=dc, + success=check_result.success, + margin=check_result.margin + ) + + return SkillCheckOutcome( + check_result=check_result, + context=outcome_context + ) + + def determine_persuasion_outcome( + self, + character: Character, + dc: int, + npc_name: Optional[str] = None, + bonus: int = 0 + ) -> SkillCheckOutcome: + """ + Convenience method for persuasion checks. + + Args: + character: The character attempting persuasion + dc: Difficulty class based on NPC disposition + npc_name: Name of the NPC being persuaded + bonus: Additional bonus + + Returns: + SkillCheckOutcome + """ + context = {"npc_name": npc_name} if npc_name else {} + return self.determine_skill_check_outcome( + character, + SkillType.PERSUASION, + dc, + bonus, + context + ) + + def determine_stealth_outcome( + self, + character: Character, + dc: int, + situation: Optional[str] = None, + bonus: int = 0 + ) -> SkillCheckOutcome: + """ + Convenience method for stealth checks. + + Args: + character: The character attempting stealth + dc: Difficulty class based on environment/observers + situation: Description of what they're sneaking past + bonus: Additional bonus + + Returns: + SkillCheckOutcome + """ + context = {"situation": situation} if situation else {} + return self.determine_skill_check_outcome( + character, + SkillType.STEALTH, + dc, + bonus, + context + ) + + def determine_lockpicking_outcome( + self, + character: Character, + dc: int, + lock_description: Optional[str] = None, + bonus: int = 0 + ) -> SkillCheckOutcome: + """ + Convenience method for lockpicking checks. + + Args: + character: The character attempting to pick the lock + dc: Difficulty class based on lock quality + lock_description: Description of the lock/door + bonus: Additional bonus (e.g., from thieves' tools) + + Returns: + SkillCheckOutcome + """ + context = {"lock_description": lock_description} if lock_description else {} + return self.determine_skill_check_outcome( + character, + SkillType.LOCKPICKING, + dc, + bonus, + context + ) + + def get_dc_for_difficulty(self, difficulty: str) -> int: + """ + Get the DC value for a named difficulty. + + Args: + difficulty: Difficulty name (trivial, easy, medium, hard, very_hard) + + Returns: + DC value + """ + difficulty_map = { + "trivial": Difficulty.TRIVIAL.value, + "easy": Difficulty.EASY.value, + "medium": Difficulty.MEDIUM.value, + "hard": Difficulty.HARD.value, + "very_hard": Difficulty.VERY_HARD.value, + "nearly_impossible": Difficulty.NEARLY_IMPOSSIBLE.value, + } + return difficulty_map.get(difficulty.lower(), Difficulty.MEDIUM.value) + + +# Global instance for use in API endpoints +outcome_service = OutcomeService() diff --git a/api/app/services/rate_limiter_service.py b/api/app/services/rate_limiter_service.py new file mode 100644 index 0000000..3651b04 --- /dev/null +++ b/api/app/services/rate_limiter_service.py @@ -0,0 +1,602 @@ +""" +Rate Limiter Service + +This module implements tier-based rate limiting for AI requests using Redis +for distributed counting. Each user tier has a different daily limit for +AI-generated turns. + +Usage: + from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded + from app.ai.model_selector import UserTier + + # Initialize service + rate_limiter = RateLimiterService() + + # Check and increment usage + try: + rate_limiter.check_rate_limit("user_123", UserTier.FREE) + rate_limiter.increment_usage("user_123") + except RateLimitExceeded as e: + print(f"Rate limit exceeded: {e}") + + # Get remaining turns + remaining = rate_limiter.get_remaining_turns("user_123", UserTier.FREE) +""" + +from datetime import date, datetime, timezone, timedelta +from typing import Optional + +from app.services.redis_service import RedisService, RedisServiceError +from app.ai.model_selector import UserTier +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + + +class RateLimitExceeded(Exception): + """ + Raised when a user has exceeded their daily rate limit. + + Attributes: + user_id: The user who exceeded the limit + user_tier: The user's subscription tier + limit: The daily limit for their tier + current_usage: The current usage count + reset_time: UTC timestamp when the limit resets + """ + + def __init__( + self, + user_id: str, + user_tier: UserTier, + limit: int, + current_usage: int, + reset_time: datetime + ): + self.user_id = user_id + self.user_tier = user_tier + self.limit = limit + self.current_usage = current_usage + self.reset_time = reset_time + + message = ( + f"Rate limit exceeded for user {user_id} ({user_tier.value} tier). " + f"Used {current_usage}/{limit} turns. Resets at {reset_time.isoformat()}" + ) + super().__init__(message) + + +class RateLimiterService: + """ + Service for managing tier-based rate limiting. + + This service uses Redis to track daily AI usage per user and enforces + limits based on subscription tier. Counters reset daily at midnight UTC. + + Tier Limits: + - Free: 20 turns/day + - Basic: 50 turns/day + - Premium: 100 turns/day + - Elite: 200 turns/day + + Attributes: + redis: RedisService instance for counter storage + tier_limits: Mapping of tier to daily turn limit + """ + + # Daily turn limits per tier + TIER_LIMITS = { + UserTier.FREE: 20, + UserTier.BASIC: 50, + UserTier.PREMIUM: 100, + UserTier.ELITE: 200, + } + + # Daily DM question limits per tier + DM_QUESTION_LIMITS = { + UserTier.FREE: 10, + UserTier.BASIC: 20, + UserTier.PREMIUM: 50, + UserTier.ELITE: -1, # -1 means unlimited + } + + # Redis key prefix for rate limit counters + KEY_PREFIX = "rate_limit:daily:" + DM_QUESTION_PREFIX = "rate_limit:dm_questions:" + + def __init__(self, redis_service: Optional[RedisService] = None): + """ + Initialize the rate limiter service. + + Args: + redis_service: Optional RedisService instance. If not provided, + a new instance will be created. + """ + self.redis = redis_service or RedisService() + + logger.info( + "RateLimiterService initialized", + tier_limits=self.TIER_LIMITS + ) + + def _get_daily_key(self, user_id: str, day: Optional[date] = None) -> str: + """ + Generate the Redis key for a user's daily counter. + + Args: + user_id: The user ID + day: The date (defaults to today UTC) + + Returns: + Redis key in format "rate_limit:daily:user_id:YYYY-MM-DD" + """ + if day is None: + day = datetime.now(timezone.utc).date() + + return f"{self.KEY_PREFIX}{user_id}:{day.isoformat()}" + + def _get_seconds_until_midnight_utc(self) -> int: + """ + Calculate seconds remaining until midnight UTC. + + Returns: + Number of seconds until the next UTC midnight + """ + now = datetime.now(timezone.utc) + tomorrow = datetime( + now.year, now.month, now.day, + tzinfo=timezone.utc + ) + timedelta(days=1) + + return int((tomorrow - now).total_seconds()) + + def _get_reset_time(self) -> datetime: + """ + Get the UTC datetime when the rate limit resets. + + Returns: + Datetime of next midnight UTC + """ + now = datetime.now(timezone.utc) + return datetime( + now.year, now.month, now.day, + tzinfo=timezone.utc + ) + timedelta(days=1) + + def get_limit_for_tier(self, user_tier: UserTier) -> int: + """ + Get the daily turn limit for a specific tier. + + Args: + user_tier: The user's subscription tier + + Returns: + Daily turn limit for the tier + """ + return self.TIER_LIMITS.get(user_tier, self.TIER_LIMITS[UserTier.FREE]) + + def get_current_usage(self, user_id: str) -> int: + """ + Get the current daily usage count for a user. + + Args: + user_id: The user ID to check + + Returns: + Current usage count (0 if no usage today) + + Raises: + RedisServiceError: If Redis operation fails + """ + key = self._get_daily_key(user_id) + + try: + value = self.redis.get(key) + usage = int(value) if value else 0 + + logger.debug( + "Retrieved current usage", + user_id=user_id, + usage=usage + ) + + return usage + + except (ValueError, TypeError) as e: + logger.error( + "Invalid usage value in Redis", + user_id=user_id, + error=str(e) + ) + return 0 + + def check_rate_limit(self, user_id: str, user_tier: UserTier) -> None: + """ + Check if a user has exceeded their daily rate limit. + + This method checks the current usage against the tier limit and + raises an exception if the limit has been reached. + + Args: + user_id: The user ID to check + user_tier: The user's subscription tier + + Raises: + RateLimitExceeded: If the user has reached their daily limit + RedisServiceError: If Redis operation fails + """ + current_usage = self.get_current_usage(user_id) + limit = self.get_limit_for_tier(user_tier) + + if current_usage >= limit: + reset_time = self._get_reset_time() + + logger.warning( + "Rate limit exceeded", + user_id=user_id, + user_tier=user_tier.value, + current_usage=current_usage, + limit=limit, + reset_time=reset_time.isoformat() + ) + + raise RateLimitExceeded( + user_id=user_id, + user_tier=user_tier, + limit=limit, + current_usage=current_usage, + reset_time=reset_time + ) + + logger.debug( + "Rate limit check passed", + user_id=user_id, + user_tier=user_tier.value, + current_usage=current_usage, + limit=limit + ) + + def increment_usage(self, user_id: str) -> int: + """ + Increment the daily usage counter for a user. + + This method should be called after successfully processing an AI request. + The counter will automatically expire at midnight UTC. + + Args: + user_id: The user ID to increment + + Returns: + The new usage count after incrementing + + Raises: + RedisServiceError: If Redis operation fails + """ + key = self._get_daily_key(user_id) + + # Increment the counter + new_count = self.redis.incr(key) + + # Set expiration if this is the first increment (new_count == 1) + # This ensures the key expires at midnight UTC + if new_count == 1: + ttl = self._get_seconds_until_midnight_utc() + self.redis.expire(key, ttl) + + logger.debug( + "Set expiration on new rate limit key", + user_id=user_id, + ttl=ttl + ) + + logger.info( + "Incremented usage counter", + user_id=user_id, + new_count=new_count + ) + + return new_count + + def get_remaining_turns(self, user_id: str, user_tier: UserTier) -> int: + """ + Get the number of remaining turns for a user today. + + Args: + user_id: The user ID to check + user_tier: The user's subscription tier + + Returns: + Number of turns remaining (0 if limit reached) + """ + current_usage = self.get_current_usage(user_id) + limit = self.get_limit_for_tier(user_tier) + + remaining = max(0, limit - current_usage) + + logger.debug( + "Calculated remaining turns", + user_id=user_id, + user_tier=user_tier.value, + current_usage=current_usage, + limit=limit, + remaining=remaining + ) + + return remaining + + def get_usage_info(self, user_id: str, user_tier: UserTier) -> dict: + """ + Get comprehensive usage information for a user. + + Args: + user_id: The user ID to check + user_tier: The user's subscription tier + + Returns: + Dictionary with usage info: + - user_id: User identifier + - user_tier: Subscription tier + - current_usage: Current daily usage + - daily_limit: Daily limit for tier + - remaining: Remaining turns + - reset_time: ISO format UTC reset time + - is_limited: Whether limit has been reached + """ + current_usage = self.get_current_usage(user_id) + limit = self.get_limit_for_tier(user_tier) + remaining = max(0, limit - current_usage) + reset_time = self._get_reset_time() + + info = { + "user_id": user_id, + "user_tier": user_tier.value, + "current_usage": current_usage, + "daily_limit": limit, + "remaining": remaining, + "reset_time": reset_time.isoformat(), + "is_limited": current_usage >= limit + } + + logger.debug("Retrieved usage info", **info) + + return info + + def reset_usage(self, user_id: str) -> bool: + """ + Reset the daily usage counter for a user. + + This is primarily for admin/testing purposes. + + Args: + user_id: The user ID to reset + + Returns: + True if the counter was deleted, False if it didn't exist + + Raises: + RedisServiceError: If Redis operation fails + """ + key = self._get_daily_key(user_id) + deleted = self.redis.delete(key) + + logger.info( + "Reset usage counter", + user_id=user_id, + deleted=deleted > 0 + ) + + return deleted > 0 + + # ===== DM QUESTION RATE LIMITING ===== + + def _get_dm_question_key(self, user_id: str, day: Optional[date] = None) -> str: + """ + Generate the Redis key for a user's daily DM question counter. + + Args: + user_id: The user ID + day: The date (defaults to today UTC) + + Returns: + Redis key in format "rate_limit:dm_questions:user_id:YYYY-MM-DD" + """ + if day is None: + day = datetime.now(timezone.utc).date() + + return f"{self.DM_QUESTION_PREFIX}{user_id}:{day.isoformat()}" + + def get_dm_question_limit_for_tier(self, user_tier: UserTier) -> int: + """ + Get the daily DM question limit for a specific tier. + + Args: + user_tier: The user's subscription tier + + Returns: + Daily DM question limit for the tier (-1 for unlimited) + """ + return self.DM_QUESTION_LIMITS.get(user_tier, self.DM_QUESTION_LIMITS[UserTier.FREE]) + + def get_current_dm_usage(self, user_id: str) -> int: + """ + Get the current daily DM question usage count for a user. + + Args: + user_id: The user ID to check + + Returns: + Current DM question usage count (0 if no usage today) + + Raises: + RedisServiceError: If Redis operation fails + """ + key = self._get_dm_question_key(user_id) + + try: + value = self.redis.get(key) + usage = int(value) if value else 0 + + logger.debug( + "Retrieved current DM question usage", + user_id=user_id, + usage=usage + ) + + return usage + + except (ValueError, TypeError) as e: + logger.error( + "Invalid DM question usage value in Redis", + user_id=user_id, + error=str(e) + ) + return 0 + + def check_dm_question_limit(self, user_id: str, user_tier: UserTier) -> None: + """ + Check if a user has exceeded their daily DM question limit. + + Args: + user_id: The user ID to check + user_tier: The user's subscription tier + + Raises: + RateLimitExceeded: If the user has reached their daily DM question limit + RedisServiceError: If Redis operation fails + """ + limit = self.get_dm_question_limit_for_tier(user_tier) + + # -1 means unlimited + if limit == -1: + logger.debug( + "DM question limit check passed (unlimited)", + user_id=user_id, + user_tier=user_tier.value + ) + return + + current_usage = self.get_current_dm_usage(user_id) + + if current_usage >= limit: + reset_time = self._get_reset_time() + + logger.warning( + "DM question limit exceeded", + user_id=user_id, + user_tier=user_tier.value, + current_usage=current_usage, + limit=limit, + reset_time=reset_time.isoformat() + ) + + raise RateLimitExceeded( + user_id=user_id, + user_tier=user_tier, + limit=limit, + current_usage=current_usage, + reset_time=reset_time + ) + + logger.debug( + "DM question limit check passed", + user_id=user_id, + user_tier=user_tier.value, + current_usage=current_usage, + limit=limit + ) + + def increment_dm_usage(self, user_id: str) -> int: + """ + Increment the daily DM question counter for a user. + + Args: + user_id: The user ID to increment + + Returns: + The new DM question usage count after incrementing + + Raises: + RedisServiceError: If Redis operation fails + """ + key = self._get_dm_question_key(user_id) + + # Increment the counter + new_count = self.redis.incr(key) + + # Set expiration if this is the first increment + if new_count == 1: + ttl = self._get_seconds_until_midnight_utc() + self.redis.expire(key, ttl) + + logger.debug( + "Set expiration on new DM question key", + user_id=user_id, + ttl=ttl + ) + + logger.info( + "Incremented DM question counter", + user_id=user_id, + new_count=new_count + ) + + return new_count + + def get_remaining_dm_questions(self, user_id: str, user_tier: UserTier) -> int: + """ + Get the number of remaining DM questions for a user today. + + Args: + user_id: The user ID to check + user_tier: The user's subscription tier + + Returns: + Number of DM questions remaining (-1 if unlimited, 0 if limit reached) + """ + limit = self.get_dm_question_limit_for_tier(user_tier) + + # -1 means unlimited + if limit == -1: + return -1 + + current_usage = self.get_current_dm_usage(user_id) + remaining = max(0, limit - current_usage) + + logger.debug( + "Calculated remaining DM questions", + user_id=user_id, + user_tier=user_tier.value, + current_usage=current_usage, + limit=limit, + remaining=remaining + ) + + return remaining + + def reset_dm_usage(self, user_id: str) -> bool: + """ + Reset the daily DM question counter for a user. + + This is primarily for admin/testing purposes. + + Args: + user_id: The user ID to reset + + Returns: + True if the counter was deleted, False if it didn't exist + + Raises: + RedisServiceError: If Redis operation fails + """ + key = self._get_dm_question_key(user_id) + deleted = self.redis.delete(key) + + logger.info( + "Reset DM question counter", + user_id=user_id, + deleted=deleted > 0 + ) + + return deleted > 0 diff --git a/api/app/services/redis_service.py b/api/app/services/redis_service.py new file mode 100644 index 0000000..b3fd61f --- /dev/null +++ b/api/app/services/redis_service.py @@ -0,0 +1,505 @@ +""" +Redis Service Wrapper + +This module provides a wrapper around the redis-py client for handling caching, +job queue data, and temporary storage. It provides connection pooling, automatic +reconnection, and a clean interface for common Redis operations. + +Usage: + from app.services.redis_service import RedisService + + # Initialize service + redis = RedisService() + + # Basic operations + redis.set("key", "value", ttl=3600) # Set with 1 hour TTL + value = redis.get("key") + redis.delete("key") + + # Health check + if redis.health_check(): + print("Redis is healthy") +""" + +import os +import json +from typing import Optional, Any, Union + +import redis +from redis.exceptions import RedisError, ConnectionError as RedisConnectionError + +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + + +class RedisServiceError(Exception): + """Base exception for Redis service errors.""" + pass + + +class RedisConnectionFailed(RedisServiceError): + """Raised when Redis connection cannot be established.""" + pass + + +class RedisService: + """ + Service class for interacting with Redis. + + This class provides: + - Connection pooling for efficient connection management + - Basic operations: get, set, delete, exists + - TTL support for caching + - Health check for monitoring + - Automatic JSON serialization for complex objects + + Attributes: + pool: Redis connection pool + client: Redis client instance + """ + + def __init__(self, redis_url: Optional[str] = None): + """ + Initialize the Redis service. + + Reads configuration from environment variables if not provided: + - REDIS_URL: Full Redis URL (e.g., redis://localhost:6379/0) + + Args: + redis_url: Optional Redis URL to override environment variable + + Raises: + RedisConnectionFailed: If connection to Redis fails + """ + self.redis_url = redis_url or os.getenv('REDIS_URL', 'redis://localhost:6379/0') + + if not self.redis_url: + logger.error("Missing Redis URL configuration") + raise ValueError("Redis URL not configured. Set REDIS_URL environment variable.") + + try: + # Create connection pool for efficient connection management + # Connection pooling allows multiple operations to share connections + # and automatically manages connection lifecycle + self.pool = redis.ConnectionPool.from_url( + self.redis_url, + max_connections=10, + decode_responses=True, # Return strings instead of bytes + socket_connect_timeout=5, # Connection timeout in seconds + socket_timeout=5, # Operation timeout in seconds + retry_on_timeout=True, # Retry on timeout + ) + + # Create client using the connection pool + self.client = redis.Redis(connection_pool=self.pool) + + # Test connection + self.client.ping() + + logger.info("Redis service initialized", redis_url=self._sanitize_url(self.redis_url)) + + except RedisConnectionError as e: + logger.error("Failed to connect to Redis", redis_url=self._sanitize_url(self.redis_url), error=str(e)) + raise RedisConnectionFailed(f"Could not connect to Redis at {self._sanitize_url(self.redis_url)}: {e}") + except RedisError as e: + logger.error("Redis initialization error", error=str(e)) + raise RedisServiceError(f"Redis initialization failed: {e}") + + def get(self, key: str) -> Optional[str]: + """ + Get a value from Redis by key. + + Args: + key: The key to retrieve + + Returns: + The value as string if found, None if key doesn't exist + + Raises: + RedisServiceError: If the operation fails + """ + try: + value = self.client.get(key) + + if value is not None: + logger.debug("Redis GET", key=key, found=True) + else: + logger.debug("Redis GET", key=key, found=False) + + return value + + except RedisError as e: + logger.error("Redis GET failed", key=key, error=str(e)) + raise RedisServiceError(f"Failed to get key '{key}': {e}") + + def get_json(self, key: str) -> Optional[Any]: + """ + Get a value from Redis and deserialize it from JSON. + + Args: + key: The key to retrieve + + Returns: + The deserialized value if found, None if key doesn't exist + + Raises: + RedisServiceError: If the operation fails or JSON is invalid + """ + value = self.get(key) + + if value is None: + return None + + try: + return json.loads(value) + except json.JSONDecodeError as e: + logger.error("Failed to decode JSON from Redis", key=key, error=str(e)) + raise RedisServiceError(f"Failed to decode JSON for key '{key}': {e}") + + def set( + self, + key: str, + value: str, + ttl: Optional[int] = None, + nx: bool = False, + xx: bool = False + ) -> bool: + """ + Set a value in Redis. + + Args: + key: The key to set + value: The value to store (must be string) + ttl: Time to live in seconds (None for no expiration) + nx: Only set if key does not exist (for locking) + xx: Only set if key already exists + + Returns: + True if the key was set, False if not set (due to nx/xx conditions) + + Raises: + RedisServiceError: If the operation fails + """ + try: + result = self.client.set( + key, + value, + ex=ttl, # Expiration in seconds + nx=nx, # Only set if not exists + xx=xx # Only set if exists + ) + + # set() returns True if set, None if not set due to nx/xx + success = result is True or result == 1 + + logger.debug("Redis SET", key=key, ttl=ttl, nx=nx, xx=xx, success=success) + + return success + + except RedisError as e: + logger.error("Redis SET failed", key=key, error=str(e)) + raise RedisServiceError(f"Failed to set key '{key}': {e}") + + def set_json( + self, + key: str, + value: Any, + ttl: Optional[int] = None, + nx: bool = False, + xx: bool = False + ) -> bool: + """ + Serialize a value to JSON and store it in Redis. + + Args: + key: The key to set + value: The value to serialize and store (must be JSON-serializable) + ttl: Time to live in seconds (None for no expiration) + nx: Only set if key does not exist + xx: Only set if key already exists + + Returns: + True if the key was set, False if not set (due to nx/xx conditions) + + Raises: + RedisServiceError: If the operation fails or value is not JSON-serializable + """ + try: + json_value = json.dumps(value) + except (TypeError, ValueError) as e: + logger.error("Failed to serialize value to JSON", key=key, error=str(e)) + raise RedisServiceError(f"Failed to serialize value for key '{key}': {e}") + + return self.set(key, json_value, ttl=ttl, nx=nx, xx=xx) + + def delete(self, *keys: str) -> int: + """ + Delete one or more keys from Redis. + + Args: + *keys: One or more keys to delete + + Returns: + The number of keys that were deleted + + Raises: + RedisServiceError: If the operation fails + """ + if not keys: + return 0 + + try: + deleted_count = self.client.delete(*keys) + + logger.debug("Redis DELETE", keys=keys, deleted_count=deleted_count) + + return deleted_count + + except RedisError as e: + logger.error("Redis DELETE failed", keys=keys, error=str(e)) + raise RedisServiceError(f"Failed to delete keys {keys}: {e}") + + def exists(self, *keys: str) -> int: + """ + Check if one or more keys exist in Redis. + + Args: + *keys: One or more keys to check + + Returns: + The number of keys that exist + + Raises: + RedisServiceError: If the operation fails + """ + if not keys: + return 0 + + try: + exists_count = self.client.exists(*keys) + + logger.debug("Redis EXISTS", keys=keys, exists_count=exists_count) + + return exists_count + + except RedisError as e: + logger.error("Redis EXISTS failed", keys=keys, error=str(e)) + raise RedisServiceError(f"Failed to check existence of keys {keys}: {e}") + + def expire(self, key: str, ttl: int) -> bool: + """ + Set a TTL (time to live) on an existing key. + + Args: + key: The key to set expiration on + ttl: Time to live in seconds + + Returns: + True if the timeout was set, False if key doesn't exist + + Raises: + RedisServiceError: If the operation fails + """ + try: + result = self.client.expire(key, ttl) + + logger.debug("Redis EXPIRE", key=key, ttl=ttl, success=result) + + return result + + except RedisError as e: + logger.error("Redis EXPIRE failed", key=key, ttl=ttl, error=str(e)) + raise RedisServiceError(f"Failed to set expiration for key '{key}': {e}") + + def ttl(self, key: str) -> int: + """ + Get the remaining TTL (time to live) for a key. + + Args: + key: The key to check + + Returns: + TTL in seconds, -1 if key exists but has no expiry, -2 if key doesn't exist + + Raises: + RedisServiceError: If the operation fails + """ + try: + remaining = self.client.ttl(key) + + logger.debug("Redis TTL", key=key, remaining=remaining) + + return remaining + + except RedisError as e: + logger.error("Redis TTL failed", key=key, error=str(e)) + raise RedisServiceError(f"Failed to get TTL for key '{key}': {e}") + + def incr(self, key: str, amount: int = 1) -> int: + """ + Increment a key's value by the given amount. + + If the key doesn't exist, it will be created with the increment value. + + Args: + key: The key to increment + amount: Amount to increment by (default 1) + + Returns: + The new value after incrementing + + Raises: + RedisServiceError: If the operation fails or value is not an integer + """ + try: + new_value = self.client.incrby(key, amount) + + logger.debug("Redis INCR", key=key, amount=amount, new_value=new_value) + + return new_value + + except RedisError as e: + logger.error("Redis INCR failed", key=key, amount=amount, error=str(e)) + raise RedisServiceError(f"Failed to increment key '{key}': {e}") + + def decr(self, key: str, amount: int = 1) -> int: + """ + Decrement a key's value by the given amount. + + If the key doesn't exist, it will be created with the negative increment value. + + Args: + key: The key to decrement + amount: Amount to decrement by (default 1) + + Returns: + The new value after decrementing + + Raises: + RedisServiceError: If the operation fails or value is not an integer + """ + try: + new_value = self.client.decrby(key, amount) + + logger.debug("Redis DECR", key=key, amount=amount, new_value=new_value) + + return new_value + + except RedisError as e: + logger.error("Redis DECR failed", key=key, amount=amount, error=str(e)) + raise RedisServiceError(f"Failed to decrement key '{key}': {e}") + + def health_check(self) -> bool: + """ + Check if Redis connection is healthy. + + This performs a PING command to verify the connection is working. + + Returns: + True if Redis is healthy and responding, False otherwise + """ + try: + response = self.client.ping() + + if response: + logger.debug("Redis health check passed") + return True + else: + logger.warning("Redis health check failed - unexpected response", response=response) + return False + + except RedisError as e: + logger.error("Redis health check failed", error=str(e)) + return False + + def info(self) -> dict: + """ + Get Redis server information. + + Returns: + Dictionary containing server info (version, memory, clients, etc.) + + Raises: + RedisServiceError: If the operation fails + """ + try: + info = self.client.info() + + logger.debug("Redis INFO retrieved", redis_version=info.get('redis_version')) + + return info + + except RedisError as e: + logger.error("Redis INFO failed", error=str(e)) + raise RedisServiceError(f"Failed to get Redis info: {e}") + + def flush_db(self) -> bool: + """ + Delete all keys in the current database. + + WARNING: This is a destructive operation. Use with caution. + + Returns: + True if successful + + Raises: + RedisServiceError: If the operation fails + """ + try: + self.client.flushdb() + + logger.warning("Redis database flushed") + + return True + + except RedisError as e: + logger.error("Redis FLUSHDB failed", error=str(e)) + raise RedisServiceError(f"Failed to flush database: {e}") + + def close(self) -> None: + """ + Close all connections in the pool. + + Call this when shutting down the application to cleanly release connections. + """ + try: + self.pool.disconnect() + logger.info("Redis connection pool closed") + except Exception as e: + logger.error("Error closing Redis connection pool", error=str(e)) + + def _sanitize_url(self, url: str) -> str: + """ + Remove password from Redis URL for safe logging. + + Args: + url: Redis URL that may contain password + + Returns: + URL with password masked + """ + # Simple sanitization - mask password if present + # Format: redis://user:password@host:port/db + if '@' in url: + # Split on @ and mask everything before it except the protocol + parts = url.split('@') + protocol_and_creds = parts[0] + host_and_rest = parts[1] + + if '://' in protocol_and_creds: + protocol = protocol_and_creds.split('://')[0] + return f"{protocol}://***@{host_and_rest}" + + return url + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - close connections.""" + self.close() + return False diff --git a/api/app/services/session_service.py b/api/app/services/session_service.py new file mode 100644 index 0000000..b782432 --- /dev/null +++ b/api/app/services/session_service.py @@ -0,0 +1,705 @@ +""" +Session Service - CRUD operations for game sessions. + +This service handles creating, reading, updating, and managing game sessions, +with support for both solo and multiplayer sessions. +""" + +import json +from typing import List, Optional +from datetime import datetime, timezone + +from appwrite.query import Query +from appwrite.id import ID + +from app.models.session import GameSession, GameState, ConversationEntry, SessionConfig +from app.models.enums import SessionStatus, SessionType +from app.models.action_prompt import LocationType +from app.services.database_service import get_database_service +from app.services.appwrite_service import AppwriteService +from app.services.character_service import get_character_service, CharacterNotFound +from app.services.location_loader import get_location_loader +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +# Session limits per user +MAX_ACTIVE_SESSIONS = 5 + + +class SessionNotFound(Exception): + """Raised when session ID doesn't exist or user doesn't own it.""" + pass + + +class SessionLimitExceeded(Exception): + """Raised when user tries to create more sessions than allowed.""" + pass + + +class SessionValidationError(Exception): + """Raised when session validation fails.""" + pass + + +class SessionService: + """ + Service for managing game sessions. + + This service provides: + - Session creation (solo and multiplayer) + - Session retrieval and listing + - Session state updates + - Conversation history management + - Game state tracking + """ + + def __init__(self): + """Initialize the session service with dependencies.""" + self.db = get_database_service() + self.appwrite = AppwriteService() + self.character_service = get_character_service() + self.collection_id = "game_sessions" + + logger.info("SessionService initialized") + + def create_solo_session( + self, + user_id: str, + character_id: str, + starting_location: Optional[str] = None, + starting_location_type: Optional[LocationType] = None + ) -> GameSession: + """ + Create a new solo game session. + + This method: + 1. Validates user owns the character + 2. Validates user hasn't exceeded session limit + 3. Determines starting location from location data + 4. Creates session with initial game state + 5. Stores in Appwrite database + + Args: + user_id: Owner's user ID + character_id: Character ID for this session + starting_location: Initial location ID (optional, uses default starting location) + starting_location_type: Initial location type (optional, derived from location data) + + Returns: + Created GameSession instance + + Raises: + CharacterNotFound: If character doesn't exist or user doesn't own it + SessionLimitExceeded: If user has reached active session limit + """ + try: + logger.info("Creating solo session", + user_id=user_id, + character_id=character_id) + + # Validate user owns the character + character = self.character_service.get_character(character_id, user_id) + if not character: + raise CharacterNotFound(f"Character not found: {character_id}") + + # Determine starting location from location data if not provided + if not starting_location: + location_loader = get_location_loader() + starting_locations = location_loader.get_starting_locations() + + if starting_locations: + # Use first starting location (usually crossville_village) + start_loc = starting_locations[0] + starting_location = start_loc.location_id + # Convert from enums.LocationType to action_prompt.LocationType via string value + starting_location_type = LocationType(start_loc.location_type.value) + logger.info("Using starting location from data", + location_id=starting_location, + location_type=starting_location_type.value) + else: + # Fallback to crossville_village + starting_location = "crossville_village" + starting_location_type = LocationType.TOWN + logger.warning("No starting locations found, using fallback", + location_id=starting_location) + + # Ensure location type is set + if not starting_location_type: + starting_location_type = LocationType.TOWN + + # Check session limit + active_count = self.count_user_sessions(user_id, active_only=True) + if active_count >= MAX_ACTIVE_SESSIONS: + logger.warning("Session limit exceeded", + user_id=user_id, + current=active_count, + limit=MAX_ACTIVE_SESSIONS) + raise SessionLimitExceeded( + f"Maximum active sessions reached ({active_count}/{MAX_ACTIVE_SESSIONS}). " + f"Please end an existing session to start a new one." + ) + + # Generate unique session ID + session_id = ID.unique() + + # Create game state with starting location + game_state = GameState( + current_location=starting_location, + location_type=starting_location_type, + discovered_locations=[starting_location], + active_quests=[], + world_events=[] + ) + + # Create session instance + session = GameSession( + session_id=session_id, + session_type=SessionType.SOLO, + solo_character_id=character_id, + user_id=user_id, + party_member_ids=[], + config=SessionConfig(), + game_state=game_state, + turn_order=[character_id], + current_turn=0, + turn_number=0, + status=SessionStatus.ACTIVE + ) + + # Serialize and store + session_dict = session.to_dict() + session_json = json.dumps(session_dict) + + document_data = { + 'userId': user_id, + 'characterId': character_id, + 'sessionData': session_json, + 'status': SessionStatus.ACTIVE.value, + 'sessionType': SessionType.SOLO.value + } + + self.db.create_document( + collection_id=self.collection_id, + data=document_data, + document_id=session_id + ) + + logger.info("Solo session created successfully", + session_id=session_id, + user_id=user_id, + character_id=character_id) + + return session + + except (CharacterNotFound, SessionLimitExceeded): + raise + except Exception as e: + logger.error("Failed to create solo session", + user_id=user_id, + character_id=character_id, + error=str(e)) + raise + + def get_session(self, session_id: str, user_id: Optional[str] = None) -> GameSession: + """ + Get a session by ID. + + Args: + session_id: Session ID + user_id: Optional user ID for ownership validation + + Returns: + GameSession instance + + Raises: + SessionNotFound: If session doesn't exist or user doesn't own it + """ + try: + logger.debug("Fetching session", session_id=session_id) + + # Get document from database + document = self.db.get_row(self.collection_id, session_id) + + if not document: + logger.warning("Session not found", session_id=session_id) + raise SessionNotFound(f"Session not found: {session_id}") + + # Verify ownership if user_id provided + if user_id and document.data.get('userId') != user_id: + logger.warning("Session ownership mismatch", + session_id=session_id, + expected_user=user_id, + actual_user=document.data.get('userId')) + raise SessionNotFound(f"Session not found: {session_id}") + + # Parse session data + session_json = document.data.get('sessionData') + session_dict = json.loads(session_json) + session = GameSession.from_dict(session_dict) + + logger.debug("Session fetched successfully", session_id=session_id) + return session + + except SessionNotFound: + raise + except Exception as e: + logger.error("Failed to fetch session", + session_id=session_id, + error=str(e)) + raise + + def update_session(self, session: GameSession) -> GameSession: + """ + Update a session in the database. + + Args: + session: GameSession instance with updated data + + Returns: + Updated GameSession instance + """ + try: + logger.debug("Updating session", session_id=session.session_id) + + # Serialize session + session_dict = session.to_dict() + session_json = json.dumps(session_dict) + + # Update in database + self.db.update_document( + collection_id=self.collection_id, + document_id=session.session_id, + data={ + 'sessionData': session_json, + 'status': session.status.value + } + ) + + logger.debug("Session updated successfully", session_id=session.session_id) + return session + + except Exception as e: + logger.error("Failed to update session", + session_id=session.session_id, + error=str(e)) + raise + + def get_user_sessions( + self, + user_id: str, + active_only: bool = True, + limit: int = 25 + ) -> List[GameSession]: + """ + Get all sessions for a user. + + Args: + user_id: User ID + active_only: If True, only return active sessions + limit: Maximum number of sessions to return + + Returns: + List of GameSession instances + """ + try: + logger.debug("Fetching user sessions", + user_id=user_id, + active_only=active_only) + + # Build query + queries = [Query.equal('userId', user_id)] + if active_only: + queries.append(Query.equal('status', SessionStatus.ACTIVE.value)) + + documents = self.db.list_rows( + table_id=self.collection_id, + queries=queries, + limit=limit + ) + + # Parse all sessions + sessions = [] + for document in documents: + try: + session_json = document.data.get('sessionData') + session_dict = json.loads(session_json) + session = GameSession.from_dict(session_dict) + sessions.append(session) + except Exception as e: + logger.error("Failed to parse session", + document_id=document.id, + error=str(e)) + continue + + logger.debug("User sessions fetched", + user_id=user_id, + count=len(sessions)) + + return sessions + + except Exception as e: + logger.error("Failed to fetch user sessions", + user_id=user_id, + error=str(e)) + raise + + def count_user_sessions(self, user_id: str, active_only: bool = True) -> int: + """ + Count sessions for a user. + + Args: + user_id: User ID + active_only: If True, only count active sessions + + Returns: + Number of sessions + """ + try: + queries = [Query.equal('userId', user_id)] + if active_only: + queries.append(Query.equal('status', SessionStatus.ACTIVE.value)) + + count = self.db.count_documents( + collection_id=self.collection_id, + queries=queries + ) + + logger.debug("Session count", + user_id=user_id, + active_only=active_only, + count=count) + return count + + except Exception as e: + logger.error("Failed to count sessions", + user_id=user_id, + error=str(e)) + return 0 + + def end_session(self, session_id: str, user_id: str) -> GameSession: + """ + End a session by marking it as completed. + + Args: + session_id: Session ID + user_id: User ID for ownership validation + + Returns: + Updated GameSession instance + + Raises: + SessionNotFound: If session doesn't exist or user doesn't own it + """ + try: + logger.info("Ending session", session_id=session_id, user_id=user_id) + + session = self.get_session(session_id, user_id) + session.status = SessionStatus.COMPLETED + session.update_activity() + + return self.update_session(session) + + except SessionNotFound: + raise + except Exception as e: + logger.error("Failed to end session", + session_id=session_id, + error=str(e)) + raise + + def add_conversation_entry( + self, + session_id: str, + character_id: str, + character_name: str, + action: str, + dm_response: str, + combat_log: Optional[List] = None, + quest_offered: Optional[dict] = None + ) -> GameSession: + """ + Add an entry to the conversation history. + + This method automatically: + - Increments turn number + - Adds timestamp + - Updates last activity + + Args: + session_id: Session ID + character_id: Acting character's ID + character_name: Acting character's name + action: Player's action text + dm_response: AI DM's response + combat_log: Optional combat actions + quest_offered: Optional quest offering info + + Returns: + Updated GameSession instance + """ + try: + logger.debug("Adding conversation entry", + session_id=session_id, + character_id=character_id) + + session = self.get_session(session_id) + + # Create conversation entry + entry = ConversationEntry( + turn=session.turn_number + 1, + character_id=character_id, + character_name=character_name, + action=action, + dm_response=dm_response, + combat_log=combat_log or [], + quest_offered=quest_offered + ) + + # Add entry and increment turn + session.conversation_history.append(entry) + session.turn_number += 1 + session.update_activity() + + # Save to database + return self.update_session(session) + + except Exception as e: + logger.error("Failed to add conversation entry", + session_id=session_id, + error=str(e)) + raise + + def get_conversation_history( + self, + session_id: str, + limit: Optional[int] = None, + offset: int = 0 + ) -> List[ConversationEntry]: + """ + Get conversation history for a session. + + Args: + session_id: Session ID + limit: Maximum entries to return (None for all) + offset: Number of entries to skip from end + + Returns: + List of ConversationEntry instances + """ + try: + session = self.get_session(session_id) + history = session.conversation_history + + # Apply offset (from end) + if offset > 0: + history = history[:-offset] if offset < len(history) else [] + + # Apply limit (from end) + if limit and len(history) > limit: + history = history[-limit:] + + return history + + except Exception as e: + logger.error("Failed to get conversation history", + session_id=session_id, + error=str(e)) + raise + + def get_recent_history(self, session_id: str, num_turns: int = 3) -> List[ConversationEntry]: + """ + Get the most recent conversation entries for AI context. + + Args: + session_id: Session ID + num_turns: Number of recent turns to return + + Returns: + List of most recent ConversationEntry instances + """ + return self.get_conversation_history(session_id, limit=num_turns) + + def update_location( + self, + session_id: str, + new_location: str, + location_type: LocationType + ) -> GameSession: + """ + Update the current location in the session. + + Also adds location to discovered_locations if not already there. + + Args: + session_id: Session ID + new_location: New location name + location_type: New location type + + Returns: + Updated GameSession instance + """ + try: + logger.debug("Updating location", + session_id=session_id, + new_location=new_location) + + session = self.get_session(session_id) + session.game_state.current_location = new_location + session.game_state.location_type = location_type + + # Track discovered locations + if new_location not in session.game_state.discovered_locations: + session.game_state.discovered_locations.append(new_location) + + session.update_activity() + return self.update_session(session) + + except Exception as e: + logger.error("Failed to update location", + session_id=session_id, + error=str(e)) + raise + + def add_discovered_location(self, session_id: str, location: str) -> GameSession: + """ + Add a location to the discovered locations list. + + Args: + session_id: Session ID + location: Location name to add + + Returns: + Updated GameSession instance + """ + try: + session = self.get_session(session_id) + + if location not in session.game_state.discovered_locations: + session.game_state.discovered_locations.append(location) + session.update_activity() + return self.update_session(session) + + return session + + except Exception as e: + logger.error("Failed to add discovered location", + session_id=session_id, + error=str(e)) + raise + + def add_active_quest(self, session_id: str, quest_id: str) -> GameSession: + """ + Add a quest to the active quests list. + + Validates max 2 active quests limit. + + Args: + session_id: Session ID + quest_id: Quest ID to add + + Returns: + Updated GameSession instance + + Raises: + SessionValidationError: If max quests limit exceeded + """ + try: + session = self.get_session(session_id) + + # Check max active quests (2) + if len(session.game_state.active_quests) >= 2: + raise SessionValidationError( + "Maximum active quests reached (2/2). " + "Complete or abandon a quest to accept a new one." + ) + + if quest_id not in session.game_state.active_quests: + session.game_state.active_quests.append(quest_id) + session.update_activity() + return self.update_session(session) + + return session + + except SessionValidationError: + raise + except Exception as e: + logger.error("Failed to add active quest", + session_id=session_id, + quest_id=quest_id, + error=str(e)) + raise + + def remove_active_quest(self, session_id: str, quest_id: str) -> GameSession: + """ + Remove a quest from the active quests list. + + Args: + session_id: Session ID + quest_id: Quest ID to remove + + Returns: + Updated GameSession instance + """ + try: + session = self.get_session(session_id) + + if quest_id in session.game_state.active_quests: + session.game_state.active_quests.remove(quest_id) + session.update_activity() + return self.update_session(session) + + return session + + except Exception as e: + logger.error("Failed to remove active quest", + session_id=session_id, + quest_id=quest_id, + error=str(e)) + raise + + def add_world_event(self, session_id: str, event: dict) -> GameSession: + """ + Add a world event to the session. + + Args: + session_id: Session ID + event: Event dictionary with type, description, etc. + + Returns: + Updated GameSession instance + """ + try: + session = self.get_session(session_id) + + # Add timestamp if not present + if 'timestamp' not in event: + event['timestamp'] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + session.game_state.world_events.append(event) + session.update_activity() + return self.update_session(session) + + except Exception as e: + logger.error("Failed to add world event", + session_id=session_id, + error=str(e)) + raise + + +# Global instance for convenience +_service_instance: Optional[SessionService] = None + + +def get_session_service() -> SessionService: + """ + Get the global SessionService instance. + + Returns: + Singleton SessionService instance + """ + global _service_instance + if _service_instance is None: + _service_instance = SessionService() + return _service_instance diff --git a/api/app/services/usage_tracking_service.py b/api/app/services/usage_tracking_service.py new file mode 100644 index 0000000..141c888 --- /dev/null +++ b/api/app/services/usage_tracking_service.py @@ -0,0 +1,528 @@ +""" +Usage Tracking Service for AI cost and usage monitoring. + +This service tracks all AI usage events, calculates costs, and provides +analytics for monitoring and rate limiting purposes. + +Usage: + from app.services.usage_tracking_service import UsageTrackingService + + tracker = UsageTrackingService() + + # Log a usage event + tracker.log_usage( + user_id="user_123", + model="anthropic/claude-3.5-sonnet", + tokens_input=100, + tokens_output=350, + task_type=TaskType.STORY_PROGRESSION + ) + + # Get daily usage + usage = tracker.get_daily_usage("user_123", date.today()) + print(f"Total requests: {usage.total_requests}") + print(f"Estimated cost: ${usage.estimated_cost:.4f}") +""" + +import os +from datetime import datetime, timezone, date, timedelta +from typing import Dict, Any, List, Optional +from uuid import uuid4 + +from appwrite.client import Client +from appwrite.services.tables_db import TablesDB +from appwrite.exception import AppwriteException +from appwrite.id import ID +from appwrite.query import Query + +from app.utils.logging import get_logger +from app.models.ai_usage import ( + AIUsageLog, + DailyUsageSummary, + MonthlyUsageSummary, + TaskType +) + +logger = get_logger(__file__) + + +# Cost per 1000 tokens by model (in USD) +# These are estimates based on Replicate pricing +MODEL_COSTS = { + # Llama models (via Replicate) - very cheap + "meta/meta-llama-3-8b-instruct": { + "input": 0.0001, # $0.0001 per 1K input tokens + "output": 0.0001, # $0.0001 per 1K output tokens + }, + "meta/meta-llama-3-70b-instruct": { + "input": 0.0006, + "output": 0.0006, + }, + # Claude models (via Replicate) + "anthropic/claude-3.5-haiku": { + "input": 0.001, # $0.001 per 1K input tokens + "output": 0.005, # $0.005 per 1K output tokens + }, + "anthropic/claude-3-haiku": { + "input": 0.00025, + "output": 0.00125, + }, + "anthropic/claude-3.5-sonnet": { + "input": 0.003, # $0.003 per 1K input tokens + "output": 0.015, # $0.015 per 1K output tokens + }, + "anthropic/claude-4.5-sonnet": { + "input": 0.003, + "output": 0.015, + }, + "anthropic/claude-3-opus": { + "input": 0.015, # $0.015 per 1K input tokens + "output": 0.075, # $0.075 per 1K output tokens + }, +} + +# Default cost for unknown models +DEFAULT_COST = {"input": 0.001, "output": 0.005} + + +class UsageTrackingService: + """ + Service for tracking AI usage and calculating costs. + + This service provides: + - Logging individual AI usage events to Appwrite + - Calculating estimated costs based on model pricing + - Retrieving daily and monthly usage summaries + - Analytics for monitoring and rate limiting + + The service stores usage logs in an Appwrite collection named 'ai_usage_logs'. + """ + + # Collection ID for usage logs + COLLECTION_ID = "ai_usage_logs" + + def __init__(self): + """ + Initialize the usage tracking service. + + Reads configuration from environment variables: + - APPWRITE_ENDPOINT: Appwrite API endpoint + - APPWRITE_PROJECT_ID: Appwrite project ID + - APPWRITE_API_KEY: Appwrite API key + - APPWRITE_DATABASE_ID: Appwrite database ID + + Raises: + ValueError: If required environment variables are missing + """ + self.endpoint = os.getenv('APPWRITE_ENDPOINT') + self.project_id = os.getenv('APPWRITE_PROJECT_ID') + self.api_key = os.getenv('APPWRITE_API_KEY') + self.database_id = os.getenv('APPWRITE_DATABASE_ID', 'main') + + if not all([self.endpoint, self.project_id, self.api_key]): + logger.error("Missing Appwrite configuration in environment variables") + raise ValueError("Appwrite configuration incomplete. Check APPWRITE_* environment variables.") + + # Initialize Appwrite client + self.client = Client() + self.client.set_endpoint(self.endpoint) + self.client.set_project(self.project_id) + self.client.set_key(self.api_key) + + # Initialize TablesDB service + self.tables_db = TablesDB(self.client) + + logger.info("UsageTrackingService initialized", database_id=self.database_id) + + def log_usage( + self, + user_id: str, + model: str, + tokens_input: int, + tokens_output: int, + task_type: TaskType, + session_id: Optional[str] = None, + character_id: Optional[str] = None, + request_duration_ms: int = 0, + success: bool = True, + error_message: Optional[str] = None + ) -> AIUsageLog: + """ + Log an AI usage event. + + This method creates a new usage log entry in Appwrite with all + relevant information about the AI request including calculated + estimated cost. + + Args: + user_id: User who made the request + model: Model identifier (e.g., "anthropic/claude-3.5-sonnet") + tokens_input: Number of input tokens (prompt) + tokens_output: Number of output tokens (response) + task_type: Type of task (story, combat, quest, npc) + session_id: Optional game session ID + character_id: Optional character ID + request_duration_ms: Request duration in milliseconds + success: Whether the request succeeded + error_message: Error message if failed + + Returns: + AIUsageLog with the logged data + + Raises: + AppwriteException: If storage fails + """ + # Calculate total tokens + tokens_total = tokens_input + tokens_output + + # Calculate estimated cost + estimated_cost = self._calculate_cost(model, tokens_input, tokens_output) + + # Generate log ID + log_id = str(uuid4()) + + # Create usage log + usage_log = AIUsageLog( + log_id=log_id, + user_id=user_id, + timestamp=datetime.now(timezone.utc), + model=model, + tokens_input=tokens_input, + tokens_output=tokens_output, + tokens_total=tokens_total, + estimated_cost=estimated_cost, + task_type=task_type, + session_id=session_id, + character_id=character_id, + request_duration_ms=request_duration_ms, + success=success, + error_message=error_message, + ) + + try: + # Store in Appwrite + result = self.tables_db.create_row( + database_id=self.database_id, + table_id=self.COLLECTION_ID, + row_id=log_id, + data=usage_log.to_dict() + ) + + logger.info( + "AI usage logged", + log_id=log_id, + user_id=user_id, + model=model, + tokens_total=tokens_total, + estimated_cost=estimated_cost, + task_type=task_type.value, + success=success + ) + + return usage_log + + except AppwriteException as e: + logger.error( + "Failed to log AI usage", + user_id=user_id, + model=model, + error=str(e), + code=e.code + ) + raise + + def get_daily_usage(self, user_id: str, target_date: date) -> DailyUsageSummary: + """ + Get AI usage summary for a specific day. + + Args: + user_id: User ID to get usage for + target_date: Date to get usage for + + Returns: + DailyUsageSummary with aggregated usage data + + Raises: + AppwriteException: If query fails + """ + try: + # Build date range for the target day (UTC) + start_of_day = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc) + end_of_day = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=timezone.utc) + + # Query usage logs for this user and date + result = self.tables_db.list_rows( + database_id=self.database_id, + table_id=self.COLLECTION_ID, + queries=[ + Query.equal("user_id", user_id), + Query.greater_than_equal("timestamp", start_of_day.isoformat()), + Query.less_than_equal("timestamp", end_of_day.isoformat()), + Query.limit(1000) # Cap at 1000 entries per day + ] + ) + + # Aggregate the data + total_requests = 0 + total_tokens = 0 + total_input_tokens = 0 + total_output_tokens = 0 + total_cost = 0.0 + requests_by_task: Dict[str, int] = {} + + for doc in result['rows']: + total_requests += 1 + total_tokens += doc.get('tokens_total', 0) + total_input_tokens += doc.get('tokens_input', 0) + total_output_tokens += doc.get('tokens_output', 0) + total_cost += doc.get('estimated_cost', 0.0) + + task_type = doc.get('task_type', 'general') + requests_by_task[task_type] = requests_by_task.get(task_type, 0) + 1 + + summary = DailyUsageSummary( + date=target_date, + user_id=user_id, + total_requests=total_requests, + total_tokens=total_tokens, + total_input_tokens=total_input_tokens, + total_output_tokens=total_output_tokens, + estimated_cost=total_cost, + requests_by_task=requests_by_task + ) + + logger.debug( + "Daily usage retrieved", + user_id=user_id, + date=target_date.isoformat(), + total_requests=total_requests, + estimated_cost=total_cost + ) + + return summary + + except AppwriteException as e: + logger.error( + "Failed to get daily usage", + user_id=user_id, + date=target_date.isoformat(), + error=str(e), + code=e.code + ) + raise + + def get_monthly_cost(self, user_id: str, year: int, month: int) -> MonthlyUsageSummary: + """ + Get AI usage cost summary for a specific month. + + Args: + user_id: User ID to get cost for + year: Year (e.g., 2025) + month: Month (1-12) + + Returns: + MonthlyUsageSummary with aggregated cost data + + Raises: + AppwriteException: If query fails + ValueError: If month is invalid + """ + if not 1 <= month <= 12: + raise ValueError(f"Invalid month: {month}. Must be 1-12.") + + try: + # Build date range for the month + start_of_month = datetime(year, month, 1, 0, 0, 0, tzinfo=timezone.utc) + + # Calculate end of month + if month == 12: + end_of_month = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - timedelta(seconds=1) + else: + end_of_month = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=timezone.utc) - timedelta(seconds=1) + + # Query usage logs for this user and month + result = self.tables_db.list_rows( + database_id=self.database_id, + table_id=self.COLLECTION_ID, + queries=[ + Query.equal("user_id", user_id), + Query.greater_than_equal("timestamp", start_of_month.isoformat()), + Query.less_than_equal("timestamp", end_of_month.isoformat()), + Query.limit(5000) # Cap at 5000 entries per month + ] + ) + + # Aggregate the data + total_requests = 0 + total_tokens = 0 + total_cost = 0.0 + + for doc in result['rows']: + total_requests += 1 + total_tokens += doc.get('tokens_total', 0) + total_cost += doc.get('estimated_cost', 0.0) + + summary = MonthlyUsageSummary( + year=year, + month=month, + user_id=user_id, + total_requests=total_requests, + total_tokens=total_tokens, + estimated_cost=total_cost + ) + + logger.debug( + "Monthly cost retrieved", + user_id=user_id, + year=year, + month=month, + total_requests=total_requests, + estimated_cost=total_cost + ) + + return summary + + except AppwriteException as e: + logger.error( + "Failed to get monthly cost", + user_id=user_id, + year=year, + month=month, + error=str(e), + code=e.code + ) + raise + + def get_total_daily_cost(self, target_date: date) -> float: + """ + Get the total AI cost across all users for a specific day. + + Used for admin monitoring and alerting. + + Args: + target_date: Date to get cost for + + Returns: + Total estimated cost in USD + + Raises: + AppwriteException: If query fails + """ + try: + # Build date range for the target day + start_of_day = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc) + end_of_day = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=timezone.utc) + + # Query all usage logs for this date + result = self.tables_db.list_rows( + database_id=self.database_id, + table_id=self.COLLECTION_ID, + queries=[ + Query.greater_than_equal("timestamp", start_of_day.isoformat()), + Query.less_than_equal("timestamp", end_of_day.isoformat()), + Query.limit(10000) + ] + ) + + # Sum up costs + total_cost = sum(doc.get('estimated_cost', 0.0) for doc in result['rows']) + + logger.debug( + "Total daily cost retrieved", + date=target_date.isoformat(), + total_cost=total_cost, + total_documents=len(result['rows']) + ) + + return total_cost + + except AppwriteException as e: + logger.error( + "Failed to get total daily cost", + date=target_date.isoformat(), + error=str(e), + code=e.code + ) + raise + + def get_user_request_count_today(self, user_id: str) -> int: + """ + Get the number of AI requests a user has made today. + + Used for rate limiting checks. + + Args: + user_id: User ID to check + + Returns: + Number of requests made today + + Raises: + AppwriteException: If query fails + """ + try: + summary = self.get_daily_usage(user_id, date.today()) + return summary.total_requests + + except AppwriteException: + # If there's an error, return 0 to be safe (fail open) + logger.warning( + "Failed to get user request count, returning 0", + user_id=user_id + ) + return 0 + + def _calculate_cost(self, model: str, tokens_input: int, tokens_output: int) -> float: + """ + Calculate the estimated cost for an AI request. + + Args: + model: Model identifier + tokens_input: Number of input tokens + tokens_output: Number of output tokens + + Returns: + Estimated cost in USD + """ + # Get cost per 1K tokens for this model + model_cost = MODEL_COSTS.get(model, DEFAULT_COST) + + # Calculate cost (costs are per 1K tokens) + input_cost = (tokens_input / 1000) * model_cost["input"] + output_cost = (tokens_output / 1000) * model_cost["output"] + total_cost = input_cost + output_cost + + return round(total_cost, 6) # Round to 6 decimal places + + @staticmethod + def estimate_cost_for_model(model: str, tokens_input: int, tokens_output: int) -> float: + """ + Static method to estimate cost without needing a service instance. + + Useful for pre-calculation and UI display. + + Args: + model: Model identifier + tokens_input: Number of input tokens + tokens_output: Number of output tokens + + Returns: + Estimated cost in USD + """ + model_cost = MODEL_COSTS.get(model, DEFAULT_COST) + input_cost = (tokens_input / 1000) * model_cost["input"] + output_cost = (tokens_output / 1000) * model_cost["output"] + return round(input_cost + output_cost, 6) + + @staticmethod + def get_model_cost_info(model: str) -> Dict[str, float]: + """ + Get cost information for a model. + + Args: + model: Model identifier + + Returns: + Dictionary with 'input' and 'output' cost per 1K tokens + """ + return MODEL_COSTS.get(model, DEFAULT_COST) diff --git a/api/app/tasks/__init__.py b/api/app/tasks/__init__.py new file mode 100644 index 0000000..457f900 --- /dev/null +++ b/api/app/tasks/__init__.py @@ -0,0 +1,156 @@ +""" +RQ Task Queue Configuration + +This module defines the job queues used for background task processing. +All async operations (AI generation, combat processing, marketplace tasks) +are processed through these queues. + +Queue Types: +- ai_tasks: AI narrative generation (highest priority) +- combat_tasks: Combat processing +- marketplace_tasks: Auction cleanup and periodic tasks (lowest priority) + +Usage: + from app.tasks import get_queue, QUEUE_AI_TASKS + + queue = get_queue(QUEUE_AI_TASKS) + job = queue.enqueue(my_function, arg1, arg2) +""" + +import os +from typing import Optional + +from redis import Redis +from rq import Queue + +from app.utils.logging import get_logger + + +# Initialize logger +logger = get_logger(__file__) + + +# Queue names +QUEUE_AI_TASKS = 'ai_tasks' +QUEUE_COMBAT_TASKS = 'combat_tasks' +QUEUE_MARKETPLACE_TASKS = 'marketplace_tasks' + +# All queue names in priority order (highest first) +ALL_QUEUES = [ + QUEUE_AI_TASKS, + QUEUE_COMBAT_TASKS, + QUEUE_MARKETPLACE_TASKS, +] + +# Queue configurations +QUEUE_CONFIG = { + QUEUE_AI_TASKS: { + 'default_timeout': 120, # 2 minutes for AI generation + 'default_result_ttl': 3600, # Keep results for 1 hour + 'default_failure_ttl': 86400, # Keep failures for 24 hours + 'description': 'AI narrative generation tasks', + }, + QUEUE_COMBAT_TASKS: { + 'default_timeout': 60, # 1 minute for combat + 'default_result_ttl': 3600, + 'default_failure_ttl': 86400, + 'description': 'Combat processing tasks', + }, + QUEUE_MARKETPLACE_TASKS: { + 'default_timeout': 300, # 5 minutes for marketplace + 'default_result_ttl': 3600, + 'default_failure_ttl': 86400, + 'description': 'Marketplace and auction tasks', + }, +} + + +# Redis connection singleton +_redis_connection: Optional[Redis] = None + + +def get_redis_connection() -> Redis: + """ + Get the Redis connection for RQ. + + Uses a singleton pattern to reuse the connection. + + Returns: + Redis connection instance + """ + global _redis_connection + + if _redis_connection is None: + redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0') + _redis_connection = Redis.from_url(redis_url) + logger.info("RQ Redis connection established", redis_url=redis_url.split('@')[-1]) + + return _redis_connection + + +def get_queue(queue_name: str) -> Queue: + """ + Get an RQ queue by name. + + Args: + queue_name: Name of the queue (use constants like QUEUE_AI_TASKS) + + Returns: + RQ Queue instance + + Raises: + ValueError: If queue name is not recognized + """ + if queue_name not in QUEUE_CONFIG: + raise ValueError(f"Unknown queue: {queue_name}. Must be one of {list(QUEUE_CONFIG.keys())}") + + config = QUEUE_CONFIG[queue_name] + conn = get_redis_connection() + + return Queue( + name=queue_name, + connection=conn, + default_timeout=config['default_timeout'], + ) + + +def get_all_queues() -> list[Queue]: + """ + Get all configured queues in priority order. + + Returns: + List of Queue instances (highest priority first) + """ + return [get_queue(name) for name in ALL_QUEUES] + + +def get_queue_info(queue_name: str) -> dict: + """ + Get information about a queue. + + Args: + queue_name: Name of the queue + + Returns: + Dictionary with queue statistics + """ + queue = get_queue(queue_name) + config = QUEUE_CONFIG[queue_name] + + return { + 'name': queue_name, + 'description': config['description'], + 'count': len(queue), + 'default_timeout': config['default_timeout'], + 'default_result_ttl': config['default_result_ttl'], + } + + +def get_all_queues_info() -> list[dict]: + """ + Get information about all queues. + + Returns: + List of queue info dictionaries + """ + return [get_queue_info(name) for name in ALL_QUEUES] diff --git a/api/app/tasks/ai_tasks.py b/api/app/tasks/ai_tasks.py new file mode 100644 index 0000000..eb54359 --- /dev/null +++ b/api/app/tasks/ai_tasks.py @@ -0,0 +1,1314 @@ +""" +AI Task Jobs for Background Processing + +This module provides the base infrastructure for AI-related background jobs. +All AI generation tasks (narrative, combat, quests) are processed through +these job structures. + +Usage: + from app.tasks.ai_tasks import enqueue_ai_task, get_job_status, get_job_result + + # Enqueue a task + result = enqueue_ai_task( + task_type="narrative", + user_id="user_123", + context={"action": "explore"}, + priority="high" + ) + # Returns: {"job_id": "abc-123", "status": "queued"} + + # Check status + status = get_job_status(result["job_id"]) + # Returns: {"job_id": "abc-123", "status": "completed", ...} + + # Get result + result = get_job_result(result["job_id"]) +""" + +import json +import uuid +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Optional +from dataclasses import dataclass, asdict + +from rq import Retry +from rq.job import Job + +from app.tasks import get_queue, get_redis_connection, QUEUE_AI_TASKS +from app.services.redis_service import RedisService +from app.utils.logging import get_logger + +# Imports for AI generation +from app.ai.narrative_generator import NarrativeGenerator, NarrativeGeneratorError +from app.ai.model_selector import UserTier + +# Import for usage tracking +from app.services.usage_tracking_service import UsageTrackingService +from app.models.ai_usage import TaskType as UsageTaskType + +# Import for response parsing and item validation +from app.ai.response_parser import parse_ai_response, ParsedAIResponse, GameStateChanges +from app.services.item_validator import get_item_validator, ItemValidationError +from app.services.character_service import get_character_service + +# Import for template rendering +from app.ai.prompt_templates import get_prompt_templates + + +# Initialize logger +logger = get_logger(__file__) + + +class JobStatus(str, Enum): + """Job status states.""" + QUEUED = "queued" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class TaskType(str, Enum): + """Types of AI tasks.""" + NARRATIVE = "narrative" + COMBAT = "combat" + QUEST_SELECTION = "quest_selection" + NPC_DIALOGUE = "npc_dialogue" + + +class TaskPriority(str, Enum): + """Task priority levels.""" + LOW = "low" + NORMAL = "normal" + HIGH = "high" + + +@dataclass +class JobResult: + """ + Result of an AI task job. + + Attributes: + job_id: Unique job identifier + status: Current job status + task_type: Type of AI task + user_id: User who requested the task + result: The actual result data (if completed) + error: Error message (if failed) + created_at: When the job was created + started_at: When processing started + completed_at: When the job finished + retries: Number of retry attempts + """ + job_id: str + status: JobStatus + task_type: str + user_id: str + result: Optional[dict] = None + error: Optional[str] = None + created_at: Optional[str] = None + started_at: Optional[str] = None + completed_at: Optional[str] = None + retries: int = 0 + + def to_dict(self) -> dict: + """Convert to dictionary.""" + data = asdict(self) + data['status'] = self.status.value if isinstance(self.status, JobStatus) else self.status + return data + + +# Redis key prefixes for job data +JOB_RESULT_PREFIX = "ai_job_result:" +JOB_STATUS_PREFIX = "ai_job_status:" + +# TTL for job results (1 hour) +JOB_RESULT_TTL = 3600 + +# Retry configuration +MAX_RETRIES = 3 +RETRY_DELAYS = [60, 300, 900] # 1 min, 5 min, 15 min + + +def enqueue_ai_task( + task_type: str, + user_id: str, + context: dict, + priority: str = "normal", + session_id: Optional[str] = None, + character_id: Optional[str] = None, +) -> dict: + """ + Enqueue an AI task for background processing. + + Args: + task_type: Type of task (narrative, combat, quest_selection, npc_dialogue) + user_id: User ID requesting the task + context: Task-specific context data + priority: Task priority (low, normal, high) + session_id: Optional game session ID + character_id: Optional character ID + + Returns: + Dictionary with job_id and status + + Raises: + ValueError: If task_type or priority is invalid + """ + # Validate task type + try: + task_type_enum = TaskType(task_type) + except ValueError: + raise ValueError(f"Invalid task_type: {task_type}. Must be one of {[t.value for t in TaskType]}") + + # Validate priority + try: + priority_enum = TaskPriority(priority) + except ValueError: + raise ValueError(f"Invalid priority: {priority}. Must be one of {[p.value for p in TaskPriority]}") + + # Generate job ID + job_id = f"ai_{task_type}_{uuid.uuid4().hex[:12]}" + + # Get queue + queue = get_queue(QUEUE_AI_TASKS) + + # Determine timeout based on task type + timeouts = { + TaskType.NARRATIVE: 120, + TaskType.COMBAT: 60, + TaskType.QUEST_SELECTION: 90, + TaskType.NPC_DIALOGUE: 60, + } + timeout = timeouts.get(task_type_enum, 120) + + # Build job arguments + job_kwargs = { + 'task_type': task_type, + 'user_id': user_id, + 'context': context, + 'session_id': session_id, + 'character_id': character_id, + 'job_id': job_id, + } + + # Configure retry + retry = Retry(max=MAX_RETRIES, interval=RETRY_DELAYS) + + # Enqueue the job + # Priority is handled by queue position (high priority jobs go to front) + at_front = priority_enum == TaskPriority.HIGH + + job = queue.enqueue( + process_ai_task, + kwargs=job_kwargs, + job_id=job_id, + job_timeout=timeout, + result_ttl=JOB_RESULT_TTL, + failure_ttl=86400, # Keep failures for 24 hours + retry=retry, + at_front=at_front, + ) + + # Store initial job status + _store_job_status( + job_id=job_id, + status=JobStatus.QUEUED, + task_type=task_type, + user_id=user_id, + ) + + logger.info( + "AI task enqueued", + job_id=job_id, + task_type=task_type, + user_id=user_id, + priority=priority, + at_front=at_front, + ) + + return { + "job_id": job_id, + "status": JobStatus.QUEUED.value, + } + + +def process_ai_task( + task_type: str, + user_id: str, + context: dict, + job_id: str, + session_id: Optional[str] = None, + character_id: Optional[str] = None, +) -> dict: + """ + Process an AI task. + + This is the main job function that gets executed by RQ workers. + It dispatches to specific handlers based on task type. + + Args: + task_type: Type of task + user_id: User ID + context: Task context + job_id: Job ID + session_id: Optional session ID + character_id: Optional character ID + + Returns: + Result dictionary + """ + logger.info( + "Processing AI task", + job_id=job_id, + task_type=task_type, + user_id=user_id, + ) + + # Update status to processing + _update_job_status(job_id, JobStatus.PROCESSING) + + try: + # Dispatch to appropriate handler based on task type + task_type_enum = TaskType(task_type) + + if task_type_enum == TaskType.NARRATIVE: + result = _process_narrative_task(user_id, context, session_id, character_id) + elif task_type_enum == TaskType.COMBAT: + result = _process_combat_task(user_id, context, session_id, character_id) + elif task_type_enum == TaskType.QUEST_SELECTION: + result = _process_quest_selection_task(user_id, context, session_id, character_id) + elif task_type_enum == TaskType.NPC_DIALOGUE: + result = _process_npc_dialogue_task(user_id, context, session_id, character_id) + else: + raise ValueError(f"Unknown task type: {task_type}") + + # Store successful result + _store_job_result(job_id, result) + _update_job_status(job_id, JobStatus.COMPLETED, result=result) + + logger.info( + "AI task completed", + job_id=job_id, + task_type=task_type, + ) + + return result + + except Exception as e: + error_msg = str(e) + logger.error( + "AI task failed", + job_id=job_id, + task_type=task_type, + error=error_msg, + exc_info=True, + ) + + # Update status to failed + _update_job_status(job_id, JobStatus.FAILED, error=error_msg) + + # Re-raise for RQ retry handling + raise + + +def _process_narrative_task( + user_id: str, + context: dict, + session_id: Optional[str], + character_id: Optional[str], +) -> dict: + """ + Process a narrative generation task (DM response to player action). + + Args: + user_id: User ID for tier lookup + context: Must contain: + - action: The player's action text + - character: Character data dict + - game_state: Game state dict + - conversation_history: Optional list of previous turns + - world_context: Optional additional world info + - dm_prompt_template: Action-specific AI instructions + session_id: Game session ID for updating + character_id: Character ID + + Returns: + Dictionary with narrative, tokens_used, model, and metadata + """ + # Validate required context fields + required_fields = ['action', 'character', 'game_state'] + for field in required_fields: + if field not in context: + raise ValueError(f"Missing required context field: {field}") + + # Get user tier for model selection + user_tier = _get_user_tier(user_id) + + # Initialize narrative generator + generator = NarrativeGenerator() + + try: + # Pre-render dm_prompt_template if check_outcome is present + dm_prompt_template = context.get('dm_prompt_template') + check_outcome = context.get('check_outcome') + + if dm_prompt_template and check_outcome: + # Render the dm_prompt_template as a Jinja2 template with check_outcome + try: + prompt_templates = get_prompt_templates() + action_instructions = prompt_templates.render_string( + dm_prompt_template, + character=context['character'], + game_state=context['game_state'], + check_outcome=check_outcome + ) + logger.debug( + "Pre-rendered dm_prompt_template with check_outcome", + success=check_outcome.get('check_result', {}).get('success') + ) + except Exception as e: + logger.warning( + "Failed to pre-render dm_prompt_template", + error=str(e) + ) + action_instructions = dm_prompt_template + else: + action_instructions = dm_prompt_template + + # Generate the narrative response + response = generator.generate_story_response( + character=context['character'], + action=context['action'], + game_state=context['game_state'], + user_tier=user_tier, + conversation_history=context.get('conversation_history'), + world_context=context.get('world_context'), + action_instructions=action_instructions + ) + + # Parse the AI response (extracts narrative text) + parsed_response = parse_ai_response(response.narrative) + + # Process game state changes from dice check outcomes + items_added = [] + items_failed = [] + gold_changed = 0 + + # Process items from check_outcome (search results) + if check_outcome and check_outcome.get('check_result', {}).get('success'): + items_found = check_outcome.get('items_found', []) + gold_found = check_outcome.get('gold_found', 0) + + if items_found: + # Add items from search to character inventory + for item_data in items_found: + try: + # Create item grant structure for the validator + item_grant = { + "name": item_data.get("name"), + "type": "consumable", # Default type for found items + "description": item_data.get("description", ""), + "value": item_data.get("value", 1) + } + + # Use item validator to add to inventory + validator = get_item_validator() + validated_item = validator.validate_and_create_item(item_grant) + if validated_item: + character_service = get_character_service() + character_service.add_item_to_inventory( + character_id, + validated_item, + user_id + ) + items_added.append(validated_item) + logger.info( + "Item from search added to inventory", + item_name=validated_item.name, + character_id=character_id + ) + except Exception as e: + logger.warning( + "Failed to add search item", + item_name=item_data.get("name"), + error=str(e) + ) + items_failed.append(item_data.get("name", "Unknown")) + + if gold_found > 0: + # Add gold from search to character + try: + character_service = get_character_service() + character_service.add_gold(character_id, gold_found, user_id) + gold_changed += gold_found + logger.info( + "Gold from search added", + amount=gold_found, + character_id=character_id + ) + except Exception as e: + logger.warning( + "Failed to add search gold", + amount=gold_found, + error=str(e) + ) + + # Note: Items/gold/XP now come exclusively from check_outcomes (predetermined dice rolls) + # AI no longer provides structured game actions + + result = { + "dm_response": parsed_response.narrative, + "tokens_used": response.tokens_used, + "model": response.model, + "context_type": response.context_type, + "generation_time": response.generation_time, + "items_added": [item.name for item in items_added], + "items_failed": items_failed, + "gold_changed": gold_changed, + "check_outcome": check_outcome, # Dice roll info for UI animation + } + + # Update game session if session_id provided + if session_id: + _update_game_session( + session_id=session_id, + action=context['action'], + dm_response=parsed_response.narrative, + character_id=character_id, + game_changes=parsed_response.game_changes, + items_added=items_added + ) + + # Log AI usage + _log_ai_usage( + user_id=user_id, + model=response.model, + tokens_input=response.tokens_input, + tokens_output=response.tokens_output, + task_type=UsageTaskType.STORY_PROGRESSION, + session_id=session_id, + character_id=character_id, + request_duration_ms=int(response.generation_time * 1000), + success=True + ) + + logger.info( + "Narrative task completed", + user_id=user_id, + tokens_used=response.tokens_used, + model=response.model + ) + + return result + + except NarrativeGeneratorError as e: + logger.error("Narrative generation failed", user_id=user_id, error=str(e)) + raise + + +def _process_combat_task( + user_id: str, + context: dict, + session_id: Optional[str], + character_id: Optional[str], +) -> dict: + """ + Process a combat narration task. + + Args: + user_id: User ID for tier lookup + context: Must contain: + - character: Character data dict + - combat_state: Combat state with enemies, round, etc. + - action: Description of combat action + - action_result: Result dict with hit, damage, etc. + - is_critical: Optional bool for critical hit + - is_finishing_blow: Optional bool for killing blow + session_id: Game session ID + character_id: Character ID + + Returns: + Dictionary with combat_narrative, tokens_used, model + """ + # Validate required context fields + required_fields = ['character', 'combat_state', 'action', 'action_result'] + for field in required_fields: + if field not in context: + raise ValueError(f"Missing required context field: {field}") + + # Get user tier + user_tier = _get_user_tier(user_id) + + # Initialize generator + generator = NarrativeGenerator() + + try: + response = generator.generate_combat_narration( + character=context['character'], + combat_state=context['combat_state'], + action=context['action'], + action_result=context['action_result'], + user_tier=user_tier, + is_critical=context.get('is_critical', False), + is_finishing_blow=context.get('is_finishing_blow', False) + ) + + result = { + "combat_narrative": response.narrative, + "tokens_used": response.tokens_used, + "model": response.model, + "context_type": response.context_type, + "generation_time": response.generation_time, + } + + # Log AI usage + _log_ai_usage( + user_id=user_id, + model=response.model, + tokens_input=response.tokens_input, + tokens_output=response.tokens_output, + task_type=UsageTaskType.COMBAT_NARRATION, + session_id=session_id, + character_id=character_id, + request_duration_ms=int(response.generation_time * 1000), + success=True + ) + + logger.info( + "Combat narration completed", + user_id=user_id, + tokens_used=response.tokens_used + ) + + return result + + except NarrativeGeneratorError as e: + logger.error("Combat narration failed", user_id=user_id, error=str(e)) + raise + + +def _process_quest_selection_task( + user_id: str, + context: dict, + session_id: Optional[str], + character_id: Optional[str], +) -> dict: + """ + Process a quest selection task (AI selects contextually appropriate quest). + + Args: + user_id: User ID for tier lookup + context: Must contain: + - character: Character data dict + - eligible_quests: List of quest dicts that can be offered + - game_context: Current game context (location, events, etc.) + - recent_actions: Optional list of recent player actions + session_id: Game session ID + character_id: Character ID + + Returns: + Dictionary with selected_quest_id and metadata + """ + # Validate required context fields + required_fields = ['character', 'eligible_quests', 'game_context'] + for field in required_fields: + if field not in context: + raise ValueError(f"Missing required context field: {field}") + + # Get user tier + user_tier = _get_user_tier(user_id) + + # Initialize generator + generator = NarrativeGenerator() + + try: + selected_quest_id = generator.generate_quest_selection( + character=context['character'], + eligible_quests=context['eligible_quests'], + game_context=context['game_context'], + user_tier=user_tier, + recent_actions=context.get('recent_actions') + ) + + result = { + "selected_quest_id": selected_quest_id, + } + + # Log AI usage (estimate tokens for quest selection - typically small) + # Quest selection uses less tokens than narrative generation + _log_ai_usage( + user_id=user_id, + model="anthropic/claude-3.5-haiku", # Quest selection uses fast model + tokens_input=150, # Estimate for quest selection prompt + tokens_output=50, # Estimate for quest_id response + task_type=UsageTaskType.QUEST_SELECTION, + session_id=session_id, + character_id=character_id, + request_duration_ms=0, + success=True + ) + + logger.info( + "Quest selection completed", + user_id=user_id, + selected_quest_id=selected_quest_id + ) + + return result + + except NarrativeGeneratorError as e: + logger.error("Quest selection failed", user_id=user_id, error=str(e)) + raise + + +def _process_npc_dialogue_task( + user_id: str, + context: dict, + session_id: Optional[str], + character_id: Optional[str], +) -> dict: + """ + Process an NPC dialogue task. + + Args: + user_id: User ID for tier lookup + context: Must contain: + - character: Character data dict + - npc: NPC data with name, role, personality, etc. + - conversation_topic: What the player said + - game_state: Current game state + - npc_relationship: Optional relationship description + - previous_dialogue: Optional list of previous exchanges + - npc_knowledge: Optional list of things NPC knows + session_id: Game session ID + character_id: Character ID + + Returns: + Dictionary with dialogue, tokens_used, model + """ + # Validate required context fields + required_fields = ['character', 'npc', 'conversation_topic', 'game_state'] + for field in required_fields: + if field not in context: + raise ValueError(f"Missing required context field: {field}") + + # Get user tier + user_tier = _get_user_tier(user_id) + + # Initialize generator + generator = NarrativeGenerator() + + try: + response = generator.generate_npc_dialogue( + character=context['character'], + npc=context['npc'], + conversation_topic=context['conversation_topic'], + game_state=context['game_state'], + user_tier=user_tier, + npc_relationship=context.get('npc_relationship'), + previous_dialogue=context.get('previous_dialogue'), + npc_knowledge=context.get('npc_knowledge') + ) + + # Get NPC info for result + npc_name = context['npc'].get('name', 'NPC') + npc_id = context.get('npc_full', {}).get('npc_id') or context['npc'].get('npc_id') + character_name = context['character'].get('name', 'You') + + # Get previous dialogue for display (before adding new exchange) + previous_dialogue = context.get('previous_dialogue', []) + + result = { + "dialogue": response.narrative, + "tokens_used": response.tokens_used, + "model": response.model, + "context_type": response.context_type, + "generation_time": response.generation_time, + "npc_name": npc_name, + "npc_id": npc_id, + "character_name": character_name, + "player_line": context['conversation_topic'], + "conversation_history": previous_dialogue, # History before this exchange + } + + # Save dialogue exchange to character's conversation history + if character_id: + try: + if npc_id: + character_service = get_character_service() + character_service.add_npc_dialogue_exchange( + character_id=character_id, + user_id=user_id, + npc_id=npc_id, + player_line=context['conversation_topic'], + npc_response=response.narrative + ) + logger.debug( + "NPC dialogue exchange saved", + character_id=character_id, + npc_id=npc_id + ) + except Exception as e: + # Don't fail the task if history save fails + logger.warning( + "Failed to save NPC dialogue exchange", + character_id=character_id, + error=str(e) + ) + + # Log AI usage + _log_ai_usage( + user_id=user_id, + model=response.model, + tokens_input=response.tokens_input, + tokens_output=response.tokens_output, + task_type=UsageTaskType.NPC_DIALOGUE, + session_id=session_id, + character_id=character_id, + request_duration_ms=int(response.generation_time * 1000), + success=True + ) + + logger.info( + "NPC dialogue completed", + user_id=user_id, + npc_name=context['npc'].get('name'), + tokens_used=response.tokens_used + ) + + return result + + except NarrativeGeneratorError as e: + logger.error("NPC dialogue failed", user_id=user_id, error=str(e)) + raise + + +def _get_user_tier(user_id: str) -> UserTier: + """ + Get the user's subscription tier. + + Args: + user_id: User ID to look up + + Returns: + UserTier enum value + """ + try: + from app.services.appwrite_service import AppwriteService + appwrite = AppwriteService() + tier_string = appwrite.get_user_tier(user_id) + + # Convert string to UserTier enum + tier_map = { + 'free': UserTier.FREE, + 'basic': UserTier.BASIC, + 'premium': UserTier.PREMIUM, + 'elite': UserTier.ELITE, + } + + return tier_map.get(tier_string.lower(), UserTier.FREE) + + except Exception as e: + logger.warning( + "Failed to get user tier, defaulting to FREE", + user_id=user_id, + error=str(e) + ) + return UserTier.FREE + + +def _process_item_grants( + game_changes: GameStateChanges, + character_id: Optional[str], + user_id: str +) -> tuple[list, list[str]]: + """ + Process item grants from AI response, validating and resolving each item. + + Args: + game_changes: GameStateChanges with items_given list + character_id: Character ID for database updates and validation + user_id: User ID for logging + + Returns: + Tuple of (list of valid Item objects, list of error messages for failed items) + """ + from app.models.character import Character + from app.models.items import Item + from app.utils.database import get_database + + validator = get_item_validator() + items_added: list[Item] = [] + items_failed: list[str] = [] + + # Fetch character from DB for validation (ensures we have current state) + if not character_id: + logger.warning( + "No character_id provided for item validation", + user_id=user_id + ) + return [], ["No character ID for item validation"] + + try: + db = get_database() + char_doc = db.get_row('characters', character_id) + if not char_doc: + return [], [f"Character not found: {character_id}"] + + char_json = char_doc.data.get('characterData', '{}') + import json + char_data = json.loads(char_json) + character = Character.from_dict(char_data) + except Exception as e: + logger.error( + "Failed to fetch character for item validation", + error=str(e), + character_id=character_id + ) + return [], [f"Character fetch failed: {str(e)}"] + + for item_grant in game_changes.items_given: + item, error = validator.validate_and_resolve_item(item_grant, character) + + if item: + items_added.append(item) + logger.info( + "Item validated for grant", + item_name=item.name, + item_id=item.item_id, + character_id=character_id + ) + else: + items_failed.append(error or "Unknown validation error") + logger.warning( + "Item grant failed validation", + item_name=item_grant.name or item_grant.item_id, + error=error, + character_id=character_id, + user_id=user_id + ) + + return items_added, items_failed + + +def _update_game_session( + session_id: str, + action: str, + dm_response: str, + character_id: Optional[str] = None, + game_changes: Optional[GameStateChanges] = None, + items_added: Optional[list] = None +) -> None: + """ + Update the game session with a new conversation entry. + + This function updates the GameSession in Appwrite, applies game state + changes (items, gold) to the character, and triggers Realtime notifications + for connected clients. + + Args: + session_id: Game session ID + action: Player's action text + dm_response: DM's narrative response + character_id: Optional character ID + game_changes: Optional game state changes from AI response + items_added: Optional list of validated Item objects to add to character + """ + try: + from app.services.database_service import DatabaseService + from datetime import datetime, timezone + import json + + db = DatabaseService() + + # Get current session document + session_doc = db.get_row('game_sessions', session_id) + if not session_doc: + logger.warning("Session not found for update", session_id=session_id) + return + + # Parse the sessionData JSON string + session_json = session_doc.data.get('sessionData', '{}') + session_data = json.loads(session_json) + + # Increment turn number + turn_number = session_data.get('turn_number', 0) + 1 + + # Get or initialize conversation history + conversation_history = session_data.get('conversation_history', []) + + # Add new entry + new_entry = { + 'turn': turn_number, + 'character_id': character_id, + 'action': action, + 'dm_response': dm_response, + 'timestamp': datetime.now(timezone.utc).isoformat() + } + conversation_history.append(new_entry) + + # Update session data + session_data['turn_number'] = turn_number + session_data['conversation_history'] = conversation_history + session_data['last_activity'] = datetime.now(timezone.utc).isoformat() + + # Serialize back to JSON and update only the sessionData field + updated_doc = { + 'sessionData': json.dumps(session_data) + } + db.update_row('game_sessions', session_id, updated_doc) + + logger.info( + "Game session updated", + session_id=session_id, + turn_number=turn_number + ) + + # Apply game state changes to character if we have a character_id + if character_id and (game_changes or items_added): + _apply_character_changes( + character_id=character_id, + game_changes=game_changes, + items_added=items_added or [] + ) + + # Note: Appwrite Realtime will automatically notify subscribed clients + # when the document is updated. No additional trigger needed. + + except Exception as e: + logger.error( + "Failed to update game session", + session_id=session_id, + error=str(e), + exc_info=True + ) + # Don't raise - the AI generation succeeded, we don't want to fail the job + + +def _apply_character_changes( + character_id: str, + game_changes: Optional[GameStateChanges], + items_added: list +) -> None: + """ + Apply game state changes to a character in the database. + + This function updates the character's inventory and gold based on + the AI's game actions. + + Args: + character_id: Character ID to update + game_changes: Game state changes (for gold, experience) + items_added: List of validated Item objects to add + """ + try: + from app.services.database_service import DatabaseService + from app.models.character import Character + import json + + db = DatabaseService() + + # Get current character document + char_doc = db.get_row('characters', character_id) + if not char_doc: + logger.warning( + "Character not found for game state update", + character_id=character_id + ) + return + + # Parse character data from the nested JSON structure + # Database stores: {userId, characterData (JSON string), is_active} + char_json = char_doc.data.get('characterData', '{}') + char_data = json.loads(char_json) + character = Character.from_dict(char_data) + + changes_made = False + + # Add items to inventory + if items_added: + for item in items_added: + character.add_item(item) + logger.info( + "Added item to character inventory", + item_name=item.name, + item_id=item.item_id, + character_id=character_id + ) + changes_made = True + + # Apply gold changes + if game_changes: + if game_changes.gold_given > 0: + character.add_gold(game_changes.gold_given) + logger.info( + "Added gold to character", + gold_added=game_changes.gold_given, + character_id=character_id + ) + changes_made = True + + if game_changes.gold_taken > 0: + if character.remove_gold(game_changes.gold_taken): + logger.info( + "Removed gold from character", + gold_removed=game_changes.gold_taken, + character_id=character_id + ) + else: + logger.warning( + "Character has insufficient gold", + gold_required=game_changes.gold_taken, + gold_available=character.gold, + character_id=character_id + ) + changes_made = True + + # Apply experience + if game_changes.experience_given > 0: + leveled_up = character.add_experience(game_changes.experience_given) + logger.info( + "Added experience to character", + experience_added=game_changes.experience_given, + leveled_up=leveled_up, + character_id=character_id + ) + changes_made = True + + # Save changes if any were made + if changes_made: + # Serialize back to the nested structure + updated_char_data = character.to_dict() + updated_doc = { + 'characterData': json.dumps(updated_char_data) + } + db.update_row('characters', character_id, updated_doc) + logger.info( + "Character updated with game state changes", + character_id=character_id, + items_added=len(items_added), + gold_change=( + (game_changes.gold_given - game_changes.gold_taken) + if game_changes else 0 + ) + ) + + except Exception as e: + logger.error( + "Failed to apply character changes", + character_id=character_id, + error=str(e), + exc_info=True + ) + # Don't raise - session update succeeded, character update is secondary + + +def get_job_status(job_id: str) -> dict: + """ + Get the current status of a job. + + Args: + job_id: The job ID to check + + Returns: + Dictionary with job status information + """ + redis = RedisService() + + # Try to get from our status cache first + status_key = f"{JOB_STATUS_PREFIX}{job_id}" + cached_status = redis.get_json(status_key) + + if cached_status: + return cached_status + + # Fall back to RQ job status + conn = get_redis_connection() + try: + job = Job.fetch(job_id, connection=conn) + + status = JobStatus.QUEUED + if job.is_finished: + status = JobStatus.COMPLETED + elif job.is_failed: + status = JobStatus.FAILED + elif job.is_started: + status = JobStatus.PROCESSING + + return { + "job_id": job_id, + "status": status.value, + "created_at": job.created_at.isoformat() if job.created_at else None, + "started_at": job.started_at.isoformat() if job.started_at else None, + "ended_at": job.ended_at.isoformat() if job.ended_at else None, + } + + except Exception as e: + logger.warning("Failed to fetch job status", job_id=job_id, error=str(e)) + return { + "job_id": job_id, + "status": "unknown", + "error": "Job not found", + } + + +def get_job_result(job_id: str) -> Optional[dict]: + """ + Get the result of a completed job. + + Args: + job_id: The job ID + + Returns: + Result dictionary if available, None otherwise + """ + redis = RedisService() + + # Try to get from our result cache + result_key = f"{JOB_RESULT_PREFIX}{job_id}" + cached_result = redis.get_json(result_key) + + if cached_result: + return cached_result + + # Fall back to RQ job result + conn = get_redis_connection() + try: + job = Job.fetch(job_id, connection=conn) + if job.is_finished and job.result: + return job.result + except Exception as e: + logger.warning("Failed to fetch job result", job_id=job_id, error=str(e)) + + return None + + +def _store_job_status( + job_id: str, + status: JobStatus, + task_type: str = "", + user_id: str = "", + result: Optional[dict] = None, + error: Optional[str] = None, +) -> None: + """Store job status in Redis.""" + redis = RedisService() + status_key = f"{JOB_STATUS_PREFIX}{job_id}" + + status_data = { + "job_id": job_id, + "status": status.value, + "task_type": task_type, + "user_id": user_id, + "created_at": datetime.now(timezone.utc).isoformat(), + "started_at": None, + "completed_at": None, + "result": result, + "error": error, + } + + redis.set_json(status_key, status_data, ttl=JOB_RESULT_TTL) + + +def _update_job_status( + job_id: str, + status: JobStatus, + result: Optional[dict] = None, + error: Optional[str] = None, +) -> None: + """Update existing job status in Redis.""" + redis = RedisService() + status_key = f"{JOB_STATUS_PREFIX}{job_id}" + + # Get existing status + existing = redis.get_json(status_key) or {} + + # Update fields + existing["status"] = status.value + + now = datetime.now(timezone.utc).isoformat() + + if status == JobStatus.PROCESSING: + existing["started_at"] = now + elif status in (JobStatus.COMPLETED, JobStatus.FAILED): + existing["completed_at"] = now + + if result: + existing["result"] = result + if error: + existing["error"] = error + + redis.set_json(status_key, existing, ttl=JOB_RESULT_TTL) + + +def _store_job_result(job_id: str, result: dict) -> None: + """Store job result in Redis.""" + redis = RedisService() + result_key = f"{JOB_RESULT_PREFIX}{job_id}" + redis.set_json(result_key, result, ttl=JOB_RESULT_TTL) + + +def _log_ai_usage( + user_id: str, + model: str, + tokens_input: int, + tokens_output: int, + task_type: UsageTaskType, + session_id: Optional[str] = None, + character_id: Optional[str] = None, + request_duration_ms: int = 0, + success: bool = True, + error_message: Optional[str] = None +) -> None: + """ + Log AI usage to the usage tracking service. + + This function wraps the UsageTrackingService to safely log usage + without failing the job if logging fails. + + Args: + user_id: User who made the request + model: Model identifier used + tokens_input: Number of input tokens (prompt) + tokens_output: Number of output tokens (response) + task_type: Type of AI task + session_id: Optional game session ID + character_id: Optional character ID + request_duration_ms: Request duration in milliseconds + success: Whether the request succeeded + error_message: Error message if failed + """ + try: + tracker = UsageTrackingService() + + tracker.log_usage( + user_id=user_id, + model=model, + tokens_input=tokens_input, + tokens_output=tokens_output, + task_type=task_type, + session_id=session_id, + character_id=character_id, + request_duration_ms=request_duration_ms, + success=success, + error_message=error_message + ) + + logger.debug( + "AI usage logged successfully", + user_id=user_id, + model=model, + tokens_input=tokens_input, + tokens_output=tokens_output, + task_type=task_type.value + ) + + except Exception as e: + # Log the error but don't fail the job + logger.error( + "Failed to log AI usage (non-fatal)", + user_id=user_id, + error=str(e) + ) + # Don't raise - usage logging failure shouldn't fail the AI job diff --git a/api/app/utils/__init__.py b/api/app/utils/__init__.py new file mode 100644 index 0000000..b21cbc2 --- /dev/null +++ b/api/app/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for Code of Conquest.""" diff --git a/api/app/utils/auth.py b/api/app/utils/auth.py new file mode 100644 index 0000000..5c8ac3a --- /dev/null +++ b/api/app/utils/auth.py @@ -0,0 +1,444 @@ +""" +Authentication Utilities + +This module provides authentication middleware, decorators, and helper functions +for protecting routes and managing user sessions. + +Usage: + from app.utils.auth import require_auth, require_tier, get_current_user + + @app.route('/protected') + @require_auth + def protected_route(): + user = get_current_user() + return f"Hello, {user.name}!" + + @app.route('/premium-feature') + @require_auth + @require_tier('premium') + def premium_feature(): + return "Premium content" +""" + +from functools import wraps +from typing import Optional, Callable +from flask import request, g, jsonify, redirect, url_for + +from app.services.appwrite_service import AppwriteService, UserData +from app.utils.response import unauthorized_response, forbidden_response +from app.utils.logging import get_logger +from app.config import get_config +from appwrite.exception import AppwriteException + + +# Initialize logger +logger = get_logger(__file__) + + +def extract_session_token() -> Optional[str]: + """ + Extract the session token from the request cookie. + + Returns: + Session token string if found, None otherwise + """ + config = get_config() + cookie_name = config.auth.cookie_name + + token = request.cookies.get(cookie_name) + return token + + +def verify_session(token: str) -> Optional[UserData]: + """ + Verify a session token and return the associated user data. + + This function: + 1. Validates the session token with Appwrite + 2. Checks if the session is still active (not expired) + 3. Retrieves and returns the user data + + Args: + token: Session token from cookie + + Returns: + UserData object if session is valid, None otherwise + """ + try: + appwrite = AppwriteService() + + # Validate session + session_data = appwrite.get_session(session_id=token) + + # Get user data + user_data = appwrite.get_user(user_id=session_data.user_id) + return user_data + + except AppwriteException as e: + logger.warning("Session verification failed", error=str(e), code=e.code) + return None + except Exception as e: + logger.error("Unexpected error during session verification", error=str(e)) + return None + + +def get_current_user() -> Optional[UserData]: + """ + Get the current authenticated user from the request context. + + This function retrieves the user object that was attached to the + request context by the @require_auth decorator. + + Returns: + UserData object if user is authenticated, None otherwise + + Usage: + @app.route('/profile') + @require_auth + def profile(): + user = get_current_user() + return f"Welcome, {user.name}!" + """ + return getattr(g, 'current_user', None) + + +def require_auth(f: Callable) -> Callable: + """ + Decorator to require authentication for a route (API endpoints). + + This decorator: + 1. Extracts the session token from the cookie + 2. Verifies the session with Appwrite + 3. Attaches the user object to the request context (g.current_user) + 4. Allows the request to proceed if authenticated + 5. Returns 401 Unauthorized JSON if not authenticated + + For web views, use @require_auth_web instead. + + Args: + f: The Flask route function to wrap + + Returns: + Wrapped function with authentication check + + Usage: + @app.route('/api/protected') + @require_auth + def protected_route(): + user = get_current_user() + return f"Hello, {user.name}!" + """ + @wraps(f) + def decorated_function(*args, **kwargs): + # Extract session token from cookie + token = extract_session_token() + + if not token: + logger.warning("Authentication required but no session token provided", path=request.path) + return unauthorized_response(message="Authentication required. Please log in.") + + # Verify session and get user + user = verify_session(token) + + if not user: + logger.warning("Invalid or expired session token", path=request.path) + return unauthorized_response(message="Session invalid or expired. Please log in again.") + + # Attach user to request context + g.current_user = user + + logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path) + + # Call the original function + return f(*args, **kwargs) + + return decorated_function + + +def require_auth_web(f: Callable) -> Callable: + """ + Decorator to require authentication for a web view route. + + This decorator: + 1. Extracts the session token from the cookie + 2. Verifies the session with Appwrite + 3. Attaches the user object to the request context (g.current_user) + 4. Allows the request to proceed if authenticated + 5. Redirects to login page if not authenticated + + For API endpoints, use @require_auth instead. + + Args: + f: The Flask route function to wrap + + Returns: + Wrapped function with authentication check + + Usage: + @app.route('/dashboard') + @require_auth_web + def dashboard(): + user = get_current_user() + return render_template('dashboard.html', user=user) + """ + @wraps(f) + def decorated_function(*args, **kwargs): + # Extract session token from cookie + token = extract_session_token() + + if not token: + logger.warning("Authentication required but no session token provided", path=request.path) + return redirect(url_for('auth_views.login')) + + # Verify session and get user + user = verify_session(token) + + if not user: + logger.warning("Invalid or expired session token", path=request.path) + return redirect(url_for('auth_views.login')) + + # Attach user to request context + g.current_user = user + + logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path) + + # Call the original function + return f(*args, **kwargs) + + return decorated_function + + +def require_tier(minimum_tier: str) -> Callable: + """ + Decorator to require a minimum subscription tier for a route. + + This decorator must be used AFTER @require_auth. + + Tier hierarchy (from lowest to highest): + - free + - basic + - premium + - elite + + Args: + minimum_tier: Minimum required tier (free, basic, premium, elite) + + Returns: + Decorator function + + Raises: + ValueError: If minimum_tier is invalid + + Usage: + @app.route('/premium-feature') + @require_auth + @require_tier('premium') + def premium_feature(): + return "Premium content" + """ + # Define tier hierarchy + tier_hierarchy = { + 'free': 0, + 'basic': 1, + 'premium': 2, + 'elite': 3 + } + + if minimum_tier not in tier_hierarchy: + raise ValueError(f"Invalid tier: {minimum_tier}. Must be one of {list(tier_hierarchy.keys())}") + + def decorator(f: Callable) -> Callable: + @wraps(f) + def decorated_function(*args, **kwargs): + # Get current user (set by @require_auth) + user = get_current_user() + + if not user: + logger.error("require_tier used without require_auth", path=request.path) + return unauthorized_response(message="Authentication required.") + + # Get user's tier level + user_tier = user.tier + user_tier_level = tier_hierarchy.get(user_tier, 0) + required_tier_level = tier_hierarchy[minimum_tier] + + # Check if user has sufficient tier + if user_tier_level < required_tier_level: + logger.warning( + "Access denied - insufficient tier", + user_id=user.id, + user_tier=user_tier, + required_tier=minimum_tier, + path=request.path + ) + return forbidden_response( + message=f"This feature requires {minimum_tier.capitalize()} tier or higher. " + f"Your current tier: {user_tier.capitalize()}." + ) + + logger.debug( + "Tier requirement met", + user_id=user.id, + user_tier=user_tier, + required_tier=minimum_tier, + path=request.path + ) + + # Call the original function + return f(*args, **kwargs) + + return decorated_function + + return decorator + + +def require_email_verified(f: Callable) -> Callable: + """ + Decorator to require email verification for a route. + + This decorator must be used AFTER @require_auth. + + Args: + f: The Flask route function to wrap + + Returns: + Wrapped function with email verification check + + Usage: + @app.route('/verified-only') + @require_auth + @require_email_verified + def verified_only(): + return "You can only see this if your email is verified" + """ + @wraps(f) + def decorated_function(*args, **kwargs): + # Get current user (set by @require_auth) + user = get_current_user() + + if not user: + logger.error("require_email_verified used without require_auth", path=request.path) + return unauthorized_response(message="Authentication required.") + + # Check if email is verified + if not user.email_verified: + logger.warning( + "Access denied - email not verified", + user_id=user.id, + email=user.email, + path=request.path + ) + return forbidden_response( + message="Email verification required. Please check your inbox and verify your email address." + ) + + logger.debug("Email verification confirmed", user_id=user.id, email=user.email, path=request.path) + + # Call the original function + return f(*args, **kwargs) + + return decorated_function + + +def optional_auth(f: Callable) -> Callable: + """ + Decorator for routes that optionally use authentication. + + This decorator will attach the user to g.current_user if authenticated, + but will NOT block the request if not authenticated. Use this for routes + that should behave differently based on authentication status. + + Args: + f: The Flask route function to wrap + + Returns: + Wrapped function with optional authentication + + Usage: + @app.route('/landing') + @optional_auth + def landing(): + user = get_current_user() + if user: + return f"Welcome back, {user.name}!" + else: + return "Welcome! Please log in." + """ + @wraps(f) + def decorated_function(*args, **kwargs): + # Extract session token from cookie + token = extract_session_token() + + if token: + # Verify session and get user + user = verify_session(token) + + if user: + # Attach user to request context + g.current_user = user + logger.debug("Optional auth - user authenticated", user_id=user.id, path=request.path) + else: + logger.debug("Optional auth - invalid session", path=request.path) + else: + logger.debug("Optional auth - no session token", path=request.path) + + # Call the original function regardless of authentication + return f(*args, **kwargs) + + return decorated_function + + +def get_user_tier() -> str: + """ + Get the current user's tier. + + Returns: + Tier string (free, basic, premium, elite), defaults to 'free' if not authenticated + + Usage: + @app.route('/dashboard') + @require_auth + def dashboard(): + tier = get_user_tier() + return f"Your tier: {tier}" + """ + user = get_current_user() + if user: + return user.tier + return 'free' + + +def is_tier_sufficient(required_tier: str) -> bool: + """ + Check if the current user's tier meets the requirement. + + Args: + required_tier: Required tier level + + Returns: + True if user's tier is sufficient, False otherwise + + Usage: + @app.route('/feature') + @require_auth + def feature(): + if is_tier_sufficient('premium'): + return "Premium features enabled" + else: + return "Upgrade to premium for more features" + """ + tier_hierarchy = { + 'free': 0, + 'basic': 1, + 'premium': 2, + 'elite': 3 + } + + user = get_current_user() + if not user: + return False + + user_tier_level = tier_hierarchy.get(user.tier, 0) + required_tier_level = tier_hierarchy.get(required_tier, 0) + + return user_tier_level >= required_tier_level diff --git a/api/app/utils/logging.py b/api/app/utils/logging.py new file mode 100644 index 0000000..6cb9743 --- /dev/null +++ b/api/app/utils/logging.py @@ -0,0 +1,272 @@ +""" +Logging configuration for Code of Conquest. + +Sets up structured logging using structlog with JSON output +and context-aware logging throughout the application. +""" + +import logging +import sys +from pathlib import Path +from typing import Optional + +import structlog +from structlog.stdlib import LoggerFactory + + +def setup_logging( + log_level: str = "INFO", + log_format: str = "json", + log_file: Optional[str] = None +) -> None: + """ + Configure structured logging for the application. + + Args: + log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_format: Output format ('json' or 'console') + log_file: Optional path to log file + + Example: + >>> from app.utils.logging import setup_logging + >>> setup_logging(log_level="DEBUG", log_format="json") + >>> logger = structlog.get_logger(__name__) + >>> logger.info("Application started", version="0.1.0") + """ + # Convert log level string to logging constant + numeric_level = getattr(logging, log_level.upper(), logging.INFO) + + # Configure standard library logging + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=numeric_level, + ) + + # Create logs directory if logging to file + if log_file: + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + # Add file handler + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(numeric_level) + logging.root.addHandler(file_handler) + + # Configure structlog processors + processors = [ + # Add log level to event dict + structlog.stdlib.add_log_level, + # Add logger name to event dict + structlog.stdlib.add_logger_name, + # Add timestamp + structlog.processors.TimeStamper(fmt="iso"), + # Add stack info for exceptions + structlog.processors.StackInfoRenderer(), + # Format exceptions + structlog.processors.format_exc_info, + # Clean up _record and _from_structlog keys + structlog.processors.UnicodeDecoder(), + ] + + # Add format-specific processor + if log_format == "json": + # JSON output for production + processors.append(structlog.processors.JSONRenderer()) + else: + # Console-friendly output for development + processors.append( + structlog.dev.ConsoleRenderer( + colors=True, + exception_formatter=structlog.dev.plain_traceback + ) + ) + + # Configure structlog + structlog.configure( + processors=processors, + context_class=dict, + logger_factory=LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + +def get_logger(name: str) -> structlog.stdlib.BoundLogger: + """ + Get a configured logger instance. + + Args: + name: Logger name (typically __name__) + + Returns: + BoundLogger: Configured structlog logger + + Example: + >>> logger = get_logger(__name__) + >>> logger.info("User logged in", user_id="123", email="user@example.com") + """ + return structlog.get_logger(name) + + +class LoggerMixin: + """ + Mixin class to add logging capabilities to any class. + + Provides a `self.logger` attribute with context automatically + bound to the class name. + + Example: + >>> class MyService(LoggerMixin): + ... def do_something(self, user_id): + ... self.logger.info("Doing something", user_id=user_id) + """ + + @property + def logger(self) -> structlog.stdlib.BoundLogger: + """Get logger for this class.""" + if not hasattr(self, '_logger'): + self._logger = get_logger(self.__class__.__name__) + return self._logger + + +# Common logging utilities + +def log_function_call(logger: structlog.stdlib.BoundLogger): + """ + Decorator to log function calls with arguments and return values. + + Args: + logger: Logger instance to use + + Example: + >>> logger = get_logger(__name__) + >>> @log_function_call(logger) + ... def process_data(data_id): + ... return {"status": "processed"} + """ + def decorator(func): + def wrapper(*args, **kwargs): + logger.debug( + f"Calling {func.__name__}", + function=func.__name__, + args=args, + kwargs=kwargs + ) + try: + result = func(*args, **kwargs) + logger.debug( + f"{func.__name__} completed", + function=func.__name__, + result=result + ) + return result + except Exception as e: + logger.error( + f"{func.__name__} failed", + function=func.__name__, + error=str(e), + exc_info=True + ) + raise + return wrapper + return decorator + + +def log_ai_call( + logger: structlog.stdlib.BoundLogger, + user_id: str, + model: str, + tier: str, + tokens_used: int, + cost_estimate: float, + context_type: str +) -> None: + """ + Log AI API call for cost tracking and analytics. + + Args: + logger: Logger instance + user_id: User making the request + model: AI model used + tier: Model tier (free, standard, premium) + tokens_used: Number of tokens consumed + cost_estimate: Estimated cost in USD + context_type: Type of context (narrative, combat, etc.) + """ + logger.info( + "AI call completed", + event_type="ai_call", + user_id=user_id, + model=model, + tier=tier, + tokens_used=tokens_used, + cost_estimate=cost_estimate, + context_type=context_type + ) + + +def log_combat_action( + logger: structlog.stdlib.BoundLogger, + session_id: str, + character_id: str, + action_type: str, + target_id: Optional[str] = None, + damage: Optional[int] = None, + effects: Optional[list] = None +) -> None: + """ + Log combat action for analytics and debugging. + + Args: + logger: Logger instance + session_id: Game session ID + character_id: Acting character ID + action_type: Type of action (attack, cast, item, defend) + target_id: Target of action (if applicable) + damage: Damage dealt (if applicable) + effects: Effects applied (if applicable) + """ + logger.info( + "Combat action executed", + event_type="combat_action", + session_id=session_id, + character_id=character_id, + action_type=action_type, + target_id=target_id, + damage=damage, + effects=effects + ) + + +def log_marketplace_transaction( + logger: structlog.stdlib.BoundLogger, + transaction_id: str, + buyer_id: str, + seller_id: str, + item_id: str, + price: int, + transaction_type: str +) -> None: + """ + Log marketplace transaction for analytics and auditing. + + Args: + logger: Logger instance + transaction_id: Transaction ID + buyer_id: Buyer user ID + seller_id: Seller user ID + item_id: Item ID + price: Transaction price + transaction_type: Type of transaction + """ + logger.info( + "Marketplace transaction", + event_type="marketplace_transaction", + transaction_id=transaction_id, + buyer_id=buyer_id, + seller_id=seller_id, + item_id=item_id, + price=price, + transaction_type=transaction_type + ) diff --git a/api/app/utils/response.py b/api/app/utils/response.py new file mode 100644 index 0000000..fb7c4ee --- /dev/null +++ b/api/app/utils/response.py @@ -0,0 +1,337 @@ +""" +API response wrapper for Code of Conquest. + +Provides standardized JSON response format for all API endpoints. +""" + +from datetime import datetime +from typing import Any, Dict, Optional +from flask import jsonify, Response +from app.config import get_config + + +def api_response( + result: Any = None, + status: int = 200, + error: Optional[Dict[str, Any]] = None, + meta: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None +) -> Response: + """ + Create a standardized API response. + + Args: + result: The response data (or None if error) + status: HTTP status code + error: Error information (dict with 'code', 'message', 'details') + meta: Metadata (pagination, etc.) + request_id: Optional request ID for tracking + + Returns: + Response: Flask JSON response + + Example: + >>> return api_response( + ... result={"user_id": "123"}, + ... status=200 + ... ) + + >>> return api_response( + ... error={ + ... "code": "INVALID_INPUT", + ... "message": "Email is required", + ... "details": {"field": "email"} + ... }, + ... status=400 + ... ) + """ + config = get_config() + + response_data = { + "app": config.app.name, + "version": config.app.version, + "status": status, + "timestamp": datetime.utcnow().isoformat() + "Z", + "request_id": request_id, + "result": result, + "error": error, + "meta": meta + } + + return jsonify(response_data), status + + +def success_response( + result: Any = None, + status: int = 200, + meta: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None +) -> Response: + """ + Create a success response. + + Args: + result: The response data + status: HTTP status code (default 200) + meta: Optional metadata + request_id: Optional request ID + + Returns: + Response: Flask JSON response + + Example: + >>> return success_response({"character_id": "123"}) + """ + return api_response( + result=result, + status=status, + meta=meta, + request_id=request_id + ) + + +def error_response( + code: str, + message: str, + status: int = 400, + details: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None +) -> Response: + """ + Create an error response. + + Args: + code: Error code (e.g., "INVALID_INPUT") + message: Human-readable error message + status: HTTP status code (default 400) + details: Optional additional error details + request_id: Optional request ID + + Returns: + Response: Flask JSON response + + Example: + >>> return error_response( + ... code="NOT_FOUND", + ... message="Character not found", + ... status=404 + ... ) + """ + error = { + "code": code, + "message": message, + "details": details or {} + } + + return api_response( + error=error, + status=status, + request_id=request_id + ) + + +def created_response( + result: Any = None, + request_id: Optional[str] = None +) -> Response: + """ + Create a 201 Created response. + + Args: + result: The created resource data + request_id: Optional request ID + + Returns: + Response: Flask JSON response with status 201 + + Example: + >>> return created_response({"character_id": "123"}) + """ + return success_response( + result=result, + status=201, + request_id=request_id + ) + + +def accepted_response( + result: Any = None, + request_id: Optional[str] = None +) -> Response: + """ + Create a 202 Accepted response (for async operations). + + Args: + result: Job information or status + request_id: Optional request ID + + Returns: + Response: Flask JSON response with status 202 + + Example: + >>> return accepted_response({"job_id": "abc123"}) + """ + return success_response( + result=result, + status=202, + request_id=request_id + ) + + +def no_content_response(request_id: Optional[str] = None) -> Response: + """ + Create a 204 No Content response. + + Args: + request_id: Optional request ID + + Returns: + Response: Flask JSON response with status 204 + + Example: + >>> return no_content_response() + """ + return success_response( + result=None, + status=204, + request_id=request_id + ) + + +def paginated_response( + items: list, + page: int, + limit: int, + total: int, + request_id: Optional[str] = None +) -> Response: + """ + Create a paginated response. + + Args: + items: List of items for current page + page: Current page number + limit: Items per page + total: Total number of items + request_id: Optional request ID + + Returns: + Response: Flask JSON response with pagination metadata + + Example: + >>> return paginated_response( + ... items=[{"id": "1"}, {"id": "2"}], + ... page=1, + ... limit=20, + ... total=100 + ... ) + """ + pages = (total + limit - 1) // limit # Ceiling division + + meta = { + "page": page, + "limit": limit, + "total": total, + "pages": pages + } + + return success_response( + result=items, + meta=meta, + request_id=request_id + ) + + +# Common error responses + +def unauthorized_response( + message: str = "Unauthorized", + request_id: Optional[str] = None +) -> Response: + """401 Unauthorized response.""" + return error_response( + code="UNAUTHORIZED", + message=message, + status=401, + request_id=request_id + ) + + +def forbidden_response( + message: str = "Forbidden", + request_id: Optional[str] = None +) -> Response: + """403 Forbidden response.""" + return error_response( + code="FORBIDDEN", + message=message, + status=403, + request_id=request_id + ) + + +def not_found_response( + message: str = "Resource not found", + request_id: Optional[str] = None +) -> Response: + """404 Not Found response.""" + return error_response( + code="NOT_FOUND", + message=message, + status=404, + request_id=request_id + ) + + +def validation_error_response( + message: str, + details: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None +) -> Response: + """400 Bad Request for validation errors.""" + return error_response( + code="INVALID_INPUT", + message=message, + status=400, + details=details, + request_id=request_id + ) + + +def rate_limit_exceeded_response( + message: str = "Rate limit exceeded", + request_id: Optional[str] = None +) -> Response: + """429 Too Many Requests response.""" + return error_response( + code="RATE_LIMIT_EXCEEDED", + message=message, + status=429, + request_id=request_id + ) + + +def internal_error_response( + message: str = "Internal server error", + request_id: Optional[str] = None +) -> Response: + """500 Internal Server Error response.""" + return error_response( + code="INTERNAL_ERROR", + message=message, + status=500, + request_id=request_id + ) + + +def premium_required_response( + message: str = "This feature requires a premium subscription", + request_id: Optional[str] = None +) -> Response: + """403 Forbidden for premium-only features.""" + return error_response( + code="PREMIUM_REQUIRED", + message=message, + status=403, + request_id=request_id + ) diff --git a/api/config/development.yaml b/api/config/development.yaml new file mode 100644 index 0000000..3847e10 --- /dev/null +++ b/api/config/development.yaml @@ -0,0 +1,127 @@ +# Development Configuration for Code of Conquest + +app: + name: "Code of Conquest" + version: "0.1.0" + environment: "development" + debug: true + +server: + host: "0.0.0.0" + port: 5000 + workers: 1 + +redis: + host: "localhost" + port: 6379 + db: 0 + max_connections: 50 + +rq: + queues: + - "ai_tasks" + - "combat_tasks" + - "marketplace_tasks" + worker_timeout: 600 + job_timeout: 300 + +ai: + timeout: 30 + max_retries: 3 + cost_alert_threshold: 100.00 + + models: + free: + provider: "replicate" + model: "meta/meta-llama-3-70b-instruct" + max_tokens: 256 + temperature: 0.7 + + standard: + provider: "anthropic" + model: "claude-3-5-haiku-20241022" + max_tokens: 512 + temperature: 0.8 + + premium: + provider: "anthropic" + model: "claude-3-5-sonnet-20241022" + max_tokens: 1024 + temperature: 0.9 + +rate_limiting: + enabled: true + storage_url: "redis://localhost:6379/1" + + tiers: + free: + requests_per_minute: 30 + ai_calls_per_day: 50 + custom_actions_per_day: 10 + custom_action_char_limit: 150 + basic: + requests_per_minute: 60 + ai_calls_per_day: 200 + custom_actions_per_day: 50 + custom_action_char_limit: 300 + premium: + requests_per_minute: 120 + ai_calls_per_day: 1000 + custom_actions_per_day: -1 # Unlimited + custom_action_char_limit: 500 + elite: + requests_per_minute: 300 + ai_calls_per_day: -1 # Unlimited + custom_actions_per_day: -1 # Unlimited + custom_action_char_limit: 500 + +session: + timeout_minutes: 30 + auto_save_interval: 5 + min_players: 1 + max_players_by_tier: + free: 1 + basic: 2 + premium: 6 + elite: 10 + +auth: + # Authentication cookie settings + cookie_name: "coc_session" + duration_normal: 86400 # 24 hours (seconds) + duration_remember_me: 2592000 # 30 days (seconds) + http_only: true + secure: false # Set to true in production (HTTPS only) + same_site: "Lax" + path: "/" + + # Password requirements + password_min_length: 8 + password_require_uppercase: true + password_require_lowercase: true + password_require_number: true + password_require_special: true + + # User input validation + name_min_length: 3 + name_max_length: 50 + email_max_length: 255 + +marketplace: + auction_check_interval: 300 # 5 minutes + max_listings_by_tier: + premium: 10 + elite: 25 + +cors: + origins: + - "http://localhost:8000" + - "http://127.0.0.1:8000" + +logging: + level: "DEBUG" + format: "json" + handlers: + - "console" + - "file" + file_path: "logs/app.log" diff --git a/api/config/production.yaml b/api/config/production.yaml new file mode 100644 index 0000000..fe17492 --- /dev/null +++ b/api/config/production.yaml @@ -0,0 +1,126 @@ +# Production Configuration for Code of Conquest + +app: + name: "Code of Conquest" + version: "0.1.0" + environment: "production" + debug: false + +server: + host: "0.0.0.0" + port: 5000 + workers: 4 + +redis: + host: "redis" # Docker service name or production host + port: 6379 + db: 0 + max_connections: 100 + +rq: + queues: + - "ai_tasks" + - "combat_tasks" + - "marketplace_tasks" + worker_timeout: 600 + job_timeout: 300 + +ai: + timeout: 30 + max_retries: 3 + cost_alert_threshold: 500.00 + + models: + free: + provider: "replicate" + model: "meta/meta-llama-3-70b-instruct" + max_tokens: 256 + temperature: 0.7 + + standard: + provider: "anthropic" + model: "claude-3-5-haiku-20241022" + max_tokens: 512 + temperature: 0.8 + + premium: + provider: "anthropic" + model: "claude-3-5-sonnet-20241022" + max_tokens: 1024 + temperature: 0.9 + +rate_limiting: + enabled: true + storage_url: "redis://redis:6379/1" + + tiers: + free: + requests_per_minute: 30 + ai_calls_per_day: 50 + custom_actions_per_day: 10 + custom_action_char_limit: 150 + basic: + requests_per_minute: 60 + ai_calls_per_day: 200 + custom_actions_per_day: 50 + custom_action_char_limit: 300 + premium: + requests_per_minute: 120 + ai_calls_per_day: 1000 + custom_actions_per_day: -1 # Unlimited + custom_action_char_limit: 500 + elite: + requests_per_minute: 300 + ai_calls_per_day: -1 # Unlimited + custom_actions_per_day: -1 # Unlimited + custom_action_char_limit: 500 + +session: + timeout_minutes: 30 + auto_save_interval: 5 + min_players: 1 + max_players_by_tier: + free: 1 + basic: 2 + premium: 6 + elite: 10 + +auth: + # Authentication cookie settings + cookie_name: "coc_session" + duration_normal: 86400 # 24 hours (seconds) + duration_remember_me: 2592000 # 30 days (seconds) + http_only: true + secure: true # HTTPS only in production + same_site: "Lax" + path: "/" + + # Password requirements + password_min_length: 8 + password_require_uppercase: true + password_require_lowercase: true + password_require_number: true + password_require_special: true + + # User input validation + name_min_length: 3 + name_max_length: 50 + email_max_length: 255 + +marketplace: + auction_check_interval: 300 # 5 minutes + max_listings_by_tier: + premium: 10 + elite: 25 + +cors: + origins: + - "https://yourdomain.com" # Replace with actual production domain + +logging: + level: "INFO" + format: "json" + handlers: + - "console" + - "file" + file_path: "/var/log/coc/app.log" diff --git a/api/config/rq_config.py b/api/config/rq_config.py new file mode 100644 index 0000000..99ba0b8 --- /dev/null +++ b/api/config/rq_config.py @@ -0,0 +1,77 @@ +""" +RQ Worker Configuration + +This module provides configuration settings for RQ workers. +Workers can be started with these settings using the start_workers.sh script. + +Usage: + # In worker startup + from config.rq_config import WORKER_CONFIG + + worker = Worker( + queues=WORKER_CONFIG['queues'], + connection=redis_conn, + **WORKER_CONFIG['worker_kwargs'] + ) +""" + +import os + +from app.tasks import ALL_QUEUES, QUEUE_AI_TASKS, QUEUE_COMBAT_TASKS, QUEUE_MARKETPLACE_TASKS + + +# Redis URL for workers +REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0') + + +# Worker configuration +WORKER_CONFIG = { + # Queues to listen on (in priority order) + 'queues': ALL_QUEUES, + + # Worker behavior settings + 'worker_kwargs': { + 'name': None, # Auto-generated if None + 'default_result_ttl': 3600, # 1 hour + 'default_worker_ttl': 420, # 7 minutes + 'job_monitoring_interval': 5, # Check job status every 5 seconds + 'disable_default_exception_handler': False, + 'log_job_description': True, + }, + + # Burst mode (process jobs then exit) + 'burst': False, + + # Logging + 'logging_level': os.getenv('LOG_LEVEL', 'INFO'), +} + + +# Scheduler configuration (for periodic tasks) +SCHEDULER_CONFIG = { + 'interval': 60, # Check for scheduled jobs every 60 seconds +} + + +# Job retry configuration +RETRY_CONFIG = { + 'max_retries': 3, + 'retry_delays': [60, 300, 900], # 1 min, 5 min, 15 min +} + + +# Queue-specific worker settings (for specialized workers) +SPECIALIZED_WORKERS = { + 'ai_worker': { + 'queues': [QUEUE_AI_TASKS], + 'description': 'Dedicated worker for AI generation tasks', + }, + 'combat_worker': { + 'queues': [QUEUE_COMBAT_TASKS], + 'description': 'Dedicated worker for combat processing', + }, + 'marketplace_worker': { + 'queues': [QUEUE_MARKETPLACE_TASKS], + 'description': 'Dedicated worker for marketplace tasks', + }, +} diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 0000000..9aa2917 --- /dev/null +++ b/api/docker-compose.yml @@ -0,0 +1,63 @@ +version: '3.8' + +services: + redis: + image: redis:7-alpine + container_name: coc_redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + api: + build: + context: . + dockerfile: Dockerfile + container_name: coc_api + ports: + - "5000:5000" + volumes: + - .:/app + env_file: + - .env + environment: + - FLASK_ENV=development + - REDIS_URL=redis://redis:6379/0 + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + rq-worker: + build: + context: . + dockerfile: Dockerfile + container_name: coc_rq_worker + command: rq worker ai_tasks combat_tasks marketplace_tasks --url redis://redis:6379/0 --with-scheduler + volumes: + - .:/app + depends_on: + redis: + condition: service_healthy + env_file: + - .env + environment: + - REDIS_URL=redis://redis:6379/0 + restart: unless-stopped + +volumes: + redis_data: + driver: local diff --git a/api/docs/ACTION_PROMPTS.md b/api/docs/ACTION_PROMPTS.md new file mode 100644 index 0000000..6258635 --- /dev/null +++ b/api/docs/ACTION_PROMPTS.md @@ -0,0 +1,563 @@ +# Action Prompts System + +## Overview + +Action prompts are predefined story actions that players can select during gameplay. They appear as buttons in the story UI, with availability filtered by subscription tier and location type. + +**Key Features:** +- YAML-driven configuration +- Tier-based availability (Free, Basic, Premium, Elite) +- Location-based context filtering +- Custom AI prompt templates per action + +**Files:** +- **Model:** `app/models/action_prompt.py` +- **Loader:** `app/services/action_prompt_loader.py` +- **Data:** `app/data/action_prompts.yaml` + +--- + +## Architecture + +``` +┌─────────────────────────┐ +│ action_prompts.yaml │ ← YAML configuration +├─────────────────────────┤ +│ ActionPromptLoader │ ← Singleton loader/cache +├─────────────────────────┤ +│ ActionPrompt │ ← Data model +├─────────────────────────┤ +│ Story UI / API │ ← Filtered action buttons +└─────────────────────────┘ +``` + +--- + +## ActionPrompt Model + +**File:** `app/models/action_prompt.py` + +### Fields + +```python +@dataclass +class ActionPrompt: + prompt_id: str # Unique identifier + category: ActionCategory # Action category + display_text: str # Button label + description: str # Tooltip text + tier_required: UserTier # Minimum tier + context_filter: List[LocationType] # Where available + dm_prompt_template: str # AI prompt template + icon: Optional[str] = None # Optional icon name + cooldown_turns: int = 0 # Turns before reuse +``` + +### ActionCategory Enum + +```python +class ActionCategory(str, Enum): + ASK_QUESTION = "ask_question" # Gather info from NPCs + TRAVEL = "travel" # Move to new location + GATHER_INFO = "gather_info" # Search/investigate + REST = "rest" # Rest and recover + INTERACT = "interact" # Interact with objects + EXPLORE = "explore" # Explore the area + SPECIAL = "special" # Tier-specific special actions +``` + +### LocationType Enum + +```python +class LocationType(str, Enum): + TOWN = "town" # Populated settlements + TAVERN = "tavern" # Taverns and inns + WILDERNESS = "wilderness" # Outdoor areas + DUNGEON = "dungeon" # Dungeons and caves + SAFE_AREA = "safe_area" # Protected zones + LIBRARY = "library" # Libraries and archives + ANY = "any" # Available everywhere +``` + +### Availability Methods + +```python +from app.models.action_prompt import ActionPrompt, LocationType +from app.ai.model_selector import UserTier + +# Check if available +if action.is_available(UserTier.FREE, LocationType.TOWN): + # Show action to player + pass + +# Check if locked (tier too low) +if action.is_locked(UserTier.FREE): + reason = action.get_lock_reason(UserTier.FREE) + # "Requires Premium tier or higher" +``` + +### Serialization + +```python +# To dictionary +data = action.to_dict() + +# From dictionary +action = ActionPrompt.from_dict(data) +``` + +--- + +## ActionPromptLoader Service + +**File:** `app/services/action_prompt_loader.py` + +Singleton service that loads and caches action prompts from YAML. + +### Basic Usage + +```python +from app.services.action_prompt_loader import ActionPromptLoader +from app.models.action_prompt import LocationType +from app.ai.model_selector import UserTier + +loader = ActionPromptLoader() + +# Load from YAML (or auto-loads from default path) +loader.load_from_yaml("app/data/action_prompts.yaml") + +# Get available actions for user at location +actions = loader.get_available_actions( + user_tier=UserTier.FREE, + location_type=LocationType.TOWN +) + +for action in actions: + print(f"{action.display_text} - {action.description}") +``` + +### Query Methods + +```python +# Get specific action +action = loader.get_action_by_id("ask_locals") + +# Get all actions +all_actions = loader.get_all_actions() + +# Get actions by tier (ignoring location) +tier_actions = loader.get_actions_by_tier(UserTier.PREMIUM) + +# Get actions by category +questions = loader.get_actions_by_category("ask_question") + +# Get locked actions (for upgrade prompts) +locked = loader.get_locked_actions(UserTier.FREE, LocationType.TOWN) +``` + +### Utility Methods + +```python +# Check if loaded +if loader.is_loaded(): + count = loader.get_prompt_count() + +# Force reload +loader.reload("app/data/action_prompts.yaml") + +# Reset singleton (for testing) +ActionPromptLoader.reset_instance() +``` + +### Error Handling + +```python +from app.services.action_prompt_loader import ( + ActionPromptLoader, + ActionPromptLoaderError, + ActionPromptNotFoundError +) + +try: + action = loader.get_action_by_id("invalid_id") +except ActionPromptNotFoundError: + # Action not found + pass + +try: + loader.load_from_yaml("invalid_path.yaml") +except ActionPromptLoaderError as e: + # File not found, invalid YAML, etc. + pass +``` + +--- + +## YAML Configuration + +**File:** `app/data/action_prompts.yaml` + +### Structure + +```yaml +action_prompts: + - prompt_id: unique_id + category: ask_question + display_text: Button Label + description: Tooltip description + tier_required: free + context_filter: [town, tavern] + icon: chat + cooldown_turns: 0 + dm_prompt_template: | + Jinja2 template for AI prompt... +``` + +### Available Actions + +#### Free Tier (4 actions) + +| ID | Display Text | Category | Locations | +|----|-------------|----------|-----------| +| `ask_locals` | Ask locals for information | ask_question | town, tavern | +| `explore_area` | Explore the area | explore | wilderness, dungeon | +| `search_supplies` | Search for supplies | gather_info | any | +| `rest_recover` | Rest and recover | rest | town, tavern, safe_area | + +#### Premium Tier (+3 actions) + +| ID | Display Text | Category | Locations | +|----|-------------|----------|-----------| +| `investigate_suspicious` | Investigate suspicious activity | gather_info | any | +| `follow_lead` | Follow a lead | travel | any | +| `make_camp` | Make camp | rest | wilderness | + +#### Elite Tier (+3 actions) + +| ID | Display Text | Category | Locations | +|----|-------------|----------|-----------| +| `consult_texts` | Consult ancient texts | special | library, town | +| `commune_nature` | Commune with nature | special | wilderness | +| `seek_audience` | Seek audience with authorities | special | town | + +### Total by Tier + +- **Free:** 4 actions +- **Premium:** 7 actions (Free + 3) +- **Elite:** 10 actions (Premium + 3) + +--- + +## DM Prompt Templates + +Each action has a Jinja2 template for generating AI prompts. + +### Available Variables + +```jinja2 +{{ character.name }} +{{ character.level }} +{{ character.player_class }} +{{ character.current_hp }} +{{ character.max_hp }} +{{ character.stats.strength }} +{{ character.stats.dexterity }} +{{ character.stats.constitution }} +{{ character.stats.intelligence }} +{{ character.stats.wisdom }} +{{ character.stats.charisma }} +{{ character.reputation }} + +{{ game_state.current_location }} +{{ game_state.location_type }} +{{ game_state.active_quests }} +``` + +### Template Example + +```yaml +dm_prompt_template: | + The player explores the area around {{ game_state.current_location }}. + + Character: {{ character.name }}, Level {{ character.level }} {{ character.player_class }} + Perception modifier: {{ character.stats.wisdom | default(10) }} + + Describe what the player discovers: + - Environmental details and atmosphere + - Points of interest (paths, structures, natural features) + - Any items, tracks, or clues found + - Potential dangers or opportunities + + Based on their Wisdom score, they may notice hidden details. + End with options for where to go or what to investigate next. +``` + +### How Templates Flow to AI + +The `dm_prompt_template` is passed through the system as follows: + +1. **Sessions API** loads the action prompt and extracts `dm_prompt_template` +2. **AI task** receives it in the context as `dm_prompt_template` +3. **NarrativeGenerator** receives it as `action_instructions` parameter +4. **story_action.j2** injects it under "Action-Specific Instructions" + +```python +# In ai_tasks.py +response = generator.generate_story_response( + character=context['character'], + action=context['action'], + game_state=context['game_state'], + user_tier=user_tier, + action_instructions=context.get('dm_prompt_template') # From action prompt +) +``` + +### Player Agency Rules + +All action templates should follow these critical rules to maintain player agency: + +```yaml +# Example with player agency enforcement +dm_prompt_template: | + The player searches for supplies in {{ game_state.current_location }}. + + IMPORTANT - This is a SEARCH action, not a purchase action: + - In towns/markets: Describe vendors and wares with PRICES. Ask what to buy. + - In wilderness: Describe what they FIND. Ask if they want to gather. + + NEVER automatically: + - Purchase items or spend gold + - Add items to inventory without asking + - Complete any transaction + + End with: "What would you like to do?" +``` + +The base `story_action.j2` template also enforces these rules globally: +- Never make decisions for the player +- Never complete transactions without consent +- Present choices and let the player decide + +--- + +## Integration Examples + +### API Endpoint + +```python +@bp.route('/sessions//available-actions', methods=['GET']) +@require_auth +def get_available_actions(session_id): + user = get_current_user() + session = get_session(session_id) + + loader = ActionPromptLoader() + + # Get available actions + available = loader.get_available_actions( + user_tier=user.tier, + location_type=session.game_state.location_type + ) + + # Get locked actions for upgrade prompts + locked = loader.get_locked_actions( + user_tier=user.tier, + location_type=session.game_state.location_type + ) + + return api_response( + status=200, + result={ + "available_actions": [a.to_dict() for a in available], + "locked_actions": [ + { + **a.to_dict(), + "lock_reason": a.get_lock_reason(user.tier) + } + for a in locked + ] + } + ) +``` + +### Story UI Template + +```jinja2 +{% for action in available_actions %} + +{% endfor %} + +{% for action in locked_actions %} + +{% endfor %} +``` + +### Processing Action in AI Task + +```python +from app.services.action_prompt_loader import ActionPromptLoader +from app.ai.prompt_templates import render_prompt + +def process_button_action(session_id: str, prompt_id: str, user_tier: UserTier): + loader = ActionPromptLoader() + session = get_session(session_id) + character = get_character(session.character_id) + + # Get the action + action = loader.get_action_by_id(prompt_id) + + # Verify availability + if not action.is_available(user_tier, session.game_state.location_type): + raise ValueError("Action not available") + + # Build the AI prompt using action's template + prompt = render_prompt( + action.dm_prompt_template, + character=character.to_dict(), + game_state=session.game_state.to_dict() + ) + + # Generate AI response + response = narrative_generator.generate_story_response(...) + + return response +``` + +--- + +## Adding New Actions + +### 1. Add to YAML + +```yaml +- prompt_id: new_action_id + category: explore # Must match ActionCategory enum + display_text: My New Action + description: What this action does + tier_required: premium # free, basic, premium, elite + context_filter: [town, wilderness] # Or [any] for all + icon: star # Optional icon name + cooldown_turns: 2 # 0 for no cooldown + dm_prompt_template: | + The player {{ action description }}... + + Character: {{ character.name }}, Level {{ character.level }} + + Describe: + - What happens + - What they discover + - Next steps + + End with a clear outcome. +``` + +### 2. Reload Actions + +```python +loader = ActionPromptLoader() +loader.reload("app/data/action_prompts.yaml") +``` + +### 3. Test + +```python +action = loader.get_action_by_id("new_action_id") +assert action.is_available(UserTier.PREMIUM, LocationType.TOWN) +``` + +--- + +## Tier Hierarchy + +Actions are available to users at or above the required tier: + +``` +FREE (0) < BASIC (1) < PREMIUM (2) < ELITE (3) +``` + +- **FREE action:** Available to all tiers +- **PREMIUM action:** Available to Premium and Elite +- **ELITE action:** Available only to Elite + +--- + +## Cooldown System + +Actions with `cooldown_turns > 0` cannot be used again for that many turns. + +```yaml +cooldown_turns: 3 # Cannot use for 3 turns after use +``` + +Cooldown tracking should be implemented in the session/game state. + +--- + +## Testing + +### Unit Tests + +```python +def test_action_availability(): + action = ActionPrompt( + prompt_id="test", + category=ActionCategory.EXPLORE, + display_text="Test", + description="Test action", + tier_required=UserTier.PREMIUM, + context_filter=[LocationType.TOWN], + dm_prompt_template="Test" + ) + + # Premium action available to Elite in Town + assert action.is_available(UserTier.ELITE, LocationType.TOWN) == True + + # Premium action not available to Free + assert action.is_available(UserTier.FREE, LocationType.TOWN) == False + + # Not available in wrong location + assert action.is_available(UserTier.ELITE, LocationType.WILDERNESS) == False + +def test_loader(): + loader = ActionPromptLoader() + loader.load_from_yaml("app/data/action_prompts.yaml") + + # Free tier in town should see limited actions + actions = loader.get_available_actions(UserTier.FREE, LocationType.TOWN) + assert len(actions) == 2 # ask_locals, search_supplies + + # Elite tier sees all actions for location + elite_actions = loader.get_available_actions(UserTier.ELITE, LocationType.TOWN) + assert len(elite_actions) > len(actions) +``` + +### Manual Verification + +```python +from app.services.action_prompt_loader import ActionPromptLoader +from app.models.action_prompt import LocationType +from app.ai.model_selector import UserTier + +loader = ActionPromptLoader() +loader.load_from_yaml("app/data/action_prompts.yaml") + +print(f"Total actions: {loader.get_prompt_count()}") + +for tier in [UserTier.FREE, UserTier.PREMIUM, UserTier.ELITE]: + actions = loader.get_actions_by_tier(tier) + print(f"{tier.value}: {len(actions)} actions") +``` diff --git a/api/docs/AI_INTEGRATION.md b/api/docs/AI_INTEGRATION.md new file mode 100644 index 0000000..77b27b3 --- /dev/null +++ b/api/docs/AI_INTEGRATION.md @@ -0,0 +1,538 @@ +# AI Integration Documentation + +## Overview + +Code of Conquest uses AI models for narrative generation through a unified Replicate API integration. This document covers the AI client architecture, model selection, and usage patterns. + +**Key Components:** +- **ReplicateClient** - Low-level API client for all AI models +- **ModelSelector** - Tier-based model routing and configuration +- **NarrativeGenerator** - High-level wrapper for game-specific generation + +--- + +## Architecture + +``` +┌─────────────────────┐ +│ NarrativeGenerator │ ← High-level game API +├─────────────────────┤ +│ ModelSelector │ ← Tier/context routing +├─────────────────────┤ +│ ReplicateClient │ ← Unified API client +├─────────────────────┤ +│ Replicate API │ ← All models (Llama, Claude) +└─────────────────────┘ +``` + +All AI models are accessed through Replicate API for unified billing and management. + +--- + +## Replicate Client + +**File:** `app/ai/replicate_client.py` + +### Supported Models + +| Model Type | Identifier | Tier | Use Case | +|------------|-----------|------|----------| +| `LLAMA_3_8B` | `meta/meta-llama-3-8b-instruct` | Free | Cost-effective, good quality | +| `CLAUDE_HAIKU` | `anthropic/claude-3.5-haiku` | Basic | Fast, high quality | +| `CLAUDE_SONNET` | `anthropic/claude-3.5-sonnet` | Premium | Excellent quality | +| `CLAUDE_SONNET_4` | `anthropic/claude-4.5-sonnet` | Elite | Best quality | + +### Basic Usage + +```python +from app.ai.replicate_client import ReplicateClient, ModelType + +# Free tier - Llama (default) +client = ReplicateClient() +response = client.generate( + prompt="You are a dungeon master...", + max_tokens=256, + temperature=0.7 +) +print(response.text) +print(f"Tokens: {response.tokens_used}") + +# Paid tier - Claude models +client = ReplicateClient(model=ModelType.CLAUDE_HAIKU) +response = client.generate( + prompt="Describe the tavern", + system_prompt="You are a dungeon master" +) + +# Override model per-call +response = client.generate("Test", model=ModelType.CLAUDE_SONNET) +``` + +### Response Object + +```python +@dataclass +class ReplicateResponse: + text: str # Generated text + tokens_used: int # Approximate token count + model: str # Model identifier + generation_time: float # Generation time in seconds +``` + +### Configuration + +```python +# Default parameters +DEFAULT_MAX_TOKENS = 256 +DEFAULT_TEMPERATURE = 0.7 +DEFAULT_TOP_P = 0.9 +DEFAULT_TIMEOUT = 30 # seconds + +# Model-specific defaults +MODEL_DEFAULTS = { + ModelType.LLAMA_3_8B: {"max_tokens": 256, "temperature": 0.7}, + ModelType.CLAUDE_HAIKU: {"max_tokens": 512, "temperature": 0.8}, + ModelType.CLAUDE_SONNET: {"max_tokens": 1024, "temperature": 0.9}, + ModelType.CLAUDE_SONNET_4: {"max_tokens": 2048, "temperature": 0.9}, +} +``` + +### Error Handling + +```python +from app.ai.replicate_client import ( + ReplicateClientError, # Base error + ReplicateAPIError, # API errors + ReplicateRateLimitError, # Rate limiting + ReplicateTimeoutError # Timeouts +) + +try: + response = client.generate(prompt) +except ReplicateRateLimitError: + # Handle rate limiting (client retries automatically 3 times) + pass +except ReplicateTimeoutError: + # Handle timeout + pass +except ReplicateAPIError as e: + # Handle other API errors + logger.error(f"API error: {e}") +``` + +### Features + +- **Retry Logic**: Exponential backoff (3 retries) for rate limits +- **Model-specific Formatting**: Llama special tokens, Claude system prompts +- **API Key Validation**: `client.validate_api_key()` method + +--- + +## Model Selector + +**File:** `app/ai/model_selector.py` + +### User Tiers + +```python +class UserTier(str, Enum): + FREE = "free" # Llama 3 8B + BASIC = "basic" # Claude Haiku + PREMIUM = "premium" # Claude Sonnet + ELITE = "elite" # Claude Sonnet 4 +``` + +### Context Types + +```python +class ContextType(str, Enum): + STORY_PROGRESSION = "story_progression" # Creative narratives + COMBAT_NARRATION = "combat_narration" # Action descriptions + QUEST_SELECTION = "quest_selection" # Quest picking + NPC_DIALOGUE = "npc_dialogue" # Character conversations + SIMPLE_RESPONSE = "simple_response" # Quick responses +``` + +### Usage + +```python +from app.ai.model_selector import ModelSelector, UserTier, ContextType + +selector = ModelSelector() + +# Select model configuration +config = selector.select_model( + user_tier=UserTier.PREMIUM, + context_type=ContextType.STORY_PROGRESSION +) + +print(config.model_type) # ModelType.CLAUDE_SONNET +print(config.max_tokens) # 1024 +print(config.temperature) # 0.9 +``` + +### Token Limits by Tier + +| Tier | Base Tokens | Model | +|------|-------------|-------| +| FREE | 256 | Llama 3 8B | +| BASIC | 512 | Claude Haiku | +| PREMIUM | 1024 | Claude Sonnet | +| ELITE | 2048 | Claude Sonnet 4 | + +### Context Adjustments + +**Temperature by Context:** +- Story Progression: 0.9 (creative) +- Combat Narration: 0.8 (exciting) +- Quest Selection: 0.5 (deterministic) +- NPC Dialogue: 0.85 (natural) +- Simple Response: 0.7 (balanced) + +**Token Multipliers:** +- Story Progression: 1.0× (full allocation) +- Combat Narration: 0.75× (shorter) +- Quest Selection: 0.5× (brief) +- NPC Dialogue: 0.75× (conversational) +- Simple Response: 0.5× (quick) + +### Cost Estimation + +```python +# Get tier information +info = selector.get_tier_info(UserTier.PREMIUM) +# { +# "tier": "premium", +# "model": "anthropic/claude-3.5-sonnet", +# "model_name": "Claude 3.5 Sonnet", +# "base_tokens": 1024, +# "quality": "Excellent quality, detailed narratives" +# } + +# Estimate cost per request +cost = selector.estimate_cost_per_request(UserTier.PREMIUM) +# ~$0.009 per request +``` + +--- + +## Narrative Generator + +**File:** `app/ai/narrative_generator.py` + +High-level wrapper that coordinates model selection, prompt templates, and AI generation. + +### Initialization + +```python +from app.ai.narrative_generator import NarrativeGenerator +from app.ai.model_selector import UserTier + +generator = NarrativeGenerator() +``` + +### Story Response Generation + +```python +response = generator.generate_story_response( + character={ + "name": "Aldric", + "level": 3, + "player_class": "Fighter", + "stats": {"strength": 16, "dexterity": 14, ...} + }, + action="I search the room for hidden doors", + game_state={ + "current_location": "Ancient Library", + "location_type": "DUNGEON", + "active_quests": ["find_artifact"] + }, + user_tier=UserTier.PREMIUM, + conversation_history=[ + {"turn": 1, "action": "entered library", "dm_response": "..."}, + {"turn": 2, "action": "examined shelves", "dm_response": "..."} + ], + action_instructions=""" + The player searches for supplies. This means: + - Describe what they FIND, not auto-purchase + - List items with PRICES if applicable + - Ask what they want to do with findings + """ # Optional: from action_prompts.yaml dm_prompt_template +) + +print(response.narrative) +print(f"Tokens: {response.tokens_used}") +print(f"Model: {response.model}") +print(f"Time: {response.generation_time:.2f}s") +``` + +### Action Instructions + +The `action_instructions` parameter passes action-specific guidance from `action_prompts.yaml` to the AI. This ensures: + +1. **Player agency** - AI presents options rather than making decisions +2. **Action semantics** - "Search" means find options, not auto-buy +3. **Context-aware responses** - Different instructions for different actions + +The instructions are injected into the prompt template and include critical player agency rules: +- Never auto-purchase items +- Never complete transactions without consent +- Present choices and ask what they want to do + +### Combat Narration + +```python +response = generator.generate_combat_narration( + character={"name": "Aldric", ...}, + combat_state={ + "round_number": 3, + "enemies": [{"name": "Goblin", "hp": 5, "max_hp": 10}], + "terrain": "cave" + }, + action="swings their sword at the goblin", + action_result={ + "hit": True, + "damage": 12, + "effects": ["bleeding"] + }, + user_tier=UserTier.BASIC, + is_critical=True, + is_finishing_blow=True +) +``` + +### Quest Selection + +```python +quest_id = generator.generate_quest_selection( + character={"name": "Aldric", "level": 3, ...}, + eligible_quests=[ + {"quest_id": "goblin_cave", "name": "Clear the Cave", ...}, + {"quest_id": "herb_gathering", "name": "Gather Herbs", ...} + ], + game_context={ + "current_location": "Tavern", + "recent_events": ["talked to locals"] + }, + user_tier=UserTier.FREE, + recent_actions=["asked about rumors", "ordered ale"] +) +print(quest_id) # "goblin_cave" +``` + +### NPC Dialogue + +```python +response = generator.generate_npc_dialogue( + character={"name": "Aldric", ...}, + npc={ + "name": "Old Barkeep", + "role": "Tavern Owner", + "personality": "gruff but kind" + }, + conversation_topic="What rumors have you heard lately?", + game_state={"current_location": "The Rusty Anchor", ...}, + user_tier=UserTier.PREMIUM, + npc_knowledge=["goblin attacks", "missing merchant"] +) +``` + +### Response Object + +```python +@dataclass +class NarrativeResponse: + narrative: str # Generated text + tokens_used: int # Token count + model: str # Model used + context_type: str # Type of generation + generation_time: float +``` + +### Error Handling + +```python +from app.ai.narrative_generator import NarrativeGeneratorError + +try: + response = generator.generate_story_response(...) +except NarrativeGeneratorError as e: + logger.error(f"Generation failed: {e}") + # Handle gracefully (show error to user, use fallback, etc.) +``` + +--- + +## Prompt Templates + +**File:** `app/ai/prompt_templates.py` +**Templates:** `app/ai/templates/*.j2` + +### Available Templates + +1. **story_action.j2** - Story progression turns +2. **combat_action.j2** - Combat narration +3. **quest_offering.j2** - Context-aware quest selection +4. **npc_dialogue.j2** - NPC conversations + +### Template Filters + +- `format_inventory` - Format item lists +- `format_stats` - Format character stats +- `format_skills` - Format skill lists +- `format_effects` - Format active effects +- `truncate_text` - Truncate with ellipsis +- `format_gold` - Format currency + +### Direct Template Usage + +```python +from app.ai.prompt_templates import get_prompt_templates + +templates = get_prompt_templates() + +prompt = templates.render( + "story_action.j2", + character={"name": "Aldric", ...}, + action="search for traps", + game_state={...}, + conversation_history=[...] +) +``` + +--- + +## Configuration + +### Environment Variables + +```bash +# Required +REPLICATE_API_TOKEN=r8_... + +# Optional (defaults shown) +REPLICATE_MODEL=meta/meta-llama-3-8b-instruct +``` + +### Cost Management + +Approximate costs per 1K tokens: + +| Model | Input | Output | +|-------|-------|--------| +| Llama 3 8B | Free | Free | +| Claude Haiku | $0.001 | $0.005 | +| Claude Sonnet | $0.003 | $0.015 | +| Claude Sonnet 4 | $0.015 | $0.075 | + +--- + +## Integration with Background Jobs + +AI generation runs asynchronously via RQ jobs. See `app/tasks/ai_tasks.py`. + +```python +from app.tasks.ai_tasks import enqueue_ai_task + +# Queue a story action +job = enqueue_ai_task( + task_type="narrative", + user_id="user_123", + context={ + "session_id": "sess_789", + "character_id": "char_456", + "action": "I explore the tavern" + } +) +# Returns: {"job_id": "abc-123", "status": "queued"} +``` + +--- + +## Usage Tracking + +All AI calls are automatically logged for cost monitoring. See `app/services/usage_tracking_service.py`. + +```python +from app.services.usage_tracking_service import UsageTrackingService + +tracker = UsageTrackingService() + +# Get daily usage +usage = tracker.get_daily_usage("user_123", date.today()) +print(f"Requests: {usage.total_requests}") +print(f"Cost: ${usage.estimated_cost:.4f}") + +# Get monthly cost +monthly = tracker.get_monthly_cost("user_123", 2025, 11) +``` + +--- + +## Rate Limiting + +Tier-based daily limits enforced via `app/services/rate_limiter_service.py`. + +### AI Calls (Turns) + +| Tier | Daily Limit | +|------|------------| +| FREE | 20 turns | +| BASIC | 50 turns | +| PREMIUM | 100 turns | +| ELITE | 200 turns | + +### Custom Actions + +Free-text player actions (beyond preset buttons) have separate limits: + +| Tier | Custom Actions/Day | Max Characters | +|------|-------------------|----------------| +| FREE | 10 | 150 | +| BASIC | 50 | 300 | +| PREMIUM | Unlimited | 500 | +| ELITE | Unlimited | 500 | + +These are configurable in `config/*.yaml` under `rate_limiting.tiers.{tier}.custom_actions_per_day` and `custom_action_char_limit`. + +```python +from app.services.rate_limiter_service import RateLimiterService + +limiter = RateLimiterService() + +try: + limiter.check_rate_limit("user_123", UserTier.PREMIUM) + # Process request... + limiter.increment_usage("user_123") +except RateLimitExceeded as e: + # Return error to user + pass +``` + +--- + +## Best Practices + +1. **Always specify context type** - Helps optimize token usage and temperature +2. **Provide conversation history** - Improves narrative coherence +3. **Handle errors gracefully** - Show user-friendly messages +4. **Monitor costs** - Use usage tracking service +5. **Test with mocks first** - Use mocked clients during development + +--- + +## Verification Scripts + +- `scripts/verify_ai_models.py` - Test model routing and API connectivity +- `scripts/verify_e2e_ai_generation.py` - End-to-end generation flow tests + +```bash +# Test model routing (no API key needed) +python scripts/verify_ai_models.py + +# Test with real API calls +python scripts/verify_ai_models.py --llama --haiku --sonnet + +# Full E2E test +python scripts/verify_e2e_ai_generation.py --real --tier premium +``` diff --git a/api/docs/API_REFERENCE.md b/api/docs/API_REFERENCE.md new file mode 100644 index 0000000..6221002 --- /dev/null +++ b/api/docs/API_REFERENCE.md @@ -0,0 +1,2309 @@ +# API Reference + +All API responses follow standardized format: + +```json +{ + "app": "AI Dungeon Master", + "version": "1.0.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "request_id": "optional-request-id", + "result": {}, + "error": null, + "meta": {} +} +``` + +**Base URL:** `/api/v1` + +--- + +## Authentication + +Authentication handled by Appwrite with HTTP-only cookies. Sessions are stored in `coc_session` cookie. + +**Cookie Configuration:** +- **Name:** `coc_session` +- **HTTP-only:** true (JavaScript cannot access) +- **Secure:** true (HTTPS only in production) +- **SameSite:** Lax (CSRF protection) +- **Duration (normal):** 24 hours +- **Duration (remember me):** 30 days + +### Register + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/auth/register` | +| **Description** | Create new user account with email verification | +| **Auth Required** | No | + +**Request Body:** +```json +{ + "email": "player@example.com", + "password": "SecurePass123!", + "name": "Adventurer" +} +``` + +**Response (201 Created):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 201, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "user": { + "id": "user_id_123", + "email": "player@example.com", + "name": "Adventurer", + "email_verified": false, + "tier": "free", + "created_at": "2025-11-14T12:00:00Z" + }, + "message": "Registration successful. Please check your email to verify your account." + } +} +``` + +### Login + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/auth/login` | +| **Description** | User login with session cookie | +| **Auth Required** | No | + +**Request Body:** +```json +{ + "email": "player@example.com", + "password": "SecurePass123!", + "remember_me": true +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "user": { + "id": "user_id_123", + "email": "player@example.com", + "name": "Adventurer", + "email_verified": true, + "tier": "free" + }, + "message": "Login successful" + } +} +``` + +**Set-Cookie Header:** +``` +Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000; Path=/ +``` + +### Logout + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/auth/logout` | +| **Description** | Logout current session and clear cookie | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "message": "Logout successful" + } +} +``` + +### Verify Email + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/auth/verify-email` | +| **Description** | Verify user email address | +| **Auth Required** | No | + +**Query Parameters:** +- `userId` - User ID from verification email +- `secret` - Verification secret from email + +**Success:** Redirects to `/auth/login?verified=true` +**Error:** Redirects to `/auth/login?verified=false` + +### Forgot Password + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/auth/forgot-password` | +| **Description** | Request password reset email | +| **Auth Required** | No | + +**Request Body:** +```json +{ + "email": "player@example.com" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "message": "If an account exists with this email, you will receive a password reset link shortly." + } +} +``` + +### Reset Password (Display Form) + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/auth/reset-password` | +| **Description** | Display password reset form | +| **Auth Required** | No | + +**Query Parameters:** +- `userId` - User ID from reset email +- `secret` - Reset secret from email + +**Success:** Renders password reset form + +### Reset Password (Submit) + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/auth/reset-password` | +| **Description** | Submit new password | +| **Auth Required** | No | + +**Request Body:** +```json +{ + "user_id": "user_id_123", + "secret": "reset_secret", + "password": "NewSecurePass123!" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "message": "Password reset successful. You can now log in with your new password." + } +} +``` + +--- + +## Characters + +### List Characters + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/characters` | +| **Description** | Get all characters for current user with tier information | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "characters": [ + { + "character_id": "char_123", + "name": "Thorin Ironheart", + "class": "vanguard", + "class_name": "Vanguard", + "level": 5, + "experience": 250, + "gold": 1000, + "current_location": "forgotten_crypt", + "origin": "soul_revenant" + } + ], + "count": 1, + "tier": "free", + "limit": 1 + } +} +``` + +### Get Character + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/characters/` | +| **Description** | Get full character details including inventory, equipment, and skills | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "character_id": "char_123", + "user_id": "user_456", + "name": "Thorin Ironheart", + "player_class": { + "class_id": "vanguard", + "name": "Vanguard", + "description": "A seasoned warrior...", + "base_stats": { + "strength": 14, + "dexterity": 10, + "constitution": 14, + "intelligence": 8, + "wisdom": 10, + "charisma": 9 + }, + "skill_trees": [ + { + "tree_id": "shield_bearer", + "name": "Shield Bearer", + "description": "Defensive tanking specialization", + "nodes": [] + } + ], + "starting_equipment": ["rusty_sword"], + "starting_abilities": ["basic_attack"] + }, + "origin": { + "id": "soul_revenant", + "name": "Soul Revenant", + "description": "Returned from death...", + "starting_location": { + "id": "forgotten_crypt", + "name": "The Forgotten Crypt", + "region": "The Deadlands", + "description": "An ancient burial site..." + }, + "narrative_hooks": [], + "starting_bonus": {} + }, + "level": 5, + "experience": 250, + "base_stats": { + "strength": 14, + "dexterity": 10, + "constitution": 14, + "intelligence": 8, + "wisdom": 10, + "charisma": 9 + }, + "unlocked_skills": ["shield_bash", "iron_defense"], + "inventory": [], + "equipped": {}, + "gold": 1000, + "active_quests": [], + "discovered_locations": ["forgotten_crypt"], + "current_location": "forgotten_crypt" + } +} +``` + +**Error Response (404 Not Found):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 404, + "timestamp": "2025-11-15T12:00:00Z", + "result": null, + "error": { + "code": "NOT_FOUND", + "message": "Character not found: char_999", + "details": {} + } +} +``` + +### Create Character + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters` | +| **Description** | Create new character (validates tier limits) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "name": "Thorin Ironheart", + "class_id": "vanguard", + "origin_id": "soul_revenant" +} +``` + +**Validation Rules:** +- **name**: 2-50 characters, letters/numbers/spaces/hyphens/apostrophes only +- **class_id**: Must be one of: vanguard, assassin, arcanist, luminary, wildstrider, oathkeeper, necromancer, lorekeeper +- **origin_id**: Must be one of: soul_revenant, memory_thief, shadow_apprentice, escaped_captive + +**Response (201 Created):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 201, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "character_id": "char_123", + "name": "Thorin Ironheart", + "class": "vanguard", + "class_name": "Vanguard", + "origin": "soul_revenant", + "origin_name": "Soul Revenant", + "level": 1, + "gold": 0, + "current_location": "forgotten_crypt", + "message": "Character created successfully" + } +} +``` + +**Error Response (400 Bad Request - Limit Exceeded):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-15T12:00:00Z", + "result": null, + "error": { + "code": "CHARACTER_LIMIT_EXCEEDED", + "message": "Character limit reached for free tier (1/1). Upgrade your subscription to create more characters.", + "details": {} + } +} +``` + +**Error Response (400 Bad Request - Validation Error):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-15T12:00:00Z", + "result": null, + "error": { + "code": "INVALID_INPUT", + "message": "Validation failed", + "details": { + "name": "Character name must be at least 2 characters", + "class_id": "Invalid class ID. Must be one of: vanguard, assassin, arcanist, luminary, wildstrider, oathkeeper, necromancer, lorekeeper" + } + } +} +``` + +### Delete Character + +| | | +|---|---| +| **Endpoint** | `DELETE /api/v1/characters/` | +| **Description** | Delete character (soft delete - marks as inactive) | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "message": "Character deleted successfully", + "character_id": "char_123" + } +} +``` + +### Unlock Skill + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters//skills/unlock` | +| **Description** | Unlock skill node (validates prerequisites and skill points) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "skill_id": "shield_bash" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "message": "Skill unlocked successfully", + "character_id": "char_123", + "skill_id": "shield_bash", + "unlocked_skills": ["shield_bash"], + "available_points": 0 + } +} +``` + +**Error Response (400 Bad Request - Prerequisites Not Met):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-15T12:00:00Z", + "result": null, + "error": { + "code": "SKILL_UNLOCK_ERROR", + "message": "Prerequisite not met: iron_defense required for fortified_resolve", + "details": {} + } +} +``` + +**Error Response (400 Bad Request - No Skill Points):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-15T12:00:00Z", + "result": null, + "error": { + "code": "SKILL_UNLOCK_ERROR", + "message": "No skill points available (Level 1, 1 skills unlocked)", + "details": {} + } +} +``` + +### Respec Skills + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters//skills/respec` | +| **Description** | Reset all unlocked skills (costs level × 100 gold) | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "message": "Skills reset successfully", + "character_id": "char_123", + "cost": 500, + "remaining_gold": 500, + "available_points": 5 + } +} +``` + +**Error Response (400 Bad Request - Insufficient Gold):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-15T12:00:00Z", + "result": null, + "error": { + "code": "INSUFFICIENT_GOLD", + "message": "Insufficient gold for respec. Cost: 500, Available: 100", + "details": {} + } +} +``` + +--- + +## Classes & Origins (Reference Data) + +### List Classes + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/classes` | +| **Description** | Get all available player classes | +| **Auth Required** | No | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "classes": [ + { + "class_id": "vanguard", + "name": "Vanguard", + "description": "A seasoned warrior who stands at the front lines...", + "base_stats": { + "strength": 14, + "dexterity": 10, + "constitution": 14, + "intelligence": 8, + "wisdom": 10, + "charisma": 9 + }, + "skill_trees": ["Shield Bearer", "Weapon Master"], + "starting_equipment": ["rusty_sword"], + "starting_abilities": ["basic_attack"] + }, + { + "class_id": "assassin", + "name": "Assassin", + "description": "A master of stealth and precision...", + "base_stats": { + "strength": 11, + "dexterity": 15, + "constitution": 10, + "intelligence": 9, + "wisdom": 10, + "charisma": 10 + }, + "skill_trees": ["Shadow Dancer", "Blade Specialist"], + "starting_equipment": ["rusty_dagger"], + "starting_abilities": ["basic_attack"] + } + ], + "count": 8 + } +} +``` + +### Get Class + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/classes/` | +| **Description** | Get full class details with skill trees | +| **Auth Required** | No | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "class_id": "vanguard", + "name": "Vanguard", + "description": "A seasoned warrior who stands at the front lines...", + "base_stats": { + "strength": 14, + "dexterity": 10, + "constitution": 14, + "intelligence": 8, + "wisdom": 10, + "charisma": 9 + }, + "skill_trees": [ + { + "tree_id": "shield_bearer", + "name": "Shield Bearer", + "description": "Defensive tanking specialization", + "nodes": [ + { + "skill_id": "shield_bash", + "name": "Shield Bash", + "description": "Strike with shield for 120% damage and 1-turn stun", + "tier": 1, + "prerequisites": [], + "effects": { + "damage_multiplier": 1.2 + }, + "unlocks_abilities": ["shield_bash"] + } + ] + }, + { + "tree_id": "weapon_master", + "name": "Weapon Master", + "description": "Offensive damage specialization", + "nodes": [] + } + ], + "starting_equipment": ["rusty_sword"], + "starting_abilities": ["basic_attack"] + } +} +``` + +**Error Response (404 Not Found):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 404, + "timestamp": "2025-11-15T12:00:00Z", + "result": null, + "error": { + "code": "NOT_FOUND", + "message": "Class not found: invalid_class", + "details": {} + } +} +``` + +### List Origins + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/origins` | +| **Description** | Get all available character origins | +| **Auth Required** | No | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "origins": [ + { + "id": "soul_revenant", + "name": "Soul Revenant", + "description": "Centuries ago, you perished in battle...", + "starting_location": { + "id": "forgotten_crypt", + "name": "The Forgotten Crypt", + "region": "The Deadlands", + "description": "An ancient burial site shrouded in mist..." + }, + "narrative_hooks": [ + "Why were you brought back to life?", + "What happened in the centuries you were dead?", + "Do you remember your past life?", + "Who or what resurrected you?", + "Are there others like you?", + "What is your purpose now?" + ], + "starting_bonus": { + "trait": "Deathless Resolve", + "description": "Having already died once, death holds no fear for you", + "effect": "Once per day, when reduced to 0 HP, survive with 1 HP instead" + } + } + ], + "count": 4 + } +} +``` + +--- + +## Sessions + +### Create Session + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/sessions` | +| **Description** | Create new solo game session | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "character_id": "char_456" +} +``` + +**Response (201 Created):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 201, + "timestamp": "2025-11-16T10:30:00Z", + "result": { + "session_id": "sess_789", + "character_id": "char_456", + "turn_number": 0, + "game_state": { + "current_location": "Crossroads Village", + "location_type": "town", + "active_quests": [] + } + } +} +``` + +**Error Responses:** +- `400` - Validation error (missing character_id) +- `404` - Character not found +- `409` - Session limit exceeded (max 5 active sessions) + +--- + +### Get Session State + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/sessions/` | +| **Description** | Get current session state with available actions | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-16T10:30:00Z", + "result": { + "session_id": "sess_789", + "character_id": "char_456", + "turn_number": 5, + "status": "active", + "game_state": { + "current_location": "The Rusty Anchor", + "location_type": "tavern", + "active_quests": ["quest_goblin_cave"] + }, + "available_actions": [ + { + "prompt_id": "ask_locals", + "display_text": "Ask locals for information", + "description": "Talk to NPCs to learn about quests and rumors", + "category": "ask_question" + }, + { + "prompt_id": "rest", + "display_text": "Rest and recover", + "description": "Take a short rest to recover health", + "category": "rest" + } + ] + } +} +``` + +**Error Responses:** +- `404` - Session not found or not owned by user + +--- + +### Take Action + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/sessions//action` | +| **Description** | Submit action for AI processing (async) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "action_type": "button", + "prompt_id": "ask_locals" +} +``` + +**Response (202 Accepted):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 202, + "timestamp": "2025-11-16T10:30:00Z", + "result": { + "job_id": "abc-123", + "status": "queued", + "message": "Your action is being processed..." + } +} +``` + +**Notes:** +- Only button actions with predefined prompts are supported +- Poll `/api/v1/jobs//status` to check processing status +- Rate limits apply based on subscription tier +- Available actions depend on user tier and current location + +**Error Responses:** +- `400` - Validation error (invalid action_type, missing prompt_id) +- `403` - Action not available for tier/location +- `404` - Session not found +- `429` - Rate limit exceeded + +**Rate Limit Error Response (429):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 429, + "timestamp": "2025-11-16T10:30:00Z", + "result": null, + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Daily turn limit reached (20 turns). Resets at 00:00 UTC", + "details": {} + } +} +``` + +--- + +### Get Job Status + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/jobs//status` | +| **Description** | Get status of an async AI processing job | +| **Auth Required** | Yes | + +**Response (200 OK - Processing):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-16T10:30:05Z", + "result": { + "job_id": "ai_TaskType.NARRATIVE_abc123", + "status": "processing" + } +} +``` + +**Response (200 OK - Completed):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-16T10:30:10Z", + "result": { + "job_id": "ai_TaskType.NARRATIVE_abc123", + "status": "completed", + "dm_response": "As you search for supplies in the village, you notice...", + "tokens_used": 273, + "model": "meta/meta-llama-3-8b-instruct" + } +} +``` + +**Response (200 OK - Failed):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-16T10:30:10Z", + "result": { + "job_id": "ai_TaskType.NARRATIVE_abc123", + "status": "failed", + "error": "AI generation failed" + } +} +``` + +**Error Responses:** +- `404` - Job not found + +--- + +### Get Conversation History + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/sessions//history` | +| **Description** | Get paginated conversation history | +| **Auth Required** | Yes | + +**Query Parameters:** +- `limit` (int, default 20, max 100) - Number of entries to return +- `offset` (int, default 0) - Number of entries to skip + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-16T10:30:00Z", + "result": { + "total_turns": 5, + "history": [ + { + "turn": 1, + "action": "I explore the tavern", + "dm_response": "You enter a smoky tavern filled with weary travelers...", + "timestamp": "2025-11-16T10:30:00Z" + }, + { + "turn": 2, + "action": "Ask locals for information", + "dm_response": "A grizzled dwarf at the bar tells you about goblin raids...", + "timestamp": "2025-11-16T10:32:00Z" + } + ], + "pagination": { + "limit": 20, + "offset": 0, + "has_more": false + } + } +} +``` + +**Error Responses:** +- `404` - Session not found + +--- + +### End Session + +| | | +|---|---| +| **Endpoint** | `DELETE /api/v1/sessions/` | +| **Description** | End and archive session | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "result": { + "message": "Session ended", + "final_state": {} + } +} +``` + +--- + +### Export Session + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/sessions//export` | +| **Description** | Export session log as Markdown | +| **Auth Required** | Yes | + +**Response:** +```markdown +# Session Log: sess_789 +**Date:** 2025-11-14 +**Character:** Aragorn the Brave + +## Turn 1 +**Action:** I explore the tavern +**DM:** You enter a smoky tavern... +``` + +--- + +## Travel + +The Travel API enables location-based world exploration. Locations are defined in YAML files and players can travel to any unlocked (discovered) location. + +### Get Available Destinations + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/travel/available` | +| **Description** | Get all locations the player can travel to | +| **Auth Required** | Yes | + +**Query Parameters:** +- `session_id` (required) - Active session ID + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "current_location": "crossville_village", + "available_locations": [ + { + "location_id": "crossville_tavern", + "name": "The Rusty Anchor Tavern", + "location_type": "tavern", + "description": "A cozy tavern where travelers share tales..." + }, + { + "location_id": "crossville_forest", + "name": "Whispering Woods", + "location_type": "wilderness", + "description": "A dense forest on the outskirts of town..." + } + ] + } +} +``` + +### Travel to Location + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/travel` | +| **Description** | Travel to a new location (must be discovered) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "session_id": "sess_789", + "location_id": "crossville_tavern" +} +``` + +**Response (202 Accepted):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 202, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "job_id": "ai_travel_abc123", + "status": "queued", + "message": "Traveling to The Rusty Anchor Tavern...", + "destination": { + "location_id": "crossville_tavern", + "name": "The Rusty Anchor Tavern" + } + } +} +``` + +**Error Responses:** +- `400` - Location not discovered +- `404` - Session or location not found + +### Get Location Details + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/travel/location/` | +| **Description** | Get full details for a specific location | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "location_id": "crossville_village", + "name": "Crossville Village", + "location_type": "town", + "region_id": "crossville", + "description": "A modest farming village built around a central square...", + "lore": "Founded two centuries ago by settlers from the eastern kingdoms...", + "ambient_description": "The village square bustles with activity...", + "available_quests": ["quest_mayors_request"], + "npc_ids": ["npc_mayor_aldric", "npc_blacksmith_hilda"], + "discoverable_locations": ["crossville_tavern", "crossville_forest"], + "is_starting_location": true, + "tags": ["town", "social", "merchant", "safe"] + } +} +``` + +### Get Current Location + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/travel/current` | +| **Description** | Get current location details with NPCs present | +| **Auth Required** | Yes | + +**Query Parameters:** +- `session_id` (required) - Active session ID + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "location": { + "location_id": "crossville_village", + "name": "Crossville Village", + "location_type": "town", + "description": "A modest farming village..." + }, + "npcs_present": [ + { + "npc_id": "npc_mayor_aldric", + "name": "Mayor Aldric", + "role": "village mayor" + }, + { + "npc_id": "npc_blacksmith_hilda", + "name": "Hilda Ironforge", + "role": "blacksmith" + } + ] + } +} +``` + +--- + +## NPCs + +The NPC API enables interaction with persistent NPCs. NPCs have personalities, knowledge, and relationships that affect dialogue generation. + +### Get NPC Details + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/npcs/` | +| **Description** | Get NPC details with knowledge filtered by relationship | +| **Auth Required** | Yes | + +**Query Parameters:** +- `character_id` (optional) - Filter knowledge by character's relationship + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "npc_id": "npc_grom_ironbeard", + "name": "Grom Ironbeard", + "role": "bartender", + "location_id": "crossville_tavern", + "personality": { + "traits": ["gruff", "observant", "secretly kind"], + "speech_style": "Uses dwarven expressions, speaks in short sentences", + "quirks": ["Polishes same mug when thinking", "Snorts when amused"] + }, + "appearance": { + "brief": "Stout dwarf with a braided grey beard", + "detailed": "A weathered dwarf with deep-set eyes..." + }, + "available_knowledge": [ + "The mayor has been acting strange lately", + "Strange lights seen in the forest" + ], + "interaction_summary": { + "interaction_count": 3, + "relationship_level": 65, + "first_met": "2025-11-20T10:30:00Z" + }, + "tags": ["merchant", "quest_giver", "information"] + } +} +``` + +### Talk to NPC + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/npcs//talk` | +| **Description** | Start or continue conversation with NPC (generates AI dialogue) | +| **Auth Required** | Yes | + +**Request Body (Initial Greeting):** +```json +{ + "session_id": "sess_789", + "topic": "greeting" +} +``` + +**Request Body (Player Response - Bidirectional Dialogue):** +```json +{ + "session_id": "sess_789", + "player_response": "What can you tell me about the bandits?" +} +``` + +**Parameters:** +- `session_id` (required): Active game session ID +- `topic` (optional): Conversation topic for initial greeting (default: "greeting") +- `player_response` (optional): Player's custom message to the NPC. If provided, overrides `topic`. Enables bidirectional conversation. + +**Response (202 Accepted):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 202, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "job_id": "ai_npc_dialogue_xyz789", + "status": "queued", + "message": "Starting conversation with Grom Ironbeard...", + "npc_name": "Grom Ironbeard", + "npc_role": "bartender" + } +} +``` + +**Job Result (when completed):** +```json +{ + "job_id": "ai_npc_dialogue_xyz789", + "status": "completed", + "result": { + "context_type": "npc_dialogue", + "dialogue": "*polishes mug thoughtfully* \"Ah, another adventurer. What'll it be?\"", + "tokens_used": 728, + "npc_name": "Grom Ironbeard", + "npc_id": "npc_grom_001", + "character_name": "Thorin", + "player_line": "greeting", + "conversation_history": [ + { + "player_line": "Hello there!", + "npc_response": "*nods gruffly* \"Welcome to the Rusty Anchor.\"" + }, + { + "player_line": "What's the news around town?", + "npc_response": "*leans in* \"Strange folk been coming through lately...\"" + } + ] + } +} +``` + +**Conversation History:** +- Previous dialogue exchanges are automatically stored per character-NPC pair +- Up to 10 exchanges are kept per NPC (oldest are pruned) +- The AI receives the last 3 exchanges as context for continuity +- The job result includes prior `conversation_history` for UI display + +**Error Responses:** +- `400` - NPC not at current location +- `404` - NPC or session not found + +### Get NPCs at Location + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/npcs/at-location/` | +| **Description** | Get all NPCs present at a location | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "location_id": "crossville_tavern", + "npcs": [ + { + "npc_id": "npc_grom_ironbeard", + "name": "Grom Ironbeard", + "role": "bartender", + "appearance": "Stout dwarf with a braided grey beard", + "tags": ["merchant", "quest_giver"] + }, + { + "npc_id": "npc_mira_swiftfoot", + "name": "Mira Swiftfoot", + "role": "traveling rogue", + "appearance": "Lithe half-elf with sharp eyes", + "tags": ["information", "secret_keeper"] + } + ] + } +} +``` + +### Adjust NPC Relationship + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/npcs//relationship` | +| **Description** | Adjust relationship level with NPC | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "character_id": "char_123", + "adjustment": 10 +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "npc_id": "npc_grom_ironbeard", + "relationship_level": 75 + } +} +``` + +### Set NPC Custom Flag + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/npcs//flag` | +| **Description** | Set custom flag on NPC interaction (for conditional secrets) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "character_id": "char_123", + "flag_name": "helped_with_rats", + "flag_value": true +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-25T10:30:00Z", + "result": { + "npc_id": "npc_grom_ironbeard", + "flag_name": "helped_with_rats", + "flag_value": true + } +} +``` + +--- + +## Combat + +### Attack + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//attack` | +| **Description** | Execute physical attack | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "attacker_id": "char123", + "target_id": "enemy1", + "weapon_id": "sword1" +} +``` + +**Response:** +```json +{ + "result": { + "damage": 15, + "critical": true, + "narrative": "Aragorn's blade strikes true...", + "target_hp": 25 + } +} +``` + +### Cast Spell + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//cast` | +| **Description** | Cast spell or ability | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "caster_id": "char123", + "spell_id": "fireball", + "target_id": "enemy1" +} +``` + +**Response:** +```json +{ + "result": { + "damage": 30, + "mana_cost": 15, + "narrative": "Flames engulf the target...", + "effects_applied": ["burning"] + } +} +``` + +### Use Item + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//item` | +| **Description** | Use item from inventory | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "character_id": "char123", + "item_id": "health_potion", + "target_id": "char123" +} +``` + +**Response:** +```json +{ + "result": { + "healing": 50, + "narrative": "You drink the potion and feel refreshed", + "current_hp": 100 + } +} +``` + +### Defend + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//defend` | +| **Description** | Take defensive stance | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "character_id": "char123" +} +``` + +**Response:** +```json +{ + "result": { + "defense_bonus": 10, + "duration": 1, + "narrative": "You brace for impact" + } +} +``` + +### Get Combat Status + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/combat//status` | +| **Description** | Get current combat state | +| **Auth Required** | Yes | + +**Response:** +```json +{ + "result": { + "combatants": [], + "turn_order": [], + "current_turn": 0, + "round_number": 1, + "status": "active" + } +} +``` + +--- + +## Game Mechanics + +### Perform Skill Check or Search + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/game/check` | +| **Description** | Perform a skill check or search action and get deterministic dice roll results | +| **Auth Required** | Yes | + +**Request Body (Skill Check):** +```json +{ + "character_id": "char_123", + "check_type": "skill", + "skill": "persuasion", + "dc": 15, + "bonus": 2, + "context": { + "npc_name": "Guard Captain" + } +} +``` + +**Request Body (Search Action):** +```json +{ + "character_id": "char_123", + "check_type": "search", + "location_type": "forest", + "dc": 12, + "bonus": 0 +} +``` + +**Request Body (Using Difficulty Name):** +```json +{ + "character_id": "char_123", + "check_type": "skill", + "skill": "stealth", + "difficulty": "hard", + "bonus": 1 +} +``` + +**Response (200 OK - Skill Check Success):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "check_result": { + "roll": 16, + "modifier": 3, + "total": 19, + "dc": 15, + "success": true, + "margin": 4, + "skill_type": "persuasion" + }, + "context": { + "skill_used": "persuasion", + "stat_used": "charisma", + "npc_name": "Guard Captain" + } + } +} +``` + +**Response (200 OK - Search Success):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "check_result": { + "roll": 18, + "modifier": 2, + "total": 20, + "dc": 12, + "success": true, + "margin": 8, + "skill_type": "perception" + }, + "items_found": [ + { + "template_key": "ancient_coin", + "name": "Ancient Coin", + "description": "A weathered coin from ages past", + "value": 25 + }, + { + "template_key": "healing_herb", + "name": "Healing Herb", + "description": "A medicinal plant bundle", + "value": 10 + } + ], + "gold_found": 15 + } +} +``` + +**Response (200 OK - Check Failed):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "check_result": { + "roll": 7, + "modifier": 1, + "total": 8, + "dc": 15, + "success": false, + "margin": -7, + "skill_type": "stealth" + }, + "context": { + "skill_used": "stealth", + "stat_used": "dexterity", + "situation": "Sneaking past guards" + } + } +} +``` + +**Error Response (400 Bad Request - Invalid skill):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-23T10:30:00Z", + "result": null, + "error": { + "code": "INVALID_INPUT", + "message": "Invalid skill type", + "details": { + "field": "skill", + "issue": "Must be one of: perception, insight, survival, medicine, stealth, acrobatics, sleight_of_hand, lockpicking, persuasion, deception, intimidation, performance, athletics, arcana, history, investigation, nature, religion, endurance" + } + } +} +``` + +**Error Response (404 Not Found - Character not found):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 404, + "timestamp": "2025-11-23T10:30:00Z", + "result": null, + "error": { + "code": "NOT_FOUND", + "message": "Character not found: char_999" + } +} +``` + +**Error Response (403 Forbidden - Not character owner):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 403, + "timestamp": "2025-11-23T10:30:00Z", + "result": null, + "error": { + "code": "FORBIDDEN", + "message": "You don't have permission to access this character" + } +} +``` + +**Notes:** +- `check_type` must be "search" or "skill" +- For skill checks, `skill` is required +- For search checks, `location_type` is optional (defaults to "default") +- `dc` or `difficulty` must be provided (dc takes precedence) +- Valid difficulty values: trivial (5), easy (10), medium (15), hard (20), very_hard (25), nearly_impossible (30) +- `bonus` is optional (defaults to 0) +- `context` is optional and merged with the response for AI narration +- Roll uses d20 + stat modifier + optional bonus +- Margin is calculated as (total - dc) +- Items found depend on location type and success margin + +--- + +### List Available Skills + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/game/skills` | +| **Description** | Get all available skill types with their associated stats | +| **Auth Required** | No | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "skills": [ + { + "name": "perception", + "stat": "wisdom" + }, + { + "name": "insight", + "stat": "wisdom" + }, + { + "name": "survival", + "stat": "wisdom" + }, + { + "name": "medicine", + "stat": "wisdom" + }, + { + "name": "stealth", + "stat": "dexterity" + }, + { + "name": "acrobatics", + "stat": "dexterity" + }, + { + "name": "sleight_of_hand", + "stat": "dexterity" + }, + { + "name": "lockpicking", + "stat": "dexterity" + }, + { + "name": "persuasion", + "stat": "charisma" + }, + { + "name": "deception", + "stat": "charisma" + }, + { + "name": "intimidation", + "stat": "charisma" + }, + { + "name": "performance", + "stat": "charisma" + }, + { + "name": "athletics", + "stat": "strength" + }, + { + "name": "arcana", + "stat": "intelligence" + }, + { + "name": "history", + "stat": "intelligence" + }, + { + "name": "investigation", + "stat": "intelligence" + }, + { + "name": "nature", + "stat": "intelligence" + }, + { + "name": "religion", + "stat": "intelligence" + }, + { + "name": "endurance", + "stat": "constitution" + } + ] + } +} +``` + +**Notes:** +- No authentication required +- Skills are grouped by their associated stat +- Use the skill names in the `skill` parameter of the `/check` endpoint + +--- + +### List Difficulty Levels + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/game/difficulties` | +| **Description** | Get all difficulty levels and their DC values | +| **Auth Required** | No | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "difficulties": [ + { + "name": "trivial", + "dc": 5 + }, + { + "name": "easy", + "dc": 10 + }, + { + "name": "medium", + "dc": 15 + }, + { + "name": "hard", + "dc": 20 + }, + { + "name": "very_hard", + "dc": 25 + }, + { + "name": "nearly_impossible", + "dc": 30 + } + ] + } +} +``` + +**Notes:** +- No authentication required +- Use difficulty names in the `difficulty` parameter of the `/check` endpoint instead of providing raw DC values +- DC values range from 5 (trivial) to 30 (nearly impossible) + +--- + +## Marketplace + +### Browse Listings + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/marketplace` | +| **Description** | Browse marketplace listings | +| **Auth Required** | Yes (Premium+ only) | + +**Query Parameters:** +- `type` - "auction" or "fixed_price" +- `category` - "weapon", "armor", "consumable" +- `min_price` - Minimum price +- `max_price` - Maximum price +- `sort` - "price_asc", "price_desc", "ending_soon" +- `page` - Page number +- `limit` - Items per page + +**Response:** +```json +{ + "result": { + "listings": [ + { + "listing_id": "list123", + "item": {}, + "listing_type": "auction", + "current_bid": 500, + "buyout_price": 1000, + "auction_end": "2025-11-15T12:00:00Z" + } + ], + "total": 50, + "page": 1, + "pages": 5 + } +} +``` + +### Get Listing + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/marketplace/` | +| **Description** | Get listing details | +| **Auth Required** | Yes (Premium+ only) | + +**Response:** +```json +{ + "result": { + "listing_id": "list123", + "seller_name": "Aragorn", + "item": {}, + "listing_type": "auction", + "current_bid": 500, + "bid_count": 5, + "bids": [], + "auction_end": "2025-11-15T12:00:00Z" + } +} +``` + +### Create Listing + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/marketplace/list` | +| **Description** | Create new marketplace listing | +| **Auth Required** | Yes (Premium+ only) | + +**Request Body (Auction):** +```json +{ + "character_id": "char123", + "item_id": "sword1", + "listing_type": "auction", + "starting_bid": 100, + "buyout_price": 1000, + "duration_hours": 48 +} +``` + +**Request Body (Fixed Price):** +```json +{ + "character_id": "char123", + "item_id": "sword1", + "listing_type": "fixed_price", + "price": 500 +} +``` + +**Response:** +```json +{ + "result": { + "listing_id": "list123", + "message": "Listing created successfully" + } +} +``` + +### Place Bid + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/marketplace//bid` | +| **Description** | Place bid on auction | +| **Auth Required** | Yes (Premium+ only) | + +**Request Body:** +```json +{ + "character_id": "char123", + "amount": 600 +} +``` + +**Response:** +```json +{ + "result": { + "current_bid": 600, + "is_winning": true, + "message": "Bid placed successfully" + } +} +``` + +### Buyout + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/marketplace//buyout` | +| **Description** | Instant purchase at buyout price | +| **Auth Required** | Yes (Premium+ only) | + +**Request Body:** +```json +{ + "character_id": "char123" +} +``` + +**Response:** +```json +{ + "result": { + "transaction_id": "trans123", + "price": 1000, + "item": {}, + "message": "Purchase successful" + } +} +``` + +### Cancel Listing + +| | | +|---|---| +| **Endpoint** | `DELETE /api/v1/marketplace/` | +| **Description** | Cancel listing (owner only) | +| **Auth Required** | Yes (Premium+ only) | + +**Response:** +```json +{ + "result": { + "message": "Listing cancelled, item returned" + } +} +``` + +### My Listings + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/marketplace/my-listings` | +| **Description** | Get current user's active listings | +| **Auth Required** | Yes (Premium+ only) | + +**Response:** +```json +{ + "result": { + "listings": [], + "total": 5 + } +} +``` + +### My Bids + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/marketplace/my-bids` | +| **Description** | Get current user's active bids | +| **Auth Required** | Yes (Premium+ only) | + +**Response:** +```json +{ + "result": { + "bids": [ + { + "listing_id": "list123", + "item": {}, + "your_bid": 500, + "current_bid": 600, + "is_winning": false, + "auction_end": "2025-11-15T12:00:00Z" + } + ] + } +} +``` + +--- + +## Shop + +### Browse Shop + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/shop/items` | +| **Description** | Browse NPC shop inventory | +| **Auth Required** | Yes | + +**Query Parameters:** +- `category` - "consumable", "weapon", "armor" + +**Response:** +```json +{ + "result": { + "items": [ + { + "item_id": "health_potion", + "name": "Health Potion", + "price": 50, + "stock": -1, + "description": "Restores 50 HP" + } + ] + } +} +``` + +### Purchase from Shop + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/shop/purchase` | +| **Description** | Buy item from NPC shop | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "character_id": "char123", + "item_id": "health_potion", + "quantity": 5 +} +``` + +**Response:** +```json +{ + "result": { + "transaction_id": "trans123", + "total_cost": 250, + "items_purchased": 5, + "remaining_gold": 750 + } +} +``` + +--- + +## Error Responses + +### Standard Error Format + +```json +{ + "app": "AI Dungeon Master", + "version": "1.0.0", + "status": 400, + "timestamp": "2025-11-14T12:00:00Z", + "result": null, + "error": { + "code": "INVALID_ACTION", + "message": "Not your turn", + "details": { + "current_turn": "char456", + "your_character": "char123" + } + } +} +``` + +### Common Error Codes + +| Code | Status | Description | +|------|--------|-------------| +| `UNAUTHORIZED` | 401 | Invalid or missing auth token | +| `FORBIDDEN` | 403 | Insufficient permissions | +| `NOT_FOUND` | 404 | Resource not found | +| `INVALID_INPUT` | 400 | Validation error | +| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests | +| `CHARACTER_LIMIT_EXCEEDED` | 400 | User has reached character limit for their tier | +| `CHARACTER_NOT_FOUND` | 404 | Character does not exist or not accessible | +| `SKILL_UNLOCK_ERROR` | 400 | Skill unlock failed (prerequisites, points, or tier) | +| `INSUFFICIENT_FUNDS` | 400 | Not enough gold | +| `INVALID_ACTION` | 400 | Action not allowed | +| `SESSION_FULL` | 400 | Session at max capacity | +| `NOT_YOUR_TURN` | 400 | Not active player's turn | +| `AI_LIMIT_EXCEEDED` | 429 | Daily AI call limit reached | +| `PREMIUM_REQUIRED` | 403 | Feature requires premium subscription | + +--- + +## Rate Limiting + +| Tier | Requests/Minute | AI Calls/Day | +|------|-----------------|--------------| +| FREE | 30 | 50 | +| BASIC | 60 | 200 | +| PREMIUM | 120 | 1000 | +| ELITE | 300 | Unlimited | + +**Rate Limit Headers:** +``` +X-RateLimit-Limit: 60 +X-RateLimit-Remaining: 45 +X-RateLimit-Reset: 1699999999 +``` + +--- + +## Pagination + +Endpoints that return lists support pagination: + +**Query Parameters:** +- `page` - Page number (default: 1) +- `limit` - Items per page (default: 20, max: 100) + +**Response Meta:** +```json +{ + "meta": { + "page": 1, + "limit": 20, + "total": 100, + "pages": 5 + } +} +``` + +--- + +## Realtime Events (WebSocket) + +**Subscribe to session updates:** + +```javascript +client.subscribe( + 'databases.main.collections.game_sessions.documents.{sessionId}', + callback +); +``` + +**Event Types:** +- Session state change +- Turn change +- Combat update +- Chat message +- Player joined/left +- Marketplace bid notification diff --git a/api/docs/API_TESTING.md b/api/docs/API_TESTING.md new file mode 100644 index 0000000..185e185 --- /dev/null +++ b/api/docs/API_TESTING.md @@ -0,0 +1,1864 @@ +# API Testing Guide + +**Last Updated:** November 15, 2025 +**Version:** 0.2.0 + +This document provides manual testing instructions for Code of Conquest API endpoints. + +--- + +## Table of Contents + +1. [Setup](#setup) +2. [Authentication Endpoints](#authentication-endpoints) +3. [Character Endpoints](#character-endpoints) +4. [Testing Workflows](#testing-workflows) +5. [Common Errors](#common-errors) + +--- + +## Setup + +### Prerequisites + +- **httpie** (recommended) or **curl** +- Flask app running locally (default: `http://localhost:5000`) +- Appwrite configured with valid credentials in `.env` + +### Install httpie + +```bash +# macOS +brew install httpie + +# Ubuntu/Debian +apt-get install httpie + +# Python pip +pip install httpie +``` + +### Start the Application + +```bash +# Activate virtual environment +source venv/bin/activate + +# Run Flask app +python wsgi.py +``` + +The app will be available at `http://localhost:5000` + +--- + +## Authentication Endpoints + +### 1. Register User + +**Endpoint:** `POST /api/v1/auth/register` + +**Description:** Create a new user account and trigger email verification. + +**Request:** + +```bash +# httpie +http POST localhost:5000/api/v1/auth/register \ + email="hero@example.com" \ + password="SecurePass123!" \ + name="Brave Adventurer" + +# curl +curl -X POST http://localhost:5000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "hero@example.com", + "password": "SecurePass123!", + "name": "Brave Adventurer" + }' +``` + +**Success Response (201 Created):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 201, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "user": { + "id": "user_abc123", + "email": "hero@example.com", + "name": "Brave Adventurer", + "email_verified": false, + "tier": "free", + "created_at": "2025-11-14T12:00:00Z", + "updated_at": "2025-11-14T12:00:00Z" + }, + "message": "Registration successful. Please check your email to verify your account." + } +} +``` + +**Error Response (400 Bad Request - Email exists):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid input data", + "details": { + "email": "An account with this email already exists" + } + } +} +``` + +**Error Response (400 Bad Request - Weak password):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid input data", + "details": { + "password": "Password must contain at least one uppercase letter, at least one number, at least one special character" + } + } +} +``` + +--- + +### 2. Login User + +**Endpoint:** `POST /api/v1/auth/login` + +**Description:** Authenticate user and create session. Sets HTTP-only cookie. + +**Request:** + +```bash +# httpie (with cookie jar) +http --session=user1 POST localhost:5000/api/v1/auth/login \ + email="hero@example.com" \ + password="SecurePass123!" \ + remember_me:=false + +# curl (save cookies) +curl -X POST http://localhost:5000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + -d '{ + "email": "hero@example.com", + "password": "SecurePass123!", + "remember_me": false + }' +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "user": { + "id": "user_abc123", + "email": "hero@example.com", + "name": "Brave Adventurer", + "email_verified": true, + "tier": "free" + }, + "message": "Login successful" + } +} +``` + +**Set-Cookie Header:** + +``` +Set-Cookie: coc_session=session_xyz789; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400 +``` + +**Error Response (401 Unauthorized):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 401, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid email or password" + } +} +``` + +--- + +### 3. Logout User + +**Endpoint:** `POST /api/v1/auth/logout` + +**Description:** Delete user session and clear cookie. Requires authentication. + +**Request:** + +```bash +# httpie (with saved session) +http --session=user1 POST localhost:5000/api/v1/auth/logout + +# curl (with saved cookies) +curl -X POST http://localhost:5000/api/v1/auth/logout \ + -b cookies.txt \ + -c cookies.txt +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "message": "Logout successful" + } +} +``` + +**Set-Cookie Header (clears cookie):** + +``` +Set-Cookie: coc_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0 +``` + +**Error Response (401 Unauthorized - Not logged in):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 401, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required. Please log in." + } +} +``` + +--- + +### 4. Verify Email + +**Endpoint:** `GET /api/v1/auth/verify-email?userId=XXX&secret=YYY` + +**Description:** Verify user's email address. Called from link in verification email. + +**Request:** + +```bash +# httpie +http GET "localhost:5000/api/v1/auth/verify-email?userId=user_abc123&secret=verification_secret_xyz" + +# curl +curl "http://localhost:5000/api/v1/auth/verify-email?userId=user_abc123&secret=verification_secret_xyz" +``` + +**Success Response:** + +Redirects to `/auth/login` with success flash message. + +**Error Response:** + +Redirects to `/auth/login` with error flash message. + +--- + +### 5. Request Password Reset + +**Endpoint:** `POST /api/v1/auth/forgot-password` + +**Description:** Request password reset email. Always returns success for security. + +**Request:** + +```bash +# httpie +http POST localhost:5000/api/v1/auth/forgot-password \ + email="hero@example.com" + +# curl +curl -X POST http://localhost:5000/api/v1/auth/forgot-password \ + -H "Content-Type: application/json" \ + -d '{"email": "hero@example.com"}' +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "message": "If an account exists with this email, you will receive a password reset link shortly." + } +} +``` + +**Note:** Response is always the same, regardless of whether the email exists (security best practice). + +--- + +### 6. Reset Password + +**Endpoint:** `POST /api/v1/auth/reset-password` + +**Description:** Confirm password reset with new password. Requires userId and secret from email link. + +**Request:** + +```bash +# httpie +http POST localhost:5000/api/v1/auth/reset-password \ + user_id="user_abc123" \ + secret="reset_secret_xyz" \ + password="NewSecurePass123!" + +# curl +curl -X POST http://localhost:5000/api/v1/auth/reset-password \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "user_abc123", + "secret": "reset_secret_xyz", + "password": "NewSecurePass123!" + }' +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "message": "Password reset successful. You can now log in with your new password." + } +} +``` + +**Error Response (400 Bad Request - Invalid/expired link):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "PASSWORD_RESET_ERROR", + "message": "Password reset failed. The link may be invalid or expired." + } +} +``` + +--- + +## Character Endpoints + +### 1. Get User's Characters + +**Endpoint:** `GET /api/v1/characters` + +**Description:** Retrieve all characters belonging to the authenticated user. + +**Request:** + +```bash +# httpie (with saved session) +http --session=user1 GET localhost:5000/api/v1/characters + +# curl (with saved cookies) +curl http://localhost:5000/api/v1/characters -b cookies.txt +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "characters": [ + { + "character_id": "char_123", + "name": "Thorin Ironforge", + "class": "vanguard", + "class_name": "Vanguard", + "level": 5, + "experience": 2400, + "gold": 150, + "current_location": "forgotten_crypt", + "origin": "soul_revenant" + } + ], + "count": 1, + "tier": "free", + "limit": 1 + } +} +``` + +**Error Response (401 Unauthorized):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 401, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required. Please log in." + } +} +``` + +--- + +### 2. Get Single Character + +**Endpoint:** `GET /api/v1/characters/` + +**Description:** Retrieve detailed information for a specific character. Returns full character data including class, origin, stats, skills, inventory, and equipment. + +**Request:** + +```bash +# httpie +http --session=user1 GET localhost:5000/api/v1/characters/char_123 + +# curl +curl http://localhost:5000/api/v1/characters/char_123 -b cookies.txt +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "character_id": "char_123", + "user_id": "user_abc123", + "name": "Thorin Ironforge", + "player_class": { + "class_id": "vanguard", + "name": "Vanguard", + "description": "Armored warriors who excel in melee combat...", + "base_stats": { + "strength": 14, + "dexterity": 10, + "constitution": 14, + "intelligence": 8, + "wisdom": 10, + "charisma": 9 + }, + "skill_trees": [...], + "starting_equipment": ["Rusty Sword", "Tattered Cloth Armor"], + "starting_abilities": [] + }, + "origin": { + "id": "soul_revenant", + "name": "The Soul Revenant", + "description": "You died. That much you remember...", + "starting_location": { + "id": "forgotten_crypt", + "name": "The Forgotten Crypt", + "description": "A crumbling stone tomb..." + }, + "narrative_hooks": [...], + "starting_bonus": { + "type": "stat", + "value": {"constitution": 1} + } + }, + "level": 5, + "experience": 2400, + "base_stats": { + "strength": 16, + "dexterity": 10, + "constitution": 14, + "intelligence": 8, + "wisdom": 12, + "charisma": 10 + }, + "unlocked_skills": ["shield_wall", "toughness"], + "inventory": [], + "equipped": {}, + "gold": 150, + "active_quests": [], + "discovered_locations": ["forgotten_crypt"], + "current_location": "forgotten_crypt" + } +} +``` + +**Error Response (404 Not Found):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 404, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "CHARACTER_NOT_FOUND", + "message": "Character not found or you do not have access to it" + } +} +``` + +--- + +### 3. Create Character + +**Endpoint:** `POST /api/v1/characters` + +**Description:** Create a new character. Subject to tier limits (Free: 1, Basic: 3, Premium: 5, Elite: 10). + +**Valid class_id values:** `vanguard`, `assassin`, `arcanist`, `luminary`, `wildstrider`, `oathkeeper`, `necromancer`, `lorekeeper` + +**Valid origin_id values:** `soul_revenant`, `memory_thief`, `shadow_apprentice`, `escaped_captive` + +**Request:** + +```bash +# httpie +http --session=user1 POST localhost:5000/api/v1/characters \ + name="Elara Moonwhisper" \ + class_id="arcanist" \ + origin_id="memory_thief" + +# curl +curl -X POST http://localhost:5000/api/v1/characters \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "name": "Elara Moonwhisper", + "class_id": "arcanist", + "origin_id": "memory_thief" + }' +``` + +**Success Response (201 Created):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 201, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "character_id": "char_456", + "name": "Elara Moonwhisper", + "class": "arcanist", + "class_name": "Arcanist", + "origin": "memory_thief", + "origin_name": "The Memory Thief", + "level": 1, + "gold": 0, + "current_location": "thornfield_plains", + "message": "Character created successfully" + } +} +``` + +**Error Response (400 Bad Request - Character limit exceeded):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "CHARACTER_LIMIT_EXCEEDED", + "message": "You have reached your character limit for your tier (Free: 1 character)" + } +} +``` + +**Error Response (400 Bad Request - Invalid name):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "INVALID_INPUT", + "message": "Character name must be between 2 and 50 characters and contain only letters, numbers, spaces, hyphens, and apostrophes" + } +} +``` + +--- + +### 4. Delete Character + +**Endpoint:** `DELETE /api/v1/characters/` + +**Description:** Soft-delete a character (marks as inactive rather than removing). + +**Request:** + +```bash +# httpie +http --session=user1 DELETE localhost:5000/api/v1/characters/char_456 + +# curl +curl -X DELETE http://localhost:5000/api/v1/characters/char_456 \ + -b cookies.txt +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-14T12:00:00Z", + "result": { + "message": "Character deleted successfully" + } +} +``` + +**Error Response (404 Not Found):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 404, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "CHARACTER_NOT_FOUND", + "message": "Character not found or you do not have access to it" + } +} +``` + +--- + +### 5. Unlock Skill + +**Endpoint:** `POST /api/v1/characters//skills/unlock` + +**Description:** Unlock a skill for a character. Validates prerequisites, skill points, and tier requirements. Requires 1 skill point (gained per level). + +**Request:** + +```bash +# httpie +http --session=user1 POST localhost:5000/api/v1/characters/char_123/skills/unlock \ + skill_id="shield_wall" + +# curl +curl -X POST http://localhost:5000/api/v1/characters/char_123/skills/unlock \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "skill_id": "shield_wall" + }' +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "message": "Skill unlocked successfully", + "character_id": "char_123", + "skill_id": "shield_wall", + "unlocked_skills": ["shield_wall"], + "available_points": 0 + } +} +``` + +**Error Response (400 Bad Request - Prerequisites not met):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "SKILL_UNLOCK_ERROR", + "message": "Cannot unlock skill: Prerequisites not met (requires: shield_bash)" + } +} +``` + +**Error Response (400 Bad Request - Insufficient skill points):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-14T12:00:00Z", + "error": { + "code": "SKILL_UNLOCK_ERROR", + "message": "Cannot unlock skill: Insufficient skill points (requires: 1, available: 0)" + } +} +``` + +--- + +### 6. Respec Skills + +**Endpoint:** `POST /api/v1/characters//skills/respec` + +**Description:** Reset all unlocked skills and refund skill points. Costs gold (level × 100 gold). + +**Request:** + +```bash +# httpie +http --session=user1 POST localhost:5000/api/v1/characters/char_123/skills/respec + +# curl +curl -X POST http://localhost:5000/api/v1/characters/char_123/skills/respec \ + -b cookies.txt +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "message": "Skills reset successfully", + "character_id": "char_123", + "cost": 500, + "remaining_gold": 100, + "available_points": 5 + } +} +``` + +**Error Response (400 Bad Request - Insufficient gold):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 400, + "timestamp": "2025-11-15T12:00:00Z", + "error": { + "code": "INSUFFICIENT_GOLD", + "message": "Insufficient gold for respec. Cost: 500, Available: 100" + } +} +``` + +--- + +### 7. Get Character Classes + +**Endpoint:** `GET /api/v1/classes` + +**Description:** Get all available character classes (8 total). No authentication required. Used for character creation UI. + +**Request:** + +```bash +# httpie +http GET localhost:5000/api/v1/classes + +# curl +curl http://localhost:5000/api/v1/classes +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "classes": [ + { + "class_id": "vanguard", + "name": "Vanguard", + "description": "Armored warriors who excel in melee combat and protecting allies", + "base_stats": { + "strength": 14, + "dexterity": 10, + "constitution": 14, + "intelligence": 8, + "wisdom": 10, + "charisma": 9 + }, + "skill_trees": ["Shield Bearer", "Weapon Master"], + "starting_equipment": ["Rusty Sword", "Tattered Cloth Armor"], + "starting_abilities": [] + }, + { + "class_id": "arcanist", + "name": "Arcanist", + "description": "Masters of elemental magic, wielding fire and ice", + "base_stats": { + "strength": 8, + "dexterity": 10, + "constitution": 9, + "intelligence": 15, + "wisdom": 12, + "charisma": 11 + }, + "skill_trees": ["Pyromancy", "Cryomancy"], + "starting_equipment": ["Worn Staff", "Tattered Cloth Armor"], + "starting_abilities": [] + } + ], + "count": 8 + } +} +``` + +**Note:** Response includes all 8 classes: vanguard, assassin, arcanist, luminary, wildstrider, oathkeeper, necromancer, lorekeeper + +--- + +### 8. Get Single Character Class + +**Endpoint:** `GET /api/v1/classes/` + +**Description:** Get detailed information about a specific character class including full skill trees. No authentication required. Returns complete skill tree data with 20 skills (2 trees × 5 tiers × 2 nodes). + +**Request:** + +```bash +# httpie +http GET localhost:5000/api/v1/classes/vanguard + +# curl +curl http://localhost:5000/api/v1/classes/vanguard +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "class_id": "vanguard", + "name": "Vanguard", + "description": "Armored warriors who excel in melee combat and protecting allies", + "base_stats": { + "strength": 14, + "dexterity": 10, + "constitution": 14, + "intelligence": 8, + "wisdom": 10, + "charisma": 9 + }, + "skill_trees": [ + { + "tree_id": "shield_bearer", + "name": "Shield Bearer", + "description": "Defensive techniques and shield mastery", + "nodes": [ + { + "skill_id": "shield_wall", + "name": "Shield Wall", + "description": "Increase armor by 5", + "tier": 1, + "prerequisites": [], + "effects": [{"type": "stat", "stat": "armor", "value": 5}] + } + ] + }, + { + "tree_id": "weapon_master", + "name": "Weapon Master", + "description": "Offensive prowess and weapon techniques", + "nodes": [...] + } + ], + "starting_equipment": ["Rusty Sword", "Tattered Cloth Armor"], + "starting_abilities": [] + } +} +``` + +**Error Response (404 Not Found):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 404, + "timestamp": "2025-11-15T12:00:00Z", + "error": { + "code": "NOT_FOUND", + "message": "Class not found: warrior" + } +} +``` + +--- + +### 9. Get Character Origins + +**Endpoint:** `GET /api/v1/origins` + +**Description:** Get all available character origins (4 total). No authentication required. Used for character creation UI. Origins provide narrative backstory and starting location. + +**Request:** + +```bash +# httpie +http GET localhost:5000/api/v1/origins + +# curl +curl http://localhost:5000/api/v1/origins +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "origins": [ + { + "id": "soul_revenant", + "name": "The Soul Revenant", + "description": "You died. That much you remember. The cold embrace of death, the fading light, the silence. But something—or someone—pulled you back...", + "starting_location": { + "id": "forgotten_crypt", + "name": "The Forgotten Crypt", + "description": "A crumbling stone tomb beneath a dead forest..." + }, + "narrative_hooks": [ + "Who or what brought you back from death?", + "What life did you lead before your demise?", + "Do fragments of past lives haunt your dreams?" + ], + "starting_bonus": { + "type": "stat", + "value": {"constitution": 1} + } + }, + { + "id": "memory_thief", + "name": "The Memory Thief", + "description": "You awaken with fragments of memories that aren't yours...", + "starting_location": { + "id": "thornfield_plains", + "name": "Thornfield Plains", + "description": "Endless grasslands dotted with ancient ruins..." + }, + "narrative_hooks": [ + "Whose memories do you carry?", + "Why were these memories stolen?" + ], + "starting_bonus": { + "type": "stat", + "value": {"intelligence": 1} + } + } + ], + "count": 4 + } +} +``` + +**Note:** Response includes all 4 origins: soul_revenant, memory_thief, shadow_apprentice, escaped_captive + +--- + +## Game Mechanics Endpoints + +### 1. Perform Skill Check + +**Endpoint:** `POST /api/v1/game/check` + +**Description:** Perform a skill check (persuasion, stealth, perception, etc.) and get deterministic dice roll results. The result includes all dice roll details for UI display and can be passed to AI for narrative description. + +**Request (Skill Check - Persuasion):** + +```bash +# httpie +http --session=user1 POST localhost:5000/api/v1/game/check \ + character_id="char_123" \ + check_type="skill" \ + skill="persuasion" \ + dc:=15 \ + bonus:=2 \ + context:='{"npc_name": "Guard Captain"}' + +# curl +curl -X POST http://localhost:5000/api/v1/game/check \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "character_id": "char_123", + "check_type": "skill", + "skill": "persuasion", + "dc": 15, + "bonus": 2, + "context": {"npc_name": "Guard Captain"} + }' +``` + +**Success Response (200 OK - Skill Check Passes):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "check_result": { + "roll": 16, + "modifier": 3, + "total": 19, + "dc": 15, + "success": true, + "margin": 4, + "skill_type": "persuasion" + }, + "context": { + "skill_used": "persuasion", + "stat_used": "charisma", + "npc_name": "Guard Captain" + } + } +} +``` + +**Success Response (200 OK - Skill Check Fails):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "check_result": { + "roll": 7, + "modifier": 1, + "total": 8, + "dc": 15, + "success": false, + "margin": -7, + "skill_type": "stealth" + }, + "context": { + "skill_used": "stealth", + "stat_used": "dexterity", + "situation": "Sneaking past guards" + } + } +} +``` + +--- + +### 2. Perform Search Action + +**Endpoint:** `POST /api/v1/game/check` + +**Description:** Perform a search action in a location and get items/gold based on perception check result. + +**Request (Search in Forest):** + +```bash +# httpie +http --session=user1 POST localhost:5000/api/v1/game/check \ + character_id="char_123" \ + check_type="search" \ + location_type="forest" \ + difficulty="medium" \ + bonus:=0 + +# curl +curl -X POST http://localhost:5000/api/v1/game/check \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "character_id": "char_123", + "check_type": "search", + "location_type": "forest", + "difficulty": "medium", + "bonus": 0 + }' +``` + +**Success Response (200 OK - Search Succeeds):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "check_result": { + "roll": 18, + "modifier": 2, + "total": 20, + "dc": 15, + "success": true, + "margin": 5, + "skill_type": "perception" + }, + "items_found": [ + { + "template_key": "ancient_coin", + "name": "Ancient Coin", + "description": "A weathered coin from ages past", + "value": 25 + }, + { + "template_key": "healing_herb", + "name": "Healing Herb", + "description": "A medicinal plant bundle", + "value": 10 + } + ], + "gold_found": 12 + } +} +``` + +**Success Response (200 OK - Search Fails):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "check_result": { + "roll": 4, + "modifier": 2, + "total": 6, + "dc": 15, + "success": false, + "margin": -9, + "skill_type": "perception" + }, + "items_found": [], + "gold_found": 0 + } +} +``` + +--- + +### 3. Get Available Skills + +**Endpoint:** `GET /api/v1/game/skills` + +**Description:** Get all available skill types and their associated stats. Used to populate skill selection UI. + +**Request:** + +```bash +# httpie +http GET localhost:5000/api/v1/game/skills + +# curl +curl http://localhost:5000/api/v1/game/skills +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "skills": [ + { + "name": "perception", + "stat": "wisdom" + }, + { + "name": "stealth", + "stat": "dexterity" + }, + { + "name": "persuasion", + "stat": "charisma" + }, + { + "name": "intimidation", + "stat": "charisma" + }, + { + "name": "deception", + "stat": "charisma" + }, + { + "name": "lockpicking", + "stat": "dexterity" + }, + { + "name": "athletics", + "stat": "strength" + }, + { + "name": "acrobatics", + "stat": "dexterity" + }, + { + "name": "sleight_of_hand", + "stat": "dexterity" + }, + { + "name": "arcana", + "stat": "intelligence" + }, + { + "name": "history", + "stat": "intelligence" + }, + { + "name": "investigation", + "stat": "intelligence" + }, + { + "name": "nature", + "stat": "intelligence" + }, + { + "name": "religion", + "stat": "intelligence" + }, + { + "name": "insight", + "stat": "wisdom" + }, + { + "name": "survival", + "stat": "wisdom" + }, + { + "name": "medicine", + "stat": "wisdom" + }, + { + "name": "performance", + "stat": "charisma" + }, + { + "name": "endurance", + "stat": "constitution" + } + ] + } +} +``` + +--- + +### 4. Get Difficulty Levels + +**Endpoint:** `GET /api/v1/game/difficulties` + +**Description:** Get all difficulty levels and their corresponding DC values. Used to help set appropriate check difficulties. + +**Request:** + +```bash +# httpie +http GET localhost:5000/api/v1/game/difficulties + +# curl +curl http://localhost:5000/api/v1/game/difficulties +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-23T10:30:00Z", + "result": { + "difficulties": [ + { + "name": "trivial", + "dc": 5 + }, + { + "name": "easy", + "dc": 10 + }, + { + "name": "medium", + "dc": 15 + }, + { + "name": "hard", + "dc": 20 + }, + { + "name": "very_hard", + "dc": 25 + }, + { + "name": "nearly_impossible", + "dc": 30 + } + ] + } +} +``` + +--- + +### 9. Get Character Origins + +**Endpoint:** `GET /api/v1/origins` + +**Description:** Get all available character origins (4 total). No authentication required. Used for character creation UI. Origins provide narrative backstory and starting location. + +**Request:** + +```bash +# httpie +http GET localhost:5000/api/v1/origins + +# curl +curl http://localhost:5000/api/v1/origins +``` + +**Success Response (200 OK):** + +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-15T12:00:00Z", + "result": { + "origins": [ + { + "id": "soul_revenant", + "name": "The Soul Revenant", + "description": "You died. That much you remember. The cold embrace of death, the fading light, the silence. But something—or someone—pulled you back...", + "starting_location": { + "id": "forgotten_crypt", + "name": "The Forgotten Crypt", + "description": "A crumbling stone tomb beneath a dead forest..." + }, + "narrative_hooks": [ + "Who or what brought you back from death?", + "What life did you lead before your demise?", + "Do fragments of past lives haunt your dreams?" + ], + "starting_bonus": { + "type": "stat", + "value": {"constitution": 1} + } + }, + { + "id": "memory_thief", + "name": "The Memory Thief", + "description": "You awaken with fragments of memories that aren't yours...", + "starting_location": { + "id": "thornfield_plains", + "name": "Thornfield Plains", + "description": "Endless grasslands dotted with ancient ruins..." + }, + "narrative_hooks": [ + "Whose memories do you carry?", + "Why were these memories stolen?" + ], + "starting_bonus": { + "type": "stat", + "value": {"intelligence": 1} + } + } + ], + "count": 4 + } +} +``` + +**Note:** Response includes all 4 origins: soul_revenant, memory_thief, shadow_apprentice, escaped_captive + +--- + +## Testing Workflows + +### Complete Registration Flow + +1. **Register new user:** + ```bash + http POST localhost:5000/api/v1/auth/register \ + email="newuser@example.com" \ + password="SecurePass123!" \ + name="New User" + ``` + +2. **Check email for verification link** + - Look for email from Appwrite + - Extract `userId` and `secret` from verification URL + +3. **Verify email:** + ```bash + http GET "localhost:5000/api/v1/auth/verify-email?userId=XXX&secret=YYY" + ``` + +4. **Login:** + ```bash + http --session=newuser POST localhost:5000/api/v1/auth/login \ + email="newuser@example.com" \ + password="SecurePass123!" \ + remember_me:=false + ``` + +5. **Verify session is active:** + - Session cookie should be set + - Can now access protected routes + +--- + +### Complete Password Reset Flow + +1. **Request password reset:** + ```bash + http POST localhost:5000/api/v1/auth/forgot-password \ + email="hero@example.com" + ``` + +2. **Check email for reset link** + - Look for email from Appwrite + - Extract `userId` and `secret` from reset URL + +3. **Reset password:** + ```bash + http POST localhost:5000/api/v1/auth/reset-password \ + user_id="XXX" \ + secret="YYY" \ + password="NewSecurePass123!" + ``` + +4. **Login with new password:** + ```bash + http --session=user1 POST localhost:5000/api/v1/auth/login \ + email="hero@example.com" \ + password="NewSecurePass123!" \ + remember_me:=false + ``` + +--- + +### Testing Protected Routes + +Protected routes require authentication. Test with and without session cookies. + +**Without authentication:** + +```bash +# Should return 401 Unauthorized +http POST localhost:5000/api/v1/auth/logout +``` + +**With authentication:** + +```bash +# Login first +http --session=user1 POST localhost:5000/api/v1/auth/login \ + email="hero@example.com" \ + password="SecurePass123!" + +# Now can access protected route +http --session=user1 POST localhost:5000/api/v1/auth/logout +``` + +--- + +### Complete Character Creation and Management Flow + +1. **Login first (required):** + ```bash + http --session=user1 POST localhost:5000/api/v1/auth/login \ + email="hero@example.com" \ + password="SecurePass123!" + ``` + +2. **Browse available classes:** + ```bash + http GET localhost:5000/api/v1/classes + ``` + +3. **Browse available origins:** + ```bash + http GET localhost:5000/api/v1/origins + ``` + +4. **Create a new character:** + ```bash + http --session=user1 POST localhost:5000/api/v1/characters \ + name="Thorin Ironforge" \ + class_id="vanguard" \ + origin_id="soul_revenant" + ``` + +5. **View all your characters:** + ```bash + http --session=user1 GET localhost:5000/api/v1/characters + ``` + +6. **View character details (replace char_123 with actual character_id from step 4):** + ```bash + http --session=user1 GET localhost:5000/api/v1/characters/char_123 + ``` + +7. **Unlock a skill (when character has skill points from leveling):** + ```bash + http --session=user1 POST localhost:5000/api/v1/characters/char_123/skills/unlock \ + skill_id="shield_wall" + ``` + +8. **Respec skills (when needed, costs level × 100 gold):** + ```bash + http --session=user1 POST localhost:5000/api/v1/characters/char_123/skills/respec + ``` + +9. **Delete a character:** + ```bash + http --session=user1 DELETE localhost:5000/api/v1/characters/char_123 + ``` + +--- + +## Common Errors + +### 400 Bad Request - Validation Error + +**Cause:** Invalid input data (email format, weak password, etc.) + +**Example:** + +```json +{ + "status": 400, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid input data", + "details": { + "email": "Invalid email format", + "password": "Password must be at least 8 characters long" + } + } +} +``` + +**Fix:** Check input validation requirements in error details. + +--- + +### 401 Unauthorized + +**Cause:** Not authenticated or invalid session. + +**Example:** + +```json +{ + "status": 401, + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required. Please log in." + } +} +``` + +**Fix:** Login first and include session cookie with requests. + +--- + +### 409 Conflict - User Already Exists + +**Cause:** Email already registered. + +**Example:** + +```json +{ + "status": 400, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid input data", + "details": { + "email": "An account with this email already exists" + } + } +} +``` + +**Fix:** Use a different email or login instead. + +--- + +### 500 Internal Server Error + +**Cause:** Server error (Appwrite connection, configuration issue, etc.) + +**Example:** + +```json +{ + "status": 500, + "error": { + "code": "INTERNAL_ERROR", + "message": "An internal error occurred. Please try again later." + } +} +``` + +**Fix:** Check server logs, verify Appwrite credentials, check network connectivity. + +--- + +## Testing Tips + +### Using httpie Sessions + +httpie sessions persist cookies automatically: + +```bash +# Login (saves cookies) +http --session=user1 POST localhost:5000/api/v1/auth/login \ + email="hero@example.com" \ + password="SecurePass123!" + +# Subsequent requests automatically include cookies +http --session=user1 POST localhost:5000/api/v1/auth/logout +``` + +### Using curl Cookie Jar + +```bash +# Login (save cookies) +curl -X POST http://localhost:5000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + -d '{"email": "hero@example.com", "password": "SecurePass123!"}' + +# Use cookies in next request +curl -X POST http://localhost:5000/api/v1/auth/logout \ + -b cookies.txt +``` + +### Pretty Print JSON + +```bash +# httpie (automatic) +http POST localhost:5000/api/v1/auth/register ... + +# curl (with jq) +curl ... | jq . +``` + +### Verbose Output + +```bash +# httpie +http -v POST localhost:5000/api/v1/auth/login ... + +# curl +curl -v -X POST http://localhost:5000/api/v1/auth/login ... +``` + +--- + +--- + +## Complete Testing Workflow with Game Mechanics + +Here's an end-to-end workflow including skill checks and searches: + +### 1. Setup (One-time) + +```bash +# Register a new user +http POST localhost:5000/api/v1/auth/register \ + email="adventurer@example.com" \ + password="SecurePass123!" \ + name="Brave Adventurer" + +# Login and save session +http --session=adventurer POST localhost:5000/api/v1/auth/login \ + email="adventurer@example.com" \ + password="SecurePass123!" \ + remember_me:=false +``` + +### 2. Create Character + +```bash +# Create a character +http --session=adventurer POST localhost:5000/api/v1/characters \ + name="Elara Moonwhisper" \ + class_id="arcanist" \ + origin_id="memory_thief" + +# Save the character_id from the response +``` + +### 3. Get Available Skills and Difficulties + +```bash +# Get available skills (before performing checks) +http GET localhost:5000/api/v1/game/skills + +# Get difficulty levels +http GET localhost:5000/api/v1/game/difficulties +``` + +### 4. Perform Skill Checks + +```bash +# Attempt persuasion check (charisma-based) +http --session=adventurer POST localhost:5000/api/v1/game/check \ + character_id="" \ + check_type="skill" \ + skill="persuasion" \ + difficulty="hard" \ + bonus:=1 \ + context:='{"npc_name": "Merchant"}' + +# Attempt stealth check (dexterity-based) +http --session=adventurer POST localhost:5000/api/v1/game/check \ + character_id="" \ + check_type="skill" \ + skill="stealth" \ + dc:=20 \ + bonus:=0 + +# Attempt arcana check (intelligence-based) +http --session=adventurer POST localhost:5000/api/v1/game/check \ + character_id="" \ + check_type="skill" \ + skill="arcana" \ + difficulty="medium" \ + bonus:=2 +``` + +### 5. Perform Searches + +```bash +# Search in a forest +http --session=adventurer POST localhost:5000/api/v1/game/check \ + character_id="" \ + check_type="search" \ + location_type="forest" \ + difficulty="easy" \ + bonus:=0 + +# Search in a cave +http --session=adventurer POST localhost:5000/api/v1/game/check \ + character_id="" \ + check_type="search" \ + location_type="cave" \ + dc:=13 \ + bonus:=1 + +# Search in a town +http --session=adventurer POST localhost:5000/api/v1/game/check \ + character_id="" \ + check_type="search" \ + location_type="town" \ + difficulty="hard" \ + bonus:=2 +``` + +--- + +## Next Steps + +As additional API endpoints are implemented, they will be documented here with: +- Endpoint description +- Request examples (httpie and curl) +- Response examples (success and error cases) +- Testing workflows + +Implemented endpoints: +- ✅ Authentication (`/api/v1/auth`) +- ✅ Character management (`/api/v1/characters`) +- ✅ Classes and Origins (`/api/v1/classes`, `/api/v1/origins`) +- ✅ Game Mechanics - Skill Checks and Searches (`/api/v1/game/check`) +- ✅ Game Mechanics - Skills and Difficulties Lists (`/api/v1/game/skills`, `/api/v1/game/difficulties`) + +Upcoming endpoints: +- Game sessions (`/api/v1/sessions`) +- Combat (`/api/v1/combat`) +- Marketplace (`/api/v1/marketplace`) +- Shop (`/api/v1/shop`) + +--- + +**Document Version:** 0.3.0 +**Created:** November 14, 2025 +**Last Updated:** November 23, 2025 diff --git a/api/docs/APPWRITE_SETUP.md b/api/docs/APPWRITE_SETUP.md new file mode 100644 index 0000000..2f675a2 --- /dev/null +++ b/api/docs/APPWRITE_SETUP.md @@ -0,0 +1,351 @@ +# Appwrite Setup Guide + +This guide walks you through setting up Appwrite for Code of Conquest. + +--- + +## Prerequisites + +- Appwrite Cloud account (https://cloud.appwrite.io) OR self-hosted Appwrite instance +- Admin access to create projects and collections + +--- + +## Step 1: Create Project + +1. Log in to Appwrite Console +2. Click **"Create Project"** +3. Project Name: `Code of Conquest` +4. Project ID: (auto-generated, save this for `.env`) +5. Click **"Create"** + +--- + +## Step 2: Get API Credentials + +1. In your project, go to **Settings** +2. Copy the following values: + - **Project ID** → `.env` as `APPWRITE_PROJECT_ID` + - **API Endpoint** → `.env` as `APPWRITE_ENDPOINT` (usually `https://cloud.appwrite.io/v1`) +3. Go to **Settings** → **API Keys** +4. Click **"Create API Key"** + - Name: `Backend Server` + - Expiration: Never (or long-term) + - Scopes: Select ALL scopes (for development) +5. Copy the generated API key → `.env` as `APPWRITE_API_KEY` + +--- + +## Step 3: Create Database + +1. Go to **Databases** in the left sidebar +2. Click **"Create Database"** +3. Database ID: `main` +4. Name: `Main Database` +5. Click **"Create"** + +--- + +## Step 4: Create Collections + +Create the following collections in the `main` database. + +### Collection 1: characters + +| Setting | Value | +|---------|-------| +| Collection ID | `characters` | +| Collection Name | `Characters` | + +**Attributes:** + +| Key | Type | Size | Required | Default | Array | +|-----|------|------|----------|---------|-------| +| `userId` | String | 255 | Yes | - | No | +| `characterData` | String | 100000 | Yes | - | No | +| `created_at` | DateTime | - | Yes | - | No | +| `updated_at` | DateTime | - | Yes | - | No | +| `is_active` | Boolean | - | Yes | true | No | + +**Indexes:** + +| Key | Type | Attributes | +|-----|------|------------| +| `userId_index` | Key | `userId` (ASC) | +| `active_index` | Key | `is_active` (ASC) | + +**Permissions:** +- Create: `users` +- Read: `users` +- Update: `users` +- Delete: `users` + +### Collection 2: game_sessions + +| Setting | Value | +|---------|-------| +| Collection ID | `game_sessions` | +| Collection Name | `Game Sessions` | + +**Attributes:** + +| Key | Type | Size | Required | Default | Array | +|-----|------|------|----------|---------|-------| +| `party_member_ids` | String | 255 | Yes | - | Yes | +| `config` | String | 5000 | Yes | - | No | +| `combat_encounter` | String | 50000 | No | - | No | +| `conversation_history` | String | 500000 | Yes | - | No | +| `game_state` | String | 50000 | Yes | - | No | +| `turn_order` | String | 255 | Yes | - | Yes | +| `current_turn` | Integer | - | Yes | 0 | No | +| `turn_number` | Integer | - | Yes | 1 | No | +| `created_at` | DateTime | - | Yes | - | No | +| `last_activity` | DateTime | - | Yes | - | No | +| `status` | String | 50 | Yes | active | No | + +**Indexes:** + +| Key | Type | Attributes | +|-----|------|------------| +| `status_index` | Key | `status` (ASC) | +| `last_activity_index` | Key | `last_activity` (DESC) | + +**Permissions:** +- Create: `users` +- Read: `users` +- Update: `users` +- Delete: `users` + +### Collection 3: marketplace_listings + +| Setting | Value | +|---------|-------| +| Collection ID | `marketplace_listings` | +| Collection Name | `Marketplace Listings` | + +**Attributes:** + +| Key | Type | Size | Required | Default | Array | +|-----|------|------|----------|---------|-------| +| `seller_id` | String | 255 | Yes | - | No | +| `character_id` | String | 255 | Yes | - | No | +| `item_data` | String | 10000 | Yes | - | No | +| `listing_type` | String | 50 | Yes | - | No | +| `price` | Integer | - | No | - | No | +| `starting_bid` | Integer | - | No | - | No | +| `current_bid` | Integer | - | No | - | No | +| `buyout_price` | Integer | - | No | - | No | +| `bids` | String | 50000 | No | - | No | +| `auction_end` | DateTime | - | No | - | No | +| `status` | String | 50 | Yes | active | No | +| `created_at` | DateTime | - | Yes | - | No | + +**Indexes:** + +| Key | Type | Attributes | +|-----|------|------------| +| `listing_type_index` | Key | `listing_type` (ASC) | +| `status_index` | Key | `status` (ASC) | +| `seller_index` | Key | `seller_id` (ASC) | +| `auction_end_index` | Key | `auction_end` (ASC) | + +**Permissions:** +- Create: `users` +- Read: `any` (public can browse) +- Update: `users` (owner only, enforced in code) +- Delete: `users` (owner only, enforced in code) + +### Collection 4: transactions + +| Setting | Value | +|---------|-------| +| Collection ID | `transactions` | +| Collection Name | `Transactions` | + +**Attributes:** + +| Key | Type | Size | Required | Default | Array | +|-----|------|------|----------|---------|-------| +| `buyer_id` | String | 255 | Yes | - | No | +| `seller_id` | String | 255 | Yes | - | No | +| `listing_id` | String | 255 | No | - | No | +| `item_data` | String | 10000 | Yes | - | No | +| `price` | Integer | - | Yes | - | No | +| `timestamp` | DateTime | - | Yes | - | No | +| `transaction_type` | String | 50 | Yes | - | No | + +**Indexes:** + +| Key | Type | Attributes | +|-----|------|------------| +| `buyer_index` | Key | `buyer_id` (ASC) | +| `seller_index` | Key | `seller_id` (ASC) | +| `timestamp_index` | Key | `timestamp` (DESC) | + +**Permissions:** +- Create: System only (API key) +- Read: `users` (buyer/seller only, enforced in code) +- Update: None +- Delete: None + +--- + +## Step 5: Enable Realtime + +1. Go to **Settings** → **Realtime** +2. Enable **Realtime API** +3. Add allowed origins: + - `http://localhost:5000` (development) + - Your production domain (when ready) + +--- + +## Step 6: Configure Authentication + +1. Go to **Auth** in the left sidebar +2. Enable **Email/Password** authentication +3. Configure password requirements: + - Minimum length: 8 + - Require lowercase: Yes + - Require uppercase: Yes + - Require numbers: Yes + - Require special characters: Optional + +**Optional:** Enable OAuth providers if desired (Google, GitHub, etc.) + +--- + +## Step 7: Update .env File + +Copy `.env.example` to `.env` and fill in the values: + +```bash +cp .env.example .env +``` + +Update the following in `.env`: + +```bash +APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +APPWRITE_PROJECT_ID=your-project-id-here +APPWRITE_API_KEY=your-api-key-here +APPWRITE_DATABASE_ID=main +``` + +--- + +## Step 8: Test Connection + +Create a test script to verify Appwrite connection: + +**test_appwrite.py:** + +```python +from appwrite.client import Client +from appwrite.services.databases import Databases +from dotenv import load_dotenv +import os + +load_dotenv() + +# Initialize Appwrite client +client = Client() +client.set_endpoint(os.getenv('APPWRITE_ENDPOINT')) +client.set_project(os.getenv('APPWRITE_PROJECT_ID')) +client.set_key(os.getenv('APPWRITE_API_KEY')) + +# Test database connection +databases = Databases(client) + +try: + # List collections + result = databases.list_collections( + database_id=os.getenv('APPWRITE_DATABASE_ID') + ) + print(f"✓ Connected to Appwrite successfully!") + print(f"✓ Found {result['total']} collections:") + for collection in result['collections']: + print(f" - {collection['name']} (ID: {collection['$id']})") +except Exception as e: + print(f"✗ Failed to connect to Appwrite:") + print(f" Error: {str(e)}") +``` + +Run: +```bash +source venv/bin/activate +pip install appwrite python-dotenv +python test_appwrite.py +``` + +--- + +## Security Best Practices + +### Production Permissions + +**For production, tighten permissions:** + +1. **characters collection:** + - Users can only access their own characters (enforce in code) + - Add server-side checks for `userId` match + +2. **game_sessions collection:** + - Party members can read (enforce in code) + - Active player can write (enforce in code) + +3. **marketplace_listings collection:** + - Anyone can read (browsing) + - Only owner can update/delete (enforce in code) + +4. **transactions collection:** + - Create via API key only + - Users can read only their own transactions (enforce in code) + +### API Key Security + +- **Never commit** `.env` file to git +- Use different API keys for dev/staging/production +- Rotate API keys periodically +- Use minimal scopes in production (not "all") + +--- + +## Troubleshooting + +### Common Issues + +**Issue:** "Project not found" +- Solution: Verify `APPWRITE_PROJECT_ID` matches the project ID in Appwrite Console + +**Issue:** "Invalid API key" +- Solution: Regenerate API key and update `.env` + +**Issue:** "Collection not found" +- Solution: Verify collection IDs match exactly (case-sensitive) + +**Issue:** "Permission denied" +- Solution: Check collection permissions in Appwrite Console + +**Issue:** "Realtime not working" +- Solution: Verify Realtime is enabled and origins are configured + +--- + +## Next Steps + +After completing Appwrite setup: + +1. ✅ Install dependencies: `pip install -r requirements.txt` +2. ✅ Test connection with `test_appwrite.py` +3. ✅ Create Appwrite service wrapper (`app/services/appwrite_service.py`) +4. Start building API endpoints (Phase 2) + +--- + +## Resources + +- **Appwrite Documentation:** https://appwrite.io/docs +- **Appwrite Python SDK:** https://github.com/appwrite/sdk-for-python +- **Appwrite Console:** https://cloud.appwrite.io +- **Appwrite Discord:** https://appwrite.io/discord diff --git a/api/docs/DATA_MODELS.md b/api/docs/DATA_MODELS.md new file mode 100644 index 0000000..49958b5 --- /dev/null +++ b/api/docs/DATA_MODELS.md @@ -0,0 +1,1182 @@ +# Data Models + +All data models use **Python dataclasses** serialized to JSON for storage in Appwrite. + +--- + +## Type System (Enums) + +All enum types are defined in `/app/models/enums.py` for type safety throughout the application. + +### EffectType + +| Value | Description | +|-------|-------------| +| `BUFF` | Temporarily increase stats | +| `DEBUFF` | Temporarily decrease stats | +| `DOT` | Damage over time (poison, bleed, burn) | +| `HOT` | Heal over time (regeneration) | +| `STUN` | Prevent actions (skip turn) | +| `SHIELD` | Absorb damage before HP loss | + +### DamageType + +| Value | Description | +|-------|-------------| +| `PHYSICAL` | Standard weapon damage | +| `FIRE` | Fire-based magic damage | +| `ICE` | Ice-based magic damage | +| `LIGHTNING` | Lightning-based magic damage | +| `HOLY` | Holy/divine damage | +| `SHADOW` | Dark/shadow magic damage | +| `POISON` | Poison damage (usually DoT) | + +### ItemType + +| Value | Description | +|-------|-------------| +| `WEAPON` | Adds damage, may have special effects | +| `ARMOR` | Adds defense/resistance | +| `CONSUMABLE` | One-time use (potions, scrolls) | +| `QUEST_ITEM` | Story-related, non-tradeable | + +### StatType + +| Value | Description | +|-------|-------------| +| `STRENGTH` | Physical power | +| `DEXTERITY` | Agility and precision | +| `CONSTITUTION` | Endurance and health | +| `INTELLIGENCE` | Magical power | +| `WISDOM` | Perception and insight | +| `CHARISMA` | Social influence | + +### AbilityType + +| Value | Description | +|-------|-------------| +| `ATTACK` | Basic physical attack | +| `SPELL` | Magical spell | +| `SKILL` | Special class ability | +| `ITEM_USE` | Using a consumable item | +| `DEFEND` | Defensive action | + +### CombatStatus + +| Value | Description | +|-------|-------------| +| `ACTIVE` | Combat is ongoing | +| `VICTORY` | Player(s) won | +| `DEFEAT` | Player(s) lost | +| `FLED` | Player(s) escaped | + +### SessionStatus + +| Value | Description | +|-------|-------------| +| `ACTIVE` | Session is ongoing | +| `COMPLETED` | Session ended normally | +| `TIMEOUT` | Session ended due to inactivity | + +### ListingType & ListingStatus + +**ListingType:** +- `AUCTION` - Bidding system +- `FIXED_PRICE` - Immediate purchase at set price + +**ListingStatus:** +- `ACTIVE` - Listing is live +- `SOLD` - Item has been sold +- `EXPIRED` - Listing time ran out +- `REMOVED` - Seller cancelled listing + +### LocationType + +Types of locations in the game world (defined in both `enums.py` and `action_prompt.py`). + +| Value | Description | +|-------|-------------| +| `TOWN` | Populated settlements | +| `TAVERN` | Taverns and inns | +| `WILDERNESS` | Outdoor areas, forests, fields | +| `DUNGEON` | Dungeons and caves | +| `RUINS` | Ancient ruins | +| `LIBRARY` | Libraries and archives | +| `SAFE_AREA` | Protected zones, temples | + +--- + +## Location System + +Locations define the game world structure. They are loaded from YAML files at runtime via `LocationLoader`. + +### Location + +Represents a defined location in the game world. + +| Field | Type | Description | +|-------|------|-------------| +| `location_id` | str | Unique identifier (e.g., "crossville_tavern") | +| `name` | str | Display name (e.g., "The Rusty Anchor Tavern") | +| `location_type` | LocationType | Type (town, tavern, wilderness, dungeon, etc.) | +| `region_id` | str | Parent region this location belongs to | +| `description` | str | Full description for AI narrative context | +| `lore` | Optional[str] | Historical/background information | +| `ambient_description` | Optional[str] | Atmospheric details for AI narration | +| `available_quests` | List[str] | Quest IDs discoverable at this location | +| `npc_ids` | List[str] | NPC IDs present at this location | +| `discoverable_locations` | List[str] | Location IDs that can be revealed from here | +| `is_starting_location` | bool | Whether valid for new character spawn | +| `tags` | List[str] | Metadata tags for filtering/categorization | + +**Methods:** +- `to_dict()` - Serialize for JSON responses +- `to_story_dict()` - Trimmed version for AI prompts (reduces token usage) +- `from_dict(data)` - Deserialize from YAML/JSON + +**YAML Format:** +```yaml +location_id: "crossville_tavern" +name: "The Rusty Anchor Tavern" +location_type: "tavern" +region_id: "crossville" +description: "A cozy tavern known for its hearty stew and warm atmosphere." +lore: "Built fifty years ago by a retired sailor." +ambient_description: "The smell of roasting meat and spilled ale fills the air." +available_quests: + - "quest_rats_tavern" +npc_ids: + - "npc_grom_001" + - "npc_elara_001" +discoverable_locations: + - "crossville_market" + - "crossville_forest_path" +is_starting_location: false +tags: + - "social" + - "rest" +``` + +### Region + +Represents a geographical region containing multiple locations. + +| Field | Type | Description | +|-------|------|-------------| +| `region_id` | str | Unique identifier (e.g., "crossville") | +| `name` | str | Display name (e.g., "Crossville Province") | +| `description` | str | Region overview and atmosphere | +| `location_ids` | List[str] | All location IDs in this region | + +**YAML Format:** +```yaml +region_id: "crossville" +name: "Crossville Province" +description: "A peaceful farming region on the edge of the kingdom." +location_ids: + - "crossville_village" + - "crossville_tavern" + - "crossville_market" +``` + +### LocationLoader Service + +Singleton service that loads and caches location/region data from YAML files. + +**Location:** `/app/services/location_loader.py` + +**Usage:** +```python +from app.services.location_loader import get_location_loader + +loader = get_location_loader() + +# Get specific location +location = loader.get_location("crossville_tavern") + +# Get all locations in a region +locations = loader.get_locations_by_region("crossville") + +# Get starting locations for new characters +starting_locations = loader.get_starting_locations() + +# Get connected locations for travel +available = loader.get_discoverable_from("crossville_village") +``` + +**Data Files:** +- `/app/data/regions/crossville.yaml` - Region definition with locations + +--- + +## NPC System + +NPCs are persistent non-player characters with rich personality, knowledge, and interaction tracking. They are loaded from YAML files via `NPCLoader`. + +### NPC + +Main NPC definition with personality and dialogue data for AI generation. + +| Field | Type | Description | +|-------|------|-------------| +| `npc_id` | str | Unique identifier (e.g., "npc_grom_001") | +| `name` | str | Display name (e.g., "Grom Ironbeard") | +| `role` | str | NPC's job/title (e.g., "bartender", "blacksmith") | +| `location_id` | str | ID of location where NPC resides | +| `personality` | NPCPersonality | Personality traits and speech patterns | +| `appearance` | NPCAppearance | Physical description | +| `knowledge` | Optional[NPCKnowledge] | What the NPC knows (public and secret) | +| `relationships` | List[NPCRelationship] | How NPC feels about other NPCs | +| `inventory_for_sale` | List[NPCInventoryItem] | Items NPC sells (if merchant) | +| `dialogue_hooks` | Optional[NPCDialogueHooks] | Pre-defined dialogue snippets | +| `quest_giver_for` | List[str] | Quest IDs this NPC can give | +| `reveals_locations` | List[str] | Location IDs this NPC can unlock | +| `tags` | List[str] | Metadata tags (e.g., "merchant", "quest_giver") | + +**Methods:** +- `to_dict()` - Serialize for JSON responses +- `to_story_dict()` - Trimmed version for AI dialogue prompts +- `from_dict(data)` - Deserialize from YAML/JSON + +### NPCPersonality + +Personality traits for AI dialogue generation. + +| Field | Type | Description | +|-------|------|-------------| +| `traits` | List[str] | Personality descriptors (e.g., "gruff", "kind", "suspicious") | +| `speech_style` | str | How the NPC speaks (accent, vocabulary, patterns) | +| `quirks` | List[str] | Distinctive behaviors or habits | + +### NPCAppearance + +Physical description for AI narration. + +| Field | Type | Description | +|-------|------|-------------| +| `brief` | str | Short one-line description for lists | +| `detailed` | Optional[str] | Longer description for detailed encounters | + +### NPCKnowledge + +Knowledge an NPC possesses - public and conditionally revealed. + +| Field | Type | Description | +|-------|------|-------------| +| `public` | List[str] | Knowledge NPC freely shares with anyone | +| `secret` | List[str] | Hidden knowledge (for AI reference only) | +| `will_share_if` | List[NPCKnowledgeCondition] | Conditional reveals based on interaction | + +### NPCKnowledgeCondition + +Condition for revealing secret knowledge. + +| Field | Type | Description | +|-------|------|-------------| +| `condition` | str | Expression (e.g., "interaction_count >= 3") | +| `reveals` | str | Information revealed when condition is met | + +### NPCDialogueHooks + +Pre-defined dialogue snippets for consistent NPC voice. + +| Field | Type | Description | +|-------|------|-------------| +| `greeting` | Optional[str] | What NPC says when first addressed | +| `farewell` | Optional[str] | What NPC says when conversation ends | +| `busy` | Optional[str] | What NPC says when occupied | +| `quest_complete` | Optional[str] | What NPC says when player completes their quest | + +### NPCRelationship + +NPC-to-NPC relationship for dialogue context. + +| Field | Type | Description | +|-------|------|-------------| +| `npc_id` | str | The other NPC's identifier | +| `attitude` | str | Feeling (e.g., "friendly", "distrustful") | +| `reason` | Optional[str] | Explanation for the attitude | + +### NPCInventoryItem + +Item available for purchase from merchant NPCs. + +| Field | Type | Description | +|-------|------|-------------| +| `item_id` | str | Reference to item definition | +| `price` | int | Cost in gold | +| `quantity` | Optional[int] | Stock count (None = unlimited) | + +### NPCInteractionState + +Tracks a character's interaction history with an NPC. Stored on Character record. + +| Field | Type | Description | +|-------|------|-------------| +| `npc_id` | str | The NPC this state tracks | +| `first_met` | str | ISO timestamp of first interaction | +| `last_interaction` | str | ISO timestamp of most recent interaction | +| `interaction_count` | int | Total number of conversations | +| `revealed_secrets` | List[int] | Indices of secrets revealed | +| `relationship_level` | int | 0-100 scale (50 is neutral) | +| `custom_flags` | Dict[str, Any] | Arbitrary flags for special conditions | +| `dialogue_history` | List[Dict] | Recent conversation exchanges (max 10 per NPC) | + +**Dialogue History Entry Format:** +```json +{ + "player_line": "What have you heard about the old mines?", + "npc_response": "Aye, strange noises coming from there lately...", + "timestamp": "2025-11-24T10:30:00Z" +} +``` + +The dialogue history enables bidirectional NPC conversations - players can respond to NPC dialogue and continue conversations with context. The system maintains the last 10 exchanges per NPC to provide conversation context without excessive storage. + +**Relationship Levels:** +- 0-20: Hostile +- 21-40: Unfriendly +- 41-60: Neutral +- 61-80: Friendly +- 81-100: Trusted + +**Example NPC YAML:** +```yaml +npc_id: "npc_grom_001" +name: "Grom Ironbeard" +role: "bartender" +location_id: "crossville_tavern" +personality: + traits: + - "gruff" + - "honest" + - "protective of locals" + speech_style: "Short sentences, occasional dwarven expressions" + quirks: + - "Polishes same glass repeatedly when nervous" + - "Refuses to serve anyone who insults his stew" +appearance: + brief: "A stocky dwarf with a magnificent iron-grey beard" + detailed: "A weathered dwarf standing about four feet tall..." +knowledge: + public: + - "The tavern was built by his grandfather" + - "Knows most travelers who pass through" + secret: + - "Saw strange lights in the forest last week" + will_share_if: + - condition: "relationship_level >= 70" + reveals: "Has heard rumors of goblins gathering in the old mines" +dialogue_hooks: + greeting: "Welcome to the Rusty Anchor! What'll it be?" + farewell: "Safe travels, friend." + busy: "Can't talk now, got orders to fill." +inventory_for_sale: + - item: "ale" + price: 2 + - item: "hearty_stew" + price: 5 +quest_giver_for: + - "quest_rats_tavern" +reveals_locations: + - "crossville_old_mines" +tags: + - "merchant" + - "quest_giver" + - "information" +``` + +### NPCLoader Service + +Singleton service that loads and caches NPC data from YAML files. + +**Location:** `/app/services/npc_loader.py` + +**Usage:** +```python +from app.services.npc_loader import get_npc_loader + +loader = get_npc_loader() + +# Get specific NPC +npc = loader.get_npc("npc_grom_001") + +# Get all NPCs at a location +npcs = loader.get_npcs_at_location("crossville_tavern") + +# Get NPCs by tag +merchants = loader.get_npcs_by_tag("merchant") +``` + +**Data Files:** +- `/app/data/npcs/crossville_npcs.yaml` - NPCs for Crossville region + +--- + +## Character System + +### Stats + +| Field | Type | Description | +|-------|------|-------------| +| `strength` | int | Physical power | +| `dexterity` | int | Agility and precision | +| `constitution` | int | Endurance and health | +| `intelligence` | int | Magical power | +| `wisdom` | int | Perception and insight | +| `charisma` | int | Social influence | + +**Derived Properties (Computed):** +- `hit_points` = 10 + (constitution × 2) +- `mana_points` = 10 + (intelligence × 2) +- `defense` = constitution // 2 (physical damage reduction) +- `resistance` = wisdom // 2 (magical damage reduction) + +**Note:** Defense and resistance are computed properties, not stored values. They are calculated on-the-fly from constitution and wisdom. + +### SkillNode + +| Field | Type | Description | +|-------|------|-------------| +| `skill_id` | str | Unique identifier | +| `name` | str | Display name | +| `description` | str | What the skill does | +| `tier` | int | 1-5 (1=basic, 5=master) | +| `prerequisites` | List[str] | Required skill_ids | +| `effects` | Dict | Stat bonuses, abilities unlocked | +| `unlocked` | bool | Current unlock status | + +**Effect Types:** +- Passive bonuses (permanent stat increases) +- Active abilities (new spells/skills to use) +- Unlocks (access to equipment types or features) + +### SkillTree + +| Field | Type | Description | +|-------|------|-------------| +| `tree_id` | str | Unique identifier | +| `name` | str | Tree name | +| `description` | str | Tree theme | +| `nodes` | List[SkillNode] | All nodes in tree | + +**Methods:** +- `can_unlock(skill_id, unlocked_skills)` - Check if prerequisites met + +**Progression Rules:** +- Must unlock tier 1 before accessing tier 2 +- Some nodes have prerequisites within same tier +- 1 skill point earned per level +- Respec available (costs gold, scales with level) + +### PlayerClass + +| Field | Type | Description | +|-------|------|-------------| +| `class_id` | str | Unique identifier | +| `name` | str | Class name | +| `description` | str | Class theme | +| `base_stats` | Stats | Starting stats | +| `skill_trees` | List[SkillTree] | 2+ skill trees | +| `starting_equipment` | List[str] | Starting item IDs | + +### Initial 8 Player Classes + +| Class | Theme | Skill Tree 1 | Skill Tree 2 | +|-------|-------|--------------|--------------| +| **Vanguard** | Tank/melee | Defensive (shields, armor, taunts) | Offensive (weapon mastery, heavy strikes) | +| **Assassin** | Stealth/critical | Assassination (critical hits, poisons) | Shadow (stealth, evasion) | +| **Arcanist** | Elemental spells | Pyromancy (fire spells, DoT) | Cryomancy (ice spells, crowd control) | +| **Luminary** | Healing/support | Holy (healing, buffs) | Divine Wrath (smite, undead damage) | +| **Wildstrider** | Ranged/nature | Marksmanship (bow skills, critical shots) | Beast Master (pet companion, nature magic) | +| **Oathkeeper** | Hybrid tank/healer | Protection (defensive auras, healing) | Retribution (holy damage, smites) | +| **Necromancer** | Death magic/summon | Dark Arts (curses, life drain) | Summoning (undead minions) | +| **Lorekeeper** | Support/control | Performance (buffs, debuffs via music) | Trickery (illusions, charm) | + +**Extensibility:** Class system designed to easily add more classes in future updates. + +### Item + +| Field | Type | Description | +|-------|------|-------------| +| `item_id` | str | Unique identifier | +| `name` | str | Item name | +| `item_type` | str | weapon, armor, consumable, quest_item | +| `stats` | Dict[str, int] | {"damage": 10, "defense": 5} | +| `effects` | List[Effect] | Buffs/debuffs on use/equip | +| `value` | int | Gold value | +| `description` | str | Item lore/description | +| `is_tradeable` | bool | Can be sold on marketplace | + +**Item Types:** +- **Weapon:** Adds damage, may have special effects +- **Armor:** Adds defense/resistance +- **Consumable:** One-time use (potions, scrolls) +- **Quest Item:** Story-related, non-tradeable + +### Ability + +Abilities represent actions that can be taken in combat (attacks, spells, skills, item uses). + +| Field | Type | Description | +|-------|------|-------------| +| `ability_id` | str | Unique identifier | +| `name` | str | Display name | +| `description` | str | What the ability does | +| `ability_type` | AbilityType | ATTACK, SPELL, SKILL, ITEM_USE, DEFEND | +| `base_power` | int | Base damage or healing value | +| `damage_type` | DamageType | Type of damage dealt (if applicable) | +| `scaling_stat` | StatType | Which stat scales this ability's power | +| `scaling_factor` | float | Multiplier for scaling stat (default 0.5) | +| `mana_cost` | int | MP required to use this ability | +| `cooldown` | int | Turns before ability can be used again | +| `effects_applied` | List[Effect] | Effects applied to target(s) on hit | +| `is_aoe` | bool | Whether this affects multiple targets | +| `target_count` | int | Number of targets if AoE (0 = all) | + +**Damage/Healing Calculation:** +``` +Final Power = base_power + (scaling_stat × scaling_factor) +Minimum power is always 1 +``` + +**Example:** +- Fireball: base_power=30, scaling_stat=INTELLIGENCE, scaling_factor=0.5 +- If caster has 16 intelligence: 30 + (16 × 0.5) = 38 power + +**Methods:** +- `calculate_power(caster_stats)` - Calculate final power based on caster's stats +- `get_effects_to_apply()` - Get copies of effects to apply to targets + +### AbilityLoader + +Abilities are loaded from YAML configuration files in `/app/data/abilities/` for data-driven game design. + +**YAML Format:** +```yaml +ability_id: "fireball" +name: "Fireball" +description: "Hurl a ball of fire at enemies" +ability_type: "spell" +base_power: 30 +damage_type: "fire" +scaling_stat: "intelligence" +scaling_factor: 0.5 +mana_cost: 15 +cooldown: 0 +is_aoe: false +target_count: 1 +effects_applied: + - effect_id: "burn" + name: "Burning" + effect_type: "dot" + duration: 3 + power: 5 + max_stacks: 3 +``` + +**Usage:** +```python +from app.models.abilities import AbilityLoader + +loader = AbilityLoader() +fireball = loader.load_ability("fireball") +power = fireball.calculate_power(caster_stats) +``` + +**Benefits:** +- Game designers can add/modify abilities without code changes +- Easy balancing and iteration +- Version control friendly (text files) +- Hot-reloading capable + +### Character + +| Field | Type | Description | +|-------|------|-------------| +| `character_id` | str | Unique identifier | +| `user_id` | str | Owner user ID | +| `name` | str | Character name | +| `player_class` | PlayerClass | Character class | +| `level` | int | Current level | +| `experience` | int | XP points | +| `stats` | Stats | Current stats | +| `unlocked_skills` | List[str] | Unlocked skill_ids | +| `inventory` | List[Item] | All items | +| `equipped` | Dict[str, Item] | {"weapon": Item, "armor": Item} | +| `gold` | int | Currency | +| `active_quests` | List[str] | Quest IDs | +| `discovered_locations` | List[str] | Location IDs | + +**Methods:** +- `to_dict()` - Serialize to dictionary for JSON storage +- `from_dict(data)` - Deserialize from dictionary +- `get_effective_stats(active_effects)` - **THE CRITICAL METHOD** - Calculate final stats + +**get_effective_stats() Details:** + +This is the **single source of truth** for all stat calculations in the game. It combines modifiers from all sources in this order: + +```python +def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats: + """ + Calculate final effective stats from all sources: + 1. Base stats (from character) + 2. Equipment bonuses (from equipped items) + 3. Skill tree bonuses (from unlocked skills) + 4. Active effect modifiers (buffs/debuffs from combat) + + Returns fully typed Stats object with all modifiers applied. + Debuffs are clamped to minimum stat value of 1. + """ +``` + +**Example Calculation:** +- Base strength: 12 +- Equipped weapon bonus: +5 strength +- Unlocked skill bonus: +5 strength +- Active buff effect: +3 strength +- **Final effective strength: 25** + +**Important Notes:** +- Defense and resistance are calculated from final constitution/wisdom +- Debuffs cannot reduce stats below 1 (minimum clamping) +- Equipment stat_bonuses dictionary: `{"strength": 5, "constitution": 3}` +- Skill effects dictionary: `{"strength": 5}` extracted from unlocked skills + +--- + +## Story Progression System + +### ActionPrompt + +Represents a button-based action prompt available to players during story progression turns. + +| Field | Type | Description | +|-------|------|-------------| +| `prompt_id` | str | Unique identifier (e.g., "ask_surroundings") | +| `category` | str | Action category: "ask", "travel", "gather" | +| `display_text` | str | Button text shown to player | +| `description` | str | Tooltip/help text | +| `tier_required` | str | Minimum tier: "free", "basic", "premium", "elite" | +| `context_filter` | Optional[str] | Where action is available: "town", "wilderness", "any" | +| `dm_prompt_template` | str | Jinja2 template for AI prompt generation | + +**Methods:** +- `is_available(user_tier, location_type) -> bool` - Check if action available to user + +**YAML Format:** +```yaml +prompt_id: "ask_surroundings" +category: "ask" +display_text: "What do I see around me?" +description: "Get a description of your current surroundings" +tier_required: "free" +context_filter: "any" +dm_prompt_template: | + The player is currently in {{ location_name }}. + Describe what they see, hear, and sense around them. +``` + +**Tier-Based Availability:** +- **Free tier**: 4 basic actions (ask surroundings, check dangers, travel, explore) +- **Premium tier**: +3 actions (recall memory, ask around, visit tavern) +- **Elite tier**: +3 actions (search secrets, seek elder, chart course) +- **Premium/Elite**: Custom free-form input (250/500 char limits) + +**Loading:** +Actions are loaded from `/app/data/action_prompts.yaml` via `ActionPromptLoader` service. + +### AI Response Parser + +Data structures for parsing structured game actions from AI narrative responses. + +#### ParsedAIResponse + +Complete parsed AI response with narrative and game state changes. + +| Field | Type | Description | +|-------|------|-------------| +| `narrative` | str | The narrative text to display to player | +| `game_changes` | GameStateChanges | Structured game state changes | +| `raw_response` | str | Original unparsed response | +| `parse_success` | bool | Whether parsing succeeded | +| `parse_errors` | List[str] | Any errors encountered | + +#### GameStateChanges + +Structured game state changes extracted from AI response. + +| Field | Type | Description | +|-------|------|-------------| +| `items_given` | List[ItemGrant] | Items to add to player inventory | +| `items_taken` | List[str] | Item IDs to remove | +| `gold_given` | int | Gold to add to player | +| `gold_taken` | int | Gold to remove from player | +| `experience_given` | int | XP to award player | +| `quest_offered` | Optional[str] | Quest ID to offer | +| `quest_completed` | Optional[str] | Quest ID completed | +| `location_change` | Optional[str] | New location ID | + +#### ItemGrant + +Represents an item granted by the AI during gameplay. + +| Field | Type | Description | +|-------|------|-------------| +| `item_id` | Optional[str] | ID for existing items from registry | +| `name` | Optional[str] | Name for generic items | +| `item_type` | Optional[str] | Type: weapon, armor, consumable, quest_item | +| `description` | Optional[str] | Description for generic items | +| `value` | int | Gold value (default 0) | +| `quantity` | int | Number of items (default 1) | + +**Methods:** +- `is_existing_item() -> bool` - Check if references existing item +- `is_generic_item() -> bool` - Check if AI-generated generic item + +**Files:** +- Parser: `/app/ai/response_parser.py` +- Validator: `/app/services/item_validator.py` +- Templates: `/app/data/generic_items.yaml` + +--- + +## Quest System + +### Quest + +Represents a quest with objectives and rewards. + +| Field | Type | Description | +|-------|------|-------------| +| `quest_id` | str | Unique identifier (e.g., "quest_rats_tavern") | +| `name` | str | Display name (e.g., "Rat Problem") | +| `description` | str | Full quest description | +| `quest_giver` | str | NPC or source name | +| `difficulty` | str | "easy", "medium", "hard", "epic" | +| `objectives` | List[QuestObjective] | List of objectives to complete | +| `rewards` | QuestReward | Rewards for completion | +| `offering_triggers` | QuestTriggers | When/where quest can be offered | +| `narrative_hooks` | List[str] | Story snippets for AI to use | +| `status` | str | "available", "active", "completed", "failed" | +| `progress` | Dict[str, Any] | Objective progress tracking | + +**Methods:** +- `is_complete() -> bool` - Check if all objectives completed +- `get_next_objective() -> Optional[QuestObjective]` - Get next incomplete objective +- `update_progress(objective_id, progress_value) -> None` - Update objective progress +- `to_dict() / from_dict()` - Serialization for JSON storage + +**YAML Format:** +```yaml +quest_id: "quest_rats_tavern" +name: "Rat Problem" +description: "Clear giant rats from the tavern basement" +quest_giver: "Tavern Keeper" +difficulty: "easy" +objectives: + - objective_id: "kill_rats" + description: "Kill 10 giant rats" + objective_type: "kill" + required_progress: 10 +rewards: + gold: 50 + experience: 100 + items: [] +offering_triggers: + location_types: ["town"] + min_character_level: 1 + max_character_level: 3 + probability_weights: + town: 0.30 + wilderness: 0.0 +narrative_hooks: + - "The tavern keeper waves you over, mentioning strange noises from the basement." +``` + +### QuestObjective + +Represents a single objective within a quest. + +| Field | Type | Description | +|-------|------|-------------| +| `objective_id` | str | Unique ID (e.g., "kill_rats") | +| `description` | str | Player-facing description | +| `objective_type` | str | "kill", "collect", "travel", "interact", "discover" | +| `required_progress` | int | Target value (e.g., 10 rats) | +| `current_progress` | int | Current value (e.g., 5 rats killed) | +| `completed` | bool | Objective completion status | + +**Objective Types:** +- **kill**: Defeat X enemies +- **collect**: Gather X items +- **travel**: Reach a specific location +- **interact**: Talk to NPCs or interact with objects +- **discover**: Find new locations or secrets + +### QuestReward + +Rewards granted upon quest completion. + +| Field | Type | Description | +|-------|------|-------------| +| `gold` | int | Gold reward | +| `experience` | int | XP reward (may trigger level up) | +| `items` | List[str] | Item IDs to grant | +| `reputation` | Optional[str] | Reputation faction (future feature) | + +### QuestTriggers + +Defines when and where a quest can be offered. + +| Field | Type | Description | +|-------|------|-------------| +| `location_types` | List[str] | ["town", "wilderness", "dungeon"] or ["any"] | +| `specific_locations` | List[str] | Specific location IDs or empty for any | +| `min_character_level` | int | Minimum level required | +| `max_character_level` | int | Maximum level (for scaling) | +| `required_quests_completed` | List[str] | Quest prerequisites | +| `probability_weights` | Dict[str, float] | Location-specific offering chances | + +**Methods:** +- `get_offer_probability(location_type) -> float` - Get probability for location type +- `can_offer(character_level, location, location_type, completed_quests) -> bool` - Check if quest can be offered + +**Quest Offering Logic:** +1. **Location-based roll**: Towns (30%), Taverns (35%), Wilderness (5%), Dungeons (10%) +2. **Filter eligible quests**: Level requirements, location match, prerequisites met +3. **Context-aware selection**: AI analyzes narrative context to select fitting quest +4. **Max 2 active quests**: Limit enforced to prevent player overwhelm + +**Quest Storage:** +Quests are defined in YAML files in `/app/data/quests/` organized by difficulty: +- `/app/data/quests/easy/` - Levels 1-3 +- `/app/data/quests/medium/` - Levels 3-7 +- `/app/data/quests/hard/` - Levels 10+ +- `/app/data/quests/epic/` - End-game content + +--- + +## Combat System + +### Effect + +Effects are temporary status modifiers applied to combatants during combat. + +| Field | Type | Description | +|-------|------|-------------| +| `effect_id` | str | Unique identifier | +| `name` | str | Effect name | +| `effect_type` | EffectType | BUFF, DEBUFF, DOT, HOT, STUN, SHIELD | +| `duration` | int | Turns remaining before expiration | +| `power` | int | Damage/healing per turn or stat modifier | +| `stat_affected` | StatType | Which stat is modified (for BUFF/DEBUFF only) | +| `stacks` | int | Current number of stacks (default 1) | +| `max_stacks` | int | Maximum stacks allowed (default 5) | +| `source` | str | Who/what applied it (ability_id or character_id) | + +**Effect Types:** + +| Type | Description | Power Usage | +|------|-------------|-------------| +| **BUFF** | Increase stats temporarily | Stat modifier (×stacks) | +| **DEBUFF** | Decrease stats temporarily | Stat modifier (×stacks) | +| **DOT** | Damage over time (poison, bleed, burn) | Damage per turn (×stacks) | +| **HOT** | Heal over time (regeneration) | Healing per turn (×stacks) | +| **STUN** | Skip turn (cannot act) | Not used | +| **SHIELD** | Absorb damage before HP loss | Shield strength (×stacks) | + +**Methods:** + +**`tick() -> Dict[str, Any]`** + +Process one turn of this effect. Called at the start of each combatant's turn. + +Returns dictionary with: +- `effect_name`: Name of the effect +- `effect_type`: Type of effect +- `value`: Damage dealt (DOT) or healing done (HOT) = power × stacks +- `shield_remaining`: Current shield strength (SHIELD only) +- `stunned`: True if this is a stun effect +- `stat_modifier`: Amount stats are modified (BUFF/DEBUFF) = power × stacks +- `expired`: True if duration reached 0 +- `message`: Human-readable description + +Duration is decremented by 1 each tick. Effect is marked expired when duration reaches 0. + +**`apply_stack(additional_duration) -> None`** + +Apply an additional stack of this effect (stacking mechanic). + +Behavior: +- Increases `stacks` by 1 (up to `max_stacks`) +- Refreshes `duration` to maximum +- If already at max_stacks, only refreshes duration + +Example: Poison with 2 stacks gets re-applied → becomes 3 stacks, duration refreshes + +**`reduce_shield(damage) -> int`** + +Reduce shield strength by damage amount (SHIELD effects only). + +Returns remaining damage after shield absorption. + +Examples: +- Shield power=50, damage=30 → power becomes 20, returns 0 (all absorbed) +- Shield power=20, damage=30 → power becomes 0, duration=0, returns 10 (partial) + +**Effect Stacking Rules:** +- Same effect applied multiple times increases stacks +- Stacks are capped at `max_stacks` (default 5, configurable per effect) +- Power scales linearly: 3 stacks of 5 power poison = 15 damage per turn +- Duration refreshes on re-application (does not stack cumulatively) +- Different effects (even same name) don't stack with each other + +### Combatant + +Wrapper for a Character or Enemy in combat. Tracks combat-specific state. + +| Field | Type | Description | +|-------|------|-------------| +| `combatant_id` | str | Character or enemy ID | +| `name` | str | Display name | +| `is_player` | bool | True for player characters, False for NPCs | +| `current_hp` | int | Current health points | +| `max_hp` | int | Maximum health points | +| `current_mp` | int | Current mana points | +| `max_mp` | int | Maximum mana points | +| `stats` | Stats | Combat stats (use Character.get_effective_stats()) | +| `active_effects` | List[Effect] | Currently active effects on this combatant | +| `abilities` | List[str] | Available ability IDs (not full Ability objects) | +| `cooldowns` | Dict[str, int] | {ability_id: turns_remaining} for abilities on cooldown | +| `initiative` | int | Turn order value (rolled at combat start) | + +**Methods:** + +- `is_alive() -> bool` - Check if combatant has HP > 0 +- `is_dead() -> bool` - Check if combatant has HP <= 0 +- `is_stunned() -> bool` - Check if any active STUN effect +- `take_damage(damage) -> int` - Apply damage with shield absorption, returns actual HP damage dealt +- `heal(amount) -> int` - Restore HP (capped at max_hp), returns actual amount healed +- `restore_mana(amount) -> int` - Restore MP (capped at max_mp) +- `can_use_ability(ability_id, ability) -> bool` - Check if ability can be used (mana, cooldown) +- `use_ability_cost(ability, ability_id) -> None` - Consume mana and set cooldown +- `tick_effects() -> List[Dict]` - Process all active effects for this turn, remove expired +- `tick_cooldowns() -> None` - Reduce all cooldowns by 1 turn +- `add_effect(effect) -> None` - Add effect, stacks if same effect exists + +**Important Notes:** +- `abilities` stores ability IDs, not full Ability objects (for serialization) +- `stats` should be set to Character.get_effective_stats() for players +- Shield effects are processed automatically in `take_damage()` +- Effects tick at start of turn via `tick_effects()` + +### CombatEncounter + +| Field | Type | Description | +|-------|------|-------------| +| `encounter_id` | str | Unique identifier | +| `combatants` | List[Combatant] | All fighters | +| `turn_order` | List[str] | Combatant IDs in order | +| `current_turn_index` | int | Index in turn_order | +| `round_number` | int | Current round | +| `combat_log` | List[Dict] | Action history | +| `status` | str | active, victory, defeat | + +**Methods:** + +- `initialize_combat() -> None` - Roll initiative for all combatants, set turn order +- `get_current_combatant() -> Combatant` - Get the combatant whose turn it is +- `get_combatant(combatant_id) -> Combatant` - Get combatant by ID +- `advance_turn() -> None` - Move to next combatant's turn, increment round if needed +- `start_turn() -> List[Dict]` - Process effects and cooldowns at turn start +- `check_end_condition() -> CombatStatus` - Check for victory/defeat, update status +- `log_action(action_type, combatant_id, message, details) -> None` - Add entry to combat log + +**Combat Flow:** +1. `initialize_combat()` - Roll initiative, sort turn order +2. Loop while status == ACTIVE: + - `start_turn()` - Tick effects, check for stun + - Execute action (if not stunned) + - `check_end_condition()` - Check if combat should end + - `advance_turn()` - Move to next combatant +3. End when status becomes VICTORY, DEFEAT, or FLED + +--- + +## Session System + +### SessionConfig + +| Field | Type | Description | +|-------|------|-------------| +| `min_players` | int | Session ends if below this | +| `timeout_minutes` | int | Inactivity timeout | +| `auto_save_interval` | int | Turns between auto-saves | + +### GameSession + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | str | Unique identifier | +| `party_member_ids` | List[str] | Character IDs in party | +| `config` | SessionConfig | Session settings | +| `combat_encounter` | CombatEncounter | Current combat or null | +| `conversation_history` | List[Dict] | Turn-by-turn log | +| `game_state` | GameState | Current world state | +| `turn_order` | List[str] | Character turn order | +| `current_turn` | int | Index in turn_order | +| `turn_number` | int | Global turn counter | +| `created_at` | ISO Timestamp | Session start | +| `last_activity` | ISO Timestamp | Last action time | +| `status` | str | active, completed, timeout | + +### GameState + +| Field | Type | Description | +|-------|------|-------------| +| `current_location` | str | Location name/ID | +| `discovered_locations` | List[str] | Location IDs | +| `active_quests` | List[str] | Quest IDs | +| `world_events` | List[Dict] | Server-wide events | + +### Conversation History Entry + +| Field | Type | Description | +|-------|------|-------------| +| `turn` | int | Turn number | +| `character_id` | str | Acting character | +| `character_name` | str | Character name | +| `action` | str | Player action text | +| `dm_response` | str | AI-generated response | +| `combat_log` | List[Dict] | Combat actions (if any) | + +--- + +## Marketplace System + +### MarketplaceListing + +| Field | Type | Description | +|-------|------|-------------| +| `listing_id` | str | Unique identifier | +| `seller_id` | str | User ID | +| `character_id` | str | Character ID | +| `item_data` | Item | Full item details | +| `listing_type` | str | "auction" or "fixed_price" | +| `price` | int | For fixed_price | +| `starting_bid` | int | For auction | +| `current_bid` | int | For auction | +| `buyout_price` | int | Optional instant buy | +| `bids` | List[Bid] | Bid history | +| `auction_end` | ISO Timestamp | For auction | +| `status` | str | active, sold, expired, removed | +| `created_at` | ISO Timestamp | Listing creation | + +### Bid + +| Field | Type | Description | +|-------|------|-------------| +| `bidder_id` | str | User ID | +| `bidder_name` | str | Character name | +| `amount` | int | Bid amount | +| `timestamp` | ISO Timestamp | Bid time | + +### Transaction + +| Field | Type | Description | +|-------|------|-------------| +| `transaction_id` | str | Unique identifier | +| `buyer_id` | str | User ID | +| `seller_id` | str | User ID | +| `listing_id` | str | Listing ID | +| `item_data` | Item | Item details | +| `price` | int | Final price | +| `timestamp` | ISO Timestamp | Transaction time | +| `transaction_type` | str | marketplace_sale, shop_purchase, etc. | + +--- + +## NPC Shop System + +### ShopItem + +| Field | Type | Description | +|-------|------|-------------| +| `item_id` | str | Item identifier | +| `item` | Item | Item details | +| `stock` | int | Available quantity (-1 = unlimited) | +| `price` | int | Fixed gold price | + +**Shop Categories:** +- Consumables (health potions, mana potions) +- Basic weapons (tier 1-2) +- Basic armor (tier 1-2) +- Crafting materials (future feature) + +**Purpose:** +- Provides gold sink to prevent inflation +- Always available (not affected by marketplace access) +- Sells basic items at fixed prices + +--- + +## Skill Tree Design + +Each skill tree has **5 tiers** with **3-5 nodes per tier**. + +### Example: Vanguard - Shield Bearer Tree + +| Tier | Node | Type | Prerequisites | Effects | +|------|------|------|---------------|---------| +| 1 | Shield Bash | Active | None | Unlock shield_bash ability, 5 damage, 1 turn stun | +| 1 | Fortify | Passive | None | +5 Defense | +| 2 | Shield Wall | Active | Shield Bash | Unlock shield_wall ability, block all damage 1 turn, 3 turn cooldown | +| 2 | Iron Skin | Passive | Fortify | +10 Defense, +5 HP | +| 3 | Guardian's Resolve | Passive | Shield Wall | Immune to stun | +| 3 | Riposte | Active | Shield Bash | Unlock riposte ability, counter attack on block | +| 4 | Bulwark | Passive | Iron Skin | +15 Defense, +10 HP, damage reduction 10% | +| 5 | Unbreakable | Ultimate | Bulwark | Unlock unbreakable ability, 5 turn buff: 50% damage reduction | + +--- + +## Data Serialization + +### JSON Storage in Appwrite + +All complex dataclasses are serialized to JSON strings for storage: + +**Storage:** +``` +Character dataclass → JSON string → Appwrite document field +``` + +**Retrieval:** +``` +Appwrite document field → JSON string → Character dataclass +``` + +### Benefits +- Schema flexibility (easy to add fields) +- No database migrations needed +- Type safety in application code +- Easy to serialize/deserialize + +--- + +## Future Data Models (Backlog) + +### Planned Additions +- **Guild:** Player organizations +- **WorldEvent:** Server-wide quests +- **Achievement:** Badge system +- **CraftingRecipe:** Item creation +- **PetCompanion:** Beast Master pets +- **LeaderboardEntry:** Rankings + +### Additional Player Classes (Backlog) +- Monk (martial arts, chi energy) +- Druid (shapeshifting, nature magic) +- Warlock (pact magic, debuffs) +- Artificer (gadgets, constructs) diff --git a/api/docs/GAME_SYSTEMS.md b/api/docs/GAME_SYSTEMS.md new file mode 100644 index 0000000..4268f50 --- /dev/null +++ b/api/docs/GAME_SYSTEMS.md @@ -0,0 +1,587 @@ +# Game Systems + +## Combat System + +### Core Concepts + +**Turn-Based Combat:** +- Initiative rolls determine turn order (d20 + speed stat) +- Each combatant takes one action per turn +- Effects (buffs/debuffs/DoT) process at start of turn +- Combat continues until one side is defeated + +### Damage Calculations + +| Attack Type | Formula | Min Damage | +|-------------|---------|------------| +| **Physical** | weapon.damage + (strength / 2) - target.defense | 1 | +| **Magical** | spell.damage + (magic_power / 2) - target.resistance | 1 | +| **Critical Hit** | base_damage × weapon.crit_multiplier | - | + +**Critical Hit System:** +- **Design Choice:** Critical hits only, no damage variance (JRPG-style) +- Base damage is **deterministic** (always same result for same stats) +- Random element is **only** whether attack crits +- Default crit_chance: **5%** (0.05) +- Default crit_multiplier: **2.0×** (double damage) +- Weapons can have different crit_chance and crit_multiplier values + +**Why This Design:** +- Predictable damage for tactical planning +- Exciting moments when crits occur +- Easier to balance than full damage ranges +- Simpler AI prompting (no damage variance to explain) + +### Combat Flow + +| Phase | Actions | +|-------|---------| +| **1. Initialize Combat** | Roll initiative for all combatants
Sort by initiative (highest first)
Set turn order | +| **2. Turn Start** | Process all active effects on current combatant
Check for stun (skip turn if stunned)
Reduce spell cooldowns | +| **3. Action Phase** | Player/AI selects action (attack, cast spell, use item, defend)
Execute action
Apply damage/effects
Check for death | +| **4. Turn End** | Advance to next combatant
If back to first combatant, increment round number
Check for combat end condition | +| **5. Combat Resolution** | **Victory:** Distribute XP and loot
**Defeat:** Handle character death/respawn | + +### Action Types + +| Action | Description | Examples | +|--------|-------------|----------| +| **Attack** | Physical weapon attack | Sword strike, bow shot | +| **Cast Spell** | Use magical ability | Fireball, heal, curse | +| **Use Item** | Consume item from inventory | Health potion, scroll | +| **Defend** | Defensive stance | +defense for 1 turn | +| **Special Ability** | Class-specific skill | Shield bash, stealth strike | + +### Effect Mechanics + +**Effect Processing:** +- Effects have **duration** (turns remaining) +- Effects can **stack** (multiple applications increase power) +- Effects are processed at **start of turn** +- Effects expire automatically when duration reaches 0 + +**Stacking Mechanics:** +- Effects stack up to **max_stacks** (default 5, configurable per effect) +- Re-applying same effect increases stacks (up to max) +- Duration **refreshes** on re-application (does not stack cumulatively) +- Power scales linearly with stacks: 3 stacks × 5 damage = 15 damage per turn +- Once at max stacks, re-application only refreshes duration + +**Stacking Examples:** +- Poison (power=5, max_stacks=5): 3 stacks = 15 damage per turn +- Defense buff (power=3, max_stacks=5): 2 stacks = +6 defense +- Applying poison 6 times = 5 stacks (capped), duration refreshed each time + +**Shield Effect Mechanics:** +- Shield absorbs damage **before HP loss** +- Shield strength = power × stacks +- Partial absorption: If damage > shield, shield breaks and remaining damage goes to HP +- Full absorption: If damage <= shield, all damage absorbed, shield reduced +- Shield depletes when power reaches 0 or duration expires + +**Effect Interaction with Stats:** +- BUFF/DEBUFF effects modify stats via `get_effective_stats()` +- Stat modifications are temporary (only while effect is active) +- Debuffs **cannot reduce stats below 1** (minimum clamping) +- Buffs stack with equipment and skill bonuses + +### AI-Generated Combat Narrative + +**Narrative Generation:** +- After each combat action executes, generate narrative description +- Code calculates mechanics ("Aragorn attacks Goblin for 15 damage (critical hit!)") +- AI generates flavor ("Aragorn's blade finds a gap in the goblin's armor, striking a devastating blow!") + +**Model Selection:** + +| Encounter Type | Model Tier | +|----------------|------------| +| Standard encounters | STANDARD (Haiku) | +| Boss fights | PREMIUM (Sonnet) | +| Free tier users | FREE (Replicate) | + +--- + +## Ability System + +### Overview + +Abilities are actions that can be used in combat (attacks, spells, skills). The system is **data-driven** using YAML configuration files. + +### Ability Components + +| Component | Description | +|-----------|-------------| +| **Base Power** | Starting damage or healing value | +| **Scaling Stat** | Which stat enhances the ability (STR, INT, etc.) | +| **Scaling Factor** | Multiplier for scaling (default 0.5) | +| **Mana Cost** | MP required to use | +| **Cooldown** | Turns before ability can be used again | +| **Effects** | Status effects applied on hit | + +### Power Calculation + +``` +Final Power = base_power + (scaling_stat × scaling_factor) +Minimum power is always 1 +``` + +**Examples:** +- **Cleave** (physical skill): base_power=15, scaling_stat=STRENGTH, scaling_factor=0.5 + - With 20 STR: 15 + (20 × 0.5) = 25 power +- **Fireball** (spell): base_power=30, scaling_stat=INTELLIGENCE, scaling_factor=0.5 + - With 16 INT: 30 + (16 × 0.5) = 38 power + +### Mana & Cooldown Mechanics + +**Mana System:** +- Each ability has a mana_cost (0 for basic attacks) +- Combatant must have current_mp >= mana_cost +- Mana is consumed when ability is used +- Mana regeneration happens between combat encounters + +**Cooldown System:** +- Abilities can have cooldowns (turns before re-use) +- Cooldown starts when ability is used +- Cooldowns tick down at start of each turn +- 0 cooldown = can use every turn + +**Example:** +- Power Strike: mana_cost=10, cooldown=3 +- Use on turn 1 → Can't use again until turn 5 + +### Effect Application + +Abilities can apply effects to targets: + +```yaml +effects_applied: + - effect_id: "burn" + name: "Burning" + effect_type: "dot" + duration: 3 + power: 5 + max_stacks: 3 +``` + +When ability hits, all `effects_applied` are added to the target's `active_effects`. + +### Data-Driven Design + +**Benefits:** +- Game designers can add/modify abilities without code changes +- Easy balancing through config file edits +- Version control friendly (text files) +- Hot-reloading capable (reload without restart) + +**Workflow:** +1. Create YAML file in `/app/data/abilities/` +2. Define ability properties +3. AbilityLoader automatically loads on request +4. Abilities available for use immediately + +**Example YAML Structure:** +```yaml +ability_id: "shield_bash" +name: "Shield Bash" +description: "Bash enemy with shield, dealing damage and stunning" +ability_type: "skill" +base_power: 10 +damage_type: "physical" +scaling_stat: "strength" +scaling_factor: 0.5 +mana_cost: 5 +cooldown: 2 +effects_applied: + - effect_id: "stun_1" + name: "Stunned" + effect_type: "stun" + duration: 1 + power: 0 +``` + +### Ability Types + +| Type | Description | Typical Use | +|------|-------------|-------------| +| **ATTACK** | Basic physical attack | Default melee/ranged attacks | +| **SPELL** | Magical ability | Fireballs, heals, buffs | +| **SKILL** | Class-specific ability | Shield bash, backstab, power strike | +| **ITEM_USE** | Using consumable | Health potion, scroll | +| **DEFEND** | Defensive action | Defensive stance, dodge | + +--- + +## Multiplayer Party System + +### Session Formation + +| Step | Action | Details | +|------|--------|---------| +| 1 | Create Session | Leader creates session with configuration | +| 2 | Generate Code | System generates invite code | +| 3 | Join Session | Other players join via invite code | +| 4 | Start Game | Session begins when min_players met | + +**Max Party Size by Tier:** + +| Subscription Tier | Max Party Size | +|-------------------|----------------| +| FREE | Solo only (1) | +| BASIC | 2 players | +| PREMIUM | 6 players | +| ELITE | 10 players | + +### Turn Flow + +1. Turn order determined by initiative +2. Active player takes action +3. Action queued to RQ for AI processing +4. AI response generated +5. Game state updated in Appwrite +6. All party members notified via Appwrite Realtime +7. Next player's turn + +### Session End Conditions + +| Condition | Result | +|-----------|--------| +| Manual end by leader | Session completed | +| Below min_players for timeout duration | Session timeout | +| All players leave | Session completed | +| Total party wipeout in combat | Session completed (defeat) | + +### Post-Session + +- Players keep all loot/gold earned +- Session logs saved: + - **Free tier:** 7 days + - **Basic:** 14 days + - **Premium:** 30 days + - **Elite:** 90 days +- Exportable as Markdown + +### Realtime Synchronization + +**Appwrite Realtime Features:** +- WebSocket connections for multiplayer +- Automatic updates when game state changes +- No polling required +- Built-in connection management +- Automatic reconnection + +**Update Flow:** +1. Player takes action +2. Backend updates Appwrite document +3. Appwrite triggers realtime event +4. All subscribed clients receive update +5. UI updates automatically + +--- + +## Marketplace System + +### Overview + +| Aspect | Details | +|--------|---------| +| **Access Level** | Premium+ subscription tiers only | +| **Currency** | In-game gold only (no real money trading) | +| **Listing Types** | Fixed price or auction | +| **Transaction Fee** | None (may implement later for economy balance) | + +### Auction System + +**eBay-Style Bidding:** + +| Feature | Description | +|---------|-------------| +| **Starting Bid** | Minimum bid set by seller | +| **Buyout Price** | Optional instant-win price | +| **Duration** | 24, 48, or 72 hours | +| **Bidding** | Must exceed current bid | +| **Auto-Win** | Buyout price triggers instant sale | +| **Winner** | Highest bidder when auction ends | +| **Notifications** | Outbid alerts via Appwrite Realtime | + +**Auction Processing:** +- RQ periodic task checks for ended auctions every 5 minutes +- Winner receives item, seller receives gold +- If no bids, item returned to seller + +### Fixed Price Listings + +| Feature | Description | +|---------|-------------| +| **Price** | Set by seller | +| **Purchase** | Immediate transaction | +| **Availability** | First come, first served | + +### Item Restrictions + +**Non-Tradeable Items:** +- Quest items +- Character-bound items +- Items marked `is_tradeable: false` + +### Marketplace Features by Tier + +| Tier | Access | Max Active Listings | Priority | +|------|--------|---------------------|----------| +| FREE | ✗ | - | - | +| BASIC | ✗ | - | - | +| PREMIUM | ✓ | 10 | Normal | +| ELITE | ✓ | 25 | Priority (shown first) | + +--- + +## NPC Shop System + +### Overview + +**Game-Run Shop:** +- Sells basic items at fixed prices +- Always available (not affected by marketplace access) +- Provides gold sink to prevent inflation + +### Shop Categories + +| Category | Items | Tier Range | +|----------|-------|------------| +| **Consumables** | Health potions, mana potions, antidotes | All | +| **Basic Weapons** | Swords, bows, staves | 1-2 | +| **Basic Armor** | Helmets, chest plates, boots | 1-2 | +| **Crafting Materials** | (Future feature) | - | + +### Pricing Strategy + +- Basic items priced reasonably for new players +- Prices higher than marketplace average (encourages player economy) +- No selling back to shop (or at 50% value to prevent abuse) + +--- + +## Progression Systems + +### Experience & Leveling + +| Source | XP Gain | +|--------|---------| +| Combat victory | Based on enemy difficulty | +| Quest completion | Fixed quest reward | +| Story milestones | Major plot points | +| Exploration | Discovering new locations | + +**Level Progression:** +- XP required increases per level (exponential curve) +- Each level grants +1 skill point +- Stats may increase based on class + +### Loot System + +**Loot Sources:** +- Defeated enemies +- Treasure chests +- Quest rewards +- Boss encounters + +**Loot Quality Tiers:** + +| Tier | Color | Drop Rate | Example | +|------|-------|-----------|---------| +| Common | Gray | 60% | Basic health potion | +| Uncommon | Green | 25% | Enhanced sword | +| Rare | Blue | 10% | Fire-enchanted blade | +| Epic | Purple | 4% | Legendary armor | +| Legendary | Orange | 1% | Artifact weapon | + +**Boss Loot:** +- Bosses always drop rare+ items +- Guaranteed unique item per boss +- Chance for legendary items + +--- + +## Quest System (Future) + +### Quest Types + +| Type | Description | Example | +|------|-------------|---------| +| **Main Story** | Plot progression | "Defeat the Dark Lord" | +| **Side Quest** | Optional content | "Help the blacksmith" | +| **Daily Quest** | Repeatable daily | "Slay 10 goblins" | +| **World Event** | Server-wide | "Defend the city" | + +### Quest Rewards + +- Gold +- Experience +- Items (equipment, consumables) +- Unlock locations/features +- Reputation with factions + +--- + +## Economy & Balance + +### Gold Sources (Inflow) + +| Source | Amount | +|--------|--------| +| Combat loot | 10-100 per encounter | +| Quest rewards | 100-1000 per quest | +| Marketplace sales | Player-driven | + +### Gold Sinks (Outflow) + +| Sink | Cost | +|------|------| +| NPC shop purchases | Varies | +| Skill respec | Level × 100 gold | +| Fast travel | 50-500 per location | +| Equipment repairs | (Future feature) | + +### Economy Monitoring + +**Metrics to Track:** +- Average gold per player +- Marketplace price trends +- Item availability +- Transaction volume + +**Balancing Actions:** +- Adjust NPC shop prices +- Introduce new gold sinks +- Modify loot drop rates +- Implement transaction fees if needed + +--- + +## PvP Arena (Future Feature) + +### Planned Features + +| Feature | Description | +|---------|-------------| +| **Arena Mode** | Optional combat mode | +| **Matchmaking** | Ranked and casual | +| **Rewards** | Exclusive PvP items | +| **Leaderboard** | Season-based rankings | +| **Restrictions** | Balanced gear/levels | + +**Note:** PvP is entirely optional and separate from main game. + +--- + +## Guild System (Future Feature) + +### Planned Features + +| Feature | Description | +|---------|-------------| +| **Guild Creation** | Player-run organizations | +| **Guild Bank** | Shared resources | +| **Guild Quests** | Cooperative challenges | +| **Guild Halls** | Customizable spaces | +| **Guild Wars** | PvP guild vs guild | + +--- + +## World Events (Future Feature) + +### Planned Features + +| Feature | Description | +|---------|-------------| +| **Server-Wide Events** | All players can participate | +| **Timed Events** | Limited duration | +| **Cooperative Goals** | Community objectives | +| **Exclusive Rewards** | Event-only items | +| **Story Impact** | Events affect world state | + +--- + +## Achievements (Future Feature) + +### Planned Categories + +| Category | Examples | +|----------|----------| +| **Combat** | "Defeat 100 enemies", "Win without taking damage" | +| **Exploration** | "Discover all locations", "Travel 1000 miles" | +| **Collection** | "Collect all legendary items", "Complete skill tree" | +| **Social** | "Join a guild", "Complete 10 multiplayer sessions" | +| **Story** | "Complete main story", "Complete all side quests" | + +**Rewards:** +- Titles +- Cosmetic items +- Special abilities +- Achievement points + +--- + +## Crafting System (Future Feature) + +### Planned Features + +| Feature | Description | +|---------|-------------| +| **Recipes** | Learn from quests, loot, NPCs | +| **Materials** | Gather from enemies, exploration | +| **Crafting Stations** | Special locations required | +| **Item Enhancement** | Upgrade existing items | +| **Unique Items** | Crafted-only items | + +--- + +## Common Patterns + +### AI Cost Tracking + +**Log every AI call:** +- User ID +- Model used +- Cost tier +- Tokens used +- Timestamp + +**Daily Limits:** +- Track usage per user per day +- Block calls if limit exceeded +- Graceful degradation message + +### Error Handling + +**Consistent error format:** +```json +{ + "error": "Error message", + "code": "ERROR_CODE", + "details": {} +} +``` + +### Logging + +**Structured logging with context:** +- Session ID +- Character ID +- Action type +- Results +- Timestamp + +--- + +## Glossary + +| Term | Definition | +|------|------------| +| **DM** | Dungeon Master (AI in this case) | +| **NPC** | Non-Player Character | +| **DoT** | Damage over Time | +| **HoT** | Heal over Time | +| **AoE** | Area of Effect | +| **XP** | Experience Points | +| **PWA** | Progressive Web App | diff --git a/api/docs/MULTIPLAYER.md b/api/docs/MULTIPLAYER.md new file mode 100644 index 0000000..4263215 --- /dev/null +++ b/api/docs/MULTIPLAYER.md @@ -0,0 +1,807 @@ +# Multiplayer System - API Backend + +**Status:** Planned +**Phase:** 6 (Multiplayer Sessions) +**Timeline:** Week 12-13 (14 days) +**Last Updated:** November 18, 2025 + +--- + +## Overview + +The Multiplayer System backend handles all business logic for time-limited co-op sessions, including session management, invite system, AI campaign generation, combat orchestration, realtime synchronization, and reward distribution. + +**Backend Responsibilities:** +- Session lifecycle management (create, join, start, end, expire) +- Invite code generation and validation +- AI-generated campaign creation +- Turn-based combat validation and state management +- Realtime event broadcasting via Appwrite +- Reward calculation and distribution +- Character snapshot management +- Session expiration enforcement (2-hour limit) + +--- + +## Multiplayer vs Solo Gameplay + +| Feature | Solo Gameplay | Multiplayer Gameplay | +|---------|---------------|----------------------| +| **Tier Requirement** | All tiers (Free, Basic, Premium, Elite) | Premium/Elite only | +| **Session Type** | Ongoing, persistent | Time-limited (2 hours) | +| **Story Progression** | ✅ Full button-based actions | ❌ Limited (combat-focused) | +| **Quests** | ✅ Context-aware quest offering | ❌ No quest system | +| **Combat** | ✅ Full combat system | ✅ Cooperative combat | +| **AI Narration** | ✅ Rich narrative responses | ✅ Campaign narration | +| **Character Use** | Uses character location/state | Temporary instance (doesn't affect solo character) | +| **Session Duration** | Unlimited | 2 hours max | +| **Access Method** | Create anytime | Invite link required | + +**Design Philosophy:** +- **Solo**: Deep, persistent RPG experience with quests, exploration, character progression +- **Multiplayer**: Drop-in co-op sessions for quick adventures with friends + +--- + +## Session Architecture + +### Session Types + +The system supports two distinct session types: + +#### Solo Session +- Created via `POST /api/v1/sessions` with `session_type: "solo"` +- Single player character +- Persistent across logins +- Full story progression and quest systems +- No time limit + +#### Multiplayer Session +- Created via `POST /api/v1/sessions/multiplayer` with invite link +- 2-4 player characters (configurable) +- Time-limited (2 hour duration) +- Combat and short campaign focus +- Realtime synchronization required + +### Multiplayer Session Lifecycle + +``` +┌─────────────────────────────────────────────────────┐ +│ Host Creates Session │ +│ - Premium/Elite tier required │ +│ - Select difficulty and party size (2-4 players) │ +│ - Generate invite link │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Invite Link Shared │ +│ - Host shares link with friends │ +│ - Link valid for 24 hours or until session starts │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Players Join Session │ +│ - Click invite link │ +│ - Select character to use (from their characters) │ +│ - Wait in lobby for all players │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Host Starts Session │ +│ - All players ready in lobby │ +│ - AI generates custom campaign │ +│ - 2-hour timer begins │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Gameplay Loop │ +│ - Turn-based cooperative combat │ +│ - AI narration and encounters │ +│ - Shared loot and rewards │ +│ - Realtime updates for all players │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Session Ends │ +│ - 2 hour timer expires OR │ +│ - Party completes campaign OR │ +│ - Party wipes in combat OR │ +│ - Host manually ends session │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Rewards Distributed │ +│ - XP, gold, items granted to characters │ +│ - Characters saved back to player accounts │ +│ - Session archived (read-only history) │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## Data Models + +### MultiplayerSession + +Extends GameSession with multiplayer-specific fields. + +```python +@dataclass +class MultiplayerSession: + """Represents a time-limited multiplayer co-op session.""" + + session_id: str # Unique identifier + host_user_id: str # User who created the session + invite_code: str # Shareable invite link code + session_type: str = "multiplayer" # Always "multiplayer" + status: str = "lobby" # "lobby", "active", "completed", "expired" + + # Party configuration + max_players: int = 4 # 2-4 players allowed + party_members: List[PartyMember] = field(default_factory=list) + + # Time limits + created_at: str = "" # ISO timestamp + started_at: Optional[str] = None # When session started + expires_at: Optional[str] = None # 2 hours after started_at + time_remaining_seconds: int = 7200 # 2 hours = 7200 seconds + + # Campaign + campaign: MultiplayerCampaign = None # AI-generated campaign + current_encounter_index: int = 0 # Progress through campaign + + # Combat state + combat_encounter: Optional[CombatEncounter] = None + turn_order: List[str] = field(default_factory=list) # Character IDs + current_turn_index: int = 0 + + # Conversation/narration + conversation_history: List[ConversationEntry] = field(default_factory=list) + + # Tier requirements + tier_required: str = "premium" # "premium" or "elite" + + def is_expired(self) -> bool: + """Check if session has exceeded time limit.""" + if not self.expires_at: + return False + return datetime.utcnow() > datetime.fromisoformat(self.expires_at) + + def get_time_remaining(self) -> int: + """Get remaining time in seconds.""" + if not self.expires_at: + return 7200 # Default 2 hours + remaining = datetime.fromisoformat(self.expires_at) - datetime.utcnow() + return max(0, int(remaining.total_seconds())) + + def can_join(self, user: UserData) -> bool: + """Check if user can join this session.""" + # Check tier requirement + if user.tier not in ["premium", "elite"]: + return False + + # Check party size limit + if len(self.party_members) >= self.max_players: + return False + + # Check session status + if self.status not in ["lobby", "active"]: + return False + + return True +``` + +### PartyMember + +```python +@dataclass +class PartyMember: + """Represents a player in a multiplayer session.""" + + user_id: str # User account ID + username: str # Display name + character_id: str # Character being used + character_snapshot: Character # Snapshot at session start (immutable) + is_host: bool = False # Is this player the host? + is_ready: bool = False # Ready to start (lobby only) + is_connected: bool = True # Currently connected via Realtime + joined_at: str = "" # ISO timestamp +``` + +### MultiplayerCampaign + +```python +@dataclass +class MultiplayerCampaign: + """AI-generated short campaign for multiplayer session.""" + + campaign_id: str + title: str # "The Goblin Raid", "Dragon's Hoard" + description: str # Campaign overview + difficulty: str # "easy", "medium", "hard", "deadly" + estimated_duration_minutes: int # 60-120 minutes + encounters: List[CampaignEncounter] = field(default_factory=list) + rewards: CampaignRewards = None + + def is_complete(self) -> bool: + """Check if all encounters are completed.""" + return all(enc.completed for enc in self.encounters) +``` + +### CampaignEncounter + +```python +@dataclass +class CampaignEncounter: + """A single encounter within a multiplayer campaign.""" + + encounter_id: str + encounter_type: str # "combat", "puzzle", "boss" + title: str # "Goblin Ambush", "The Dragon Awakens" + narrative_intro: str # AI-generated intro text + narrative_completion: str # AI-generated completion text + + # Combat details (if encounter_type == "combat") + enemies: List[Dict] = field(default_factory=list) # Enemy definitions + combat_modifiers: Dict = field(default_factory=dict) # Terrain, weather, etc. + + completed: bool = False + completion_time: Optional[str] = None +``` + +### CampaignRewards + +```python +@dataclass +class CampaignRewards: + """Rewards for completing the multiplayer campaign.""" + + gold_per_player: int = 0 + experience_per_player: int = 0 + shared_items: List[str] = field(default_factory=list) # Items to distribute + completion_bonus: int = 0 # Bonus for finishing under time limit +``` + +--- + +## Invite System + +### Invite Link Generation + +When a Premium/Elite player creates a multiplayer session: + +1. **Generate unique invite code**: 8-character alphanumeric (e.g., `A7K9X2M4`) +2. **Create shareable link**: `https://codeofconquest.com/join/{invite_code}` +3. **Set link expiration**: Valid for 24 hours or until session starts +4. **Store invite metadata**: Host info, tier requirement, party size + +**Endpoint:** `POST /api/v1/sessions/multiplayer/create` + +**Request:** +```json +{ + "max_players": 4, + "difficulty": "medium", + "tier_required": "premium" +} +``` + +**Response:** +```json +{ + "session_id": "mp_xyz789", + "invite_code": "A7K9X2M4", + "invite_link": "https://codeofconquest.com/join/A7K9X2M4", + "expires_at": "2025-11-17T12:00:00Z", + "max_players": 4, + "status": "lobby" +} +``` + +### Joining via Invite Link + +Players click the invite link and are prompted to select a character: + +**Endpoint:** `GET /api/v1/sessions/multiplayer/join/{invite_code}` + +**Response:** +```json +{ + "session_id": "mp_xyz789", + "host_username": "PlayerOne", + "max_players": 4, + "current_players": 2, + "difficulty": "medium", + "tier_required": "premium", + "can_join": true, + "user_characters": [ + { + "character_id": "char_abc123", + "name": "Thorin", + "class": "Vanguard", + "level": 5 + } + ] +} +``` + +**Join Endpoint:** `POST /api/v1/sessions/multiplayer/join/{invite_code}` + +**Request:** +```json +{ + "character_id": "char_abc123" +} +``` + +**Response:** +```json +{ + "success": true, + "session_id": "mp_xyz789", + "party_member_id": "pm_def456", + "lobby_status": { + "players_joined": 3, + "max_players": 4, + "all_ready": false + } +} +``` + +--- + +## Session Time Limit + +### 2-Hour Duration + +All multiplayer sessions have a hard 2-hour time limit: + +- **Timer starts**: When host clicks "Start Session" (all players ready) +- **Expiration**: Session automatically ends, rewards distributed +- **Warnings**: Backend sends notifications at 10min, 5min, 1min remaining + +### Session Expiration Handling + +```python +def check_session_expiration(session: MultiplayerSession) -> bool: + """Check if session has expired and handle cleanup.""" + + if not session.is_expired(): + return False + + # Session has expired + logger.info(f"Session {session.session_id} expired after 2 hours") + + # If in combat, end combat (players flee) + if session.combat_encounter: + end_combat(session, status="fled") + + # Calculate partial rewards (progress-based) + partial_rewards = calculate_partial_rewards(session) + distribute_rewards(session, partial_rewards) + + # Mark session as expired + session.status = "expired" + save_session(session) + + # Notify all players + notify_all_players(session, "Session time limit reached. Rewards distributed.") + + return True +``` + +### Completion Before Time Limit + +If the party completes the campaign before 2 hours: + +- **Completion bonus**: Extra gold/XP for finishing quickly +- **Session ends**: No need to wait for timer +- **Full rewards**: All campaign rewards distributed + +--- + +## AI Campaign Generation + +### Campaign Generation at Session Start + +When the host starts the session (all players ready), the AI generates a custom campaign: + +**Campaign Generation Function:** +```python +def generate_multiplayer_campaign( + party_members: List[PartyMember], + difficulty: str, + duration_minutes: int = 120 +) -> MultiplayerCampaign: + """Use AI to generate a custom campaign for the party.""" + + # Build context + context = { + "party_size": len(party_members), + "party_composition": [ + { + "name": pm.character_snapshot.name, + "class": pm.character_snapshot.player_class.name, + "level": pm.character_snapshot.level + } + for pm in party_members + ], + "average_level": sum(pm.character_snapshot.level for pm in party_members) / len(party_members), + "difficulty": difficulty, + "duration_minutes": duration_minutes + } + + # AI prompt + prompt = f""" + Generate a {difficulty} difficulty D&D-style campaign for a party of {context['party_size']} adventurers. + Average party level: {context['average_level']:.1f} + Duration: {duration_minutes} minutes + Party composition: {context['party_composition']} + + Create a cohesive short campaign with: + - Engaging title and description + - 3-5 combat encounters (scaled to party level and difficulty) + - Narrative connecting the encounters + - Appropriate rewards (gold, XP, magic items) + - A climactic final boss encounter + + Return JSON format: + {{ + "title": "Campaign title", + "description": "Campaign overview", + "encounters": [ + {{ + "title": "Encounter name", + "narrative_intro": "Intro text", + "enemies": [/* enemy definitions */], + "narrative_completion": "Completion text" + }} + ], + "rewards": {{ + "gold_per_player": 500, + "experience_per_player": 1000, + "shared_items": ["magic_sword", "healing_potion"] + }} + }} + """ + + ai_response = call_ai_api(prompt, model="claude-sonnet") + campaign_data = parse_json_response(ai_response) + + return MultiplayerCampaign.from_dict(campaign_data) +``` + +### Example Generated Campaign + +**Title:** "The Goblin Raid on Millstone Village" + +**Description:** A band of goblins has been terrorizing the nearby village of Millstone. The village elder has hired your party to track down the goblin war band and put an end to their raids. + +**Encounters:** +1. **Goblin Scouts** - Encounter 2 goblin scouts on the road +2. **Ambush in the Woods** - 5 goblins ambush the party +3. **The Goblin Camp** - Assault the main goblin camp (8 goblins + 1 hobgoblin) +4. **The Goblin Chieftain** - Final boss fight (Goblin Chieftain + 2 elite guards) + +**Rewards:** +- 300 gold per player +- 800 XP per player +- Shared loot: Goblin Warblade, Ring of Minor Protection, 3x Healing Potions + +--- + +## Turn Management + +### Turn-Based Cooperative Combat + +In multiplayer combat, all party members take turns along with enemies: + +#### Initialize Multiplayer Combat + +```python +def initialize_multiplayer_combat( + session: MultiplayerSession, + encounter: CampaignEncounter +) -> None: + """Start combat for multiplayer session.""" + + # Create combatants for all party members + party_combatants = [] + for member in session.party_members: + combatant = create_combatant_from_character(member.character_snapshot) + combatant.player_id = member.user_id # Track which player controls this + party_combatants.append(combatant) + + # Create enemy combatants + enemy_combatants = [] + for enemy_def in encounter.enemies: + enemy = create_enemy_combatant(enemy_def) + enemy_combatants.append(enemy) + + # Create combat encounter + combat = CombatEncounter( + encounter_id=f"combat_{session.session_id}_{encounter.encounter_id}", + combatants=party_combatants + enemy_combatants, + turn_order=[], + current_turn_index=0, + round_number=1, + status="active" + ) + + # Roll initiative for all combatants + combat.initialize_combat() + + # Set combat encounter on session + session.combat_encounter = combat + save_session(session) + + # Notify all players combat has started + notify_all_players(session, "combat_started", { + "encounter_title": encounter.title, + "turn_order": combat.turn_order + }) +``` + +#### Validate Combat Actions + +Only the player whose character's turn it is can take actions: + +**Endpoint:** `POST /api/v1/sessions/multiplayer/{session_id}/combat/action` + +**Request:** +```json +{ + "action_type": "attack", + "ability_id": "basic_attack", + "target_id": "enemy_goblin_1" +} +``` + +**Validation:** +```python +def validate_combat_action( + session: MultiplayerSession, + user_id: str, + action: CombatAction +) -> bool: + """Ensure it's this player's turn.""" + + combat = session.combat_encounter + current_combatant = combat.get_current_combatant() + + # Check if current combatant belongs to this user + if hasattr(current_combatant, 'player_id'): + if current_combatant.player_id != user_id: + raise NotYourTurnError("It's not your turn") + + return True +``` + +--- + +## Realtime Synchronization + +### Backend Event Broadcasting + +Backend broadcasts events to all players via Appwrite Realtime: + +**Events Broadcast to All Players:** +- Player joined lobby +- Player ready status changed +- Session started +- Combat started +- Turn advanced +- Action taken (attack, spell, item use) +- Damage dealt +- Character defeated +- Combat ended +- Encounter completed +- Session time warnings (10min, 5min, 1min) +- Session expired + +### Player Disconnection Handling + +If a player disconnects during active session: + +```python +def handle_player_disconnect(session: MultiplayerSession, user_id: str) -> None: + """Handle player disconnection gracefully.""" + + # Mark player as disconnected + for member in session.party_members: + if member.user_id == user_id: + member.is_connected = False + break + + # If in combat, set their character to auto-defend + if session.combat_encounter: + set_character_auto_mode(session.combat_encounter, user_id, mode="defend") + + # Notify other players + notify_other_players(session, user_id, "player_disconnected", { + "username": member.username, + "message": f"{member.username} has disconnected" + }) + + # If host disconnects, promote new host + if member.is_host: + promote_new_host(session) +``` + +**Auto-Defend Mode:** +When a player is disconnected, their character automatically: +- Takes "Defend" action on their turn (reduces incoming damage) +- Skips any decision-making +- Continues until player reconnects or session ends + +--- + +## Rewards and Character Updates + +### Reward Distribution + +At session end (completion or expiration), rewards are distributed: + +```python +def distribute_rewards(session: MultiplayerSession, rewards: CampaignRewards) -> None: + """Distribute rewards to all party members.""" + + for member in session.party_members: + character = get_character(member.character_id) + + # Grant gold + character.gold += rewards.gold_per_player + + # Grant XP (check for level up) + character.experience += rewards.experience_per_player + leveled_up = check_level_up(character) + + # Grant shared items (distribute evenly) + distributed_items = distribute_shared_items(rewards.shared_items, len(session.party_members)) + for item_id in distributed_items: + item = load_item(item_id) + character.inventory.append(item) + + # Save character updates + update_character(character) + + # Notify player + notify_player(member.user_id, "rewards_received", { + "gold": rewards.gold_per_player, + "experience": rewards.experience_per_player, + "items": distributed_items, + "leveled_up": leveled_up + }) +``` + +### Character Snapshot vs Live Character + +**Important Design Decision:** + +When a player joins a multiplayer session, a **snapshot** of their character is taken: + +```python +@dataclass +class PartyMember: + character_id: str # Original character ID + character_snapshot: Character # Immutable copy at session start +``` + +**Why?** +- Multiplayer sessions don't affect character location/state in solo campaigns +- Character in solo game can continue progressing independently +- Only rewards (gold, XP, items) are transferred back at session end + +**Example:** +- Player has "Thorin" at level 5 in Thornfield Plains +- Joins multiplayer session (snapshot taken) +- Multiplayer session takes place in different location +- After 2 hours, session ends +- Thorin gains 800 XP, 300 gold, and levels up to 6 +- **In solo campaign**: Thorin is still in Thornfield Plains, now level 6, with new gold/items + +--- + +## API Endpoints + +### Session Management + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/sessions/multiplayer/create` | POST | Create new multiplayer session (Premium/Elite only) | +| `/api/v1/sessions/multiplayer/join/{invite_code}` | GET | Get session info by invite code | +| `/api/v1/sessions/multiplayer/join/{invite_code}` | POST | Join session with character | +| `/api/v1/sessions/multiplayer/{session_id}` | GET | Get current session state | +| `/api/v1/sessions/multiplayer/{session_id}/ready` | POST | Toggle ready status (lobby) | +| `/api/v1/sessions/multiplayer/{session_id}/start` | POST | Start session (host only, all ready) | +| `/api/v1/sessions/multiplayer/{session_id}/leave` | POST | Leave session | +| `/api/v1/sessions/multiplayer/{session_id}/end` | POST | End session (host only) | + +### Combat Actions + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/sessions/multiplayer/{session_id}/combat/action` | POST | Take combat action (attack, spell, item, defend) | +| `/api/v1/sessions/multiplayer/{session_id}/combat/state` | GET | Get current combat state | + +### Campaign Progress + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/sessions/multiplayer/{session_id}/campaign` | GET | Get campaign overview and progress | +| `/api/v1/sessions/multiplayer/{session_id}/rewards` | GET | Get final rewards (after completion) | + +--- + +## Implementation Timeline + +### Week 12: Core Multiplayer Infrastructure (Days 1-7) + +| Task | Priority | Status | Notes | +|------|----------|--------|-------| +| Create MultiplayerSession dataclass | High | ⬜ | Extends GameSession with time limits, invite codes | +| Create PartyMember dataclass | High | ⬜ | Player info, character snapshot | +| Create MultiplayerCampaign models | High | ⬜ | Campaign, CampaignEncounter, CampaignRewards | +| Implement invite code generation | High | ⬜ | 8-char alphanumeric, unique, 24hr expiration | +| Implement session creation API | High | ⬜ | POST /sessions/multiplayer/create (Premium/Elite only) | +| Implement join via invite API | High | ⬜ | GET/POST /join/{invite_code} | +| Implement lobby system | High | ⬜ | Ready status, player list, host controls | +| Implement 2-hour timer logic | High | ⬜ | Session expiration, warnings, auto-end | +| Set up Appwrite Realtime | High | ⬜ | WebSocket subscriptions for live updates | +| Write unit tests | Medium | ⬜ | Invite generation, join validation, timer logic | + +### Week 13: Campaign Generation & Combat (Days 8-14) + +| Task | Priority | Status | Notes | +|------|----------|--------|-------| +| Implement AI campaign generator | High | ⬜ | Generate 3-5 encounters based on party composition | +| Create campaign templates | Medium | ⬜ | Pre-built campaign structures for AI to fill | +| Implement turn management | High | ⬜ | Initiative, turn order, action validation | +| Implement multiplayer combat flow | High | ⬜ | Reuse Phase 5 combat, add multi-player support | +| Implement disconnect handling | High | ⬜ | Auto-defend mode, host promotion | +| Implement reward distribution | High | ⬜ | Calculate and grant rewards at session end | +| Write integration tests | High | ⬜ | Full session flow: create → join → play → complete | +| Test session expiration | Medium | ⬜ | Force expiration, verify cleanup | + +--- + +## Cost Considerations + +### AI Campaign Generation Cost + +| Tier | Model | Campaign Gen Cost | Per Session | +|------|-------|-------------------|-------------| +| Premium | Claude Sonnet | ~3000 tokens | ~$0.09 | +| Elite | Claude Opus | ~3000 tokens | ~$0.45 | + +**Mitigation:** +- Campaign generation happens once per session +- Can cache campaign templates +- Cost is acceptable for paid-tier feature + +### Realtime Connection Cost + +Appwrite Realtime connections are included in Appwrite Cloud pricing. No additional cost per connection. + +--- + +## Related Documentation + +- **[STORY_PROGRESSION.md](STORY_PROGRESSION.md)** - Solo story gameplay (comparison) +- **[GAME_SYSTEMS.md](GAME_SYSTEMS.md)** - Combat system (reused in multiplayer) +- **[DATA_MODELS.md](DATA_MODELS.md)** - GameSession, CombatEncounter models +- **[API_REFERENCE.md](API_REFERENCE.md)** - Full API endpoint documentation +- **[/public_web/docs/MULTIPLAYER.md](../../public_web/docs/MULTIPLAYER.md)** - Web frontend UI implementation +- **[/godot_client/docs/MULTIPLAYER.md](../../godot_client/docs/MULTIPLAYER.md)** - Godot client implementation + +--- + +**Document Version:** 2.0 (Microservices Split) +**Created:** November 16, 2025 +**Last Updated:** November 18, 2025 diff --git a/api/docs/PHASE4_IMPLEMENTATION.md b/api/docs/PHASE4_IMPLEMENTATION.md new file mode 100644 index 0000000..fe21c5f --- /dev/null +++ b/api/docs/PHASE4_IMPLEMENTATION.md @@ -0,0 +1,940 @@ +# Phase 4: AI Integration + Story Progression + Quest System +## Implementation Plan + +> **Note:** This document contains detailed implementation tasks for developers building the API backend. +> For high-level project roadmap and progress tracking, see [/docs/ROADMAP.md](../../docs/ROADMAP.md). + +**Document Version:** 1.2 +**Created:** November 16, 2025 +**Last Updated:** November 23, 2025 +**Status:** In Progress +**Duration:** 3 weeks (21 days) +**Total Tasks:** 45 tasks +**Completed Tasks:** 22/45 (49%) + +--- + +### Next Tasks +- Task 8.29: Create story gameplay template with HTMX + +--- + +## Overview + +Phase 4 delivers the core single-player gameplay experience for Code of Conquest. This phase integrates AI narrative generation via Replicate (Llama-3 and Claude models), implements turn-based story progression with button-based actions, and creates a context-aware quest system. + +> **Architecture Note:** All AI models (Llama-3, Claude Haiku/Sonnet/Opus) are accessed through the Replicate API for unified billing and management. + +**Key Deliverables:** +- AI narrative generation with tier-based model selection +- Turn-based story progression system +- YAML-driven quest system with context-aware offering +- Cost tracking and usage limits +- Complete solo gameplay loop + +**Development Approach:** +- Tasks are moderately granular (4 hours each) +- Testing bundled into implementation tasks +- Verification checkpoints after major features +- YAML data created inline with features + +--- + +## Week 8: Story Progression System (Days 8-14) + +**Goal:** Implement turn-based story progression with button-based actions + +### Task Group 8: Story UI & Integration (Tasks 29-31) + +#### Task 8.29: Create story gameplay template with HTMX +**Duration:** 5 hours +**File:** `templates/game/story.html` + +**Implementation:** +- Create main story gameplay page layout: + - Header: Character info, turn count, location + - Left sidebar: Quest tracker (placeholder for Week 9) + - Main area: Latest DM response + - Action panel: Available action buttons + - Footer: Custom input (if Premium/Elite) + - Right sidebar: Conversation history (collapsible) +- Add HTMX for dynamic updates: + - Action buttons trigger `hx-post="/api/v1/sessions/{id}/action"` + - Poll job status with `hx-trigger="every 2s"` + - Update content when job completes +- Add loading spinner during AI processing +- Style with dark fantasy theme (match existing CSS) +- Add responsive design (mobile-friendly) +- Write Flask route to render template +- Test UI rendering + +**Dependencies:** Tasks 8.25-8.28 (API endpoints) +**Deliverable:** Story gameplay UI template + +**Key HTMX Patterns:** +```html + + + + +
+ Processing... +
+``` + +--- + +#### Task 8.30: Build action button UI with tier filtering +**Duration:** 4 hours +**File:** `templates/game/story.html` (extend), `app/routes/game.py` + +**Implementation:** +- Create Jinja2 macro for rendering action buttons +- Filter actions based on user tier (passed from backend) +- Show locked actions with upgrade prompt for higher tiers +- Add tooltips with action descriptions +- Implement custom text input area: + - Only visible for Premium/Elite + - Character counter (250 for Premium, 500 for Elite) + - Submit button with validation +- Add HTMX for seamless submission +- Style buttons with RPG aesthetic (icons optional) +- Disable buttons during AI processing +- Write Flask route to provide available actions + +**Dependencies:** Task 8.29 (Story template) +**Deliverable:** Dynamic action button system + +**Jinja2 Macro:** +```jinja +{% macro render_action(action, user_tier, locked=False) %} + +{% endmacro %} +``` + +--- + +#### Task 8.31: ✅ CHECKPOINT - Full story turn integration test +**Duration:** 4 hours + +**Verification Steps:** +1. Create a new session via UI +2. Click an action button +3. Verify loading state appears +4. Wait for AI response +5. Verify DM response displayed +6. Check conversation history updated +7. Verify turn number incremented +8. Test with different action buttons +9. Test custom text input (Premium tier) +10. Verify tier restrictions enforced (Free can't use Premium actions) +11. Test rate limiting +12. Verify Realtime updates work + +**Success Criteria:** +- Full story turn loop works end-to-end +- UI updates smoothly with HTMX +- AI responses are coherent and relevant +- Tier filtering works correctly +- Rate limits enforced +- No errors in browser console or server logs + +--- + +## Week 9: Quest System (Days 15-21) + +**Goal:** Implement YAML-driven quest system with context-aware offering + +### Task Group 9: Quest Data Models (Tasks 32-34) + +#### Task 9.32: Create Quest dataclasses +**Duration:** 5 hours +**File:** `app/models/quest.py` + +**Implementation:** +- Create `QuestObjective` dataclass: + - `objective_id`, `description`, `objective_type` (enum) + - `required_progress`, `current_progress`, `completed` +- Create `QuestReward` dataclass: + - `gold`, `experience`, `items` (List[Item]) + - `reputation` (optional, for future use) +- Create `Quest` dataclass: + - `quest_id`, `name`, `description`, `quest_giver` + - `difficulty` (enum: EASY, MEDIUM, HARD, EPIC) + - `objectives` (List[QuestObjective]) + - `rewards` (QuestReward) + - `offering_triggers` (QuestTriggers) + - `narrative_hooks` (List[str]) + - `status` (enum: AVAILABLE, ACTIVE, COMPLETED, FAILED) +- Implement methods: + - `is_complete()` - Check if all objectives done + - `get_next_objective()` - Get first incomplete objective + - `update_progress(objective_id, amount)` - Increment progress +- Add to_dict() / from_dict() serialization +- Write unit tests for all methods + +**Dependencies:** None +**Deliverable:** Quest data models + +**Example:** +```python +quest = Quest( + quest_id="quest_goblin_cave", + name="Clear the Goblin Cave", + description="A nearby cave is infested with goblins...", + quest_giver="Village Elder", + difficulty=QuestDifficulty.EASY, + objectives=[ + QuestObjective( + objective_id="kill_goblins", + description="Defeat 5 goblins", + objective_type=ObjectiveType.KILL, + required_progress=5, + current_progress=0 + ) + ], + rewards=QuestReward(gold=50, experience=100, items=[]) +) + +quest.update_progress("kill_goblins", 1) +quest.is_complete() # False (4 more goblins needed) +``` + +--- + +#### Task 9.33: Create QuestTriggers with offering logic +**Duration:** 4 hours +**File:** `app/models/quest.py` (extend) + +**Implementation:** +- Create `QuestTriggers` dataclass: + - `location_types` (List[LocationType]) - Where quest can be offered + - `specific_locations` (List[str]) - Optional specific location names + - `min_character_level`, `max_character_level` (int) + - `required_quests_completed` (List[str]) - Quest prerequisites + - `probability_weights` (Dict[LocationType, float]) - Offer chance by location +- Implement methods: + - `can_offer(character_level, completed_quests)` - Check eligibility + - `get_offer_probability(location_type)` - Get chance for location +- Add validation (probabilities 0.0-1.0) +- Write unit tests for offering logic +- Document trigger system in docstrings + +**Dependencies:** Task 9.32 (Quest model) +**Deliverable:** Quest offering trigger system + +**Example:** +```python +triggers = QuestTriggers( + location_types=[LocationType.TOWN, LocationType.TAVERN], + min_character_level=1, + max_character_level=5, + probability_weights={ + LocationType.TOWN: 0.30, + LocationType.TAVERN: 0.35 + } +) + +triggers.can_offer(character_level=3, completed_quests=[]) # True +triggers.get_offer_probability(LocationType.TAVERN) # 0.35 (35% chance) +``` + +--- + +#### Task 9.34: ✅ CHECKPOINT - Verify quest model serialization +**Duration:** 2 hours + +**Verification Steps:** +1. Create sample quest with all fields +2. Convert to dict with to_dict() +3. Serialize to JSON +4. Deserialize from JSON +5. Recreate quest with from_dict() +6. Verify all fields match original +7. Test quest methods (is_complete, update_progress) +8. Test trigger methods (can_offer, get_offer_probability) +9. Test edge cases (invalid progress, level requirements) + +**Success Criteria:** +- Quest serialization round-trips correctly +- All methods work as expected +- Offering logic accurate +- No data loss during serialization + +--- + +### Task Group 10: Quest Content & Loading (Tasks 35-38) + +#### Task 9.35: Create quest YAML schema +**Duration:** 3 hours +**File:** `app/data/quests/schema.yaml` (documentation), update `docs/QUEST_SYSTEM.md` + +**Implementation:** +- Document YAML structure for quests +- Define all required and optional fields +- Provide examples for each objective type +- Document narrative_hooks usage +- Create template quest file +- Add validation rules +- Update QUEST_SYSTEM.md with schema details + +**Dependencies:** Task 9.32 (Quest models) +**Deliverable:** Quest YAML schema documentation + +**Schema Example:** +```yaml +quest_id: quest_goblin_cave +name: Clear the Goblin Cave +description: | + A nearby cave has been overrun by goblins who are raiding nearby farms. + The village elder asks you to clear them out. +quest_giver: Village Elder +difficulty: EASY + +objectives: + - objective_id: kill_goblins + description: Defeat 5 goblins + objective_type: KILL + required_progress: 5 + +rewards: + gold: 50 + experience: 100 + items: [] + +offering_triggers: + location_types: [TOWN, TAVERN] + min_character_level: 1 + max_character_level: 5 + probability_weights: + TOWN: 0.30 + TAVERN: 0.35 + +narrative_hooks: + - "The village elder looks worried about recent goblin attacks" + - "You hear farmers complaining about lost livestock" + - "A town guard mentions a cave to the north" +``` + +--- + +#### Task 9.36: Write 10 example quests +**Duration:** 5 hours +**Files:** `app/data/quests/easy/*.yaml`, `app/data/quests/medium/*.yaml`, etc. + +**Implementation:** +- Create quest files organized by difficulty: + - **Easy (4 quests):** Levels 1-3, simple objectives + 1. Clear Goblin Cave (kill 5 goblins) + 2. Gather Healing Herbs (collect 10 herbs) + 3. Deliver Message to Town (travel to location) + 4. Find Lost Cat (discover location) + - **Medium (3 quests):** Levels 3-7, multi-objective + 5. Investigate Bandit Camp (kill + collect + discover) + 6. Rescue Kidnapped Villager (travel + interact) + 7. Ancient Artifact Recovery (discover + collect) + - **Hard (2 quests):** Levels 7-10, complex chains + 8. Stop the Necromancer (multi-step with prerequisites) + 9. Dragon's Hoard (discover + kill boss + collect) + - **Epic (1 quest):** Level 10+, multi-chapter + 10. The Demon Lord's Return (epic multi-objective chain) +- Include rich narrative_hooks for each quest +- Vary reward amounts by difficulty +- Add location variety +- Ensure proper level gating + +**Dependencies:** Task 9.35 (YAML schema) +**Deliverable:** 10 complete quest YAML files + +--- + +#### Task 9.37: Implement QuestService with YAML loader +**Duration:** 5 hours +**File:** `app/services/quest_service.py` + +**Implementation:** +- Create `QuestService` class +- Implement `load_quests_from_yaml(directory)` method +- Parse all YAML files in quest directory +- Convert to Quest objects +- Validate quest structure and fields +- Cache loaded quests in memory +- Implement methods: + - `get_quest_by_id(quest_id)` + - `get_eligible_quests(character_level, location_type, completed_quests)` + - `filter_by_difficulty(difficulty)` + - `get_all_quests()` +- Add error handling for malformed YAML +- Write unit tests with sample quests +- Add logging for quest loading + +**Dependencies:** Task 9.36 (Quest YAML files) +**Deliverable:** Quest loading and filtering service + +**Usage:** +```python +service = QuestService() +service.load_quests_from_yaml("app/data/quests/") + +# Get eligible quests +eligible = service.get_eligible_quests( + character_level=3, + location_type=LocationType.TAVERN, + completed_quests=[] +) +# Returns: [Quest(...), Quest(...)] +``` + +--- + +#### Task 9.38: ✅ CHECKPOINT - Verify quest loading and validation +**Duration:** 2 hours + +**Verification Steps:** +1. Load all 10 quests from YAML files +2. Verify all quests parsed correctly +3. Check quest filtering by level works +4. Test filtering by location type +5. Verify offering probability calculations +6. Test get_eligible_quests() with various inputs +7. Verify error handling for invalid YAML +8. Check quest caching works + +**Success Criteria:** +- All 10 quests load without errors +- Filtering logic accurate +- Offering probabilities correct +- No performance issues loading quests +- Error handling graceful + +--- + +### Task Group 11: Quest Offering & Management (Tasks 39-42) + +#### Task 9.39: Implement context-aware quest offering logic +**Duration:** 5 hours +**File:** `app/services/quest_offering_service.py` + +**Implementation:** +- Create `QuestOfferingService` class +- Implement two-stage offering: + 1. **Location probability roll:** + - Get location type probability from triggers + - Roll random 0.0-1.0, check if < probability + - If fail, no quest offered + 2. **Context-aware AI selection:** + - Get eligible quests from QuestService + - Build AI prompt with narrative_hooks + - Ask AI to select most contextually relevant quest + - Parse AI response to get selected quest_id +- Implement `should_offer_quest(location_type)` method (probability roll) +- Implement `select_quest_for_context(eligible_quests, game_context)` method (AI selection) +- Implement main `offer_quest(session_id)` method (full flow) +- Add validation (max 2 active quests) +- Write integration tests with mocked AI +- Add logging for quest offerings + +**Dependencies:** Tasks 7.10 (NarrativeGenerator), 9.37 (QuestService) +**Deliverable:** Context-aware quest offering system + +**Flow:** +```python +offering_service = QuestOfferingService() + +# Called after each story turn +if offering_service.should_offer_quest(LocationType.TAVERN): + eligible = quest_service.get_eligible_quests(...) + selected_quest = offering_service.select_quest_for_context( + eligible_quests=eligible, + game_context={ + "location": "The Rusty Anchor", + "recent_actions": ["talked to locals", "rested"], + "active_quests": [] + } + ) + # Returns: Quest object or None +``` + +--- + +#### Task 9.40: Integrate quest offering into story turns +**Duration:** 4 hours +**File:** `app/tasks/ai_tasks.py` (extend generate_dm_response job) + +**Implementation:** +- Update `generate_dm_response()` RQ job +- After AI narrative generated and before saving: + 1. Check if quest should be offered (probability roll) + 2. If yes, get eligible quests + 3. Call AI to select contextually relevant quest + 4. Add quest offering to response data + 5. Store offered quest in session state (pending acceptance) +- Add quest offering to conversation entry: + ```python + { + "turn": 5, + "action": "...", + "dm_response": "...", + "quest_offered": { + "quest_id": "quest_goblin_cave", + "quest_name": "Clear the Goblin Cave" + } + } + ``` +- Update API response format to include quest offering +- Write integration tests for offering flow +- Add logging for quest offerings + +**Dependencies:** Task 9.39 (Quest offering logic) +**Deliverable:** Integrated quest offering in story turns + +**Updated Response:** +```json +{ + "status": 200, + "result": { + "dm_response": "As you chat with the locals...", + "quest_offered": { + "quest_id": "quest_goblin_cave", + "name": "Clear the Goblin Cave", + "description": "...", + "rewards": {"gold": 50, "experience": 100} + } + } +} +``` + +--- + +#### Task 9.41: Implement quest accept endpoint +**Duration:** 4 hours +**File:** `app/routes/quests.py` (new file) + +**Implementation:** +- Create `POST /api/v1/quests/accept` endpoint +- Validate request body: + ```json + { + "session_id": "sess_789", + "quest_id": "quest_goblin_cave" + } + ``` +- Validate quest is currently offered to session +- Check max 2 active quests limit +- Add quest to session's active_quests +- Initialize quest with status ACTIVE +- Store quest state in character (or session) +- Return accepted quest details +- Add @require_auth decorator +- Write integration tests +- Document in API_REFERENCE.md + +**Dependencies:** Task 9.39 (Quest offering) +**Deliverable:** Quest acceptance endpoint + +**Response:** +```json +{ + "status": 200, + "result": { + "quest_id": "quest_goblin_cave", + "status": "ACTIVE", + "objectives": [ + { + "objective_id": "kill_goblins", + "description": "Defeat 5 goblins", + "progress": "0/5" + } + ] + } +} +``` + +--- + +#### Task 9.42: Implement quest complete endpoint with rewards +**Duration:** 5 hours +**File:** `app/routes/quests.py` (extend) + +**Implementation:** +- Create `POST /api/v1/quests/complete` endpoint +- Validate request body: + ```json + { + "session_id": "sess_789", + "quest_id": "quest_goblin_cave" + } + ``` +- Verify quest is active for session +- Check all objectives completed +- Grant rewards: + - Add gold to character + - Add experience to character + - Add items to inventory + - Check for level up +- Update quest status to COMPLETED +- Remove from active_quests +- Add to completed_quests list +- Return completion details with level up info +- Add @require_auth decorator +- Write integration tests +- Document in API_REFERENCE.md + +**Dependencies:** Task 9.41 (Quest accept) +**Deliverable:** Quest completion and reward system + +**Response:** +```json +{ + "status": 200, + "result": { + "quest_id": "quest_goblin_cave", + "status": "COMPLETED", + "rewards_granted": { + "gold": 50, + "experience": 100, + "items": [] + }, + "level_up": { + "leveled_up": true, + "new_level": 4, + "skill_points_gained": 1 + } + } +} +``` + +--- + +### Task Group 12: Quest UI & Final Testing (Tasks 43-45) + +#### Task 9.43: Create quest tracker sidebar UI +**Duration:** 4 hours +**File:** `templates/game/story.html` (extend left sidebar) + +**Implementation:** +- Add quest tracker to left sidebar +- Display active quests (max 2) +- Show quest name and description +- Display objective progress (X/Y format) +- Add "View Details" button for each quest +- Style with RPG theme +- Add HTMX for dynamic updates when objectives progress +- Show "No active quests" message when empty +- Add quest complete notification (toast/modal) +- Test UI rendering + +**Dependencies:** Tasks 9.41-9.42 (Quest endpoints) +**Deliverable:** Quest tracker sidebar UI + +**UI Structure:** +```html +
+

Active Quests ({{ active_quests|length }}/2)

+ {% for quest in active_quests %} +
+

{{ quest.name }}

+
+ {% for obj in quest.objectives %} +
+ {{ obj.description }}: {{ obj.current_progress }}/{{ obj.required_progress }} +
+ {% endfor %} +
+ +
+ {% endfor %} +
+``` + +--- + +#### Task 9.44: Create quest offering modal UI +**Duration:** 4 hours +**File:** `templates/game/partials/quest_offer_modal.html` + +**Implementation:** +- Create modal component for quest offering +- Display when quest offered in DM response +- Show quest name, description, quest giver +- Display objectives and rewards clearly +- Add "Accept Quest" button (HTMX post to /api/v1/quests/accept) +- Add "Decline" button (closes modal) +- Style modal with RPG theme (parchment background) +- Add HTMX to update quest tracker when accepted +- Show error if max quests reached (2/2) +- Test modal behavior + +**Dependencies:** Task 9.41 (Quest accept endpoint) +**Deliverable:** Quest offering modal UI + +**Modal Structure:** +```html + +``` + +--- + +#### Task 9.45: ✅ FINAL CHECKPOINT - Full quest integration test +**Duration:** 4 hours + +**Comprehensive Test Flow:** + +**Setup:** +1. Create new character (level 1) +2. Create solo session +3. Verify starting state + +**Quest Offering:** +4. Take multiple story actions in a town/tavern +5. Wait for quest offering (may take several turns) +6. Verify quest modal appears +7. Check quest details display correctly +8. Accept quest +9. Verify quest appears in tracker (1/2 active) + +**Quest Progress:** +10. Simulate quest progress (update objective manually via API or through combat) +11. Verify tracker updates in real-time +12. Complete all objectives +13. Verify completion indicator + +**Quest Completion:** +14. Call complete quest endpoint +15. Verify rewards granted (gold, XP) +16. Check for level up if applicable +17. Verify quest removed from active list +18. Verify quest in completed list + +**Edge Cases:** +19. Try accepting 3rd quest (should fail with max 2 message) +20. Try completing incomplete quest (should fail) +21. Test with ineligible quest (wrong level) +22. Verify offering probabilities work (multiple sessions) + +**Success Criteria:** +- Full quest lifecycle works end-to-end +- Quest offering feels natural in story flow +- UI updates smoothly with HTMX +- Rewards granted correctly +- Level up system works +- Max 2 quest limit enforced +- Error handling graceful +- No bugs in browser console or server logs + +--- + +## Deferred Tasks + +### Task 7.15: Set up cost monitoring and alerts +**Duration:** 3 hours +**Files:** `app/tasks/monitoring_tasks.py`, `app/services/alert_service.py` + +**Implementation:** +- Create `calculate_daily_cost()` RQ job (runs daily at midnight) +- Aggregate all AI usage from previous day +- Calculate total cost by summing estimated costs +- Store daily cost in Redis timeseries +- Create `AlertService` class +- Implement alert triggers: + - Daily cost > $50 → Warning email + - Daily cost > $100 → Critical email + - Monthly projection > $1500 → Warning email +- Add email sending via SMTP or service (e.g., SendGrid) +- Create admin dashboard endpoint: `GET /admin/costs` +- Write tests for cost calculation + +**Dependencies:** Task 7.13 (Usage tracking) +**Deliverable:** Automated cost monitoring with alerts + +**Daily Job:** +```python +# Runs at midnight UTC +@job('monitoring_tasks') +def calculate_daily_cost(): + yesterday = date.today() - timedelta(days=1) + total_cost = sum_all_user_costs(yesterday) + + if total_cost > 100: + send_alert(f"CRITICAL: Daily AI cost ${total_cost}") + elif total_cost > 50: + send_alert(f"WARNING: Daily AI cost ${total_cost}") + + store_cost_metric(yesterday, total_cost) +``` + +--- + +## Phase 4 Complete! + +**Deliverables Summary:** + +### Week 7: AI Engine Foundation ✅ +- Redis/RQ infrastructure working +- AI clients for Replicate + Anthropic (Haiku/Sonnet/Opus) +- Model selection with tier-based routing +- Jinja2 prompt templates (4 types) +- Narrative generator wrapper +- Async AI task jobs with Appwrite integration +- Usage tracking and cost monitoring +- Daily limits per tier enforced + +### Week 8: Story Progression System ✅ +- 10 action prompts defined in YAML +- ActionPrompt loader with tier/context filtering +- Solo GameSession model with state tracking +- SessionService for CRUD operations +- Conversation history management +- 4 API endpoints (create, state, action, history) +- Story gameplay UI with HTMX +- Dynamic action buttons with tier filtering +- Full story turn loop working + +### Week 9: Quest System ✅ +- Quest data models (Quest, Objective, Reward, Triggers) +- 10 example quests in YAML (4 easy, 3 medium, 2 hard, 1 epic) +- QuestService with YAML loader +- Context-aware quest offering logic +- Quest offering integrated into story turns +- Quest accept/complete API endpoints +- Quest tracker sidebar UI +- Quest offering modal UI +- Full quest lifecycle tested + +**Next Phase:** Phase 5 - Combat System + Skill Tree UI (Week 10-11) + +--- + +## Dependencies Graph + +``` +Week 7 (AI Engine): +Task 7.1 (Redis) → 7.2 (RQ) → 7.3 (AI jobs) → 7.4 (✅ Verify) +Task 7.5 (Replicate+Claude) → 7.7 (Model selector) → 7.8 (✅ Verify) + │ +Task 7.9 (Templates) ───────────────────┘→ 7.10 (Narrative gen) → 7.11 (AI jobs) → 7.12 (✅ Verify) + │ +Task 7.13 (Usage track) → 7.14 (Rate limits) ────────────────────────┘ + └→ 7.15 (Cost monitoring) + +Note: Task 7.6 merged into 7.5 (all models via Replicate API) + +Week 8 (Story Progression): +Task 8.16 (ActionPrompt) → 8.17 (YAML) → 8.18 (Loader) → 8.19 (✅ Verify) + │ +Task 8.20 (GameSession) → 8.21 (SessionService) ──────────┤ + └→ 8.22 (History) ──────────────────┤ + └→ 8.23 (State tracking) → 8.24 (✅ Verify) + │ +Task 8.25 (Create API) ───────────────────────────┤ +Task 8.26 (Action API) ───────────────────────────┤ +Task 8.27 (State API) ────────────────────────────┤ +Task 8.28 (History API) ──────────────────────────┤ + │ +Task 8.29 (Story UI) → 8.30 (Action buttons) → 8.31 (✅ Integration test) + +Week 9 (Quest System): +Task 9.32 (Quest models) → 9.33 (Triggers) → 9.34 (✅ Verify) + │ +Task 9.35 (YAML schema) → 9.36 (10 quests) → 9.37 (QuestService) → 9.38 (✅ Verify) + │ +Task 9.39 (Offering logic) ──────────────────────────────────────────┤ +Task 9.40 (Story integration) → 9.41 (Accept API) → 9.42 (Complete API) + │ +Task 9.43 (Tracker UI) ──────────────────────────────┤ +Task 9.44 (Offer modal) ─────────────────────────────┤ + │ +Task 9.45 (✅ Final integration test) +``` + +--- + +## Notes + +**Testing Strategy:** +- Unit tests bundled into each implementation task +- Integration tests at verification checkpoints +- Manual testing for UI/UX flows +- Use docs/API_TESTING.md for endpoint testing + +**Cost Management:** +- Target: < $500/month total AI costs +- Free tier users cost $0 (Replicate) +- Monitor daily costs via Task 7.15 +- Adjust tier limits if costs spike + +**Development Tips:** +- Start each week by reviewing previous week's work +- Commit frequently with conventional commit messages +- Update API_REFERENCE.md as you build endpoints +- Test with real AI calls periodically (not just mocks) +- Keep YAML files well-documented and validated + +**Estimated Timeline:** +- Week 7: ~40 hours (5 days at 8 hours/day) +- Week 8: ~44 hours (5.5 days at 8 hours/day) +- Week 9: ~42 hours (5.25 days at 8 hours/day) +- **Total: ~126 hours (~16 days of focused work)** + +**Success Metrics:** +- All 45 tasks completed +- All verification checkpoints passed +- No critical bugs in core gameplay loop +- AI costs within budget (<$50/day during development) +- Story progression feels engaging and responsive +- Quest system feels natural and rewarding + +--- + +**Document History:** +- v1.0 (2025-11-16): Initial Phase 4 implementation plan created diff --git a/api/docs/PROMPT_TEMPLATES.md b/api/docs/PROMPT_TEMPLATES.md new file mode 100644 index 0000000..b738d83 --- /dev/null +++ b/api/docs/PROMPT_TEMPLATES.md @@ -0,0 +1,530 @@ +# Prompt Templates Documentation + +## Overview + +The prompt template system uses Jinja2 to build consistent, well-structured prompts for AI generation. Templates separate prompt logic from application code, making prompts easy to iterate and maintain. + +**Location:** `app/ai/prompt_templates.py` and `app/ai/templates/*.j2` + +--- + +## Architecture + +``` +PromptTemplates (singleton) + ├── Jinja2 Environment + │ ├── FileSystemLoader (templates directory) + │ ├── Custom filters (format_inventory, etc.) + │ └── Global functions (len, min, max) + └── Templates + ├── story_action.j2 + ├── combat_action.j2 + ├── quest_offering.j2 + └── npc_dialogue.j2 +``` + +--- + +## Basic Usage + +### Quick Start + +```python +from app.ai.prompt_templates import render_prompt + +# Render a template with context +prompt = render_prompt( + "story_action.j2", + character={"name": "Aldric", "level": 3, "player_class": "Fighter", ...}, + action="I search for hidden doors", + game_state={"current_location": "Ancient Library", ...} +) +``` + +### Using the PromptTemplates Class + +```python +from app.ai.prompt_templates import get_prompt_templates, PromptTemplates + +# Get singleton instance +templates = get_prompt_templates() + +# Or create custom instance with different directory +templates = PromptTemplates(template_dir="/path/to/custom/templates") + +# Render template +prompt = templates.render("story_action.j2", **context) + +# Render from string +prompt = templates.render_string( + "Hello {{ name }}, you are level {{ level }}", + name="Aldric", + level=3 +) + +# List available templates +available = templates.get_template_names() +# ['story_action.j2', 'combat_action.j2', ...] +``` + +--- + +## Available Templates + +### 1. Story Action (`story_action.j2`) + +Generates DM responses to player story actions. + +**Required Context:** +- `character` - Character dict with name, level, player_class, stats, current_hp, max_hp +- `game_state` - Dict with current_location, location_type, active_quests +- `action` - String describing the player's action + +**Optional Context:** +- `conversation_history` - List of recent turns (turn, action, dm_response) +- `world_context` - Additional world information +- `action_instructions` - Action-specific AI instructions from `dm_prompt_template` in action_prompts.yaml +- `max_tokens` - Token limit for response length guidance + +**Player Agency Rules:** + +The template includes critical rules to ensure AI respects player choice: +- Never make decisions for the player (no auto-purchasing) +- Never complete transactions without consent +- Present options and ask what they want to do +- If items have costs, show prices and ask to proceed + +**Example:** +```python +prompt = render_prompt( + "story_action.j2", + character={ + "name": "Aldric", + "level": 3, + "player_class": "Fighter", + "current_hp": 25, + "max_hp": 30, + "stats": {"strength": 16, "dexterity": 14, "constitution": 15}, + "skills": [{"name": "Athletics", "level": 2}], + "effects": [{"name": "Blessed", "remaining_turns": 3}] + }, + game_state={ + "current_location": "Ancient Library", + "location_type": "DUNGEON", + "active_quests": ["find_artifact"], + "discovered_locations": ["Village", "Forest"], + "time_of_day": "Evening" + }, + action="I search the room for hidden doors", + conversation_history=[ + {"turn": 1, "action": "entered library", "dm_response": "You push open the heavy oak doors..."}, + {"turn": 2, "action": "examined shelves", "dm_response": "The shelves contain dusty tomes..."} + ] +) +``` + +**Output Structure:** +- Character status (HP, stats, skills, effects) +- Current situation (location, quests, time) +- Recent history (last 3 turns) +- Player action +- Action-specific instructions (if provided) +- Generation task with length guidance +- Player agency rules + +--- + +### 2. Combat Action (`combat_action.j2`) + +Narrates combat actions with dramatic flair. + +**Required Context:** +- `character` - Character dict +- `combat_state` - Dict with enemies, round_number, current_turn +- `action` - Combat action description +- `action_result` - Dict with hit, damage, effects_applied, target + +**Optional Context:** +- `is_critical` - Boolean for critical hits +- `is_finishing_blow` - Boolean if enemy is defeated + +**Example:** +```python +prompt = render_prompt( + "combat_action.j2", + character={ + "name": "Aldric", + "level": 3, + "player_class": "Fighter", + "current_hp": 20, + "max_hp": 30, + "effects": [] + }, + combat_state={ + "round_number": 3, + "current_turn": "Aldric", + "enemies": [ + {"name": "Goblin Chief", "current_hp": 8, "max_hp": 25, "effects": []} + ] + }, + action="swings their longsword at the Goblin Chief", + action_result={ + "hit": True, + "damage": 12, + "effects_applied": ["bleeding"], + "target": "Goblin Chief" + }, + is_critical=True, + is_finishing_blow=False +) +``` + +**Output Structure:** +- Combatants (player and enemies with HP/effects) +- Combat round and turn +- Action and result +- Narrative instructions (1-2 paragraphs) + +--- + +### 3. Quest Offering (`quest_offering.j2`) + +AI selects the most contextually appropriate quest. + +**Required Context:** +- `character` - Character dict with completed_quests +- `eligible_quests` - List of quest dicts +- `game_context` - Dict with current_location, location_type, active_quests + +**Optional Context:** +- `recent_actions` - List of recent player action strings + +**Example:** +```python +prompt = render_prompt( + "quest_offering.j2", + character={ + "name": "Aldric", + "level": 3, + "player_class": "Fighter", + "completed_quests": ["tutorial_quest"] + }, + eligible_quests=[ + { + "quest_id": "goblin_cave", + "name": "Clear the Goblin Cave", + "difficulty": "EASY", + "quest_giver": "Village Elder", + "description": "A nearby cave has been overrun by goblins...", + "narrative_hooks": [ + "The village elder looks worried about recent goblin attacks", + "Farmers complain about lost livestock" + ] + }, + { + "quest_id": "herb_gathering", + "name": "Gather Healing Herbs", + "difficulty": "EASY", + "quest_giver": "Herbalist", + "description": "The local herbalist needs rare herbs...", + "narrative_hooks": [ + "The herbalist mentions a shortage of supplies", + "You notice the apothecary shelves are nearly bare" + ] + } + ], + game_context={ + "current_location": "The Rusty Anchor Tavern", + "location_type": "TAVERN", + "active_quests": [], + "world_events": ["goblin raids increasing"] + }, + recent_actions=["asked about rumors", "talked to locals"] +) +``` + +**Output:** Just the quest_id (e.g., `goblin_cave`) + +--- + +### 4. NPC Dialogue (`npc_dialogue.j2`) + +Generates contextual NPC conversations. + +**Required Context:** +- `character` - Player character dict +- `npc` - NPC dict with name, role, personality +- `conversation_topic` - What the player wants to discuss +- `game_state` - Current game state + +**Optional Context:** +- `npc_relationship` - Description of relationship +- `previous_dialogue` - List of exchanges (player_line, npc_response) +- `npc_knowledge` - List of things this NPC knows + +**Example:** +```python +prompt = render_prompt( + "npc_dialogue.j2", + character={ + "name": "Aldric", + "level": 3, + "player_class": "Fighter" + }, + npc={ + "name": "Old Barkeep", + "role": "Tavern Owner", + "personality": "Gruff but kind-hearted", + "speaking_style": "Short sentences, occasional grunt", + "goals": "Keep the tavern running, protect the village", + "secret_knowledge": "Knows about the hidden cellar entrance" + }, + conversation_topic="What rumors have you heard lately?", + game_state={ + "current_location": "The Rusty Anchor", + "time_of_day": "Evening", + "active_quests": [] + }, + npc_relationship="Acquaintance - met twice before", + npc_knowledge=["goblin attacks", "missing merchant", "ancient ruins"], + previous_dialogue=[ + { + "player_line": "I'll have an ale", + "npc_response": "*slides a mug across the bar* Two copper. You new around here?" + } + ] +) +``` + +**Output:** NPC dialogue with action/emotion tags +Format: `*action* "Dialogue here."` + +--- + +## Custom Filters + +Templates have access to these formatting filters: + +### `format_inventory` + +Formats item lists with quantities. + +```jinja2 +{{ items | format_inventory }} +{# Output: "Health Potion (x3), Sword, Shield, and 5 more items" #} +``` + +**Parameters:** `max_items` (default 10) + +### `format_stats` + +Formats stat dictionaries. + +```jinja2 +{{ character.stats | format_stats }} +{# Output: "Strength: 16, Dexterity: 14, Constitution: 15" #} +``` + +### `format_skills` + +Formats skill lists with levels. + +```jinja2 +{{ character.skills | format_skills }} +{# Output: "Athletics (Lv.2), Perception (Lv.3), and 2 more skills" #} +``` + +**Parameters:** `max_skills` (default 5) + +### `format_effects` + +Formats active effects/buffs/debuffs. + +```jinja2 +{{ character.effects | format_effects }} +{# Output: "Blessed (3 turns), Poisoned (2 turns)" #} +``` + +### `truncate_text` + +Truncates long text with ellipsis. + +```jinja2 +{{ long_description | truncate_text(100) }} +{# Output: "This is a very long description that will be cut off..." #} +``` + +**Parameters:** `max_length` (default 100) + +### `format_gold` + +Formats currency with commas. + +```jinja2 +{{ 10000 | format_gold }} +{# Output: "10,000 gold" #} +``` + +--- + +## Global Functions + +Available in all templates: + +```jinja2 +{{ len(items) }} {# Length of list #} +{{ min(a, b) }} {# Minimum value #} +{{ max(a, b) }} {# Maximum value #} +{% for i, item in enumerate(items) %} + {{ i }}: {{ item }} +{% endfor %} +``` + +--- + +## Creating New Templates + +### Template Structure + +1. Create file in `app/ai/templates/` with `.j2` extension +2. Add documentation header with required/optional context +3. Structure the prompt with clear sections +4. End with task instructions + +**Template Example:** +```jinja2 +{# +My Custom Template +Description of what this template does. + +Required context: +- param1: Description +- param2: Description + +Optional context: +- param3: Description +#} + +## Section Header +Content using {{ param1 }} + +{% if param3 %} +## Optional Section +{{ param3 }} +{% endif %} + +## Task Instructions +Tell the AI what to do... +``` + +### Best Practices + +1. **Clear documentation** - Always document required/optional context +2. **Structured output** - Use markdown headers for readability +3. **Graceful fallbacks** - Use `{% if %}` for optional fields +4. **Concise prompts** - Include only necessary context +5. **Explicit instructions** - Tell AI exactly what to output + +--- + +## Error Handling + +```python +from app.ai.prompt_templates import PromptTemplateError + +try: + prompt = render_prompt("unknown.j2", **context) +except PromptTemplateError as e: + logger.error(f"Template error: {e}") + # Handle missing template or rendering error +``` + +Common errors: +- Template not found +- Missing required context variables +- Filter errors (e.g., None passed to filter) + +--- + +## Configuration + +### Template Directory + +Default: `app/ai/templates/` + +Custom directory: +```python +templates = PromptTemplates(template_dir="/custom/path") +``` + +### Jinja2 Settings + +- `trim_blocks=True` - Remove newline after block tags +- `lstrip_blocks=True` - Remove leading whitespace before block tags +- `autoescape=True` - Escape HTML/XML (security) + +--- + +## Integration with NarrativeGenerator + +The `NarrativeGenerator` uses templates internally: + +```python +from app.ai.narrative_generator import NarrativeGenerator + +generator = NarrativeGenerator() + +# Uses story_action.j2 internally +response = generator.generate_story_response( + character=character, + action=action, + game_state=game_state, + user_tier=UserTier.PREMIUM +) +``` + +--- + +## Testing Templates + +### Unit Test Pattern + +```python +from app.ai.prompt_templates import PromptTemplates + +def test_story_action_template(): + templates = PromptTemplates() + + context = { + "character": { + "name": "Test", + "level": 1, + "player_class": "Fighter", + "current_hp": 10, + "max_hp": 10, + "stats": {"strength": 10} + }, + "game_state": { + "current_location": "Test Location", + "location_type": "TOWN" + }, + "action": "Test action" + } + + prompt = templates.render("story_action.j2", **context) + + assert "Test" in prompt + assert "Fighter" in prompt + assert "Test action" in prompt +``` + +### Manual Testing + +```python +from app.ai.prompt_templates import render_prompt + +# Render and print to inspect +prompt = render_prompt("story_action.j2", **test_context) +print(prompt) +print(f"\nLength: {len(prompt)} chars") +``` diff --git a/api/docs/QUEST_SYSTEM.md b/api/docs/QUEST_SYSTEM.md new file mode 100644 index 0000000..fabd73e --- /dev/null +++ b/api/docs/QUEST_SYSTEM.md @@ -0,0 +1,927 @@ +# Quest System + +**Status:** Planned +**Phase:** 4 (AI Integration + Story Progression) +**Timeline:** Week 9 of Phase 4 (Days 13-14) +**Last Updated:** November 16, 2025 + +--- + +## Overview + +The Quest System provides structured objectives and rewards for players during their solo story progression sessions. Quests are defined in YAML files and offered to players by the AI Dungeon Master based on context-aware triggers and location-based probability. + +**Key Principles:** +- **YAML-driven design** - Quests defined in data files, no code changes needed +- **Context-aware offering** - AI analyzes narrative context to offer relevant quests +- **Location-based triggers** - Different areas have different quest probabilities +- **Max 2 active quests** - Prevents player overwhelm +- **Random but meaningful** - Quest offering feels natural, not forced +- **Rewarding progression** - Quests provide gold, XP, and items + +--- + +## Quest Structure + +### Quest Components + +A quest consists of: + +1. **Metadata** - Name, description, difficulty, quest giver +2. **Objectives** - Specific goals to complete (ordered or unordered) +3. **Rewards** - Gold, XP, items awarded upon completion +4. **Offering Triggers** - Context and location requirements +5. **Narrative Hooks** - Story fragments for AI to use in offering + +--- + +## Data Models + +### Quest Dataclass + +```python +@dataclass +class Quest: + """Represents a quest with objectives and rewards.""" + + quest_id: str # Unique identifier (e.g., "quest_rats_tavern") + name: str # Display name (e.g., "Rat Problem") + description: str # Full quest description + quest_giver: str # NPC or source (e.g., "Tavern Keeper") + difficulty: str # "easy", "medium", "hard", "epic" + objectives: List[QuestObjective] # List of objectives to complete + rewards: QuestReward # Rewards for completion + offering_triggers: QuestTriggers # When/where quest can be offered + narrative_hooks: List[str] # Story snippets for AI to use + status: str = "available" # "available", "active", "completed", "failed" + progress: Dict[str, Any] = field(default_factory=dict) # Objective progress tracking + + def is_complete(self) -> bool: + """Check if all objectives are completed.""" + return all(obj.completed for obj in self.objectives) + + def get_next_objective(self) -> Optional[QuestObjective]: + """Get the next incomplete objective.""" + for obj in self.objectives: + if not obj.completed: + return obj + return None + + def update_progress(self, objective_id: str, progress_value: int) -> None: + """Update progress for a specific objective.""" + for obj in self.objectives: + if obj.objective_id == objective_id: + obj.current_progress = min(progress_value, obj.required_progress) + if obj.current_progress >= obj.required_progress: + obj.completed = True + break + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dictionary for JSON storage.""" + return { + "quest_id": self.quest_id, + "name": self.name, + "description": self.description, + "quest_giver": self.quest_giver, + "difficulty": self.difficulty, + "objectives": [obj.to_dict() for obj in self.objectives], + "rewards": self.rewards.to_dict(), + "offering_triggers": self.offering_triggers.to_dict(), + "narrative_hooks": self.narrative_hooks, + "status": self.status, + "progress": self.progress + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Quest': + """Deserialize from dictionary.""" + return cls( + quest_id=data["quest_id"], + name=data["name"], + description=data["description"], + quest_giver=data["quest_giver"], + difficulty=data["difficulty"], + objectives=[QuestObjective.from_dict(obj) for obj in data["objectives"]], + rewards=QuestReward.from_dict(data["rewards"]), + offering_triggers=QuestTriggers.from_dict(data["offering_triggers"]), + narrative_hooks=data["narrative_hooks"], + status=data.get("status", "available"), + progress=data.get("progress", {}) + ) +``` + +### QuestObjective Dataclass + +```python +@dataclass +class QuestObjective: + """Represents a single objective within a quest.""" + + objective_id: str # Unique ID (e.g., "kill_rats") + description: str # Player-facing description + objective_type: str # "kill", "collect", "travel", "interact", "discover" + required_progress: int # Target value (e.g., 10 rats) + current_progress: int = 0 # Current value (e.g., 5 rats killed) + completed: bool = False # Objective completion status + + def to_dict(self) -> Dict[str, Any]: + return { + "objective_id": self.objective_id, + "description": self.description, + "objective_type": self.objective_type, + "required_progress": self.required_progress, + "current_progress": self.current_progress, + "completed": self.completed + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'QuestObjective': + return cls( + objective_id=data["objective_id"], + description=data["description"], + objective_type=data["objective_type"], + required_progress=data["required_progress"], + current_progress=data.get("current_progress", 0), + completed=data.get("completed", False) + ) +``` + +### QuestReward Dataclass + +```python +@dataclass +class QuestReward: + """Rewards granted upon quest completion.""" + + gold: int = 0 # Gold reward + experience: int = 0 # XP reward + items: List[str] = field(default_factory=list) # Item IDs to grant + reputation: Optional[str] = None # Reputation faction (future feature) + + def to_dict(self) -> Dict[str, Any]: + return { + "gold": self.gold, + "experience": self.experience, + "items": self.items, + "reputation": self.reputation + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'QuestReward': + return cls( + gold=data.get("gold", 0), + experience=data.get("experience", 0), + items=data.get("items", []), + reputation=data.get("reputation") + ) +``` + +### QuestTriggers Dataclass + +```python +@dataclass +class QuestTriggers: + """Defines when and where a quest can be offered.""" + + location_types: List[str] # ["town", "wilderness", "dungeon"] or ["any"] + specific_locations: List[str] # Specific location IDs or empty for any + min_character_level: int = 1 # Minimum level required + max_character_level: int = 100 # Maximum level (for scaling) + required_quests_completed: List[str] = field(default_factory=list) # Quest prerequisites + probability_weights: Dict[str, float] = field(default_factory=dict) # Location-specific chances + + def get_offer_probability(self, location_type: str) -> float: + """Get the probability of offering this quest at location type.""" + return self.probability_weights.get(location_type, 0.0) + + def can_offer(self, character_level: int, location: str, location_type: str, completed_quests: List[str]) -> bool: + """Check if quest can be offered to this character at this location.""" + + # Check level requirements + if character_level < self.min_character_level or character_level > self.max_character_level: + return False + + # Check quest prerequisites + for required_quest in self.required_quests_completed: + if required_quest not in completed_quests: + return False + + # Check location type + if "any" not in self.location_types and location_type not in self.location_types: + return False + + # Check specific location (if specified) + if self.specific_locations and location not in self.specific_locations: + return False + + return True + + def to_dict(self) -> Dict[str, Any]: + return { + "location_types": self.location_types, + "specific_locations": self.specific_locations, + "min_character_level": self.min_character_level, + "max_character_level": self.max_character_level, + "required_quests_completed": self.required_quests_completed, + "probability_weights": self.probability_weights + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'QuestTriggers': + return cls( + location_types=data["location_types"], + specific_locations=data.get("specific_locations", []), + min_character_level=data.get("min_character_level", 1), + max_character_level=data.get("max_character_level", 100), + required_quests_completed=data.get("required_quests_completed", []), + probability_weights=data.get("probability_weights", {}) + ) +``` + +--- + +## YAML Quest Definitions + +Quests are stored in `/app/data/quests/` organized by difficulty: + +``` +/app/data/quests/ +├── easy/ +│ ├── rat_problem.yaml +│ ├── delivery_run.yaml +│ └── missing_cat.yaml +├── medium/ +│ ├── bandit_camp.yaml +│ ├── haunted_ruins.yaml +│ └── merchant_escort.yaml +├── hard/ +│ ├── dragon_lair.yaml +│ ├── necromancer_tower.yaml +│ └── lost_artifact.yaml +└── epic/ + ├── demon_invasion.yaml + └── ancient_prophecy.yaml +``` + +### Example Quest YAML: Rat Problem (Easy) + +**File:** `/app/data/quests/easy/rat_problem.yaml` + +```yaml +quest_id: "quest_rats_tavern" +name: "Rat Problem" +description: "The local tavern is overrun with giant rats. The tavern keeper needs someone to clear them out before they scare away all the customers." +quest_giver: "Tavern Keeper" +difficulty: "easy" + +objectives: + - objective_id: "kill_rats" + description: "Kill 10 giant rats in the tavern basement" + objective_type: "kill" + required_progress: 10 + +rewards: + gold: 50 + experience: 100 + items: [] + +offering_triggers: + location_types: ["town"] + specific_locations: [] # Any town + min_character_level: 1 + max_character_level: 3 + required_quests_completed: [] + probability_weights: + town: 0.30 # 30% chance in towns + wilderness: 0.0 # 0% chance in wilderness + dungeon: 0.0 # 0% chance in dungeons + +narrative_hooks: + - "The tavern keeper frantically waves you over, mentioning strange noises from the basement." + - "You overhear patrons complaining about rat infestations ruining the food supplies." + - "A desperate-looking innkeeper approaches you, begging for help with a pest problem." +``` + +### Example Quest YAML: Bandit Camp (Medium) + +**File:** `/app/data/quests/medium/bandit_camp.yaml` + +```yaml +quest_id: "quest_bandit_camp" +name: "Clear the Bandit Camp" +description: "A group of bandits has been raiding merchant caravans along the main road. The town guard wants someone to clear out their camp in the nearby woods." +quest_giver: "Captain of the Guard" +difficulty: "medium" + +objectives: + - objective_id: "find_camp" + description: "Locate the bandit camp in the forest" + objective_type: "discover" + required_progress: 1 + + - objective_id: "defeat_bandits" + description: "Defeat the bandit leader and their gang" + objective_type: "kill" + required_progress: 1 + + - objective_id: "return_goods" + description: "Return stolen goods to the town" + objective_type: "interact" + required_progress: 1 + +rewards: + gold: 200 + experience: 500 + items: ["iron_sword", "leather_armor"] + +offering_triggers: + location_types: ["town"] + specific_locations: [] + min_character_level: 3 + max_character_level: 7 + required_quests_completed: [] + probability_weights: + town: 0.20 + wilderness: 0.05 + dungeon: 0.0 + +narrative_hooks: + - "The captain of the guard summons you, speaking urgently about increased bandit activity." + - "A merchant tells you tales of lost caravans and a hidden camp somewhere in the eastern woods." + - "Wanted posters line the town walls, offering rewards for dealing with the bandit menace." +``` + +### Example Quest YAML: Dragon's Lair (Hard) + +**File:** `/app/data/quests/hard/dragon_lair.yaml` + +```yaml +quest_id: "quest_dragon_lair" +name: "The Dragon's Lair" +description: "An ancient red dragon has awakened in the mountains and is terrorizing the region. The kingdom offers a substantial reward for anyone brave enough to slay the beast." +quest_giver: "Royal Herald" +difficulty: "hard" + +objectives: + - objective_id: "gather_info" + description: "Gather information about the dragon's lair" + objective_type: "interact" + required_progress: 3 + + - objective_id: "find_lair" + description: "Locate the dragon's lair in the mountains" + objective_type: "discover" + required_progress: 1 + + - objective_id: "slay_dragon" + description: "Defeat the ancient red dragon" + objective_type: "kill" + required_progress: 1 + + - objective_id: "claim_hoard" + description: "Claim a portion of the dragon's hoard" + objective_type: "collect" + required_progress: 1 + +rewards: + gold: 5000 + experience: 10000 + items: ["dragon_scale_armor", "flaming_longsword", "ring_of_fire_resistance"] + +offering_triggers: + location_types: ["town"] + specific_locations: ["capital_city", "mountain_fortress"] + min_character_level: 10 + max_character_level: 100 + required_quests_completed: [] + probability_weights: + town: 0.10 + wilderness: 0.02 + dungeon: 0.0 + +narrative_hooks: + - "A royal herald announces a call to arms - a dragon threatens the kingdom!" + - "You hear tales of entire villages razed by dragonfire in the northern mountains." + - "The king's messenger seeks experienced adventurers for a quest of utmost danger." +``` + +--- + +## Quest Offering Logic + +### When Quests Are Offered + +Quests are checked for offering after each story turn action, using a two-stage process: + +#### Stage 1: Location-Based Probability Roll + +```python +def roll_for_quest_offering(location_type: str) -> bool: + """Roll to see if any quest should be offered this turn.""" + + base_probabilities = { + "town": 0.30, # 30% chance in towns/cities + "tavern": 0.35, # 35% chance in taverns (special location) + "wilderness": 0.05, # 5% chance in wilderness + "dungeon": 0.10, # 10% chance in dungeons + } + + chance = base_probabilities.get(location_type, 0.05) + return random.random() < chance +``` + +#### Stage 2: Context-Aware Selection + +If the roll succeeds, the AI Dungeon Master analyzes the recent narrative context to select an appropriate quest: + +```python +def select_quest_from_context( + available_quests: List[Quest], + character: Character, + session: GameSession, + location: str, + location_type: str +) -> Optional[Quest]: + """Use AI to select a contextually appropriate quest.""" + + # Filter quests by eligibility + eligible_quests = [ + q for q in available_quests + if q.offering_triggers.can_offer( + character.level, + location, + location_type, + character.completed_quests + ) + ] + + if not eligible_quests: + return None + + # Build context for AI decision + context = { + "character_name": character.name, + "character_level": character.level, + "location": location, + "recent_actions": session.conversation_history[-3:], + "available_quests": [ + { + "quest_id": q.quest_id, + "name": q.name, + "narrative_hooks": q.narrative_hooks, + "difficulty": q.difficulty + } + for q in eligible_quests + ] + } + + # Ask AI to select most fitting quest (or none) + prompt = render_quest_selection_prompt(context) + ai_response = call_ai_api(prompt) + + selected_quest_id = parse_quest_selection(ai_response) + + if selected_quest_id: + return next(q for q in eligible_quests if q.quest_id == selected_quest_id) + + return None +``` + +### Quest Offering Flow Diagram + +``` +After Story Turn Action + │ + ▼ +┌─────────────────────────┐ +│ Check Active Quests │ +│ If >= 2, skip offering │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Location-Based Roll │ +│ - Town: 30% │ +│ - Tavern: 35% │ +│ - Wilderness: 5% │ +│ - Dungeon: 10% │ +└────────┬────────────────┘ + │ + ▼ + Roll Success? + │ + ┌────┴────┐ + No Yes + │ │ + │ ▼ + │ ┌─────────────────────────┐ + │ │ Filter Eligible Quests │ + │ │ - Level requirements │ + │ │ - Location match │ + │ │ - Prerequisites met │ + │ └────────┬────────────────┘ + │ │ + │ ▼ + │ Any Eligible? + │ │ + │ ┌────┴────┐ + │ No Yes + │ │ │ + │ │ ▼ + │ │ ┌─────────────────────────┐ + │ │ │ AI Context Analysis │ + │ │ │ Select fitting quest │ + │ │ │ from narrative context │ + │ │ └────────┬────────────────┘ + │ │ │ + │ │ ▼ + │ │ Quest Selected? + │ │ │ + │ │ ┌────┴────┐ + │ │ No Yes + │ │ │ │ + │ │ │ ▼ + │ │ │ ┌─────────────────────────┐ + │ │ │ │ Offer Quest to Player │ + │ │ │ │ Display narrative hook │ + │ │ │ └─────────────────────────┘ + │ │ │ + └────────┴────────┴──► No Quest Offered +``` + +--- + +## Quest Tracking and Completion + +### Accepting a Quest + +**Endpoint:** `POST /api/v1/quests/accept` + +**Request:** +```json +{ + "character_id": "char_abc123", + "quest_id": "quest_rats_tavern" +} +``` + +**Response:** +```json +{ + "success": true, + "quest": { + "quest_id": "quest_rats_tavern", + "name": "Rat Problem", + "description": "The local tavern is overrun with giant rats...", + "objectives": [ + { + "objective_id": "kill_rats", + "description": "Kill 10 giant rats in the tavern basement", + "current_progress": 0, + "required_progress": 10, + "completed": false + } + ], + "status": "active" + } +} +``` + +### Updating Quest Progress + +Quest progress is updated automatically during combat or story actions: + +```python +def update_quest_progress(character: Character, event_type: str, event_data: Dict) -> List[str]: + """Update quest progress based on game events.""" + + updated_quests = [] + + for quest_id in character.active_quests: + quest = load_quest(quest_id) + + for objective in quest.objectives: + if objective.completed: + continue + + # Match event to objective type + if objective.objective_type == "kill" and event_type == "enemy_killed": + if matches_objective(objective, event_data): + quest.update_progress(objective.objective_id, objective.current_progress + 1) + updated_quests.append(quest_id) + + elif objective.objective_type == "collect" and event_type == "item_obtained": + if matches_objective(objective, event_data): + quest.update_progress(objective.objective_id, objective.current_progress + 1) + updated_quests.append(quest_id) + + elif objective.objective_type == "discover" and event_type == "location_discovered": + if matches_objective(objective, event_data): + quest.update_progress(objective.objective_id, 1) + updated_quests.append(quest_id) + + elif objective.objective_type == "interact" and event_type == "npc_interaction": + if matches_objective(objective, event_data): + quest.update_progress(objective.objective_id, objective.current_progress + 1) + updated_quests.append(quest_id) + + # Check if quest is complete + if quest.is_complete(): + complete_quest(character, quest) + + return updated_quests +``` + +### Completing a Quest + +**Endpoint:** `POST /api/v1/quests/complete` + +**Request:** +```json +{ + "character_id": "char_abc123", + "quest_id": "quest_rats_tavern" +} +``` + +**Response:** +```json +{ + "success": true, + "quest_completed": true, + "rewards": { + "gold": 50, + "experience": 100, + "items": [], + "level_up": false + }, + "message": "You have completed the Rat Problem quest! The tavern keeper thanks you profusely." +} +``` + +--- + +## Quest Service + +### QuestService Class + +```python +class QuestService: + """Service for managing quests.""" + + def __init__(self): + self.quest_cache: Dict[str, Quest] = {} + self._load_all_quests() + + def _load_all_quests(self) -> None: + """Load all quests from YAML files.""" + quest_dirs = [ + "app/data/quests/easy/", + "app/data/quests/medium/", + "app/data/quests/hard/", + "app/data/quests/epic/" + ] + + for quest_dir in quest_dirs: + for yaml_file in glob.glob(f"{quest_dir}*.yaml"): + quest = self._load_quest_from_yaml(yaml_file) + self.quest_cache[quest.quest_id] = quest + + def _load_quest_from_yaml(self, filepath: str) -> Quest: + """Load a single quest from YAML file.""" + with open(filepath, 'r') as f: + data = yaml.safe_load(f) + return Quest.from_dict(data) + + def get_quest(self, quest_id: str) -> Optional[Quest]: + """Get quest by ID.""" + return self.quest_cache.get(quest_id) + + def get_available_quests(self, character: Character, location: str, location_type: str) -> List[Quest]: + """Get all quests available to character at location.""" + return [ + quest for quest in self.quest_cache.values() + if quest.offering_triggers.can_offer( + character.level, + location, + location_type, + character.completed_quests + ) + ] + + def accept_quest(self, character_id: str, quest_id: str) -> bool: + """Accept a quest for a character.""" + character = get_character(character_id) + + # Check quest limit + if len(character.active_quests) >= 2: + raise QuestLimitExceeded("You can only have 2 active quests at a time") + + # Add quest to active quests + if quest_id not in character.active_quests: + character.active_quests.append(quest_id) + update_character(character) + return True + + return False + + def complete_quest(self, character_id: str, quest_id: str) -> QuestReward: + """Complete a quest and grant rewards.""" + character = get_character(character_id) + quest = self.get_quest(quest_id) + + if not quest or not quest.is_complete(): + raise QuestNotComplete("Quest objectives not complete") + + # Grant rewards + character.gold += quest.rewards.gold + character.experience += quest.rewards.experience + + for item_id in quest.rewards.items: + item = load_item(item_id) + character.inventory.append(item) + + # Move quest to completed + character.active_quests.remove(quest_id) + character.completed_quests.append(quest_id) + + # Check for level up + leveled_up = check_level_up(character) + + update_character(character) + + return quest.rewards +``` + +--- + +## UI Integration + +### Quest Tracker (Sidebar) + +``` +┌─────────────────────────┐ +│ 🎯 Active Quests (1/2) │ +├─────────────────────────┤ +│ │ +│ Rat Problem │ +│ ─────────────── │ +│ ✅ Kill 10 giant rats │ +│ (7/10) │ +│ │ +│ [View Details] │ +│ │ +└─────────────────────────┘ +``` + +### Quest Offering Modal + +When a quest is offered during a story turn: + +``` +┌─────────────────────────────────────────┐ +│ Quest Offered! │ +├─────────────────────────────────────────┤ +│ │ +│ 🎯 Rat Problem │ +│ │ +│ The tavern keeper frantically waves │ +│ you over, mentioning strange noises │ +│ from the basement. │ +│ │ +│ "Please, you must help! Giant rats │ +│ have overrun my cellar. I'll pay you │ +│ well to clear them out!" │ +│ │ +│ Difficulty: Easy │ +│ Rewards: 50 gold, 100 XP │ +│ │ +│ Objectives: │ +│ • Kill 10 giant rats │ +│ │ +│ [Accept Quest] [Decline] │ +│ │ +└─────────────────────────────────────────┘ +``` + +### Quest Detail View + +``` +┌─────────────────────────────────────────┐ +│ Rat Problem [Close] │ +├─────────────────────────────────────────┤ +│ │ +│ Quest Giver: Tavern Keeper │ +│ Difficulty: Easy │ +│ │ +│ Description: │ +│ The local tavern is overrun with giant │ +│ rats. The tavern keeper needs someone │ +│ to clear them out before they scare │ +│ away all the customers. │ +│ │ +│ Objectives: │ +│ ✅ Kill 10 giant rats (7/10) │ +│ │ +│ Rewards: │ +│ 💰 50 gold │ +│ ⭐ 100 XP │ +│ │ +│ [Abandon Quest] │ +│ │ +└─────────────────────────────────────────┘ +``` + +--- + +## Implementation Timeline + +### Week 9 of Phase 4 (Days 13-14) + +**Day 13: Quest Data Models & Service** +- Create Quest, QuestObjective, QuestReward, QuestTriggers dataclasses +- Create QuestService (load from YAML, manage state) +- Write 5-10 example quest YAML files (2 easy, 2 medium, 1 hard) +- Unit tests for quest loading and progression logic + +**Day 14: Quest Offering & Integration** +- Implement quest offering logic (context-aware + location-based) +- Integrate quest offering into story turn flow +- Quest acceptance/completion API endpoints +- Quest progress tracking during combat/story events +- Quest UI components (tracker, offering modal, detail view) +- Integration testing + +--- + +## Testing Criteria + +### Unit Tests +- ✅ Quest loading from YAML +- ✅ Quest.is_complete() logic +- ✅ Quest.update_progress() logic +- ✅ QuestTriggers.can_offer() filtering +- ✅ QuestService.get_available_quests() filtering + +### Integration Tests +- ✅ Quest offered during story turn +- ✅ Accept quest (add to active_quests) +- ✅ Quest limit enforced (max 2 active) +- ✅ Quest progress updates during combat +- ✅ Complete quest and receive rewards +- ✅ Level up from quest XP +- ✅ Abandon quest + +### Manual Testing +- ✅ Full quest flow (offer → accept → progress → complete) +- ✅ Multiple quests active simultaneously +- ✅ Quest offering feels natural in narrative +- ✅ Context-aware quest selection works +- ✅ Location-based probabilities feel right + +--- + +## Success Criteria + +- ✅ Quest data models implemented and tested +- ✅ QuestService loads quests from YAML files +- ✅ Quest offering logic integrated into story turns +- ✅ Context-aware quest selection working +- ✅ Location-based probability rolls functioning +- ✅ Max 2 active quests enforced +- ✅ Quest acceptance and tracking functional +- ✅ Quest progress updates automatically +- ✅ Quest completion and rewards granted +- ✅ Quest UI components implemented +- ✅ At least 5 example quests defined in YAML + +--- + +## Future Enhancements (Post-MVP) + +### Phase 13+ +- **Quest chains**: Multi-quest storylines with prerequisites +- **Repeatable quests**: Daily/weekly quests for ongoing rewards +- **Dynamic quest generation**: AI creates custom quests on-the-fly +- **Quest sharing**: Multiplayer party quests +- **Quest journal**: Full quest log with completed quest history +- **Quest dialogue trees**: Branching conversations with quest givers +- **Time-limited quests**: Quests that expire after X turns +- **Reputation system**: Quest completion affects faction standing +- **Quest difficulty scaling**: Rewards scale with character level + +--- + +## Related Documentation + +- **[STORY_PROGRESSION.md](STORY_PROGRESSION.md)** - Turn-based story gameplay system +- **[DATA_MODELS.md](DATA_MODELS.md)** - Quest data model specifications +- **[GAME_SYSTEMS.md](GAME_SYSTEMS.md)** - Combat integration with quests +- **[API_REFERENCE.md](API_REFERENCE.md)** - Quest API endpoints +- **[ROADMAP.md](ROADMAP.md)** - Phase 4 timeline + +--- + +**Document Version:** 1.0 +**Created:** November 16, 2025 +**Last Updated:** November 16, 2025 diff --git a/api/docs/SESSION_MANAGEMENT.md b/api/docs/SESSION_MANAGEMENT.md new file mode 100644 index 0000000..f310981 --- /dev/null +++ b/api/docs/SESSION_MANAGEMENT.md @@ -0,0 +1,435 @@ +# Session Management + +This document describes the game session system for Code of Conquest, covering both solo and multiplayer sessions. + +**Last Updated:** November 22, 2025 + +--- + +## Overview + +Game sessions track the state of gameplay including: +- Current location and discovered locations +- Conversation history between player and AI DM +- Active quests (max 2) +- World events +- Combat encounters + +Sessions support both **solo play** (single character) and **multiplayer** (party-based). + +--- + +## Data Models + +### SessionType Enum + +```python +class SessionType(Enum): + SOLO = "solo" # Single-player session + MULTIPLAYER = "multiplayer" # Multi-player party session +``` + +### LocationType Enum + +```python +class LocationType(Enum): + TOWN = "town" # Town or city + TAVERN = "tavern" # Tavern or inn + WILDERNESS = "wilderness" # Outdoor wilderness areas + DUNGEON = "dungeon" # Underground dungeons/caves + RUINS = "ruins" # Ancient ruins + LIBRARY = "library" # Library or archive + SAFE_AREA = "safe_area" # Safe rest areas +``` + +### GameState + +Tracks current world state for a session: + +```python +@dataclass +class GameState: + current_location: str = "Crossroads Village" + location_type: LocationType = LocationType.TOWN + discovered_locations: List[str] = [] + active_quests: List[str] = [] # Max 2 + world_events: List[Dict] = [] +``` + +### ConversationEntry + +Single turn in the conversation history: + +```python +@dataclass +class ConversationEntry: + turn: int # Turn number (1-indexed) + character_id: str # Acting character + character_name: str # Character display name + action: str # Player's action text + dm_response: str # AI DM's response + timestamp: str # ISO timestamp (auto-generated) + combat_log: List[Dict] = [] # Combat actions if any + quest_offered: Optional[Dict] = None # Quest offering info +``` + +### GameSession + +Main session object: + +```python +@dataclass +class GameSession: + session_id: str + session_type: SessionType = SessionType.SOLO + solo_character_id: Optional[str] = None # For solo sessions + user_id: str = "" + party_member_ids: List[str] = [] # For multiplayer + config: SessionConfig + combat_encounter: Optional[CombatEncounter] = None + conversation_history: List[ConversationEntry] = [] + game_state: GameState + turn_order: List[str] = [] + current_turn: int = 0 + turn_number: int = 0 + created_at: str # ISO timestamp + last_activity: str # ISO timestamp + status: SessionStatus = SessionStatus.ACTIVE +``` + +--- + +## SessionService + +The `SessionService` class (`app/services/session_service.py`) provides all session operations. + +### Initialization + +```python +from app.services.session_service import get_session_service + +service = get_session_service() +``` + +### Creating Sessions + +#### Solo Session + +```python +session = service.create_solo_session( + user_id="user_123", + character_id="char_456", + starting_location="Crossroads Village", # Optional + starting_location_type=LocationType.TOWN # Optional +) +``` + +**Validations:** +- User must own the character +- User cannot exceed 5 active sessions + +**Returns:** `GameSession` instance + +**Raises:** +- `CharacterNotFound` - Character doesn't exist or user doesn't own it +- `SessionLimitExceeded` - User has 5+ active sessions + +### Retrieving Sessions + +#### Get Single Session + +```python +session = service.get_session( + session_id="sess_789", + user_id="user_123" # Optional, validates ownership +) +``` + +**Raises:** `SessionNotFound` + +#### Get User's Sessions + +```python +sessions = service.get_user_sessions( + user_id="user_123", + active_only=True, # Default: True + limit=25 # Default: 25 +) +``` + +#### Count Sessions + +```python +count = service.count_user_sessions( + user_id="user_123", + active_only=True +) +``` + +### Updating Sessions + +#### Direct Update + +```python +session.turn_number += 1 +session = service.update_session(session) +``` + +#### End Session + +```python +session = service.end_session( + session_id="sess_789", + user_id="user_123" +) +# Sets status to COMPLETED +``` + +--- + +## Conversation History + +### Adding Entries + +```python +session = service.add_conversation_entry( + session_id="sess_789", + character_id="char_456", + character_name="Brave Hero", + action="I explore the tavern", + dm_response="You enter a smoky tavern filled with patrons...", + combat_log=[], # Optional + quest_offered={"quest_id": "q1", "name": "..."} # Optional +) +``` + +**Automatic behaviors:** +- Increments `turn_number` +- Adds timestamp +- Updates `last_activity` + +### Retrieving History + +```python +# Get all history (with pagination) +history = service.get_conversation_history( + session_id="sess_789", + limit=20, # Optional + offset=0 # Optional, from end +) + +# Get recent entries for AI context +recent = service.get_recent_history( + session_id="sess_789", + num_turns=3 # Default: 3 +) +``` + +--- + +## Game State Tracking + +### Location Management + +```python +# Update current location +session = service.update_location( + session_id="sess_789", + new_location="Dark Forest", + location_type=LocationType.WILDERNESS +) +# Also adds to discovered_locations if new + +# Add discovered location without traveling +session = service.add_discovered_location( + session_id="sess_789", + location="Ancient Ruins" +) +``` + +### Quest Management + +```python +# Add active quest (max 2) +session = service.add_active_quest( + session_id="sess_789", + quest_id="quest_goblin_cave" +) + +# Remove quest (on completion or abandonment) +session = service.remove_active_quest( + session_id="sess_789", + quest_id="quest_goblin_cave" +) +``` + +**Raises:** `SessionValidationError` if adding 3rd quest + +### World Events + +```python +session = service.add_world_event( + session_id="sess_789", + event={ + "type": "festival", + "description": "A harvest festival begins in town" + } +) +# Timestamp auto-added +``` + +--- + +## Session Limits + +| Limit | Value | Notes | +|-------|-------|-------| +| Active sessions per user | 5 | End existing sessions to create new | +| Active quests per session | 2 | Complete or abandon to accept new | +| Conversation history | Unlimited | Consider archiving for very long sessions | + +--- + +## Database Schema + +Sessions are stored in Appwrite `game_sessions` collection: + +| Field | Type | Description | +|-------|------|-------------| +| `$id` | string | Session ID (document ID) | +| `userId` | string | Owner's user ID | +| `sessionData` | string | JSON serialized GameSession | +| `status` | string | "active", "completed", "timeout" | +| `sessionType` | string | "solo" or "multiplayer" | + +### Indexes + +- `userId` - For user session queries +- `status` - For active session filtering +- `userId + status` - Composite for active user sessions + +--- + +## Usage Examples + +### Complete Solo Gameplay Flow + +```python +from app.services.session_service import get_session_service +from app.models.enums import LocationType + +service = get_session_service() + +# 1. Create session +session = service.create_solo_session( + user_id="user_123", + character_id="char_456" +) + +# 2. Player takes action, AI responds +session = service.add_conversation_entry( + session_id=session.session_id, + character_id="char_456", + character_name="Hero", + action="I look around the village square", + dm_response="The village square bustles with activity..." +) + +# 3. Player travels +session = service.update_location( + session_id=session.session_id, + new_location="The Rusty Anchor Tavern", + location_type=LocationType.TAVERN +) + +# 4. Quest offered and accepted +session = service.add_active_quest( + session_id=session.session_id, + quest_id="quest_goblin_cave" +) + +# 5. End session +session = service.end_session( + session_id=session.session_id, + user_id="user_123" +) +``` + +### Checking Session State + +```python +session = service.get_session("sess_789") + +# Check session type +if session.is_solo(): + char_id = session.solo_character_id +else: + char_id = session.get_current_character_id() + +# Check current location +location = session.game_state.current_location +location_type = session.game_state.location_type + +# Check active quests +quests = session.game_state.active_quests +can_accept_quest = len(quests) < 2 + +# Get recent context for AI +recent = service.get_recent_history(session.session_id, num_turns=3) +``` + +--- + +## Error Handling + +### Exception Classes + +```python +from app.services.session_service import ( + SessionNotFound, + SessionLimitExceeded, + SessionValidationError, +) + +try: + session = service.get_session("invalid_id", "user_123") +except SessionNotFound: + # Session doesn't exist or user doesn't own it + pass + +try: + service.create_solo_session(user_id, char_id) +except SessionLimitExceeded: + # User has 5+ active sessions + pass + +try: + service.add_active_quest(session_id, "quest_3") +except SessionValidationError: + # Already have 2 active quests + pass +``` + +--- + +## Testing + +Run session tests: + +```bash +# Unit tests +pytest tests/test_session_model.py -v +pytest tests/test_session_service.py -v + +# Verification script (requires TEST_USER_ID and TEST_CHARACTER_ID in .env) +python scripts/verify_session_persistence.py +``` + +--- + +## Related Documentation + +- [DATA_MODELS.md](DATA_MODELS.md) - Character and item models +- [STORY_PROGRESSION.md](STORY_PROGRESSION.md) - Story turn system +- [QUEST_SYSTEM.md](QUEST_SYSTEM.md) - Quest mechanics +- [API_REFERENCE.md](API_REFERENCE.md) - Session API endpoints diff --git a/api/docs/STORY_PROGRESSION.md b/api/docs/STORY_PROGRESSION.md new file mode 100644 index 0000000..6319bc0 --- /dev/null +++ b/api/docs/STORY_PROGRESSION.md @@ -0,0 +1,985 @@ +# Story Progression System + +**Status:** Active +**Phase:** 4 (AI Integration + Story Progression) +**Timeline:** Week 8 of Phase 4 (Days 8-14) +**Last Updated:** November 23, 2025 + +--- + +## Overview + +The Story Progression System is the core single-player gameplay loop where players interact with the AI Dungeon Master through turn-based actions. Unlike combat (which uses structured mechanics), story progression allows players to explore the world, gather information, travel, and engage with narrative content. + +**Key Principles:** +- **Solo sessions only** - Story progression is single-player focused +- **Button-based actions** - Structured prompts reduce AI costs and improve response quality +- **Tier-based features** - Free tier has basic actions, paid tiers unlock more options +- **Turn-based gameplay** - Player action → AI response → state update → repeat +- **Quest integration** - Quests are offered during story turns based on context and location +- **Location-based world** - Structured world with regions, locations, and travel +- **Persistent NPCs** - Characters with personalities, knowledge, and relationship tracking + +--- + +## World Structure + +### Regions and Locations + +The game world is organized hierarchically: + +``` +World +└── Regions (e.g., Crossville Province) + └── Locations (e.g., Crossville Village, The Rusty Anchor Tavern) + └── NPCs (e.g., Grom Ironbeard, Elara the Herbalist) +``` + +**Regions** group related locations together for organizational purposes. Each region has: +- Regional lore and atmosphere +- List of contained locations +- Region-wide events (future feature) + +**Locations** are the atomic units of the world map: +- Each location has a type (town, tavern, wilderness, dungeon, etc.) +- Locations contain NPCs who reside there +- Travel connections define which locations can be reached from where +- Some locations are "starting locations" where new characters spawn + +### Location Discovery + +Players don't know about all locations initially. Locations are discovered through: + +1. **Starting location**: All new characters begin at a designated starting location (Crossville Village) +2. **Travel exploration**: Adjacent locations listed in `discoverable_locations` +3. **NPC conversations**: NPCs can reveal hidden locations via `reveals_locations` +4. **Story events**: AI narrative can unlock new destinations + +**Example Location Discovery Flow:** +``` +1. Player starts in Crossville Village + └── Discovered: [crossville_village] + +2. Player explores village, finds paths to tavern and market + └── Discovered: [crossville_village, crossville_tavern, crossville_market] + +3. Player talks to tavern keeper, learns about old mines + └── Discovered: [..., crossville_old_mines] +``` + +### Travel System + +Travel moves players between discovered locations: + +1. **Check available destinations**: `GET /api/v1/travel/available?session_id={id}` +2. **Travel to location**: `POST /api/v1/travel` with `session_id` and `location_id` +3. **AI narration**: Travel triggers narrative description of journey and arrival +4. **State update**: Player's `current_location_id` updated, NPCs at new location available + +**Travel Restrictions:** +- Can only travel to discovered locations +- Some locations may require prerequisites (quest completion, level, etc.) +- Travel may trigger random encounters (future feature) + +--- + +## NPC Interaction System + +### NPC Overview + +NPCs are persistent characters with rich data for AI dialogue generation: + +- **Personality**: Traits, speech patterns, and quirks +- **Knowledge**: What they know (public) and secrets they may reveal +- **Relationships**: How they feel about other NPCs +- **Inventory**: Items for sale (if merchant) +- **Quest connections**: Quests they give and locations they reveal + +### Talking to NPCs + +When players interact with NPCs: + +1. **NPC selection**: Player chooses NPC from location's NPC list +2. **Initial greeting or response**: Player either greets NPC or responds to previous dialogue +3. **AI prompt built**: Includes NPC personality, knowledge, relationship state, and conversation history +4. **Dialogue generated**: AI creates in-character response +5. **State updated**: Interaction tracked, dialogue exchange saved, secrets potentially revealed + +**Initial Greeting Flow:** +``` +Player: Clicks "Greet" on Grom Ironbeard + └── Topic: "greeting" + +System builds prompt with: + - NPC personality (gruff, honest, protective) + - NPC speech style (short sentences, dwarven expressions) + - Public knowledge (tavern history, knows travelers) + - Conditional knowledge (if relationship >= 70, mention goblins) + - Current relationship level (65 - friendly) + +AI generates response as Grom: + "Welcome to the Rusty Anchor! What'll it be?" +``` + +**Bidirectional Conversation Flow:** + +Players can respond to NPC dialogue to continue conversations: + +``` +Player: Types "Have you heard any rumors lately?" + └── player_response: "Have you heard any rumors lately?" + +System builds prompt with: + - All personality/knowledge context (same as above) + - Previous dialogue history (last 3 exchanges for context) + - Player's current response + +AI generates continuation as Grom: + "Aye, I've heard things. *leans in* Strange folk been coming + through lately. Watch yerself on the roads, friend." + +System saves exchange: + - player_line: "Have you heard any rumors lately?" + - npc_response: "Aye, I've heard things..." + - timestamp: "2025-11-24T10:30:00Z" +``` + +**Conversation History Display:** + +The UI shows the full conversation history with each NPC: +- Previous exchanges are displayed with reduced opacity +- Current exchange is highlighted +- Player can type new responses to continue the conversation +- Last 10 exchanges per NPC are stored for context + +### Relationship Tracking + +Each character-NPC pair has an `NPCInteractionState`: + +| Relationship Level | Status | Effects | +|--------------------|--------|---------| +| 0-20 | Hostile | NPC refuses to help, may report player | +| 21-40 | Unfriendly | Minimal assistance, no secrets | +| 41-60 | Neutral | Normal interaction | +| 61-80 | Friendly | Better prices, more information | +| 81-100 | Trusted | Full access to secrets, special quests | + +**Relationship modifiers:** +- Successful interactions: +1 to +5 relationship +- Failed attempts: -1 to -5 relationship +- Quest completion for NPC: +10 to +20 relationship +- Helping NPC's friends: +5 relationship +- Harming NPC's allies: -10 to -20 relationship + +### Secret Knowledge Reveals + +NPCs can hold secrets that unlock under conditions: + +```yaml +knowledge: + secret: + - "Saw strange lights in the forest last week" + will_share_if: + - condition: "relationship_level >= 70" + reveals: "Has heard rumors of goblins gathering in the old mines" + - condition: "interaction_count >= 5" + reveals: "Knows about a hidden cave behind the waterfall" +``` + +When a condition is met: +1. Secret index added to `revealed_secrets` list +2. Information included in AI prompt context +3. NPC "remembers" sharing this in future conversations + +### Location Reveals from NPCs + +NPCs can unlock new locations for players: + +```yaml +reveals_locations: + - "crossville_old_mines" + - "hidden_waterfall_cave" +``` + +When an NPC reveals a location: +1. Location ID added to character's `discovered_locations` +2. Location becomes available for travel +3. AI narration describes how player learned about it + +--- + +## Gameplay Loop + +### Turn Structure + +``` +1. Display current state (location, quests, conversation history) +2. Present available actions (buttons based on tier + context) +3. Player selects action (or enters custom text if Premium/Elite) +4. Action sent to backend API +5. AI processes action and generates narrative response +6. Game state updated (location changes, quest progress, etc.) +7. Quest offering check (context-aware + location-based) +8. Response displayed to player +9. Next turn begins +``` + +### Turn Flow Diagram + +``` +┌─────────────────────────────────────┐ +│ Player Views Current State │ +│ - Location │ +│ - Active Quests │ +│ - Conversation History │ +└────────────┬────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ System Presents Action Options │ +│ - Tier-based button selection │ +│ - Context-aware prompts │ +│ - Custom input (Premium/Elite) │ +└────────────┬────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Player Selects Action │ +│ POST /api/v1/sessions/{id}/action │ +└────────────┬────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Backend Processing │ +│ - Validate action │ +│ - Build AI prompt (context) │ +│ - Call AI API (tier-based model) │ +│ - Parse response │ +└────────────┬────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Update Game State │ +│ - Location changes │ +│ - Quest progress │ +│ - Conversation history │ +└────────────┬────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Quest Offering Check │ +│ - Context-aware analysis │ +│ - Location-based roll │ +│ - Max 2 active quests limit │ +└────────────┬────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Return Response to Player │ +│ - AI narrative text │ +│ - State changes │ +│ - Quest offered (if any) │ +└────────────┬────────────────────────┘ + │ + ▼ + Next Turn +``` + +--- + +## Action System + +### Action Categories + +Actions are organized into three primary categories: + +#### 1. Ask Questions +Information-gathering actions that don't change location or trigger major events. + +#### 2. Travel +Movement actions that change the player's current location. + +#### 3. Gather Information +Social interaction actions that gather rumors, quests, and world knowledge. + +### Tier-Based Action Availability + +| Tier | Action Count | Free-Form Input | AI Model | Cost/Turn | +|------|--------------|-----------------|----------|-----------| +| **Free** | 4 basic actions | ❌ No | Replicate (free tier) | $0 | +| **Basic** | 4 basic actions | ❌ No | Claude Haiku | ~$0.01 | +| **Premium** | 7 actions | ✅ Yes (250 chars) | Claude Sonnet | ~$0.03 | +| **Elite** | 10 actions | ✅ Yes (500 chars) | Claude Opus | ~$0.10 | + +### Action Prompt Definitions + +#### Free Tier Actions (4 total) + +**Ask Questions:** +1. **"What do I see around me?"** - Describe current location and visible surroundings +2. **"Are there any dangers nearby?"** - Check for enemies, hazards, or threats + +**Travel:** +3. **"Travel to [dropdown: known towns]"** - Fast travel to discovered towns +4. **"Explore the area"** - Random exploration in current region + +#### Premium Tier Additional Actions (+3 more, 7 total) + +**Ask Questions:** +5. **"What do I remember about this place?"** - Recall location history (ties to Memory Thief origin) + +**Gather Information:** +6. **"Ask around the streets"** - Gather rumors from common folk (town/city only) +7. **"Visit the local tavern"** - Gather information and hear tales (town/city only) + +#### Elite Tier Additional Actions (+3 more, 10 total) + +**Ask Questions:** +8. **"Search for hidden secrets"** - Thorough investigation of area (may find loot or lore) + +**Gather Information:** +9. **"Seek out the town elder"** - Get official information and potential quests (town/city only) + +**Travel:** +10. **"Chart a course to unexplored lands"** - Discover new locations (wilderness only) + +#### Premium/Elite: Custom Free-Form Input + +**"Ask the DM a custom question"** (Premium/Elite only) +- Premium: 250 character limit +- Elite: 500 character limit +- Examples: "I want to climb the tower and look for a vantage point", "Can I search the abandoned house for clues?" + +--- + +## Action Prompt Data Model + +### ActionPrompt Dataclass + +```python +@dataclass +class ActionPrompt: + """Represents a button-based action prompt available to players.""" + + prompt_id: str # Unique identifier (e.g., "ask_surroundings") + category: str # "ask", "travel", "gather" + display_text: str # Button text shown to player + description: str # Tooltip/help text + tier_required: str # "free", "basic", "premium", "elite" + context_filter: Optional[str] # "town", "wilderness", "any" (where action is available) + dm_prompt_template: str # Jinja2 template for AI prompt + + def is_available(self, user_tier: str, location_type: str) -> bool: + """Check if this action is available to the user.""" + # Check tier requirement + tier_hierarchy = ["free", "basic", "premium", "elite"] + if tier_hierarchy.index(user_tier) < tier_hierarchy.index(self.tier_required): + return False + + # Check context filter + if self.context_filter and self.context_filter != "any": + return location_type == self.context_filter + + return True +``` + +### YAML Action Definitions + +Actions will be defined in `/app/data/action_prompts.yaml`: + +```yaml +actions: + - prompt_id: "ask_surroundings" + category: "ask" + display_text: "What do I see around me?" + description: "Get a description of your current surroundings" + tier_required: "free" + context_filter: "any" + dm_prompt_template: | + The player is currently in {{ location_name }}. + Describe what they see, hear, and sense around them. + Include atmosphere, notable features, and any immediate points of interest. + + - prompt_id: "check_dangers" + category: "ask" + display_text: "Are there any dangers nearby?" + description: "Check for threats, enemies, or hazards in the area" + tier_required: "free" + context_filter: "any" + dm_prompt_template: | + The player is in {{ location_name }} and wants to know about nearby dangers. + Assess the area for threats: enemies, traps, environmental hazards. + Be honest about danger level but maintain narrative tension. + + - prompt_id: "travel_town" + category: "travel" + display_text: "Travel to..." + description: "Fast travel to a known town or city" + tier_required: "free" + context_filter: "any" + dm_prompt_template: | + The player is traveling from {{ current_location }} to {{ destination }}. + Describe a brief travel montage. Include: + - Journey description (terrain, weather) + - Any minor encounters or observations + - Arrival at destination + + - prompt_id: "explore_area" + category: "travel" + display_text: "Explore the area" + description: "Wander and explore your current region" + tier_required: "free" + context_filter: "any" + dm_prompt_template: | + The player explores {{ location_name }}. + Generate a random encounter or discovery: + - 30% chance: Minor combat encounter + - 30% chance: Interesting location/landmark + - 20% chance: NPC interaction + - 20% chance: Nothing significant + + - prompt_id: "recall_memory" + category: "ask" + display_text: "What do I remember about this place?" + description: "Recall memories or knowledge about current location" + tier_required: "premium" + context_filter: "any" + dm_prompt_template: | + The player tries to recall memories about {{ location_name }}. + {% if character.origin == "memory_thief" %} + The player has fragmented memories due to their amnesia. + Provide vague, incomplete recollections that hint at a past here. + {% else %} + Provide relevant historical knowledge or personal memories. + {% endif %} + + - prompt_id: "gather_street_rumors" + category: "gather" + display_text: "Ask around the streets" + description: "Talk to common folk and gather local rumors" + tier_required: "premium" + context_filter: "town" + dm_prompt_template: | + The player mingles with townsfolk in {{ location_name }}. + Generate 2-3 local rumors or pieces of information: + - Local events or concerns + - Potential quest hooks + - World lore or history + + - prompt_id: "visit_tavern" + category: "gather" + display_text: "Visit the local tavern" + description: "Gather information and hear tales at the tavern" + tier_required: "premium" + context_filter: "town" + dm_prompt_template: | + The player enters the tavern in {{ location_name }}. + Describe the atmosphere and provide: + - Overheard conversations + - Rumors and tales + - Potential quest opportunities + - NPC interactions + + - prompt_id: "search_secrets" + category: "ask" + display_text: "Search for hidden secrets" + description: "Thoroughly investigate the area for hidden items or lore" + tier_required: "elite" + context_filter: "any" + dm_prompt_template: | + The player conducts a thorough search of {{ location_name }}. + Roll for discovery (60% chance of finding something): + - Hidden items or treasures + - Secret passages or areas + - Lore fragments or journals + - Environmental storytelling clues + + - prompt_id: "seek_elder" + category: "gather" + display_text: "Seek out the town elder" + description: "Get official information and guidance from town leadership" + tier_required: "elite" + context_filter: "town" + dm_prompt_template: | + The player seeks audience with the town elder of {{ location_name }}. + The elder provides: + - Official town history and current situation + - Important quests or tasks + - Warnings about regional threats + - Access to restricted information + + - prompt_id: "chart_course" + category: "travel" + display_text: "Chart a course to unexplored lands" + description: "Venture into unknown territory to discover new locations" + tier_required: "elite" + context_filter: "wilderness" + dm_prompt_template: | + The player ventures into unexplored territory from {{ location_name }}. + Generate a new location discovery: + - Name and type of location + - Initial description + - Potential dangers or opportunities + - Add to player's discovered locations +``` + +--- + +## Session Management + +### Solo Session Creation + +**Endpoint:** `POST /api/v1/sessions` + +**Request Body:** +```json +{ + "character_id": "char_abc123", + "session_type": "solo" +} +``` + +**Response:** +```json +{ + "session_id": "session_xyz789", + "character_id": "char_abc123", + "session_type": "solo", + "current_location": "thornfield_plains", + "turn_number": 0, + "status": "active", + "created_at": "2025-11-16T10:00:00Z" +} +``` + +### Taking a Story Action + +**Endpoint:** `POST /api/v1/sessions/{session_id}/action` + +**Request Body (Button Action):** +```json +{ + "action_type": "prompt", + "prompt_id": "ask_surroundings" +} +``` + +**Request Body (Custom Free-Form - Premium/Elite Only):** +```json +{ + "action_type": "custom", + "custom_text": "I want to climb the old tower and scout the horizon" +} +``` + +**Request Body (Ask DM - Out-of-Character Question):** +```json +{ + "action_type": "ask_dm", + "question": "Is there anyone around who might see me steal this item?" +} +``` + +> **Note:** Ask DM questions are informational only. They don't advance the story, increment the turn number, or cause game state changes. They use a separate rate limit from regular actions. + +**Response:** +```json +{ + "session_id": "session_xyz789", + "turn_number": 1, + "action_taken": "What do I see around me?", + "dm_response": "You stand in the windswept Thornfield Plains, tall grasses swaying in the breeze. To the north, you can make out the silhouette of a crumbling watchtower against the grey sky. The air smells of rain and distant smoke. A worn path leads east toward what might be a settlement.", + "state_changes": { + "discovered_locations": ["old_watchtower"] + }, + "quest_offered": null, + "ai_cost": 0.025, + "timestamp": "2025-11-16T10:01:23Z" +} +``` + +### Getting Session State + +**Endpoint:** `GET /api/v1/sessions/{session_id}` + +**Response:** +```json +{ + "session_id": "session_xyz789", + "character_id": "char_abc123", + "session_type": "solo", + "current_location": "thornfield_plains", + "location_type": "wilderness", + "turn_number": 5, + "status": "active", + "active_quests": ["quest_001"], + "discovered_locations": ["thornfield_plains", "old_watchtower", "riverwatch"], + "conversation_history": [ + { + "turn": 1, + "action": "What do I see around me?", + "dm_response": "You stand in the windswept Thornfield Plains..." + } + ], + "available_actions": [ + { + "prompt_id": "ask_surroundings", + "display_text": "What do I see around me?", + "category": "ask" + }, + { + "prompt_id": "check_dangers", + "display_text": "Are there any dangers nearby?", + "category": "ask" + } + ] +} +``` + +--- + +## AI Prompt Engineering + +### Context Building + +Each player action requires building a comprehensive prompt for the AI Dungeon Master: + +```python +def build_dm_prompt(session: GameSession, character: Character, action: ActionPrompt, custom_text: Optional[str] = None) -> str: + """Build the AI prompt with full context.""" + + context = { + # Character info + "character_name": character.name, + "character_class": character.player_class.name, + "character_level": character.level, + "character_origin": character.origin, + + # Location info + "location_name": session.game_state.current_location, + "location_type": get_location_type(session.game_state.current_location), + "discovered_locations": session.game_state.discovered_locations, + + # Quest info + "active_quests": get_quest_details(session.game_state.active_quests), + + # Recent history (last 3 turns) + "recent_history": session.conversation_history[-3:], + + # Player action + "player_action": custom_text or action.display_text, + } + + # Render base prompt template + if custom_text: + prompt = render_custom_action_prompt(context, custom_text) + else: + prompt = render_template(action.dm_prompt_template, context) + + return prompt +``` + +### AI Model Selection by Tier + +```python +def get_ai_model_for_tier(user_tier: str) -> str: + """Select AI model based on user subscription tier.""" + model_map = { + "free": "replicate/llama-3-8b", # Free tier model + "basic": "claude-haiku", # Fast, cheap + "premium": "claude-sonnet", # Balanced + "elite": "claude-opus" # Best quality + } + return model_map.get(user_tier, "replicate/llama-3-8b") +``` + +### Response Parsing and Item Extraction + +AI responses include both narrative text and structured game actions. The system automatically parses responses to extract items, gold, and experience to apply to the player's character. + +**Response Format:** + +The AI returns narrative followed by a structured `---GAME_ACTIONS---` block: + +``` +The kind vendor smiles and presses a few items into your hands... + +---GAME_ACTIONS--- +{ + "items_given": [ + {"name": "Stale Bread", "type": "consumable", "description": "A half-loaf of bread", "value": 1}, + {"item_id": "health_potion"} + ], + "items_taken": [], + "gold_given": 5, + "gold_taken": 0, + "experience_given": 10, + "quest_offered": null, + "quest_completed": null, + "location_change": null +} +``` + +**Item Grant Types:** + +1. **Existing Items** (by `item_id`): References items from the game data registry +2. **Generic Items** (by name/type/description): Creates simple mundane items + +**Item Validation:** + +All AI-granted items are validated before being added to inventory: + +- **Level requirements**: Items requiring higher level than character are rejected +- **Class restrictions**: Class-specific items validated against character class +- **Validation logging**: Failed items logged for review but don't fail the request + +**Processing Flow:** + +```python +# 1. Parse AI response +parsed_response = parse_ai_response(ai_response.narrative) + +# 2. Validate and resolve items +for item_grant in parsed_response.game_changes.items_given: + item, error = validator.validate_and_resolve_item(item_grant, character) + if item: + character.add_item(item) + +# 3. Apply gold/experience +character.add_gold(parsed_response.game_changes.gold_given) +character.add_experience(parsed_response.game_changes.experience_given) + +# 4. Save to database +``` + +**Generic Item Templates:** + +Common mundane items have predefined templates in `/app/data/generic_items.yaml`: +- Light sources: torch, lantern, candle +- Food: bread, cheese, rations, ale +- Tools: rope, flint, bedroll, crowbar +- Supplies: ink, parchment, bandages + +--- + +## UI/UX Design + +### Story Gameplay Screen Layout + +``` +┌─────────────────────────────────────────────────────┐ +│ Code of Conquest [Char] [Logout] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ 📍 Current Location: Thornfield Plains │ +│ 🎯 Active Quests: 1 │ +│ │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Conversation History │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Turn 1 │ │ +│ │ You: What do I see around me? │ │ +│ │ │ │ +│ │ DM: You stand in the windswept Thornfield │ │ +│ │ Plains, tall grasses swaying in the breeze. │ │ +│ │ To the north, you can make out the │ │ +│ │ silhouette of a crumbling watchtower... │ │ +│ │ │ │ +│ │ Turn 2 │ │ +│ │ You: Explore the area │ │ +│ │ │ │ +│ │ DM: As you wander through the plains, you │ │ +│ │ stumble upon fresh tracks leading north... │ │ +│ │ │ │ +│ └───────────────────────────────────────────────┘ │ +│ │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Take Action │ +│ │ +│ Ask Questions: │ +│ [What do I see?] [Any dangers?] │ +│ [What do I remember?] 🔒Premium │ +│ │ +│ Travel: │ +│ [Travel to ▼] [Explore area] │ +│ │ +│ Gather Information: │ +│ [Ask around streets] 🔒Premium │ +│ [Visit tavern] 🔒Premium │ +│ │ +│ Custom (Premium/Elite): │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ I want to... │ │ +│ └───────────────────────────────────────────────┘ │ +│ [Ask the DM] (250 chars) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +### Action Button States + +- **Available**: Full color, clickable +- **Tier-locked**: Greyed out with 🔒 icon and "Premium" or "Elite" badge +- **Context-locked**: Hidden (e.g., "Visit tavern" not shown in wilderness) +- **Loading**: Disabled with spinner during AI processing + +### Conversation History Display + +- Scrollable container with newest at bottom +- Turn numbers clearly visible +- Player actions in different style from DM responses +- Quest notifications highlighted +- Location changes noted +- Timestamps optional (show on hover) + +--- + +## Cost Management + +### Per-Turn Cost Estimates + +| Tier | AI Model | Est. Tokens | Cost/Turn | Daily Limit (Turns) | +|------|----------|-------------|-----------|---------------------| +| Free | Replicate Llama-3 8B | ~500 | $0 | Unlimited | +| Basic | Claude Haiku | ~800 | ~$0.01 | 200 turns/day | +| Premium | Claude Sonnet | ~1200 | ~$0.03 | 100 turns/day | +| Elite | Claude Opus | ~1500 | ~$0.10 | 50 turns/day | + +### Cost Control Measures + +1. **Token limits**: Cap AI responses at 500 tokens (narrative should be concise) +2. **Daily turn limits**: Prevent abuse and runaway costs +3. **Prompt caching**: Cache location descriptions, character context (Phase 4B) +4. **Model selection**: Free tier uses free model, paid tiers get better quality +5. **Monitoring**: Track per-user AI spending daily + +--- + +## Implementation Timeline + +### Week 8 of Phase 4 (Days 8-14) + +**Day 8: Action Prompt System** +- Create ActionPrompt dataclass +- Create ActionPromptLoader (YAML-based) +- Define all 10 action prompts in `action_prompts.yaml` +- Write unit tests for action availability logic + +**Day 9: Session Service** +- Create SessionService (create solo sessions, manage state) +- Session creation logic +- Session state retrieval +- Turn advancement logic +- Database operations + +**Day 10: Story Progression API** +- Implement story action endpoints: + - `POST /api/v1/sessions` - Create solo session + - `GET /api/v1/sessions/{id}` - Get session state + - `POST /api/v1/sessions/{id}/action` - Take action + - `GET /api/v1/sessions/{id}/history` - Get conversation history +- Validate actions based on tier and context +- Integrate with AI service (from Week 7) + +**Day 11-12: Story Progression UI** +- Create `templates/game/story.html` - Main story gameplay screen +- Action button rendering (tier-based, context-aware) +- Conversation history display (scrollable, formatted) +- HTMX integration for real-time turn updates +- Loading states during AI processing + +**Day 13-14: Integration Testing** +- Test full story turn flow +- Test tier restrictions +- Test action context filtering +- Test AI prompt building and response parsing +- Test session state persistence +- Performance testing (response times, cost tracking) + +--- + +## Testing Criteria + +### Unit Tests +- ✅ ActionPrompt.is_available() logic +- ✅ ActionPromptLoader loads all prompts correctly +- ✅ Tier hierarchy validation +- ✅ Context filtering (town vs wilderness) + +### Integration Tests +- ✅ Create solo session +- ✅ Take action (button-based) +- ✅ Take action (custom text, Premium/Elite) +- ✅ Verify tier restrictions enforced +- ✅ Verify context filtering works +- ✅ Verify conversation history persists +- ✅ Verify AI cost tracking +- ✅ Verify daily turn limits + +### Manual Testing +- ✅ Full story turn flow (10+ turns) +- ✅ All action types tested +- ✅ Custom text input (Premium/Elite) +- ✅ UI responsiveness +- ✅ Conversation history display +- ✅ Quest offering during turns (tested in Quest System phase) + +--- + +## Success Criteria + +- ✅ All 10 action prompts defined and loadable from YAML +- ✅ Action availability logic working (tier + context) +- ✅ Solo session creation and management functional +- ✅ Story action API endpoints operational +- ✅ AI prompt building with full context +- ✅ AI responses parsed and state updated correctly +- ✅ Story gameplay UI functional with HTMX +- ✅ Tier restrictions enforced (Free vs Premium vs Elite) +- ✅ Custom text input working for Premium/Elite tiers +- ✅ Conversation history persisted and displayed +- ✅ Cost tracking and daily limits enforced + +--- + +## Future Enhancements (Post-MVP) + +### Phase 13+ +- **Dynamic action generation**: AI suggests context-specific actions +- **Voice narration**: Text-to-speech for DM responses (Elite tier) +- **Branching narratives**: Major story choices with consequences +- **Location memory**: AI remembers previous visits and descriptions +- ~~**NPC persistence**: Recurring NPCs with memory of past interactions~~ ✅ Implemented +- **Session sharing**: Export/share story sessions as Markdown +- **Illustrations**: AI-generated scene images (Elite tier) +- **Random encounters**: Travel between locations may trigger events +- **NPC schedules**: NPCs move between locations based on time of day + +--- + +## Related Documentation + +- **[QUEST_SYSTEM.md](QUEST_SYSTEM.md)** - Quest offering and tracking during story turns +- **[GAME_SYSTEMS.md](GAME_SYSTEMS.md)** - Combat system (separate turn-based gameplay) +- **[DATA_MODELS.md](DATA_MODELS.md)** - GameSession, ConversationEntry, ActionPrompt, Location, NPC models +- **[API_REFERENCE.md](API_REFERENCE.md)** - Full API endpoint documentation (includes Travel and NPC APIs) +- **[ROADMAP.md](ROADMAP.md)** - Phase 4 timeline and milestones + +### Data Files +- `/app/data/regions/crossville.yaml` - Crossville region locations +- `/app/data/npcs/crossville_npcs.yaml` - NPCs in Crossville region +- `/app/data/action_prompts.yaml` - Player action definitions + +### Services +- `LocationLoader` - Loads and caches location/region data +- `NPCLoader` - Loads and caches NPC definitions +- `SessionService` - Manages game sessions and state + +--- + +**Document Version:** 1.2 +**Created:** November 16, 2025 +**Last Updated:** November 24, 2025 diff --git a/api/docs/USAGE_TRACKING.md b/api/docs/USAGE_TRACKING.md new file mode 100644 index 0000000..fe2fec2 --- /dev/null +++ b/api/docs/USAGE_TRACKING.md @@ -0,0 +1,614 @@ +# Usage Tracking & Cost Controls + +## Overview + +Code of Conquest implements comprehensive usage tracking and cost controls for AI operations. This ensures sustainable costs, fair usage across tiers, and visibility into system usage patterns. + +**Key Components:** +- **UsageTrackingService** - Logs all AI usage and calculates costs +- **RateLimiterService** - Enforces tier-based daily limits +- **AIUsageLog** - Data model for usage events + +--- + +## Architecture + +``` +┌─────────────────────┐ +│ AI Task Jobs │ +├─────────────────────┤ +│ UsageTrackingService│ ← Logs usage, calculates costs +├─────────────────────┤ +│ RateLimiterService │ ← Enforces limits before processing +├─────────────────────┤ +│ Redis + Appwrite │ ← Storage layer +└─────────────────────┘ +``` + +--- + +## Usage Tracking Service + +**File:** `app/services/usage_tracking_service.py` + +### Initialization + +```python +from app.services.usage_tracking_service import UsageTrackingService + +tracker = UsageTrackingService() +``` + +**Required Environment Variables:** +```bash +APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +APPWRITE_PROJECT_ID=your-project-id +APPWRITE_API_KEY=your-api-key +APPWRITE_DATABASE_ID=main +``` + +### Logging Usage + +```python +from app.models.ai_usage import TaskType + +# Log a usage event +usage_log = tracker.log_usage( + user_id="user_123", + model="anthropic/claude-3.5-sonnet", + tokens_input=150, + tokens_output=450, + task_type=TaskType.STORY_PROGRESSION, + session_id="sess_789", + character_id="char_456", + request_duration_ms=2500, + success=True +) + +print(f"Log ID: {usage_log.log_id}") +print(f"Cost: ${usage_log.estimated_cost:.6f}") +``` + +### Querying Usage + +**Daily Usage:** +```python +from datetime import date + +# Get today's usage +usage = tracker.get_daily_usage("user_123", date.today()) + +print(f"Requests: {usage.total_requests}") +print(f"Tokens: {usage.total_tokens}") +print(f"Input tokens: {usage.total_input_tokens}") +print(f"Output tokens: {usage.total_output_tokens}") +print(f"Cost: ${usage.estimated_cost:.4f}") +print(f"By task: {usage.requests_by_task}") +# {"story_progression": 10, "combat_narration": 3, ...} +``` + +**Monthly Cost:** +```python +# Get November 2025 cost +monthly = tracker.get_monthly_cost("user_123", 2025, 11) + +print(f"Monthly requests: {monthly.total_requests}") +print(f"Monthly tokens: {monthly.total_tokens}") +print(f"Monthly cost: ${monthly.estimated_cost:.2f}") +``` + +**Admin Monitoring:** +```python +# Get total platform cost for a day +total_cost = tracker.get_total_daily_cost(date.today()) +print(f"Platform daily cost: ${total_cost:.2f}") + +# Get user request count for rate limiting +count = tracker.get_user_request_count_today("user_123") +``` + +### Cost Estimation + +**Static Methods (no instance needed):** +```python +from app.services.usage_tracking_service import UsageTrackingService + +# Estimate cost for specific request +cost = UsageTrackingService.estimate_cost_for_model( + model="anthropic/claude-3.5-sonnet", + tokens_input=100, + tokens_output=400 +) +print(f"Estimated: ${cost:.6f}") + +# Get model pricing +info = UsageTrackingService.get_model_cost_info("anthropic/claude-3.5-sonnet") +print(f"Input: ${info['input']}/1K tokens") +print(f"Output: ${info['output']}/1K tokens") +``` + +--- + +## Model Pricing + +Costs per 1,000 tokens (USD): + +| Model | Input | Output | Tier | +|-------|-------|--------|------| +| `meta/meta-llama-3-8b-instruct` | $0.0001 | $0.0001 | Free | +| `meta/meta-llama-3-70b-instruct` | $0.0006 | $0.0006 | - | +| `anthropic/claude-3.5-haiku` | $0.001 | $0.005 | Basic | +| `anthropic/claude-3.5-sonnet` | $0.003 | $0.015 | Premium | +| `anthropic/claude-4.5-sonnet` | $0.003 | $0.015 | Elite | +| `anthropic/claude-3-opus` | $0.015 | $0.075 | - | + +**Default cost for unknown models:** $0.001 input, $0.005 output per 1K tokens + +--- + +## Token Estimation + +Since the Replicate API doesn't return exact token counts, tokens are estimated based on text length. + +### Estimation Formula + +```python +# Approximate 4 characters per token +tokens = len(text) // 4 +``` + +### How Tokens Are Calculated + +**Input Tokens:** +- Calculated from the full prompt sent to the AI +- Includes: user prompt + system prompt +- Estimated at: `len(prompt + system_prompt) // 4` + +**Output Tokens:** +- Calculated from the AI's response text +- Estimated at: `len(response_text) // 4` + +### ReplicateResponse Structure + +The Replicate client returns both input and output token estimates: + +```python +@dataclass +class ReplicateResponse: + text: str + tokens_used: int # Total (input + output) + tokens_input: int # Estimated input tokens + tokens_output: int # Estimated output tokens + model: str + generation_time: float +``` + +### Example Token Counts + +| Content | Characters | Estimated Tokens | +|---------|------------|------------------| +| Short prompt | 400 chars | ~100 tokens | +| Full DM prompt | 4,000 chars | ~1,000 tokens | +| Short response | 200 chars | ~50 tokens | +| Full narrative | 800 chars | ~200 tokens | + +### Accuracy Notes + +- Estimation is approximate (~75-80% accurate) +- Real tokenization varies by model +- Better to over-estimate for cost budgeting +- Logs use estimates; billing reconciliation may differ + +--- + +## Data Models + +**File:** `app/models/ai_usage.py` + +### AIUsageLog + +```python +@dataclass +class AIUsageLog: + log_id: str # Unique identifier + user_id: str # User who made request + timestamp: datetime # When request was made + model: str # Model identifier + tokens_input: int # Input/prompt tokens + tokens_output: int # Output/response tokens + tokens_total: int # Total tokens + estimated_cost: float # Cost in USD + task_type: TaskType # Type of task + session_id: Optional[str] # Game session + character_id: Optional[str] # Character + request_duration_ms: int # Duration + success: bool # Success status + error_message: Optional[str] # Error if failed +``` + +### TaskType Enum + +```python +class TaskType(str, Enum): + STORY_PROGRESSION = "story_progression" + COMBAT_NARRATION = "combat_narration" + QUEST_SELECTION = "quest_selection" + NPC_DIALOGUE = "npc_dialogue" + GENERAL = "general" +``` + +### Summary Objects + +```python +@dataclass +class DailyUsageSummary: + date: date + user_id: str + total_requests: int + total_tokens: int + total_input_tokens: int + total_output_tokens: int + estimated_cost: float + requests_by_task: Dict[str, int] + +@dataclass +class MonthlyUsageSummary: + year: int + month: int + user_id: str + total_requests: int + total_tokens: int + estimated_cost: float + daily_breakdown: list +``` + +--- + +## Rate Limiter Service + +**File:** `app/services/rate_limiter_service.py` + +### Daily Turn Limits + +| Tier | Limit | Cost Level | +|------|-------|------------| +| FREE | 20 turns/day | Zero | +| BASIC | 50 turns/day | Low | +| PREMIUM | 100 turns/day | Medium | +| ELITE | 200 turns/day | High | + +Counters reset at midnight UTC. + +### Custom Action Limits + +Free-text actions (beyond preset buttons) have additional limits per tier: + +| Tier | Custom Actions/Day | Character Limit | +|------|-------------------|-----------------| +| FREE | 10 | 150 chars | +| BASIC | 50 | 300 chars | +| PREMIUM | Unlimited | 500 chars | +| ELITE | Unlimited | 500 chars | + +**Configuration:** These values are defined in `config/*.yaml` under `rate_limiting.tiers`: +```yaml +tiers: + free: + custom_actions_per_day: 10 + custom_action_char_limit: 150 +``` + +**Access in code:** +```python +from app.config import get_config + +config = get_config() +tier_config = config.rate_limiting.tiers['free'] +print(tier_config.custom_actions_per_day) # 10 +print(tier_config.custom_action_char_limit) # 150 +``` + +### Basic Usage + +```python +from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded +from app.ai.model_selector import UserTier + +limiter = RateLimiterService() + +# Check and increment (typical flow) +try: + limiter.check_rate_limit("user_123", UserTier.PREMIUM) + # Process AI request... + limiter.increment_usage("user_123") +except RateLimitExceeded as e: + print(f"Limit reached: {e.current_usage}/{e.limit}") + print(f"Resets at: {e.reset_time}") +``` + +### Query Methods + +```python +# Get current usage +current = limiter.get_current_usage("user_123") + +# Get remaining turns +remaining = limiter.get_remaining_turns("user_123", UserTier.PREMIUM) +print(f"Remaining: {remaining} turns") + +# Get comprehensive info +info = limiter.get_usage_info("user_123", UserTier.PREMIUM) +# { +# "user_id": "user_123", +# "user_tier": "premium", +# "current_usage": 45, +# "daily_limit": 100, +# "remaining": 55, +# "reset_time": "2025-11-22T00:00:00+00:00", +# "is_limited": False +# } + +# Get limit for tier +limit = limiter.get_limit_for_tier(UserTier.ELITE) # 200 +``` + +### Admin Functions + +```python +# Reset user's daily counter (testing/admin) +limiter.reset_usage("user_123") +``` + +### RateLimitExceeded Exception + +```python +class RateLimitExceeded(Exception): + user_id: str + user_tier: UserTier + limit: int + current_usage: int + reset_time: datetime +``` + +Provides all information needed for user-friendly error messages. + +--- + +## Integration Pattern + +### In AI Task Jobs + +```python +from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded +from app.services.usage_tracking_service import UsageTrackingService +from app.ai.narrative_generator import NarrativeGenerator +from app.models.ai_usage import TaskType + +def process_ai_request(user_id: str, user_tier: UserTier, action: str, ...): + limiter = RateLimiterService() + tracker = UsageTrackingService() + generator = NarrativeGenerator() + + # 1. Check rate limit BEFORE processing + try: + limiter.check_rate_limit(user_id, user_tier) + except RateLimitExceeded as e: + return { + "error": "rate_limit_exceeded", + "message": f"Daily limit reached ({e.limit} turns). Resets at {e.reset_time}", + "remaining": 0, + "reset_time": e.reset_time.isoformat() + } + + # 2. Generate AI response + start_time = time.time() + response = generator.generate_story_response(...) + duration_ms = int((time.time() - start_time) * 1000) + + # 3. Log usage (tokens are estimated in ReplicateClient) + tracker.log_usage( + user_id=user_id, + model=response.model, + tokens_input=response.tokens_input, # From prompt length + tokens_output=response.tokens_output, # From response length + task_type=TaskType.STORY_PROGRESSION, + session_id=session_id, + request_duration_ms=duration_ms, + success=True + ) + + # 4. Increment rate limit counter + limiter.increment_usage(user_id) + + return {"narrative": response.narrative, ...} +``` + +### API Endpoint Pattern + +```python +@bp.route('/sessions//action', methods=['POST']) +@require_auth +def take_action(session_id): + user = get_current_user() + limiter = RateLimiterService() + + # Check limit and return remaining info + try: + limiter.check_rate_limit(user.id, user.tier) + except RateLimitExceeded as e: + return api_response( + status=429, + error={ + "code": "RATE_LIMIT_EXCEEDED", + "message": "Daily turn limit reached", + "details": { + "limit": e.limit, + "current": e.current_usage, + "reset_time": e.reset_time.isoformat() + } + } + ) + + # Queue AI job... + remaining = limiter.get_remaining_turns(user.id, user.tier) + + return api_response( + status=202, + result={ + "job_id": job.id, + "remaining_turns": remaining + } + ) +``` + +--- + +## Appwrite Collection Schema + +**Collection:** `ai_usage_logs` + +| Field | Type | Description | +|-------|------|-------------| +| `log_id` | string | Primary key | +| `user_id` | string | User identifier | +| `timestamp` | datetime | Request time (UTC) | +| `model` | string | Model identifier | +| `tokens_input` | integer | Input tokens | +| `tokens_output` | integer | Output tokens | +| `tokens_total` | integer | Total tokens | +| `estimated_cost` | double | Cost in USD | +| `task_type` | string | Task type enum | +| `session_id` | string | Optional session | +| `character_id` | string | Optional character | +| `request_duration_ms` | integer | Duration | +| `success` | boolean | Success status | +| `error_message` | string | Error if failed | + +**Indexes:** +- `user_id` + `timestamp` (for daily queries) +- `timestamp` (for admin monitoring) + +--- + +## Cost Management Best Practices + +### 1. Pre-request Validation + +Always check rate limits before processing: + +```python +limiter.check_rate_limit(user_id, user_tier) +``` + +### 2. Log All Requests + +Log both successful and failed requests: + +```python +tracker.log_usage( + ..., + success=False, + error_message="Model timeout" +) +``` + +### 3. Monitor Platform Costs + +```python +# Daily monitoring +daily_cost = tracker.get_total_daily_cost(date.today()) + +if daily_cost > 50: + send_alert("WARNING: Daily AI cost exceeded $50") +if daily_cost > 100: + send_alert("CRITICAL: Daily AI cost exceeded $100") +``` + +### 4. Cost Estimation for UI + +Show users estimated costs before actions: + +```python +cost_info = UsageTrackingService.get_model_cost_info(model) +estimated = (base_tokens * 1.5 / 1000) * (cost_info['input'] + cost_info['output']) +``` + +### 5. Tier Upgrade Prompts + +When rate limited, prompt upgrades: + +```python +if e.user_tier == UserTier.FREE: + message = "Upgrade to Basic for 50 turns/day!" +elif e.user_tier == UserTier.BASIC: + message = "Upgrade to Premium for 100 turns/day!" +``` + +--- + +## Target Cost Goals + +- **Development:** < $50/day +- **Production target:** < $500/month total +- **Cost per user:** ~$0.10/day (premium tier average) + +### Cost Breakdown by Tier (estimated daily) + +| Tier | Avg Requests | Avg Cost/Request | Daily Cost | +|------|-------------|-----------------|------------| +| FREE | 10 | $0.00 | $0.00 | +| BASIC | 30 | $0.003 | $0.09 | +| PREMIUM | 60 | $0.01 | $0.60 | +| ELITE | 100 | $0.02 | $2.00 | + +--- + +## Testing + +### Unit Tests + +```python +# test_usage_tracking_service.py +def test_log_usage(): + tracker = UsageTrackingService() + log = tracker.log_usage( + user_id="test_user", + model="meta/meta-llama-3-8b-instruct", + tokens_input=100, + tokens_output=200, + task_type=TaskType.STORY_PROGRESSION + ) + assert log.tokens_total == 300 + assert log.estimated_cost > 0 + +# test_rate_limiter_service.py +def test_rate_limit_exceeded(): + limiter = RateLimiterService() + + # Exceed free tier limit + for _ in range(20): + limiter.increment_usage("test_user") + + with pytest.raises(RateLimitExceeded): + limiter.check_rate_limit("test_user", UserTier.FREE) +``` + +### Integration Testing + +```bash +# Check Redis connection +redis-cli ping + +# Check Appwrite connection +python -c "from app.services.usage_tracking_service import UsageTrackingService; UsageTrackingService()" +``` + +--- + +## Future Enhancements (Deferred) + +- **Task 7.15:** Cost monitoring and alerts (daily job, email alerts) +- Billing integration +- Usage quotas per session +- Real-time cost dashboard +- Cost projections diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..f437eb5 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,57 @@ +# Web Framework +Flask>=3.0.0,<4.0.0 +Jinja2>=3.1.0,<4.0.0 +Werkzeug>=3.0.0,<4.0.0 + +# WSGI Server (Production) +gunicorn>=21.2.0,<22.0.0 + +# HTTP Client +requests>=2.31.0,<3.0.0 + +# Job Queue +rq>=1.15.0,<2.0.0 + +# Redis Client +redis>=5.0.0,<6.0.0 + +# Database & Auth (Appwrite SDK) +appwrite>=13.0.0,<14.0.0 + +# AI APIs +anthropic>=0.34.0,<1.0.0 +replicate>=0.25.0,<1.0.0 + +# Logging +structlog>=24.1.0,<25.0.0 +python-json-logger>=2.0.7,<3.0.0 + +# Configuration +PyYAML>=6.0.1,<7.0.0 +python-dotenv>=1.0.0,<2.0.0 + +# Validation & Serialization +marshmallow>=3.20.0,<4.0.0 +pydantic>=2.5.0,<3.0.0 + +# Rate Limiting +Flask-Limiter>=3.5.0,<4.0.0 + +# CORS +Flask-CORS>=4.0.0,<5.0.0 + +# Security +bleach>=6.1.0,<7.0.0 +cryptography>=41.0.0,<42.0.0 + +# Date/Time +python-dateutil>=2.8.2,<3.0.0 + +# Development Tools +pytest>=7.4.0,<8.0.0 +pytest-cov>=4.1.0,<5.0.0 +flake8>=6.1.0,<7.0.0 +black>=23.12.0,<24.0.0 + +# Utilities +click>=8.1.7,<9.0.0 diff --git a/api/scripts/README.md b/api/scripts/README.md new file mode 100644 index 0000000..125ee5d --- /dev/null +++ b/api/scripts/README.md @@ -0,0 +1,124 @@ +# Scripts Directory + +This directory contains utility scripts for database initialization, migrations, and other maintenance tasks. + +## Database Initialization + +### `init_database.py` + +Initializes all database tables in Appwrite with the correct schema, columns, and indexes. + +**Usage:** + +```bash +# Ensure virtual environment is activated +source venv/bin/activate + +# Run the initialization script +python scripts/init_database.py +``` + +**Prerequisites:** + +1. Appwrite instance running and accessible +2. `.env` file configured with Appwrite credentials: + - `APPWRITE_ENDPOINT` + - `APPWRITE_PROJECT_ID` + - `APPWRITE_API_KEY` + - `APPWRITE_DATABASE_ID` + +**What it does:** + +1. Validates environment configuration +2. Creates the following tables: + - **characters**: Player character data with userId indexing + +3. Creates necessary columns and indexes for efficient querying +4. Skips tables/columns/indexes that already exist (idempotent) + +**Output:** + +``` +============================================================ +Code of Conquest - Database Initialization +============================================================ + +✓ Environment variables loaded + Endpoint: https://your-appwrite-instance.com/v1 + Project: your-project-id + Database: main + +Initializing database tables... + +============================================================ +Initialization Results +============================================================ + +✓ characters: SUCCESS + +Total: 1 succeeded, 0 failed + +✓ All tables initialized successfully! + +You can now start the application. +``` + +## Adding New Tables + +To add a new table to the initialization process: + +1. Open `app/services/database_init.py` +2. Create a new method following the pattern of `init_characters_table()` +3. Add the table initialization to `init_all_tables()` method +4. Run the initialization script + +Example: + +```python +def init_sessions_table(self) -> bool: + """Initialize the sessions table.""" + table_id = 'sessions' + + # Create table + table = self.tables_db.create_table( + database_id=self.database_id, + table_id=table_id, + name='Sessions' + ) + + # Create columns + self._create_column( + table_id=table_id, + column_id='userId', + column_type='string', + size=255, + required=True + ) + + # Create indexes + self._create_index( + table_id=table_id, + index_id='idx_userId', + index_type='key', + attributes=['userId'] + ) + + return True +``` + +## Troubleshooting + +### Missing Environment Variables + +If you see errors about missing environment variables, ensure your `.env` file contains all required Appwrite configuration. + +### Connection Errors + +If the script cannot connect to Appwrite: +- Verify the `APPWRITE_ENDPOINT` is correct and accessible +- Check that the API key has sufficient permissions +- Ensure the database exists in your Appwrite project + +### Column/Index Already Exists + +The script is idempotent and will log warnings for existing columns/indexes without failing. This is normal if you run the script multiple times. diff --git a/api/scripts/clear_char_daily_limit.sh b/api/scripts/clear_char_daily_limit.sh new file mode 100755 index 0000000..ee5d365 --- /dev/null +++ b/api/scripts/clear_char_daily_limit.sh @@ -0,0 +1,2 @@ +#!/bin/env bash +python -c "from app.services.rate_limiter_service import RateLimiterService; RateLimiterService().reset_usage('69180281baf6d52c772d')" \ No newline at end of file diff --git a/api/scripts/clear_worker_queues.sh b/api/scripts/clear_worker_queues.sh new file mode 100755 index 0000000..0439921 --- /dev/null +++ b/api/scripts/clear_worker_queues.sh @@ -0,0 +1,2 @@ +#!/bin/env bash +docker exec -it coc_redis redis-cli FLUSHALL \ No newline at end of file diff --git a/api/scripts/init_database.py b/api/scripts/init_database.py new file mode 100755 index 0000000..83482cb --- /dev/null +++ b/api/scripts/init_database.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Database Initialization Script. + +This script initializes all database tables in Appwrite. +Run this script once to set up the database schema before running the application. + +Usage: + python scripts/init_database.py +""" + +import sys +import os +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from dotenv import load_dotenv +from app.services.database_init import init_database +from app.utils.logging import get_logger + +# Load environment variables +load_dotenv() + +logger = get_logger(__file__) + + +def main(): + """Initialize database tables.""" + print("=" * 60) + print("Code of Conquest - Database Initialization") + print("=" * 60) + print() + + # Verify environment variables are set + required_vars = [ + 'APPWRITE_ENDPOINT', + 'APPWRITE_PROJECT_ID', + 'APPWRITE_API_KEY', + 'APPWRITE_DATABASE_ID' + ] + + missing_vars = [var for var in required_vars if not os.getenv(var)] + if missing_vars: + print("❌ ERROR: Missing required environment variables:") + for var in missing_vars: + print(f" - {var}") + print() + print("Please ensure your .env file is configured correctly.") + sys.exit(1) + + print("✓ Environment variables loaded") + print(f" Endpoint: {os.getenv('APPWRITE_ENDPOINT')}") + print(f" Project: {os.getenv('APPWRITE_PROJECT_ID')}") + print(f" Database: {os.getenv('APPWRITE_DATABASE_ID')}") + print() + + # Initialize database + print("Initializing database tables...") + print() + + try: + results = init_database() + + print() + print("=" * 60) + print("Initialization Results") + print("=" * 60) + print() + + success_count = 0 + failed_count = 0 + + for table_name, success in results.items(): + if success: + print(f"✓ {table_name}: SUCCESS") + success_count += 1 + else: + print(f"✗ {table_name}: FAILED") + failed_count += 1 + + print() + print(f"Total: {success_count} succeeded, {failed_count} failed") + print() + + if failed_count > 0: + print("⚠️ Some tables failed to initialize. Check logs for details.") + sys.exit(1) + else: + print("✓ All tables initialized successfully!") + print() + print("You can now start the application.") + + except Exception as e: + logger.error("Database initialization failed", error=str(e)) + print() + print(f"❌ ERROR: {str(e)}") + print() + print("Check logs for details.") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/api/scripts/queue_info.py b/api/scripts/queue_info.py new file mode 100755 index 0000000..e0c0e30 --- /dev/null +++ b/api/scripts/queue_info.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +RQ Queue Monitoring Utility + +Displays information about RQ queues and their jobs. + +Usage: + python scripts/queue_info.py # Show all queues + python scripts/queue_info.py --failed # Show failed jobs + python scripts/queue_info.py --workers # Show active workers +""" + +import argparse +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from redis import Redis +from rq import Queue, Worker +from rq.job import Job +from rq.registry import FailedJobRegistry, StartedJobRegistry + +from app.tasks import ALL_QUEUES, get_redis_connection, get_all_queues_info + + +def show_queue_info(): + """Display information about all queues.""" + print("\n" + "=" * 60) + print("RQ Queue Status") + print("=" * 60) + + for info in get_all_queues_info(): + print(f"\nQueue: {info['name']}") + print(f" Description: {info['description']}") + print(f" Jobs in queue: {info['count']}") + print(f" Default timeout: {info['default_timeout']}s") + print(f" Result TTL: {info['default_result_ttl']}s") + + +def show_failed_jobs(): + """Display failed jobs from all queues.""" + print("\n" + "=" * 60) + print("Failed Jobs") + print("=" * 60) + + conn = get_redis_connection() + + for queue_name in ALL_QUEUES: + queue = Queue(queue_name, connection=conn) + registry = FailedJobRegistry(queue=queue) + job_ids = registry.get_job_ids() + + if job_ids: + print(f"\nQueue: {queue_name} ({len(job_ids)} failed)") + for job_id in job_ids[:10]: # Show first 10 + job = Job.fetch(job_id, connection=conn) + print(f" - {job_id}") + print(f" Function: {job.func_name}") + print(f" Failed at: {job.ended_at}") + if job.exc_info: + # Show first line of exception + exc_line = job.exc_info.split('\n')[-2] if job.exc_info else 'Unknown' + print(f" Error: {exc_line[:80]}") + else: + print(f"\nQueue: {queue_name} (no failed jobs)") + + +def show_workers(): + """Display active workers.""" + print("\n" + "=" * 60) + print("Active Workers") + print("=" * 60) + + conn = get_redis_connection() + workers = Worker.all(connection=conn) + + if not workers: + print("\nNo active workers found.") + return + + for worker in workers: + print(f"\nWorker: {worker.name}") + print(f" State: {worker.get_state()}") + print(f" Queues: {', '.join(q.name for q in worker.queues)}") + print(f" PID: {worker.pid}") + + current_job = worker.get_current_job() + if current_job: + print(f" Current job: {current_job.id}") + print(f" Function: {current_job.func_name}") + + +def show_started_jobs(): + """Display currently running jobs.""" + print("\n" + "=" * 60) + print("Running Jobs") + print("=" * 60) + + conn = get_redis_connection() + + for queue_name in ALL_QUEUES: + queue = Queue(queue_name, connection=conn) + registry = StartedJobRegistry(queue=queue) + job_ids = registry.get_job_ids() + + if job_ids: + print(f"\nQueue: {queue_name} ({len(job_ids)} running)") + for job_id in job_ids: + job = Job.fetch(job_id, connection=conn) + print(f" - {job_id}") + print(f" Function: {job.func_name}") + print(f" Started at: {job.started_at}") + else: + print(f"\nQueue: {queue_name} (no running jobs)") + + +def main(): + parser = argparse.ArgumentParser(description='RQ Queue Monitoring Utility') + parser.add_argument('--failed', action='store_true', help='Show failed jobs') + parser.add_argument('--workers', action='store_true', help='Show active workers') + parser.add_argument('--running', action='store_true', help='Show running jobs') + parser.add_argument('--all', action='store_true', help='Show all information') + + args = parser.parse_args() + + # Always show queue info + show_queue_info() + + if args.all or args.workers: + show_workers() + + if args.all or args.running: + show_started_jobs() + + if args.all or args.failed: + show_failed_jobs() + + print("\n" + "=" * 60) + print("Done") + print("=" * 60 + "\n") + + +if __name__ == '__main__': + main() diff --git a/api/scripts/setup.sh b/api/scripts/setup.sh new file mode 100755 index 0000000..3b21e79 --- /dev/null +++ b/api/scripts/setup.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Setup script for Code of Conquest +# Run this after cloning the repository + +set -e + +echo "=========================================" +echo "Code of Conquest - Setup Script" +echo "=========================================" +echo "" + +# Check Python version +echo "Checking Python version..." +python3 --version + +# Check if virtual environment exists +if [ ! -d "venv" ]; then + echo "Creating virtual environment..." + python3 -m venv venv +else + echo "Virtual environment already exists." +fi + +# Activate virtual environment +echo "Activating virtual environment..." +source venv/bin/activate + +# Install dependencies +echo "Installing dependencies..." +pip install --upgrade pip +pip install -r requirements.txt + +# Create .env if it doesn't exist +if [ ! -f ".env" ]; then + echo "Creating .env file from template..." + cp .env.example .env + echo "⚠️ Please edit .env and add your API keys!" +else + echo ".env file already exists." +fi + +# Create logs directory +mkdir -p logs + +# Check Docker +echo "" +echo "Checking Docker installation..." +if command -v docker &> /dev/null; then + echo "✓ Docker is installed" + docker --version +else + echo "✗ Docker is not installed. Please install Docker to run Redis locally." +fi + +# Check Docker Compose +if command -v docker-compose &> /dev/null; then + echo "✓ Docker Compose is installed" + docker-compose --version +else + echo "✗ Docker Compose is not installed." +fi + +echo "" +echo "=========================================" +echo "Setup complete!" +echo "=========================================" +echo "" +echo "Next steps:" +echo "1. Edit .env and add your API keys" +echo "2. Follow docs/APPWRITE_SETUP.md to configure Appwrite" +echo "3. Start Redis: docker-compose up -d" +echo "4. Run the app: python wsgi.py" +echo "" +echo "For more information, see README.md" +echo "" diff --git a/api/scripts/start_workers.sh b/api/scripts/start_workers.sh new file mode 100755 index 0000000..5a30173 --- /dev/null +++ b/api/scripts/start_workers.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# RQ Worker Startup Script +# +# This script starts RQ workers for processing background jobs. +# Workers listen on configured queues in priority order. +# +# Usage: +# ./scripts/start_workers.sh # Start all-queue worker +# ./scripts/start_workers.sh ai # Start AI-only worker +# ./scripts/start_workers.sh combat # Start combat-only worker +# ./scripts/start_workers.sh marketplace # Start marketplace-only worker +# +# Environment Variables: +# REDIS_URL - Redis connection URL (default: redis://localhost:6379/0) +# LOG_LEVEL - Logging level (default: INFO) +# WORKER_COUNT - Number of workers to start (default: 1) + +set -e + +# Change to API directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +API_DIR="$(dirname "$SCRIPT_DIR")" +cd "$API_DIR" + +# Load environment variables if .env exists +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +# Default configuration +REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" +LOG_LEVEL="${LOG_LEVEL:-INFO}" +WORKER_COUNT="${WORKER_COUNT:-1}" + +# Determine which queues to listen on +WORKER_TYPE="${1:-all}" + +case "$WORKER_TYPE" in + ai) + QUEUES="ai_tasks" + WORKER_NAME="ai-worker" + ;; + combat) + QUEUES="combat_tasks" + WORKER_NAME="combat-worker" + ;; + marketplace) + QUEUES="marketplace_tasks" + WORKER_NAME="marketplace-worker" + ;; + all|*) + QUEUES="ai_tasks,combat_tasks,marketplace_tasks" + WORKER_NAME="all-queues-worker" + ;; +esac + +echo "==========================================" +echo "Starting RQ Worker" +echo "==========================================" +echo "Worker Type: $WORKER_TYPE" +echo "Worker Name: $WORKER_NAME" +echo "Queues: $QUEUES" +echo "Redis URL: ${REDIS_URL//:*@/:***@}" +echo "Log Level: $LOG_LEVEL" +echo "Worker Count: $WORKER_COUNT" +echo "==========================================" + +# Activate virtual environment if it exists +if [ -d "venv" ]; then + echo "Activating virtual environment..." + source venv/bin/activate +fi + +# Start workers +if [ "$WORKER_COUNT" -eq 1 ]; then + # Single worker + echo "Starting single worker..." + exec rq worker \ + --url "$REDIS_URL" \ + --name "$WORKER_NAME" \ + --logging_level "$LOG_LEVEL" \ + --with-scheduler \ + $QUEUES +else + # Multiple workers (use supervisord or run in background) + echo "Starting $WORKER_COUNT workers..." + for i in $(seq 1 $WORKER_COUNT); do + rq worker \ + --url "$REDIS_URL" \ + --name "${WORKER_NAME}-${i}" \ + --logging_level "$LOG_LEVEL" \ + $QUEUES & + echo "Started worker ${WORKER_NAME}-${i} (PID: $!)" + done + + echo "All workers started. Press Ctrl+C to stop." + wait +fi diff --git a/api/scripts/verify_ai_models.py b/api/scripts/verify_ai_models.py new file mode 100644 index 0000000..dfcc2da --- /dev/null +++ b/api/scripts/verify_ai_models.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Verification script for Task 7.8: Verify all AI models respond correctly. + +This script tests: +1. Replicate client with Llama-3 8B +2. Replicate client with Claude Haiku +3. Replicate client with Claude Sonnet +4. Model selector tier routing +5. Token counting accuracy +6. Response quality comparison + +Usage: + python scripts/verify_ai_models.py [--all] [--llama] [--haiku] [--sonnet] [--opus] +""" + +import argparse +import sys +import time +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Load .env before importing app modules +from dotenv import load_dotenv +load_dotenv() + +import structlog +from app.ai import ( + ReplicateClient, + ModelType, + ModelSelector, + UserTier, + ContextType, + ReplicateClientError, +) + +logger = structlog.get_logger(__name__) + +# Test prompt for narrative generation +TEST_PROMPT = """You are a dungeon master. The player enters a dimly lit tavern. +Describe the scene in 2-3 sentences. Include at least one interesting NPC.""" + +TEST_SYSTEM_PROMPT = "You are a creative fantasy storyteller. Keep responses concise but vivid." + + +def test_model(model_type: ModelType, client: ReplicateClient | None = None) -> dict: + """ + Test a specific model and return results. + + Args: + model_type: The model to test. + client: Optional existing client, otherwise creates new one. + + Returns: + Dictionary with test results. + """ + model_name = model_type.name + print(f"\n{'='*60}") + print(f"Testing: {model_name}") + print(f"Model ID: {model_type.value}") + print(f"{'='*60}") + + try: + if client is None: + client = ReplicateClient(model=model_type) + + start_time = time.time() + response = client.generate( + prompt=TEST_PROMPT, + system_prompt=TEST_SYSTEM_PROMPT, + model=model_type + ) + elapsed = time.time() - start_time + + print(f"\n✅ SUCCESS") + print(f"Response time: {elapsed:.2f}s") + print(f"Tokens used: {response.tokens_used}") + print(f"Response length: {len(response.text)} chars") + print(f"\nGenerated text:") + print("-" * 40) + print(response.text[:500] + ("..." if len(response.text) > 500 else "")) + print("-" * 40) + + return { + "model": model_name, + "success": True, + "response_time": elapsed, + "tokens_used": response.tokens_used, + "text_length": len(response.text), + "text_preview": response.text[:200] + } + + except ReplicateClientError as e: + print(f"\n❌ FAILED: {e}") + return { + "model": model_name, + "success": False, + "error": str(e) + } + except Exception as e: + print(f"\n❌ UNEXPECTED ERROR: {e}") + return { + "model": model_name, + "success": False, + "error": str(e) + } + + +def test_model_selector(): + """Test the model selector tier routing.""" + print(f"\n{'='*60}") + print("Testing Model Selector") + print(f"{'='*60}") + + selector = ModelSelector() + + test_cases = [ + (UserTier.FREE, ContextType.STORY_PROGRESSION), + (UserTier.BASIC, ContextType.STORY_PROGRESSION), + (UserTier.PREMIUM, ContextType.STORY_PROGRESSION), + (UserTier.ELITE, ContextType.STORY_PROGRESSION), + (UserTier.PREMIUM, ContextType.QUEST_SELECTION), + (UserTier.PREMIUM, ContextType.COMBAT_NARRATION), + ] + + print("\nTier → Model Routing:") + print("-" * 40) + + for tier, context in test_cases: + config = selector.select_model(tier, context) + info = selector.get_tier_info(tier) + cost = selector.estimate_cost_per_request(tier) + + print(f"{tier.value:10} + {context.value:20} → {config.model_type.name:15} " + f"(tokens={config.max_tokens}, temp={config.temperature}, cost=${cost:.4f})") + + print("\n✅ Model selector routing verified") + + +def run_verification(models_to_test: list[ModelType]): + """ + Run full verification suite. + + Args: + models_to_test: List of models to test with real API calls. + """ + print("\n" + "=" * 60) + print("Phase 4 Task 7.8: AI Model Verification") + print("=" * 60) + + results = [] + + # Test model selector first (no API calls) + test_model_selector() + + if not models_to_test: + print("\nNo models selected for API testing.") + print("Use --llama, --haiku, --sonnet, --opus, or --all") + return + + # Create a single client for efficiency + try: + client = ReplicateClient() + except ReplicateClientError as e: + print(f"\n❌ Failed to initialize Replicate client: {e}") + print("Check REPLICATE_API_TOKEN in .env") + return + + # Test each selected model + for model_type in models_to_test: + result = test_model(model_type, client) + results.append(result) + + # Summary + print("\n" + "=" * 60) + print("VERIFICATION SUMMARY") + print("=" * 60) + + passed = sum(1 for r in results if r.get("success")) + failed = len(results) - passed + + for result in results: + status = "✅" if result.get("success") else "❌" + model = result.get("model") + if result.get("success"): + time_s = result.get("response_time", 0) + tokens = result.get("tokens_used", 0) + print(f"{status} {model}: {time_s:.2f}s, {tokens} tokens") + else: + error = result.get("error", "Unknown error") + print(f"{status} {model}: {error[:50]}") + + print(f"\nTotal: {passed} passed, {failed} failed") + + if failed == 0: + print("\n✅ All verification checks passed!") + else: + print(f"\n⚠️ {failed} model(s) failed verification") + + +def main(): + parser = argparse.ArgumentParser( + description="Verify AI models respond correctly through Replicate" + ) + parser.add_argument("--all", action="store_true", help="Test all models") + parser.add_argument("--llama", action="store_true", help="Test Llama-3 8B") + parser.add_argument("--haiku", action="store_true", help="Test Claude Haiku") + parser.add_argument("--sonnet", action="store_true", help="Test Claude Sonnet") + parser.add_argument("--opus", action="store_true", help="Test Claude Opus") + + args = parser.parse_args() + + models_to_test = [] + + if args.all: + models_to_test = [ + ModelType.LLAMA_3_8B, + ModelType.CLAUDE_HAIKU, + ModelType.CLAUDE_SONNET, + ModelType.CLAUDE_SONNET_4, + ] + else: + if args.llama: + models_to_test.append(ModelType.LLAMA_3_8B) + if args.haiku: + models_to_test.append(ModelType.CLAUDE_HAIKU) + if args.sonnet: + models_to_test.append(ModelType.CLAUDE_SONNET) + if args.opus: + models_to_test.append(ModelType.CLAUDE_SONNET_4) + + run_verification(models_to_test) + + +if __name__ == "__main__": + main() diff --git a/api/scripts/verify_e2e_ai_generation.py b/api/scripts/verify_e2e_ai_generation.py new file mode 100755 index 0000000..31446b1 --- /dev/null +++ b/api/scripts/verify_e2e_ai_generation.py @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +""" +Task 7.12: CHECKPOINT - Verify end-to-end AI generation flow + +This script verifies the complete AI generation pipeline: +1. Queue a story action job via RQ +2. Verify job processes and calls AI client +3. Check AI response is coherent and appropriate +4. Verify GameSession updated in Appwrite +5. Confirm Realtime notification sent (via document update) +6. Test job failure and retry logic +7. Verify response stored in Redis cache +8. Test with all 3 user tiers (Free, Premium, Elite) + +Usage: + # Run without real AI calls (mock mode) + python scripts/verify_e2e_ai_generation.py + + # Run with real AI calls (requires REPLICATE_API_TOKEN) + python scripts/verify_e2e_ai_generation.py --real + + # Test specific user tier + python scripts/verify_e2e_ai_generation.py --tier free + python scripts/verify_e2e_ai_generation.py --tier premium + python scripts/verify_e2e_ai_generation.py --tier elite + + # Run full integration test (requires Redis, worker, Appwrite) + python scripts/verify_e2e_ai_generation.py --integration +""" + +import argparse +import json +import os +import sys +import time +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Load environment variables from .env file +from dotenv import load_dotenv +load_dotenv() + +from app.ai.model_selector import UserTier, ContextType, ModelSelector +from app.ai.narrative_generator import NarrativeGenerator, NarrativeResponse +from app.ai.replicate_client import ReplicateClient, ReplicateResponse, ModelType +from app.tasks.ai_tasks import ( + enqueue_ai_task, + get_job_status, + get_job_result, + process_ai_task, + TaskType, + JobStatus, +) +from app.services.redis_service import RedisService + + +class Colors: + """Terminal colors for output.""" + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + BLUE = '\033[94m' + BOLD = '\033[1m' + END = '\033[0m' + + +def log_pass(message: str) -> None: + """Log a passing test.""" + print(f"{Colors.GREEN}✓{Colors.END} {message}") + + +def log_fail(message: str) -> None: + """Log a failing test.""" + print(f"{Colors.RED}✗{Colors.END} {message}") + + +def log_info(message: str) -> None: + """Log info message.""" + print(f"{Colors.BLUE}ℹ{Colors.END} {message}") + + +def log_section(title: str) -> None: + """Log section header.""" + print(f"\n{Colors.BOLD}{Colors.YELLOW}{'='*60}{Colors.END}") + print(f"{Colors.BOLD}{Colors.YELLOW}{title}{Colors.END}") + print(f"{Colors.BOLD}{Colors.YELLOW}{'='*60}{Colors.END}\n") + + +# Sample test data +SAMPLE_CHARACTER = { + "character_id": "char_test_123", + "name": "Aldric the Bold", + "level": 3, + "player_class": "Fighter", + "race": "Human", + "stats": { + "strength": 16, + "dexterity": 12, + "constitution": 14, + "intelligence": 10, + "wisdom": 11, + "charisma": 13 + }, + "current_hp": 28, + "max_hp": 28, + "gold": 50, + "inventory": [ + {"name": "Longsword", "type": "weapon", "quantity": 1}, + {"name": "Shield", "type": "armor", "quantity": 1}, + {"name": "Healing Potion", "type": "consumable", "quantity": 2} + ], + "skills": [ + {"name": "Athletics", "level": 5}, + {"name": "Intimidation", "level": 3}, + {"name": "Perception", "level": 4} + ], + "effects": [] +} + +SAMPLE_GAME_STATE = { + "current_location": "The Rusty Anchor Tavern", + "location_type": "TAVERN", + "discovered_locations": ["Crossroads Village", "The Rusty Anchor Tavern"], + "active_quests": [], + "world_events": [], + "time_of_day": "evening", + "weather": "clear" +} + +SAMPLE_CONVERSATION_HISTORY = [ + { + "turn": 1, + "action": "I enter the tavern", + "dm_response": "You push open the heavy wooden door and step inside. The warmth hits you immediately...", + "timestamp": "2025-11-21T10:00:00Z" + }, + { + "turn": 2, + "action": "I approach the bar", + "dm_response": "The barkeep, a stout dwarf with a magnificent braided beard, looks up...", + "timestamp": "2025-11-21T10:05:00Z" + } +] + +SAMPLE_COMBAT_STATE = { + "round_number": 2, + "enemies": [ + {"name": "Goblin", "current_hp": 5, "max_hp": 7, "armor_class": 13} + ], + "is_player_turn": True, + "combat_log": [] +} + +SAMPLE_NPC = { + "name": "Old Barkeep", + "role": "Tavern Owner", + "personality": "Gruff but kind-hearted", + "description": "A stout dwarf with a magnificent braided beard and keen eyes" +} + +SAMPLE_ELIGIBLE_QUESTS = [ + { + "quest_id": "quest_goblin_cave", + "name": "Clear the Goblin Cave", + "description": "A nearby cave has been overrun by goblins raiding farms", + "quest_giver": "Village Elder", + "difficulty": "EASY", + "narrative_hooks": [ + "The village elder looks worried about recent goblin attacks", + "You hear farmers complaining about lost livestock" + ] + }, + { + "quest_id": "quest_lost_merchant", + "name": "Find the Lost Merchant", + "description": "A merchant went missing on the forest road", + "quest_giver": "Merchant Guild", + "difficulty": "EASY", + "narrative_hooks": [ + "Posters about a missing merchant are everywhere", + "The merchant guild is offering a reward" + ] + } +] + + +def verify_model_selector_routing() -> bool: + """Verify model selector routes correctly for all tiers.""" + log_section("1. Model Selector Routing") + + selector = ModelSelector() + all_passed = True + + tier_tests = [ + (UserTier.FREE, ModelType.LLAMA_3_8B, "Llama-3 8B"), + (UserTier.BASIC, ModelType.CLAUDE_HAIKU, "Claude Haiku"), + (UserTier.PREMIUM, ModelType.CLAUDE_SONNET, "Claude Sonnet"), + (UserTier.ELITE, ModelType.CLAUDE_SONNET_4, "Claude Sonnet 4.5"), + ] + + for tier, expected_model, model_name in tier_tests: + config = selector.select_model(tier, ContextType.STORY_PROGRESSION) + if config.model_type == expected_model: + log_pass(f"{tier.value} tier → {model_name}") + else: + log_fail(f"{tier.value} tier: Expected {model_name}, got {config.model_type}") + all_passed = False + + return all_passed + + +def verify_narrative_generator_mocked() -> bool: + """Verify NarrativeGenerator works with mocked AI client.""" + log_section("2. Narrative Generator (Mocked)") + + all_passed = True + + # Mock the Replicate client + mock_response = ReplicateResponse( + text="You scan the tavern carefully, your trained eyes taking in every detail...", + tokens_used=150, + model="meta/meta-llama-3-8b-instruct", + generation_time=1.5 + ) + + mock_client = MagicMock(spec=ReplicateClient) + mock_client.generate.return_value = mock_response + + generator = NarrativeGenerator(replicate_client=mock_client) + + # Test story response + try: + response = generator.generate_story_response( + character=SAMPLE_CHARACTER, + action="I search the room for hidden doors", + game_state=SAMPLE_GAME_STATE, + user_tier=UserTier.FREE, + conversation_history=SAMPLE_CONVERSATION_HISTORY + ) + + if response.narrative and len(response.narrative) > 0: + log_pass(f"Story response generated ({response.tokens_used} tokens)") + else: + log_fail("Story response is empty") + all_passed = False + + except Exception as e: + log_fail(f"Story generation failed: {e}") + all_passed = False + + # Test combat narration + try: + action_result = {"hit": True, "damage": 8, "effects": []} + response = generator.generate_combat_narration( + character=SAMPLE_CHARACTER, + combat_state=SAMPLE_COMBAT_STATE, + action="swings sword at goblin", + action_result=action_result, + user_tier=UserTier.BASIC, + is_critical=False, + is_finishing_blow=True + ) + + if response.narrative: + log_pass(f"Combat narration generated ({response.tokens_used} tokens)") + else: + log_fail("Combat narration is empty") + all_passed = False + + except Exception as e: + log_fail(f"Combat narration failed: {e}") + all_passed = False + + # Test NPC dialogue + try: + response = generator.generate_npc_dialogue( + character=SAMPLE_CHARACTER, + npc=SAMPLE_NPC, + conversation_topic="What rumors have you heard lately?", + game_state=SAMPLE_GAME_STATE, + user_tier=UserTier.PREMIUM + ) + + if response.narrative: + log_pass(f"NPC dialogue generated ({response.tokens_used} tokens)") + else: + log_fail("NPC dialogue is empty") + all_passed = False + + except Exception as e: + log_fail(f"NPC dialogue failed: {e}") + all_passed = False + + # Test quest selection + mock_client.generate.return_value = ReplicateResponse( + text="quest_goblin_cave", + tokens_used=50, + model="meta/meta-llama-3-8b-instruct", + generation_time=0.5 + ) + + try: + quest_id = generator.generate_quest_selection( + character=SAMPLE_CHARACTER, + eligible_quests=SAMPLE_ELIGIBLE_QUESTS, + game_context=SAMPLE_GAME_STATE, + user_tier=UserTier.FREE + ) + + if quest_id == "quest_goblin_cave": + log_pass(f"Quest selection returned: {quest_id}") + else: + log_fail(f"Unexpected quest_id: {quest_id}") + all_passed = False + + except Exception as e: + log_fail(f"Quest selection failed: {e}") + all_passed = False + + return all_passed + + +def verify_ai_task_processing_mocked() -> bool: + """Verify AI task processing with mocked components.""" + log_section("3. AI Task Processing (Mocked)") + + all_passed = True + + # Mock dependencies + mock_response = ReplicateResponse( + text="The tavern grows quiet as you make your proclamation...", + tokens_used=200, + model="meta/meta-llama-3-8b-instruct", + generation_time=2.0 + ) + + with patch('app.tasks.ai_tasks.NarrativeGenerator') as MockGenerator, \ + patch('app.tasks.ai_tasks._get_user_tier') as mock_get_tier, \ + patch('app.tasks.ai_tasks._update_game_session') as mock_update_session: + + # Setup mocks + mock_get_tier.return_value = UserTier.FREE + + mock_gen_instance = MagicMock() + mock_gen_instance.generate_story_response.return_value = NarrativeResponse( + narrative=mock_response.text, + tokens_used=mock_response.tokens_used, + model=mock_response.model, + context_type="story_progression", + generation_time=mock_response.generation_time + ) + MockGenerator.return_value = mock_gen_instance + + # Test narrative task processing + context = { + "action": "I stand on a table and announce myself", + "character": SAMPLE_CHARACTER, + "game_state": SAMPLE_GAME_STATE, + "conversation_history": SAMPLE_CONVERSATION_HISTORY + } + + job_id = f"test_{uuid4().hex[:8]}" + + try: + result = process_ai_task( + task_type="narrative", + user_id="test_user_123", + context=context, + job_id=job_id, + session_id="sess_test_123", + character_id="char_test_123" + ) + + if result.get("narrative"): + log_pass(f"Narrative task processed successfully") + log_info(f" Tokens: {result.get('tokens_used')}, Model: {result.get('model')}") + else: + log_fail("Narrative task returned no narrative") + all_passed = False + + # Verify session update was called + if mock_update_session.called: + log_pass("GameSession update called") + else: + log_fail("GameSession update NOT called") + all_passed = False + + except Exception as e: + log_fail(f"Narrative task processing failed: {e}") + all_passed = False + + return all_passed + + +def verify_job_lifecycle_mocked() -> bool: + """Verify job queueing, status tracking, and result storage (mocked).""" + log_section("4. Job Lifecycle (Mocked)") + + all_passed = True + + # Test with mocked Redis and queue + with patch('app.tasks.ai_tasks.get_queue') as mock_get_queue, \ + patch('app.tasks.ai_tasks._store_job_status') as mock_store_status: + + mock_queue = MagicMock() + mock_job = MagicMock() + mock_job.id = "test_job_123" + mock_queue.enqueue.return_value = mock_job + mock_get_queue.return_value = mock_queue + + # Test job enqueueing + try: + result = enqueue_ai_task( + task_type="narrative", + user_id="test_user", + context={"action": "test", "character": {}, "game_state": {}}, + priority="high" + ) + + if result.get("job_id") and result.get("status") == "queued": + log_pass(f"Job enqueued: {result.get('job_id')}") + else: + log_fail(f"Unexpected enqueue result: {result}") + all_passed = False + + # Verify queue was called with correct priority + if mock_queue.enqueue.called: + call_kwargs = mock_queue.enqueue.call_args + if call_kwargs.kwargs.get('at_front') == True: + log_pass("High priority job placed at front of queue") + else: + log_fail("High priority not placed at front") + all_passed = False + + # Verify status was stored + if mock_store_status.called: + log_pass("Job status stored in Redis") + else: + log_fail("Job status NOT stored") + all_passed = False + + except Exception as e: + log_fail(f"Job enqueueing failed: {e}") + all_passed = False + + return all_passed + + +def verify_error_handling() -> bool: + """Verify error handling and validation.""" + log_section("5. Error Handling") + + all_passed = True + + # Test invalid task type + try: + enqueue_ai_task( + task_type="invalid_type", + user_id="test", + context={} + ) + log_fail("Should have raised ValueError for invalid task_type") + all_passed = False + except ValueError as e: + if "Invalid task_type" in str(e): + log_pass("Invalid task_type raises ValueError") + else: + log_fail(f"Unexpected error: {e}") + all_passed = False + + # Test invalid priority + try: + enqueue_ai_task( + task_type="narrative", + user_id="test", + context={}, + priority="super_urgent" + ) + log_fail("Should have raised ValueError for invalid priority") + all_passed = False + except ValueError as e: + if "Invalid priority" in str(e): + log_pass("Invalid priority raises ValueError") + else: + log_fail(f"Unexpected error: {e}") + all_passed = False + + # Test missing context fields + with patch('app.tasks.ai_tasks._get_user_tier') as mock_tier, \ + patch('app.tasks.ai_tasks._update_job_status'): + mock_tier.return_value = UserTier.FREE + + try: + process_ai_task( + task_type="narrative", + user_id="test", + context={"action": "test"}, # Missing character and game_state + job_id="test_job" + ) + log_fail("Should have raised error for missing context fields") + all_passed = False + except ValueError as e: + if "Missing required context field" in str(e): + log_pass("Missing context fields raises ValueError") + else: + log_fail(f"Unexpected error: {e}") + all_passed = False + + return all_passed + + +def verify_real_ai_generation(tier: str = "free") -> bool: + """Test with real AI calls (requires REPLICATE_API_TOKEN).""" + log_section(f"6. Real AI Generation ({tier.upper()} tier)") + + # Check for API token + if not os.environ.get("REPLICATE_API_TOKEN"): + log_info("REPLICATE_API_TOKEN not set - skipping real AI test") + return True + + tier_map = { + "free": UserTier.FREE, + "basic": UserTier.BASIC, + "premium": UserTier.PREMIUM, + "elite": UserTier.ELITE + } + + user_tier = tier_map.get(tier.lower(), UserTier.FREE) + + generator = NarrativeGenerator() + + try: + log_info("Calling Replicate API...") + response = generator.generate_story_response( + character=SAMPLE_CHARACTER, + action="I look around the tavern and ask the barkeep about any interesting rumors", + game_state=SAMPLE_GAME_STATE, + user_tier=user_tier, + conversation_history=SAMPLE_CONVERSATION_HISTORY + ) + + log_pass(f"AI response generated successfully") + log_info(f" Model: {response.model}") + log_info(f" Tokens: {response.tokens_used}") + log_info(f" Time: {response.generation_time:.2f}s") + log_info(f" Response preview: {response.narrative[:200]}...") + + # Check response quality + if len(response.narrative) > 50: + log_pass("Response has substantial content") + else: + log_fail("Response seems too short") + return False + + if any(word in response.narrative.lower() for word in ["tavern", "barkeep", "rumor", "hear"]): + log_pass("Response is contextually relevant") + else: + log_info("Response may not be fully contextual (check manually)") + + return True + + except Exception as e: + log_fail(f"Real AI generation failed: {e}") + return False + + +def verify_integration(tier: str = "free") -> bool: + """Full integration test with Redis, RQ, and real job processing.""" + log_section("7. Full Integration Test") + + # Check Redis connection + try: + redis = RedisService() + redis.set("integration_test", "ok", ttl=60) + if redis.get("integration_test") == "ok": + log_pass("Redis connection working") + else: + log_fail("Redis read/write failed") + return False + except Exception as e: + log_fail(f"Redis connection failed: {e}") + log_info("Make sure Redis is running: docker-compose up -d redis") + return False + + # Check if we have Replicate token + has_api_token = bool(os.environ.get("REPLICATE_API_TOKEN")) + if not has_api_token: + log_info("REPLICATE_API_TOKEN not set - will test with mocked AI") + + tier_map = { + "free": UserTier.FREE, + "basic": UserTier.BASIC, + "premium": UserTier.PREMIUM, + "elite": UserTier.ELITE + } + user_tier = tier_map.get(tier.lower(), UserTier.FREE) + + # Create context for test + context = { + "action": "I search the tavern for any suspicious characters", + "character": SAMPLE_CHARACTER, + "game_state": SAMPLE_GAME_STATE, + "conversation_history": SAMPLE_CONVERSATION_HISTORY + } + + if has_api_token: + # Real integration test - queue job and let worker process it + log_info("To run full integration, start a worker in another terminal:") + log_info(" cd api && source venv/bin/activate") + log_info(" rq worker ai_tasks --url redis://localhost:6379") + + try: + result = enqueue_ai_task( + task_type="narrative", + user_id="integration_test_user", + context=context, + priority="high" + ) + + job_id = result.get("job_id") + log_pass(f"Job enqueued: {job_id}") + + # Poll for completion + log_info("Waiting for worker to process job...") + max_wait = 60 # seconds + waited = 0 + + while waited < max_wait: + status = get_job_status(job_id) + current_status = status.get("status", "unknown") + + if current_status == "completed": + log_pass(f"Job completed after {waited}s") + + # Get result + job_result = get_job_result(job_id) + if job_result: + log_pass("Job result retrieved from Redis") + log_info(f" Tokens: {job_result.get('tokens_used')}") + log_info(f" Model: {job_result.get('model')}") + else: + log_fail("Could not retrieve job result") + return False + + return True + + elif current_status == "failed": + log_fail(f"Job failed: {status.get('error')}") + return False + + time.sleep(2) + waited += 2 + + log_fail(f"Job did not complete within {max_wait}s") + log_info("Make sure RQ worker is running") + return False + + except Exception as e: + log_fail(f"Integration test failed: {e}") + return False + else: + # Mocked integration test - process directly + log_info("Running mocked integration (no worker needed)") + + with patch('app.tasks.ai_tasks.NarrativeGenerator') as MockGenerator, \ + patch('app.tasks.ai_tasks._get_user_tier') as mock_get_tier, \ + patch('app.tasks.ai_tasks._update_game_session') as mock_update: + + mock_get_tier.return_value = user_tier + + mock_gen = MagicMock() + mock_gen.generate_story_response.return_value = NarrativeResponse( + narrative="The tavern is filled with a motley crew of adventurers...", + tokens_used=180, + model="meta/meta-llama-3-8b-instruct", + context_type="story_progression", + generation_time=1.8 + ) + MockGenerator.return_value = mock_gen + + job_id = f"integration_test_{uuid4().hex[:8]}" + + try: + result = process_ai_task( + task_type="narrative", + user_id="integration_test_user", + context=context, + job_id=job_id, + session_id="sess_integration_test" + ) + + log_pass("Mocked job processed successfully") + log_info(f" Result: {result.get('narrative', '')[:100]}...") + + return True + + except Exception as e: + log_fail(f"Mocked integration failed: {e}") + return False + + +def main(): + """Run all verification tests.""" + parser = argparse.ArgumentParser(description="Verify end-to-end AI generation flow") + parser.add_argument("--real", action="store_true", help="Run with real AI API calls") + parser.add_argument("--tier", type=str, default="free", + choices=["free", "basic", "premium", "elite"], + help="User tier to test") + parser.add_argument("--integration", action="store_true", + help="Run full integration test with Redis/RQ") + args = parser.parse_args() + + print(f"\n{Colors.BOLD}Task 7.12: End-to-End AI Generation Verification{Colors.END}") + print(f"Started at: {datetime.now(timezone.utc).isoformat()}\n") + + results = [] + + # Core tests (always run) + results.append(("Model Selector Routing", verify_model_selector_routing())) + results.append(("Narrative Generator (Mocked)", verify_narrative_generator_mocked())) + results.append(("AI Task Processing (Mocked)", verify_ai_task_processing_mocked())) + results.append(("Job Lifecycle (Mocked)", verify_job_lifecycle_mocked())) + results.append(("Error Handling", verify_error_handling())) + + # Optional tests + if args.real: + results.append(("Real AI Generation", verify_real_ai_generation(args.tier))) + + if args.integration: + results.append(("Full Integration", verify_integration(args.tier))) + + # Summary + log_section("VERIFICATION SUMMARY") + + passed = sum(1 for _, result in results if result) + total = len(results) + + for name, result in results: + status = f"{Colors.GREEN}PASS{Colors.END}" if result else f"{Colors.RED}FAIL{Colors.END}" + print(f" {name}: {status}") + + print(f"\n{Colors.BOLD}Total: {passed}/{total} tests passed{Colors.END}") + + if passed == total: + print(f"\n{Colors.GREEN}✓ Task 7.12 CHECKPOINT VERIFIED{Colors.END}") + return 0 + else: + print(f"\n{Colors.RED}✗ Some tests failed - review issues above{Colors.END}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/scripts/verify_session_persistence.py b/api/scripts/verify_session_persistence.py new file mode 100755 index 0000000..82f5526 --- /dev/null +++ b/api/scripts/verify_session_persistence.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +""" +Verification script for Task 8.24: Session Persistence Checkpoint. + +Tests the SessionService against real Appwrite database to verify: +1. Solo session creation and storage +2. Session retrieval and ownership validation +3. Conversation history persistence +4. Game state tracking (location, quests, events) +5. Session lifecycle (create, update, end) + +Usage: + python scripts/verify_session_persistence.py + +Requirements: + - Appwrite configured in .env + - game_sessions collection created in Appwrite + - At least one character created for testing +""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +from app.services.session_service import ( + SessionService, + SessionNotFound, + SessionLimitExceeded, + SessionValidationError, + get_session_service, +) +from app.services.character_service import get_character_service, CharacterNotFound +from app.models.enums import SessionStatus, SessionType, LocationType +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +def print_header(text: str): + """Print a section header.""" + print(f"\n{'='*60}") + print(f" {text}") + print(f"{'='*60}\n") + + +def print_result(test_name: str, passed: bool, details: str = ""): + """Print test result.""" + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status}: {test_name}") + if details: + print(f" {details}") + + +def verify_session_creation(service: SessionService, user_id: str, character_id: str) -> str: + """ + Test 1: Create a solo session and verify it's stored correctly. + + Returns session_id if successful. + """ + print_header("Test 1: Solo Session Creation") + + try: + # Create session + session = service.create_solo_session( + user_id=user_id, + character_id=character_id, + starting_location="Test Town", + starting_location_type=LocationType.TOWN + ) + + # Verify fields + checks = [ + (session.session_type == SessionType.SOLO, "session_type is SOLO"), + (session.solo_character_id == character_id, "solo_character_id matches"), + (session.user_id == user_id, "user_id matches"), + (session.turn_number == 0, "turn_number is 0"), + (session.status == SessionStatus.ACTIVE, "status is ACTIVE"), + (session.game_state.current_location == "Test Town", "current_location set"), + (session.game_state.location_type == LocationType.TOWN, "location_type set"), + ("Test Town" in session.game_state.discovered_locations, "location in discovered"), + ] + + all_passed = True + for passed, desc in checks: + print_result(desc, passed) + if not passed: + all_passed = False + + if all_passed: + print(f"\n Session ID: {session.session_id}") + return session.session_id + else: + return None + + except Exception as e: + print_result("Session creation", False, str(e)) + return None + + +def verify_session_retrieval(service: SessionService, session_id: str, user_id: str) -> bool: + """ + Test 2: Load session from database and verify data integrity. + """ + print_header("Test 2: Session Retrieval") + + try: + # Load session + session = service.get_session(session_id, user_id) + + checks = [ + (session.session_id == session_id, "session_id matches"), + (session.user_id == user_id, "user_id matches"), + (session.session_type == SessionType.SOLO, "session_type preserved"), + (session.status == SessionStatus.ACTIVE, "status preserved"), + ] + + all_passed = True + for passed, desc in checks: + print_result(desc, passed) + if not passed: + all_passed = False + + # Test ownership validation + try: + service.get_session(session_id, "wrong_user_id") + print_result("Ownership validation", False, "Should have raised SessionNotFound") + all_passed = False + except SessionNotFound: + print_result("Ownership validation (wrong user rejected)", True) + + return all_passed + + except Exception as e: + print_result("Session retrieval", False, str(e)) + return False + + +def verify_conversation_history(service: SessionService, session_id: str) -> bool: + """ + Test 3: Add conversation entries and verify persistence. + """ + print_header("Test 3: Conversation History") + + try: + # Add first entry + service.add_conversation_entry( + session_id=session_id, + character_id="char_test", + character_name="Test Hero", + action="I explore the town", + dm_response="You find a bustling marketplace..." + ) + + # Add second entry with quest + service.add_conversation_entry( + session_id=session_id, + character_id="char_test", + character_name="Test Hero", + action="Talk to the merchant", + dm_response="The merchant offers you a quest...", + quest_offered={"quest_id": "test_quest", "name": "Test Quest"} + ) + + # Retrieve and verify + session = service.get_session(session_id) + + checks = [ + (session.turn_number == 2, f"turn_number is 2 (got {session.turn_number})"), + (len(session.conversation_history) == 2, f"2 entries in history (got {len(session.conversation_history)})"), + (session.conversation_history[0].action == "I explore the town", "first action preserved"), + (session.conversation_history[1].quest_offered is not None, "quest_offered preserved"), + (session.conversation_history[0].timestamp != "", "timestamp auto-generated"), + ] + + all_passed = True + for passed, desc in checks: + print_result(desc, passed) + if not passed: + all_passed = False + + # Test get_recent_history + recent = service.get_recent_history(session_id, num_turns=1) + check = len(recent) == 1 and recent[0].turn == 2 + print_result("get_recent_history returns last entry", check) + if not check: + all_passed = False + + return all_passed + + except Exception as e: + print_result("Conversation history", False, str(e)) + return False + + +def verify_game_state_tracking(service: SessionService, session_id: str) -> bool: + """ + Test 4: Test location, quest, and event tracking. + """ + print_header("Test 4: Game State Tracking") + + try: + all_passed = True + + # Update location + service.update_location( + session_id=session_id, + new_location="Dark Forest", + location_type=LocationType.WILDERNESS + ) + + session = service.get_session(session_id) + check = session.game_state.current_location == "Dark Forest" + print_result("Location updated", check, f"Got: {session.game_state.current_location}") + if not check: + all_passed = False + + check = session.game_state.location_type == LocationType.WILDERNESS + print_result("Location type updated", check) + if not check: + all_passed = False + + check = "Dark Forest" in session.game_state.discovered_locations + print_result("New location added to discovered", check) + if not check: + all_passed = False + + # Add quest + service.add_active_quest(session_id, "quest_1") + session = service.get_session(session_id) + check = "quest_1" in session.game_state.active_quests + print_result("Quest added to active_quests", check) + if not check: + all_passed = False + + # Add second quest + service.add_active_quest(session_id, "quest_2") + session = service.get_session(session_id) + check = len(session.game_state.active_quests) == 2 + print_result("Second quest added", check) + if not check: + all_passed = False + + # Try to add third quest (should fail) + try: + service.add_active_quest(session_id, "quest_3") + print_result("Max quest limit enforced", False, "Should have raised error") + all_passed = False + except SessionValidationError: + print_result("Max quest limit enforced (2/2)", True) + + # Remove quest + service.remove_active_quest(session_id, "quest_1") + session = service.get_session(session_id) + check = "quest_1" not in session.game_state.active_quests + print_result("Quest removed", check) + if not check: + all_passed = False + + # Add world event + service.add_world_event(session_id, {"type": "storm", "description": "A storm approaches"}) + session = service.get_session(session_id) + check = len(session.game_state.world_events) == 1 + print_result("World event added", check) + if not check: + all_passed = False + + check = "timestamp" in session.game_state.world_events[0] + print_result("Event timestamp auto-added", check) + if not check: + all_passed = False + + return all_passed + + except Exception as e: + print_result("Game state tracking", False, str(e)) + return False + + +def verify_session_lifecycle(service: SessionService, session_id: str, user_id: str) -> bool: + """ + Test 5: Test session ending and status changes. + """ + print_header("Test 5: Session Lifecycle") + + try: + all_passed = True + + # End session + session = service.end_session(session_id, user_id) + check = session.status == SessionStatus.COMPLETED + print_result("Session status set to COMPLETED", check, f"Got: {session.status}") + if not check: + all_passed = False + + # Verify persisted + session = service.get_session(session_id) + check = session.status == SessionStatus.COMPLETED + print_result("Completed status persisted", check) + if not check: + all_passed = False + + # Verify it's not counted as active + count = service.count_user_sessions(user_id, active_only=True) + # Note: This might include other sessions, so just check it works + print_result(f"count_user_sessions works (active: {count})", True) + + return all_passed + + except Exception as e: + print_result("Session lifecycle", False, str(e)) + return False + + +def verify_error_handling(service: SessionService) -> bool: + """ + Test 6: Test error handling for invalid operations. + """ + print_header("Test 6: Error Handling") + + all_passed = True + + # Invalid session ID + try: + service.get_session("invalid_session_id_12345") + print_result("Invalid session ID raises error", False) + all_passed = False + except SessionNotFound: + print_result("Invalid session ID raises SessionNotFound", True) + except Exception as e: + print_result("Invalid session ID raises error", False, str(e)) + all_passed = False + + return all_passed + + +def main(): + """Run all verification tests.""" + print("\n" + "="*60) + print(" Task 8.24: Session Persistence Verification") + print("="*60) + + # Check environment + if not os.getenv('APPWRITE_ENDPOINT'): + print("\n❌ ERROR: Appwrite not configured. Set APPWRITE_* in .env") + return False + + # Get test user and character + print("\nSetup: Finding test character...") + + char_service = get_character_service() + + # Try to find an existing character for testing + # You may need to adjust this based on your test data + test_user_id = os.getenv('TEST_USER_ID', '') + test_character_id = os.getenv('TEST_CHARACTER_ID', '') + + if not test_user_id or not test_character_id: + print("\n⚠️ No TEST_USER_ID or TEST_CHARACTER_ID in .env") + print(" Will attempt to use mock IDs for basic testing") + print(" For full integration test, set these environment variables") + + # Use mock approach - only tests that don't need real DB + print("\n" + "="*60) + print(" Running Unit Test Verification Only") + print("="*60) + + # Run the pytest tests instead + import subprocess + result = subprocess.run( + ["python", "-m", "pytest", "tests/test_session_service.py", "-v", "--tb=short"], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + return result.returncode == 0 + + # Initialize service + service = get_session_service() + + # Run tests + results = [] + + # Test 1: Create session + session_id = verify_session_creation(service, test_user_id, test_character_id) + results.append(("Session Creation", session_id is not None)) + + if session_id: + # Test 2: Retrieve session + results.append(("Session Retrieval", verify_session_retrieval(service, session_id, test_user_id))) + + # Test 3: Conversation history + results.append(("Conversation History", verify_conversation_history(service, session_id))) + + # Test 4: Game state tracking + results.append(("Game State Tracking", verify_game_state_tracking(service, session_id))) + + # Test 5: Session lifecycle + results.append(("Session Lifecycle", verify_session_lifecycle(service, session_id, test_user_id))) + + # Test 6: Error handling + results.append(("Error Handling", verify_error_handling(service))) + + # Summary + print_header("Verification Summary") + + passed = sum(1 for _, p in results if p) + total = len(results) + + for name, result in results: + status = "✅" if result else "❌" + print(f" {status} {name}") + + print(f"\n Total: {passed}/{total} tests passed") + + if passed == total: + print("\n✅ All session persistence tests PASSED!") + return True + else: + print("\n❌ Some tests FAILED. Check output above for details.") + return False + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..d136005 --- /dev/null +++ b/api/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Unit tests for Code of Conquest. +""" diff --git a/api/tests/test_action_prompt.py b/api/tests/test_action_prompt.py new file mode 100644 index 0000000..9b1bb94 --- /dev/null +++ b/api/tests/test_action_prompt.py @@ -0,0 +1,311 @@ +""" +Tests for ActionPrompt model + +Tests the action prompt availability logic, tier filtering, +location filtering, and serialization. +""" + +import pytest + +from app.models.action_prompt import ( + ActionPrompt, + ActionCategory, + LocationType, +) +from app.ai.model_selector import UserTier + + +class TestActionPrompt: + """Tests for ActionPrompt dataclass.""" + + @pytest.fixture + def free_action(self): + """Create a free tier action available in towns.""" + return ActionPrompt( + prompt_id="ask_locals", + category=ActionCategory.ASK_QUESTION, + display_text="Ask locals for information", + description="Talk to NPCs to learn about quests and rumors", + tier_required=UserTier.FREE, + context_filter=[LocationType.TOWN, LocationType.TAVERN], + dm_prompt_template="The player asks locals about {{ topic }}.", + ) + + @pytest.fixture + def premium_action(self): + """Create a premium tier action available anywhere.""" + return ActionPrompt( + prompt_id="investigate", + category=ActionCategory.GATHER_INFO, + display_text="Investigate suspicious activity", + description="Look for clues and hidden details", + tier_required=UserTier.PREMIUM, + context_filter=[LocationType.ANY], + dm_prompt_template="The player investigates the area.", + icon="magnifying_glass", + ) + + @pytest.fixture + def elite_action(self): + """Create an elite tier action for libraries.""" + return ActionPrompt( + prompt_id="consult_texts", + category=ActionCategory.SPECIAL, + display_text="Consult ancient texts", + description="Study rare manuscripts for hidden knowledge", + tier_required=UserTier.ELITE, + context_filter=[LocationType.LIBRARY, LocationType.TOWN], + dm_prompt_template="The player studies ancient texts.", + cooldown_turns=3, + ) + + # Availability tests + + def test_free_action_available_to_free_user(self, free_action): + """Free action should be available to free tier users.""" + assert free_action.is_available(UserTier.FREE, LocationType.TOWN) is True + + def test_free_action_available_to_premium_user(self, free_action): + """Free action should be available to higher tier users.""" + assert free_action.is_available(UserTier.PREMIUM, LocationType.TOWN) is True + assert free_action.is_available(UserTier.ELITE, LocationType.TOWN) is True + + def test_premium_action_not_available_to_free_user(self, premium_action): + """Premium action should not be available to free tier users.""" + assert premium_action.is_available(UserTier.FREE, LocationType.TOWN) is False + + def test_premium_action_available_to_premium_user(self, premium_action): + """Premium action should be available to premium tier users.""" + assert premium_action.is_available(UserTier.PREMIUM, LocationType.TOWN) is True + + def test_elite_action_not_available_to_premium_user(self, elite_action): + """Elite action should not be available to premium tier users.""" + assert elite_action.is_available(UserTier.PREMIUM, LocationType.LIBRARY) is False + + def test_elite_action_available_to_elite_user(self, elite_action): + """Elite action should be available to elite tier users.""" + assert elite_action.is_available(UserTier.ELITE, LocationType.LIBRARY) is True + + # Location filtering tests + + def test_action_available_in_matching_location(self, free_action): + """Action should be available in matching locations.""" + assert free_action.is_available(UserTier.FREE, LocationType.TOWN) is True + assert free_action.is_available(UserTier.FREE, LocationType.TAVERN) is True + + def test_action_not_available_in_non_matching_location(self, free_action): + """Action should not be available in non-matching locations.""" + assert free_action.is_available(UserTier.FREE, LocationType.WILDERNESS) is False + assert free_action.is_available(UserTier.FREE, LocationType.DUNGEON) is False + + def test_any_location_matches_all(self, premium_action): + """Action with ANY location should be available everywhere.""" + assert premium_action.is_available(UserTier.PREMIUM, LocationType.TOWN) is True + assert premium_action.is_available(UserTier.PREMIUM, LocationType.WILDERNESS) is True + assert premium_action.is_available(UserTier.PREMIUM, LocationType.DUNGEON) is True + assert premium_action.is_available(UserTier.PREMIUM, LocationType.LIBRARY) is True + + def test_both_tier_and_location_must_match(self, free_action): + """Both tier and location requirements must be met.""" + # Wrong location, right tier + assert free_action.is_available(UserTier.ELITE, LocationType.DUNGEON) is False + + # Lock status tests + + def test_is_locked_for_lower_tier(self, premium_action): + """Action should be locked for lower tier users.""" + assert premium_action.is_locked(UserTier.FREE) is True + assert premium_action.is_locked(UserTier.BASIC) is True + + def test_is_not_locked_for_sufficient_tier(self, premium_action): + """Action should not be locked for sufficient tier users.""" + assert premium_action.is_locked(UserTier.PREMIUM) is False + assert premium_action.is_locked(UserTier.ELITE) is False + + def test_get_lock_reason_returns_message(self, premium_action): + """Lock reason should explain tier requirement.""" + reason = premium_action.get_lock_reason(UserTier.FREE) + assert reason is not None + assert "Premium" in reason + + def test_get_lock_reason_returns_none_when_unlocked(self, premium_action): + """Lock reason should be None when action is unlocked.""" + reason = premium_action.get_lock_reason(UserTier.PREMIUM) + assert reason is None + + # Tier hierarchy tests + + def test_tier_hierarchy_free_to_elite(self): + """Test full tier hierarchy from FREE to ELITE.""" + action = ActionPrompt( + prompt_id="test", + category=ActionCategory.EXPLORE, + display_text="Test", + description="Test action", + tier_required=UserTier.BASIC, + context_filter=[LocationType.ANY], + dm_prompt_template="Test", + ) + + # FREE < BASIC (should fail) + assert action.is_available(UserTier.FREE, LocationType.TOWN) is False + + # BASIC >= BASIC (should pass) + assert action.is_available(UserTier.BASIC, LocationType.TOWN) is True + + # PREMIUM > BASIC (should pass) + assert action.is_available(UserTier.PREMIUM, LocationType.TOWN) is True + + # ELITE > BASIC (should pass) + assert action.is_available(UserTier.ELITE, LocationType.TOWN) is True + + # Serialization tests + + def test_to_dict(self, free_action): + """Test serialization to dictionary.""" + data = free_action.to_dict() + + assert data["prompt_id"] == "ask_locals" + assert data["category"] == "ask_question" + assert data["display_text"] == "Ask locals for information" + assert data["tier_required"] == "free" + assert data["context_filter"] == ["town", "tavern"] + assert "dm_prompt_template" in data + + def test_from_dict(self): + """Test deserialization from dictionary.""" + data = { + "prompt_id": "explore_area", + "category": "explore", + "display_text": "Explore the area", + "description": "Look around for points of interest", + "tier_required": "free", + "context_filter": ["wilderness", "dungeon"], + "dm_prompt_template": "The player explores {{ location }}.", + "icon": "compass", + "cooldown_turns": 2, + } + + action = ActionPrompt.from_dict(data) + + assert action.prompt_id == "explore_area" + assert action.category == ActionCategory.EXPLORE + assert action.tier_required == UserTier.FREE + assert LocationType.WILDERNESS in action.context_filter + assert action.icon == "compass" + assert action.cooldown_turns == 2 + + def test_round_trip_serialization(self, free_action): + """Test that to_dict and from_dict are inverse operations.""" + data = free_action.to_dict() + restored = ActionPrompt.from_dict(data) + + assert restored.prompt_id == free_action.prompt_id + assert restored.category == free_action.category + assert restored.display_text == free_action.display_text + assert restored.tier_required == free_action.tier_required + assert restored.context_filter == free_action.context_filter + + def test_from_dict_invalid_category(self): + """Test error handling for invalid category.""" + data = { + "prompt_id": "test", + "category": "invalid_category", + "display_text": "Test", + "description": "Test", + "tier_required": "free", + "context_filter": ["any"], + "dm_prompt_template": "Test", + } + + with pytest.raises(ValueError) as exc_info: + ActionPrompt.from_dict(data) + + assert "Invalid action category" in str(exc_info.value) + + def test_from_dict_invalid_tier(self): + """Test error handling for invalid tier.""" + data = { + "prompt_id": "test", + "category": "explore", + "display_text": "Test", + "description": "Test", + "tier_required": "super_premium", + "context_filter": ["any"], + "dm_prompt_template": "Test", + } + + with pytest.raises(ValueError) as exc_info: + ActionPrompt.from_dict(data) + + assert "Invalid user tier" in str(exc_info.value) + + def test_from_dict_invalid_location(self): + """Test error handling for invalid location type.""" + data = { + "prompt_id": "test", + "category": "explore", + "display_text": "Test", + "description": "Test", + "tier_required": "free", + "context_filter": ["invalid_location"], + "dm_prompt_template": "Test", + } + + with pytest.raises(ValueError) as exc_info: + ActionPrompt.from_dict(data) + + assert "Invalid location type" in str(exc_info.value) + + # Optional fields tests + + def test_optional_icon(self, free_action, premium_action): + """Test that icon is optional.""" + assert free_action.icon is None + assert premium_action.icon == "magnifying_glass" + + def test_default_cooldown(self, free_action, elite_action): + """Test default and custom cooldown values.""" + assert free_action.cooldown_turns == 0 + assert elite_action.cooldown_turns == 3 + + # Repr test + + def test_repr(self, free_action): + """Test string representation.""" + repr_str = repr(free_action) + assert "ask_locals" in repr_str + assert "ask_question" in repr_str + assert "free" in repr_str + + +class TestActionCategory: + """Tests for ActionCategory enum.""" + + def test_all_categories_defined(self): + """Verify all expected categories exist.""" + categories = [cat.value for cat in ActionCategory] + + assert "ask_question" in categories + assert "travel" in categories + assert "gather_info" in categories + assert "rest" in categories + assert "interact" in categories + assert "explore" in categories + assert "special" in categories + + +class TestLocationType: + """Tests for LocationType enum.""" + + def test_all_location_types_defined(self): + """Verify all expected location types exist.""" + locations = [loc.value for loc in LocationType] + + assert "town" in locations + assert "tavern" in locations + assert "wilderness" in locations + assert "dungeon" in locations + assert "safe_area" in locations + assert "library" in locations + assert "any" in locations diff --git a/api/tests/test_action_prompt_loader.py b/api/tests/test_action_prompt_loader.py new file mode 100644 index 0000000..1c269c5 --- /dev/null +++ b/api/tests/test_action_prompt_loader.py @@ -0,0 +1,314 @@ +""" +Tests for ActionPromptLoader service + +Tests loading from YAML, filtering by tier and location, +and error handling. +""" + +import pytest +import tempfile +import os + +from app.services.action_prompt_loader import ( + ActionPromptLoader, + ActionPromptLoaderError, + ActionPromptNotFoundError, +) +from app.models.action_prompt import LocationType +from app.ai.model_selector import UserTier + + +class TestActionPromptLoader: + """Tests for ActionPromptLoader service.""" + + @pytest.fixture(autouse=True) + def reset_singleton(self): + """Reset singleton before each test.""" + ActionPromptLoader.reset_instance() + yield + ActionPromptLoader.reset_instance() + + @pytest.fixture + def sample_yaml(self): + """Create a sample YAML file for testing.""" + content = """ +action_prompts: + - prompt_id: test_free + category: explore + display_text: Free Action + description: Available to all + tier_required: free + context_filter: [town, tavern] + dm_prompt_template: Test template + + - prompt_id: test_premium + category: gather_info + display_text: Premium Action + description: Premium only + tier_required: premium + context_filter: [any] + dm_prompt_template: Premium template + + - prompt_id: test_elite + category: special + display_text: Elite Action + description: Elite only + tier_required: elite + context_filter: [library] + dm_prompt_template: Elite template +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(content) + filepath = f.name + + yield filepath + os.unlink(filepath) + + @pytest.fixture + def loader(self, sample_yaml): + """Create a loader with sample data.""" + loader = ActionPromptLoader() + loader.load_from_yaml(sample_yaml) + return loader + + # Loading tests + + def test_load_from_yaml(self, sample_yaml): + """Test loading prompts from YAML file.""" + loader = ActionPromptLoader() + count = loader.load_from_yaml(sample_yaml) + + assert count == 3 + assert loader.is_loaded() + + def test_load_file_not_found(self): + """Test error when file doesn't exist.""" + loader = ActionPromptLoader() + + with pytest.raises(ActionPromptLoaderError) as exc_info: + loader.load_from_yaml("/nonexistent/path.yaml") + + assert "not found" in str(exc_info.value) + + def test_load_invalid_yaml(self): + """Test error when YAML is malformed.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("invalid: yaml: content: [") + filepath = f.name + + try: + loader = ActionPromptLoader() + with pytest.raises(ActionPromptLoaderError) as exc_info: + loader.load_from_yaml(filepath) + + assert "Invalid YAML" in str(exc_info.value) + finally: + os.unlink(filepath) + + def test_load_missing_key(self): + """Test error when action_prompts key is missing.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("other_key: value") + filepath = f.name + + try: + loader = ActionPromptLoader() + with pytest.raises(ActionPromptLoaderError) as exc_info: + loader.load_from_yaml(filepath) + + assert "Missing 'action_prompts'" in str(exc_info.value) + finally: + os.unlink(filepath) + + # Get methods tests + + def test_get_all_actions(self, loader): + """Test getting all loaded actions.""" + actions = loader.get_all_actions() + + assert len(actions) == 3 + prompt_ids = [a.prompt_id for a in actions] + assert "test_free" in prompt_ids + assert "test_premium" in prompt_ids + assert "test_elite" in prompt_ids + + def test_get_action_by_id(self, loader): + """Test getting action by ID.""" + action = loader.get_action_by_id("test_free") + + assert action.prompt_id == "test_free" + assert action.display_text == "Free Action" + + def test_get_action_by_id_not_found(self, loader): + """Test error when action ID not found.""" + with pytest.raises(ActionPromptNotFoundError): + loader.get_action_by_id("nonexistent") + + # Filtering tests + + def test_get_available_actions_free_tier(self, loader): + """Test filtering for free tier user.""" + actions = loader.get_available_actions(UserTier.FREE, LocationType.TOWN) + + assert len(actions) == 1 + assert actions[0].prompt_id == "test_free" + + def test_get_available_actions_premium_tier(self, loader): + """Test filtering for premium tier user.""" + actions = loader.get_available_actions(UserTier.PREMIUM, LocationType.TOWN) + + # Premium gets: test_free (town) + test_premium (any) + assert len(actions) == 2 + prompt_ids = [a.prompt_id for a in actions] + assert "test_free" in prompt_ids + assert "test_premium" in prompt_ids + + def test_get_available_actions_elite_tier(self, loader): + """Test filtering for elite tier user.""" + actions = loader.get_available_actions(UserTier.ELITE, LocationType.LIBRARY) + + # Elite in library gets: test_premium (any) + test_elite (library) + assert len(actions) == 2 + prompt_ids = [a.prompt_id for a in actions] + assert "test_premium" in prompt_ids + assert "test_elite" in prompt_ids + + def test_get_available_actions_location_filter(self, loader): + """Test that location filtering works correctly.""" + # In town, no elite actions available + actions = loader.get_available_actions(UserTier.ELITE, LocationType.TOWN) + prompt_ids = [a.prompt_id for a in actions] + + assert "test_elite" not in prompt_ids # Only in library + + def test_get_actions_by_tier(self, loader): + """Test getting actions by tier without location filter.""" + free_actions = loader.get_actions_by_tier(UserTier.FREE) + premium_actions = loader.get_actions_by_tier(UserTier.PREMIUM) + elite_actions = loader.get_actions_by_tier(UserTier.ELITE) + + assert len(free_actions) == 1 + assert len(premium_actions) == 2 + assert len(elite_actions) == 3 + + def test_get_actions_by_category(self, loader): + """Test getting actions by category.""" + explore_actions = loader.get_actions_by_category("explore") + special_actions = loader.get_actions_by_category("special") + + assert len(explore_actions) == 1 + assert explore_actions[0].prompt_id == "test_free" + + assert len(special_actions) == 1 + assert special_actions[0].prompt_id == "test_elite" + + def test_get_locked_actions(self, loader): + """Test getting locked actions for upgrade prompts.""" + # Free user in library sees elite action as locked + locked = loader.get_locked_actions(UserTier.FREE, LocationType.LIBRARY) + + # test_premium (any) and test_elite (library) are locked for free + assert len(locked) == 2 + prompt_ids = [a.prompt_id for a in locked] + assert "test_premium" in prompt_ids + assert "test_elite" in prompt_ids + + # Singleton and reload tests + + def test_singleton_pattern(self, sample_yaml): + """Test that loader is singleton.""" + loader1 = ActionPromptLoader() + loader1.load_from_yaml(sample_yaml) + + loader2 = ActionPromptLoader() + + assert loader1 is loader2 + assert loader2.is_loaded() + + def test_reload(self, sample_yaml): + """Test reloading prompts.""" + loader = ActionPromptLoader() + loader.load_from_yaml(sample_yaml) + + # Modify and reload + count = loader.reload(sample_yaml) + assert count == 3 + + def test_get_prompt_count(self, loader): + """Test getting prompt count.""" + assert loader.get_prompt_count() == 3 + + +class TestActionPromptLoaderIntegration: + """Integration tests with actual YAML file.""" + + @pytest.fixture(autouse=True) + def reset_singleton(self): + """Reset singleton before each test.""" + ActionPromptLoader.reset_instance() + yield + ActionPromptLoader.reset_instance() + + def test_load_actual_yaml(self): + """Test loading the actual action_prompts.yaml file.""" + loader = ActionPromptLoader() + filepath = os.path.join( + os.path.dirname(__file__), + '..', 'app', 'data', 'action_prompts.yaml' + ) + + if os.path.exists(filepath): + count = loader.load_from_yaml(filepath) + + # Should have 10 actions + assert count == 10 + + # Verify tier distribution + free_actions = loader.get_actions_by_tier(UserTier.FREE) + premium_actions = loader.get_actions_by_tier(UserTier.PREMIUM) + elite_actions = loader.get_actions_by_tier(UserTier.ELITE) + + assert len(free_actions) == 4 # Only free tier + assert len(premium_actions) == 7 # Free + premium + assert len(elite_actions) == 10 # All + + def test_free_tier_town_actions(self): + """Test free tier actions in town location.""" + loader = ActionPromptLoader() + filepath = os.path.join( + os.path.dirname(__file__), + '..', 'app', 'data', 'action_prompts.yaml' + ) + + if os.path.exists(filepath): + loader.load_from_yaml(filepath) + + actions = loader.get_available_actions(UserTier.FREE, LocationType.TOWN) + + # Free user in town should have: + # - ask_locals (town/tavern) + # - search_supplies (any) + # - rest_recover (town/tavern/safe_area) + prompt_ids = [a.prompt_id for a in actions] + assert "ask_locals" in prompt_ids + assert "search_supplies" in prompt_ids + assert "rest_recover" in prompt_ids + + def test_premium_tier_wilderness_actions(self): + """Test premium tier actions in wilderness location.""" + loader = ActionPromptLoader() + filepath = os.path.join( + os.path.dirname(__file__), + '..', 'app', 'data', 'action_prompts.yaml' + ) + + if os.path.exists(filepath): + loader.load_from_yaml(filepath) + + actions = loader.get_available_actions(UserTier.PREMIUM, LocationType.WILDERNESS) + + prompt_ids = [a.prompt_id for a in actions] + # Should include wilderness actions + assert "explore_area" in prompt_ids + assert "make_camp" in prompt_ids + assert "search_supplies" in prompt_ids diff --git a/api/tests/test_ai_tasks.py b/api/tests/test_ai_tasks.py new file mode 100644 index 0000000..7b91c19 --- /dev/null +++ b/api/tests/test_ai_tasks.py @@ -0,0 +1,571 @@ +""" +Unit tests for AI Task Jobs. + +These tests verify the job enqueueing, status tracking, +and result storage functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import json + +from app.tasks.ai_tasks import ( + enqueue_ai_task, + process_ai_task, + get_job_status, + get_job_result, + JobStatus, + TaskType, + TaskPriority, + _store_job_status, + _update_job_status, + _store_job_result, +) + + +class TestEnqueueAITask: + """Test AI task enqueueing.""" + + @patch('app.tasks.ai_tasks.get_queue') + @patch('app.tasks.ai_tasks._store_job_status') + def test_enqueue_narrative_task(self, mock_store_status, mock_get_queue): + """Test enqueueing a narrative task.""" + # Setup mock queue + mock_queue = MagicMock() + mock_job = MagicMock() + mock_job.id = "test_job_123" + mock_queue.enqueue.return_value = mock_job + mock_get_queue.return_value = mock_queue + + # Enqueue task + result = enqueue_ai_task( + task_type="narrative", + user_id="user_123", + context={"action": "explore"}, + priority="normal", + ) + + # Verify + assert "job_id" in result + assert result["status"] == "queued" + mock_queue.enqueue.assert_called_once() + mock_store_status.assert_called_once() + + @patch('app.tasks.ai_tasks.get_queue') + @patch('app.tasks.ai_tasks._store_job_status') + def test_enqueue_high_priority_task(self, mock_store_status, mock_get_queue): + """Test high priority task goes to front of queue.""" + mock_queue = MagicMock() + mock_job = MagicMock() + mock_queue.enqueue.return_value = mock_job + mock_get_queue.return_value = mock_queue + + enqueue_ai_task( + task_type="narrative", + user_id="user_123", + context={}, + priority="high", + ) + + # Verify at_front=True for high priority + call_kwargs = mock_queue.enqueue.call_args[1] + assert call_kwargs["at_front"] is True + + @patch('app.tasks.ai_tasks.get_queue') + @patch('app.tasks.ai_tasks._store_job_status') + def test_enqueue_with_session_and_character(self, mock_store_status, mock_get_queue): + """Test enqueueing with session and character IDs.""" + mock_queue = MagicMock() + mock_job = MagicMock() + mock_queue.enqueue.return_value = mock_job + mock_get_queue.return_value = mock_queue + + result = enqueue_ai_task( + task_type="combat", + user_id="user_123", + context={"enemy": "goblin"}, + session_id="sess_456", + character_id="char_789", + ) + + assert "job_id" in result + + # Verify kwargs passed to enqueue include session and character + call_kwargs = mock_queue.enqueue.call_args[1] + job_kwargs = call_kwargs["kwargs"] + assert job_kwargs["session_id"] == "sess_456" + assert job_kwargs["character_id"] == "char_789" + + def test_enqueue_invalid_task_type(self): + """Test enqueueing with invalid task type raises error.""" + with pytest.raises(ValueError) as exc_info: + enqueue_ai_task( + task_type="invalid_type", + user_id="user_123", + context={}, + ) + + assert "Invalid task_type" in str(exc_info.value) + + def test_enqueue_invalid_priority(self): + """Test enqueueing with invalid priority raises error.""" + with pytest.raises(ValueError) as exc_info: + enqueue_ai_task( + task_type="narrative", + user_id="user_123", + context={}, + priority="invalid_priority", + ) + + assert "Invalid priority" in str(exc_info.value) + + +class TestProcessAITask: + """Test AI task processing with mocked NarrativeGenerator.""" + + @pytest.fixture + def sample_character(self): + """Sample character data for tests.""" + return { + "name": "Aldric", + "level": 3, + "player_class": "Fighter", + "current_hp": 25, + "max_hp": 30, + "stats": {"strength": 16, "dexterity": 12}, + "skills": [], + "effects": [] + } + + @pytest.fixture + def sample_game_state(self): + """Sample game state for tests.""" + return { + "current_location": "Tavern", + "location_type": "TAVERN", + "discovered_locations": [], + "active_quests": [] + } + + @pytest.fixture + def mock_narrative_response(self): + """Mock NarrativeResponse for tests.""" + from app.ai.narrative_generator import NarrativeResponse + return NarrativeResponse( + narrative="You enter the tavern...", + tokens_used=150, + model="meta/meta-llama-3-8b-instruct", + context_type="story_progression", + generation_time=2.5 + ) + + @patch('app.tasks.ai_tasks._get_user_tier') + @patch('app.tasks.ai_tasks._update_game_session') + @patch('app.tasks.ai_tasks.NarrativeGenerator') + @patch('app.tasks.ai_tasks._update_job_status') + @patch('app.tasks.ai_tasks._store_job_result') + def test_process_narrative_task( + self, mock_store_result, mock_update_status, mock_generator_class, + mock_update_session, mock_get_tier, sample_character, sample_game_state, mock_narrative_response + ): + """Test processing a narrative task with NarrativeGenerator.""" + from app.ai.model_selector import UserTier + + # Setup mocks + mock_get_tier.return_value = UserTier.FREE + mock_generator = MagicMock() + mock_generator.generate_story_response.return_value = mock_narrative_response + mock_generator_class.return_value = mock_generator + + # Process task + result = process_ai_task( + task_type="narrative", + user_id="user_123", + context={ + "action": "I explore the tavern", + "character": sample_character, + "game_state": sample_game_state + }, + job_id="job_123", + session_id="sess_456", + character_id="char_789" + ) + + # Verify result structure + assert "narrative" in result + assert result["narrative"] == "You enter the tavern..." + assert result["tokens_used"] == 150 + assert result["model"] == "meta/meta-llama-3-8b-instruct" + + # Verify generator was called + mock_generator.generate_story_response.assert_called_once() + + # Verify session update was called + mock_update_session.assert_called_once() + + @patch('app.tasks.ai_tasks._get_user_tier') + @patch('app.tasks.ai_tasks.NarrativeGenerator') + @patch('app.tasks.ai_tasks._update_job_status') + @patch('app.tasks.ai_tasks._store_job_result') + def test_process_combat_task( + self, mock_store_result, mock_update_status, mock_generator_class, + mock_get_tier, sample_character, mock_narrative_response + ): + """Test processing a combat task.""" + from app.ai.model_selector import UserTier + + mock_get_tier.return_value = UserTier.BASIC + mock_generator = MagicMock() + mock_generator.generate_combat_narration.return_value = mock_narrative_response + mock_generator_class.return_value = mock_generator + + result = process_ai_task( + task_type="combat", + user_id="user_123", + context={ + "character": sample_character, + "combat_state": {"round_number": 1, "enemies": [], "current_turn": "player"}, + "action": "swings sword", + "action_result": {"hit": True, "damage": 10} + }, + job_id="job_123", + ) + + assert "combat_narrative" in result + mock_generator.generate_combat_narration.assert_called_once() + + @patch('app.tasks.ai_tasks._get_user_tier') + @patch('app.tasks.ai_tasks.NarrativeGenerator') + @patch('app.tasks.ai_tasks._update_job_status') + @patch('app.tasks.ai_tasks._store_job_result') + def test_process_quest_selection_task( + self, mock_store_result, mock_update_status, mock_generator_class, + mock_get_tier, sample_character, sample_game_state + ): + """Test processing a quest selection task.""" + from app.ai.model_selector import UserTier + + mock_get_tier.return_value = UserTier.FREE + mock_generator = MagicMock() + mock_generator.generate_quest_selection.return_value = "goblin_cave" + mock_generator_class.return_value = mock_generator + + result = process_ai_task( + task_type="quest_selection", + user_id="user_123", + context={ + "character": sample_character, + "eligible_quests": [{"quest_id": "goblin_cave"}], + "game_context": sample_game_state + }, + job_id="job_123", + ) + + assert result["selected_quest_id"] == "goblin_cave" + mock_generator.generate_quest_selection.assert_called_once() + + @patch('app.tasks.ai_tasks._get_user_tier') + @patch('app.tasks.ai_tasks.NarrativeGenerator') + @patch('app.tasks.ai_tasks._update_job_status') + @patch('app.tasks.ai_tasks._store_job_result') + def test_process_npc_dialogue_task( + self, mock_store_result, mock_update_status, mock_generator_class, + mock_get_tier, sample_character, sample_game_state, mock_narrative_response + ): + """Test processing an NPC dialogue task.""" + from app.ai.model_selector import UserTier + + mock_get_tier.return_value = UserTier.PREMIUM + mock_generator = MagicMock() + mock_generator.generate_npc_dialogue.return_value = mock_narrative_response + mock_generator_class.return_value = mock_generator + + result = process_ai_task( + task_type="npc_dialogue", + user_id="user_123", + context={ + "character": sample_character, + "npc": {"name": "Innkeeper", "role": "Barkeeper"}, + "conversation_topic": "What's the news?", + "game_state": sample_game_state + }, + job_id="job_123", + ) + + assert "dialogue" in result + mock_generator.generate_npc_dialogue.assert_called_once() + + @patch('app.tasks.ai_tasks._update_job_status') + def test_process_task_failure_invalid_type(self, mock_update_status): + """Test task processing failure with invalid task type.""" + with pytest.raises(ValueError): + process_ai_task( + task_type="invalid", + user_id="user_123", + context={}, + job_id="job_123", + ) + + # Verify status was updated to PROCESSING then FAILED + calls = mock_update_status.call_args_list + assert any(call[0][1] == JobStatus.FAILED for call in calls) + + @patch('app.tasks.ai_tasks._get_user_tier') + @patch('app.tasks.ai_tasks._update_job_status') + def test_process_task_missing_context_fields(self, mock_update_status, mock_get_tier): + """Test task processing failure with missing required context fields.""" + from app.ai.model_selector import UserTier + mock_get_tier.return_value = UserTier.FREE + + with pytest.raises(ValueError) as exc_info: + process_ai_task( + task_type="narrative", + user_id="user_123", + context={"action": "test"}, # Missing character and game_state + job_id="job_123", + ) + + assert "Missing required context field" in str(exc_info.value) + + +class TestJobStatus: + """Test job status retrieval.""" + + @patch('app.tasks.ai_tasks.RedisService') + def test_get_job_status_from_cache(self, mock_redis_class): + """Test getting job status from Redis cache.""" + mock_redis = MagicMock() + mock_redis.get_json.return_value = { + "job_id": "job_123", + "status": "completed", + "created_at": "2025-11-21T10:00:00Z", + } + mock_redis_class.return_value = mock_redis + + result = get_job_status("job_123") + + assert result["job_id"] == "job_123" + assert result["status"] == "completed" + + @patch('app.tasks.ai_tasks.RedisService') + @patch('app.tasks.ai_tasks.get_redis_connection') + @patch('app.tasks.ai_tasks.Job') + def test_get_job_status_from_rq(self, mock_job_class, mock_get_conn, mock_redis_class): + """Test getting job status from RQ when not in cache.""" + # No cached status + mock_redis = MagicMock() + mock_redis.get_json.return_value = None + mock_redis_class.return_value = mock_redis + + # RQ job + mock_job = MagicMock() + mock_job.is_finished = True + mock_job.is_failed = False + mock_job.is_started = False + mock_job.created_at = None + mock_job.started_at = None + mock_job.ended_at = None + mock_job_class.fetch.return_value = mock_job + + result = get_job_status("job_123") + + assert result["status"] == "completed" + + @patch('app.tasks.ai_tasks.RedisService') + @patch('app.tasks.ai_tasks.get_redis_connection') + @patch('app.tasks.ai_tasks.Job') + def test_get_job_status_not_found(self, mock_job_class, mock_get_conn, mock_redis_class): + """Test getting status of non-existent job.""" + mock_redis = MagicMock() + mock_redis.get_json.return_value = None + mock_redis_class.return_value = mock_redis + + mock_job_class.fetch.side_effect = Exception("Job not found") + + result = get_job_status("nonexistent_job") + + assert result["status"] == "unknown" + assert "error" in result + + +class TestJobResult: + """Test job result retrieval.""" + + @patch('app.tasks.ai_tasks.RedisService') + def test_get_job_result_from_cache(self, mock_redis_class): + """Test getting job result from Redis cache.""" + expected_result = { + "narrative": "You enter the tavern...", + "tokens_used": 450, + } + + mock_redis = MagicMock() + mock_redis.get_json.return_value = expected_result + mock_redis_class.return_value = mock_redis + + result = get_job_result("job_123") + + assert result == expected_result + + @patch('app.tasks.ai_tasks.RedisService') + @patch('app.tasks.ai_tasks.get_redis_connection') + @patch('app.tasks.ai_tasks.Job') + def test_get_job_result_from_rq(self, mock_job_class, mock_get_conn, mock_redis_class): + """Test getting job result from RQ when not in cache.""" + expected_result = {"narrative": "You find a sword..."} + + mock_redis = MagicMock() + mock_redis.get_json.return_value = None + mock_redis_class.return_value = mock_redis + + mock_job = MagicMock() + mock_job.is_finished = True + mock_job.result = expected_result + mock_job_class.fetch.return_value = mock_job + + result = get_job_result("job_123") + + assert result == expected_result + + @patch('app.tasks.ai_tasks.RedisService') + @patch('app.tasks.ai_tasks.get_redis_connection') + @patch('app.tasks.ai_tasks.Job') + def test_get_job_result_not_found(self, mock_job_class, mock_get_conn, mock_redis_class): + """Test getting result of non-existent job.""" + mock_redis = MagicMock() + mock_redis.get_json.return_value = None + mock_redis_class.return_value = mock_redis + + mock_job_class.fetch.side_effect = Exception("Job not found") + + result = get_job_result("nonexistent_job") + + assert result is None + + +class TestStatusStorage: + """Test job status storage functions.""" + + @patch('app.tasks.ai_tasks.RedisService') + def test_store_job_status(self, mock_redis_class): + """Test storing initial job status.""" + mock_redis = MagicMock() + mock_redis_class.return_value = mock_redis + + _store_job_status( + job_id="job_123", + status=JobStatus.QUEUED, + task_type="narrative", + user_id="user_456", + ) + + mock_redis.set_json.assert_called_once() + call_args = mock_redis.set_json.call_args + stored_data = call_args[0][1] + + assert stored_data["job_id"] == "job_123" + assert stored_data["status"] == "queued" + assert stored_data["task_type"] == "narrative" + assert stored_data["user_id"] == "user_456" + + @patch('app.tasks.ai_tasks.RedisService') + def test_update_job_status_to_processing(self, mock_redis_class): + """Test updating job status to processing.""" + mock_redis = MagicMock() + mock_redis.get_json.return_value = { + "job_id": "job_123", + "status": "queued", + "created_at": "2025-11-21T10:00:00Z", + } + mock_redis_class.return_value = mock_redis + + _update_job_status("job_123", JobStatus.PROCESSING) + + mock_redis.set_json.assert_called_once() + call_args = mock_redis.set_json.call_args + updated_data = call_args[0][1] + + assert updated_data["status"] == "processing" + assert updated_data["started_at"] is not None + + @patch('app.tasks.ai_tasks.RedisService') + def test_update_job_status_to_completed(self, mock_redis_class): + """Test updating job status to completed with result.""" + mock_redis = MagicMock() + mock_redis.get_json.return_value = { + "job_id": "job_123", + "status": "processing", + } + mock_redis_class.return_value = mock_redis + + result_data = {"narrative": "Test result"} + _update_job_status("job_123", JobStatus.COMPLETED, result=result_data) + + call_args = mock_redis.set_json.call_args + updated_data = call_args[0][1] + + assert updated_data["status"] == "completed" + assert updated_data["completed_at"] is not None + assert updated_data["result"] == result_data + + @patch('app.tasks.ai_tasks.RedisService') + def test_update_job_status_to_failed(self, mock_redis_class): + """Test updating job status to failed with error.""" + mock_redis = MagicMock() + mock_redis.get_json.return_value = { + "job_id": "job_123", + "status": "processing", + } + mock_redis_class.return_value = mock_redis + + _update_job_status("job_123", JobStatus.FAILED, error="Something went wrong") + + call_args = mock_redis.set_json.call_args + updated_data = call_args[0][1] + + assert updated_data["status"] == "failed" + assert updated_data["error"] == "Something went wrong" + + @patch('app.tasks.ai_tasks.RedisService') + def test_store_job_result(self, mock_redis_class): + """Test storing job result.""" + mock_redis = MagicMock() + mock_redis_class.return_value = mock_redis + + result_data = { + "narrative": "You discover a hidden passage...", + "tokens_used": 500, + } + + _store_job_result("job_123", result_data) + + mock_redis.set_json.assert_called_once() + call_args = mock_redis.set_json.call_args + + assert call_args[0][0] == "ai_job_result:job_123" + assert call_args[0][1] == result_data + assert call_args[1]["ttl"] == 3600 # 1 hour + + +class TestTaskTypes: + """Test task type and priority enums.""" + + def test_task_type_values(self): + """Test all task types are defined.""" + assert TaskType.NARRATIVE.value == "narrative" + assert TaskType.COMBAT.value == "combat" + assert TaskType.QUEST_SELECTION.value == "quest_selection" + assert TaskType.NPC_DIALOGUE.value == "npc_dialogue" + + def test_task_priority_values(self): + """Test all priorities are defined.""" + assert TaskPriority.LOW.value == "low" + assert TaskPriority.NORMAL.value == "normal" + assert TaskPriority.HIGH.value == "high" + + def test_job_status_values(self): + """Test all job statuses are defined.""" + assert JobStatus.QUEUED.value == "queued" + assert JobStatus.PROCESSING.value == "processing" + assert JobStatus.COMPLETED.value == "completed" + assert JobStatus.FAILED.value == "failed" diff --git a/api/tests/test_api_characters_integration.py b/api/tests/test_api_characters_integration.py new file mode 100644 index 0000000..8f1ee37 --- /dev/null +++ b/api/tests/test_api_characters_integration.py @@ -0,0 +1,579 @@ +""" +Integration tests for Character API endpoints. + +These tests verify the complete character management API flow including: +- List characters +- Get character details +- Create character (with tier limits) +- Delete character +- Unlock skills +- Respec skills +- Get classes and origins (reference data) + +Tests use Flask test client with mocked authentication and database layers. +""" + +import pytest +import json +from unittest.mock import Mock, patch, MagicMock +from datetime import datetime + +from app.models.character import Character +from app.models.stats import Stats +from app.models.skills import PlayerClass, SkillTree, SkillNode +from app.models.origins import Origin, StartingLocation, StartingBonus +from app.services.character_service import ( + CharacterLimitExceeded, + CharacterNotFound, + SkillUnlockError, + InsufficientGold +) +from app.services.database_service import DatabaseDocument + + +class TestCharacterAPIIntegration: + """Integration tests for Character API endpoints.""" + + @pytest.fixture + def app(self): + """Create Flask application for testing.""" + # Import here to avoid circular imports + from app import create_app + app = create_app() + app.config['TESTING'] = True + return app + + @pytest.fixture + def client(self, app): + """Create test client.""" + return app.test_client() + + @pytest.fixture(autouse=True) + def mock_auth_decorator(self): + """Mock the require_auth decorator to bypass authentication.""" + def pass_through_decorator(func): + """Decorator that does nothing - passes through to the function.""" + return func + + with patch('app.api.characters.require_auth', side_effect=pass_through_decorator): + yield + + @pytest.fixture + def mock_user(self): + """Create a mock authenticated user.""" + user = Mock() + user.id = "test_user_123" + user.email = "test@example.com" + user.name = "Test User" + user.tier = "free" + user.email_verified = True + return user + + @pytest.fixture + def sample_class(self): + """Create a sample player class.""" + base_stats = Stats(strength=14, dexterity=10, constitution=14, + intelligence=8, wisdom=10, charisma=9) + + skill_nodes = [ + SkillNode( + skill_id="shield_wall", + name="Shield Wall", + description="Increase armor by 5", + tier=1, + prerequisites=[], + effects={"armor": 5} + ), + SkillNode( + skill_id="toughness", + name="Toughness", + description="Increase HP by 10", + tier=2, + prerequisites=["shield_wall"], + effects={"hit_points": 10} + ) + ] + + skill_tree = SkillTree( + tree_id="shield_bearer", + name="Shield Bearer", + description="Defensive techniques", + nodes=skill_nodes + ) + + return PlayerClass( + class_id="vanguard", + name="Vanguard", + description="Armored warrior", + base_stats=base_stats, + skill_trees=[skill_tree], + starting_equipment=["Rusty Sword", "Tattered Cloth Armor"], + starting_abilities=[] + ) + + @pytest.fixture + def sample_origin(self): + """Create a sample origin.""" + starting_location = StartingLocation( + id="forgotten_crypt", + name="The Forgotten Crypt", + region="The Deadlands", + description="A crumbling stone tomb beneath a dead forest" + ) + + starting_bonus = StartingBonus( + type="stat", + value={"constitution": 1} + ) + + return Origin( + id="soul_revenant", + name="The Soul Revenant", + description="You died. That much you remember...", + starting_location=starting_location, + narrative_hooks=["Who brought you back?"], + starting_bonus=starting_bonus + ) + + @pytest.fixture + def sample_character(self, sample_class, sample_origin): + """Create a sample character.""" + return Character( + character_id="char_123", + user_id="test_user_123", + name="Thorin Ironforge", + player_class=sample_class, + origin=sample_origin, + level=5, + experience=2400, + base_stats=Stats(strength=16, dexterity=10, constitution=14, + intelligence=8, wisdom=12, charisma=10), + unlocked_skills=["shield_wall"], + inventory=[], + equipped={}, + gold=500, + active_quests=[], + discovered_locations=["forgotten_crypt"], + current_location="forgotten_crypt" + ) + + # ===== LIST CHARACTERS ===== + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_list_characters_success(self, mock_get_service, mock_get_user, + client, mock_user, sample_character): + """Test listing user's characters.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.get_user_characters.return_value = [sample_character] + mock_get_service.return_value = mock_service + + # Execute + response = client.get('/api/v1/characters') + + # Verify + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 200 + assert len(data['result']['characters']) == 1 + assert data['result']['characters'][0]['name'] == "Thorin Ironforge" + assert data['result']['characters'][0]['class'] == "vanguard" + assert data['result']['count'] == 1 + assert data['result']['tier'] == "free" + assert data['result']['limit'] == 1 + + @patch('app.api.characters.get_current_user') + def test_list_characters_unauthorized(self, mock_get_user, client): + """Test listing characters without authentication.""" + # Setup - simulate no user logged in + mock_get_user.return_value = None + + # Execute + response = client.get('/api/v1/characters') + + # Verify + assert response.status_code == 401 + + # ===== GET CHARACTER ===== + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_get_character_success(self, mock_get_service, mock_get_user, + client, mock_user, sample_character): + """Test getting a single character.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.get_character.return_value = sample_character + mock_get_service.return_value = mock_service + + # Execute + response = client.get('/api/v1/characters/char_123') + + # Verify + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 200 + assert data['result']['name'] == "Thorin Ironforge" + assert data['result']['level'] == 5 + assert data['result']['gold'] == 500 + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_get_character_not_found(self, mock_get_service, mock_get_user, + client, mock_user): + """Test getting a non-existent character.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.get_character.side_effect = CharacterNotFound("Character not found") + mock_get_service.return_value = mock_service + + # Execute + response = client.get('/api/v1/characters/char_999') + + # Verify + assert response.status_code == 404 + data = json.loads(response.data) + assert 'error' in data + + # ===== CREATE CHARACTER ===== + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_create_character_success(self, mock_get_service, mock_get_user, + client, mock_user, sample_character): + """Test creating a new character.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.create_character.return_value = sample_character + mock_get_service.return_value = mock_service + + # Execute + response = client.post('/api/v1/characters', + json={ + 'name': 'Thorin Ironforge', + 'class_id': 'vanguard', + 'origin_id': 'soul_revenant' + }) + + # Verify + assert response.status_code == 201 + data = json.loads(response.data) + assert data['status'] == 201 + assert data['result']['name'] == "Thorin Ironforge" + assert data['result']['class'] == "vanguard" + assert data['result']['origin'] == "soul_revenant" + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_create_character_limit_exceeded(self, mock_get_service, mock_get_user, + client, mock_user): + """Test creating a character when tier limit is reached.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.create_character.side_effect = CharacterLimitExceeded( + "Character limit reached for free tier (1/1)" + ) + mock_get_service.return_value = mock_service + + # Execute + response = client.post('/api/v1/characters', + json={ + 'name': 'Thorin Ironforge', + 'class_id': 'vanguard', + 'origin_id': 'soul_revenant' + }) + + # Verify + assert response.status_code == 400 + data = json.loads(response.data) + assert 'error' in data + assert data['error']['code'] == 'CHARACTER_LIMIT_EXCEEDED' + + def test_create_character_validation_errors(self, client, mock_user): + """Test character creation with invalid input.""" + with patch('app.api.characters.get_current_user', return_value=mock_user): + # Test missing name + response = client.post('/api/v1/characters', + json={ + 'class_id': 'vanguard', + 'origin_id': 'soul_revenant' + }) + assert response.status_code == 400 + + # Test invalid class_id + response = client.post('/api/v1/characters', + json={ + 'name': 'Test', + 'class_id': 'invalid_class', + 'origin_id': 'soul_revenant' + }) + assert response.status_code == 400 + + # Test invalid origin_id + response = client.post('/api/v1/characters', + json={ + 'name': 'Test', + 'class_id': 'vanguard', + 'origin_id': 'invalid_origin' + }) + assert response.status_code == 400 + + # Test name too short + response = client.post('/api/v1/characters', + json={ + 'name': 'T', + 'class_id': 'vanguard', + 'origin_id': 'soul_revenant' + }) + assert response.status_code == 400 + + # ===== DELETE CHARACTER ===== + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_delete_character_success(self, mock_get_service, mock_get_user, + client, mock_user): + """Test deleting a character.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.delete_character.return_value = True + mock_get_service.return_value = mock_service + + # Execute + response = client.delete('/api/v1/characters/char_123') + + # Verify + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 200 + assert 'deleted successfully' in data['result']['message'] + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_delete_character_not_found(self, mock_get_service, mock_get_user, + client, mock_user): + """Test deleting a non-existent character.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.delete_character.side_effect = CharacterNotFound("Character not found") + mock_get_service.return_value = mock_service + + # Execute + response = client.delete('/api/v1/characters/char_999') + + # Verify + assert response.status_code == 404 + + # ===== UNLOCK SKILL ===== + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_unlock_skill_success(self, mock_get_service, mock_get_user, + client, mock_user, sample_character): + """Test unlocking a skill.""" + # Setup + mock_get_user.return_value = mock_user + sample_character.unlocked_skills = ["shield_wall", "toughness"] + mock_service = Mock() + mock_service.unlock_skill.return_value = sample_character + mock_get_service.return_value = mock_service + + # Execute + response = client.post('/api/v1/characters/char_123/skills/unlock', + json={'skill_id': 'toughness'}) + + # Verify + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 200 + assert data['result']['skill_id'] == 'toughness' + assert 'toughness' in data['result']['unlocked_skills'] + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_unlock_skill_prerequisites_not_met(self, mock_get_service, mock_get_user, + client, mock_user): + """Test unlocking a skill without meeting prerequisites.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.unlock_skill.side_effect = SkillUnlockError( + "Prerequisite not met: shield_wall required for toughness" + ) + mock_get_service.return_value = mock_service + + # Execute + response = client.post('/api/v1/characters/char_123/skills/unlock', + json={'skill_id': 'toughness'}) + + # Verify + assert response.status_code == 400 + data = json.loads(response.data) + assert 'error' in data + assert data['error']['code'] == 'SKILL_UNLOCK_ERROR' + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_unlock_skill_no_points(self, mock_get_service, mock_get_user, + client, mock_user): + """Test unlocking a skill without available skill points.""" + # Setup + mock_get_user.return_value = mock_user + mock_service = Mock() + mock_service.unlock_skill.side_effect = SkillUnlockError( + "No skill points available (Level 1, 1 skills unlocked)" + ) + mock_get_service.return_value = mock_service + + # Execute + response = client.post('/api/v1/characters/char_123/skills/unlock', + json={'skill_id': 'shield_wall'}) + + # Verify + assert response.status_code == 400 + data = json.loads(response.data) + assert 'error' in data + + # ===== RESPEC SKILLS ===== + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_respec_skills_success(self, mock_get_service, mock_get_user, + client, mock_user, sample_character): + """Test respeccing character skills.""" + # Setup + mock_get_user.return_value = mock_user + + # Get character returns current state + char_before = sample_character + char_before.unlocked_skills = ["shield_wall"] + char_before.gold = 500 + + # After respec, skills cleared and gold reduced + char_after = sample_character + char_after.unlocked_skills = [] + char_after.gold = 0 # 500 - (5 * 100) + + mock_service = Mock() + mock_service.get_character.return_value = char_before + mock_service.respec_skills.return_value = char_after + mock_get_service.return_value = mock_service + + # Execute + response = client.post('/api/v1/characters/char_123/skills/respec') + + # Verify + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 200 + assert data['result']['cost'] == 500 # level 5 * 100 + assert data['result']['remaining_gold'] == 0 + assert data['result']['available_points'] == 5 + + @patch('app.api.characters.get_current_user') + @patch('app.api.characters.get_character_service') + def test_respec_skills_insufficient_gold(self, mock_get_service, mock_get_user, + client, mock_user, sample_character): + """Test respeccing without enough gold.""" + # Setup + mock_get_user.return_value = mock_user + sample_character.gold = 100 # Not enough for level 5 respec (needs 500) + + mock_service = Mock() + mock_service.get_character.return_value = sample_character + mock_service.respec_skills.side_effect = InsufficientGold( + "Insufficient gold for respec. Cost: 500, Available: 100" + ) + mock_get_service.return_value = mock_service + + # Execute + response = client.post('/api/v1/characters/char_123/skills/respec') + + # Verify + assert response.status_code == 400 + data = json.loads(response.data) + assert 'error' in data + assert data['error']['code'] == 'INSUFFICIENT_GOLD' + + # ===== CLASSES ENDPOINTS (REFERENCE DATA) ===== + + @patch('app.api.characters.get_class_loader') + def test_list_classes(self, mock_get_loader, client, sample_class): + """Test listing all character classes.""" + # Setup + mock_loader = Mock() + mock_loader.get_all_class_ids.return_value = ['vanguard', 'arcanist'] + mock_loader.load_class.return_value = sample_class + mock_get_loader.return_value = mock_loader + + # Execute + response = client.get('/api/v1/classes') + + # Verify + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 200 + assert len(data['result']['classes']) == 2 + assert data['result']['count'] == 2 + + @patch('app.api.characters.get_class_loader') + def test_get_class_details(self, mock_get_loader, client, sample_class): + """Test getting details of a specific class.""" + # Setup + mock_loader = Mock() + mock_loader.load_class.return_value = sample_class + mock_get_loader.return_value = mock_loader + + # Execute + response = client.get('/api/v1/classes/vanguard') + + # Verify + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 200 + assert data['result']['class_id'] == 'vanguard' + assert data['result']['name'] == 'Vanguard' + assert len(data['result']['skill_trees']) == 1 + + @patch('app.api.characters.get_class_loader') + def test_get_class_not_found(self, mock_get_loader, client): + """Test getting a non-existent class.""" + # Setup + mock_loader = Mock() + mock_loader.load_class.return_value = None + mock_get_loader.return_value = mock_loader + + # Execute + response = client.get('/api/v1/classes/invalid_class') + + # Verify + assert response.status_code == 404 + + # ===== ORIGINS ENDPOINTS (REFERENCE DATA) ===== + + @patch('app.api.characters.get_origin_service') + def test_list_origins(self, mock_get_service, client, sample_origin): + """Test listing all character origins.""" + # Setup + mock_service = Mock() + mock_service.get_all_origin_ids.return_value = ['soul_revenant', 'memory_thief'] + mock_service.load_origin.return_value = sample_origin + mock_get_service.return_value = mock_service + + # Execute + response = client.get('/api/v1/origins') + + # Verify + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 200 + assert len(data['result']['origins']) == 2 + assert data['result']['count'] == 2 diff --git a/api/tests/test_character.py b/api/tests/test_character.py new file mode 100644 index 0000000..1664087 --- /dev/null +++ b/api/tests/test_character.py @@ -0,0 +1,454 @@ +""" +Unit tests for Character dataclass. + +Tests the critical get_effective_stats() method which combines all stat modifiers, +as well as inventory, equipment, experience, and serialization. +""" + +import pytest +from app.models.character import Character +from app.models.stats import Stats +from app.models.items import Item +from app.models.effects import Effect +from app.models.skills import PlayerClass, SkillTree, SkillNode +from app.models.origins import Origin, StartingLocation, StartingBonus +from app.models.enums import ItemType, EffectType, StatType, DamageType + + +@pytest.fixture +def basic_player_class(): + """Create a basic player class for testing.""" + base_stats = Stats(strength=12, dexterity=10, constitution=14, intelligence=8, wisdom=10, charisma=11) + + # Create a simple skill tree + skill_tree = SkillTree( + tree_id="warrior_offense", + name="Warrior Offense", + description="Offensive combat skills", + nodes=[ + SkillNode( + skill_id="power_strike", + name="Power Strike", + description="+5 Strength", + tier=1, + effects={"strength": 5}, + ), + ], + ) + + return PlayerClass( + class_id="warrior", + name="Warrior", + description="Strong melee fighter", + base_stats=base_stats, + skill_trees=[skill_tree], + starting_equipment=["basic_sword"], + starting_abilities=["basic_attack"], + ) + + +@pytest.fixture +def basic_origin(): + """Create a basic origin for testing.""" + starting_location = StartingLocation( + id="test_location", + name="Test Village", + region="Test Region", + description="A simple test location" + ) + + starting_bonus = StartingBonus( + trait="Test Trait", + description="A test trait for testing", + effect="+1 to all stats" + ) + + return Origin( + id="test_origin", + name="Test Origin", + description="A test origin for character testing", + starting_location=starting_location, + narrative_hooks=["Test hook 1", "Test hook 2"], + starting_bonus=starting_bonus + ) + + +@pytest.fixture +def basic_character(basic_player_class, basic_origin): + """Create a basic character for testing.""" + return Character( + character_id="char_001", + user_id="user_001", + name="Test Hero", + player_class=basic_player_class, + origin=basic_origin, + level=1, + experience=0, + base_stats=basic_player_class.base_stats.copy(), + ) + + +def test_character_creation(basic_character): + """Test creating a Character instance.""" + assert basic_character.character_id == "char_001" + assert basic_character.user_id == "user_001" + assert basic_character.name == "Test Hero" + assert basic_character.level == 1 + assert basic_character.experience == 0 + assert basic_character.gold == 0 + + +def test_get_effective_stats_base_only(basic_character): + """Test get_effective_stats() with only base stats (no modifiers).""" + effective = basic_character.get_effective_stats() + + # Should match base stats exactly + assert effective.strength == 12 + assert effective.dexterity == 10 + assert effective.constitution == 14 + assert effective.intelligence == 8 + assert effective.wisdom == 10 + assert effective.charisma == 11 + + +def test_get_effective_stats_with_equipment(basic_character): + """Test get_effective_stats() with equipped items.""" + # Create a weapon with +5 strength + weapon = Item( + item_id="iron_sword", + name="Iron Sword", + item_type=ItemType.WEAPON, + description="A sturdy iron sword", + stat_bonuses={"strength": 5}, + damage=10, + ) + + basic_character.equipped["weapon"] = weapon + effective = basic_character.get_effective_stats() + + # Strength should be base (12) + weapon (5) = 17 + assert effective.strength == 17 + assert effective.dexterity == 10 # Unchanged + + +def test_get_effective_stats_with_skill_bonuses(basic_character): + """Test get_effective_stats() with skill tree bonuses.""" + # Unlock the "power_strike" skill which gives +5 strength + basic_character.unlocked_skills.append("power_strike") + + effective = basic_character.get_effective_stats() + + # Strength should be base (12) + skill (5) = 17 + assert effective.strength == 17 + + +def test_get_effective_stats_with_all_modifiers(basic_character): + """Test get_effective_stats() with equipment + skills + active effects.""" + # Add equipment: +5 strength + weapon = Item( + item_id="iron_sword", + name="Iron Sword", + item_type=ItemType.WEAPON, + description="A sturdy iron sword", + stat_bonuses={"strength": 5}, + damage=10, + ) + basic_character.equipped["weapon"] = weapon + + # Unlock skill: +5 strength + basic_character.unlocked_skills.append("power_strike") + + # Add buff effect: +3 strength + buff = Effect( + effect_id="str_buff", + name="Strength Boost", + effect_type=EffectType.BUFF, + duration=3, + power=3, + stat_affected=StatType.STRENGTH, + ) + + effective = basic_character.get_effective_stats([buff]) + + # Total strength: 12 (base) + 5 (weapon) + 5 (skill) + 3 (buff) = 25 + assert effective.strength == 25 + + +def test_get_effective_stats_with_debuff(basic_character): + """Test get_effective_stats() with a debuff.""" + debuff = Effect( + effect_id="weakened", + name="Weakened", + effect_type=EffectType.DEBUFF, + duration=2, + power=5, + stat_affected=StatType.STRENGTH, + ) + + effective = basic_character.get_effective_stats([debuff]) + + # Strength: 12 (base) - 5 (debuff) = 7 + assert effective.strength == 7 + + +def test_get_effective_stats_debuff_minimum(basic_character): + """Test that debuffs cannot reduce stats below 1.""" + # Massive debuff + debuff = Effect( + effect_id="weakened", + name="Weakened", + effect_type=EffectType.DEBUFF, + duration=2, + power=20, # More than base strength + stat_affected=StatType.STRENGTH, + ) + + effective = basic_character.get_effective_stats([debuff]) + + # Should be clamped at 1, not 0 or negative + assert effective.strength == 1 + + +def test_inventory_management(basic_character): + """Test adding and removing items from inventory.""" + item = Item( + item_id="potion", + name="Health Potion", + item_type=ItemType.CONSUMABLE, + description="Restores 50 HP", + value=25, + ) + + # Add item + basic_character.add_item(item) + assert len(basic_character.inventory) == 1 + assert basic_character.inventory[0].item_id == "potion" + + # Remove item + removed = basic_character.remove_item("potion") + assert removed.item_id == "potion" + assert len(basic_character.inventory) == 0 + + +def test_equip_and_unequip(basic_character): + """Test equipping and unequipping items.""" + weapon = Item( + item_id="iron_sword", + name="Iron Sword", + item_type=ItemType.WEAPON, + description="A sturdy sword", + stat_bonuses={"strength": 5}, + damage=10, + ) + + # Add to inventory first + basic_character.add_item(weapon) + assert len(basic_character.inventory) == 1 + + # Equip weapon + basic_character.equip_item(weapon, "weapon") + assert "weapon" in basic_character.equipped + assert basic_character.equipped["weapon"].item_id == "iron_sword" + assert len(basic_character.inventory) == 0 # Removed from inventory + + # Unequip weapon + unequipped = basic_character.unequip_item("weapon") + assert unequipped.item_id == "iron_sword" + assert "weapon" not in basic_character.equipped + assert len(basic_character.inventory) == 1 # Back in inventory + + +def test_equip_replaces_existing(basic_character): + """Test that equipping a new item replaces the old one.""" + weapon1 = Item( + item_id="iron_sword", + name="Iron Sword", + item_type=ItemType.WEAPON, + description="A sturdy sword", + damage=10, + ) + + weapon2 = Item( + item_id="steel_sword", + name="Steel Sword", + item_type=ItemType.WEAPON, + description="A better sword", + damage=15, + ) + + # Equip first weapon + basic_character.add_item(weapon1) + basic_character.equip_item(weapon1, "weapon") + + # Equip second weapon + basic_character.add_item(weapon2) + previous = basic_character.equip_item(weapon2, "weapon") + + assert previous.item_id == "iron_sword" # Old weapon returned + assert basic_character.equipped["weapon"].item_id == "steel_sword" + assert len(basic_character.inventory) == 1 # Old weapon back in inventory + + +def test_gold_management(basic_character): + """Test adding and removing gold.""" + assert basic_character.gold == 0 + + # Add gold + basic_character.add_gold(100) + assert basic_character.gold == 100 + + # Remove gold + success = basic_character.remove_gold(50) + assert success == True + assert basic_character.gold == 50 + + # Try to remove more than available + success = basic_character.remove_gold(100) + assert success == False + assert basic_character.gold == 50 # Unchanged + + +def test_can_afford(basic_character): + """Test can_afford() method.""" + basic_character.gold = 100 + + assert basic_character.can_afford(50) == True + assert basic_character.can_afford(100) == True + assert basic_character.can_afford(101) == False + + +def test_add_experience_no_level_up(basic_character): + """Test adding experience without leveling up.""" + leveled_up = basic_character.add_experience(50) + + assert leveled_up == False + assert basic_character.level == 1 + assert basic_character.experience == 50 + + +def test_add_experience_with_level_up(basic_character): + """Test adding enough experience to level up.""" + # Level 1 requires 100 XP for level 2 + leveled_up = basic_character.add_experience(100) + + assert leveled_up == True + assert basic_character.level == 2 + assert basic_character.experience == 0 # Reset + + +def test_add_experience_with_overflow(basic_character): + """Test leveling up with overflow experience.""" + # Level 1 requires 100 XP, give 150 + leveled_up = basic_character.add_experience(150) + + assert leveled_up == True + assert basic_character.level == 2 + assert basic_character.experience == 50 # Overflow + + +def test_xp_calculation(basic_origin): + """Test XP required for each level.""" + char = Character( + character_id="test", + user_id="user", + name="Test", + player_class=PlayerClass( + class_id="test", + name="Test", + description="Test", + base_stats=Stats(), + ), + origin=basic_origin, + ) + + # Formula: 100 * (level ^ 1.5) + assert char._calculate_xp_for_next_level() == 100 # Level 1→2 + + char.level = 2 + assert char._calculate_xp_for_next_level() == 282 # Level 2→3 + + char.level = 3 + assert char._calculate_xp_for_next_level() == 519 # Level 3→4 + + +def test_get_unlocked_abilities(basic_character): + """Test getting abilities from class + unlocked skills.""" + # Should have starting abilities + abilities = basic_character.get_unlocked_abilities() + assert "basic_attack" in abilities + + # TODO: When skills unlock abilities, test that here + + +def test_character_serialization(basic_character): + """Test character to_dict() serialization.""" + # Add some data + basic_character.gold = 500 + basic_character.level = 3 + basic_character.experience = 100 + + data = basic_character.to_dict() + + assert data["character_id"] == "char_001" + assert data["user_id"] == "user_001" + assert data["name"] == "Test Hero" + assert data["level"] == 3 + assert data["experience"] == 100 + assert data["gold"] == 500 + + +def test_character_deserialization(basic_player_class, basic_origin): + """Test character from_dict() deserialization.""" + data = { + "character_id": "char_002", + "user_id": "user_002", + "name": "Restored Hero", + "player_class": basic_player_class.to_dict(), + "origin": basic_origin.to_dict(), + "level": 5, + "experience": 200, + "base_stats": Stats(strength=15).to_dict(), + "unlocked_skills": ["power_strike"], + "inventory": [], + "equipped": {}, + "gold": 1000, + "active_quests": ["quest_1"], + "discovered_locations": ["town_1"], + } + + char = Character.from_dict(data) + + assert char.character_id == "char_002" + assert char.name == "Restored Hero" + assert char.level == 5 + assert char.gold == 1000 + assert "power_strike" in char.unlocked_skills + + +def test_character_round_trip_serialization(basic_character): + """Test that serialization and deserialization preserve all data.""" + # Add complex state + basic_character.gold = 500 + basic_character.level = 3 + basic_character.unlocked_skills = ["power_strike"] + + weapon = Item( + item_id="sword", + name="Sword", + item_type=ItemType.WEAPON, + description="A sword", + damage=10, + ) + basic_character.equipped["weapon"] = weapon + + # Serialize and deserialize + data = basic_character.to_dict() + restored = Character.from_dict(data) + + assert restored.character_id == basic_character.character_id + assert restored.name == basic_character.name + assert restored.level == basic_character.level + assert restored.gold == basic_character.gold + assert restored.unlocked_skills == basic_character.unlocked_skills + assert "weapon" in restored.equipped + assert restored.equipped["weapon"].item_id == "sword" diff --git a/api/tests/test_character_service.py b/api/tests/test_character_service.py new file mode 100644 index 0000000..44cfe14 --- /dev/null +++ b/api/tests/test_character_service.py @@ -0,0 +1,547 @@ +""" +Unit tests for CharacterService - character CRUD operations and tier limits. + +These tests verify character creation, retrieval, deletion, skill unlock, +and respec functionality with proper validation and error handling. +""" + +import pytest +import json +from unittest.mock import Mock, MagicMock, patch +from datetime import datetime, timezone + +from app.services.character_service import ( + CharacterService, + CharacterLimitExceeded, + CharacterNotFound, + SkillUnlockError, + InsufficientGold, + CHARACTER_LIMITS +) +from app.models.character import Character +from app.models.stats import Stats +from app.models.skills import PlayerClass, SkillTree, SkillNode +from app.models.origins import Origin, StartingLocation, StartingBonus +from app.services.database_service import DatabaseDocument + + +class TestCharacterService: + """Test suite for CharacterService.""" + + @pytest.fixture + def mock_db(self): + """Mock database service.""" + return Mock() + + @pytest.fixture + def mock_appwrite(self): + """Mock Appwrite service.""" + return Mock() + + @pytest.fixture + def mock_class_loader(self): + """Mock class loader.""" + return Mock() + + @pytest.fixture + def mock_origin_service(self): + """Mock origin service.""" + return Mock() + + @pytest.fixture + def character_service(self, mock_db, mock_appwrite, mock_class_loader, mock_origin_service): + """Create CharacterService with mocked dependencies.""" + # Patch the singleton getters before instantiation + with patch('app.services.character_service.get_database_service', return_value=mock_db), \ + patch('app.services.character_service.AppwriteService', return_value=mock_appwrite), \ + patch('app.services.character_service.get_class_loader', return_value=mock_class_loader), \ + patch('app.services.character_service.get_origin_service', return_value=mock_origin_service): + + service = CharacterService() + + # Ensure mocks are still assigned + service.db = mock_db + service.appwrite = mock_appwrite + service.class_loader = mock_class_loader + service.origin_service = mock_origin_service + return service + + @pytest.fixture + def sample_class(self): + """Create a sample player class.""" + base_stats = Stats(strength=12, dexterity=10, constitution=14) + skill_tree = SkillTree( + tree_id="warrior_offense", + name="Warrior Offense", + description="Offensive skills", + nodes=[ + SkillNode( + skill_id="power_strike", + name="Power Strike", + description="+5 Strength", + tier=1, + effects={"strength": 5}, + ), + SkillNode( + skill_id="heavy_blow", + name="Heavy Blow", + description="+10 Strength", + tier=2, + prerequisites=["power_strike"], + effects={"strength": 10}, + ), + ], + ) + + return PlayerClass( + class_id="warrior", + name="Warrior", + description="Strong fighter", + base_stats=base_stats, + skill_trees=[skill_tree], + starting_equipment=["basic_sword"], + starting_abilities=["basic_attack"], + ) + + @pytest.fixture + def sample_origin(self): + """Create a sample origin.""" + starting_location = StartingLocation( + id="test_crypt", + name="Test Crypt", + region="Test Region", + description="A test location" + ) + + starting_bonus = StartingBonus( + trait="Test Trait", + description="Test bonus", + effect="+1 to all stats" + ) + + return Origin( + id="test_origin", + name="Test Origin", + description="A test origin", + starting_location=starting_location, + narrative_hooks=["Test hook"], + starting_bonus=starting_bonus + ) + + def test_create_character_success( + self, character_service, mock_appwrite, mock_class_loader, + mock_origin_service, mock_db, sample_class, sample_origin + ): + """Test successful character creation.""" + # Setup mocks + mock_appwrite.get_user_tier.return_value = 'free' + mock_class_loader.load_class.return_value = sample_class + mock_origin_service.load_origin.return_value = sample_origin + + # Mock count_user_characters to return 0 + character_service.count_user_characters = Mock(return_value=0) + + # Create character + with patch('app.services.character_service.ID') as mock_id: + mock_id.unique.return_value = 'char_123' + + character = character_service.create_character( + user_id='user_001', + name='Test Hero', + class_id='warrior', + origin_id='test_origin' + ) + + # Assertions + assert character.character_id == 'char_123' + assert character.user_id == 'user_001' + assert character.name == 'Test Hero' + assert character.level == 1 + assert character.experience == 0 + assert character.gold == 0 + assert character.current_location == 'test_crypt' + + # Verify database was called + mock_db.create_document.assert_called_once() + + def test_create_character_exceeds_limit( + self, character_service, mock_appwrite, mock_class_loader, mock_origin_service + ): + """Test character creation fails when tier limit exceeded.""" + # Setup: user on free tier (limit 1) with 1 existing character + mock_appwrite.get_user_tier.return_value = 'free' + character_service.count_user_characters = Mock(return_value=1) + + # Attempt to create second character + with pytest.raises(CharacterLimitExceeded) as exc_info: + character_service.create_character( + user_id='user_001', + name='Second Hero', + class_id='warrior', + origin_id='test_origin' + ) + + assert 'free tier' in str(exc_info.value) + assert '1/1' in str(exc_info.value) + + def test_create_character_tier_limits(self): + """Test that tier limits are correctly defined.""" + assert CHARACTER_LIMITS['free'] == 1 + assert CHARACTER_LIMITS['basic'] == 3 + assert CHARACTER_LIMITS['premium'] == 5 + assert CHARACTER_LIMITS['elite'] == 10 + + def test_create_character_invalid_class( + self, character_service, mock_appwrite, mock_class_loader, mock_origin_service + ): + """Test character creation fails with invalid class.""" + mock_appwrite.get_user_tier.return_value = 'free' + character_service.count_user_characters = Mock(return_value=0) + mock_class_loader.load_class.return_value = None + + with pytest.raises(ValueError) as exc_info: + character_service.create_character( + user_id='user_001', + name='Test Hero', + class_id='invalid_class', + origin_id='test_origin' + ) + + assert 'Class not found' in str(exc_info.value) + + def test_create_character_invalid_origin( + self, character_service, mock_appwrite, mock_class_loader, + mock_origin_service, sample_class + ): + """Test character creation fails with invalid origin.""" + mock_appwrite.get_user_tier.return_value = 'free' + character_service.count_user_characters = Mock(return_value=0) + mock_class_loader.load_class.return_value = sample_class + mock_origin_service.load_origin.return_value = None + + with pytest.raises(ValueError) as exc_info: + character_service.create_character( + user_id='user_001', + name='Test Hero', + class_id='warrior', + origin_id='invalid_origin' + ) + + assert 'Origin not found' in str(exc_info.value) + + def test_get_character_success(self, character_service, mock_db, sample_class, sample_origin): + """Test successfully retrieving a character.""" + # Create test character data + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + level=5, + gold=500 + ) + + # Mock database response + mock_doc = Mock(spec=DatabaseDocument) + mock_doc.id = 'char_123' + mock_doc.data = { + 'userId': 'user_001', + 'characterData': json.dumps(character.to_dict()), + 'is_active': True + } + mock_db.get_document.return_value = mock_doc + + # Get character + result = character_service.get_character('char_123', 'user_001') + + # Assertions + assert result.character_id == 'char_123' + assert result.name == 'Test Hero' + assert result.level == 5 + assert result.gold == 500 + + def test_get_character_not_found(self, character_service, mock_db): + """Test getting non-existent character raises error.""" + mock_db.get_document.return_value = None + + with pytest.raises(CharacterNotFound): + character_service.get_character('nonexistent', 'user_001') + + def test_get_character_wrong_owner(self, character_service, mock_db): + """Test getting character owned by different user raises error.""" + mock_doc = Mock(spec=DatabaseDocument) + mock_doc.data = {'userId': 'user_002'} # Different user + mock_db.get_document.return_value = mock_doc + + with pytest.raises(CharacterNotFound): + character_service.get_character('char_123', 'user_001') + + def test_get_user_characters(self, character_service, mock_db, sample_class, sample_origin): + """Test getting all characters for a user.""" + # Create test character data + char1_data = Character( + character_id='char_1', + user_id='user_001', + name='Hero 1', + player_class=sample_class, + origin=sample_origin + ).to_dict() + + char2_data = Character( + character_id='char_2', + user_id='user_001', + name='Hero 2', + player_class=sample_class, + origin=sample_origin + ).to_dict() + + # Mock database response + mock_doc1 = Mock(spec=DatabaseDocument) + mock_doc1.id = 'char_1' + mock_doc1.data = {'characterData': json.dumps(char1_data)} + + mock_doc2 = Mock(spec=DatabaseDocument) + mock_doc2.id = 'char_2' + mock_doc2.data = {'characterData': json.dumps(char2_data)} + + mock_db.list_rows.return_value = [mock_doc1, mock_doc2] + + # Get characters + characters = character_service.get_user_characters('user_001') + + # Assertions + assert len(characters) == 2 + assert characters[0].name == 'Hero 1' + assert characters[1].name == 'Hero 2' + + def test_count_user_characters(self, character_service, mock_db): + """Test counting user's characters.""" + mock_db.count_documents.return_value = 3 + + count = character_service.count_user_characters('user_001') + + assert count == 3 + mock_db.count_documents.assert_called_once() + + def test_delete_character_success(self, character_service, mock_db): + """Test successfully deleting a character.""" + # Mock get_character to return a valid character + character_service.get_character = Mock(return_value=Mock(character_id='char_123')) + + # Delete character + result = character_service.delete_character('char_123', 'user_001') + + # Assertions + assert result is True + mock_db.update_document.assert_called_once() + # Verify it's a soft delete (is_active set to False) + call_args = mock_db.update_document.call_args + assert call_args[1]['data']['is_active'] is False + + def test_delete_character_not_found(self, character_service): + """Test deleting non-existent character raises error.""" + character_service.get_character = Mock(side_effect=CharacterNotFound("Not found")) + + with pytest.raises(CharacterNotFound): + character_service.delete_character('nonexistent', 'user_001') + + def test_unlock_skill_success(self, character_service, sample_class, sample_origin): + """Test successfully unlocking a skill.""" + # Create character with level 2 (1 skill point available) + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + level=2, + unlocked_skills=[] + ) + + # Mock get_character + character_service.get_character = Mock(return_value=character) + + # Mock _save_character + character_service._save_character = Mock() + + # Unlock skill + result = character_service.unlock_skill('char_123', 'user_001', 'power_strike') + + # Assertions + assert 'power_strike' in result.unlocked_skills + character_service._save_character.assert_called_once() + + def test_unlock_skill_already_unlocked(self, character_service, sample_class, sample_origin): + """Test unlocking already unlocked skill raises error.""" + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + level=2, + unlocked_skills=['power_strike'] # Already unlocked + ) + + character_service.get_character = Mock(return_value=character) + + with pytest.raises(SkillUnlockError) as exc_info: + character_service.unlock_skill('char_123', 'user_001', 'power_strike') + + assert 'already unlocked' in str(exc_info.value) + + def test_unlock_skill_not_in_class(self, character_service, sample_class, sample_origin): + """Test unlocking skill not in class raises error.""" + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + level=2, + unlocked_skills=[] + ) + + character_service.get_character = Mock(return_value=character) + + with pytest.raises(SkillUnlockError) as exc_info: + character_service.unlock_skill('char_123', 'user_001', 'invalid_skill') + + assert 'not found in class' in str(exc_info.value) + + def test_unlock_skill_missing_prerequisite(self, character_service, sample_class, sample_origin): + """Test unlocking skill without prerequisite raises error.""" + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + level=2, + unlocked_skills=[] # Missing 'power_strike' prerequisite + ) + + character_service.get_character = Mock(return_value=character) + + with pytest.raises(SkillUnlockError) as exc_info: + character_service.unlock_skill('char_123', 'user_001', 'heavy_blow') + + assert 'Prerequisite not met' in str(exc_info.value) + assert 'power_strike' in str(exc_info.value) + + def test_unlock_skill_no_points_available(self, character_service, sample_class, sample_origin): + """Test unlocking skill without available points raises error.""" + # Add a tier 3 skill to test with + tier3_skill = SkillNode( + skill_id="master_strike", + name="Master Strike", + description="+15 Strength", + tier=3, + effects={"strength": 15}, + ) + sample_class.skill_trees[0].nodes.append(tier3_skill) + + # Level 1 character with 1 skill already unlocked = 0 points remaining + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + level=1, + unlocked_skills=['power_strike'] # Used the 1 point from level 1 + ) + + character_service.get_character = Mock(return_value=character) + + # Try to unlock another skill (master_strike exists in class) + with pytest.raises(SkillUnlockError) as exc_info: + character_service.unlock_skill('char_123', 'user_001', 'master_strike') + + assert 'No skill points available' in str(exc_info.value) + + def test_respec_skills_success(self, character_service, sample_class, sample_origin): + """Test successfully respecing character skills.""" + # Level 5 character with 3 skills and 500 gold + # Respec cost = 5 * 100 = 500 gold + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + level=5, + gold=500, + unlocked_skills=['skill1', 'skill2', 'skill3'] + ) + + character_service.get_character = Mock(return_value=character) + character_service._save_character = Mock() + + # Respec skills + result = character_service.respec_skills('char_123', 'user_001') + + # Assertions + assert len(result.unlocked_skills) == 0 # Skills cleared + assert result.gold == 0 # 500 - 500 = 0 + character_service._save_character.assert_called_once() + + def test_respec_skills_insufficient_gold(self, character_service, sample_class, sample_origin): + """Test respec fails with insufficient gold.""" + # Level 5 character with only 100 gold (needs 500) + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + level=5, + gold=100, # Not enough + unlocked_skills=['skill1'] + ) + + character_service.get_character = Mock(return_value=character) + + with pytest.raises(InsufficientGold) as exc_info: + character_service.respec_skills('char_123', 'user_001') + + assert '500' in str(exc_info.value) # Cost + assert '100' in str(exc_info.value) # Available + + def test_update_character_success(self, character_service, sample_class, sample_origin): + """Test successfully updating a character.""" + character = Character( + character_id='char_123', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin, + gold=1000 # Updated gold + ) + + # Mock get_character to verify ownership + character_service.get_character = Mock(return_value=character) + character_service._save_character = Mock() + + # Update character + result = character_service.update_character(character, 'user_001') + + # Assertions + assert result.gold == 1000 + character_service._save_character.assert_called_once() + + def test_update_character_not_found(self, character_service, sample_class, sample_origin): + """Test updating non-existent character raises error.""" + character = Character( + character_id='nonexistent', + user_id='user_001', + name='Test Hero', + player_class=sample_class, + origin=sample_origin + ) + + character_service.get_character = Mock(side_effect=CharacterNotFound("Not found")) + + with pytest.raises(CharacterNotFound): + character_service.update_character(character, 'user_001') diff --git a/api/tests/test_class_loader.py b/api/tests/test_class_loader.py new file mode 100644 index 0000000..d54750e --- /dev/null +++ b/api/tests/test_class_loader.py @@ -0,0 +1,256 @@ +""" +Unit tests for ClassLoader service. + +Tests loading player class definitions from YAML files. +""" + +import pytest +from pathlib import Path + +from app.services.class_loader import ClassLoader +from app.models.skills import PlayerClass, SkillTree, SkillNode +from app.models.stats import Stats + + +class TestClassLoader: + """Test ClassLoader functionality.""" + + @pytest.fixture + def loader(self): + """Create a ClassLoader instance for testing.""" + return ClassLoader() + + def test_load_vanguard_class(self, loader): + """Test loading the Vanguard class.""" + vanguard = loader.load_class("vanguard") + + # Verify class loaded successfully + assert vanguard is not None + assert isinstance(vanguard, PlayerClass) + + # Check basic properties + assert vanguard.class_id == "vanguard" + assert vanguard.name == "Vanguard" + assert "melee combat" in vanguard.description.lower() + + def test_vanguard_base_stats(self, loader): + """Test Vanguard base stats are correct.""" + vanguard = loader.load_class("vanguard") + + assert isinstance(vanguard.base_stats, Stats) + assert vanguard.base_stats.strength == 14 + assert vanguard.base_stats.dexterity == 10 + assert vanguard.base_stats.constitution == 14 + assert vanguard.base_stats.intelligence == 8 + assert vanguard.base_stats.wisdom == 10 + assert vanguard.base_stats.charisma == 9 + + def test_vanguard_skill_trees(self, loader): + """Test Vanguard has 2 skill trees.""" + vanguard = loader.load_class("vanguard") + + # Should have exactly 2 skill trees + assert len(vanguard.skill_trees) == 2 + + # Get trees by ID + shield_bearer = vanguard.get_skill_tree("shield_bearer") + weapon_master = vanguard.get_skill_tree("weapon_master") + + assert shield_bearer is not None + assert weapon_master is not None + + # Check tree names + assert shield_bearer.name == "Shield Bearer" + assert weapon_master.name == "Weapon Master" + + def test_shield_bearer_tree_structure(self, loader): + """Test Shield Bearer tree has correct structure.""" + vanguard = loader.load_class("vanguard") + shield_bearer = vanguard.get_skill_tree("shield_bearer") + + # Should have 10 nodes (5 tiers × 2 nodes) + assert len(shield_bearer.nodes) == 10 + + # Check tier distribution + tier_counts = {} + for node in shield_bearer.nodes: + tier_counts[node.tier] = tier_counts.get(node.tier, 0) + 1 + + # Should have 2 nodes per tier for tiers 1-5 + assert tier_counts == {1: 2, 2: 2, 3: 2, 4: 2, 5: 2} + + def test_weapon_master_tree_structure(self, loader): + """Test Weapon Master tree has correct structure.""" + vanguard = loader.load_class("vanguard") + weapon_master = vanguard.get_skill_tree("weapon_master") + + # Should have 10 nodes (5 tiers × 2 nodes) + assert len(weapon_master.nodes) == 10 + + # Check tier distribution + tier_counts = {} + for node in weapon_master.nodes: + tier_counts[node.tier] = tier_counts.get(node.tier, 0) + 1 + + # Should have 2 nodes per tier for tiers 1-5 + assert tier_counts == {1: 2, 2: 2, 3: 2, 4: 2, 5: 2} + + def test_skill_node_prerequisites(self, loader): + """Test skill nodes have correct prerequisites.""" + vanguard = loader.load_class("vanguard") + shield_bearer = vanguard.get_skill_tree("shield_bearer") + + # Find tier 1 and tier 2 nodes + tier1_nodes = [n for n in shield_bearer.nodes if n.tier == 1] + tier2_nodes = [n for n in shield_bearer.nodes if n.tier == 2] + + # Tier 1 nodes should have no prerequisites + for node in tier1_nodes: + assert len(node.prerequisites) == 0 + + # Tier 2 nodes should have prerequisites + for node in tier2_nodes: + assert len(node.prerequisites) > 0 + # Prerequisites should reference tier 1 skills + for prereq_id in node.prerequisites: + prereq_found = any(n.skill_id == prereq_id for n in tier1_nodes) + assert prereq_found, f"Prerequisite {prereq_id} not found in tier 1" + + def test_skill_node_effects(self, loader): + """Test skill nodes have proper effects defined.""" + vanguard = loader.load_class("vanguard") + shield_bearer = vanguard.get_skill_tree("shield_bearer") + + # Find the "fortify" skill (passive defense bonus) + fortify = next((n for n in shield_bearer.nodes if n.skill_id == "fortify"), None) + assert fortify is not None + + # Should have stat bonuses in effects + assert "stat_bonuses" in fortify.effects + assert "defense" in fortify.effects["stat_bonuses"] + assert fortify.effects["stat_bonuses"]["defense"] == 5 + + def test_skill_node_abilities(self, loader): + """Test skill nodes with ability unlocks.""" + vanguard = loader.load_class("vanguard") + shield_bearer = vanguard.get_skill_tree("shield_bearer") + + # Find the "shield_bash" skill (active ability) + shield_bash = next((n for n in shield_bearer.nodes if n.skill_id == "shield_bash"), None) + assert shield_bash is not None + + # Should have abilities in effects + assert "abilities" in shield_bash.effects + assert "shield_bash" in shield_bash.effects["abilities"] + + def test_starting_equipment(self, loader): + """Test Vanguard starting equipment.""" + vanguard = loader.load_class("vanguard") + + # Should have starting equipment + assert len(vanguard.starting_equipment) > 0 + assert "rusty_sword" in vanguard.starting_equipment + assert "cloth_armor" in vanguard.starting_equipment + assert "rusty_knife" in vanguard.starting_equipment + + def test_starting_abilities(self, loader): + """Test Vanguard starting abilities.""" + vanguard = loader.load_class("vanguard") + + # Should have basic_attack + assert len(vanguard.starting_abilities) > 0 + assert "basic_attack" in vanguard.starting_abilities + + def test_cache_functionality(self, loader): + """Test that classes are cached after first load.""" + # Load class twice + vanguard1 = loader.load_class("vanguard") + vanguard2 = loader.load_class("vanguard") + + # Should be the same object (cached) + assert vanguard1 is vanguard2 + + def test_reload_class(self, loader): + """Test reload_class forces a fresh load.""" + # Load class + vanguard1 = loader.load_class("vanguard") + + # Reload class + vanguard2 = loader.reload_class("vanguard") + + # Should still be equal but different objects + assert vanguard1.class_id == vanguard2.class_id + # Note: May or may not be same object depending on implementation + + def test_load_nonexistent_class(self, loader): + """Test loading a class that doesn't exist.""" + result = loader.load_class("nonexistent_class") + assert result is None + + def test_get_all_class_ids(self, loader): + """Test getting list of all class IDs.""" + class_ids = loader.get_all_class_ids() + + # Should include vanguard + assert "vanguard" in class_ids + + # Should be a list of strings + assert all(isinstance(cid, str) for cid in class_ids) + + def test_load_all_classes(self, loader): + """Test loading all classes at once.""" + classes = loader.load_all_classes() + + # Should have at least 1 class (vanguard) + assert len(classes) >= 1 + + # All should be PlayerClass instances + assert all(isinstance(c, PlayerClass) for c in classes) + + # Vanguard should be in the list + vanguard_found = any(c.class_id == "vanguard" for c in classes) + assert vanguard_found + + def test_get_all_skills_method(self, loader): + """Test PlayerClass.get_all_skills() method.""" + vanguard = loader.load_class("vanguard") + + all_skills = vanguard.get_all_skills() + + # Should have 20 skills (2 trees × 10 nodes) + assert len(all_skills) == 20 + + # All should be SkillNode instances + assert all(isinstance(skill, SkillNode) for skill in all_skills) + + # Should include skills from both trees + skill_ids = [s.skill_id for s in all_skills] + assert "shield_bash" in skill_ids # Shield Bearer tree + assert "power_strike" in skill_ids # Weapon Master tree + + def test_tier_5_ultimate_skills(self, loader): + """Test that tier 5 skills exist and have powerful effects.""" + vanguard = loader.load_class("vanguard") + all_skills = vanguard.get_all_skills() + + # Get tier 5 skills + tier5_skills = [s for s in all_skills if s.tier == 5] + + # Should have 4 tier 5 skills (2 per tree) + assert len(tier5_skills) == 4 + + # Each tier 5 skill should have prerequisites + for skill in tier5_skills: + assert len(skill.prerequisites) > 0 + + # At least one tier 5 skill should have significant stat bonuses + has_major_bonuses = any( + "stat_bonuses" in s.effects and + any(v >= 10 for v in s.effects.get("stat_bonuses", {}).values()) + for s in tier5_skills + ) + assert has_major_bonuses + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/api/tests/test_combat_simulation.py b/api/tests/test_combat_simulation.py new file mode 100644 index 0000000..8d8b088 --- /dev/null +++ b/api/tests/test_combat_simulation.py @@ -0,0 +1,509 @@ +""" +Integration tests for combat system. + +Tests the complete combat flow including damage calculation, effects, and turn order. +""" + +import pytest +from app.models.stats import Stats +from app.models.items import Item +from app.models.effects import Effect +from app.models.abilities import Ability +from app.models.combat import Combatant, CombatEncounter +from app.models.character import Character +from app.models.skills import PlayerClass +from app.models.enums import ( + ItemType, + DamageType, + EffectType, + StatType, + AbilityType, + CombatStatus, +) + + +@pytest.fixture +def warrior_combatant(): + """Create a warrior combatant for testing.""" + stats = Stats(strength=15, dexterity=10, constitution=14, intelligence=8, wisdom=10, charisma=11) + + return Combatant( + combatant_id="warrior_1", + name="Test Warrior", + is_player=True, + current_hp=stats.hit_points, + max_hp=stats.hit_points, + current_mp=stats.mana_points, + max_mp=stats.mana_points, + stats=stats, + abilities=["basic_attack", "power_strike"], + ) + + +@pytest.fixture +def goblin_combatant(): + """Create a goblin enemy for testing.""" + stats = Stats(strength=8, dexterity=12, constitution=10, intelligence=6, wisdom=8, charisma=6) + + return Combatant( + combatant_id="goblin_1", + name="Goblin", + is_player=False, + current_hp=stats.hit_points, + max_hp=stats.hit_points, + current_mp=stats.mana_points, + max_mp=stats.mana_points, + stats=stats, + abilities=["basic_attack"], + ) + + +def test_combatant_creation(warrior_combatant): + """Test creating a Combatant.""" + assert warrior_combatant.combatant_id == "warrior_1" + assert warrior_combatant.name == "Test Warrior" + assert warrior_combatant.is_player == True + assert warrior_combatant.is_alive() == True + assert warrior_combatant.is_stunned() == False + + +def test_combatant_take_damage(warrior_combatant): + """Test taking damage.""" + initial_hp = warrior_combatant.current_hp + + damage_dealt = warrior_combatant.take_damage(10) + + assert damage_dealt == 10 + assert warrior_combatant.current_hp == initial_hp - 10 + + +def test_combatant_take_damage_with_shield(warrior_combatant): + """Test taking damage with shield absorption.""" + # Add a shield effect + shield = Effect( + effect_id="shield_1", + name="Shield", + effect_type=EffectType.SHIELD, + duration=3, + power=15, + ) + warrior_combatant.add_effect(shield) + + initial_hp = warrior_combatant.current_hp + + # Deal 10 damage - should be fully absorbed by shield + damage_dealt = warrior_combatant.take_damage(10) + + assert damage_dealt == 0 # No HP damage + assert warrior_combatant.current_hp == initial_hp + + +def test_combatant_death(warrior_combatant): + """Test combatant death.""" + assert warrior_combatant.is_alive() == True + + # Deal massive damage + warrior_combatant.take_damage(1000) + + assert warrior_combatant.is_alive() == False + assert warrior_combatant.is_dead() == True + + +def test_combatant_healing(warrior_combatant): + """Test healing.""" + # Take some damage first + warrior_combatant.take_damage(20) + damaged_hp = warrior_combatant.current_hp + + # Heal + healed = warrior_combatant.heal(10) + + assert healed == 10 + assert warrior_combatant.current_hp == damaged_hp + 10 + + +def test_combatant_healing_capped_at_max(warrior_combatant): + """Test that healing cannot exceed max HP.""" + max_hp = warrior_combatant.max_hp + + # Try to heal beyond max + healed = warrior_combatant.heal(1000) + + assert warrior_combatant.current_hp == max_hp + + +def test_combatant_stun_effect(warrior_combatant): + """Test stun effect prevents actions.""" + assert warrior_combatant.is_stunned() == False + + # Add stun effect + stun = Effect( + effect_id="stun_1", + name="Stunned", + effect_type=EffectType.STUN, + duration=1, + power=0, + ) + warrior_combatant.add_effect(stun) + + assert warrior_combatant.is_stunned() == True + + +def test_combatant_tick_effects(warrior_combatant): + """Test that ticking effects deals damage/healing.""" + # Add a DOT effect + poison = Effect( + effect_id="poison_1", + name="Poison", + effect_type=EffectType.DOT, + duration=3, + power=5, + ) + warrior_combatant.add_effect(poison) + + initial_hp = warrior_combatant.current_hp + + # Tick effects + results = warrior_combatant.tick_effects() + + # Should have taken 5 poison damage + assert len(results) == 1 + assert results[0]["effect_type"] == "dot" + assert results[0]["value"] == 5 + assert warrior_combatant.current_hp == initial_hp - 5 + + +def test_combatant_effect_expiration(warrior_combatant): + """Test that expired effects are removed.""" + # Add effect with 1 turn duration + dot = Effect( + effect_id="burn_1", + name="Burning", + effect_type=EffectType.DOT, + duration=1, + power=5, + ) + warrior_combatant.add_effect(dot) + + assert len(warrior_combatant.active_effects) == 1 + + # Tick - effect should expire + results = warrior_combatant.tick_effects() + + assert results[0]["expired"] == True + assert len(warrior_combatant.active_effects) == 0 # Removed + + +def test_ability_mana_cost(warrior_combatant): + """Test ability mana cost and usage.""" + ability = Ability( + ability_id="fireball", + name="Fireball", + description="Fiery explosion", + ability_type=AbilityType.SPELL, + base_power=30, + damage_type=DamageType.FIRE, + mana_cost=15, + ) + + initial_mp = warrior_combatant.current_mp + + # Check if can use + assert warrior_combatant.can_use_ability("fireball", ability) == False # Not in ability list + warrior_combatant.abilities.append("fireball") + assert warrior_combatant.can_use_ability("fireball", ability) == True + + # Use ability + warrior_combatant.use_ability_cost(ability, "fireball") + + assert warrior_combatant.current_mp == initial_mp - 15 + + +def test_ability_cooldown(warrior_combatant): + """Test ability cooldowns.""" + ability = Ability( + ability_id="power_strike", + name="Power Strike", + description="Powerful attack", + ability_type=AbilityType.SKILL, + base_power=20, + cooldown=3, + ) + + warrior_combatant.abilities.append("power_strike") + + # Can use initially + assert warrior_combatant.can_use_ability("power_strike", ability) == True + + # Use ability + warrior_combatant.use_ability_cost(ability, "power_strike") + + # Now on cooldown + assert "power_strike" in warrior_combatant.cooldowns + assert warrior_combatant.cooldowns["power_strike"] == 3 + assert warrior_combatant.can_use_ability("power_strike", ability) == False + + # Tick cooldown + warrior_combatant.tick_cooldowns() + assert warrior_combatant.cooldowns["power_strike"] == 2 + + # Tick more + warrior_combatant.tick_cooldowns() + warrior_combatant.tick_cooldowns() + + # Should be available again + assert "power_strike" not in warrior_combatant.cooldowns + assert warrior_combatant.can_use_ability("power_strike", ability) == True + + +def test_combat_encounter_initialization(warrior_combatant, goblin_combatant): + """Test initializing a combat encounter.""" + encounter = CombatEncounter( + encounter_id="combat_001", + combatants=[warrior_combatant, goblin_combatant], + ) + + encounter.initialize_combat() + + # Should have turn order + assert len(encounter.turn_order) == 2 + assert encounter.round_number == 1 + assert encounter.status == CombatStatus.ACTIVE + + # Both combatants should have initiative + assert warrior_combatant.initiative > 0 + assert goblin_combatant.initiative > 0 + + +def test_combat_turn_advancement(warrior_combatant, goblin_combatant): + """Test advancing turns in combat.""" + encounter = CombatEncounter( + encounter_id="combat_001", + combatants=[warrior_combatant, goblin_combatant], + ) + + encounter.initialize_combat() + + # Get first combatant + first = encounter.get_current_combatant() + assert first is not None + + # Advance turn + encounter.advance_turn() + + # Should be second combatant now + second = encounter.get_current_combatant() + assert second is not None + assert second.combatant_id != first.combatant_id + + # Advance again - should cycle back to first and increment round + encounter.advance_turn() + + assert encounter.round_number == 2 + third = encounter.get_current_combatant() + assert third.combatant_id == first.combatant_id + + +def test_combat_victory_condition(warrior_combatant, goblin_combatant): + """Test victory condition detection.""" + encounter = CombatEncounter( + encounter_id="combat_001", + combatants=[warrior_combatant, goblin_combatant], + ) + + encounter.initialize_combat() + + # Kill the goblin + goblin_combatant.current_hp = 0 + + # Check end condition + status = encounter.check_end_condition() + + assert status == CombatStatus.VICTORY + assert encounter.status == CombatStatus.VICTORY + + +def test_combat_defeat_condition(warrior_combatant, goblin_combatant): + """Test defeat condition detection.""" + encounter = CombatEncounter( + encounter_id="combat_001", + combatants=[warrior_combatant, goblin_combatant], + ) + + encounter.initialize_combat() + + # Kill the warrior + warrior_combatant.current_hp = 0 + + # Check end condition + status = encounter.check_end_condition() + + assert status == CombatStatus.DEFEAT + assert encounter.status == CombatStatus.DEFEAT + + +def test_combat_start_turn_processing(warrior_combatant): + """Test start_turn() processes effects and cooldowns.""" + encounter = CombatEncounter( + encounter_id="combat_001", + combatants=[warrior_combatant], + ) + + # Initialize combat to set turn order + encounter.initialize_combat() + + # Add a DOT effect + poison = Effect( + effect_id="poison_1", + name="Poison", + effect_type=EffectType.DOT, + duration=3, + power=5, + ) + warrior_combatant.add_effect(poison) + + # Add a cooldown + warrior_combatant.cooldowns["power_strike"] = 2 + + initial_hp = warrior_combatant.current_hp + + # Start turn + results = encounter.start_turn() + + # Effects should have ticked + assert len(results) == 1 + assert warrior_combatant.current_hp == initial_hp - 5 + + # Cooldown should have decreased + assert warrior_combatant.cooldowns["power_strike"] == 1 + + +def test_combat_logging(warrior_combatant, goblin_combatant): + """Test combat log entries.""" + encounter = CombatEncounter( + encounter_id="combat_001", + combatants=[warrior_combatant, goblin_combatant], + ) + + encounter.log_action("attack", "warrior_1", "Warrior attacks Goblin for 10 damage") + + assert len(encounter.combat_log) == 1 + assert encounter.combat_log[0]["action_type"] == "attack" + assert encounter.combat_log[0]["combatant_id"] == "warrior_1" + assert "Warrior attacks Goblin" in encounter.combat_log[0]["message"] + + +def test_ability_damage_calculation(): + """Test ability power calculation with stat scaling.""" + stats = Stats(strength=20, intelligence=16) + + # Physical ability scaling with strength + physical = Ability( + ability_id="cleave", + name="Cleave", + description="Powerful strike", + ability_type=AbilityType.SKILL, + base_power=15, + scaling_stat=StatType.STRENGTH, + scaling_factor=0.5, + ) + + power = physical.calculate_power(stats) + # 15 (base) + (20 strength × 0.5) = 15 + 10 = 25 + assert power == 25 + + # Magical ability scaling with intelligence + magical = Ability( + ability_id="fireball", + name="Fireball", + description="Fire spell", + ability_type=AbilityType.SPELL, + base_power=20, + scaling_stat=StatType.INTELLIGENCE, + scaling_factor=0.5, + ) + + power = magical.calculate_power(stats) + # 20 (base) + (16 intelligence × 0.5) = 20 + 8 = 28 + assert power == 28 + + +def test_full_combat_simulation(): + """Integration test: Full combat simulation with all systems.""" + # Create warrior + warrior_stats = Stats(strength=15, constitution=14) + warrior = Combatant( + combatant_id="hero", + name="Hero", + is_player=True, + current_hp=warrior_stats.hit_points, + max_hp=warrior_stats.hit_points, + current_mp=warrior_stats.mana_points, + max_mp=warrior_stats.mana_points, + stats=warrior_stats, + ) + + # Create goblin + goblin_stats = Stats(strength=8, constitution=10) + goblin = Combatant( + combatant_id="goblin", + name="Goblin", + is_player=False, + current_hp=goblin_stats.hit_points, + max_hp=goblin_stats.hit_points, + current_mp=goblin_stats.mana_points, + max_mp=goblin_stats.mana_points, + stats=goblin_stats, + ) + + # Create encounter + encounter = CombatEncounter( + encounter_id="test_combat", + combatants=[warrior, goblin], + ) + + encounter.initialize_combat() + + # Verify setup + assert encounter.status == CombatStatus.ACTIVE + assert len(encounter.turn_order) == 2 + assert warrior.is_alive() and goblin.is_alive() + + # Simulate turns until combat ends + max_turns = 50 # Increased to ensure combat completes + turn_count = 0 + + while encounter.status == CombatStatus.ACTIVE and turn_count < max_turns: + # Get current combatant + current = encounter.get_current_combatant() + + # Start turn (tick effects) + encounter.start_turn() + + if current and current.is_alive() and not current.is_stunned(): + # Simple AI: deal damage to opponent + if current.combatant_id == "hero": + target = goblin + else: + target = warrior + + # Calculate simple attack damage: strength / 2 - target defense + damage = max(1, (current.stats.strength // 2) - target.stats.defense) + target.take_damage(damage) + + encounter.log_action( + "attack", + current.combatant_id, + f"{current.name} attacks {target.name} for {damage} damage", + ) + + # Check for combat end + encounter.check_end_condition() + + # Advance turn + encounter.advance_turn() + turn_count += 1 + + # Combat should have ended + assert encounter.status in [CombatStatus.VICTORY, CombatStatus.DEFEAT] + assert len(encounter.combat_log) > 0 diff --git a/api/tests/test_effects.py b/api/tests/test_effects.py new file mode 100644 index 0000000..4de4b25 --- /dev/null +++ b/api/tests/test_effects.py @@ -0,0 +1,361 @@ +""" +Unit tests for Effect dataclass. + +Tests all effect types, tick() method, stacking, and serialization. +""" + +import pytest +from app.models.effects import Effect +from app.models.enums import EffectType, StatType + + +def test_effect_creation(): + """Test creating an Effect instance.""" + effect = Effect( + effect_id="burn_1", + name="Burning", + effect_type=EffectType.DOT, + duration=3, + power=5, + ) + + assert effect.effect_id == "burn_1" + assert effect.name == "Burning" + assert effect.effect_type == EffectType.DOT + assert effect.duration == 3 + assert effect.power == 5 + assert effect.stacks == 1 + assert effect.max_stacks == 5 + + +def test_dot_effect_tick(): + """Test DOT (damage over time) effect ticking.""" + effect = Effect( + effect_id="poison_1", + name="Poisoned", + effect_type=EffectType.DOT, + duration=3, + power=10, + stacks=2, + ) + + result = effect.tick() + + assert result["effect_type"] == "dot" + assert result["value"] == 20 # 10 power × 2 stacks + assert result["expired"] == False + assert effect.duration == 2 # Reduced by 1 + + +def test_hot_effect_tick(): + """Test HOT (heal over time) effect ticking.""" + effect = Effect( + effect_id="regen_1", + name="Regeneration", + effect_type=EffectType.HOT, + duration=5, + power=8, + stacks=1, + ) + + result = effect.tick() + + assert result["effect_type"] == "hot" + assert result["value"] == 8 # 8 power × 1 stack + assert result["expired"] == False + assert effect.duration == 4 + + +def test_stun_effect_tick(): + """Test STUN effect ticking.""" + effect = Effect( + effect_id="stun_1", + name="Stunned", + effect_type=EffectType.STUN, + duration=1, + power=0, + ) + + result = effect.tick() + + assert result["effect_type"] == "stun" + assert result.get("stunned") == True + assert effect.duration == 0 + + +def test_shield_effect_tick(): + """Test SHIELD effect ticking.""" + effect = Effect( + effect_id="shield_1", + name="Shield", + effect_type=EffectType.SHIELD, + duration=3, + power=50, + ) + + result = effect.tick() + + assert result["effect_type"] == "shield" + assert result["shield_remaining"] == 50 + assert effect.duration == 2 + + +def test_buff_effect_tick(): + """Test BUFF effect ticking.""" + effect = Effect( + effect_id="str_buff_1", + name="Strength Boost", + effect_type=EffectType.BUFF, + duration=4, + power=5, + stat_affected=StatType.STRENGTH, + stacks=2, + ) + + result = effect.tick() + + assert result["effect_type"] == "buff" + assert result["stat_affected"] == "strength" + assert result["stat_modifier"] == 10 # 5 power × 2 stacks + assert effect.duration == 3 + + +def test_debuff_effect_tick(): + """Test DEBUFF effect ticking.""" + effect = Effect( + effect_id="weak_1", + name="Weakened", + effect_type=EffectType.DEBUFF, + duration=2, + power=3, + stat_affected=StatType.STRENGTH, + ) + + result = effect.tick() + + assert result["effect_type"] == "debuff" + assert result["stat_affected"] == "strength" + assert result["stat_modifier"] == 3 + assert effect.duration == 1 + + +def test_effect_expiration(): + """Test that effect expires when duration reaches 0.""" + effect = Effect( + effect_id="burn_1", + name="Burning", + effect_type=EffectType.DOT, + duration=1, + power=5, + ) + + result = effect.tick() + + assert result["expired"] == True + assert effect.duration == 0 + + +def test_effect_stacking(): + """Test apply_stack() increases stacks up to max.""" + effect = Effect( + effect_id="poison_1", + name="Poison", + effect_type=EffectType.DOT, + duration=3, + power=5, + max_stacks=5, + ) + + assert effect.stacks == 1 + + effect.apply_stack() + assert effect.stacks == 2 + + effect.apply_stack() + assert effect.stacks == 3 + + # Apply 3 more to reach max + effect.apply_stack() + effect.apply_stack() + effect.apply_stack() + assert effect.stacks == 5 # Capped at max_stacks + + # Try to apply one more - should still be 5 + effect.apply_stack() + assert effect.stacks == 5 + + +def test_shield_damage_absorption(): + """Test reduce_shield() method.""" + effect = Effect( + effect_id="shield_1", + name="Shield", + effect_type=EffectType.SHIELD, + duration=3, + power=50, + stacks=1, + ) + + # Shield absorbs 20 damage + remaining = effect.reduce_shield(20) + assert remaining == 0 # All damage absorbed + assert effect.power == 30 # Shield reduced by 20 + + # Shield absorbs 20 more + remaining = effect.reduce_shield(20) + assert remaining == 0 + assert effect.power == 10 + + # Shield takes 15 damage, breaks completely + remaining = effect.reduce_shield(15) + assert remaining == 5 # 5 damage passes through + assert effect.power == 0 + assert effect.duration == 0 # Effect expires + + +def test_shield_with_stacks(): + """Test shield absorption with multiple stacks.""" + effect = Effect( + effect_id="shield_1", + name="Shield", + effect_type=EffectType.SHIELD, + duration=3, + power=20, + stacks=3, + ) + + # Total shield = 20 × 3 = 60 + # Apply 50 damage + remaining = effect.reduce_shield(50) + assert remaining == 0 # All absorbed + # Shield reduced: 50 / 3 stacks = 16.67 per stack + assert effect.power < 20 # Power reduced + + +def test_effect_serialization(): + """Test to_dict() serialization.""" + effect = Effect( + effect_id="burn_1", + name="Burning", + effect_type=EffectType.DOT, + duration=3, + power=5, + stacks=2, + max_stacks=5, + source="fireball", + ) + + data = effect.to_dict() + + assert data["effect_id"] == "burn_1" + assert data["name"] == "Burning" + assert data["effect_type"] == "dot" + assert data["duration"] == 3 + assert data["power"] == 5 + assert data["stacks"] == 2 + assert data["max_stacks"] == 5 + assert data["source"] == "fireball" + + +def test_effect_deserialization(): + """Test from_dict() deserialization.""" + data = { + "effect_id": "regen_1", + "name": "Regeneration", + "effect_type": "hot", + "duration": 5, + "power": 10, + "stat_affected": None, + "stacks": 1, + "max_stacks": 5, + "source": "potion", + } + + effect = Effect.from_dict(data) + + assert effect.effect_id == "regen_1" + assert effect.name == "Regeneration" + assert effect.effect_type == EffectType.HOT + assert effect.duration == 5 + assert effect.power == 10 + assert effect.stacks == 1 + + +def test_effect_with_stat_deserialization(): + """Test deserializing effect with stat_affected.""" + data = { + "effect_id": "buff_1", + "name": "Strength Boost", + "effect_type": "buff", + "duration": 3, + "power": 5, + "stat_affected": "strength", + "stacks": 1, + "max_stacks": 5, + "source": "spell", + } + + effect = Effect.from_dict(data) + + assert effect.stat_affected == StatType.STRENGTH + assert effect.effect_type == EffectType.BUFF + + +def test_effect_round_trip_serialization(): + """Test that serialization and deserialization preserve data.""" + original = Effect( + effect_id="test_effect", + name="Test Effect", + effect_type=EffectType.DEBUFF, + duration=10, + power=15, + stat_affected=StatType.CONSTITUTION, + stacks=3, + max_stacks=5, + source="enemy_spell", + ) + + # Serialize then deserialize + data = original.to_dict() + restored = Effect.from_dict(data) + + assert restored.effect_id == original.effect_id + assert restored.name == original.name + assert restored.effect_type == original.effect_type + assert restored.duration == original.duration + assert restored.power == original.power + assert restored.stat_affected == original.stat_affected + assert restored.stacks == original.stacks + assert restored.max_stacks == original.max_stacks + assert restored.source == original.source + + +def test_effect_repr(): + """Test string representation.""" + effect = Effect( + effect_id="poison_1", + name="Poison", + effect_type=EffectType.DOT, + duration=3, + power=5, + stacks=2, + ) + + repr_str = repr(effect) + + assert "Poison" in repr_str + assert "dot" in repr_str + + +def test_non_shield_reduce_shield(): + """Test that reduce_shield() on non-SHIELD effects returns full damage.""" + effect = Effect( + effect_id="burn_1", + name="Burning", + effect_type=EffectType.DOT, + duration=3, + power=5, + ) + + remaining = effect.reduce_shield(50) + assert remaining == 50 # All damage passes through for non-shield effects diff --git a/api/tests/test_model_selector.py b/api/tests/test_model_selector.py new file mode 100644 index 0000000..290c0f7 --- /dev/null +++ b/api/tests/test_model_selector.py @@ -0,0 +1,294 @@ +""" +Unit tests for model selector module. +""" + +import pytest + +from app.ai import ( + ModelSelector, + ModelConfig, + UserTier, + ContextType, + ModelType, +) + + +class TestModelSelector: + """Tests for ModelSelector class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.selector = ModelSelector() + + def test_initialization(self): + """Test ModelSelector initializes correctly.""" + assert self.selector is not None + + # Test tier to model mapping + def test_free_tier_gets_llama(self): + """Free tier should get Llama-3 8B.""" + config = self.selector.select_model(UserTier.FREE) + assert config.model_type == ModelType.LLAMA_3_8B + + def test_basic_tier_gets_haiku(self): + """Basic tier should get Claude Haiku.""" + config = self.selector.select_model(UserTier.BASIC) + assert config.model_type == ModelType.CLAUDE_HAIKU + + def test_premium_tier_gets_sonnet(self): + """Premium tier should get Claude Sonnet.""" + config = self.selector.select_model(UserTier.PREMIUM) + assert config.model_type == ModelType.CLAUDE_SONNET + + def test_elite_tier_gets_opus(self): + """Elite tier should get Claude Opus.""" + config = self.selector.select_model(UserTier.ELITE) + assert config.model_type == ModelType.CLAUDE_SONNET_4 + + # Test token limits by tier (using STORY_PROGRESSION for full allocation) + def test_free_tier_token_limit(self): + """Free tier should have 256 base tokens.""" + config = self.selector.select_model(UserTier.FREE, ContextType.STORY_PROGRESSION) + assert config.max_tokens == 256 + + def test_basic_tier_token_limit(self): + """Basic tier should have 512 base tokens.""" + config = self.selector.select_model(UserTier.BASIC, ContextType.STORY_PROGRESSION) + assert config.max_tokens == 512 + + def test_premium_tier_token_limit(self): + """Premium tier should have 1024 base tokens.""" + config = self.selector.select_model(UserTier.PREMIUM, ContextType.STORY_PROGRESSION) + assert config.max_tokens == 1024 + + def test_elite_tier_token_limit(self): + """Elite tier should have 2048 base tokens.""" + config = self.selector.select_model(UserTier.ELITE, ContextType.STORY_PROGRESSION) + assert config.max_tokens == 2048 + + # Test context-based token adjustments + def test_story_progression_full_tokens(self): + """Story progression should use full token allocation.""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.STORY_PROGRESSION + ) + # Full allocation = 1024 tokens for premium + assert config.max_tokens == 1024 + + def test_combat_narration_reduced_tokens(self): + """Combat narration should use 75% of tokens.""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.COMBAT_NARRATION + ) + # 75% of 1024 = 768 + assert config.max_tokens == 768 + + def test_quest_selection_half_tokens(self): + """Quest selection should use 50% of tokens.""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.QUEST_SELECTION + ) + # 50% of 1024 = 512 + assert config.max_tokens == 512 + + def test_npc_dialogue_reduced_tokens(self): + """NPC dialogue should use 75% of tokens.""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.NPC_DIALOGUE + ) + # 75% of 1024 = 768 + assert config.max_tokens == 768 + + def test_simple_response_half_tokens(self): + """Simple response should use 50% of tokens.""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.SIMPLE_RESPONSE + ) + # 50% of 1024 = 512 + assert config.max_tokens == 512 + + # Test context-based temperature settings + def test_story_progression_high_temperature(self): + """Story progression should have high temperature (0.9).""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.STORY_PROGRESSION + ) + assert config.temperature == 0.9 + + def test_combat_narration_medium_high_temperature(self): + """Combat narration should have medium-high temperature (0.8).""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.COMBAT_NARRATION + ) + assert config.temperature == 0.8 + + def test_quest_selection_low_temperature(self): + """Quest selection should have low temperature (0.5).""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.QUEST_SELECTION + ) + assert config.temperature == 0.5 + + def test_npc_dialogue_medium_temperature(self): + """NPC dialogue should have medium temperature (0.85).""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.NPC_DIALOGUE + ) + assert config.temperature == 0.85 + + def test_simple_response_balanced_temperature(self): + """Simple response should have balanced temperature (0.7).""" + config = self.selector.select_model( + UserTier.PREMIUM, + ContextType.SIMPLE_RESPONSE + ) + assert config.temperature == 0.7 + + # Test ModelConfig properties + def test_model_config_model_property(self): + """ModelConfig.model should return model identifier string.""" + config = self.selector.select_model(UserTier.PREMIUM) + assert config.model == "anthropic/claude-3.5-sonnet" + + # Test get_model_for_tier method + def test_get_model_for_tier_free(self): + """get_model_for_tier should return correct model for free tier.""" + model = self.selector.get_model_for_tier(UserTier.FREE) + assert model == ModelType.LLAMA_3_8B + + def test_get_model_for_tier_elite(self): + """get_model_for_tier should return correct model for elite tier.""" + model = self.selector.get_model_for_tier(UserTier.ELITE) + assert model == ModelType.CLAUDE_SONNET_4 + + # Test get_tier_info method + def test_get_tier_info_structure(self): + """get_tier_info should return complete tier information.""" + info = self.selector.get_tier_info(UserTier.PREMIUM) + + assert "tier" in info + assert "model" in info + assert "model_name" in info + assert "base_tokens" in info + assert "quality" in info + + def test_get_tier_info_premium_values(self): + """get_tier_info should return correct values for premium tier.""" + info = self.selector.get_tier_info(UserTier.PREMIUM) + + assert info["tier"] == "premium" + assert info["model"] == "anthropic/claude-3.5-sonnet" + assert info["model_name"] == "Claude 3.5 Sonnet" + assert info["base_tokens"] == 1024 + + def test_get_tier_info_free_values(self): + """get_tier_info should return correct values for free tier.""" + info = self.selector.get_tier_info(UserTier.FREE) + + assert info["tier"] == "free" + assert info["model_name"] == "Llama 3 8B" + assert info["base_tokens"] == 256 + + # Test estimate_cost_per_request method + def test_free_tier_zero_cost(self): + """Free tier should have zero cost.""" + cost = self.selector.estimate_cost_per_request(UserTier.FREE) + assert cost == 0.0 + + def test_basic_tier_has_cost(self): + """Basic tier should have non-zero cost.""" + cost = self.selector.estimate_cost_per_request(UserTier.BASIC) + assert cost > 0 + + def test_premium_tier_higher_cost(self): + """Premium tier should have higher cost than basic.""" + basic_cost = self.selector.estimate_cost_per_request(UserTier.BASIC) + premium_cost = self.selector.estimate_cost_per_request(UserTier.PREMIUM) + assert premium_cost > basic_cost + + def test_elite_tier_highest_cost(self): + """Elite tier should have highest cost.""" + premium_cost = self.selector.estimate_cost_per_request(UserTier.PREMIUM) + elite_cost = self.selector.estimate_cost_per_request(UserTier.ELITE) + assert elite_cost > premium_cost + + # Test all tier combinations + def test_all_tiers_return_valid_config(self): + """All tiers should return valid ModelConfig objects.""" + for tier in UserTier: + config = self.selector.select_model(tier) + assert isinstance(config, ModelConfig) + assert config.model_type in ModelType + assert config.max_tokens > 0 + assert 0 <= config.temperature <= 1 + + # Test all context combinations + def test_all_contexts_return_valid_config(self): + """All context types should return valid ModelConfig objects.""" + for context in ContextType: + config = self.selector.select_model(UserTier.PREMIUM, context) + assert isinstance(config, ModelConfig) + assert config.max_tokens > 0 + assert 0 <= config.temperature <= 1 + + +class TestUserTierEnum: + """Tests for UserTier enum.""" + + def test_tier_values(self): + """Test UserTier enum values are correct strings.""" + assert UserTier.FREE.value == "free" + assert UserTier.BASIC.value == "basic" + assert UserTier.PREMIUM.value == "premium" + assert UserTier.ELITE.value == "elite" + + def test_tier_string_conversion(self): + """Test UserTier can be converted to string.""" + assert str(UserTier.FREE) == "UserTier.FREE" + + +class TestContextTypeEnum: + """Tests for ContextType enum.""" + + def test_context_values(self): + """Test ContextType enum values are correct strings.""" + assert ContextType.STORY_PROGRESSION.value == "story_progression" + assert ContextType.COMBAT_NARRATION.value == "combat_narration" + assert ContextType.QUEST_SELECTION.value == "quest_selection" + assert ContextType.NPC_DIALOGUE.value == "npc_dialogue" + assert ContextType.SIMPLE_RESPONSE.value == "simple_response" + + +class TestModelConfig: + """Tests for ModelConfig dataclass.""" + + def test_model_config_creation(self): + """Test ModelConfig can be created with valid data.""" + config = ModelConfig( + model_type=ModelType.CLAUDE_SONNET, + max_tokens=1024, + temperature=0.9 + ) + + assert config.model_type == ModelType.CLAUDE_SONNET + assert config.max_tokens == 1024 + assert config.temperature == 0.9 + + def test_model_property(self): + """Test model property returns model identifier.""" + config = ModelConfig( + model_type=ModelType.LLAMA_3_8B, + max_tokens=256, + temperature=0.7 + ) + + assert config.model == "meta/meta-llama-3-8b-instruct" diff --git a/api/tests/test_narrative_generator.py b/api/tests/test_narrative_generator.py new file mode 100644 index 0000000..c094b2e --- /dev/null +++ b/api/tests/test_narrative_generator.py @@ -0,0 +1,583 @@ +""" +Tests for the NarrativeGenerator wrapper. + +These tests use mocked AI clients to verify the generator's +behavior without making actual API calls. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from app.ai.narrative_generator import ( + NarrativeGenerator, + NarrativeResponse, + NarrativeGeneratorError, +) +from app.ai.replicate_client import ReplicateResponse, ReplicateClientError +from app.ai.model_selector import UserTier, ContextType, ModelSelector, ModelConfig, ModelType +from app.ai.prompt_templates import PromptTemplates + + +# Test fixtures +@pytest.fixture +def mock_replicate_client(): + """Create a mock Replicate client.""" + client = MagicMock() + client.generate.return_value = ReplicateResponse( + text="The tavern falls silent as you step through the doorway...", + tokens_used=150, + model="meta/meta-llama-3-8b-instruct", + generation_time=2.5 + ) + return client + + +@pytest.fixture +def generator(mock_replicate_client): + """Create a NarrativeGenerator with mocked dependencies.""" + return NarrativeGenerator(replicate_client=mock_replicate_client) + + +@pytest.fixture +def sample_character(): + """Sample character data for tests.""" + return { + "name": "Aldric", + "level": 3, + "player_class": "Fighter", + "current_hp": 25, + "max_hp": 30, + "stats": { + "strength": 16, + "dexterity": 12, + "constitution": 14, + "intelligence": 10, + "wisdom": 11, + "charisma": 8 + }, + "skills": [ + {"name": "Sword Mastery", "level": 2}, + {"name": "Shield Block", "level": 1} + ], + "effects": [], + "completed_quests": [] + } + + +@pytest.fixture +def sample_game_state(): + """Sample game state for tests.""" + return { + "current_location": "The Rusty Anchor Tavern", + "location_type": "TAVERN", + "discovered_locations": ["Crossroads Village", "Dark Forest"], + "active_quests": [], + "time_of_day": "Evening" + } + + +@pytest.fixture +def sample_combat_state(): + """Sample combat state for tests.""" + return { + "round_number": 2, + "current_turn": "player", + "enemies": [ + { + "name": "Goblin Scout", + "current_hp": 8, + "max_hp": 12, + "effects": [] + } + ] + } + + +@pytest.fixture +def sample_npc(): + """Sample NPC data for tests.""" + return { + "name": "Barkeep Magnus", + "role": "Tavern Owner", + "personality": "Gruff but kind-hearted", + "speaking_style": "Short sentences, occasional jokes", + "goals": "Keep the tavern running, help adventurers" + } + + +@pytest.fixture +def sample_quests(): + """Sample eligible quests for tests.""" + return [ + { + "quest_id": "goblin_cave", + "name": "Clear the Goblin Cave", + "difficulty": "EASY", + "quest_giver": "Village Elder", + "description": "A nearby cave has been overrun by goblins.", + "narrative_hooks": [ + "Farmers complain about stolen livestock", + "Goblin tracks spotted on the road" + ] + }, + { + "quest_id": "missing_merchant", + "name": "Find the Missing Merchant", + "difficulty": "MEDIUM", + "quest_giver": "Guild Master", + "description": "A merchant caravan has gone missing.", + "narrative_hooks": [ + "The merchant was carrying valuable goods", + "His family is worried sick" + ] + } + ] + + +class TestNarrativeGeneratorInit: + """Tests for NarrativeGenerator initialization.""" + + def test_init_with_defaults(self): + """Test initialization with default dependencies.""" + with patch('app.ai.narrative_generator.ReplicateClient'): + generator = NarrativeGenerator() + assert generator.model_selector is not None + assert generator.prompt_templates is not None + + def test_init_with_custom_selector(self): + """Test initialization with custom model selector.""" + custom_selector = ModelSelector() + generator = NarrativeGenerator(model_selector=custom_selector) + assert generator.model_selector is custom_selector + + def test_init_with_custom_client(self, mock_replicate_client): + """Test initialization with custom Replicate client.""" + generator = NarrativeGenerator(replicate_client=mock_replicate_client) + assert generator.replicate_client is mock_replicate_client + + +class TestGenerateStoryResponse: + """Tests for generate_story_response method.""" + + def test_basic_story_generation( + self, generator, mock_replicate_client, sample_character, sample_game_state + ): + """Test basic story response generation.""" + response = generator.generate_story_response( + character=sample_character, + action="I search the room for hidden doors", + game_state=sample_game_state, + user_tier=UserTier.FREE + ) + + assert isinstance(response, NarrativeResponse) + assert response.narrative is not None + assert response.tokens_used > 0 + assert response.context_type == "story_progression" + assert response.generation_time > 0 + + # Verify client was called + mock_replicate_client.generate.assert_called_once() + + def test_story_with_conversation_history( + self, generator, mock_replicate_client, sample_character, sample_game_state + ): + """Test story generation with conversation history.""" + history = [ + { + "turn": 1, + "action": "I enter the tavern", + "dm_response": "The tavern is warm and inviting..." + }, + { + "turn": 2, + "action": "I approach the bar", + "dm_response": "The barkeep nods in greeting..." + } + ] + + response = generator.generate_story_response( + character=sample_character, + action="I ask about local rumors", + game_state=sample_game_state, + user_tier=UserTier.PREMIUM, + conversation_history=history + ) + + assert isinstance(response, NarrativeResponse) + mock_replicate_client.generate.assert_called_once() + + def test_story_with_world_context( + self, generator, mock_replicate_client, sample_character, sample_game_state + ): + """Test story generation with additional world context.""" + response = generator.generate_story_response( + character=sample_character, + action="I look around", + game_state=sample_game_state, + user_tier=UserTier.ELITE, + world_context="A festival is being celebrated in the village" + ) + + assert isinstance(response, NarrativeResponse) + + def test_story_uses_correct_model_for_tier( + self, generator, mock_replicate_client, sample_character, sample_game_state + ): + """Test that correct model is selected based on tier.""" + for tier in UserTier: + mock_replicate_client.generate.reset_mock() + + generator.generate_story_response( + character=sample_character, + action="Test action", + game_state=sample_game_state, + user_tier=tier + ) + + # Verify generate was called with appropriate model + call_kwargs = mock_replicate_client.generate.call_args + assert call_kwargs is not None + + def test_story_handles_client_error( + self, generator, mock_replicate_client, sample_character, sample_game_state + ): + """Test error handling when client fails.""" + mock_replicate_client.generate.side_effect = ReplicateClientError("API error") + + with pytest.raises(NarrativeGeneratorError) as exc_info: + generator.generate_story_response( + character=sample_character, + action="Test action", + game_state=sample_game_state, + user_tier=UserTier.FREE + ) + + assert "AI generation failed" in str(exc_info.value) + + +class TestGenerateCombatNarration: + """Tests for generate_combat_narration method.""" + + def test_basic_combat_narration( + self, generator, mock_replicate_client, sample_character, sample_combat_state + ): + """Test basic combat narration generation.""" + action_result = { + "hit": True, + "damage": 8, + "target": "Goblin Scout" + } + + response = generator.generate_combat_narration( + character=sample_character, + combat_state=sample_combat_state, + action="swings their sword", + action_result=action_result, + user_tier=UserTier.BASIC + ) + + assert isinstance(response, NarrativeResponse) + assert response.context_type == "combat_narration" + + def test_critical_hit_narration( + self, generator, mock_replicate_client, sample_character, sample_combat_state + ): + """Test combat narration for critical hit.""" + action_result = { + "hit": True, + "damage": 16, + "target": "Goblin Scout" + } + + response = generator.generate_combat_narration( + character=sample_character, + combat_state=sample_combat_state, + action="strikes with precision", + action_result=action_result, + user_tier=UserTier.PREMIUM, + is_critical=True + ) + + assert isinstance(response, NarrativeResponse) + + def test_finishing_blow_narration( + self, generator, mock_replicate_client, sample_character, sample_combat_state + ): + """Test combat narration for finishing blow.""" + action_result = { + "hit": True, + "damage": 10, + "target": "Goblin Scout" + } + + response = generator.generate_combat_narration( + character=sample_character, + combat_state=sample_combat_state, + action="delivers the final blow", + action_result=action_result, + user_tier=UserTier.ELITE, + is_finishing_blow=True + ) + + assert isinstance(response, NarrativeResponse) + + def test_miss_narration( + self, generator, mock_replicate_client, sample_character, sample_combat_state + ): + """Test combat narration for a miss.""" + action_result = { + "hit": False, + "damage": 0, + "target": "Goblin Scout" + } + + response = generator.generate_combat_narration( + character=sample_character, + combat_state=sample_combat_state, + action="swings wildly", + action_result=action_result, + user_tier=UserTier.FREE + ) + + assert isinstance(response, NarrativeResponse) + + +class TestGenerateQuestSelection: + """Tests for generate_quest_selection method.""" + + def test_basic_quest_selection( + self, generator, mock_replicate_client, sample_character, sample_game_state, sample_quests + ): + """Test basic quest selection.""" + # Mock response to return a valid quest_id + mock_replicate_client.generate.return_value = ReplicateResponse( + text="goblin_cave", + tokens_used=50, + model="meta/meta-llama-3-8b-instruct", + generation_time=1.0 + ) + + quest_id = generator.generate_quest_selection( + character=sample_character, + eligible_quests=sample_quests, + game_context=sample_game_state, + user_tier=UserTier.FREE + ) + + assert quest_id == "goblin_cave" + + def test_quest_selection_with_recent_actions( + self, generator, mock_replicate_client, sample_character, sample_game_state, sample_quests + ): + """Test quest selection with recent actions.""" + mock_replicate_client.generate.return_value = ReplicateResponse( + text="missing_merchant", + tokens_used=50, + model="meta/meta-llama-3-8b-instruct", + generation_time=1.0 + ) + + recent_actions = [ + "Asked about missing traders", + "Investigated the market square" + ] + + quest_id = generator.generate_quest_selection( + character=sample_character, + eligible_quests=sample_quests, + game_context=sample_game_state, + user_tier=UserTier.PREMIUM, + recent_actions=recent_actions + ) + + assert quest_id == "missing_merchant" + + def test_quest_selection_invalid_response_fallback( + self, generator, mock_replicate_client, sample_character, sample_game_state, sample_quests + ): + """Test fallback when AI returns invalid quest_id.""" + # Mock response with invalid quest_id + mock_replicate_client.generate.return_value = ReplicateResponse( + text="invalid_quest_id", + tokens_used=50, + model="meta/meta-llama-3-8b-instruct", + generation_time=1.0 + ) + + quest_id = generator.generate_quest_selection( + character=sample_character, + eligible_quests=sample_quests, + game_context=sample_game_state, + user_tier=UserTier.FREE + ) + + # Should fall back to first eligible quest + assert quest_id == "goblin_cave" + + def test_quest_selection_no_quests_error( + self, generator, sample_character, sample_game_state + ): + """Test error when no eligible quests provided.""" + with pytest.raises(NarrativeGeneratorError) as exc_info: + generator.generate_quest_selection( + character=sample_character, + eligible_quests=[], + game_context=sample_game_state, + user_tier=UserTier.FREE + ) + + assert "No eligible quests" in str(exc_info.value) + + +class TestGenerateNPCDialogue: + """Tests for generate_npc_dialogue method.""" + + def test_basic_npc_dialogue( + self, generator, mock_replicate_client, sample_character, sample_npc, sample_game_state + ): + """Test basic NPC dialogue generation.""" + mock_replicate_client.generate.return_value = ReplicateResponse( + text='*wipes down the bar* "Aye, rumors aplenty around here..."', + tokens_used=100, + model="anthropic/claude-3.5-haiku", + generation_time=1.5 + ) + + response = generator.generate_npc_dialogue( + character=sample_character, + npc=sample_npc, + conversation_topic="What rumors have you heard?", + game_state=sample_game_state, + user_tier=UserTier.BASIC + ) + + assert isinstance(response, NarrativeResponse) + assert response.context_type == "npc_dialogue" + + def test_npc_dialogue_with_relationship( + self, generator, mock_replicate_client, sample_character, sample_npc, sample_game_state + ): + """Test NPC dialogue with established relationship.""" + response = generator.generate_npc_dialogue( + character=sample_character, + npc=sample_npc, + conversation_topic="Hello old friend", + game_state=sample_game_state, + user_tier=UserTier.PREMIUM, + npc_relationship="Friendly - helped defend the tavern last month" + ) + + assert isinstance(response, NarrativeResponse) + + def test_npc_dialogue_with_previous_conversation( + self, generator, mock_replicate_client, sample_character, sample_npc, sample_game_state + ): + """Test NPC dialogue with previous conversation history.""" + previous = [ + { + "player_line": "What's on tap tonight?", + "npc_response": "Got some fine ale from the southern vineyards." + } + ] + + response = generator.generate_npc_dialogue( + character=sample_character, + npc=sample_npc, + conversation_topic="I'll take one", + game_state=sample_game_state, + user_tier=UserTier.FREE, + previous_dialogue=previous + ) + + assert isinstance(response, NarrativeResponse) + + def test_npc_dialogue_with_special_knowledge( + self, generator, mock_replicate_client, sample_character, sample_npc, sample_game_state + ): + """Test NPC dialogue with special knowledge.""" + response = generator.generate_npc_dialogue( + character=sample_character, + npc=sample_npc, + conversation_topic="Have you seen anything strange?", + game_state=sample_game_state, + user_tier=UserTier.ELITE, + npc_knowledge=["Secret passage in the cellar", "Hidden treasure map"] + ) + + assert isinstance(response, NarrativeResponse) + + +class TestModelSelection: + """Tests for model selection behavior.""" + + def test_free_tier_uses_llama( + self, generator, mock_replicate_client, sample_character, sample_game_state + ): + """Test that free tier uses Llama model.""" + generator.generate_story_response( + character=sample_character, + action="Test", + game_state=sample_game_state, + user_tier=UserTier.FREE + ) + + call_kwargs = mock_replicate_client.generate.call_args + assert call_kwargs is not None + # Model type should be Llama for free tier + model_arg = call_kwargs.kwargs.get('model') + if model_arg: + assert "llama" in str(model_arg).lower() or model_arg == ModelType.LLAMA_3_8B + + def test_different_contexts_use_different_temperatures(self, generator): + """Test that different contexts have different temperature settings.""" + # Get model configs for different contexts + config_story = generator.model_selector.select_model( + UserTier.FREE, ContextType.STORY_PROGRESSION + ) + config_quest = generator.model_selector.select_model( + UserTier.FREE, ContextType.QUEST_SELECTION + ) + + # Story should have higher temperature (more creative) + assert config_story.temperature > config_quest.temperature + + +class TestErrorHandling: + """Tests for error handling behavior.""" + + def test_template_error_handling(self, mock_replicate_client): + """Test handling of template errors.""" + from app.ai.prompt_templates import PromptTemplateError + + # Create generator with bad template path + with patch.object(PromptTemplates, 'render') as mock_render: + mock_render.side_effect = PromptTemplateError("Template not found") + + generator = NarrativeGenerator(replicate_client=mock_replicate_client) + + with pytest.raises(NarrativeGeneratorError) as exc_info: + generator.generate_story_response( + character={"name": "Test"}, + action="Test", + game_state={"current_location": "Test"}, + user_tier=UserTier.FREE + ) + + assert "Prompt template error" in str(exc_info.value) + + def test_api_error_handling( + self, generator, mock_replicate_client, sample_character, sample_game_state + ): + """Test handling of API errors.""" + mock_replicate_client.generate.side_effect = ReplicateClientError("Connection failed") + + with pytest.raises(NarrativeGeneratorError) as exc_info: + generator.generate_story_response( + character=sample_character, + action="Test", + game_state=sample_game_state, + user_tier=UserTier.FREE + ) + + assert "AI generation failed" in str(exc_info.value) diff --git a/api/tests/test_origin_service.py b/api/tests/test_origin_service.py new file mode 100644 index 0000000..26a6b24 --- /dev/null +++ b/api/tests/test_origin_service.py @@ -0,0 +1,200 @@ +""" +Unit tests for OriginService - character origin loading and validation. + +Tests verify that origins load correctly from YAML, contain all required +data, and provide proper narrative hooks for the AI DM. +""" + +import pytest +from app.services.origin_service import OriginService, get_origin_service +from app.models.origins import Origin, StartingLocation, StartingBonus + + +class TestOriginService: + """Test suite for OriginService functionality.""" + + @pytest.fixture + def origin_service(self): + """Create a fresh OriginService instance for each test.""" + service = OriginService() + service.clear_cache() + return service + + def test_service_initializes(self, origin_service): + """Test that OriginService initializes correctly.""" + assert origin_service is not None + assert origin_service.data_file.exists() + + def test_singleton_pattern(self): + """Test that get_origin_service returns a singleton.""" + service1 = get_origin_service() + service2 = get_origin_service() + assert service1 is service2 + + def test_load_all_origins(self, origin_service): + """Test loading all origins from YAML file.""" + origins = origin_service.load_all_origins() + + assert len(origins) == 4 + origin_ids = [origin.id for origin in origins] + assert "soul_revenant" in origin_ids + assert "memory_thief" in origin_ids + assert "shadow_apprentice" in origin_ids + assert "escaped_captive" in origin_ids + + def test_load_origin_by_id(self, origin_service): + """Test loading a specific origin by ID.""" + origin = origin_service.load_origin("soul_revenant") + + assert origin is not None + assert origin.id == "soul_revenant" + assert origin.name == "Soul Revenant" + assert isinstance(origin.description, str) + assert len(origin.description) > 0 + + def test_origin_not_found(self, origin_service): + """Test that loading non-existent origin returns None.""" + origin = origin_service.load_origin("nonexistent_origin") + assert origin is None + + def test_origin_has_starting_location(self, origin_service): + """Test that all origins have valid starting locations.""" + origins = origin_service.load_all_origins() + + for origin in origins: + assert origin.starting_location is not None + assert isinstance(origin.starting_location, StartingLocation) + assert origin.starting_location.id + assert origin.starting_location.name + assert origin.starting_location.region + assert origin.starting_location.description + + def test_origin_has_narrative_hooks(self, origin_service): + """Test that all origins have narrative hooks for AI DM.""" + origins = origin_service.load_all_origins() + + for origin in origins: + assert origin.narrative_hooks is not None + assert isinstance(origin.narrative_hooks, list) + assert len(origin.narrative_hooks) > 0, f"{origin.id} has no narrative hooks" + + def test_origin_has_starting_bonus(self, origin_service): + """Test that all origins have starting bonuses.""" + origins = origin_service.load_all_origins() + + for origin in origins: + assert origin.starting_bonus is not None + assert isinstance(origin.starting_bonus, StartingBonus) + assert origin.starting_bonus.trait + assert origin.starting_bonus.description + assert origin.starting_bonus.effect + + def test_soul_revenant_details(self, origin_service): + """Test Soul Revenant origin has correct details.""" + origin = origin_service.load_origin("soul_revenant") + + assert origin.name == "Soul Revenant" + assert "centuries" in origin.description.lower() + assert origin.starting_location.id == "forgotten_crypt" + assert "Deathless Resolve" in origin.starting_bonus.trait + assert any("past" in hook.lower() for hook in origin.narrative_hooks) + + def test_memory_thief_details(self, origin_service): + """Test Memory Thief origin has correct details.""" + origin = origin_service.load_origin("memory_thief") + + assert origin.name == "Memory Thief" + assert "memory" in origin.description.lower() + assert origin.starting_location.id == "thornfield_plains" + assert "Blank Slate" in origin.starting_bonus.trait + assert any("memory" in hook.lower() for hook in origin.narrative_hooks) + + def test_shadow_apprentice_details(self, origin_service): + """Test Shadow Apprentice origin has correct details.""" + origin = origin_service.load_origin("shadow_apprentice") + + assert origin.name == "Shadow Apprentice" + assert "master" in origin.description.lower() + assert origin.starting_location.id == "shadowfen" + assert "Trained in Shadows" in origin.starting_bonus.trait + assert any("master" in hook.lower() for hook in origin.narrative_hooks) + + def test_escaped_captive_details(self, origin_service): + """Test Escaped Captive origin has correct details.""" + origin = origin_service.load_origin("escaped_captive") + + assert origin.name == "The Escaped Captive" + assert "prison" in origin.description.lower() or "ironpeak" in origin.description.lower() + assert origin.starting_location.id == "ironpeak_pass" + assert "Hardened Survivor" in origin.starting_bonus.trait + assert any("prison" in hook.lower() or "past" in hook.lower() for hook in origin.narrative_hooks) + + def test_origin_serialization(self, origin_service): + """Test that origins can be serialized to dict and back.""" + original = origin_service.load_origin("soul_revenant") + + # Serialize to dict + origin_dict = original.to_dict() + assert isinstance(origin_dict, dict) + assert origin_dict["id"] == "soul_revenant" + assert origin_dict["name"] == "Soul Revenant" + + # Deserialize from dict + restored = Origin.from_dict(origin_dict) + assert restored.id == original.id + assert restored.name == original.name + assert restored.description == original.description + assert restored.starting_location.id == original.starting_location.id + assert restored.starting_bonus.trait == original.starting_bonus.trait + + def test_caching_works(self, origin_service): + """Test that caching improves performance on repeated loads.""" + # First load + origin1 = origin_service.load_origin("soul_revenant") + + # Second load should come from cache + origin2 = origin_service.load_origin("soul_revenant") + + # Should be the exact same instance from cache + assert origin1 is origin2 + + def test_cache_clear(self, origin_service): + """Test that cache clearing works correctly.""" + # Load origins to populate cache + origin_service.load_all_origins() + assert len(origin_service._origins_cache) > 0 + + # Clear cache + origin_service.clear_cache() + assert len(origin_service._origins_cache) == 0 + assert origin_service._all_origins_loaded is False + + def test_reload_origins(self, origin_service): + """Test that reloading clears cache and reloads from file.""" + # Load origins + origins1 = origin_service.load_all_origins() + + # Reload + origins2 = origin_service.reload_origins() + + # Should have same content but be fresh instances + assert len(origins1) == len(origins2) + assert all(o1.id == o2.id for o1, o2 in zip(origins1, origins2)) + + def test_get_all_origin_ids(self, origin_service): + """Test getting list of all origin IDs.""" + origin_ids = origin_service.get_all_origin_ids() + + assert isinstance(origin_ids, list) + assert len(origin_ids) == 4 + assert "soul_revenant" in origin_ids + assert "memory_thief" in origin_ids + assert "shadow_apprentice" in origin_ids + assert "escaped_captive" in origin_ids + + def test_get_origin_by_id_alias(self, origin_service): + """Test that get_origin_by_id is an alias for load_origin.""" + origin1 = origin_service.load_origin("soul_revenant") + origin2 = origin_service.get_origin_by_id("soul_revenant") + + assert origin1 is origin2 diff --git a/api/tests/test_prompt_templates.py b/api/tests/test_prompt_templates.py new file mode 100644 index 0000000..f266929 --- /dev/null +++ b/api/tests/test_prompt_templates.py @@ -0,0 +1,321 @@ +""" +Unit tests for prompt templates module. +""" + +import pytest +from pathlib import Path + +from app.ai import ( + PromptTemplates, + PromptTemplateError, + get_prompt_templates, + render_prompt, +) + + +class TestPromptTemplates: + """Tests for PromptTemplates class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.templates = PromptTemplates() + + def test_initialization(self): + """Test PromptTemplates initializes correctly.""" + assert self.templates is not None + assert self.templates.env is not None + + def test_template_directory_exists(self): + """Test that template directory is created.""" + assert self.templates.template_dir.exists() + + def test_get_template_names(self): + """Test listing available templates.""" + names = self.templates.get_template_names() + assert isinstance(names, list) + # Should have our 4 core templates + assert 'story_action.j2' in names + assert 'combat_action.j2' in names + assert 'quest_offering.j2' in names + assert 'npc_dialogue.j2' in names + + def test_render_string_simple(self): + """Test rendering a simple template string.""" + result = self.templates.render_string( + "Hello, {{ name }}!", + name="Player" + ) + assert result == "Hello, Player!" + + def test_render_string_with_filter(self): + """Test rendering with custom filter.""" + result = self.templates.render_string( + "{{ items | format_inventory }}", + items=[ + {"name": "Sword", "quantity": 1}, + {"name": "Potion", "quantity": 3} + ] + ) + assert "Sword" in result + assert "Potion (x3)" in result + + def test_render_story_action_template(self): + """Test rendering story_action template.""" + result = self.templates.render( + "story_action.j2", + character={ + "name": "Aldric", + "level": 5, + "player_class": "Warrior", + "current_hp": 45, + "max_hp": 50, + "stats": {"strength": 16, "dexterity": 12}, + "skills": [], + "effects": [] + }, + game_state={ + "current_location": "The Rusty Anchor", + "location_type": "TAVERN", + "active_quests": [], + "discovered_locations": [] + }, + action="I look around the tavern for anyone suspicious" + ) + + assert "Aldric" in result + assert "Warrior" in result + assert "Rusty Anchor" in result + assert "suspicious" in result + + def test_render_combat_action_template(self): + """Test rendering combat_action template.""" + result = self.templates.render( + "combat_action.j2", + character={ + "name": "Aldric", + "level": 5, + "player_class": "Warrior", + "current_hp": 45, + "max_hp": 50, + "effects": [] + }, + combat_state={ + "round_number": 2, + "current_turn": "Player", + "enemies": [ + { + "name": "Goblin", + "current_hp": 8, + "max_hp": 15, + "effects": [] + } + ] + }, + action="swings their sword at the Goblin", + action_result={ + "hit": True, + "damage": 7, + "effects_applied": [] + }, + is_critical=False, + is_finishing_blow=False + ) + + assert "Aldric" in result + assert "Goblin" in result + assert "sword" in result + + def test_render_quest_offering_template(self): + """Test rendering quest_offering template.""" + result = self.templates.render( + "quest_offering.j2", + character={ + "name": "Aldric", + "level": 3, + "player_class": "Warrior", + "completed_quests": [] + }, + game_context={ + "current_location": "Village Square", + "location_type": "TOWN", + "active_quests": [], + "world_events": [] + }, + eligible_quests=[ + { + "quest_id": "quest_goblin_cave", + "name": "Clear the Goblin Cave", + "difficulty": "EASY", + "quest_giver": "Village Elder", + "description": "Goblins have been raiding farms", + "narrative_hooks": [ + "Farmers complaining about lost livestock" + ] + } + ], + recent_actions=["Talked to locals"] + ) + + assert "quest_goblin_cave" in result + assert "Clear the Goblin Cave" in result + assert "Village Elder" in result + + def test_render_npc_dialogue_template(self): + """Test rendering npc_dialogue template.""" + result = self.templates.render( + "npc_dialogue.j2", + character={ + "name": "Aldric", + "level": 5, + "player_class": "Warrior" + }, + npc={ + "name": "Grizzled Bartender", + "role": "Tavern Owner", + "personality": "Gruff but kind", + "speaking_style": "Short sentences, common slang" + }, + conversation_topic="What's the latest news around here?", + game_state={ + "current_location": "The Rusty Anchor", + "time_of_day": "Evening", + "active_quests": [] + } + ) + + assert "Grizzled Bartender" in result + assert "Aldric" in result + assert "news" in result + + def test_format_inventory_filter_empty(self): + """Test format_inventory filter with empty list.""" + result = PromptTemplates._format_inventory([]) + assert result == "Empty inventory" + + def test_format_inventory_filter_single(self): + """Test format_inventory filter with single item.""" + result = PromptTemplates._format_inventory([ + {"name": "Sword", "quantity": 1} + ]) + assert result == "Sword" + + def test_format_inventory_filter_multiple(self): + """Test format_inventory filter with multiple items.""" + result = PromptTemplates._format_inventory([ + {"name": "Sword", "quantity": 1}, + {"name": "Shield", "quantity": 1}, + {"name": "Potion", "quantity": 5} + ]) + assert "Sword" in result + assert "Shield" in result + assert "Potion (x5)" in result + + def test_format_inventory_filter_truncation(self): + """Test format_inventory filter truncates long lists.""" + items = [{"name": f"Item{i}", "quantity": 1} for i in range(15)] + result = PromptTemplates._format_inventory(items, max_items=10) + assert "and 5 more items" in result + + def test_format_stats_filter(self): + """Test format_stats filter.""" + result = PromptTemplates._format_stats({ + "strength": 16, + "dexterity": 14 + }) + assert "Strength: 16" in result + assert "Dexterity: 14" in result + + def test_format_stats_filter_empty(self): + """Test format_stats filter with empty dict.""" + result = PromptTemplates._format_stats({}) + assert result == "No stats available" + + def test_format_skills_filter(self): + """Test format_skills filter.""" + result = PromptTemplates._format_skills([ + {"name": "Sword Mastery", "level": 3}, + {"name": "Shield Block", "level": 2} + ]) + assert "Sword Mastery (Lv.3)" in result + assert "Shield Block (Lv.2)" in result + + def test_format_skills_filter_empty(self): + """Test format_skills filter with empty list.""" + result = PromptTemplates._format_skills([]) + assert result == "No skills" + + def test_format_effects_filter(self): + """Test format_effects filter.""" + result = PromptTemplates._format_effects([ + {"name": "Blessed", "remaining_turns": 3}, + {"name": "Strength Buff"} + ]) + assert "Blessed (3 turns)" in result + assert "Strength Buff" in result + + def test_format_effects_filter_empty(self): + """Test format_effects filter with empty list.""" + result = PromptTemplates._format_effects([]) + assert result == "No active effects" + + def test_truncate_text_filter_short(self): + """Test truncate_text filter with short text.""" + result = PromptTemplates._truncate_text("Hello", 100) + assert result == "Hello" + + def test_truncate_text_filter_long(self): + """Test truncate_text filter with long text.""" + long_text = "A" * 150 + result = PromptTemplates._truncate_text(long_text, 100) + assert len(result) == 100 + assert result.endswith("...") + + def test_format_gold_filter(self): + """Test format_gold filter.""" + assert PromptTemplates._format_gold(1000) == "1,000 gold" + assert PromptTemplates._format_gold(1000000) == "1,000,000 gold" + assert PromptTemplates._format_gold(50) == "50 gold" + + def test_invalid_template_raises_error(self): + """Test that invalid template raises PromptTemplateError.""" + with pytest.raises(PromptTemplateError): + self.templates.render("nonexistent_template.j2") + + def test_invalid_template_string_raises_error(self): + """Test that invalid template string raises PromptTemplateError.""" + with pytest.raises(PromptTemplateError): + self.templates.render_string("{{ invalid syntax") + + +class TestPromptTemplateConvenienceFunctions: + """Tests for module-level convenience functions.""" + + def test_get_prompt_templates_singleton(self): + """Test get_prompt_templates returns singleton.""" + templates1 = get_prompt_templates() + templates2 = get_prompt_templates() + assert templates1 is templates2 + + def test_render_prompt_function(self): + """Test render_prompt convenience function.""" + result = render_prompt( + "story_action.j2", + character={ + "name": "Test", + "level": 1, + "player_class": "Warrior", + "current_hp": 10, + "max_hp": 10, + "stats": {}, + "skills": [], + "effects": [] + }, + game_state={ + "current_location": "Test Location", + "location_type": "TOWN", + "active_quests": [] + }, + action="test action" + ) + assert "Test" in result + assert "test action" in result diff --git a/api/tests/test_rate_limiter_service.py b/api/tests/test_rate_limiter_service.py new file mode 100644 index 0000000..631d2fd --- /dev/null +++ b/api/tests/test_rate_limiter_service.py @@ -0,0 +1,342 @@ +""" +Tests for RateLimiterService + +Tests the tier-based rate limiting functionality including: +- Daily limits per tier +- Usage tracking and incrementing +- Rate limit checks and exceptions +- Reset functionality +""" + +import pytest +from unittest.mock import MagicMock, patch +from datetime import datetime, timezone, timedelta + +from app.services.rate_limiter_service import ( + RateLimiterService, + RateLimitExceeded, +) +from app.ai.model_selector import UserTier + + +class TestRateLimiterService: + """Tests for RateLimiterService.""" + + @pytest.fixture + def mock_redis(self): + """Create a mock Redis service.""" + mock = MagicMock() + mock.get.return_value = None + mock.incr.return_value = 1 + mock.expire.return_value = True + mock.delete.return_value = 1 + return mock + + @pytest.fixture + def rate_limiter(self, mock_redis): + """Create a RateLimiterService with mock Redis.""" + return RateLimiterService(redis_service=mock_redis) + + def test_tier_limits(self, rate_limiter): + """Test that tier limits are correctly defined.""" + assert rate_limiter.get_limit_for_tier(UserTier.FREE) == 20 + assert rate_limiter.get_limit_for_tier(UserTier.BASIC) == 50 + assert rate_limiter.get_limit_for_tier(UserTier.PREMIUM) == 100 + assert rate_limiter.get_limit_for_tier(UserTier.ELITE) == 200 + + def test_get_daily_key(self, rate_limiter): + """Test Redis key generation.""" + from datetime import date + + key = rate_limiter._get_daily_key("user_123", date(2025, 1, 15)) + assert key == "rate_limit:daily:user_123:2025-01-15" + + def test_get_current_usage_no_usage(self, rate_limiter, mock_redis): + """Test getting current usage when no usage exists.""" + mock_redis.get.return_value = None + + usage = rate_limiter.get_current_usage("user_123") + + assert usage == 0 + mock_redis.get.assert_called_once() + + def test_get_current_usage_with_usage(self, rate_limiter, mock_redis): + """Test getting current usage when usage exists.""" + mock_redis.get.return_value = "15" + + usage = rate_limiter.get_current_usage("user_123") + + assert usage == 15 + + def test_check_rate_limit_under_limit(self, rate_limiter, mock_redis): + """Test that check passes when under limit.""" + mock_redis.get.return_value = "10" + + # Should not raise + rate_limiter.check_rate_limit("user_123", UserTier.FREE) + + def test_check_rate_limit_at_limit(self, rate_limiter, mock_redis): + """Test that check fails when at limit.""" + mock_redis.get.return_value = "20" # Free tier limit + + with pytest.raises(RateLimitExceeded) as exc_info: + rate_limiter.check_rate_limit("user_123", UserTier.FREE) + + exc = exc_info.value + assert exc.user_id == "user_123" + assert exc.user_tier == UserTier.FREE + assert exc.limit == 20 + assert exc.current_usage == 20 + + def test_check_rate_limit_over_limit(self, rate_limiter, mock_redis): + """Test that check fails when over limit.""" + mock_redis.get.return_value = "25" # Over free tier limit + + with pytest.raises(RateLimitExceeded): + rate_limiter.check_rate_limit("user_123", UserTier.FREE) + + def test_check_rate_limit_premium_tier(self, rate_limiter, mock_redis): + """Test that premium tier has higher limit.""" + mock_redis.get.return_value = "50" # Over free limit, under premium + + # Should not raise for premium + rate_limiter.check_rate_limit("user_123", UserTier.PREMIUM) + + # Should raise for free + with pytest.raises(RateLimitExceeded): + rate_limiter.check_rate_limit("user_123", UserTier.FREE) + + def test_increment_usage_first_time(self, rate_limiter, mock_redis): + """Test incrementing usage for the first time (sets expiration).""" + mock_redis.incr.return_value = 1 + + new_count = rate_limiter.increment_usage("user_123") + + assert new_count == 1 + mock_redis.incr.assert_called_once() + mock_redis.expire.assert_called_once() # Should set expiration + + def test_increment_usage_subsequent(self, rate_limiter, mock_redis): + """Test incrementing usage after first time (no expiration set).""" + mock_redis.incr.return_value = 5 + + new_count = rate_limiter.increment_usage("user_123") + + assert new_count == 5 + mock_redis.incr.assert_called_once() + mock_redis.expire.assert_not_called() # Should NOT set expiration + + def test_get_remaining_turns_full(self, rate_limiter, mock_redis): + """Test remaining turns when no usage.""" + mock_redis.get.return_value = None + + remaining = rate_limiter.get_remaining_turns("user_123", UserTier.FREE) + + assert remaining == 20 + + def test_get_remaining_turns_partial(self, rate_limiter, mock_redis): + """Test remaining turns with partial usage.""" + mock_redis.get.return_value = "12" + + remaining = rate_limiter.get_remaining_turns("user_123", UserTier.FREE) + + assert remaining == 8 + + def test_get_remaining_turns_exhausted(self, rate_limiter, mock_redis): + """Test remaining turns when limit reached.""" + mock_redis.get.return_value = "20" + + remaining = rate_limiter.get_remaining_turns("user_123", UserTier.FREE) + + assert remaining == 0 + + def test_get_remaining_turns_over_limit(self, rate_limiter, mock_redis): + """Test remaining turns when over limit (should be 0, not negative).""" + mock_redis.get.return_value = "25" + + remaining = rate_limiter.get_remaining_turns("user_123", UserTier.FREE) + + assert remaining == 0 + + def test_get_usage_info(self, rate_limiter, mock_redis): + """Test getting comprehensive usage info.""" + mock_redis.get.return_value = "15" + + info = rate_limiter.get_usage_info("user_123", UserTier.FREE) + + assert info["user_id"] == "user_123" + assert info["user_tier"] == "free" + assert info["current_usage"] == 15 + assert info["daily_limit"] == 20 + assert info["remaining"] == 5 + assert info["is_limited"] is False + assert "reset_time" in info + + def test_get_usage_info_limited(self, rate_limiter, mock_redis): + """Test usage info when limited.""" + mock_redis.get.return_value = "20" + + info = rate_limiter.get_usage_info("user_123", UserTier.FREE) + + assert info["is_limited"] is True + assert info["remaining"] == 0 + + def test_reset_usage(self, rate_limiter, mock_redis): + """Test resetting usage counter.""" + mock_redis.delete.return_value = 1 + + result = rate_limiter.reset_usage("user_123") + + assert result is True + mock_redis.delete.assert_called_once() + + def test_reset_usage_no_key(self, rate_limiter, mock_redis): + """Test resetting when no usage exists.""" + mock_redis.delete.return_value = 0 + + result = rate_limiter.reset_usage("user_123") + + assert result is False + + +class TestRateLimitExceeded: + """Tests for RateLimitExceeded exception.""" + + def test_exception_attributes(self): + """Test that exception has correct attributes.""" + reset_time = datetime(2025, 1, 16, 0, 0, 0, tzinfo=timezone.utc) + + exc = RateLimitExceeded( + user_id="user_123", + user_tier=UserTier.FREE, + limit=20, + current_usage=20, + reset_time=reset_time + ) + + assert exc.user_id == "user_123" + assert exc.user_tier == UserTier.FREE + assert exc.limit == 20 + assert exc.current_usage == 20 + assert exc.reset_time == reset_time + + def test_exception_message(self): + """Test that exception message is formatted correctly.""" + reset_time = datetime(2025, 1, 16, 0, 0, 0, tzinfo=timezone.utc) + + exc = RateLimitExceeded( + user_id="user_123", + user_tier=UserTier.FREE, + limit=20, + current_usage=20, + reset_time=reset_time + ) + + message = str(exc) + assert "user_123" in message + assert "free tier" in message + assert "20/20" in message + + +class TestRateLimiterIntegration: + """Integration tests for rate limiter workflow.""" + + @pytest.fixture + def mock_redis(self): + """Create a mock Redis that simulates real behavior.""" + storage = {} + + mock = MagicMock() + + def mock_get(key): + return storage.get(key) + + def mock_incr(key): + if key not in storage: + storage[key] = 0 + storage[key] = int(storage[key]) + 1 + return storage[key] + + def mock_delete(key): + if key in storage: + del storage[key] + return 1 + return 0 + + mock.get.side_effect = mock_get + mock.incr.side_effect = mock_incr + mock.delete.side_effect = mock_delete + mock.expire.return_value = True + + return mock + + @pytest.fixture + def rate_limiter(self, mock_redis): + """Create rate limiter with simulated Redis.""" + return RateLimiterService(redis_service=mock_redis) + + def test_full_workflow(self, rate_limiter): + """Test complete rate limiting workflow.""" + user_id = "user_123" + tier = UserTier.FREE # 20 turns/day + + # Initial state - should pass + rate_limiter.check_rate_limit(user_id, tier) + assert rate_limiter.get_remaining_turns(user_id, tier) == 20 + + # Use some turns + for i in range(15): + rate_limiter.check_rate_limit(user_id, tier) + rate_limiter.increment_usage(user_id) + + # Check remaining + assert rate_limiter.get_remaining_turns(user_id, tier) == 5 + + # Use remaining turns + for i in range(5): + rate_limiter.check_rate_limit(user_id, tier) + rate_limiter.increment_usage(user_id) + + # Now should be limited + assert rate_limiter.get_remaining_turns(user_id, tier) == 0 + + with pytest.raises(RateLimitExceeded): + rate_limiter.check_rate_limit(user_id, tier) + + def test_different_tiers_same_usage(self, rate_limiter): + """Test that same usage affects different tiers differently.""" + user_id = "user_123" + + # Use 30 turns + for _ in range(30): + rate_limiter.increment_usage(user_id) + + # Free tier (20) should be limited + with pytest.raises(RateLimitExceeded): + rate_limiter.check_rate_limit(user_id, UserTier.FREE) + + # Basic tier (50) should not be limited + rate_limiter.check_rate_limit(user_id, UserTier.BASIC) + + # Premium tier (100) should not be limited + rate_limiter.check_rate_limit(user_id, UserTier.PREMIUM) + + def test_reset_clears_usage(self, rate_limiter): + """Test that reset allows new usage.""" + user_id = "user_123" + tier = UserTier.FREE + + # Use all turns + for _ in range(20): + rate_limiter.increment_usage(user_id) + + # Should be limited + with pytest.raises(RateLimitExceeded): + rate_limiter.check_rate_limit(user_id, tier) + + # Reset usage + rate_limiter.reset_usage(user_id) + + # Should be able to use again + rate_limiter.check_rate_limit(user_id, tier) + assert rate_limiter.get_remaining_turns(user_id, tier) == 20 diff --git a/api/tests/test_redis_service.py b/api/tests/test_redis_service.py new file mode 100644 index 0000000..5feb377 --- /dev/null +++ b/api/tests/test_redis_service.py @@ -0,0 +1,573 @@ +""" +Unit tests for Redis Service. + +These tests use mocking to test the RedisService without requiring +a real Redis connection. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import json + +from redis.exceptions import RedisError, ConnectionError as RedisConnectionError + +from app.services.redis_service import ( + RedisService, + RedisServiceError, + RedisConnectionFailed +) + + +class TestRedisServiceInit: + """Test RedisService initialization.""" + + @patch('app.services.redis_service.redis.ConnectionPool.from_url') + @patch('app.services.redis_service.redis.Redis') + def test_init_success(self, mock_redis_class, mock_pool_from_url): + """Test successful initialization.""" + # Setup mocks + mock_pool = MagicMock() + mock_pool_from_url.return_value = mock_pool + + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_class.return_value = mock_client + + # Create service + service = RedisService(redis_url='redis://localhost:6379/0') + + # Verify + mock_pool_from_url.assert_called_once() + mock_redis_class.assert_called_once_with(connection_pool=mock_pool) + mock_client.ping.assert_called_once() + assert service.client == mock_client + + @patch('app.services.redis_service.redis.ConnectionPool.from_url') + def test_init_connection_failed(self, mock_pool_from_url): + """Test initialization fails when Redis is unavailable.""" + mock_pool_from_url.side_effect = RedisConnectionError("Connection refused") + + with pytest.raises(RedisConnectionFailed) as exc_info: + RedisService(redis_url='redis://localhost:6379/0') + + assert "Could not connect to Redis" in str(exc_info.value) + + def test_init_missing_url(self): + """Test initialization fails with missing URL.""" + # Clear environment variable and pass empty string + with patch.dict('os.environ', {'REDIS_URL': ''}, clear=True): + with pytest.raises(ValueError) as exc_info: + RedisService(redis_url='') + + assert "Redis URL not configured" in str(exc_info.value) + + +class TestRedisServiceOperations: + """Test Redis operations (get, set, delete, exists).""" + + @pytest.fixture + def redis_service(self): + """Create a RedisService with mocked client.""" + with patch('app.services.redis_service.redis.ConnectionPool.from_url') as mock_pool_from_url: + with patch('app.services.redis_service.redis.Redis') as mock_redis_class: + mock_pool = MagicMock() + mock_pool_from_url.return_value = mock_pool + + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_class.return_value = mock_client + + service = RedisService(redis_url='redis://localhost:6379/0') + yield service + + def test_get_existing_key(self, redis_service): + """Test getting an existing key.""" + redis_service.client.get.return_value = "test_value" + + result = redis_service.get("test_key") + + redis_service.client.get.assert_called_once_with("test_key") + assert result == "test_value" + + def test_get_nonexistent_key(self, redis_service): + """Test getting a non-existent key returns None.""" + redis_service.client.get.return_value = None + + result = redis_service.get("nonexistent_key") + + assert result is None + + def test_get_error(self, redis_service): + """Test get raises RedisServiceError on failure.""" + redis_service.client.get.side_effect = RedisError("Connection lost") + + with pytest.raises(RedisServiceError) as exc_info: + redis_service.get("test_key") + + assert "Failed to get key" in str(exc_info.value) + + def test_set_basic(self, redis_service): + """Test basic set operation.""" + redis_service.client.set.return_value = True + + result = redis_service.set("test_key", "test_value") + + redis_service.client.set.assert_called_once_with( + "test_key", "test_value", ex=None, nx=False, xx=False + ) + assert result is True + + def test_set_with_ttl(self, redis_service): + """Test set with TTL.""" + redis_service.client.set.return_value = True + + result = redis_service.set("test_key", "test_value", ttl=3600) + + redis_service.client.set.assert_called_once_with( + "test_key", "test_value", ex=3600, nx=False, xx=False + ) + assert result is True + + def test_set_nx_success(self, redis_service): + """Test set with NX (only if not exists) - success.""" + redis_service.client.set.return_value = True + + result = redis_service.set("test_key", "test_value", nx=True) + + redis_service.client.set.assert_called_once_with( + "test_key", "test_value", ex=None, nx=True, xx=False + ) + assert result is True + + def test_set_nx_failure(self, redis_service): + """Test set with NX fails when key exists.""" + redis_service.client.set.return_value = None # NX returns None if key exists + + result = redis_service.set("test_key", "test_value", nx=True) + + assert result is False + + def test_set_error(self, redis_service): + """Test set raises RedisServiceError on failure.""" + redis_service.client.set.side_effect = RedisError("Connection lost") + + with pytest.raises(RedisServiceError) as exc_info: + redis_service.set("test_key", "test_value") + + assert "Failed to set key" in str(exc_info.value) + + def test_delete_single_key(self, redis_service): + """Test deleting a single key.""" + redis_service.client.delete.return_value = 1 + + result = redis_service.delete("test_key") + + redis_service.client.delete.assert_called_once_with("test_key") + assert result == 1 + + def test_delete_multiple_keys(self, redis_service): + """Test deleting multiple keys.""" + redis_service.client.delete.return_value = 3 + + result = redis_service.delete("key1", "key2", "key3") + + redis_service.client.delete.assert_called_once_with("key1", "key2", "key3") + assert result == 3 + + def test_delete_no_keys(self, redis_service): + """Test delete with no keys returns 0.""" + result = redis_service.delete() + + redis_service.client.delete.assert_not_called() + assert result == 0 + + def test_delete_error(self, redis_service): + """Test delete raises RedisServiceError on failure.""" + redis_service.client.delete.side_effect = RedisError("Connection lost") + + with pytest.raises(RedisServiceError) as exc_info: + redis_service.delete("test_key") + + assert "Failed to delete keys" in str(exc_info.value) + + def test_exists_single_key(self, redis_service): + """Test checking existence of a single key.""" + redis_service.client.exists.return_value = 1 + + result = redis_service.exists("test_key") + + redis_service.client.exists.assert_called_once_with("test_key") + assert result == 1 + + def test_exists_multiple_keys(self, redis_service): + """Test checking existence of multiple keys.""" + redis_service.client.exists.return_value = 2 + + result = redis_service.exists("key1", "key2", "key3") + + redis_service.client.exists.assert_called_once_with("key1", "key2", "key3") + assert result == 2 + + def test_exists_no_keys(self, redis_service): + """Test exists with no keys returns 0.""" + result = redis_service.exists() + + redis_service.client.exists.assert_not_called() + assert result == 0 + + def test_exists_error(self, redis_service): + """Test exists raises RedisServiceError on failure.""" + redis_service.client.exists.side_effect = RedisError("Connection lost") + + with pytest.raises(RedisServiceError) as exc_info: + redis_service.exists("test_key") + + assert "Failed to check existence" in str(exc_info.value) + + +class TestRedisServiceJSON: + """Test JSON serialization methods.""" + + @pytest.fixture + def redis_service(self): + """Create a RedisService with mocked client.""" + with patch('app.services.redis_service.redis.ConnectionPool.from_url') as mock_pool_from_url: + with patch('app.services.redis_service.redis.Redis') as mock_redis_class: + mock_pool = MagicMock() + mock_pool_from_url.return_value = mock_pool + + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_class.return_value = mock_client + + service = RedisService(redis_url='redis://localhost:6379/0') + yield service + + def test_get_json_success(self, redis_service): + """Test getting and deserializing JSON.""" + test_data = {"name": "test", "value": 123, "nested": {"key": "value"}} + redis_service.client.get.return_value = json.dumps(test_data) + + result = redis_service.get_json("test_key") + + assert result == test_data + + def test_get_json_none(self, redis_service): + """Test get_json returns None for non-existent key.""" + redis_service.client.get.return_value = None + + result = redis_service.get_json("nonexistent_key") + + assert result is None + + def test_get_json_invalid(self, redis_service): + """Test get_json raises error for invalid JSON.""" + redis_service.client.get.return_value = "not valid json {" + + with pytest.raises(RedisServiceError) as exc_info: + redis_service.get_json("test_key") + + assert "Failed to decode JSON" in str(exc_info.value) + + def test_set_json_success(self, redis_service): + """Test serializing and setting JSON.""" + redis_service.client.set.return_value = True + test_data = {"name": "test", "value": 123} + + result = redis_service.set_json("test_key", test_data, ttl=3600) + + # Verify the value was serialized + call_args = redis_service.client.set.call_args + stored_value = call_args[0][1] + assert json.loads(stored_value) == test_data + assert result is True + + def test_set_json_non_serializable(self, redis_service): + """Test set_json raises error for non-serializable data.""" + non_serializable = {"func": lambda x: x} + + with pytest.raises(RedisServiceError) as exc_info: + redis_service.set_json("test_key", non_serializable) + + assert "Failed to serialize value" in str(exc_info.value) + + +class TestRedisServiceTTL: + """Test TTL-related operations.""" + + @pytest.fixture + def redis_service(self): + """Create a RedisService with mocked client.""" + with patch('app.services.redis_service.redis.ConnectionPool.from_url') as mock_pool_from_url: + with patch('app.services.redis_service.redis.Redis') as mock_redis_class: + mock_pool = MagicMock() + mock_pool_from_url.return_value = mock_pool + + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_class.return_value = mock_client + + service = RedisService(redis_url='redis://localhost:6379/0') + yield service + + def test_expire_success(self, redis_service): + """Test setting expiration on existing key.""" + redis_service.client.expire.return_value = True + + result = redis_service.expire("test_key", 3600) + + redis_service.client.expire.assert_called_once_with("test_key", 3600) + assert result is True + + def test_expire_nonexistent_key(self, redis_service): + """Test expire returns False for non-existent key.""" + redis_service.client.expire.return_value = False + + result = redis_service.expire("nonexistent_key", 3600) + + assert result is False + + def test_ttl_existing_key(self, redis_service): + """Test getting TTL of existing key.""" + redis_service.client.ttl.return_value = 3500 + + result = redis_service.ttl("test_key") + + redis_service.client.ttl.assert_called_once_with("test_key") + assert result == 3500 + + def test_ttl_no_expiry(self, redis_service): + """Test TTL returns -1 for key without expiry.""" + redis_service.client.ttl.return_value = -1 + + result = redis_service.ttl("test_key") + + assert result == -1 + + def test_ttl_nonexistent_key(self, redis_service): + """Test TTL returns -2 for non-existent key.""" + redis_service.client.ttl.return_value = -2 + + result = redis_service.ttl("test_key") + + assert result == -2 + + +class TestRedisServiceIncrement: + """Test increment/decrement operations.""" + + @pytest.fixture + def redis_service(self): + """Create a RedisService with mocked client.""" + with patch('app.services.redis_service.redis.ConnectionPool.from_url') as mock_pool_from_url: + with patch('app.services.redis_service.redis.Redis') as mock_redis_class: + mock_pool = MagicMock() + mock_pool_from_url.return_value = mock_pool + + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_class.return_value = mock_client + + service = RedisService(redis_url='redis://localhost:6379/0') + yield service + + def test_incr_default(self, redis_service): + """Test incrementing by default amount (1).""" + redis_service.client.incrby.return_value = 5 + + result = redis_service.incr("counter") + + redis_service.client.incrby.assert_called_once_with("counter", 1) + assert result == 5 + + def test_incr_custom_amount(self, redis_service): + """Test incrementing by custom amount.""" + redis_service.client.incrby.return_value = 15 + + result = redis_service.incr("counter", 10) + + redis_service.client.incrby.assert_called_once_with("counter", 10) + assert result == 15 + + def test_decr_default(self, redis_service): + """Test decrementing by default amount (1).""" + redis_service.client.decrby.return_value = 4 + + result = redis_service.decr("counter") + + redis_service.client.decrby.assert_called_once_with("counter", 1) + assert result == 4 + + def test_decr_custom_amount(self, redis_service): + """Test decrementing by custom amount.""" + redis_service.client.decrby.return_value = 0 + + result = redis_service.decr("counter", 5) + + redis_service.client.decrby.assert_called_once_with("counter", 5) + assert result == 0 + + +class TestRedisServiceHealth: + """Test health check and info operations.""" + + @pytest.fixture + def redis_service(self): + """Create a RedisService with mocked client.""" + with patch('app.services.redis_service.redis.ConnectionPool.from_url') as mock_pool_from_url: + with patch('app.services.redis_service.redis.Redis') as mock_redis_class: + mock_pool = MagicMock() + mock_pool_from_url.return_value = mock_pool + + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_class.return_value = mock_client + + service = RedisService(redis_url='redis://localhost:6379/0') + yield service + + def test_health_check_success(self, redis_service): + """Test health check when Redis is healthy.""" + redis_service.client.ping.return_value = True + + result = redis_service.health_check() + + assert result is True + + def test_health_check_failure(self, redis_service): + """Test health check when Redis is unhealthy.""" + redis_service.client.ping.side_effect = RedisError("Connection lost") + + result = redis_service.health_check() + + assert result is False + + def test_info_success(self, redis_service): + """Test getting Redis info.""" + mock_info = { + 'redis_version': '7.0.0', + 'used_memory': 1000000, + 'connected_clients': 5 + } + redis_service.client.info.return_value = mock_info + + result = redis_service.info() + + assert result == mock_info + + def test_info_error(self, redis_service): + """Test info raises error on failure.""" + redis_service.client.info.side_effect = RedisError("Connection lost") + + with pytest.raises(RedisServiceError) as exc_info: + redis_service.info() + + assert "Failed to get Redis info" in str(exc_info.value) + + +class TestRedisServiceUtility: + """Test utility methods.""" + + @pytest.fixture + def redis_service(self): + """Create a RedisService with mocked client.""" + with patch('app.services.redis_service.redis.ConnectionPool.from_url') as mock_pool_from_url: + with patch('app.services.redis_service.redis.Redis') as mock_redis_class: + mock_pool = MagicMock() + mock_pool_from_url.return_value = mock_pool + + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_class.return_value = mock_client + + service = RedisService(redis_url='redis://localhost:6379/0') + yield service + + def test_flush_db(self, redis_service): + """Test flushing database.""" + redis_service.client.flushdb.return_value = True + + result = redis_service.flush_db() + + redis_service.client.flushdb.assert_called_once() + assert result is True + + def test_close(self, redis_service): + """Test closing connection pool.""" + redis_service.close() + + redis_service.pool.disconnect.assert_called_once() + + def test_context_manager(self, redis_service): + """Test using service as context manager.""" + with redis_service as service: + assert service is not None + + redis_service.pool.disconnect.assert_called_once() + + def test_sanitize_url_with_password(self, redis_service): + """Test URL sanitization masks password.""" + url = "redis://user:secretpassword@localhost:6379/0" + + result = redis_service._sanitize_url(url) + + assert "secretpassword" not in result + assert "***" in result + assert "localhost:6379/0" in result + + def test_sanitize_url_without_password(self, redis_service): + """Test URL sanitization with no password.""" + url = "redis://localhost:6379/0" + + result = redis_service._sanitize_url(url) + + assert result == url + + +class TestRedisServiceIntegration: + """Integration-style tests that verify the flow of operations.""" + + @pytest.fixture + def redis_service(self): + """Create a RedisService with mocked client.""" + with patch('app.services.redis_service.redis.ConnectionPool.from_url') as mock_pool_from_url: + with patch('app.services.redis_service.redis.Redis') as mock_redis_class: + mock_pool = MagicMock() + mock_pool_from_url.return_value = mock_pool + + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_redis_class.return_value = mock_client + + service = RedisService(redis_url='redis://localhost:6379/0') + yield service + + def test_set_then_get(self, redis_service): + """Test setting and then getting a value.""" + # Set + redis_service.client.set.return_value = True + redis_service.set("test_key", "test_value") + + # Get + redis_service.client.get.return_value = "test_value" + result = redis_service.get("test_key") + + assert result == "test_value" + + def test_json_roundtrip(self, redis_service): + """Test JSON serialization roundtrip.""" + test_data = { + "user_id": "user_123", + "tokens_used": 450, + "model": "claude-3-5-haiku" + } + + # Set JSON + redis_service.client.set.return_value = True + redis_service.set_json("job_result", test_data, ttl=3600) + + # Get JSON + redis_service.client.get.return_value = json.dumps(test_data) + result = redis_service.get_json("job_result") + + assert result == test_data diff --git a/api/tests/test_replicate_client.py b/api/tests/test_replicate_client.py new file mode 100644 index 0000000..f880078 --- /dev/null +++ b/api/tests/test_replicate_client.py @@ -0,0 +1,462 @@ +""" +Tests for Replicate API client. + +Tests cover initialization, prompt formatting, generation, +retry logic, and error handling. +""" + +import pytest +from unittest.mock import patch, MagicMock + +from app.ai.replicate_client import ( + ReplicateClient, + ReplicateResponse, + ReplicateClientError, + ReplicateAPIError, + ReplicateRateLimitError, + ReplicateTimeoutError, + ModelType, +) + + +class TestReplicateClientInit: + """Tests for ReplicateClient initialization.""" + + @patch('app.ai.replicate_client.get_config') + def test_init_with_token(self, mock_config): + """Test initialization with explicit API token.""" + mock_config.return_value = MagicMock( + replicate_api_token=None, + REPLICATE_MODEL=None + ) + + client = ReplicateClient(api_token="test_token_123") + + assert client.api_token == "test_token_123" + assert client.model == ReplicateClient.DEFAULT_MODEL.value + + @patch('app.ai.replicate_client.get_config') + def test_init_from_config(self, mock_config): + """Test initialization from config.""" + mock_config.return_value = MagicMock( + replicate_api_token="config_token", + REPLICATE_MODEL="custom/model" + ) + + client = ReplicateClient() + + assert client.api_token == "config_token" + assert client.model == "custom/model" + + @patch('app.ai.replicate_client.get_config') + def test_init_missing_token(self, mock_config): + """Test initialization fails without API token.""" + mock_config.return_value = MagicMock( + replicate_api_token=None, + REPLICATE_MODEL=None + ) + + with pytest.raises(ReplicateClientError) as exc_info: + ReplicateClient() + + assert "API token not configured" in str(exc_info.value) + + @patch('app.ai.replicate_client.get_config') + def test_init_custom_model(self, mock_config): + """Test initialization with custom model.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + client = ReplicateClient(model="meta/llama-2-70b") + + assert client.model == "meta/llama-2-70b" + + +class TestPromptFormatting: + """Tests for Llama-3 prompt formatting.""" + + @patch('app.ai.replicate_client.get_config') + def test_format_prompt_user_only(self, mock_config): + """Test formatting with only user prompt.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + client = ReplicateClient() + formatted = client._format_llama_prompt("Hello world") + + assert "<|begin_of_text|>" in formatted + assert "<|start_header_id|>user<|end_header_id|>" in formatted + assert "Hello world" in formatted + assert "<|start_header_id|>assistant<|end_header_id|>" in formatted + # No system header without system prompt + assert "system<|end_header_id|>" not in formatted + + @patch('app.ai.replicate_client.get_config') + def test_format_prompt_with_system(self, mock_config): + """Test formatting with system and user prompts.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + client = ReplicateClient() + formatted = client._format_llama_prompt( + "What is 2+2?", + system_prompt="You are a helpful assistant." + ) + + assert "<|start_header_id|>system<|end_header_id|>" in formatted + assert "You are a helpful assistant." in formatted + assert "<|start_header_id|>user<|end_header_id|>" in formatted + assert "What is 2+2?" in formatted + + +class TestGenerate: + """Tests for text generation.""" + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_generate_success(self, mock_replicate, mock_config): + """Test successful text generation.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + # Mock streaming response + mock_replicate.run.return_value = iter(["Hello ", "world ", "!"]) + + client = ReplicateClient() + response = client.generate("Say hello") + + assert isinstance(response, ReplicateResponse) + assert response.text == "Hello world !" + assert response.tokens_used > 0 + assert response.model == ReplicateClient.DEFAULT_MODEL.value + assert response.generation_time > 0 + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_generate_with_parameters(self, mock_replicate, mock_config): + """Test generation with custom parameters.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + mock_replicate.run.return_value = iter(["Response"]) + + client = ReplicateClient() + response = client.generate( + prompt="Test", + system_prompt="Be concise", + max_tokens=100, + temperature=0.5, + top_p=0.8, + timeout=60 + ) + + # Verify parameters were passed + call_args = mock_replicate.run.call_args + assert call_args[1]['input']['max_tokens'] == 100 + assert call_args[1]['input']['temperature'] == 0.5 + assert call_args[1]['input']['top_p'] == 0.8 + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_generate_string_response(self, mock_replicate, mock_config): + """Test handling string response (non-streaming).""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + mock_replicate.run.return_value = "Direct string response" + + client = ReplicateClient() + response = client.generate("Test") + + assert response.text == "Direct string response" + + +class TestRetryLogic: + """Tests for retry and error handling.""" + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + @patch('app.ai.replicate_client.time.sleep') + def test_retry_on_rate_limit(self, mock_sleep, mock_replicate, mock_config): + """Test retry logic on rate limit errors.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + # First call raises rate limit, second succeeds + mock_replicate.exceptions.ReplicateError = Exception + mock_replicate.run.side_effect = [ + Exception("Rate limit exceeded 429"), + iter(["Success"]) + ] + + client = ReplicateClient() + response = client.generate("Test") + + assert response.text == "Success" + assert mock_sleep.called # Verify backoff happened + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + @patch('app.ai.replicate_client.time.sleep') + def test_max_retries_exceeded(self, mock_sleep, mock_replicate, mock_config): + """Test that max retries raises error.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + # All calls fail with rate limit + mock_replicate.exceptions.ReplicateError = Exception + mock_replicate.run.side_effect = Exception("Rate limit exceeded 429") + + client = ReplicateClient() + + with pytest.raises(ReplicateRateLimitError): + client.generate("Test") + + # Should have retried MAX_RETRIES times + assert mock_replicate.run.call_count == ReplicateClient.MAX_RETRIES + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_timeout_error(self, mock_replicate, mock_config): + """Test timeout error handling.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + mock_replicate.exceptions.ReplicateError = Exception + mock_replicate.run.side_effect = Exception("Request timeout") + + client = ReplicateClient() + + with pytest.raises(ReplicateTimeoutError): + client.generate("Test") + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_api_error(self, mock_replicate, mock_config): + """Test generic API error handling.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + mock_replicate.exceptions.ReplicateError = Exception + mock_replicate.run.side_effect = Exception("Invalid model") + + client = ReplicateClient() + + with pytest.raises(ReplicateAPIError): + client.generate("Test") + + +class TestValidation: + """Tests for API key validation.""" + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_validate_api_key_success(self, mock_replicate, mock_config): + """Test successful API key validation.""" + mock_config.return_value = MagicMock( + replicate_api_token="valid_token", + REPLICATE_MODEL=None + ) + + mock_replicate.models.get.return_value = MagicMock() + + client = ReplicateClient() + assert client.validate_api_key() is True + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_validate_api_key_failure(self, mock_replicate, mock_config): + """Test failed API key validation.""" + mock_config.return_value = MagicMock( + replicate_api_token="invalid_token", + REPLICATE_MODEL=None + ) + + mock_replicate.models.get.side_effect = Exception("Invalid API token") + + client = ReplicateClient() + assert client.validate_api_key() is False + + +class TestResponseDataclass: + """Tests for ReplicateResponse dataclass.""" + + def test_response_creation(self): + """Test creating ReplicateResponse.""" + response = ReplicateResponse( + text="Hello world", + tokens_used=50, + model="meta/llama-3-8b", + generation_time=1.5 + ) + + assert response.text == "Hello world" + assert response.tokens_used == 50 + assert response.model == "meta/llama-3-8b" + assert response.generation_time == 1.5 + + def test_response_immutability(self): + """Test that response fields are accessible.""" + response = ReplicateResponse( + text="Test", + tokens_used=10, + model="test", + generation_time=0.5 + ) + + # Dataclass should allow attribute access + assert hasattr(response, 'text') + assert hasattr(response, 'tokens_used') + + +class TestModelType: + """Tests for ModelType enum and multi-model support.""" + + def test_model_type_values(self): + """Test ModelType enum has expected values.""" + assert ModelType.LLAMA_3_8B.value == "meta/meta-llama-3-8b-instruct" + assert ModelType.CLAUDE_HAIKU.value == "anthropic/claude-3.5-haiku" + assert ModelType.CLAUDE_SONNET.value == "anthropic/claude-3.5-sonnet" + assert ModelType.CLAUDE_SONNET_4.value == "anthropic/claude-sonnet-4" + + @patch('app.ai.replicate_client.get_config') + def test_init_with_model_type_enum(self, mock_config): + """Test initialization with ModelType enum.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + client = ReplicateClient(model=ModelType.CLAUDE_HAIKU) + + assert client.model == "anthropic/claude-3.5-haiku" + assert client.model_type == ModelType.CLAUDE_HAIKU + + @patch('app.ai.replicate_client.get_config') + def test_is_claude_model(self, mock_config): + """Test _is_claude_model helper.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + # Llama model + client = ReplicateClient(model=ModelType.LLAMA_3_8B) + assert client._is_claude_model() is False + + # Claude models + client = ReplicateClient(model=ModelType.CLAUDE_HAIKU) + assert client._is_claude_model() is True + + client = ReplicateClient(model=ModelType.CLAUDE_SONNET) + assert client._is_claude_model() is True + + +class TestClaudeModels: + """Tests for Claude model generation via Replicate.""" + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_generate_with_claude_haiku(self, mock_replicate, mock_config): + """Test generation with Claude Haiku model.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + mock_replicate.run.return_value = iter(["Claude ", "response"]) + + client = ReplicateClient(model=ModelType.CLAUDE_HAIKU) + response = client.generate("Test prompt") + + assert response.text == "Claude response" + assert response.model == "anthropic/claude-3.5-haiku" + + # Verify Claude-style params (not Llama formatted prompt) + call_args = mock_replicate.run.call_args + assert "prompt" in call_args[1]['input'] + # Claude params don't include Llama special tokens + assert "<|begin_of_text|>" not in call_args[1]['input']['prompt'] + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_generate_with_claude_system_prompt(self, mock_replicate, mock_config): + """Test Claude generation includes system_prompt parameter.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + mock_replicate.run.return_value = iter(["Response"]) + + client = ReplicateClient(model=ModelType.CLAUDE_SONNET) + client.generate( + prompt="User message", + system_prompt="You are a DM" + ) + + call_args = mock_replicate.run.call_args + assert call_args[1]['input']['system_prompt'] == "You are a DM" + assert call_args[1]['input']['prompt'] == "User message" + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_model_specific_defaults(self, mock_replicate, mock_config): + """Test that model-specific defaults are applied.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + mock_replicate.run.return_value = iter(["Response"]) + + # Claude Sonnet should use higher max_tokens by default + client = ReplicateClient(model=ModelType.CLAUDE_SONNET) + client.generate("Test") + + call_args = mock_replicate.run.call_args + # Sonnet default is 1024 tokens + assert call_args[1]['input']['max_tokens'] == 1024 + assert call_args[1]['input']['temperature'] == 0.9 + + @patch('app.ai.replicate_client.get_config') + @patch('app.ai.replicate_client.replicate') + def test_model_override_in_generate(self, mock_replicate, mock_config): + """Test overriding model in generate() call.""" + mock_config.return_value = MagicMock( + replicate_api_token="token", + REPLICATE_MODEL=None + ) + + mock_replicate.run.return_value = iter(["Response"]) + + # Init with Llama, but call with Claude + client = ReplicateClient(model=ModelType.LLAMA_3_8B) + response = client.generate("Test", model=ModelType.CLAUDE_HAIKU) + + # Response should reflect the overridden model + assert response.model == "anthropic/claude-3.5-haiku" + + # Verify correct model was called + call_args = mock_replicate.run.call_args + assert call_args[0][0] == "anthropic/claude-3.5-haiku" diff --git a/api/tests/test_session_model.py b/api/tests/test_session_model.py new file mode 100644 index 0000000..4518aed --- /dev/null +++ b/api/tests/test_session_model.py @@ -0,0 +1,390 @@ +""" +Tests for the GameSession model with solo play support. + +Tests cover: +- Solo session creation and serialization +- Multiplayer session creation and serialization +- ConversationEntry with timestamps +- GameState with location_type +- Session type detection +""" + +import pytest +from datetime import datetime + +from app.models.session import ( + GameSession, + GameState, + ConversationEntry, + SessionConfig, +) +from app.models.enums import ( + SessionStatus, + SessionType, + LocationType, +) + + +class TestGameState: + """Tests for GameState dataclass.""" + + def test_default_values(self): + """Test GameState has correct defaults.""" + state = GameState() + assert state.current_location == "Crossroads Village" + assert state.location_type == LocationType.TOWN + assert state.discovered_locations == [] + assert state.active_quests == [] + assert state.world_events == [] + + def test_to_dict_serializes_location_type(self): + """Test location_type is serialized as string.""" + state = GameState( + current_location="The Rusty Anchor", + location_type=LocationType.TAVERN, + ) + data = state.to_dict() + assert data["current_location"] == "The Rusty Anchor" + assert data["location_type"] == "tavern" + + def test_from_dict_deserializes_location_type(self): + """Test location_type is deserialized from string.""" + data = { + "current_location": "Dark Forest", + "location_type": "wilderness", + "discovered_locations": ["Town A"], + "active_quests": ["quest_1"], + "world_events": [], + } + state = GameState.from_dict(data) + assert state.current_location == "Dark Forest" + assert state.location_type == LocationType.WILDERNESS + assert state.discovered_locations == ["Town A"] + + def test_roundtrip_serialization(self): + """Test GameState serializes and deserializes correctly.""" + state = GameState( + current_location="Ancient Library", + location_type=LocationType.LIBRARY, + discovered_locations=["Town", "Forest"], + active_quests=["quest_1", "quest_2"], + world_events=[{"type": "festival"}], + ) + data = state.to_dict() + restored = GameState.from_dict(data) + + assert restored.current_location == state.current_location + assert restored.location_type == state.location_type + assert restored.discovered_locations == state.discovered_locations + assert restored.active_quests == state.active_quests + + +class TestConversationEntry: + """Tests for ConversationEntry dataclass.""" + + def test_auto_timestamp(self): + """Test timestamp is auto-generated.""" + entry = ConversationEntry( + turn=1, + character_id="char_123", + character_name="Hero", + action="I explore", + dm_response="You find a chest", + ) + assert entry.timestamp + assert entry.timestamp.endswith("Z") + + def test_provided_timestamp_preserved(self): + """Test provided timestamp is not overwritten.""" + ts = "2025-11-21T10:30:00Z" + entry = ConversationEntry( + turn=1, + character_id="char_123", + character_name="Hero", + action="I explore", + dm_response="You find a chest", + timestamp=ts, + ) + assert entry.timestamp == ts + + def test_to_dict_with_quest_offered(self): + """Test serialization includes quest_offered when present.""" + entry = ConversationEntry( + turn=5, + character_id="char_123", + character_name="Hero", + action="Talk to elder", + dm_response="The elder offers you a quest", + quest_offered={ + "quest_id": "quest_goblin_cave", + "quest_name": "Clear the Goblin Cave", + }, + ) + data = entry.to_dict() + assert "quest_offered" in data + assert data["quest_offered"]["quest_id"] == "quest_goblin_cave" + + def test_to_dict_without_quest_offered(self): + """Test serialization omits quest_offered when None.""" + entry = ConversationEntry( + turn=1, + character_id="char_123", + character_name="Hero", + action="I explore", + dm_response="You find nothing", + ) + data = entry.to_dict() + assert "quest_offered" not in data + + def test_from_dict_roundtrip(self): + """Test ConversationEntry roundtrip serialization.""" + entry = ConversationEntry( + turn=3, + character_id="char_456", + character_name="Wizard", + action="Cast fireball", + dm_response="The spell illuminates the cave", + combat_log=[{"action": "attack", "damage": 15}], + quest_offered={"quest_id": "q1"}, + ) + data = entry.to_dict() + restored = ConversationEntry.from_dict(data) + + assert restored.turn == entry.turn + assert restored.character_id == entry.character_id + assert restored.action == entry.action + assert restored.dm_response == entry.dm_response + assert restored.timestamp == entry.timestamp + assert restored.combat_log == entry.combat_log + assert restored.quest_offered == entry.quest_offered + + +class TestGameSessionSolo: + """Tests for solo GameSession functionality.""" + + def test_create_solo_session(self): + """Test creating a solo session.""" + session = GameSession( + session_id="sess_123", + session_type=SessionType.SOLO, + solo_character_id="char_456", + user_id="user_789", + ) + assert session.session_type == SessionType.SOLO + assert session.solo_character_id == "char_456" + assert session.user_id == "user_789" + assert session.is_solo() is True + + def test_is_solo_method(self): + """Test is_solo returns correct values.""" + solo = GameSession( + session_id="s1", + session_type=SessionType.SOLO, + solo_character_id="c1", + ) + multi = GameSession( + session_id="s2", + session_type=SessionType.MULTIPLAYER, + party_member_ids=["c1", "c2"], + ) + assert solo.is_solo() is True + assert multi.is_solo() is False + + def test_get_character_id_solo(self): + """Test get_character_id returns solo_character_id for solo sessions.""" + session = GameSession( + session_id="sess_123", + session_type=SessionType.SOLO, + solo_character_id="char_456", + ) + assert session.get_character_id() == "char_456" + + def test_get_character_id_multiplayer(self): + """Test get_character_id returns current turn character for multiplayer.""" + session = GameSession( + session_id="sess_123", + session_type=SessionType.MULTIPLAYER, + party_member_ids=["c1", "c2", "c3"], + turn_order=["c2", "c1", "c3"], + current_turn=1, + ) + assert session.get_character_id() == "c1" + + def test_to_dict_includes_new_fields(self): + """Test to_dict includes session_type, solo_character_id, user_id.""" + session = GameSession( + session_id="sess_123", + session_type=SessionType.SOLO, + solo_character_id="char_456", + user_id="user_789", + ) + data = session.to_dict() + + assert data["session_id"] == "sess_123" + assert data["session_type"] == "solo" + assert data["solo_character_id"] == "char_456" + assert data["user_id"] == "user_789" + + def test_from_dict_solo_session(self): + """Test from_dict correctly deserializes solo session.""" + data = { + "session_id": "sess_123", + "session_type": "solo", + "solo_character_id": "char_456", + "user_id": "user_789", + "party_member_ids": [], + "turn_number": 5, + "game_state": { + "current_location": "Town", + "location_type": "town", + }, + } + session = GameSession.from_dict(data) + + assert session.session_id == "sess_123" + assert session.session_type == SessionType.SOLO + assert session.solo_character_id == "char_456" + assert session.user_id == "user_789" + assert session.turn_number == 5 + assert session.game_state.location_type == LocationType.TOWN + + def test_roundtrip_serialization(self): + """Test complete roundtrip of solo session.""" + session = GameSession( + session_id="sess_test", + session_type=SessionType.SOLO, + solo_character_id="char_hero", + user_id="user_player", + turn_number=10, + game_state=GameState( + current_location="Dark Dungeon", + location_type=LocationType.DUNGEON, + active_quests=["quest_1"], + ), + conversation_history=[ + ConversationEntry( + turn=1, + character_id="char_hero", + character_name="Hero", + action="Enter dungeon", + dm_response="The darkness swallows you...", + ) + ], + ) + + data = session.to_dict() + restored = GameSession.from_dict(data) + + assert restored.session_id == session.session_id + assert restored.session_type == session.session_type + assert restored.solo_character_id == session.solo_character_id + assert restored.user_id == session.user_id + assert restored.turn_number == session.turn_number + assert restored.game_state.current_location == session.game_state.current_location + assert restored.game_state.location_type == session.game_state.location_type + assert len(restored.conversation_history) == 1 + assert restored.conversation_history[0].action == "Enter dungeon" + + def test_repr_solo(self): + """Test __repr__ for solo session.""" + session = GameSession( + session_id="sess_123", + session_type=SessionType.SOLO, + solo_character_id="char_456", + turn_number=5, + ) + repr_str = repr(session) + assert "type=solo" in repr_str + assert "char=char_456" in repr_str + assert "turn=5" in repr_str + + def test_repr_multiplayer(self): + """Test __repr__ for multiplayer session.""" + session = GameSession( + session_id="sess_123", + session_type=SessionType.MULTIPLAYER, + party_member_ids=["c1", "c2", "c3"], + turn_number=10, + ) + repr_str = repr(session) + assert "type=multiplayer" in repr_str + assert "party=3" in repr_str + assert "turn=10" in repr_str + + +class TestGameSessionBackwardsCompatibility: + """Tests for backwards compatibility with existing sessions.""" + + def test_default_session_type_is_solo(self): + """Test new sessions default to solo type.""" + session = GameSession(session_id="test") + assert session.session_type == SessionType.SOLO + + def test_from_dict_without_session_type(self): + """Test from_dict handles missing session_type (defaults to solo).""" + data = { + "session_id": "old_session", + "party_member_ids": ["c1"], + } + session = GameSession.from_dict(data) + assert session.session_type == SessionType.SOLO + + def test_from_dict_without_location_type(self): + """Test from_dict handles missing location_type in game_state.""" + data = { + "session_id": "old_session", + "game_state": { + "current_location": "Old Town", + }, + } + session = GameSession.from_dict(data) + assert session.game_state.location_type == LocationType.TOWN + + def test_existing_methods_still_work(self): + """Test existing session methods work with new fields.""" + session = GameSession( + session_id="test", + session_type=SessionType.SOLO, + solo_character_id="char_1", + ) + + # Test existing methods + assert session.is_in_combat() is False + session.update_activity() + assert session.last_activity + + # Add conversation entry + entry = ConversationEntry( + turn=1, + character_id="char_1", + character_name="Hero", + action="test", + dm_response="response", + ) + session.add_conversation_entry(entry) + assert len(session.conversation_history) == 1 + + +class TestLocationTypeEnum: + """Tests for LocationType enum.""" + + def test_all_location_types_defined(self): + """Test all expected location types exist.""" + expected = ["town", "tavern", "wilderness", "dungeon", "ruins", "library", "safe_area"] + actual = [lt.value for lt in LocationType] + assert sorted(actual) == sorted(expected) + + def test_location_type_from_string(self): + """Test LocationType can be created from string.""" + assert LocationType("town") == LocationType.TOWN + assert LocationType("wilderness") == LocationType.WILDERNESS + assert LocationType("dungeon") == LocationType.DUNGEON + + +class TestSessionTypeEnum: + """Tests for SessionType enum.""" + + def test_session_types_defined(self): + """Test session types are defined correctly.""" + assert SessionType.SOLO.value == "solo" + assert SessionType.MULTIPLAYER.value == "multiplayer" diff --git a/api/tests/test_session_service.py b/api/tests/test_session_service.py new file mode 100644 index 0000000..c1eb306 --- /dev/null +++ b/api/tests/test_session_service.py @@ -0,0 +1,566 @@ +""" +Tests for the SessionService. + +Tests cover: +- Solo session creation +- Session retrieval and listing +- Conversation history management +- Game state tracking (location, quests, events) +- Session validation and limits +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from datetime import datetime + +from app.services.session_service import ( + SessionService, + SessionNotFound, + SessionLimitExceeded, + SessionValidationError, + MAX_ACTIVE_SESSIONS, +) +from app.models.session import GameSession, GameState, ConversationEntry +from app.models.enums import SessionStatus, SessionType, LocationType +from app.models.character import Character +from app.models.skills import PlayerClass +from app.models.origins import Origin + + +@pytest.fixture +def mock_db(): + """Create mock database service.""" + with patch('app.services.session_service.get_database_service') as mock: + db = Mock() + mock.return_value = db + yield db + + +@pytest.fixture +def mock_appwrite(): + """Create mock Appwrite service.""" + with patch('app.services.session_service.AppwriteService') as mock: + service = Mock() + mock.return_value = service + yield service + + +@pytest.fixture +def mock_character_service(): + """Create mock character service.""" + with patch('app.services.session_service.get_character_service') as mock: + service = Mock() + mock.return_value = service + yield service + + +@pytest.fixture +def sample_character(): + """Create a sample character for testing.""" + return Character( + character_id="char_123", + user_id="user_456", + name="Test Hero", + player_class=Mock(spec=PlayerClass), + origin=Mock(spec=Origin), + level=5, + experience=1000, + base_stats={"strength": 10}, + unlocked_skills=[], + inventory=[], + equipped={}, + gold=100, + active_quests=[], + discovered_locations=[], + current_location="Town" + ) + + +class TestSessionServiceCreation: + """Tests for session creation.""" + + def test_create_solo_session_success(self, mock_db, mock_appwrite, mock_character_service, sample_character): + """Test successful solo session creation.""" + mock_character_service.get_character.return_value = sample_character + mock_db.count_documents.return_value = 0 + mock_db.create_document.return_value = None + + service = SessionService() + session = service.create_solo_session( + user_id="user_456", + character_id="char_123" + ) + + assert session.session_type == SessionType.SOLO + assert session.solo_character_id == "char_123" + assert session.user_id == "user_456" + assert session.turn_number == 0 + assert session.status == SessionStatus.ACTIVE + assert session.game_state.current_location == "Crossroads Village" + assert session.game_state.location_type == LocationType.TOWN + + mock_db.create_document.assert_called_once() + + def test_create_solo_session_character_not_found(self, mock_db, mock_appwrite, mock_character_service): + """Test session creation fails when character not found.""" + from app.services.character_service import CharacterNotFound + mock_character_service.get_character.side_effect = CharacterNotFound("Not found") + + service = SessionService() + with pytest.raises(CharacterNotFound): + service.create_solo_session( + user_id="user_456", + character_id="char_invalid" + ) + + def test_create_solo_session_limit_exceeded(self, mock_db, mock_appwrite, mock_character_service, sample_character): + """Test session creation fails when limit exceeded.""" + mock_character_service.get_character.return_value = sample_character + mock_db.count_documents.return_value = MAX_ACTIVE_SESSIONS + + service = SessionService() + with pytest.raises(SessionLimitExceeded): + service.create_solo_session( + user_id="user_456", + character_id="char_123" + ) + + def test_create_solo_session_custom_location(self, mock_db, mock_appwrite, mock_character_service, sample_character): + """Test session creation with custom starting location.""" + mock_character_service.get_character.return_value = sample_character + mock_db.count_documents.return_value = 0 + + service = SessionService() + session = service.create_solo_session( + user_id="user_456", + character_id="char_123", + starting_location="Dark Forest", + starting_location_type=LocationType.WILDERNESS + ) + + assert session.game_state.current_location == "Dark Forest" + assert session.game_state.location_type == LocationType.WILDERNESS + assert "Dark Forest" in session.game_state.discovered_locations + + +class TestSessionServiceRetrieval: + """Tests for session retrieval.""" + + def test_get_session_success(self, mock_db, mock_appwrite, mock_character_service): + """Test successful session retrieval.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "solo_character_id": "char_456", + "user_id": "user_789", + "party_member_ids": [], + "turn_number": 5, + "status": "active", + "game_state": { + "current_location": "Town", + "location_type": "town" + }, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0 + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_789', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + session = service.get_session("sess_123", "user_789") + + assert session.session_id == "sess_123" + assert session.user_id == "user_789" + assert session.turn_number == 5 + + def test_get_session_not_found(self, mock_db, mock_appwrite, mock_character_service): + """Test session retrieval when not found.""" + mock_db.get_document.return_value = None + + service = SessionService() + with pytest.raises(SessionNotFound): + service.get_session("sess_invalid") + + def test_get_session_wrong_user(self, mock_db, mock_appwrite, mock_character_service): + """Test session retrieval with wrong user ID.""" + mock_document = Mock() + mock_document.data = { + 'userId': 'user_other', + 'sessionData': '{}' + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + with pytest.raises(SessionNotFound): + service.get_session("sess_123", "user_wrong") + + def test_get_user_sessions(self, mock_db, mock_appwrite, mock_character_service): + """Test getting all sessions for a user.""" + session_data = { + "session_id": "sess_1", + "session_type": "solo", + "user_id": "user_123", + "status": "active", + "turn_number": 0, + "game_state": {}, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_doc = Mock() + mock_doc.data = {'sessionData': __import__('json').dumps(session_data)} + mock_doc.id = "sess_1" + mock_db.list_rows.return_value = [mock_doc] + + service = SessionService() + sessions = service.get_user_sessions("user_123") + + assert len(sessions) == 1 + assert sessions[0].session_id == "sess_1" + + +class TestConversationHistory: + """Tests for conversation history management.""" + + def test_add_conversation_entry(self, mock_db, mock_appwrite, mock_character_service): + """Test adding a conversation entry.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 0, + "status": "active", + "game_state": {"current_location": "Town", "location_type": "town"}, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + updated = service.add_conversation_entry( + session_id="sess_123", + character_id="char_789", + character_name="Hero", + action="I explore the area", + dm_response="You find a hidden path..." + ) + + assert updated.turn_number == 1 + assert len(updated.conversation_history) == 1 + assert updated.conversation_history[0].action == "I explore the area" + assert updated.conversation_history[0].dm_response == "You find a hidden path..." + + mock_db.update_document.assert_called_once() + + def test_add_conversation_entry_with_quest(self, mock_db, mock_appwrite, mock_character_service): + """Test adding conversation entry with quest offering.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 5, + "status": "active", + "game_state": {}, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + updated = service.add_conversation_entry( + session_id="sess_123", + character_id="char_789", + character_name="Hero", + action="Talk to elder", + dm_response="The elder offers you a quest...", + quest_offered={"quest_id": "quest_goblin", "name": "Clear Goblin Cave"} + ) + + assert updated.conversation_history[0].quest_offered is not None + assert updated.conversation_history[0].quest_offered["quest_id"] == "quest_goblin" + + def test_get_recent_history(self, mock_db, mock_appwrite, mock_character_service): + """Test getting recent conversation history.""" + # Create session with 5 conversation entries + entries = [] + for i in range(5): + entries.append({ + "turn": i + 1, + "character_id": "char_123", + "character_name": "Hero", + "action": f"Action {i+1}", + "dm_response": f"Response {i+1}", + "timestamp": "2025-11-21T10:00:00Z", + "combat_log": [] + }) + + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 5, + "status": "active", + "game_state": {}, + "conversation_history": entries, + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + recent = service.get_recent_history("sess_123", num_turns=3) + + assert len(recent) == 3 + assert recent[0].turn == 3 # Last 3 entries + assert recent[2].turn == 5 + + +class TestGameStateTracking: + """Tests for game state tracking methods.""" + + def test_update_location(self, mock_db, mock_appwrite, mock_character_service): + """Test updating session location.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 0, + "status": "active", + "game_state": { + "current_location": "Town", + "location_type": "town", + "discovered_locations": ["Town"] + }, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + updated = service.update_location( + session_id="sess_123", + new_location="Dark Forest", + location_type=LocationType.WILDERNESS + ) + + assert updated.game_state.current_location == "Dark Forest" + assert updated.game_state.location_type == LocationType.WILDERNESS + assert "Dark Forest" in updated.game_state.discovered_locations + + def test_add_active_quest(self, mock_db, mock_appwrite, mock_character_service): + """Test adding an active quest.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 0, + "status": "active", + "game_state": { + "active_quests": [], + "current_location": "Town", + "location_type": "town" + }, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + updated = service.add_active_quest("sess_123", "quest_goblin") + + assert "quest_goblin" in updated.game_state.active_quests + + def test_add_active_quest_limit(self, mock_db, mock_appwrite, mock_character_service): + """Test adding quest fails when max reached.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 0, + "status": "active", + "game_state": { + "active_quests": ["quest_1", "quest_2"], # Already at max + "current_location": "Town", + "location_type": "town" + }, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + with pytest.raises(SessionValidationError): + service.add_active_quest("sess_123", "quest_3") + + def test_remove_active_quest(self, mock_db, mock_appwrite, mock_character_service): + """Test removing an active quest.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 0, + "status": "active", + "game_state": { + "active_quests": ["quest_1", "quest_2"], + "current_location": "Town", + "location_type": "town" + }, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + updated = service.remove_active_quest("sess_123", "quest_1") + + assert "quest_1" not in updated.game_state.active_quests + assert "quest_2" in updated.game_state.active_quests + + def test_add_world_event(self, mock_db, mock_appwrite, mock_character_service): + """Test adding a world event.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 0, + "status": "active", + "game_state": { + "world_events": [], + "current_location": "Town", + "location_type": "town" + }, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + updated = service.add_world_event("sess_123", {"type": "festival", "description": "A festival begins"}) + + assert len(updated.game_state.world_events) == 1 + assert updated.game_state.world_events[0]["type"] == "festival" + assert "timestamp" in updated.game_state.world_events[0] + + +class TestSessionLifecycle: + """Tests for session lifecycle management.""" + + def test_end_session(self, mock_db, mock_appwrite, mock_character_service): + """Test ending a session.""" + session_data = { + "session_id": "sess_123", + "session_type": "solo", + "user_id": "user_456", + "turn_number": 10, + "status": "active", + "game_state": {}, + "conversation_history": [], + "config": {}, + "turn_order": [], + "current_turn": 0, + "party_member_ids": [] + } + + mock_document = Mock() + mock_document.data = { + 'userId': 'user_456', + 'sessionData': __import__('json').dumps(session_data) + } + mock_db.get_document.return_value = mock_document + + service = SessionService() + updated = service.end_session("sess_123", "user_456") + + assert updated.status == SessionStatus.COMPLETED + mock_db.update_document.assert_called_once() + + def test_count_user_sessions(self, mock_db, mock_appwrite, mock_character_service): + """Test counting user sessions.""" + mock_db.count_documents.return_value = 3 + + service = SessionService() + count = service.count_user_sessions("user_123", active_only=True) + + assert count == 3 + mock_db.count_documents.assert_called_once() diff --git a/api/tests/test_stats.py b/api/tests/test_stats.py new file mode 100644 index 0000000..0af61d6 --- /dev/null +++ b/api/tests/test_stats.py @@ -0,0 +1,198 @@ +""" +Unit tests for Stats dataclass. + +Tests computed properties, serialization, and basic operations. +""" + +import pytest +from app.models.stats import Stats + + +def test_stats_default_values(): + """Test that Stats initializes with default values.""" + stats = Stats() + + assert stats.strength == 10 + assert stats.dexterity == 10 + assert stats.constitution == 10 + assert stats.intelligence == 10 + assert stats.wisdom == 10 + assert stats.charisma == 10 + + +def test_stats_custom_values(): + """Test creating Stats with custom values.""" + stats = Stats( + strength=15, + dexterity=12, + constitution=14, + intelligence=8, + wisdom=10, + charisma=11, + ) + + assert stats.strength == 15 + assert stats.dexterity == 12 + assert stats.constitution == 14 + assert stats.intelligence == 8 + assert stats.wisdom == 10 + assert stats.charisma == 11 + + +def test_hit_points_calculation(): + """Test HP calculation: 10 + (constitution × 2).""" + stats = Stats(constitution=10) + assert stats.hit_points == 30 # 10 + (10 × 2) + + stats = Stats(constitution=15) + assert stats.hit_points == 40 # 10 + (15 × 2) + + stats = Stats(constitution=20) + assert stats.hit_points == 50 # 10 + (20 × 2) + + +def test_mana_points_calculation(): + """Test MP calculation: 10 + (intelligence × 2).""" + stats = Stats(intelligence=10) + assert stats.mana_points == 30 # 10 + (10 × 2) + + stats = Stats(intelligence=15) + assert stats.mana_points == 40 # 10 + (15 × 2) + + stats = Stats(intelligence=8) + assert stats.mana_points == 26 # 10 + (8 × 2) + + +def test_defense_calculation(): + """Test defense calculation: constitution // 2.""" + stats = Stats(constitution=10) + assert stats.defense == 5 # 10 // 2 + + stats = Stats(constitution=15) + assert stats.defense == 7 # 15 // 2 + + stats = Stats(constitution=21) + assert stats.defense == 10 # 21 // 2 + + +def test_resistance_calculation(): + """Test resistance calculation: wisdom // 2.""" + stats = Stats(wisdom=10) + assert stats.resistance == 5 # 10 // 2 + + stats = Stats(wisdom=14) + assert stats.resistance == 7 # 14 // 2 + + stats = Stats(wisdom=9) + assert stats.resistance == 4 # 9 // 2 + + +def test_stats_serialization(): + """Test to_dict() serialization.""" + stats = Stats( + strength=15, + dexterity=12, + constitution=14, + intelligence=10, + wisdom=11, + charisma=8, + ) + + data = stats.to_dict() + + assert data["strength"] == 15 + assert data["dexterity"] == 12 + assert data["constitution"] == 14 + assert data["intelligence"] == 10 + assert data["wisdom"] == 11 + assert data["charisma"] == 8 + + +def test_stats_deserialization(): + """Test from_dict() deserialization.""" + data = { + "strength": 18, + "dexterity": 14, + "constitution": 16, + "intelligence": 12, + "wisdom": 10, + "charisma": 9, + } + + stats = Stats.from_dict(data) + + assert stats.strength == 18 + assert stats.dexterity == 14 + assert stats.constitution == 16 + assert stats.intelligence == 12 + assert stats.wisdom == 10 + assert stats.charisma == 9 + + +def test_stats_deserialization_with_missing_values(): + """Test from_dict() with missing values (should use defaults).""" + data = { + "strength": 15, + # Missing other stats + } + + stats = Stats.from_dict(data) + + assert stats.strength == 15 + assert stats.dexterity == 10 # Default + assert stats.constitution == 10 # Default + assert stats.intelligence == 10 # Default + assert stats.wisdom == 10 # Default + assert stats.charisma == 10 # Default + + +def test_stats_round_trip_serialization(): + """Test that serialization and deserialization preserve data.""" + original = Stats( + strength=20, + dexterity=15, + constitution=18, + intelligence=10, + wisdom=12, + charisma=14, + ) + + # Serialize then deserialize + data = original.to_dict() + restored = Stats.from_dict(data) + + assert restored.strength == original.strength + assert restored.dexterity == original.dexterity + assert restored.constitution == original.constitution + assert restored.intelligence == original.intelligence + assert restored.wisdom == original.wisdom + assert restored.charisma == original.charisma + + +def test_stats_copy(): + """Test that copy() creates an independent copy.""" + original = Stats(strength=15, dexterity=12, constitution=14) + copy = original.copy() + + assert copy.strength == original.strength + assert copy.dexterity == original.dexterity + assert copy.constitution == original.constitution + + # Modify copy + copy.strength = 20 + + # Original should be unchanged + assert original.strength == 15 + assert copy.strength == 20 + + +def test_stats_repr(): + """Test string representation.""" + stats = Stats(strength=15, constitution=12, intelligence=10) + repr_str = repr(stats) + + assert "STR=15" in repr_str + assert "CON=12" in repr_str + assert "INT=10" in repr_str + assert "HP=" in repr_str + assert "MP=" in repr_str diff --git a/api/tests/test_usage_tracking_service.py b/api/tests/test_usage_tracking_service.py new file mode 100644 index 0000000..3bba071 --- /dev/null +++ b/api/tests/test_usage_tracking_service.py @@ -0,0 +1,460 @@ +""" +Tests for UsageTrackingService. + +These tests verify: +- Cost calculation for different models +- Usage logging functionality +- Daily and monthly usage aggregation +- Static helper methods +""" + +import pytest +from datetime import datetime, date, timezone, timedelta +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 + +from app.services.usage_tracking_service import ( + UsageTrackingService, + MODEL_COSTS, + DEFAULT_COST +) +from app.models.ai_usage import ( + AIUsageLog, + DailyUsageSummary, + MonthlyUsageSummary, + TaskType +) + + +class TestAIUsageLogModel: + """Tests for the AIUsageLog dataclass.""" + + def test_to_dict(self): + """Test conversion to dictionary.""" + log = AIUsageLog( + log_id="log_123", + user_id="user_456", + timestamp=datetime(2025, 11, 21, 10, 30, 0, tzinfo=timezone.utc), + model="anthropic/claude-3.5-sonnet", + tokens_input=100, + tokens_output=350, + tokens_total=450, + estimated_cost=0.00555, + task_type=TaskType.STORY_PROGRESSION, + session_id="sess_789", + character_id="char_abc", + request_duration_ms=1500, + success=True, + error_message=None + ) + + result = log.to_dict() + + assert result["log_id"] == "log_123" + assert result["user_id"] == "user_456" + assert result["model"] == "anthropic/claude-3.5-sonnet" + assert result["tokens_total"] == 450 + assert result["task_type"] == "story_progression" + assert result["success"] is True + + def test_from_dict(self): + """Test creation from dictionary.""" + data = { + "log_id": "log_123", + "user_id": "user_456", + "timestamp": "2025-11-21T10:30:00+00:00", + "model": "anthropic/claude-3.5-sonnet", + "tokens_input": 100, + "tokens_output": 350, + "tokens_total": 450, + "estimated_cost": 0.00555, + "task_type": "story_progression", + "session_id": "sess_789", + "character_id": "char_abc", + "request_duration_ms": 1500, + "success": True, + "error_message": None + } + + log = AIUsageLog.from_dict(data) + + assert log.log_id == "log_123" + assert log.user_id == "user_456" + assert log.task_type == TaskType.STORY_PROGRESSION + assert log.tokens_total == 450 + + def test_from_dict_with_invalid_task_type(self): + """Test handling of invalid task type.""" + data = { + "log_id": "log_123", + "user_id": "user_456", + "timestamp": "2025-11-21T10:30:00+00:00", + "model": "test-model", + "tokens_input": 100, + "tokens_output": 200, + "tokens_total": 300, + "estimated_cost": 0.001, + "task_type": "invalid_type" + } + + log = AIUsageLog.from_dict(data) + + # Should default to GENERAL + assert log.task_type == TaskType.GENERAL + + +class TestDailyUsageSummary: + """Tests for the DailyUsageSummary dataclass.""" + + def test_to_dict(self): + """Test conversion to dictionary.""" + summary = DailyUsageSummary( + date=date(2025, 11, 21), + user_id="user_123", + total_requests=15, + total_tokens=6750, + total_input_tokens=2000, + total_output_tokens=4750, + estimated_cost=0.45, + requests_by_task={"story_progression": 10, "combat_narration": 5} + ) + + result = summary.to_dict() + + assert result["date"] == "2025-11-21" + assert result["total_requests"] == 15 + assert result["estimated_cost"] == 0.45 + assert result["requests_by_task"]["story_progression"] == 10 + + +class TestCostCalculation: + """Tests for cost calculation functionality.""" + + def test_calculate_cost_llama(self): + """Test cost calculation for Llama model.""" + cost = UsageTrackingService.estimate_cost_for_model( + model="meta/meta-llama-3-8b-instruct", + tokens_input=1000, + tokens_output=1000 + ) + + # Llama: $0.0001 per 1K input + $0.0001 per 1K output + expected = 0.0001 + 0.0001 + assert abs(cost - expected) < 0.000001 + + def test_calculate_cost_haiku(self): + """Test cost calculation for Claude Haiku.""" + cost = UsageTrackingService.estimate_cost_for_model( + model="anthropic/claude-3.5-haiku", + tokens_input=1000, + tokens_output=1000 + ) + + # Haiku: $0.001 per 1K input + $0.005 per 1K output + expected = 0.001 + 0.005 + assert abs(cost - expected) < 0.000001 + + def test_calculate_cost_sonnet(self): + """Test cost calculation for Claude Sonnet.""" + cost = UsageTrackingService.estimate_cost_for_model( + model="anthropic/claude-3.5-sonnet", + tokens_input=1000, + tokens_output=1000 + ) + + # Sonnet: $0.003 per 1K input + $0.015 per 1K output + expected = 0.003 + 0.015 + assert abs(cost - expected) < 0.000001 + + def test_calculate_cost_opus(self): + """Test cost calculation for Claude Opus.""" + cost = UsageTrackingService.estimate_cost_for_model( + model="anthropic/claude-3-opus", + tokens_input=1000, + tokens_output=1000 + ) + + # Opus: $0.015 per 1K input + $0.075 per 1K output + expected = 0.015 + 0.075 + assert abs(cost - expected) < 0.000001 + + def test_calculate_cost_unknown_model(self): + """Test cost calculation for unknown model uses default.""" + cost = UsageTrackingService.estimate_cost_for_model( + model="unknown/model", + tokens_input=1000, + tokens_output=1000 + ) + + # Default: $0.001 per 1K input + $0.005 per 1K output + expected = DEFAULT_COST["input"] + DEFAULT_COST["output"] + assert abs(cost - expected) < 0.000001 + + def test_calculate_cost_fractional_tokens(self): + """Test cost calculation with fractional token counts.""" + cost = UsageTrackingService.estimate_cost_for_model( + model="anthropic/claude-3.5-sonnet", + tokens_input=500, + tokens_output=250 + ) + + # Sonnet: (500/1000 * 0.003) + (250/1000 * 0.015) + expected = 0.0015 + 0.00375 + assert abs(cost - expected) < 0.000001 + + def test_get_model_cost_info(self): + """Test getting cost info for a model.""" + cost_info = UsageTrackingService.get_model_cost_info( + "anthropic/claude-3.5-sonnet" + ) + + assert cost_info["input"] == 0.003 + assert cost_info["output"] == 0.015 + + def test_get_model_cost_info_unknown(self): + """Test getting cost info for unknown model.""" + cost_info = UsageTrackingService.get_model_cost_info("unknown/model") + + assert cost_info == DEFAULT_COST + + +class TestUsageTrackingService: + """Tests for UsageTrackingService class.""" + + @pytest.fixture + def mock_env(self): + """Set up mock environment variables.""" + with patch.dict('os.environ', { + 'APPWRITE_ENDPOINT': 'https://cloud.appwrite.io/v1', + 'APPWRITE_PROJECT_ID': 'test_project', + 'APPWRITE_API_KEY': 'test_api_key', + 'APPWRITE_DATABASE_ID': 'test_db' + }): + yield + + @pytest.fixture + def mock_databases(self): + """Create mock Databases service.""" + with patch('app.services.usage_tracking_service.Databases') as mock: + yield mock + + @pytest.fixture + def service(self, mock_env, mock_databases): + """Create UsageTrackingService instance with mocked dependencies.""" + service = UsageTrackingService() + return service + + def test_init_missing_env(self): + """Test initialization fails with missing env vars.""" + with patch.dict('os.environ', {}, clear=True): + with pytest.raises(ValueError, match="Appwrite configuration incomplete"): + UsageTrackingService() + + def test_log_usage_success(self, service): + """Test logging usage successfully.""" + # Mock the create_document response + service.databases.create_document = Mock(return_value={ + "$id": "doc_123" + }) + + result = service.log_usage( + user_id="user_123", + model="anthropic/claude-3.5-sonnet", + tokens_input=100, + tokens_output=350, + task_type=TaskType.STORY_PROGRESSION, + session_id="sess_789" + ) + + # Verify result + assert result.user_id == "user_123" + assert result.model == "anthropic/claude-3.5-sonnet" + assert result.tokens_input == 100 + assert result.tokens_output == 350 + assert result.tokens_total == 450 + assert result.task_type == TaskType.STORY_PROGRESSION + assert result.estimated_cost > 0 + + # Verify Appwrite was called + service.databases.create_document.assert_called_once() + call_args = service.databases.create_document.call_args + assert call_args.kwargs["database_id"] == "test_db" + assert call_args.kwargs["collection_id"] == "ai_usage_logs" + + def test_log_usage_with_error(self, service): + """Test logging usage when request failed.""" + service.databases.create_document = Mock(return_value={ + "$id": "doc_123" + }) + + result = service.log_usage( + user_id="user_123", + model="anthropic/claude-3.5-sonnet", + tokens_input=100, + tokens_output=0, + task_type=TaskType.STORY_PROGRESSION, + success=False, + error_message="API timeout" + ) + + assert result.success is False + assert result.error_message == "API timeout" + assert result.tokens_output == 0 + + def test_get_daily_usage(self, service): + """Test getting daily usage summary.""" + # Mock list_rows response + service.tables_db.list_rows = Mock(return_value={ + "rows": [ + { + "tokens_input": 100, + "tokens_output": 300, + "tokens_total": 400, + "estimated_cost": 0.005, + "task_type": "story_progression" + }, + { + "tokens_input": 150, + "tokens_output": 350, + "tokens_total": 500, + "estimated_cost": 0.006, + "task_type": "story_progression" + }, + { + "tokens_input": 50, + "tokens_output": 200, + "tokens_total": 250, + "estimated_cost": 0.003, + "task_type": "combat_narration" + } + ] + }) + + result = service.get_daily_usage("user_123", date(2025, 11, 21)) + + assert result.user_id == "user_123" + assert result.date == date(2025, 11, 21) + assert result.total_requests == 3 + assert result.total_tokens == 1150 + assert result.total_input_tokens == 300 + assert result.total_output_tokens == 850 + assert abs(result.estimated_cost - 0.014) < 0.0001 + assert result.requests_by_task["story_progression"] == 2 + assert result.requests_by_task["combat_narration"] == 1 + + def test_get_daily_usage_empty(self, service): + """Test getting daily usage when no usage exists.""" + service.tables_db.list_rows = Mock(return_value={ + "rows": [] + }) + + result = service.get_daily_usage("user_123", date(2025, 11, 21)) + + assert result.total_requests == 0 + assert result.total_tokens == 0 + assert result.estimated_cost == 0.0 + assert result.requests_by_task == {} + + def test_get_monthly_cost(self, service): + """Test getting monthly cost summary.""" + service.tables_db.list_rows = Mock(return_value={ + "rows": [ + {"tokens_total": 1000, "estimated_cost": 0.01}, + {"tokens_total": 2000, "estimated_cost": 0.02}, + {"tokens_total": 1500, "estimated_cost": 0.015} + ] + }) + + result = service.get_monthly_cost("user_123", 2025, 11) + + assert result.year == 2025 + assert result.month == 11 + assert result.user_id == "user_123" + assert result.total_requests == 3 + assert result.total_tokens == 4500 + assert abs(result.estimated_cost - 0.045) < 0.0001 + + def test_get_monthly_cost_invalid_month(self, service): + """Test monthly cost with invalid month raises ValueError.""" + with pytest.raises(ValueError, match="Invalid month"): + service.get_monthly_cost("user_123", 2025, 13) + + with pytest.raises(ValueError, match="Invalid month"): + service.get_monthly_cost("user_123", 2025, 0) + + def test_get_total_daily_cost(self, service): + """Test getting total daily cost across all users.""" + service.tables_db.list_rows = Mock(return_value={ + "rows": [ + {"estimated_cost": 0.10}, + {"estimated_cost": 0.25}, + {"estimated_cost": 0.15} + ] + }) + + result = service.get_total_daily_cost(date(2025, 11, 21)) + + assert abs(result - 0.50) < 0.0001 + + def test_get_user_request_count_today(self, service): + """Test getting user request count for today.""" + service.tables_db.list_rows = Mock(return_value={ + "rows": [ + {"tokens_total": 100, "tokens_input": 30, "tokens_output": 70, "estimated_cost": 0.001, "task_type": "story_progression"}, + {"tokens_total": 200, "tokens_input": 50, "tokens_output": 150, "estimated_cost": 0.002, "task_type": "story_progression"} + ] + }) + + result = service.get_user_request_count_today("user_123") + + assert result == 2 + + +class TestCostEstimations: + """Tests for realistic cost estimation scenarios.""" + + def test_free_tier_daily_cost(self): + """Test estimated daily cost for free tier user with Llama.""" + # 20 requests per day, average 500 total tokens each + total_input = 20 * 200 + total_output = 20 * 300 + + cost = UsageTrackingService.estimate_cost_for_model( + model="meta/meta-llama-3-8b-instruct", + tokens_input=total_input, + tokens_output=total_output + ) + + # Should be very cheap (essentially free) + assert cost < 0.01 + + def test_premium_tier_daily_cost(self): + """Test estimated daily cost for premium tier user with Sonnet.""" + # 100 requests per day, average 1000 total tokens each + total_input = 100 * 300 + total_output = 100 * 700 + + cost = UsageTrackingService.estimate_cost_for_model( + model="anthropic/claude-3.5-sonnet", + tokens_input=total_input, + tokens_output=total_output + ) + + # Should be under $2/day for heavy usage + assert cost < 2.0 + + def test_elite_tier_monthly_cost(self): + """Test estimated monthly cost for elite tier user.""" + # 200 requests per day * 30 days = 6000 requests + # Average 1500 tokens per request + total_input = 6000 * 500 + total_output = 6000 * 1000 + + cost = UsageTrackingService.estimate_cost_for_model( + model="anthropic/claude-4.5-sonnet", + tokens_input=total_input, + tokens_output=total_output + ) + + # Elite tier should be under $100/month even with heavy usage + assert cost < 100.0 diff --git a/api/wsgi.py b/api/wsgi.py new file mode 100644 index 0000000..52e5f28 --- /dev/null +++ b/api/wsgi.py @@ -0,0 +1,15 @@ +""" +WSGI entry point for Code of Conquest. + +Used by production WSGI servers like Gunicorn. +""" + +from app import create_app + +# Create application instance +app = create_app() + +if __name__ == "__main__": + # For development only + # In production, use: gunicorn wsgi:app + app.run(debug=True) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..34d2c32 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,921 @@ +# Architecture + +## System Overview + +**Microservices Architecture** - Three independent, deployable components: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend Layer (2 options) │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ Public Web │ │ Godot Client │ │ +│ │ (Flask + Jinja2) │ │ (Native Game) │ │ +│ │ │ │ │ │ +│ │ - Browser-based UI │ │ - Desktop/Mobile │ │ +│ │ - HTMX interactions │ │ - Cross-platform │ │ +│ │ - Server-side │ │ - GDScript │ │ +│ │ rendering │ │ │ │ +│ └──────────────────────┘ └──────────────────────┘ │ +│ │ │ │ +│ └──────────────┬───────────────┘ │ +│ │ │ +│ │ HTTP/REST │ +└──────────────────────────┼─────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Backend (Flask) │ +│ │ +│ Single Source of Truth - All Business Logic │ +│ - Authentication (via Appwrite) │ +│ - Game logic & mechanics │ +│ - Character management │ +│ - Combat system │ +│ - Marketplace transactions │ +│ - Session management │ +│ - Data models & validation │ +│ - AI orchestration │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┼──────────────────┐ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Appwrite │ │ Redis + RQ │ │ AI APIs │ +│ │ │ │ │ │ +│ - Auth │ │ - Job Queue │ │ - Replicate │ +│ - Database │ │ - Caching │ │ - Anthropic │ +│ - Storage │ │ - Sessions │ │ │ +│ - Realtime │ │ │ │ │ +└──────────────┘ └──────────────┘ └──────────────┘ +``` + +### Component Responsibilities + +**`/api` - API Backend (Port 5000)** +- **Role:** Single source of truth, all business logic +- **Tech:** Flask REST API + Appwrite + RQ + Redis +- **Contains:** Models, services, game logic, AI integration +- **Deployment:** Independent with own venv, config, tests +- **Location:** `/api` directory + +**`/public_web` - Web Frontend (Port 5001)** +- **Role:** Thin UI layer for browsers +- **Tech:** Flask + Jinja2 + HTMX +- **Contains:** Views, templates, static assets +- **Communication:** HTTP requests to API backend +- **Deployment:** Independent with own venv, config +- **Location:** `/public_web` directory + +**`/godot_client` - Game Client** +- **Role:** Native cross-platform game client +- **Tech:** Godot 4.5 + GDScript +- **Contains:** UI, scenes, client-side logic +- **Communication:** HTTP requests to API backend +- **Deployment:** Exports to Desktop/Mobile/Web +- **Location:** `/godot_client` directory + +--- + +## Core Design Principles + +1. **Microservices Architecture:** Three independent, deployable components (API, Web, Godot) +2. **API as Single Source of Truth:** All business logic centralized in API backend +3. **AI-Driven Narrative:** Claude models generate story, NPC dialogue, combat descriptions +4. **Code-Driven Mechanics:** All game mechanics (damage, stats, effects) use deterministic formulas (JRPG-style) +5. **Turn-Based Gameplay:** Classic D&D style, not real-time +6. **Scalable Architecture:** Independent services, horizontal scaling ready +7. **Security First:** Developer is cybersecurity senior engineer/director - security is paramount +8. **Monetization:** Tiered subscription model with free tier + +--- + +## Tech Stack + +### API Backend (`/api`) + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| **Framework** | Flask 3.x | REST API framework | +| **Authentication** | Appwrite Auth | User management, session tokens | +| **Database** | Appwrite Database | NoSQL document storage | +| **Realtime** | Appwrite Realtime | WebSocket connections for multiplayer | +| **Storage** | Appwrite Storage | User assets, logs | +| **Job Queue** | RQ (Redis Queue) | Async AI calls, background tasks | +| **Caching** | Redis | Session data, rate limiting | +| **AI (Free)** | Replicate API | Free tier users (Llama 3 70B) | +| **AI (Paid)** | Anthropic Claude | Paid tier users (Haiku/Sonnet) | +| **Data Models** | Dataclasses | Typed domain models | +| **Logging** | Structlog | Structured logging | +| **Testing** | Pytest | Unit and integration tests | +| **WSGI Server** | Gunicorn | Production WSGI server | + +### Web Frontend (`/public_web`) + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| **Framework** | Flask 3.x | Lightweight web server | +| **Templates** | Jinja2 | Server-side HTML rendering | +| **UI Enhancement** | HTMX | Dynamic updates without heavy JS | +| **Styling** | Vanilla CSS | Custom dark theme | +| **API Client** | Python requests | HTTP calls to API backend | +| **Logging** | Structlog | Structured logging | +| **WSGI Server** | Gunicorn | Production WSGI server | + +### Godot Client (`/godot_client`) + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| **Engine** | Godot 4.5 | Cross-platform game engine | +| **Language** | GDScript | Game logic and UI | +| **HTTP Client** | HTTPClient singleton | API communication | +| **State Management** | StateManager singleton | Global game state | +| **Fonts** | Cinzel + Lato | Display and body fonts | +| **Exports** | Desktop/Mobile/Web | Multi-platform deployment | + +### Shared Infrastructure + +| Component | Technology | Purpose | +|-----------|-----------|---------| +| **Configuration** | YAML files | Environment-specific settings | +| **Secrets** | .env files | API keys, credentials | +| **Deployment** | Docker | Containerized services | +| **Version Control** | Git | Source code management | + +--- + +## AI Model Strategy + +### Model Selection Philosophy +- Use **Anthropic Claude models** for all paid tier interactions +- Use **Replicate free models** only for free tier users +- Model selection is **per-call based**, determined by context importance and user tier + +### Model Tiers + +| Tier | Provider | Model | Max Tokens | Temperature | Use Cases | +|------|----------|-------|------------|-------------|-----------| +| **FREE** | Replicate | meta-llama-3-70b-instruct | 256 | 0.7 | Free tier users only | +| **STANDARD** | Anthropic | claude-3-5-haiku-20241022 | 512 | 0.8 | Most interactions (paid) | +| **PREMIUM** | Anthropic | claude-3-5-sonnet-20241022 | 1024 | 0.9 | Important moments (paid) | + +### Context-Based Model Selection + +| Context | Model Tier | Examples | +|---------|-----------|----------| +| Simple acknowledgment | FREE | "You enter the room", "Item picked up" | +| Item description | FREE | Basic item lore | +| NPC dialogue | STANDARD | Merchant conversations, quest givers | +| Combat narrative | STANDARD | Regular combat descriptions | +| Story progression | PREMIUM | Major plot developments | +| Boss encounter | PREMIUM | Epic boss fight narratives | +| Character death | PREMIUM | Dramatic death scenes | + +**Note:** Free tier users always get FREE tier models regardless of context. + +### Prompt Management +- Use **Jinja2 templates** for all AI prompts +- Enable easy data injection and maintainability +- Templates stored in `app/ai/prompt_templates.py` + +--- + +## Appwrite Collections + +### users +Handled automatically by Appwrite Auth. + +### characters + +| Field | Type | Description | +|-------|------|-------------| +| `$id` | String | Unique ID | +| `userId` | String | Owner user ID | +| `characterData` | JSON String | Serialized Character dataclass | +| `created_at` | ISO Timestamp | Creation time | +| `updated_at` | ISO Timestamp | Last update | +| `is_active` | Boolean | Active character flag | + +### game_sessions + +| Field | Type | Description | +|-------|------|-------------| +| `$id` | String | Session ID | +| `party_member_ids` | Array[String] | Character IDs in party | +| `config` | Object | Session configuration | +| `combat_encounter` | JSON String | Current combat or null | +| `conversation_history` | Array[Object] | Turn-by-turn history | +| `game_state` | Object | Current location, quests, events | +| `turn_order` | Array[String] | Character IDs in turn order | +| `current_turn` | Integer | Index in turn_order | +| `turn_number` | Integer | Global turn counter | +| `created_at` | ISO Timestamp | Session start | +| `last_activity` | ISO Timestamp | Last action | +| `status` | String | active, completed, timeout | + +**Session Config Structure:** +```json +{ + "min_players": 2, + "timeout_minutes": 30, + "auto_save_interval": 5 +} +``` + +**Conversation History Entry:** +```json +{ + "turn": 1, + "character_id": "char_id_1", + "character_name": "Aragorn", + "action": "I search for traps", + "dm_response": "You notice...", + "combat_log": [] +} +``` + +### marketplace_listings + +| Field | Type | Description | +|-------|------|-------------| +| `$id` | String | Listing ID | +| `seller_id` | String | User ID | +| `character_id` | String | Character ID | +| `item_data` | Object | Full item details | +| `listing_type` | String | "auction" or "fixed_price" | +| `price` | Integer | For fixed_price | +| `starting_bid` | Integer | For auction | +| `current_bid` | Integer | For auction | +| `buyout_price` | Integer | Optional instant buy | +| `bids` | Array[Object] | Bid history | +| `auction_end` | ISO Timestamp | For auction | +| `status` | String | active, sold, expired, removed | +| `created_at` | ISO Timestamp | Listing creation | + +### transactions + +| Field | Type | Description | +|-------|------|-------------| +| `$id` | String | Transaction ID | +| `buyer_id` | String | User ID | +| `seller_id` | String | User ID | +| `listing_id` | String | Listing ID | +| `item_data` | Object | Item details | +| `price` | Integer | Final price | +| `timestamp` | ISO Timestamp | Transaction time | +| `transaction_type` | String | marketplace_sale, shop_purchase, etc. | + +--- + +## Project Structure + +**Repository Root:** + +``` +/coc +├── api/ # API Backend Component +├── public_web/ # Web Frontend Component +├── godot_client/ # Godot Game Client Component +├── docs/ # Project-wide documentation +├── CLAUDE.md # Development guidelines +└── README.md # Project overview +``` + +### API Backend (`/api`) + +**Single source of truth - All business logic** + +``` +api/ +├── app/ +│ ├── __init__.py # Flask app factory +│ ├── config.py # Configuration management +│ ├── models/ # Data models (dataclasses) +│ │ ├── __init__.py +│ │ ├── enums.py # All enum types +│ │ ├── stats.py # Stats dataclass +│ │ ├── effects.py # Effect dataclass +│ │ ├── abilities.py # Ability dataclass +│ │ ├── items.py # Item dataclass +│ │ ├── skills.py # SkillNode, SkillTree, PlayerClass +│ │ ├── origins.py # Origin dataclass +│ │ ├── character.py # Character with get_effective_stats() +│ │ ├── combat.py # Combatant, CombatEncounter +│ │ ├── session.py # GameSession, SessionConfig +│ │ └── marketplace.py # Marketplace models +│ ├── api/ # REST API endpoints +│ │ ├── __init__.py +│ │ ├── health.py # Health check +│ │ ├── auth.py # Authentication endpoints +│ │ ├── characters.py # Character CRUD +│ │ ├── sessions.py # Session management (TODO) +│ │ ├── combat.py # Combat actions (TODO) +│ │ ├── marketplace.py # Marketplace operations (TODO) +│ │ └── shop.py # NPC shop (TODO) +│ ├── game_logic/ # Game mechanics +│ │ ├── __init__.py +│ │ ├── combat_engine.py # Combat calculations (TODO) +│ │ ├── damage_calculator.py # Damage formulas (TODO) +│ │ ├── effect_processor.py # Effect tick processing (TODO) +│ │ ├── skill_manager.py # Skill unlock logic (TODO) +│ │ └── loot_generator.py # Random loot generation (TODO) +│ ├── ai/ # AI integration +│ │ ├── __init__.py +│ │ ├── ai_client.py # Replicate + Anthropic clients (TODO) +│ │ ├── model_selector.py # Model tier selection (TODO) +│ │ ├── prompt_templates.py # Jinja2 prompt templates (TODO) +│ │ └── narrative_generator.py # AI narrative wrapper (TODO) +│ ├── tasks/ # Background jobs (RQ) +│ │ ├── __init__.py +│ │ ├── ai_tasks.py # RQ jobs for AI calls (TODO) +│ │ ├── combat_tasks.py # Async combat processing (TODO) +│ │ └── marketplace_tasks.py # Auction processing (TODO) +│ ├── services/ # Business logic & integrations +│ │ ├── __init__.py +│ │ ├── appwrite_service.py # Appwrite SDK wrapper +│ │ ├── database_service.py # Database operations +│ │ ├── database_init.py # Database initialization +│ │ ├── character_service.py # Character management +│ │ ├── class_loader.py # Load classes from YAML +│ │ └── origin_service.py # Load origins from YAML +│ ├── utils/ # Utilities +│ │ ├── __init__.py +│ │ ├── logging.py # Structlog setup +│ │ ├── response.py # Standardized API responses +│ │ └── auth.py # Auth decorators +│ └── data/ # Game data (YAML) +│ ├── abilities/ # Ability definitions +│ ├── classes/ # Character class definitions +│ ├── items/ # Item definitions (TODO) +│ └── origins.yaml # Origin definitions +├── config/ # Environment configs +│ ├── development.yaml # Dev environment settings +│ └── production.yaml # Production settings +├── tests/ # Pytest test suite +│ ├── __init__.py +│ ├── test_character.py +│ ├── test_stats.py +│ ├── test_effects.py +│ ├── test_combat_simulation.py +│ ├── test_class_loader.py +│ ├── test_origin_service.py +│ ├── test_character_service.py +│ └── test_api_characters_integration.py +├── scripts/ # Utility scripts +│ ├── init_database.py # Database initialization +│ ├── setup.sh # Project setup +│ └── README.md +├── logs/ # Application logs +│ └── app.log +├── docs/ # API documentation +│ ├── API_REFERENCE.md # API endpoint docs +│ ├── API_TESTING.md # API testing guide +│ ├── DATA_MODELS.md # Data model documentation +│ ├── GAME_SYSTEMS.md # Game mechanics docs +│ └── APPWRITE_SETUP.md # Database setup guide +├── requirements.txt # Python dependencies (full stack) +├── wsgi.py # WSGI entry point +├── docker-compose.yml # Redis service +├── .env.example # Environment variable template +└── README.md # API backend README +``` + +### Web Frontend (`/public_web`) + +**Thin UI layer - Makes HTTP requests to API** + +``` +public_web/ +├── app/ +│ ├── __init__.py # Flask app factory +│ ├── config.py # Configuration loader +│ ├── views/ # View blueprints (Flask routes) +│ │ ├── __init__.py +│ │ ├── auth_views.py # Auth pages (login, register, etc.) +│ │ └── character_views.py # Character pages +│ ├── services/ # (Stub) API client services +│ │ ├── __init__.py # TODO: Refactor to HTTP client +│ │ ├── character_service.py # Stub (raises NotImplementedError) +│ │ ├── class_loader.py # Stub (raises NotImplementedError) +│ │ └── origin_service.py # Stub (raises NotImplementedError) +│ └── utils/ # Utilities +│ ├── __init__.py +│ ├── logging.py # Structlog setup +│ └── auth.py # Auth helpers (stubs, need refactor) +├── templates/ # Jinja2 HTML templates +│ ├── base.html # Base layout +│ ├── auth/ # Authentication pages +│ │ ├── login.html +│ │ ├── register.html +│ │ ├── forgot_password.html +│ │ ├── reset_password.html +│ │ └── verify_email.html +│ └── character/ # Character pages +│ ├── list.html # Character list +│ ├── detail.html # Character detail +│ ├── create_origin.html # Creation step 1 +│ ├── create_class.html # Creation step 2 +│ ├── create_customize.html # Creation step 3 +│ └── create_confirm.html # Creation step 4 +├── static/ # CSS, JS, images +│ └── css/ +│ └── main.css # Main stylesheet +├── config/ # Web frontend configs +│ ├── development.yaml # Dev settings (API URL, etc.) +│ └── production.yaml # Production settings +├── logs/ # Application logs +├── docs/ # Frontend documentation +├── requirements.txt # Python dependencies (minimal) +├── wsgi.py # WSGI entry point +├── .env.example # Environment template +└── README.md # Web frontend README +``` + +**Known Technical Debt:** +- Views currently import stub services that raise `NotImplementedError` +- Need to refactor views to make HTTP requests to API backend +- Auth helpers need to validate sessions via API +- See `/public_web/README.md` for details + +### Godot Client (`/godot_client`) + +**Native cross-platform game client** + +``` +godot_client/ +├── project.godot # Godot project configuration +├── scenes/ # Godot scene files (.tscn) +│ ├── main.tscn # Entry point +│ ├── auth/ # Authentication scenes +│ │ └── login.tscn +│ ├── character/ # Character scenes +│ ├── combat/ # Combat scenes +│ ├── world/ # World exploration scenes +│ └── components/ # Reusable UI components +├── scripts/ # GDScript code +│ ├── main.gd # Main scene controller +│ ├── services/ # Singleton autoloads +│ │ ├── settings.gd # Settings management +│ │ ├── http_client.gd # HTTP client for API calls +│ │ └── state_manager.gd # Global state management +│ ├── models/ # Data models +│ │ └── api_response.gd # API response wrapper +│ ├── components/ # Component scripts +│ │ ├── form_field.gd # Form input component +│ │ ├── card.gd # Card container +│ │ └── custom_button.gd # Styled button +│ └── utils/ # Helper utilities +│ └── theme_colors.gd # Color constants +├── assets/ # Game assets +│ ├── fonts/ # Cinzel + Lato fonts +│ ├── themes/ # UI theme files +│ └── ui/ # UI assets +├── docs/ # Godot client documentation +│ ├── ARCHITECTURE.md # Client architecture +│ ├── GETTING_STARTED.md # Setup guide +│ ├── EXPORT.md # Export instructions +│ └── THEME_SETUP.md # Theming guide +├── ARCHITECTURE.md # Architecture overview +├── README.md # Setup and usage +└── EXPORT.md # Platform export guide +``` + +--- + +## RQ Job Processing + +### Job Types + +| Queue | Job Function | Purpose | +|-------|--------------|---------| +| **ai_tasks** | `generate_dm_response()` | Generate narrative responses | +| | `generate_npc_dialogue()` | NPC conversations | +| | `narrate_combat_action()` | Combat descriptions | +| **combat_tasks** | `process_combat_round()` | Execute combat turn | +| | `finalize_combat()` | Handle combat end | +| **marketplace_tasks** | `process_ended_auctions()` | Periodic auction cleanup | +| | `cleanup_old_session_logs()` | Periodic log cleanup | + +### Worker Configuration + +**Start RQ workers:** +```bash +# Single worker for all queues +rq worker ai_tasks combat_tasks marketplace_tasks --url redis://localhost:6379 + +# Or separate workers +rq worker ai_tasks --url redis://localhost:6379 +rq worker combat_tasks --url redis://localhost:6379 +rq worker marketplace_tasks --url redis://localhost:6379 +``` + +### Job Flow + +1. User takes action in game +2. Action queued to RQ with job ID +3. API returns 202 Accepted with job ID +4. Worker processes job asynchronously +5. Worker updates Appwrite document +6. Appwrite Realtime notifies all clients +7. UI updates automatically + +--- + +## Realtime Synchronization + +**Use Appwrite Realtime for WebSocket connections:** + +Frontend subscribes to session updates and receives automatic notifications when game state changes. + +**Benefits:** +- No polling required +- Instant updates for all party members +- Built-in connection management +- Automatic reconnection + +--- + +## Subscription Tiers + +| Tier | Price | AI Calls/Day | AI Model | Marketplace | Log Retention | Max Chars | Party Size | +|------|-------|--------------|----------|-------------|---------------|-----------|------------| +| **FREE** | $0 | 50 | Replicate | ✗ | 7 days | 1 | Solo | +| **BASIC** | $4.99 | 200 | Haiku | ✗ | 14 days | 3 | 2 | +| **PREMIUM** | $9.99 | 1000 | Sonnet | ✓ | 30 days | 10 | 6 | +| **ELITE** | $19.99 | Unlimited | Sonnet | ✓+ | 90 days | Unlimited | 10 | + +**ELITE Perks:** +- Priority marketplace listings +- Early access to new classes +- Exclusive items + +--- + +## Security Architecture + +### Authentication & Authorization +- Appwrite Auth handles user authentication +- HTTP-only cookies (`coc_session`) for session storage + - Prevents XSS attacks (JavaScript cannot access) + - HTTPS only in production (Secure flag) + - SameSite=Lax for CSRF protection + - 24-hour sessions (30 days with "remember me") +- Email verification required before login +- Password reset via email flow +- Auth decorators for protected routes: + - `@require_auth` - Enforce authentication + - `@require_tier(tier)` - Enforce minimum subscription tier + - `@require_email_verified` - Enforce verified email +- User tier system (Free/Basic/Premium/Elite) +- Session validation on every protected API call +- User ID verification (users can only access their own data) + +### Input Validation +- Validate all JSON payloads against schemas +- Sanitize user inputs (character names, chat messages) +- Prevent injection attacks + +### Rate Limiting +- AI endpoint limits based on subscription tier +- Marketplace actions (max listings per day) +- Combat actions (prevent spam/automation) +- Use Flask-Limiter with Redis backend + +### API Security +- CORS properly configured (only allow frontend domain) +- API keys stored in environment variables +- Appwrite permissions set correctly on all collections +- HTTPS only in production + +### Cost Control (AI) +- Daily limits on AI calls per tier +- Max tokens per request type +- Cost logging for analytics and alerts +- Graceful degradation if limits exceeded + +### Data Protection + +| Resource | Read Access | Write Access | +|----------|-------------|--------------| +| **Characters** | Owner only | Owner only | +| **Sessions** | Party members | Active player | +| **Marketplace** | All users | Listing owner | +| **Transactions** | Buyer + Seller | System only | + +--- + +## Why These Technologies? + +### Flask over FastAPI +- Team familiarity with Flask +- Async handled by RQ (don't need FastAPI's native async) +- Mature ecosystem for templates (Jinja2) +- Can migrate specific endpoints later if needed + +### RQ over Celery +- Simpler setup and configuration +- Adequate for current scale +- Easier debugging +- Redis already required for caching + +### Appwrite +- Reduces infrastructure overhead +- Built-in auth, database, storage, realtime +- Handles scaling of data layer +- Real-time WebSocket support out of box +- Self-hosted option available if needed later + +### Dataclasses over ORM +- Flexibility in data structure (easy to change) +- No database migration headaches +- JSON storage in Appwrite is schema-flexible +- Easier to serialize/deserialize +- Performance (no ORM overhead) + +### Turn-Based +- Simpler AI prompt construction +- Better for multiplayer coordination +- Classic D&D feel +- Easier to balance than real-time +- Less server load (no constant state updates) + +### Microservices Architecture +- **Independent Deployment:** Each component can be updated/scaled separately +- **Technology Flexibility:** Can use different tech stacks per component (Flask + Godot) +- **Team Scalability:** Different teams can work on different components +- **Fault Isolation:** Failures in one component don't crash entire system +- **API as Contract:** Clear interface between frontend and backend + +--- + +## Microservices Communication + +### API-First Design + +All frontends communicate with the API backend exclusively via HTTP/REST: + +``` +┌─────────────┐ +│ Public Web │──┐ +└─────────────┘ │ + │ HTTP/REST +┌─────────────┐ │ (JSON) +│ Godot Client│──┼──────────► ┌──────────────┐ +└─────────────┘ │ │ API Backend │ + │ │ (Port 5000) │ + │ └──────────────┘ + │ + Other │ + Clients │ + (Future) ────┘ +``` + +### Communication Patterns + +**1. Request/Response (Synchronous)** +- Standard CRUD operations +- Character creation, updates +- Authentication +- Data retrieval + +**Example:** +``` +POST /api/v1/characters +{ + "name": "Aragorn", + "class_id": "vanguard", + "origin_id": "noble_exile" +} + +→ API processes request +→ Returns character data + +Response: +{ + "app": "Code of Conquest", + "status": 200, + "result": { character_data }, + "error": null +} +``` + +**2. Job Queue (Asynchronous)** +- AI narrative generation +- Combat processing +- Long-running operations + +**Example:** +``` +POST /api/v1/sessions/{id}/action +{ + "action": "I search the ancient library" +} + +→ API queues job to RQ +→ Returns job ID immediately + +Response: +{ + "status": 202, + "result": { + "job_id": "abc123", + "status": "queued" + } +} + +→ Worker processes AI call +→ Updates Appwrite document +→ Appwrite Realtime notifies clients +``` + +**3. Real-Time (WebSocket via Appwrite)** +- Multiplayer session updates +- Combat state changes +- Party member actions + +**Example:** +```javascript +// Subscribe to session updates +appwrite.subscribe('sessions.{sessionId}', callback); + +// When another player acts: +→ Worker updates session in Appwrite +→ Appwrite broadcasts to all subscribers +→ All clients update UI automatically +``` + +### API Endpoints Structure + +``` +/api/v1/ +├── health # Health check +├── auth/ # Authentication +│ ├── login +│ ├── register +│ ├── logout +│ └── reset-password +├── characters/ # Character management +│ ├── GET / # List characters +│ ├── POST / # Create character +│ ├── GET /{id} # Get character +│ ├── PUT /{id} # Update character +│ └── DELETE /{id} # Delete character +├── sessions/ # Game sessions (TODO) +│ ├── POST / # Create session +│ ├── GET /{id} # Get session +│ └── POST /{id}/action # Take action +├── combat/ # Combat system (TODO) +│ └── POST /sessions/{id}/combat/action +├── marketplace/ # Marketplace (TODO) +│ ├── GET /listings +│ └── POST /listings +└── shop/ # NPC shop (TODO) + └── GET /items +``` + +### Frontend Responsibilities + +**API Backend (`/api`):** +- ✅ All business logic +- ✅ Data validation +- ✅ Database operations +- ✅ Authentication & authorization +- ✅ Game mechanics calculations +- ✅ AI orchestration + +**Web Frontend (`/public_web`):** +- ❌ No business logic +- ✅ Render HTML templates +- ✅ Form validation (UI only) +- ✅ Make HTTP requests to API +- ✅ Display API responses +- ⚠️ **Current:** Has stub services (needs refactoring) + +**Godot Client (`/godot_client`):** +- ❌ No business logic +- ✅ Render game UI +- ✅ Handle user input +- ✅ Make HTTP requests to API +- ✅ Display API responses +- ✅ Client-side animations/effects + +--- + +## Deployment Architecture + +### Development Environment + +``` +┌────────────────────┐ +│ Developer Machine │ +├────────────────────┤ +│ │ +│ API Backend │ ← Port 5000 +│ (Flask dev server)│ +│ │ +│ Web Frontend │ ← Port 5001 +│ (Flask dev server)│ +│ │ +│ Godot Editor │ ← F5 to test +│ │ +│ Redis (Docker) │ ← Port 6379 +│ │ +└────────────────────┘ + │ + │ API calls + ▼ + ┌──────────────┐ + │ Appwrite │ + │ (Cloud) │ + └──────────────┘ +``` + +**Commands:** +```bash +# Terminal 1: API Backend +cd api +source venv/bin/activate +python wsgi.py # → http://localhost:5000 + +# Terminal 2: Web Frontend +cd public_web +source venv/bin/activate +python wsgi.py # → http://localhost:5001 + +# Terminal 3: Redis +cd api +docker-compose up + +# Terminal 4: RQ Worker (optional) +cd api +source venv/bin/activate +rq worker ai_tasks --url redis://localhost:6379 + +# Godot: Open project and press F5 +``` + +### Production Environment + +``` +┌──────────────────────────────────────┐ +│ Load Balancer / CDN │ +└────────────┬─────────────────────────┘ + │ + ┌──────┴──────┐ + │ │ + ▼ ▼ +┌──────────┐ ┌──────────┐ +│ Web │ │ Web │ ← Port 8080 (internal) +│ Frontend │ │ Frontend │ +│(Gunicorn)│ │(Gunicorn)│ +└────┬─────┘ └────┬─────┘ + │ │ + │ HTTP Requests + └──────┬──────┘ + ▼ + ┌──────────┐ + │ API │ ← Port 5000 (internal) + │ Backend │ + │(Gunicorn)│ + └────┬─────┘ + │ + ┌────┴────┬──────────┬──────────┐ + ▼ ▼ ▼ ▼ +┌──────────┐ ┌─────┐ ┌────────┐ ┌────────┐ +│ Appwrite │ │Redis│ │ RQ │ │ AI │ +│ │ │ │ │Workers │ │ APIs │ +└──────────┘ └─────┘ └────────┘ └────────┘ +``` + +**Deployment Strategy:** +- Each component containerized with Docker +- Independent scaling (more web servers if needed) +- API backend as single deployment (multiple workers via Gunicorn) +- Godot client exported to native apps (distributed separately) + +### Environment Variables + +**API Backend:** +``` +FLASK_ENV=production +APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 +APPWRITE_PROJECT_ID=... +APPWRITE_API_KEY=... +APPWRITE_DATABASE_ID=... +ANTHROPIC_API_KEY=... +REPLICATE_API_TOKEN=... +REDIS_URL=redis://redis:6379 +SECRET_KEY=... +``` + +**Web Frontend:** +``` +FLASK_ENV=production +SECRET_KEY=... +API_BASE_URL=https://api.codeofconquest.com +``` + +**Godot Client:** +```gdscript +# In settings.gd or environment config +var api_base_url = "https://api.codeofconquest.com" +``` diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..49d0a83 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,580 @@ +# Deployment & Operations + +## Local Development Setup + +### Prerequisites + +| Tool | Version | Purpose | +|------|---------|---------| +| Python | 3.11+ | Backend runtime | +| Docker | Latest | Local services | +| Redis | 7.0+ | Job queue & caching | +| Git | Latest | Version control | + +### Setup Steps + +```bash +# 1. Clone repository +git clone +cd code_of_conquest + +# 2. Create virtual environment +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 3. Install dependencies +pip install -r requirements.txt + +# 4. Configure environment +cp .env.example .env +# Edit .env with your API keys and settings + +# 5. Start local services +docker-compose up -d + +# 6. Start RQ workers +rq worker ai_tasks combat_tasks marketplace_tasks & + +# 7. Run Flask development server +flask run --debug +``` + +### Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `FLASK_ENV` | development/production | Yes | +| `SECRET_KEY` | Flask secret key | Yes | +| `REPLICATE_API_KEY` | Replicate API key | Yes | +| `ANTHROPIC_API_KEY` | Anthropic API key | Yes | +| `APPWRITE_ENDPOINT` | Appwrite server URL | Yes | +| `APPWRITE_PROJECT_ID` | Appwrite project ID | Yes | +| `APPWRITE_API_KEY` | Appwrite API key | Yes | +| `REDIS_URL` | Redis connection URL | Yes | +| `LOG_LEVEL` | Logging level (DEBUG/INFO/WARNING/ERROR) | No | + +--- + +## Docker Compose (Local Development) + +**docker-compose.yml:** + +```yaml +version: '3.8' +services: + redis: + image: redis:alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + + rq-worker: + build: . + command: rq worker ai_tasks combat_tasks marketplace_tasks --url redis://redis:6379 + depends_on: + - redis + env_file: + - .env + environment: + - REDIS_URL=redis://redis:6379 + +volumes: + redis_data: +``` + +--- + +## Testing Strategy + +### Manual Testing (Preferred) + +**API Testing Document:** `docs/API_TESTING.md` + +Contains: +- Endpoint examples +- Sample curl/httpie commands +- Expected responses +- Authentication setup + +**Example API Test:** + +```bash +# Login +curl -X POST http://localhost:5000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "test@example.com", "password": "password123"}' + +# Create character (with auth token) +curl -X POST http://localhost:5000/api/v1/characters \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"name": "Aragorn", "class_id": "vanguard"}' +``` + +### Unit Tests (Optional) + +**Framework:** pytest + +**Test Categories:** + +| Category | Location | Focus | +|----------|----------|-------| +| Combat | `tests/test_combat.py` | Damage calculations, effect processing | +| Skills | `tests/test_skills.py` | Skill unlock logic, prerequisites | +| Marketplace | `tests/test_marketplace.py` | Bidding logic, auction processing | +| Character | `tests/test_character.py` | Character creation, stats | + +**Run Tests:** +```bash +# All tests +pytest + +# Specific test file +pytest tests/test_combat.py + +# With coverage +pytest --cov=app tests/ +``` + +### Load Testing + +**Tool:** Locust or Apache Bench + +**Test Scenarios:** + +| Scenario | Target | Success Criteria | +|----------|--------|------------------| +| Concurrent AI requests | 50 concurrent users | < 5s response time | +| Marketplace browsing | 100 concurrent users | < 1s response time | +| Session realtime updates | 10 players per session | < 100ms update latency | + +--- + +## Production Deployment + +### Deployment Checklist + +**Pre-Deployment:** +- [ ] All environment variables configured +- [ ] Appwrite collections created with proper permissions +- [ ] Redis configured and accessible +- [ ] RQ workers running +- [ ] SSL certificates installed +- [ ] Rate limiting configured +- [ ] Error logging/monitoring set up (Sentry recommended) +- [ ] Backup strategy for Appwrite data + +**Production Configuration:** +- [ ] `DEBUG = False` in Flask +- [ ] Secure session keys (random, long) +- [ ] CORS restricted to production domain +- [ ] Rate limits appropriate for production +- [ ] AI cost alerts configured +- [ ] CDN for static assets (optional) + +### Dockerfile + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY . . + +# Create non-root user +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 5000 + +# Run application +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "wsgi:app"] +``` + +### Build & Push Script + +**scripts/build_and_push.sh:** + +```bash +#!/bin/bash + +# Get current git branch +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Ask for tag options +read -p "Tag as :latest? (y/n) " TAG_LATEST +read -p "Push to registry? (y/n) " PUSH_IMAGE + +# Build image +docker build -t ai-dungeon-master:$BRANCH . + +if [ "$TAG_LATEST" = "y" ]; then + docker tag ai-dungeon-master:$BRANCH ai-dungeon-master:latest +fi + +if [ "$PUSH_IMAGE" = "y" ]; then + docker push ai-dungeon-master:$BRANCH + if [ "$TAG_LATEST" = "y" ]; then + docker push ai-dungeon-master:latest + fi +fi +``` + +### Production Environment + +**Recommended Stack:** +- **Web Server:** Nginx (reverse proxy) +- **WSGI Server:** Gunicorn (4+ workers) +- **Process Manager:** Supervisor or systemd +- **Redis:** Standalone or Redis Cluster +- **RQ Workers:** Separate instances for each queue + +**Scaling Strategy:** + +| Component | Scaling Method | Trigger | +|-----------|----------------|---------| +| Flask API | Horizontal (add workers) | CPU > 70% | +| RQ Workers | Horizontal (add workers) | Queue length > 100 | +| Redis | Vertical (upgrade instance) | Memory > 80% | +| Appwrite | Managed by Appwrite | N/A | + +--- + +## Monitoring & Logging + +### Application Logging + +**Logging Configuration:** + +| Level | Use Case | Examples | +|-------|----------|----------| +| DEBUG | Development only | Variable values, function calls | +| INFO | Normal operations | User actions, API calls | +| WARNING | Potential issues | Rate limit approaching, slow queries | +| ERROR | Errors (recoverable) | Failed AI calls, validation errors | +| CRITICAL | Critical failures | Database connection lost, service down | + +**Structured Logging with Structlog:** + +```python +import structlog + +logger = structlog.get_logger(__name__) + +logger.info("Combat action executed", + session_id=session_id, + character_id=character_id, + action_type="attack", + damage=15 +) +``` + +### Monitoring Tools + +**Recommended Tools:** + +| Tool | Purpose | Priority | +|------|---------|----------| +| **Sentry** | Error tracking and alerting | High | +| **Prometheus** | Metrics collection | Medium | +| **Grafana** | Metrics visualization | Medium | +| **Uptime Robot** | Uptime monitoring | High | +| **CloudWatch** | AWS logs/metrics (if using AWS) | Medium | + +### Key Metrics to Monitor + +| Metric | Alert Threshold | Action | +|--------|----------------|--------| +| API response time | > 3s average | Scale workers | +| Error rate | > 5% | Investigate logs | +| AI API errors | > 10% | Check API status | +| Queue length | > 500 | Add workers | +| Redis memory | > 80% | Upgrade instance | +| CPU usage | > 80% | Scale horizontally | +| AI cost per day | > budget × 1.2 | Investigate usage | + +### AI Cost Tracking + +**Log Structure:** + +| Field | Type | Purpose | +|-------|------|---------| +| `user_id` | str | Track per-user usage | +| `model` | str | Which model used | +| `tier` | str | FREE/STANDARD/PREMIUM | +| `tokens_used` | int | Token count | +| `cost_estimate` | float | Estimated cost | +| `timestamp` | datetime | When called | +| `context_type` | str | What prompted the call | + +**Daily Report:** +- Total AI calls per tier +- Total tokens used +- Estimated cost +- Top users by usage +- Anomaly detection (unusual spikes) + +--- + +## Security + +### Authentication & Authorization + +**Implementation:** + +| Layer | Method | Details | +|-------|--------|---------| +| **User Auth** | Appwrite Auth | Email/password, OAuth providers | +| **API Auth** | JWT tokens | Bearer token in Authorization header | +| **Session Validation** | Every API call | Verify token, check expiry | +| **Resource Access** | User ID check | Users can only access their own data | + +### Input Validation + +**Validation Strategy:** + +| Input Type | Validation | Tools | +|------------|------------|-------| +| JSON payloads | Schema validation | Marshmallow or Pydantic | +| Character names | Sanitize, length limits | Bleach library | +| Chat messages | Sanitize, profanity filter | Custom validators | +| AI prompts | Template-based only | Jinja2 (no direct user input) | + +**Example Validation:** + +| Field | Rules | +|-------|-------| +| Character name | 3-20 chars, alphanumeric + spaces only | +| Gold amount | Positive integer, max 999,999,999 | +| Action text | Max 500 chars, sanitized HTML | + +### Rate Limiting + +**Implementation:** Flask-Limiter with Redis backend + +**Limits by Tier:** + +| Tier | API Calls/Min | AI Calls/Day | Marketplace Actions/Day | +|------|---------------|--------------|------------------------| +| FREE | 30 | 50 | N/A | +| BASIC | 60 | 200 | N/A | +| PREMIUM | 120 | 1000 | 50 | +| ELITE | 300 | Unlimited | 100 | + +**Rate Limit Bypass:** +- Admin accounts +- Health check endpoints +- Static assets + +### API Security + +**Configuration:** + +| Setting | Value | Reason | +|---------|-------|--------| +| **CORS** | Production domain only | Prevent unauthorized access | +| **HTTPS** | Required | Encrypt data in transit | +| **API Keys** | Environment variables | Never in code | +| **Appwrite Permissions** | Least privilege | Collection-level security | +| **SQL Injection** | N/A | Using Appwrite (NoSQL) | +| **XSS** | Sanitize all inputs | Prevent script injection | +| **CSRF** | CSRF tokens | For form submissions | + +### Data Protection + +**Access Control Matrix:** + +| Resource | Owner | Party Member | Public | System | +|----------|-------|--------------|--------|--------| +| Characters | RW | R | - | RW | +| Sessions | R | RW (turn) | - | RW | +| Marketplace Listings | RW (own) | - | R | RW | +| Transactions | R (own) | - | - | RW | + +**RW = Read/Write, R = Read only, - = No access** + +### Secrets Management + +**Never Commit:** +- API keys +- Database credentials +- Secret keys +- Tokens + +**Best Practices:** +- Use `.env` for local development +- Use environment variables in production +- Use secrets manager (AWS Secrets Manager, HashiCorp Vault) in production +- Rotate keys regularly +- Different keys for dev/staging/prod + +--- + +## Backup & Recovery + +### Appwrite Data Backup + +**Strategy:** + +| Data Type | Backup Frequency | Retention | Method | +|-----------|------------------|-----------|--------| +| Characters | Daily | 30 days | Appwrite export | +| Sessions (active) | Hourly | 7 days | Appwrite export | +| Marketplace | Daily | 30 days | Appwrite export | +| Transactions | Daily | 90 days | Appwrite export | + +**Backup Script:** +- Export collections to JSON +- Compress and encrypt +- Upload to S3 or object storage +- Verify backup integrity + +### Disaster Recovery Plan + +| Scenario | RTO | RPO | Steps | +|----------|-----|-----|-------| +| **Database corruption** | 4 hours | 24 hours | Restore from latest backup | +| **API server down** | 15 minutes | 0 | Restart/failover to standby | +| **Redis failure** | 5 minutes | Session data loss | Restart, users re-login | +| **Complete infrastructure loss** | 24 hours | 24 hours | Restore from backups to new infrastructure | + +**RTO = Recovery Time Objective, RPO = Recovery Point Objective** + +--- + +## CI/CD Pipeline + +### Recommended Workflow + +| Stage | Actions | Tools | +|-------|---------|-------| +| **1. Commit** | Developer pushes to `dev` branch | Git | +| **2. Build** | Run tests, lint code | GitHub Actions, pytest, flake8 | +| **3. Test** | Unit tests, integration tests | pytest | +| **4. Build Image** | Create Docker image | Docker | +| **5. Deploy to Staging** | Deploy to staging environment | Docker, SSH | +| **6. Manual Test** | QA testing on staging | Manual | +| **7. Merge to Beta** | Promote to beta branch | Git | +| **8. Deploy to Beta** | Deploy to beta environment | Docker, SSH | +| **9. Merge to Master** | Production promotion | Git | +| **10. Deploy to Prod** | Deploy to production | Docker, SSH | +| **11. Tag Release** | Create version tag | Git | + +### GitHub Actions Example + +```yaml +name: CI/CD + +on: + push: + branches: [ dev, beta, master ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.11 + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run tests + run: pytest + - name: Lint + run: flake8 app/ + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build Docker image + run: docker build -t ai-dungeon-master:${{ github.ref_name }} . + - name: Push to registry + run: docker push ai-dungeon-master:${{ github.ref_name }} +``` + +--- + +## Performance Optimization + +### Caching Strategy + +| Cache Type | What to Cache | TTL | +|------------|---------------|-----| +| **Redis Cache** | Session data | 30 minutes | +| | Character data (read-heavy) | 5 minutes | +| | Marketplace listings | 1 minute | +| | NPC shop items | 1 hour | +| **Browser Cache** | Static assets | 1 year | +| | API responses (GET) | 30 seconds | + +### Database Optimization + +**Appwrite Indexing:** +- Index `userId` on characters collection +- Index `status` on game_sessions collection +- Index `listing_type` + `status` on marketplace_listings +- Index `created_at` for time-based queries + +### AI Call Optimization + +**Strategies:** + +| Strategy | Impact | Implementation | +|----------|--------|----------------| +| **Batch requests** | Reduce API calls | Combine multiple actions | +| **Cache common responses** | Reduce cost | Cache item descriptions | +| **Prompt optimization** | Reduce tokens | Shorter, more efficient prompts | +| **Model selection** | Reduce cost | Use cheaper models when appropriate | + +--- + +## Troubleshooting + +### Common Issues + +| Issue | Symptoms | Solution | +|-------|----------|----------| +| **RQ workers not processing** | Jobs stuck in queue | Check Redis connection, restart workers | +| **AI calls failing** | 401/403 errors | Verify API keys, check rate limits | +| **Appwrite connection errors** | Database errors | Check Appwrite status, verify credentials | +| **Session not updating** | Stale data in UI | Check Appwrite Realtime connection | +| **High latency** | Slow API responses | Check RQ queue length, scale workers | + +### Debug Mode + +**Enable Debug Logging:** + +```bash +export LOG_LEVEL=DEBUG +flask run --debug +``` + +**Debug Endpoints (development only):** +- `GET /debug/health` - Health check +- `GET /debug/redis` - Redis connection status +- `GET /debug/queues` - RQ queue status + +--- + +## Resources + +| Resource | URL | +|----------|-----| +| **Appwrite Docs** | https://appwrite.io/docs | +| **RQ Docs** | https://python-rq.org/ | +| **Flask Docs** | https://flask.palletsprojects.com/ | +| **Structlog Docs** | https://www.structlog.org/ | +| **HTMX Docs** | https://htmx.org/docs/ | +| **Anthropic API** | https://docs.anthropic.com/ | +| **Replicate API** | https://replicate.com/docs | diff --git a/docs/PLAYSCREEN.md b/docs/PLAYSCREEN.md new file mode 100644 index 0000000..d04df71 --- /dev/null +++ b/docs/PLAYSCREEN.md @@ -0,0 +1,272 @@ +# Production Play Screen Implementation Plan + +## Overview + +Create a new production play screen at `templates/game/play.html` with a 3-column layout optimized for immersive gameplay, separate from the dev console. + +## Layout Structure + +``` ++-------------+------------------------+------------------+ +| LEFT | MIDDLE | RIGHT | +| (280px) | (1fr flex) | (320px) | ++-------------+------------------------+------------------+ +| Character | Location Header | [History] | +| - Name/Lv | - Name, Type, Turn # | [Quests] | +| - HP/MP | Ambient Details | [NPCs] | +| - Stats | ---------------- | [Map] | +| ---------- | DM Response Area | | +| Actions | (main narrative) | Each accordion | +| [Free] | | independently | +| [Premium] | | refreshable | +| [Elite] | | | +| ---------- | | | +| [Talk NPC] | | | +| [Travel] | | | ++-------------+------------------------+------------------+ +``` + +## Files to Create + +### Templates +| File | Purpose | +|------|---------| +| `templates/game/play.html` | Main 3-column layout | +| `templates/game/partials/character_panel.html` | Left: actions + character stats | +| `templates/game/partials/narrative_panel.html` | Middle: DM response + location | +| `templates/game/partials/sidebar_history.html` | Right accordion: turn history | +| `templates/game/partials/sidebar_quests.html` | Right accordion: active quests | +| `templates/game/partials/sidebar_npcs.html` | Right accordion: NPCs at location | +| `templates/game/partials/sidebar_map.html` | Right accordion: discovered locations | +| `templates/game/partials/job_polling.html` | Job status polling partial | +| `templates/game/partials/travel_modal.html` | Travel destination modal | +| `templates/game/partials/npc_chat_modal.html` | NPC dialogue modal | + +### CSS +| File | Purpose | +|------|---------| +| `static/css/play.css` | All play screen styles | + +### Flask Views +| File | Purpose | +|------|---------| +| `app/views/game_views.py` | New blueprint for production game routes | + +### Modify +| File | Change | +|------|--------| +| `app/__init__.py` | Register `game_bp` blueprint | + +## Flask Routes + +```python +# Main routes +GET /play/session/ # Main play screen +GET /play/session//character-panel # Refresh character stats +GET /play/session//narrative # Refresh narrative +GET /play/session//history # Refresh history accordion +GET /play/session//quests # Refresh quests accordion +GET /play/session//npcs # Refresh NPCs accordion +GET /play/session//map # Refresh map accordion + +# Action routes +POST /play/session//action # Submit action -> job polling +GET /play/session//job/ # Poll job status + +# Modal routes +GET /play/session//travel-modal # Get travel modal +POST /play/session//travel # Execute travel +GET /play/session//npc//chat # Get NPC chat modal +POST /play/session//npc//talk # Send message to NPC +``` + +## CSS Theme + +```css +/* Dark fantasy theme matching existing */ +--play-bg-primary: #1a1a2a; +--play-bg-secondary: #2a2a3a; +--play-border: #4a4a5a; + +/* Action tiers */ +--action-free: #3b82f6; /* Blue */ +--action-premium: #8b5cf6; /* Purple */ +--action-elite: #f59e0b; /* Gold */ + +/* Resource bars */ +--hp-bar-fill: #ef4444; +--mp-bar-fill: #3b82f6; +``` + +## Action Button Organization + +**Free Tier (Blue):** +1. Ask Locals for Information (town/tavern) +2. Explore the Area (wilderness/dungeon) +3. Search for Supplies (any) - 2 turn cooldown +4. Rest and Recover (town/tavern/safe) - 3 turn cooldown + +**Premium Tier (Purple):** +5. Investigate Suspicious Activity (any) +6. Follow a Lead (any) +7. Make Camp (wilderness) - 5 turn cooldown + +**Elite Tier (Gold):** +8. Consult Ancient Texts (library/town) - 3 turn cooldown +9. Commune with Nature (wilderness) - 4 turn cooldown +10. Seek Audience with Authorities (town) - 5 turn cooldown + +## HTMX Patterns + +### Action Submission +```html + + + + +
+``` + +### CSS Standards + +**Organization:** +- Use BEM naming convention (Block Element Modifier) +- Group related styles together +- Use CSS variables for colors/spacing +- Mobile-first responsive design + +**Example:** +```css +:root { + --color-primary: #8b5cf6; + --color-bg: #1a1a1a; + --color-text: #e5e7eb; + --spacing-unit: 1rem; +} + +.character-card { + background: var(--color-bg); + color: var(--color-text); + padding: calc(var(--spacing-unit) * 2); + border-radius: 8px; +} + +.character-card__title { + font-size: 1.5rem; + margin-bottom: var(--spacing-unit); +} + +.character-card--highlighted { + border: 2px solid var(--color-primary); +} +``` + +### Configuration +- Environment-specific configs in `/config/*.yaml` + - `development.yaml` - Local dev settings (API URL: http://localhost:5000) + - `production.yaml` - Production settings (API URL from env var) +- `.env` for secrets (never committed) +- Typed config loaders using dataclasses + +**Configuration Structure:** +```yaml +# config/development.yaml +api: + base_url: "http://localhost:5000" + timeout: 30 + verify_ssl: false + +server: + host: "0.0.0.0" + port: 5001 + workers: 1 + +session: + lifetime_hours: 24 + cookie_secure: false + cookie_httponly: true +``` + +### Error Handling + +**View Error Handling:** +```python +@character_bp.route('/') +def character_detail(character_id): + """Display character details.""" + config = load_config() + + try: + response = requests.get( + f"{config.api.base_url}/api/v1/characters/{character_id}", + timeout=config.api.timeout + ) + response.raise_for_status() + + data = response.json() + character = data.get('result') + return render_template('character/detail.html', character=character) + + except requests.exceptions.Timeout: + logger.error("API timeout", character_id=character_id) + return render_template('error.html', message="Request timed out"), 504 + + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + return render_template('error.html', message="Character not found"), 404 + logger.error("API error", status=e.response.status_code) + return render_template('error.html', message="An error occurred"), 500 + + except Exception as e: + logger.exception("Unexpected error", character_id=character_id) + return render_template('error.html', message="An unexpected error occurred"), 500 +``` + +### Dependency Management +- Use `requirements.txt` in `/public_web` directory +- Minimal dependencies (Flask, Jinja2, requests, structlog) +- Use virtual environment: `python3 -m venv venv` +- Activate venv before running: `source venv/bin/activate` + +### Testing Standards + +**Manual Testing:** +- Use the checklist in README.md +- Test all user flows: + - [ ] Login flow + - [ ] Registration flow + - [ ] Character creation wizard (all 4 steps) + - [ ] Character list and detail views + - [ ] Logout + - [ ] Error handling + +**Browser Testing:** +- Test in Chrome, Firefox, Safari +- Test mobile responsive design +- Test HTMX interactions + +--- + +## Architecture Status + +✅ **COMPLETE:** All views use the `APIClient` class for HTTP requests to the API backend. + +**What's Implemented:** +- All views use `get_api_client()` from `app/utils/api_client.py` +- Typed error handling with `APIError`, `APINotFoundError`, `APITimeoutError`, `APIAuthenticationError` +- Session cookie forwarding for authentication +- Proper JSON serialization/deserialization + +**Minor Improvements (Optional):** +- Auth decorator could re-validate expired API sessions +- Origin/class validation could use single-item lookups instead of fetching full lists + +--- + +## Workflow for Web Frontend Development + +When implementing new pages: + +1. **Design the page** - Sketch layout, user flow +2. **Create template** - Add Jinja2 template in `/templates` +3. **Create view** - Add Flask route in `/app/views` +4. **Make API calls** - Use requests library to call API backend +5. **Handle errors** - Graceful error handling with user feedback +6. **Add styles** - Update CSS in `/static/css` +7. **Test manually** - Check all user flows + +**Example Flow:** +```bash +# 1. Create template +# templates/quest/list.html + +# 2. Create view +# app/views/quest_views.py +@quest_bp.route('/') +def list_quests(): + # Make API request + response = requests.get(f"{api_url}/api/v1/quests") + quests = response.json()['result'] + return render_template('quest/list.html', quests=quests) + +# 3. Register blueprint +# app/__init__.py +from .views.quest_views import quest_bp +app.register_blueprint(quest_bp) + +# 4. Add styles +# static/css/quest.css + +# 5. Test in browser +# http://localhost:5001/quests +``` + +--- + +## Running the Web Frontend + +### Development + +**Prerequisites:** +- Python 3.11+ +- API backend running at http://localhost:5000 + +**Setup:** +```bash +cd public_web +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +# Edit .env with your settings +``` + +**Run Development Server:** +```bash +source venv/bin/activate +export FLASK_ENV=development +python wsgi.py # → http://localhost:5001 +``` + +### Production + +**Run with Gunicorn:** +```bash +gunicorn --bind 0.0.0.0:8080 --workers 4 wsgi:app +``` + +**Environment Variables:** +``` +FLASK_ENV=production +SECRET_KEY=... +API_BASE_URL=https://api.codeofconquest.com +``` + +--- + +## Git Standards + +**Commit Messages:** +- Use conventional commit format: `feat:`, `fix:`, `docs:`, `style:`, etc. +- Examples: + - `feat(web): add quest list page` + - `fix(views): handle API timeout errors` + - `style(css): improve character card layout` + +**Branch Strategy:** +- Branch off `dev` for features +- Merge back to `dev` for testing +- Promote to `master` for production + +--- + +## Notes for Claude Code + +When working on the web frontend: + +1. **Thin client only** - No business logic, just UI +2. **Always call API** - Use HTTP requests for all data operations +3. **Handle errors gracefully** - Show user-friendly error messages +4. **Keep templates clean** - Avoid complex logic in Jinja2 +5. **Mobile responsive** - Design for all screen sizes +6. **HTMX for interactivity** - Use HTMX instead of heavy JavaScript +7. **Document refactoring needs** - Note any technical debt you encounter + +**Remember:** +- This is a thin client - all logic lives in the API backend +- The API serves multiple frontends (this web UI and Godot client) +- Security validation happens in the API, but do basic UI validation for UX +- Keep it simple - complicated logic belongs in the API diff --git a/public_web/README.md b/public_web/README.md new file mode 100644 index 0000000..b3ac696 --- /dev/null +++ b/public_web/README.md @@ -0,0 +1,220 @@ +# Code of Conquest - Public Web Frontend + +Traditional web frontend for Code of Conquest, providing HTML/HTMX UI for browser-based gameplay. + +## Overview + +This is the **public web frontend** component of Code of Conquest. It provides: + +- HTML-based UI using Jinja2 templates +- Interactive forms with HTMX +- Character creation wizard +- Session-based authentication +- Responsive design + +**Architecture:** Lightweight view layer that makes HTTP requests to the API backend for all business logic. + +## Tech Stack + +- **Framework:** Flask 3.x +- **Templates:** Jinja2 +- **Interactivity:** HTMX +- **Styling:** Vanilla CSS +- **API Client:** Python requests library +- **Logging:** Structlog +- **WSGI Server:** Gunicorn + +## Setup + +### Prerequisites +- Python 3.11+ +- Running API backend (see `/api`) + +### Installation + +1. Create virtual environment: +```bash +python3 -m venv venv +source venv/bin/activate +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + +3. Configure environment: +```bash +cp .env.example .env +# Edit .env with your settings +``` + +### Running Locally + +**Development mode:** +```bash +# Activate virtual environment +source venv/bin/activate + +# Set environment +export FLASK_ENV=development + +# Run development server +python wsgi.py +``` + +The web UI will be available at `http://localhost:5001` + +**Production mode:** +```bash +gunicorn --bind 0.0.0.0:8080 --workers 4 wsgi:app +``` + +## Configuration + +Environment-specific configs are in `/config`: +- `development.yaml` - Local development settings +- `production.yaml` - Production settings + +Key settings: +- **Server:** Port (5001 dev, 8080 prod), workers +- **API:** Backend URL, timeout +- **Session:** Cookie settings, lifetime +- **UI:** Theme, pagination + +## Project Structure + +``` +public_web/ +├── app/ # Application code +│ ├── views/ # View blueprints (Flask routes) +│ └── utils/ # Utilities (logging, auth helpers, API client) +├── templates/ # Jinja2 HTML templates +│ ├── auth/ # Authentication pages +│ ├── character/ # Character pages +│ ├── errors/ # Error pages (404, 500) +│ └── base.html # Base template +├── static/ # CSS, JS, images +│ └── css/ # Stylesheets +├── docs/ # Service-specific documentation +├── config/ # Configuration files +├── logs/ # Application logs +├── requirements.txt # Python dependencies +├── wsgi.py # WSGI entry point +└── .env.example # Environment template +``` + +## Features + +### Authentication +- Login / Register +- Password reset +- Email verification +- Session management + +### Character Management +- Character creation wizard (4 steps) + 1. Origin selection + 2. Class selection + 3. Customization + 4. Confirmation +- Character list view +- Character detail page + +### UI/UX +- Dark theme +- Responsive design +- HTMX-powered interactivity +- Form validation +- Loading states + +## Development + +### Adding New Pages + +1. Create template in `/templates`: +```html +{% extends "base.html" %} +{% block content %} + +{% endblock %} +``` + +2. Create view in `/app/views`: +```python +@blueprint.route('/your-route') +def your_view(): + return render_template('your_template.html') +``` + +3. Register blueprint in `/app/__init__.py` + +### Making API Calls + +All views use the `APIClient` class to communicate with the API backend: + +```python +from app.utils.api_client import get_api_client, APIError + +api_client = get_api_client() + +# GET request +try: + response = api_client.get("/api/v1/characters") + characters = response.get('result', {}).get('characters', []) +except APIError as e: + flash(f'Error: {e.message}', 'error') + +# POST request +response = api_client.post("/api/v1/characters", data={ + 'name': 'Hero', + 'class_id': 'warrior', + 'origin_id': 'noble' +}) +``` + +The API client handles: +- Session cookie forwarding +- Error handling with typed exceptions (`APIError`, `APINotFoundError`, `APITimeoutError`) +- JSON serialization/deserialization +- SSL verification and timeouts from config + +## Testing + +Currently, the public web frontend relies on manual testing. Use the API for automated testing. + +**Manual testing checklist:** +- [ ] Login flow +- [ ] Registration flow +- [ ] Character creation wizard (all 4 steps) +- [ ] Character list and detail views +- [ ] Logout +- [ ] Error handling + +## Deployment + +See [DEPLOYMENT.md](../docs/DEPLOYMENT.md) for production deployment instructions. + +### Environment Variables + +Required environment variables: +- `FLASK_ENV` - development or production +- `SECRET_KEY` - Flask session secret (generate random string) +- `API_BASE_URL` - (Optional) API backend URL + +## Related Components + +- **API Backend:** `/api` - REST API that this frontend calls +- **Godot Client:** `/godot_client` - Alternative native game client + +## Development Guidelines + +See [CLAUDE.md](../CLAUDE.md) in the project root for: +- Coding standards +- Template best practices +- Git conventions +- Project workflow + +## License + +Proprietary - All rights reserved diff --git a/public_web/app/__init__.py b/public_web/app/__init__.py new file mode 100644 index 0000000..46d552d --- /dev/null +++ b/public_web/app/__init__.py @@ -0,0 +1,83 @@ +""" +Public Web Frontend - Flask Application Factory + +This is a lightweight web frontend that provides HTML/HTMX UI for the Code of Conquest game. +All business logic is handled by the API backend - this frontend only renders views and +makes HTTP requests to the API. +""" + +from flask import Flask +from flask import render_template +import structlog +import yaml +import os +from pathlib import Path + + +logger = structlog.get_logger(__name__) + + +def load_config(): + """Load configuration from YAML file based on environment.""" + env = os.getenv("FLASK_ENV", "development") + config_path = Path(__file__).parent.parent / "config" / f"{env}.yaml" + + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + + logger.info("configuration_loaded", env=env, config_path=str(config_path)) + return config + + +def create_app(): + """Create and configure the Flask application.""" + app = Flask(__name__, + template_folder="../templates", + static_folder="../static") + + # Load configuration + config = load_config() + app.config.update(config) + + # Configure secret key from environment + app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') + + # Context processor to make API config and user available in templates + @app.context_processor + def inject_template_globals(): + """Make API base URL and current user available to all templates.""" + from .utils.auth import get_current_user + return { + 'api_base_url': app.config.get('api', {}).get('base_url', 'http://localhost:5000'), + 'current_user': get_current_user() + } + + # Register blueprints + from .views.auth_views import auth_bp + from .views.character_views import character_bp + from .views.game_views import game_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(character_bp) + app.register_blueprint(game_bp) + + # Register dev blueprint only in development + env = os.getenv("FLASK_ENV", "development") + if env == "development": + from .views.dev import dev_bp + app.register_blueprint(dev_bp) + logger.info("dev_blueprint_registered", message="Dev testing routes available at /dev") + + # Error handlers + @app.errorhandler(404) + def not_found(error): + return render_template('errors/404.html'), 404 + + @app.errorhandler(500) + def internal_error(error): + logger.error("internal_server_error", error=str(error)) + return render_template('errors/500.html'), 500 + + logger.info("flask_app_created", blueprints=["auth", "character", "game"]) + + return app diff --git a/public_web/app/config.py b/public_web/app/config.py new file mode 100644 index 0000000..25ea4ca --- /dev/null +++ b/public_web/app/config.py @@ -0,0 +1,87 @@ +""" +Configuration loader for Public Web Frontend + +Loads environment-specific configuration from YAML files. +""" + +import yaml +import os +from pathlib import Path +from dataclasses import dataclass +from typing import Optional +import structlog + + +logger = structlog.get_logger(__name__) + + +@dataclass +class ServerConfig: + """Server configuration settings.""" + host: str + port: int + debug: bool + workers: int = 4 + + +@dataclass +class APIConfig: + """API backend configuration.""" + base_url: str + timeout: int = 30 + verify_ssl: bool = True + + +@dataclass +class SessionConfig: + """Session configuration.""" + lifetime_hours: int + cookie_secure: bool + cookie_httponly: bool + cookie_samesite: str + + +@dataclass +class Config: + """Main configuration object.""" + server: ServerConfig + api: APIConfig + session: SessionConfig + environment: str + + +def load_config(environment: Optional[str] = None) -> Config: + """ + Load configuration from YAML file. + + Args: + environment: Environment name (development, production). If None, uses FLASK_ENV env var. + + Returns: + Config object with all settings. + """ + if environment is None: + environment = os.getenv("FLASK_ENV", "development") + + config_dir = Path(__file__).parent.parent / "config" + config_path = config_dir / f"{environment}.yaml" + + if not config_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {config_path}") + + with open(config_path, 'r') as f: + data = yaml.safe_load(f) + + config = Config( + server=ServerConfig(**data['server']), + api=APIConfig(**data['api']), + session=SessionConfig(**data['session']), + environment=environment + ) + + logger.info("config_loaded", + environment=environment, + api_url=config.api.base_url, + server_port=config.server.port) + + return config diff --git a/public_web/app/utils/__init__.py b/public_web/app/utils/__init__.py new file mode 100644 index 0000000..47ce46d --- /dev/null +++ b/public_web/app/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for public web frontend.""" diff --git a/public_web/app/utils/api_client.py b/public_web/app/utils/api_client.py new file mode 100644 index 0000000..1c4e63f --- /dev/null +++ b/public_web/app/utils/api_client.py @@ -0,0 +1,336 @@ +""" +API Client for Public Web Frontend + +Provides HTTP request wrapper for communicating with the API backend. +Handles session cookie forwarding, error handling, and response parsing. +""" + +import requests +from flask import request as flask_request, session as flask_session +from typing import Optional, Any +from app.config import load_config +from .logging import get_logger + + +logger = get_logger(__name__) + + +class APIError(Exception): + """Base exception for API errors.""" + + def __init__(self, message: str, status_code: int = 500, details: Optional[dict] = None): + self.message = message + self.status_code = status_code + self.details = details or {} + super().__init__(self.message) + + +class APITimeoutError(APIError): + """Raised when API request times out.""" + + def __init__(self, message: str = "Request timed out"): + super().__init__(message, status_code=504) + + +class APINotFoundError(APIError): + """Raised when resource not found (404).""" + + def __init__(self, message: str = "Resource not found"): + super().__init__(message, status_code=404) + + +class APIAuthenticationError(APIError): + """Raised when authentication fails (401).""" + + def __init__(self, message: str = "Authentication required"): + super().__init__(message, status_code=401) + + +class APIClient: + """ + HTTP client for making requests to the API backend. + + Usage: + client = APIClient() + + # GET request + response = client.get("/api/v1/characters") + characters = response.get("result", []) + + # POST request + response = client.post("/api/v1/characters", data={"name": "Hero"}) + + # DELETE request + client.delete("/api/v1/characters/123") + """ + + def __init__(self): + """Initialize API client with config.""" + self.config = load_config() + self.base_url = self.config.api.base_url.rstrip('/') + self.timeout = self.config.api.timeout + self.verify_ssl = self.config.api.verify_ssl + + def _get_cookies(self) -> dict: + """ + Get cookies to forward to API. + + Returns: + Dictionary of cookies to forward (session cookie). + """ + cookies = {} + + # Get session cookie from Flask session (stored after login) + try: + if 'api_session_cookie' in flask_session: + cookies['coc_session'] = flask_session['api_session_cookie'] + except RuntimeError: + # Outside of request context + pass + + return cookies + + def _save_session_cookie(self, response: requests.Response) -> None: + """ + Save session cookie from API response to Flask session. + + Args: + response: Response from requests library. + """ + try: + session_cookie = response.cookies.get('coc_session') + if session_cookie: + flask_session['api_session_cookie'] = session_cookie + logger.debug("Saved API session cookie to Flask session") + except RuntimeError: + # Outside of request context + pass + + def _get_headers(self) -> dict: + """ + Get default headers for API requests. + + Returns: + Dictionary of headers. + """ + return { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + def _handle_response(self, response: requests.Response) -> dict: + """ + Handle API response and raise appropriate exceptions. + + Args: + response: Response from requests library. + + Returns: + Parsed JSON response. + + Raises: + APIError: For various HTTP error codes. + """ + try: + data = response.json() + except ValueError: + data = {} + + # Check for errors + if response.status_code == 401: + error_msg = data.get('error', {}).get('message', 'Authentication required') + raise APIAuthenticationError(error_msg) + + if response.status_code == 404: + error_msg = data.get('error', {}).get('message', 'Resource not found') + raise APINotFoundError(error_msg) + + if response.status_code >= 400: + error_msg = data.get('error', {}).get('message', f'API error: {response.status_code}') + error_details = data.get('error', {}).get('details', {}) + raise APIError(error_msg, response.status_code, error_details) + + return data + + def get(self, endpoint: str, params: Optional[dict] = None) -> dict: + """ + Make GET request to API. + + Args: + endpoint: API endpoint (e.g., "/api/v1/characters"). + params: Optional query parameters. + + Returns: + Parsed JSON response. + + Raises: + APIError: For various error conditions. + """ + url = f"{self.base_url}{endpoint}" + + try: + response = requests.get( + url, + params=params, + headers=self._get_headers(), + cookies=self._get_cookies(), + timeout=self.timeout, + verify=self.verify_ssl + ) + + logger.debug("API GET request", url=url, status=response.status_code) + return self._handle_response(response) + + except requests.exceptions.Timeout: + logger.error("API timeout", url=url) + raise APITimeoutError() + + except requests.exceptions.ConnectionError as e: + logger.error("API connection error", url=url, error=str(e)) + raise APIError(f"Could not connect to API: {str(e)}", status_code=503) + + except requests.exceptions.RequestException as e: + logger.error("API request error", url=url, error=str(e)) + raise APIError(f"Request failed: {str(e)}") + + def post(self, endpoint: str, data: Optional[dict] = None) -> dict: + """ + Make POST request to API. + + Args: + endpoint: API endpoint (e.g., "/api/v1/characters"). + data: Request body data. + + Returns: + Parsed JSON response. + + Raises: + APIError: For various error conditions. + """ + url = f"{self.base_url}{endpoint}" + + try: + response = requests.post( + url, + json=data or {}, + headers=self._get_headers(), + cookies=self._get_cookies(), + timeout=self.timeout, + verify=self.verify_ssl + ) + + logger.debug("API POST request", url=url, status=response.status_code) + + # Save session cookie if present (for login responses) + self._save_session_cookie(response) + + return self._handle_response(response) + + except requests.exceptions.Timeout: + logger.error("API timeout", url=url) + raise APITimeoutError() + + except requests.exceptions.ConnectionError as e: + logger.error("API connection error", url=url, error=str(e)) + raise APIError(f"Could not connect to API: {str(e)}", status_code=503) + + except requests.exceptions.RequestException as e: + logger.error("API request error", url=url, error=str(e)) + raise APIError(f"Request failed: {str(e)}") + + def delete(self, endpoint: str) -> dict: + """ + Make DELETE request to API. + + Args: + endpoint: API endpoint (e.g., "/api/v1/characters/123"). + + Returns: + Parsed JSON response. + + Raises: + APIError: For various error conditions. + """ + url = f"{self.base_url}{endpoint}" + + try: + response = requests.delete( + url, + headers=self._get_headers(), + cookies=self._get_cookies(), + timeout=self.timeout, + verify=self.verify_ssl + ) + + logger.debug("API DELETE request", url=url, status=response.status_code) + return self._handle_response(response) + + except requests.exceptions.Timeout: + logger.error("API timeout", url=url) + raise APITimeoutError() + + except requests.exceptions.ConnectionError as e: + logger.error("API connection error", url=url, error=str(e)) + raise APIError(f"Could not connect to API: {str(e)}", status_code=503) + + except requests.exceptions.RequestException as e: + logger.error("API request error", url=url, error=str(e)) + raise APIError(f"Request failed: {str(e)}") + + def put(self, endpoint: str, data: Optional[dict] = None) -> dict: + """ + Make PUT request to API. + + Args: + endpoint: API endpoint. + data: Request body data. + + Returns: + Parsed JSON response. + + Raises: + APIError: For various error conditions. + """ + url = f"{self.base_url}{endpoint}" + + try: + response = requests.put( + url, + json=data or {}, + headers=self._get_headers(), + cookies=self._get_cookies(), + timeout=self.timeout, + verify=self.verify_ssl + ) + + logger.debug("API PUT request", url=url, status=response.status_code) + return self._handle_response(response) + + except requests.exceptions.Timeout: + logger.error("API timeout", url=url) + raise APITimeoutError() + + except requests.exceptions.ConnectionError as e: + logger.error("API connection error", url=url, error=str(e)) + raise APIError(f"Could not connect to API: {str(e)}", status_code=503) + + except requests.exceptions.RequestException as e: + logger.error("API request error", url=url, error=str(e)) + raise APIError(f"Request failed: {str(e)}") + + +# Singleton instance +_api_client: Optional[APIClient] = None + + +def get_api_client() -> APIClient: + """ + Get singleton API client instance. + + Returns: + APIClient instance. + """ + global _api_client + if _api_client is None: + _api_client = APIClient() + return _api_client diff --git a/public_web/app/utils/auth.py b/public_web/app/utils/auth.py new file mode 100644 index 0000000..7ad8156 --- /dev/null +++ b/public_web/app/utils/auth.py @@ -0,0 +1,146 @@ +""" +Authentication utilities for public web frontend. + +Provides authentication checking and decorators for protected routes. +Uses API backend for session validation. +""" + +from functools import wraps +from flask import session, redirect, url_for, request, flash +from .logging import get_logger + + +logger = get_logger(__file__) + +# Track last API validation time per session to avoid excessive checks +_SESSION_VALIDATION_KEY = '_api_validated_at' + + +def get_current_user(): + """ + Get the currently authenticated user from session. + + Returns: + Dictionary with user data if authenticated, None otherwise. + """ + # Check if we have user in Flask session + if 'user' in session and session.get('user'): + return session['user'] + + return None + + +def require_auth_web(f): + """ + Decorator to require authentication for web routes. + + Validates the session with the API backend and redirects to + login if not authenticated. + + Args: + f: Flask route function + + Returns: + Wrapped function that checks authentication + """ + @wraps(f) + def decorated_function(*args, **kwargs): + user = get_current_user() + if user is None: + logger.info("Unauthenticated access attempt", path=request.path) + # Store the intended destination + session['next'] = request.url + return redirect(url_for('auth_views.login')) + return f(*args, **kwargs) + return decorated_function + + +def clear_user_session(): + """ + Clear user session data. + + Should be called after logout. + """ + session.pop('user', None) + session.pop('next', None) + session.pop('api_session_cookie', None) + session.pop(_SESSION_VALIDATION_KEY, None) + logger.debug("User session cleared") + + +def require_auth_strict(revalidate_interval: int = 300): + """ + Decorator to require authentication with API session validation. + + This decorator validates the session with the API backend periodically + to ensure the session is still valid on the server side. + + Args: + revalidate_interval: Seconds between API validation checks (default 5 minutes). + Set to 0 to validate on every request. + + Returns: + Decorator function. + + Usage: + @app.route('/protected') + @require_auth_strict() # Validates every 5 minutes + def protected_route(): + pass + + @app.route('/sensitive') + @require_auth_strict(revalidate_interval=0) # Validates every request + def sensitive_route(): + pass + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + import time + from .api_client import get_api_client, APIAuthenticationError, APIError + + user = get_current_user() + if user is None: + logger.info("Unauthenticated access attempt", path=request.path) + session['next'] = request.url + return redirect(url_for('auth_views.login')) + + # Check if we need to revalidate with API + current_time = time.time() + last_validated = session.get(_SESSION_VALIDATION_KEY, 0) + + if revalidate_interval == 0 or (current_time - last_validated) > revalidate_interval: + try: + # Validate session by hitting a lightweight endpoint + api_client = get_api_client() + api_client.get("/api/v1/auth/me") + + # Update validation timestamp + session[_SESSION_VALIDATION_KEY] = current_time + session.modified = True + logger.debug("API session validated", user_id=user.get('id')) + + except APIAuthenticationError: + # Session expired on server side + logger.warning( + "API session expired", + user_id=user.get('id'), + path=request.path + ) + clear_user_session() + flash('Your session has expired. Please log in again.', 'warning') + session['next'] = request.url + return redirect(url_for('auth_views.login')) + + except APIError as e: + # API error - log but allow through (fail open for availability) + logger.error( + "API validation error", + user_id=user.get('id'), + error=str(e) + ) + # Don't block the user, but don't update validation timestamp + + return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/public_web/app/utils/logging.py b/public_web/app/utils/logging.py new file mode 100644 index 0000000..e4248aa --- /dev/null +++ b/public_web/app/utils/logging.py @@ -0,0 +1,47 @@ +""" +Logging utilities for public web frontend. + +Simplified logging wrapper using structlog. +""" + +import structlog +import logging +import sys +from pathlib import Path + + +def setup_logging(): + """Configure structured logging for the web frontend.""" + structlog.configure( + processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.dev.ConsoleRenderer() + ], + wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + + +def get_logger(name: str): + """ + Get a logger instance. + + Args: + name: Logger name (usually __file__) + + Returns: + Configured structlog logger + """ + if isinstance(name, str) and name.endswith('.py'): + # Extract module name from file path + name = Path(name).stem + + return structlog.get_logger(name) + + +# Setup logging on module import +setup_logging() diff --git a/public_web/app/views/__init__.py b/public_web/app/views/__init__.py new file mode 100644 index 0000000..66ddfbd --- /dev/null +++ b/public_web/app/views/__init__.py @@ -0,0 +1,3 @@ +""" +Views package for Code of Conquest web UI. +""" diff --git a/public_web/app/views/auth_views.py b/public_web/app/views/auth_views.py new file mode 100644 index 0000000..b70cd7c --- /dev/null +++ b/public_web/app/views/auth_views.py @@ -0,0 +1,193 @@ +""" +Auth Views Blueprint + +This module provides web UI routes for authentication: +- Login page +- Registration page +- Password reset pages +- Email verification + +All forms use HTMX to submit to the API endpoints. +""" + +from flask import Blueprint, render_template, redirect, url_for, request, session +from app.utils.auth import get_current_user, clear_user_session +from app.utils.logging import get_logger +from app.utils.api_client import get_api_client, APIError + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +auth_bp = Blueprint('auth_views', __name__) + + +@auth_bp.route('/') +def index(): + """ + Landing page / home page. + + If user is authenticated, redirect to character list. + Otherwise, redirect to login page. + """ + user = get_current_user() + + if user: + logger.info("Authenticated user accessing home, redirecting to characters", user_id=user.get('id')) + return redirect(url_for('character_views.list_characters')) + + logger.info("Unauthenticated user accessing home, redirecting to login") + return redirect(url_for('auth_views.login')) + + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + """ + Display login page and handle login. + + GET: If user is already authenticated, redirect to character list. + POST: Authenticate via API and set session. + """ + user = get_current_user() + + if user: + logger.info("User already authenticated, redirecting to characters", user_id=user.get('id')) + return redirect(url_for('character_views.list_characters')) + + if request.method == 'POST': + # Get form data + email = request.form.get('email', '').strip() + password = request.form.get('password', '') + + if not email or not password: + return render_template('auth/login.html', error="Email and password are required") + + # Call API to authenticate + try: + api_client = get_api_client() + response = api_client.post("/api/v1/auth/login", data={ + 'email': email, + 'password': password + }) + + # Store user in session + if response.get('result'): + session['user'] = response['result'] + logger.info("User logged in successfully", user_id=response['result'].get('id')) + + # Redirect to next page or character list + next_url = session.pop('next', None) + if next_url: + return redirect(next_url) + return redirect(url_for('character_views.list_characters')) + + except APIError as e: + logger.warning("Login failed", error=str(e)) + return render_template('auth/login.html', error=e.message) + + logger.info("Rendering login page") + return render_template('auth/login.html') + + +@auth_bp.route('/register') +def register(): + """ + Display registration page. + + If user is already authenticated, redirect to character list. + """ + user = get_current_user() + + if user: + logger.info("User already authenticated, redirecting to characters", user_id=user.get('id')) + return redirect(url_for('character_views.list_characters')) + + logger.info("Rendering registration page") + return render_template('auth/register.html') + + +@auth_bp.route('/forgot-password') +def forgot_password(): + """ + Display forgot password page. + + Allows users to request a password reset email. + """ + logger.info("Rendering forgot password page") + return render_template('auth/forgot_password.html') + + +@auth_bp.route('/reset-password') +def reset_password(): + """ + Display password reset page. + + This page is accessed via a link in the password reset email. + The reset token should be in the query parameters. + """ + # Get reset token from query parameters + token = request.args.get('token') + user_id = request.args.get('userId') + secret = request.args.get('secret') + + if not all([token, user_id, secret]): + logger.warning("Reset password accessed without required parameters") + # Could redirect to forgot-password with an error message + return redirect(url_for('auth_views.forgot_password')) + + logger.info("Rendering password reset page", user_id=user_id) + return render_template( + 'auth/reset_password.html', + token=token, + user_id=user_id, + secret=secret + ) + + +@auth_bp.route('/verify-email') +def verify_email(): + """ + Display email verification page. + + This page is accessed via a link in the verification email. + The verification token should be in the query parameters. + """ + # Get verification token from query parameters + token = request.args.get('token') + user_id = request.args.get('userId') + secret = request.args.get('secret') + + if not all([token, user_id, secret]): + logger.warning("Email verification accessed without required parameters") + return redirect(url_for('auth_views.login')) + + logger.info("Rendering email verification page", user_id=user_id) + return render_template( + 'auth/verify_email.html', + token=token, + user_id=user_id, + secret=secret + ) + + +@auth_bp.route('/logout', methods=['POST']) +def logout(): + """ + Handle logout by calling API and clearing session. + + This is a convenience route for non-HTMX logout forms. + """ + logger.info("Logout initiated via web form") + + # Call API to logout (this will invalidate session cookie) + try: + api_client = get_api_client() + api_client.post("/api/v1/auth/logout") + except APIError as e: + logger.error("Failed to call logout API", error=str(e)) + + # Clear local session + clear_user_session() + + return redirect(url_for('auth_views.login')) diff --git a/public_web/app/views/character_views.py b/public_web/app/views/character_views.py new file mode 100644 index 0000000..c4c0bbf --- /dev/null +++ b/public_web/app/views/character_views.py @@ -0,0 +1,666 @@ +""" +Character Views Blueprint + +This module provides web UI routes for character management: +- Character creation flow (4 steps) +- Character list view +- Character detail view +- Skill tree view + +All views require authentication and render HTML templates with HTMX. +""" + +import time +from flask import Blueprint, render_template, request, session, redirect, url_for, flash +from app.utils.auth import require_auth_web, get_current_user +from app.utils.logging import get_logger +from app.utils.api_client import ( + get_api_client, + APIError, + APINotFoundError, + APITimeoutError +) + + +# Initialize logger +logger = get_logger(__file__) + +# Create blueprint +character_bp = Blueprint('character_views', __name__, url_prefix='/characters') + +# Cache settings +_CACHE_TTL = 300 # 5 minutes in seconds +_origins_cache = {'data': None, 'timestamp': 0} +_classes_cache = {'data': None, 'timestamp': 0} + +# Wizard session timeout (1 hour) +_WIZARD_TIMEOUT = 3600 + + +def _get_cached_origins(api_client): + """ + Get origins list with caching. + + Returns cached data if available and fresh, otherwise fetches from API. + + Args: + api_client: API client instance. + + Returns: + List of origin dictionaries. + """ + global _origins_cache + current_time = time.time() + + if _origins_cache['data'] and (current_time - _origins_cache['timestamp']) < _CACHE_TTL: + logger.debug("Using cached origins") + return _origins_cache['data'] + + # Fetch from API + response = api_client.get("/api/v1/origins") + origins = response.get('result', {}).get('origins', []) + + # Update cache + _origins_cache = {'data': origins, 'timestamp': current_time} + logger.debug("Cached origins", count=len(origins)) + + return origins + + +def _get_cached_classes(api_client): + """ + Get classes list with caching. + + Returns cached data if available and fresh, otherwise fetches from API. + + Args: + api_client: API client instance. + + Returns: + List of class dictionaries. + """ + global _classes_cache + current_time = time.time() + + if _classes_cache['data'] and (current_time - _classes_cache['timestamp']) < _CACHE_TTL: + logger.debug("Using cached classes") + return _classes_cache['data'] + + # Fetch from API + response = api_client.get("/api/v1/classes") + classes = response.get('result', {}).get('classes', []) + + # Update cache + _classes_cache = {'data': classes, 'timestamp': current_time} + logger.debug("Cached classes", count=len(classes)) + + return classes + + +def _cleanup_stale_wizard_session(): + """ + Clean up stale character creation wizard session data. + + Called at the start of character creation to remove abandoned wizard data. + """ + if 'character_creation' in session: + creation_data = session['character_creation'] + started_at = creation_data.get('started_at', 0) + current_time = time.time() + + if (current_time - started_at) > _WIZARD_TIMEOUT: + logger.info("Cleaning up stale wizard session", age_seconds=int(current_time - started_at)) + session.pop('character_creation', None) + + +# ===== CHARACTER CREATION FLOW ===== + +@character_bp.route('/create/origin', methods=['GET', 'POST']) +@require_auth_web +def create_origin(): + """ + Step 1: Origin Selection + + GET: Display all available origins for user to choose from + POST: Save selected origin to session and redirect to class selection + """ + user = get_current_user() + api_client = get_api_client() + + # Clean up any stale wizard session from previous attempts + _cleanup_stale_wizard_session() + + logger.info("Character creation started - origin selection", user_id=user.get('id')) + + if request.method == 'POST': + # Get selected origin from form + origin_id = request.form.get('origin_id') + + if not origin_id: + flash('Please select an origin story.', 'error') + return redirect(url_for('character_views.create_origin')) + + # Validate origin exists using cached data + try: + origins = _get_cached_origins(api_client) + + # Check if selected origin_id is valid + valid_origin = None + for origin in origins: + if origin.get('id') == origin_id: + valid_origin = origin + break + + if not valid_origin: + flash('Invalid origin selected.', 'error') + return redirect(url_for('character_views.create_origin')) + + except APIError as e: + flash(f'Error validating origin: {e.message}', 'error') + return redirect(url_for('character_views.create_origin')) + + # Store in session with timestamp + session['character_creation'] = { + 'origin_id': origin_id, + 'step': 1, + 'started_at': time.time() + } + + logger.info("Origin selected", user_id=user.get('id'), origin_id=origin_id) + return redirect(url_for('character_views.create_class')) + + # GET: Display origin selection using cached data + try: + origins = _get_cached_origins(api_client) + except APIError as e: + logger.error("Failed to load origins", error=str(e)) + flash('Failed to load origins. Please try again.', 'error') + origins = [] + + return render_template( + 'character/create_origin.html', + origins=origins, + current_step=1 + ) + + +@character_bp.route('/create/class', methods=['GET', 'POST']) +@require_auth_web +def create_class(): + """ + Step 2: Class Selection + + GET: Display all available classes for user to choose from + POST: Save selected class to session and redirect to customization + """ + user = get_current_user() + api_client = get_api_client() + + # Ensure we have origin selected first + if 'character_creation' not in session or session['character_creation'].get('step', 0) < 1: + flash('Please start from the beginning.', 'warning') + return redirect(url_for('character_views.create_origin')) + + if request.method == 'POST': + # Get selected class from form + class_id = request.form.get('class_id') + + if not class_id: + flash('Please select a class.', 'error') + return redirect(url_for('character_views.create_class')) + + # Validate class exists using cached data + try: + classes = _get_cached_classes(api_client) + + # Check if selected class_id is valid + valid_class = None + for player_class in classes: + if player_class.get('class_id') == class_id: + valid_class = player_class + break + + if not valid_class: + flash('Invalid class selected.', 'error') + return redirect(url_for('character_views.create_class')) + + except APIError as e: + flash(f'Error validating class: {e.message}', 'error') + return redirect(url_for('character_views.create_class')) + + # Store in session + session['character_creation']['class_id'] = class_id + session['character_creation']['step'] = 2 + session.modified = True + + logger.info("Class selected", user_id=user.get('id'), class_id=class_id) + return redirect(url_for('character_views.create_customize')) + + # GET: Display class selection using cached data + try: + classes = _get_cached_classes(api_client) + except APIError as e: + logger.error("Failed to load classes", error=str(e)) + flash('Failed to load classes. Please try again.', 'error') + classes = [] + + return render_template( + 'character/create_class.html', + classes=classes, + current_step=2 + ) + + +@character_bp.route('/create/customize', methods=['GET', 'POST']) +@require_auth_web +def create_customize(): + """ + Step 3: Customize Character + + GET: Display form to enter character name + POST: Save character name to session and redirect to confirmation + """ + user = get_current_user() + api_client = get_api_client() + + # Ensure we have both origin and class selected + if 'character_creation' not in session or session['character_creation'].get('step', 0) < 2: + flash('Please complete previous steps first.', 'warning') + return redirect(url_for('character_views.create_origin')) + + if request.method == 'POST': + # Get character name from form + character_name = request.form.get('name', '').strip() + + if not character_name: + flash('Please enter a character name.', 'error') + return redirect(url_for('character_views.create_customize')) + + # Validate name length (3-30 characters) + if len(character_name) < 3 or len(character_name) > 30: + flash('Character name must be between 3 and 30 characters.', 'error') + return redirect(url_for('character_views.create_customize')) + + # Store in session + session['character_creation']['name'] = character_name + session['character_creation']['step'] = 3 + session.modified = True + + logger.info("Character name entered", user_id=user.get('id'), name=character_name) + return redirect(url_for('character_views.create_confirm')) + + # GET: Display customization form + creation_data = session.get('character_creation', {}) + + # Load origin and class for display using cached data + origin = None + player_class = None + + try: + # Find origin in cached list + if creation_data.get('origin_id'): + origins = _get_cached_origins(api_client) + for o in origins: + if o.get('id') == creation_data['origin_id']: + origin = o + break + + # Fetch class - can use single endpoint + if creation_data.get('class_id'): + response = api_client.get(f"/api/v1/classes/{creation_data['class_id']}") + player_class = response.get('result') + + except APIError as e: + logger.error("Failed to load origin/class data", error=str(e)) + + return render_template( + 'character/create_customize.html', + origin=origin, + player_class=player_class, + current_step=3 + ) + + +@character_bp.route('/create/confirm', methods=['GET', 'POST']) +@require_auth_web +def create_confirm(): + """ + Step 4: Confirm and Create Character + + GET: Display character summary for final confirmation + POST: Create the character via API and redirect to character list + """ + user = get_current_user() + api_client = get_api_client() + + # Ensure we have all data + if 'character_creation' not in session or session['character_creation'].get('step', 0) < 3: + flash('Please complete all steps first.', 'warning') + return redirect(url_for('character_views.create_origin')) + + creation_data = session.get('character_creation', {}) + + if request.method == 'POST': + # Create the character via API + try: + response = api_client.post("/api/v1/characters", data={ + 'name': creation_data['name'], + 'class_id': creation_data['class_id'], + 'origin_id': creation_data['origin_id'] + }) + + character = response.get('result', {}) + + # Clear session data + session.pop('character_creation', None) + + logger.info( + "Character created successfully", + user_id=user.get('id'), + character_id=character.get('id'), + character_name=character.get('name') + ) + + flash(f'Character "{character.get("name")}" created successfully!', 'success') + return redirect(url_for('character_views.list_characters')) + + except APIError as e: + if 'limit' in e.message.lower(): + logger.warning("Character limit exceeded", user_id=user.get('id'), error=str(e)) + flash(e.message, 'error') + return redirect(url_for('character_views.list_characters')) + + logger.error( + "Failed to create character", + user_id=user.get('id'), + error=str(e) + ) + flash('An error occurred while creating your character. Please try again.', 'error') + return redirect(url_for('character_views.create_origin')) + + # GET: Display confirmation page using cached data + origin = None + player_class = None + + try: + # Find origin in cached list + origins = _get_cached_origins(api_client) + for o in origins: + if o.get('id') == creation_data['origin_id']: + origin = o + break + + # Fetch class - can use single endpoint + response = api_client.get(f"/api/v1/classes/{creation_data['class_id']}") + player_class = response.get('result') + + except APIError as e: + logger.error("Failed to load origin/class data", error=str(e)) + + return render_template( + 'character/create_confirm.html', + character_name=creation_data['name'], + origin=origin, + player_class=player_class, + current_step=4 + ) + + +# ===== CHARACTER MANAGEMENT ===== + +@character_bp.route('/') +@require_auth_web +def list_characters(): + """ + Display list of all characters for the current user. + + Also fetches active sessions and maps them to characters. + """ + user = get_current_user() + api_client = get_api_client() + + try: + response = api_client.get("/api/v1/characters") + result = response.get('result', {}) + + # API returns characters in nested structure + characters = result.get('characters', []) + api_tier = result.get('tier', 'free') + api_limit = result.get('limit', 1) + + current_tier = api_tier + max_characters = api_limit + can_create = len(characters) < max_characters + + # Fetch all user sessions and map to characters + sessions_by_character = {} + try: + sessions_response = api_client.get("/api/v1/sessions") + sessions = sessions_response.get('result', []) + # Handle case where result is a list or a dict with sessions key + if isinstance(sessions, dict): + sessions = sessions.get('sessions', []) + + for sess in sessions: + char_id = sess.get('character_id') + if char_id: + if char_id not in sessions_by_character: + sessions_by_character[char_id] = [] + sessions_by_character[char_id].append(sess) + except (APIError, APINotFoundError) as e: + # Sessions endpoint may not exist or have issues + logger.debug("Could not fetch sessions", error=str(e)) + + # Attach sessions to each character + for character in characters: + char_id = character.get('character_id') + character['sessions'] = sessions_by_character.get(char_id, []) + + logger.info( + "Characters listed", + user_id=user.get('id'), + count=len(characters), + tier=current_tier + ) + + return render_template( + 'character/list.html', + characters=characters, + current_tier=current_tier, + max_characters=max_characters, + can_create=can_create + ) + + except APITimeoutError: + logger.error("API timeout while listing characters", user_id=user.get('id')) + flash('Request timed out. Please try again.', 'error') + return render_template('character/list.html', characters=[], can_create=False) + + except APIError as e: + logger.error("Failed to list characters", user_id=user.get('id'), error=str(e)) + flash('An error occurred while loading your characters.', 'error') + return render_template('character/list.html', characters=[], can_create=False) + + +@character_bp.route('/') +@require_auth_web +def view_character(character_id: str): + """ + Display detailed view of a specific character. + + Args: + character_id: ID of the character to view + """ + user = get_current_user() + api_client = get_api_client() + + try: + response = api_client.get(f"/api/v1/characters/{character_id}") + character = response.get('result') + + logger.info( + "Character viewed", + user_id=user.get('id'), + character_id=character_id + ) + + return render_template('character/detail.html', character=character) + + except APINotFoundError: + logger.warning("Character not found", user_id=user.get('id'), character_id=character_id) + flash('Character not found.', 'error') + return redirect(url_for('character_views.list_characters')) + + except APIError as e: + logger.error( + "Failed to view character", + user_id=user.get('id'), + character_id=character_id, + error=str(e) + ) + flash('An error occurred while loading the character.', 'error') + return redirect(url_for('character_views.list_characters')) + + +@character_bp.route('//delete', methods=['POST']) +@require_auth_web +def delete_character(character_id: str): + """ + Delete a character (soft delete - marks as inactive). + + Args: + character_id: ID of the character to delete + """ + user = get_current_user() + api_client = get_api_client() + + try: + api_client.delete(f"/api/v1/characters/{character_id}") + + logger.info("Character deleted", user_id=user.get('id'), character_id=character_id) + flash('Character deleted successfully.', 'success') + + except APINotFoundError: + logger.warning("Character not found for deletion", user_id=user.get('id'), character_id=character_id) + flash('Character not found.', 'error') + + except APIError as e: + logger.error( + "Failed to delete character", + user_id=user.get('id'), + character_id=character_id, + error=str(e) + ) + flash('An error occurred while deleting the character.', 'error') + + return redirect(url_for('character_views.list_characters')) + + +# ===== SESSION MANAGEMENT ===== + +@character_bp.route('//play', methods=['POST']) +@require_auth_web +def create_session(character_id: str): + """ + Create a new game session for a character and redirect to play screen. + + Args: + character_id: ID of the character to start a session with + """ + user = get_current_user() + api_client = get_api_client() + + try: + # Create new session via API + response = api_client.post("/api/v1/sessions", data={ + 'character_id': character_id + }) + + result = response.get('result', {}) + session_id = result.get('session_id') + + if not session_id: + flash('Failed to create session - no session ID returned.', 'error') + return redirect(url_for('character_views.list_characters')) + + logger.info( + "Session created", + user_id=user.get('id'), + character_id=character_id, + session_id=session_id + ) + + # Redirect to play screen + return redirect(url_for('game.play_session', session_id=session_id)) + + except APINotFoundError: + logger.warning("Character not found for session creation", user_id=user.get('id'), character_id=character_id) + flash('Character not found.', 'error') + return redirect(url_for('character_views.list_characters')) + + except APIError as e: + logger.error( + "Failed to create session", + user_id=user.get('id'), + character_id=character_id, + error=str(e) + ) + # Check for specific errors (session limit, etc.) + if 'limit' in str(e).lower(): + flash(f'Session limit reached: {e.message}', 'error') + else: + flash(f'Failed to create session: {e.message}', 'error') + return redirect(url_for('character_views.list_characters')) + + +@character_bp.route('//skills') +@require_auth_web +def view_skills(character_id: str): + """ + Display skill tree view for a specific character. + + Args: + character_id: ID of the character to view skills for + """ + user = get_current_user() + api_client = get_api_client() + + try: + # Get character data + response = api_client.get(f"/api/v1/characters/{character_id}") + character = response.get('result') + + # Load class data to get skill trees + class_id = character.get('class_id') + player_class = None + + if class_id: + response = api_client.get(f"/api/v1/classes/{class_id}") + player_class = response.get('result') + + logger.info( + "Skill tree viewed", + user_id=user.get('id'), + character_id=character_id + ) + + return render_template( + 'character/skills.html', + character=character, + player_class=player_class + ) + + except APINotFoundError: + logger.warning("Character not found for skills view", user_id=user.get('id'), character_id=character_id) + flash('Character not found.', 'error') + return redirect(url_for('character_views.list_characters')) + + except APIError as e: + logger.error( + "Failed to view skills", + user_id=user.get('id'), + character_id=character_id, + error=str(e) + ) + flash('An error occurred while loading the skill tree.', 'error') + return redirect(url_for('character_views.list_characters')) diff --git a/public_web/app/views/dev.py b/public_web/app/views/dev.py new file mode 100644 index 0000000..789fe94 --- /dev/null +++ b/public_web/app/views/dev.py @@ -0,0 +1,382 @@ +""" +Development-only views for testing API functionality. + +This blueprint only loads when FLASK_ENV=development. +Provides HTMX-based testing interfaces for API endpoints. +""" + +from flask import Blueprint, render_template, request, jsonify +import structlog + +from ..utils.api_client import get_api_client, APIError, APINotFoundError +from ..utils.auth import require_auth_web as require_auth, get_current_user + +logger = structlog.get_logger(__name__) + +dev_bp = Blueprint('dev', __name__, url_prefix='/dev') + + +@dev_bp.route('/') +def index(): + """Dev tools hub - links to all testing interfaces.""" + return render_template('dev/index.html') + + +@dev_bp.route('/story') +@require_auth +def story_hub(): + """Story testing hub - select character and create/load sessions.""" + client = get_api_client() + + try: + # Get user's characters + characters_response = client.get('/api/v1/characters') + result = characters_response.get('result', {}) + characters = result.get('characters', []) + + # Get user's active sessions (if endpoint exists) + sessions = [] + try: + sessions_response = client.get('/api/v1/sessions') + sessions = sessions_response.get('result', []) + except (APINotFoundError, APIError): + # Sessions list endpoint may not exist yet or has issues + pass + + return render_template( + 'dev/story.html', + characters=characters, + sessions=sessions + ) + except APIError as e: + logger.error("failed_to_load_story_hub", error=str(e)) + return render_template('dev/story.html', characters=[], sessions=[], error=str(e)) + + +@dev_bp.route('/story/session/') +@require_auth +def story_session(session_id: str): + """Story session gameplay interface.""" + client = get_api_client() + + try: + # Get session state + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + + # Get session history + history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=50') + history_data = history_response.get('result', {}) + + # Get NPCs at current location + npcs_present = [] + game_state = session_data.get('game_state', {}) + current_location = game_state.get('current_location_id') or game_state.get('current_location') + if current_location: + try: + npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location}') + npcs_present = npcs_response.get('result', {}).get('npcs', []) + except (APINotFoundError, APIError): + # NPCs endpoint may not exist yet + pass + + return render_template( + 'dev/story_session.html', + session=session_data, + history=history_data.get('history', []), + session_id=session_id, + npcs_present=npcs_present + ) + except APINotFoundError: + return render_template('dev/story.html', error=f"Session {session_id} not found"), 404 + except APIError as e: + logger.error("failed_to_load_session", session_id=session_id, error=str(e)) + return render_template('dev/story.html', error=str(e)), 500 + + +# HTMX Partial endpoints + +@dev_bp.route('/story/create-session', methods=['POST']) +@require_auth +def create_session(): + """Create a new story session - returns HTMX partial.""" + client = get_api_client() + character_id = request.form.get('character_id') + + logger.info("create_session called", + character_id=character_id, + form_data=dict(request.form)) + + if not character_id: + return '
No character selected
', 400 + + try: + response = client.post('/api/v1/sessions', {'character_id': character_id}) + session_data = response.get('result', {}) + session_id = session_data.get('session_id') + + # Return redirect script to session page + return f''' + +
Session created! Redirecting...
+ ''' + except APIError as e: + logger.error("failed_to_create_session", character_id=character_id, error=str(e)) + return f'
Failed to create session: {e}
', 500 + + +@dev_bp.route('/story/action/', methods=['POST']) +@require_auth +def take_action(session_id: str): + """Submit an action - returns job status partial for polling.""" + client = get_api_client() + + action_type = request.form.get('action_type', 'button') + prompt_id = request.form.get('prompt_id') + custom_text = request.form.get('custom_text') + question = request.form.get('question') + + payload = {'action_type': action_type} + if action_type == 'button' and prompt_id: + payload['prompt_id'] = prompt_id + elif action_type == 'custom' and custom_text: + payload['custom_text'] = custom_text + elif action_type == 'ask_dm' and question: + payload['question'] = question + + try: + response = client.post(f'/api/v1/sessions/{session_id}/action', payload) + result = response.get('result', {}) + job_id = result.get('job_id') + + # Return polling partial + return render_template( + 'dev/partials/job_status.html', + job_id=job_id, + session_id=session_id, + status='queued' + ) + except APIError as e: + logger.error("failed_to_take_action", session_id=session_id, error=str(e)) + return f'
Action failed: {e}
', 500 + + +@dev_bp.route('/story/job-status/') +@require_auth +def job_status(job_id: str): + """Poll job status - returns updated partial.""" + client = get_api_client() + session_id = request.args.get('session_id', '') + + try: + response = client.get(f'/api/v1/jobs/{job_id}/status') + result = response.get('result', {}) + status = result.get('status', 'unknown') + + if status == 'completed': + # Job done - return response + # Check for NPC dialogue (in result.dialogue) vs story action (in dm_response) + nested_result = result.get('result', {}) + if nested_result.get('context_type') == 'npc_dialogue': + # Use NPC dialogue template with conversation history + return render_template( + 'dev/partials/npc_dialogue.html', + npc_name=nested_result.get('npc_name', 'NPC'), + character_name=nested_result.get('character_name', 'You'), + conversation_history=nested_result.get('conversation_history', []), + player_line=nested_result.get('player_line', ''), + dialogue=nested_result.get('dialogue', 'No response'), + session_id=session_id + ) + else: + dm_response = result.get('dm_response', 'No response') + + return render_template( + 'dev/partials/dm_response.html', + dm_response=dm_response, + raw_result=result, + session_id=session_id + ) + elif status in ('failed', 'error'): + error_msg = result.get('error', 'Unknown error') + return f'
Job failed: {error_msg}
' + else: + # Still processing - return polling partial + return render_template( + 'dev/partials/job_status.html', + job_id=job_id, + session_id=session_id, + status=status + ) + except APIError as e: + logger.error("failed_to_get_job_status", job_id=job_id, error=str(e)) + return f'
Failed to get job status: {e}
', 500 + + +@dev_bp.route('/story/history/') +@require_auth +def get_history(session_id: str): + """Get session history - returns HTMX partial.""" + client = get_api_client() + limit = request.args.get('limit', 20, type=int) + offset = request.args.get('offset', 0, type=int) + + try: + response = client.get(f'/api/v1/sessions/{session_id}/history?limit={limit}&offset={offset}') + result = response.get('result', {}) + + return render_template( + 'dev/partials/history.html', + history=result.get('history', []), + pagination=result.get('pagination', {}), + session_id=session_id + ) + except APIError as e: + logger.error("failed_to_get_history", session_id=session_id, error=str(e)) + return f'
Failed to load history: {e}
', 500 + + +@dev_bp.route('/story/state/') +@require_auth +def get_state(session_id: str): + """Get current session state - returns HTMX partial.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/sessions/{session_id}') + session_data = response.get('result', {}) + + return render_template( + 'dev/partials/session_state.html', + session=session_data, + session_id=session_id + ) + except APIError as e: + logger.error("failed_to_get_state", session_id=session_id, error=str(e)) + return f'
Failed to load state: {e}
', 500 + + +# ===== NPC & Travel Endpoints ===== + +@dev_bp.route('/story/talk//', methods=['POST']) +@require_auth +def talk_to_npc(session_id: str, npc_id: str): + """Talk to an NPC - returns dialogue response.""" + client = get_api_client() + # Support both topic (initial greeting) and player_response (conversation) + player_response = request.form.get('player_response') + topic = request.form.get('topic', 'greeting') + + try: + payload = {'session_id': session_id} + if player_response: + # Player typed a custom response + payload['player_response'] = player_response + else: + # Initial greeting click + payload['topic'] = topic + + response = client.post(f'/api/v1/npcs/{npc_id}/talk', payload) + result = response.get('result', {}) + + # Check if it's a job-based response (async) or immediate + job_id = result.get('job_id') + if job_id: + return render_template( + 'dev/partials/job_status.html', + job_id=job_id, + session_id=session_id, + status='queued', + is_npc_dialogue=True + ) + + # Immediate response (if AI is sync or cached) + dialogue = result.get('dialogue', result.get('response', 'The NPC has nothing to say.')) + npc_name = result.get('npc_name', 'NPC') + + return f''' +
+
{npc_name} says:
+
{dialogue}
+
+ ''' + except APINotFoundError: + return '
NPC not found.
', 404 + except APIError as e: + logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e)) + return f'
Failed to talk to NPC: {e}
', 500 + + +@dev_bp.route('/story/travel-modal/') +@require_auth +def travel_modal(session_id: str): + """Get travel modal with available locations.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/travel/available?session_id={session_id}') + result = response.get('result', {}) + available_locations = result.get('available_locations', []) + + return render_template( + 'dev/partials/travel_modal.html', + locations=available_locations, + session_id=session_id + ) + except APIError as e: + logger.error("failed_to_get_travel_options", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@dev_bp.route('/story/travel/', methods=['POST']) +@require_auth +def do_travel(session_id: str): + """Travel to a new location - returns updated DM response.""" + client = get_api_client() + location_id = request.form.get('location_id') + + if not location_id: + return '
No destination selected.
', 400 + + try: + response = client.post('/api/v1/travel', { + 'session_id': session_id, + 'location_id': location_id + }) + result = response.get('result', {}) + + # Check if travel triggers a job (narrative generation) + job_id = result.get('job_id') + if job_id: + return render_template( + 'dev/partials/job_status.html', + job_id=job_id, + session_id=session_id, + status='queued' + ) + + # Immediate response + narrative = result.get('narrative', result.get('description', 'You arrive at your destination.')) + location_name = result.get('location_name', 'Unknown Location') + + # Return script to close modal and update response + return f''' + +
+ Arrived at {location_name}

+ {narrative} +
+ ''' + except APIError as e: + logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e)) + return f'
Travel failed: {e}
', 500 diff --git a/public_web/app/views/game_views.py b/public_web/app/views/game_views.py new file mode 100644 index 0000000..423d220 --- /dev/null +++ b/public_web/app/views/game_views.py @@ -0,0 +1,796 @@ +""" +Production game views for the play screen. + +Provides the main gameplay interface with 3-column layout: +- Left: Character stats + action buttons +- Middle: Narrative + location context +- Right: Accordions for history, quests, NPCs, map +""" + +from flask import Blueprint, render_template, request +import structlog + +from ..utils.api_client import get_api_client, APIError, APINotFoundError +from ..utils.auth import require_auth_web as require_auth, get_current_user + +logger = structlog.get_logger(__name__) + +game_bp = Blueprint('game', __name__, url_prefix='/play') + + +# ===== Action Definitions ===== +# Actions organized by tier - context filtering happens in template +# These are static definitions, available actions come from API session state + +DEFAULT_ACTIONS = { + 'free': [ + {'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']}, + {'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']}, + {'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2}, + {'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3} + ], + 'premium': [ + {'prompt_id': 'investigate_suspicious', 'display_text': 'Investigate Suspicious Activity', 'icon': 'magnifying_glass', 'context': ['any']}, + {'prompt_id': 'follow_lead', 'display_text': 'Follow a Lead', 'icon': 'footprints', 'context': ['any']}, + {'prompt_id': 'make_camp', 'display_text': 'Make Camp', 'icon': 'campfire', 'context': ['wilderness'], 'cooldown': 5} + ], + 'elite': [ + {'prompt_id': 'consult_texts', 'display_text': 'Consult Ancient Texts', 'icon': 'book', 'context': ['library', 'town'], 'cooldown': 3}, + {'prompt_id': 'commune_nature', 'display_text': 'Commune with Nature', 'icon': 'leaf', 'context': ['wilderness'], 'cooldown': 4}, + {'prompt_id': 'seek_audience', 'display_text': 'Seek Audience with Authorities', 'icon': 'crown', 'context': ['town'], 'cooldown': 5} + ] +} + + +def _get_user_tier(client) -> str: + """Get user's subscription tier from API or session.""" + try: + # Try to get user info which includes tier + user_response = client.get('/api/v1/auth/me') + user_data = user_response.get('result', {}) + return user_data.get('tier', 'free') + except (APIError, APINotFoundError): + # Default to free tier if we can't determine + return 'free' + + +def _build_location_from_game_state(game_state: dict) -> dict: + """Build location dict from game_state data.""" + return { + 'location_id': game_state.get('current_location_id') or game_state.get('current_location'), + 'name': game_state.get('current_location_name', game_state.get('current_location', 'Unknown')), + 'location_type': game_state.get('location_type', 'unknown'), + 'region': game_state.get('region', ''), + 'description': game_state.get('location_description', ''), + 'ambient_description': game_state.get('ambient_description', '') + } + + +def _build_character_from_api(char_data: dict) -> dict: + """ + Build character dict from API character response. + + Always returns a dict with all required fields, using sensible defaults + if the API data is incomplete or empty. + """ + if not char_data: + char_data = {} + + # Extract stats from base_stats or stats, with defaults + stats = char_data.get('base_stats', char_data.get('stats', {})) + if not stats: + stats = { + 'strength': 10, + 'dexterity': 10, + 'constitution': 10, + 'intelligence': 10, + 'wisdom': 10, + 'charisma': 10 + } + + # Calculate HP/MP - these may come from different places + # For now use defaults based on level/constitution + level = char_data.get('level', 1) + constitution = stats.get('constitution', 10) + intelligence = stats.get('intelligence', 10) + + # Simple HP/MP calculation (can be refined based on game rules) + max_hp = max(1, 50 + (level * 10) + ((constitution - 10) * level)) + max_mp = max(1, 20 + (level * 5) + ((intelligence - 10) * level // 2)) + + # Get class name from various possible locations + class_name = 'Unknown' + if char_data.get('player_class'): + class_name = char_data['player_class'].get('name', 'Unknown') + elif char_data.get('class_name'): + class_name = char_data['class_name'] + elif char_data.get('class'): + class_name = char_data['class'].replace('_', ' ').title() + + return { + 'character_id': char_data.get('character_id', ''), + 'name': char_data.get('name', 'Unknown Hero'), + 'class_name': class_name, + 'level': level, + 'current_hp': char_data.get('current_hp', max_hp), + 'max_hp': char_data.get('max_hp', max_hp), + 'current_mp': char_data.get('current_mp', max_mp), + 'max_mp': char_data.get('max_mp', max_mp), + 'stats': stats, + 'equipped': char_data.get('equipped', {}), + 'inventory': char_data.get('inventory', []), + 'gold': char_data.get('gold', 0), + 'experience': char_data.get('experience', 0) + } + + +# ===== Main Routes ===== + +@game_bp.route('/session/') +@require_auth +def play_session(session_id: str): + """ + Production play screen for a game session. + + Displays 3-column layout with character panel, narrative area, + and sidebar accordions for history/quests/NPCs/map. + """ + client = get_api_client() + + try: + # Get session state (includes game_state with location info) + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + + # Extract game state and build location info + game_state = session_data.get('game_state', {}) + location = _build_location_from_game_state(game_state) + + # Get character details - always build a valid character dict + character_id = session_data.get('character_id') + char_data = {} + if character_id: + try: + char_response = client.get(f'/api/v1/characters/{character_id}') + char_data = char_response.get('result', {}) + except (APINotFoundError, APIError) as e: + logger.warning("failed_to_load_character", character_id=character_id, error=str(e)) + + # Always build character with defaults for any missing fields + character = _build_character_from_api(char_data) + + # Get session history (last DM response for display) + history = [] + dm_response = "Your adventure awaits..." + try: + history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=10') + history_data = history_response.get('result', {}) + history = history_data.get('history', []) + # Get the most recent DM response for the main narrative panel + if history: + dm_response = history[0].get('dm_response', dm_response) + except (APINotFoundError, APIError) as e: + logger.warning("failed_to_load_history", session_id=session_id, error=str(e)) + + # Get NPCs at current location + npcs = [] + current_location_id = location.get('location_id') + if current_location_id: + try: + npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location_id}') + npcs = npcs_response.get('result', {}).get('npcs', []) + except (APINotFoundError, APIError) as e: + logger.debug("no_npcs_at_location", location_id=current_location_id, error=str(e)) + + # Get available travel destinations (discovered locations) + discovered_locations = [] + try: + travel_response = client.get(f'/api/v1/travel/available?session_id={session_id}') + travel_result = travel_response.get('result', {}) + discovered_locations = travel_result.get('available_locations', []) + # Mark current location + for loc in discovered_locations: + loc['is_current'] = loc.get('location_id') == current_location_id + except (APINotFoundError, APIError) as e: + logger.debug("failed_to_load_travel_destinations", session_id=session_id, error=str(e)) + + # Get quests (from character's active_quests or session) + quests = game_state.get('active_quests', []) + # If quests are just IDs, we could expand them, but for now use what we have + + # Get user tier + user_tier = _get_user_tier(client) + + # Build session object for template + session = { + 'session_id': session_id, + 'turn_number': session_data.get('turn_number', 0), + 'status': session_data.get('status', 'active') + } + + return render_template( + 'game/play.html', + session_id=session_id, + session=session, + character=character, + location=location, + dm_response=dm_response, + history=history, + quests=quests, + npcs=npcs, + discovered_locations=discovered_locations, + actions=DEFAULT_ACTIONS, + user_tier=user_tier + ) + + except APINotFoundError: + logger.warning("session_not_found", session_id=session_id) + return render_template('errors/404.html', message=f"Session {session_id} not found"), 404 + except APIError as e: + logger.error("failed_to_load_play_session", session_id=session_id, error=str(e)) + return render_template('errors/500.html', message=str(e)), 500 + + +# ===== Partial Refresh Routes ===== + +@game_bp.route('/session//character-panel') +@require_auth +def character_panel(session_id: str): + """Refresh character stats and actions panel.""" + client = get_api_client() + + try: + # Get session to find character and location + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + game_state = session_data.get('game_state', {}) + location = _build_location_from_game_state(game_state) + + # Get character - always build valid character dict + char_data = {} + character_id = session_data.get('character_id') + if character_id: + try: + char_response = client.get(f'/api/v1/characters/{character_id}') + char_data = char_response.get('result', {}) + except (APINotFoundError, APIError): + pass + + character = _build_character_from_api(char_data) + user_tier = _get_user_tier(client) + + return render_template( + 'game/partials/character_panel.html', + session_id=session_id, + character=character, + location=location, + actions=DEFAULT_ACTIONS, + user_tier=user_tier + ) + except APIError as e: + logger.error("failed_to_refresh_character_panel", session_id=session_id, error=str(e)) + return f'
Failed to load character panel: {e}
', 500 + + +@game_bp.route('/session//narrative') +@require_auth +def narrative_panel(session_id: str): + """Refresh narrative content panel.""" + client = get_api_client() + + try: + # Get session state + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + game_state = session_data.get('game_state', {}) + location = _build_location_from_game_state(game_state) + + # Get latest DM response from history + dm_response = "Your adventure awaits..." + try: + history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=1') + history_data = history_response.get('result', {}) + history = history_data.get('history', []) + if history: + dm_response = history[0].get('dm_response', dm_response) + except (APINotFoundError, APIError): + pass + + session = { + 'session_id': session_id, + 'turn_number': session_data.get('turn_number', 0), + 'status': session_data.get('status', 'active') + } + + return render_template( + 'game/partials/narrative_panel.html', + session_id=session_id, + session=session, + location=location, + dm_response=dm_response + ) + except APIError as e: + logger.error("failed_to_refresh_narrative", session_id=session_id, error=str(e)) + return f'
Failed to load narrative: {e}
', 500 + + +@game_bp.route('/session//history') +@require_auth +def history_accordion(session_id: str): + """Refresh history accordion content.""" + client = get_api_client() + + try: + history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=20') + history_data = history_response.get('result', {}) + history = history_data.get('history', []) + + return render_template( + 'game/partials/sidebar_history.html', + session_id=session_id, + history=history + ) + except APIError as e: + logger.error("failed_to_refresh_history", session_id=session_id, error=str(e)) + return f'
Failed to load history: {e}
', 500 + + +@game_bp.route('/session//quests') +@require_auth +def quests_accordion(session_id: str): + """Refresh quests accordion content.""" + client = get_api_client() + + try: + # Get session to access game_state.active_quests + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + game_state = session_data.get('game_state', {}) + quests = game_state.get('active_quests', []) + + return render_template( + 'game/partials/sidebar_quests.html', + session_id=session_id, + quests=quests + ) + except APIError as e: + logger.error("failed_to_refresh_quests", session_id=session_id, error=str(e)) + return f'
Failed to load quests: {e}
', 500 + + +@game_bp.route('/session//npcs') +@require_auth +def npcs_accordion(session_id: str): + """Refresh NPCs accordion content.""" + client = get_api_client() + + try: + # Get session to find current location + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + game_state = session_data.get('game_state', {}) + current_location_id = game_state.get('current_location_id') or game_state.get('current_location') + + # Get NPCs at location + npcs = [] + if current_location_id: + try: + npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location_id}') + npcs = npcs_response.get('result', {}).get('npcs', []) + except (APINotFoundError, APIError): + pass + + return render_template( + 'game/partials/sidebar_npcs.html', + session_id=session_id, + npcs=npcs + ) + except APIError as e: + logger.error("failed_to_refresh_npcs", session_id=session_id, error=str(e)) + return f'
Failed to load NPCs: {e}
', 500 + + +@game_bp.route('/session//map') +@require_auth +def map_accordion(session_id: str): + """Refresh map accordion content.""" + client = get_api_client() + + try: + # Get session for current location + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + game_state = session_data.get('game_state', {}) + current_location = _build_location_from_game_state(game_state) + current_location_id = current_location.get('location_id') + + # Get available travel destinations + discovered_locations = [] + try: + travel_response = client.get(f'/api/v1/travel/available?session_id={session_id}') + travel_result = travel_response.get('result', {}) + discovered_locations = travel_result.get('available_locations', []) + # Mark current location + for loc in discovered_locations: + loc['is_current'] = loc.get('location_id') == current_location_id + except (APINotFoundError, APIError): + pass + + return render_template( + 'game/partials/sidebar_map.html', + session_id=session_id, + discovered_locations=discovered_locations, + current_location=current_location + ) + except APIError as e: + logger.error("failed_to_refresh_map", session_id=session_id, error=str(e)) + return f'
Failed to load map: {e}
', 500 + + +# ===== Action Routes ===== + +@game_bp.route('/session//action', methods=['POST']) +@require_auth +def take_action(session_id: str): + """ + Submit an action - returns job polling partial. + + Handles two action types: + - 'button': Predefined action via prompt_id + - 'custom': Free-form player text input + """ + client = get_api_client() + action_type = request.form.get('action_type', 'button') + + try: + # Build payload based on action type + payload = {'action_type': action_type} + + if action_type == 'text' or action_type == 'custom': + # Free-form text action from player input + action_text = request.form.get('action_text', request.form.get('custom_text', '')).strip() + if not action_text: + return '
Please enter an action.
', 400 + + logger.info("Player text action submitted", + session_id=session_id, + action_text=action_text[:100]) + + payload['action_type'] = 'custom' + payload['custom_text'] = action_text + player_action = action_text + else: + # Button action via prompt_id + prompt_id = request.form.get('prompt_id') + if not prompt_id: + return '
No action selected.
', 400 + + logger.info("Player button action submitted", + session_id=session_id, + prompt_id=prompt_id) + + payload['prompt_id'] = prompt_id + player_action = None # Will display prompt_id display text + + # POST to API + response = client.post(f'/api/v1/sessions/{session_id}/action', payload) + result = response.get('result', {}) + job_id = result.get('job_id') + + if not job_id: + # Immediate response (shouldn't happen, but handle it) + dm_response = result.get('dm_response', 'Action completed.') + return render_template( + 'game/partials/dm_response.html', + session_id=session_id, + dm_response=dm_response + ) + + # Return polling partial + return render_template( + 'game/partials/job_polling.html', + session_id=session_id, + job_id=job_id, + status=result.get('status', 'queued'), + player_action=player_action + ) + + except APIError as e: + logger.error("failed_to_take_action", session_id=session_id, error=str(e)) + return f'
Action failed: {e}
', 500 + + +@game_bp.route('/session//job/') +@require_auth +def poll_job(session_id: str, job_id: str): + """Poll job status - returns updated partial.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/jobs/{job_id}/status') + result = response.get('result', {}) + status = result.get('status', 'unknown') + + if status == 'completed': + # Job done - check for NPC dialogue vs story action + nested_result = result.get('result', {}) + if nested_result.get('context_type') == 'npc_dialogue': + # NPC dialogue response - return dialogue partial + return render_template( + 'game/partials/npc_dialogue_response.html', + npc_name=nested_result.get('npc_name', 'NPC'), + character_name=nested_result.get('character_name', 'You'), + conversation_history=nested_result.get('conversation_history', []), + player_line=nested_result.get('player_line', ''), + dialogue=nested_result.get('dialogue', 'No response'), + session_id=session_id + ) + else: + # Standard DM response + dm_response = result.get('dm_response', nested_result.get('dm_response', 'No response')) + return render_template( + 'game/partials/dm_response.html', + session_id=session_id, + dm_response=dm_response + ) + + elif status in ('failed', 'error'): + error_msg = result.get('error', 'Unknown error occurred') + return f'
Action failed: {error_msg}
' + + else: + # Still processing - return polling partial to continue + return render_template( + 'game/partials/job_polling.html', + session_id=session_id, + job_id=job_id, + status=status + ) + + except APIError as e: + logger.error("failed_to_poll_job", job_id=job_id, session_id=session_id, error=str(e)) + return f'
Failed to check job status: {e}
', 500 + + +# ===== Modal Routes ===== + +@game_bp.route('/session//equipment-modal') +@require_auth +def equipment_modal(session_id: str): + """Get equipment modal with character's gear.""" + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + character = {} + if character_id: + char_response = client.get(f'/api/v1/characters/{character_id}') + char_data = char_response.get('result', {}) + character = _build_character_from_api(char_data) + + return render_template( + 'game/partials/equipment_modal.html', + session_id=session_id, + character=character + ) + except APIError as e: + logger.error("failed_to_load_equipment_modal", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@game_bp.route('/session//travel-modal') +@require_auth +def travel_modal(session_id: str): + """Get travel modal with available destinations.""" + client = get_api_client() + + try: + # Get available travel destinations + response = client.get(f'/api/v1/travel/available?session_id={session_id}') + result = response.get('result', {}) + available_locations = result.get('available_locations', []) + current_location_id = result.get('current_location') + + # Get current location details from session + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + game_state = session_data.get('game_state', {}) + current_location = _build_location_from_game_state(game_state) + + # Filter out current location from destinations + destinations = [loc for loc in available_locations if loc.get('location_id') != current_location_id] + + return render_template( + 'game/partials/travel_modal.html', + session_id=session_id, + destinations=destinations, + current_location=current_location + ) + except APIError as e: + logger.error("failed_to_load_travel_modal", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@game_bp.route('/session//travel', methods=['POST']) +@require_auth +def do_travel(session_id: str): + """Execute travel to location - returns job polling partial or immediate response.""" + client = get_api_client() + location_id = request.form.get('location_id') + + if not location_id: + return '
No destination selected.
', 400 + + try: + response = client.post('/api/v1/travel', { + 'session_id': session_id, + 'location_id': location_id + }) + result = response.get('result', {}) + + # Check if travel triggers a job (narrative generation) + job_id = result.get('job_id') + if job_id: + # Close modal and return job polling partial + return f''' + + ''' + render_template( + 'game/partials/job_polling.html', + job_id=job_id, + session_id=session_id, + status='queued' + ) + + # Immediate response (no AI generation) + narrative = result.get('narrative', result.get('description', 'You arrive at your destination.')) + location_name = result.get('location_name', 'Unknown Location') + + # Close modal and update response area + return f''' + +
+ Arrived at {location_name}

+ {narrative} +
+ ''' + render_template( + 'game/partials/dm_response.html', + session_id=session_id, + dm_response=f"**Arrived at {location_name}**\n\n{narrative}" + ) + + except APIError as e: + logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e)) + return f'
Travel failed: {e}
', 500 + + +@game_bp.route('/session//npc//chat') +@require_auth +def npc_chat_modal(session_id: str, npc_id: str): + """Get NPC chat modal with conversation history.""" + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + # Get NPC details with relationship info + npc_response = client.get(f'/api/v1/npcs/{npc_id}?character_id={character_id}') + npc_data = npc_response.get('result', {}) + + npc = { + 'npc_id': npc_data.get('npc_id'), + 'name': npc_data.get('name'), + 'role': npc_data.get('role'), + 'appearance': npc_data.get('appearance', {}).get('brief', ''), + 'tags': npc_data.get('tags', []) + } + + # Get relationship info + interaction_summary = npc_data.get('interaction_summary', {}) + relationship_level = interaction_summary.get('relationship_level', 50) + interaction_count = interaction_summary.get('interaction_count', 0) + + # Conversation history would come from character's npc_interactions + # For now, we'll leave it empty - the API returns it in dialogue responses + conversation_history = [] + + return render_template( + 'game/partials/npc_chat_modal.html', + session_id=session_id, + npc=npc, + conversation_history=conversation_history, + relationship_level=relationship_level, + interaction_count=interaction_count + ) + + except APINotFoundError: + return '
NPC not found
', 404 + except APIError as e: + logger.error("failed_to_load_npc_chat", session_id=session_id, npc_id=npc_id, error=str(e)) + return f''' + + ''' + + +@game_bp.route('/session//npc//talk', methods=['POST']) +@require_auth +def talk_to_npc(session_id: str, npc_id: str): + """Send message to NPC - returns dialogue response or job polling partial.""" + client = get_api_client() + + # Support both topic (initial greeting) and player_response (conversation) + player_response = request.form.get('player_response') + topic = request.form.get('topic', 'greeting') + + try: + payload = {'session_id': session_id} + if player_response: + # Player typed a custom response + payload['player_response'] = player_response + else: + # Initial greeting click + payload['topic'] = topic + + response = client.post(f'/api/v1/npcs/{npc_id}/talk', payload) + result = response.get('result', {}) + + # Check if it's a job-based response (async) or immediate + job_id = result.get('job_id') + if job_id: + # Return job polling partial for the chat area + return render_template( + 'game/partials/job_polling.html', + job_id=job_id, + session_id=session_id, + status='queued', + is_npc_dialogue=True + ) + + # Immediate response (if AI is sync or cached) + dialogue = result.get('dialogue', result.get('response', 'The NPC has nothing to say.')) + npc_name = result.get('npc_name', 'NPC') + + # Return dialogue in chat format + player_display = player_response if player_response else f"[{topic}]" + return f''' +
+ You: {player_display} +
+
+ {npc_name}: {dialogue} +
+ ''' + + except APINotFoundError: + return '
NPC not found.
', 404 + except APIError as e: + logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e)) + return f'
Failed to talk to NPC: {e}
', 500 diff --git a/public_web/config/development.yaml b/public_web/config/development.yaml new file mode 100644 index 0000000..39f88ed --- /dev/null +++ b/public_web/config/development.yaml @@ -0,0 +1,46 @@ +# Development Configuration for Public Web Frontend + +app: + name: "Code of Conquest - Web UI" + version: "0.1.0" + environment: "development" + debug: true + +server: + host: "0.0.0.0" + port: 8000 # Different port from API (5000) + debug: true + workers: 1 + +api: + # API backend base URL + base_url: "http://localhost:5000" + timeout: 30 + verify_ssl: false # Set to true in production + +session: + # Session lifetime in hours + lifetime_hours: 24 + cookie_secure: false # Set to true in production (HTTPS only) + cookie_httponly: true + cookie_samesite: "Lax" + +cors: + enabled: true + origins: + - "http://localhost:8000" + - "http://127.0.0.1:8000" + +logging: + level: "DEBUG" + format: "json" + handlers: + - "console" + - "file" + file_path: "logs/app.log" + +# UI Settings +ui: + theme: "dark" + items_per_page: 20 + enable_animations: true diff --git a/public_web/config/production.yaml b/public_web/config/production.yaml new file mode 100644 index 0000000..9bc402f --- /dev/null +++ b/public_web/config/production.yaml @@ -0,0 +1,43 @@ +# Production Configuration for Public Web Frontend + +app: + name: "Code of Conquest - Web UI" + version: "0.1.0" + environment: "production" + debug: false + +server: + host: "0.0.0.0" + port: 8000 + workers: 4 + +api: + # API backend base URL (set via environment variable in production) + # Use: API_BASE_URL environment variable + base_url: "https://api.codeofconquest.com" + timeout: 30 + verify_ssl: true + +session: + # Session lifetime in hours + lifetime_hours: 24 + cookie_secure: true # HTTPS only + cookie_httponly: true + cookie_samesite: "Strict" + +cors: + enabled: false # Not needed in production if same domain + +logging: + level: "INFO" + format: "json" + handlers: + - "console" + - "file" + file_path: "/var/log/coc/web.log" + +# UI Settings +ui: + theme: "dark" + items_per_page: 20 + enable_animations: true diff --git a/public_web/docs/.gitkeep b/public_web/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public_web/docs/HTMX_PATTERNS.md b/public_web/docs/HTMX_PATTERNS.md new file mode 100644 index 0000000..f73df65 --- /dev/null +++ b/public_web/docs/HTMX_PATTERNS.md @@ -0,0 +1,651 @@ +# HTMX Integration Patterns - Public Web Frontend + +**Last Updated:** November 18, 2025 + +--- + +## Overview + +This document outlines HTMX usage patterns, best practices, and common implementations for the Code of Conquest web frontend. + +**HTMX Benefits:** +- Server-side rendering with dynamic interactions +- No JavaScript framework overhead +- Progressive enhancement (works without JS) +- Clean separation of concerns +- Natural integration with Flask/Jinja2 + +--- + +## Core HTMX Attributes + +### hx-get / hx-post / hx-put / hx-delete + +Make HTTP requests from HTML elements: + +```html + + + + +
+ + +
+ + + +``` + +### hx-target + +Specify where to insert the response: + +```html + + + + + + + + + +``` + +### hx-swap + +Control how content is swapped: + +```html + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+``` + +### hx-trigger + +Specify what triggers the request: + +```html + + + + + + + +
...
+ + +
...
+ + + + + +
Status: ...
+ + +
...
+``` + +--- + +## Common Patterns + +### Form Submission + +**Pattern:** Submit form via AJAX, replace form with result + +```html +
+ + + + + + +
+``` + +**Backend Response:** +```python +@characters_bp.route('/', methods=['POST']) +def create_character(): + # Create character via API + response = api_client.post('/characters', request.form) + + if response.status_code == 200: + character = response.json()['result'] + return render_template('partials/character_card.html', character=character) + else: + return render_template('partials/form_error.html', error=response.json()['error']) +``` + +### Delete with Confirmation + +**Pattern:** Confirm before deleting, remove element on success + +```html +
+

{{ character.name }}

+ + +
+``` + +**Backend Response (empty for delete):** +```python +@characters_bp.route('/', methods=['DELETE']) +def delete_character(character_id): + api_client.delete(f'/characters/{character_id}') + return '', 200 # Empty response removes element +``` + +### Search/Filter + +**Pattern:** Live search with debouncing + +```html + + +Searching... + +
+ +
+``` + +**Backend:** +```python +@characters_bp.route('/search') +def search_characters(): + query = request.args.get('search', '') + characters = api_client.get(f'/characters?search={query}') + return render_template('partials/character_list.html', characters=characters) +``` + +### Pagination + +**Pattern:** Load more items + +```html +
+ {% for character in characters %} + {% include 'partials/character_card.html' %} + {% endfor %} +
+ +{% if has_more %} + +{% endif %} +``` + +### Inline Edit + +**Pattern:** Click to edit, save inline + +```html +
+ + {{ character.name }} ✏️ + +
+``` + +**Edit Form Response:** +```html +
+
+ + + +
+
+``` + +### Modal Dialog + +**Pattern:** Load modal content dynamically + +```html + + + +``` + +**Modal Response:** +```html + + + +``` + +### Tabs + +**Pattern:** Tab switching without page reload + +```html +
+ + + +
+ +
+ +
+``` + +### Polling for Updates + +**Pattern:** Auto-refresh session status + +```html +
+ Loading status... +
+``` + +**Conditional Polling (stop when complete):** +```html +
+ Status: {{ session.status }} +
+``` + +### Infinite Scroll + +**Pattern:** Load more as user scrolls + +```html +
+ {% for character in characters %} + {% include 'partials/character_card.html' %} + {% endfor %} +
+ +{% if has_more %} +
+ Loading more... +
+{% endif %} +``` + +--- + +## Advanced Patterns + +### Dependent Dropdowns + +**Pattern:** Update second dropdown based on first selection + +```html + + +
+ +
+``` + +### Out of Band Swaps + +**Pattern:** Update multiple areas from single response + +```html + + +
+
HP: 50/100
+``` + +**Backend Response:** +```html + +
You dealt 15 damage!
+ + +
HP: 45/100
+``` + +### Optimistic UI + +**Pattern:** Show result immediately, revert on error + +```html + +``` + +**Immediate feedback with CSS:** +```css +.htmx-request .htmx-indicator { + display: inline; +} +``` + +### Progressive Enhancement + +**Pattern:** Fallback to normal form submission + +```html +
+ + + +
+``` + +--- + +## HTMX + Appwrite Realtime + +### Hybrid Approach + +Use HTMX for user actions, Appwrite Realtime for server push updates: + +```html + + + + + +``` + +--- + +## Error Handling + +### Show Error Messages + +```html +
+ +
+ +
+``` + +**Backend Error Response:** +```python +@characters_bp.route('/', methods=['POST']) +def create_character(): + try: + # Create character + pass + except ValidationError as e: + response = make_response(render_template('partials/error.html', error=str(e)), 400) + response.headers['HX-Retarget'] = '#error-container' + return response +``` + +### Retry on Failure + +```html +
+ Loading... +
+``` + +--- + +## Loading Indicators + +### Global Indicator + +```html + + + +Loading... +``` + +### Inline Indicator + +```html + +``` + +--- + +## Best Practices + +### 1. Use Semantic HTML +```html + + + + +
Submit
+``` + +### 2. Provide Fallbacks +```html +
+ +
+``` + +### 3. Use hx-indicator for Loading States +```html + + +``` + +### 4. Debounce Search Inputs +```html + +``` + +### 5. Use CSRF Protection +```html +
+ +
+``` + +--- + +## Debugging + +### HTMX Events + +Listen to HTMX events for debugging: + +```html + +``` + +### HTMX Logger Extension + +```html + + +``` + +--- + +## Performance Tips + +### 1. Use hx-select to Extract Partial +```html + +``` + +### 2. Disable During Request +```html + +``` + +### 3. Use hx-sync for Sequential Requests +```html +
+ +
+``` + +--- + +## Related Documentation + +- **[TEMPLATES.md](TEMPLATES.md)** - Template structure and conventions +- **[TESTING.md](TESTING.md)** - Manual testing guide +- **[/api/docs/API_REFERENCE.md](../../api/docs/API_REFERENCE.md)** - API endpoints +- **[HTMX Official Docs](https://htmx.org/docs/)** - Complete HTMX documentation + +--- + +**Document Version:** 1.1 +**Created:** November 18, 2025 +**Last Updated:** November 21, 2025 diff --git a/public_web/docs/MULTIPLAYER.md b/public_web/docs/MULTIPLAYER.md new file mode 100644 index 0000000..c25b41b --- /dev/null +++ b/public_web/docs/MULTIPLAYER.md @@ -0,0 +1,738 @@ +# Multiplayer System - Web Frontend + +**Status:** Planned +**Phase:** 6 (Multiplayer Sessions) +**Last Updated:** November 18, 2025 + +--- + +## Overview + +The Web Frontend handles the UI/UX for multiplayer sessions, including lobby screens, active session displays, combat interfaces, and realtime synchronization via JavaScript/HTMX patterns. + +**Frontend Responsibilities:** +- Render multiplayer session creation forms +- Display lobby with player list and ready status +- Show active session UI (timer, party status, combat) +- Handle realtime updates via Appwrite Realtime WebSocket +- Submit player actions to API backend +- Display session completion and rewards + +**Technical Stack:** +- **Templates**: Jinja2 +- **Interactivity**: HTMX for AJAX interactions +- **Realtime**: Appwrite JavaScript SDK for WebSocket subscriptions +- **Styling**: Custom CSS (responsive design) + +--- + +## UI/UX Design + +### Session Creation Screen (Host) + +**Route:** `/multiplayer/create` + +**Template:** `templates/multiplayer/create.html` + +```html +{% extends "base.html" %} + +{% block content %} +
+

Create Multiplayer Session Premium

+

Invite your friends to a 2-hour co-op adventure!

+ +
+ +
+ +
+ + + + + + + + +
+
+ +
+ +
+ + + + + + + + + + + +
+
+ +

+ AI will generate a custom campaign for your party based on your + characters' levels and the selected difficulty. +

+ + +
+ +
+
+{% endblock %} +``` + +**HTMX Pattern:** +- Form submission via `hx-post` to API endpoint +- Response replaces `#session-result` div +- API returns session details and redirects to lobby + +### Lobby Screen + +**Route:** `/multiplayer/lobby/{session_id}` + +**Template:** `templates/multiplayer/lobby.html` + +```html +{% extends "base.html" %} + +{% block content %} +
+

Multiplayer Lobby + +

+ +
+

Session Code: {{ session.invite_code }}

+

+ Invite Link: + + +

+
+ +
+

Difficulty: {{ session.campaign.difficulty|title }}

+

Duration: 2 hours

+
+ +
+ + {% for member in session.party_members %} +
+ {% if member.is_host %} + 👑 + {% endif %} + {{ member.username }} {% if member.is_host %}(Host){% endif %} + {{ member.character_snapshot.name }} - {{ member.character_snapshot.player_class.name }} Lvl {{ member.character_snapshot.level }} + + {% if member.is_ready %} + ✅ Ready + {% else %} + ⏳ Not Ready + {% endif %} + +
+ {% endfor %} + + + {% for i in range(session.max_players - session.party_members|length) %} +
+ [Waiting for player...] +
+ {% endfor %} +
+ +
+ {% if current_user.user_id == session.host_user_id %} + + + {% else %} + + + {% endif %} +
+ +
+
+ + +{% endblock %} +``` + +**Realtime Pattern:** +- JavaScript subscribes to Appwrite Realtime for session updates +- Updates player ready status dynamically +- Redirects to active session when host starts +- No page reload required + +### Active Session Screen + +**Route:** `/multiplayer/session/{session_id}` + +**Template:** `templates/multiplayer/session.html` + +```html +{% extends "base.html" %} + +{% block content %} +
+
+

{{ session.campaign.title }}

+
+ ⏱️ {{ time_remaining }} Remaining +
+
+ +
+

Campaign Progress: + + + + ({{ session.current_encounter_index }}/{{ session.campaign.encounters|length }} encounters) +

+
+ +
+

Party:

+
+ {% for member in session.party_members %} +
+ {{ member.character_snapshot.name }} + HP: {{ member.character_snapshot.current_hp }}/{{ member.character_snapshot.max_hp }} + {% if not member.is_connected %} + ⚠️ Disconnected + {% endif %} +
+ {% endfor %} +
+
+ +
+

Narrative:

+
+ {% for entry in session.conversation_history %} +
+ {{ entry.role|title }}: {{ entry.content }} +
+ {% endfor %} +
+
+ + {% if session.combat_encounter %} + +
+

Combat: {{ current_encounter.title }}

+ +
+

Turn Order:

+
    + {% for combatant_id in session.combat_encounter.turn_order %} +
  1. + {{ get_combatant_name(combatant_id) }} + {% if is_current_user_turn(combatant_id) %} + (Your Turn!) + {% endif %} +
  2. + {% endfor %} +
+
+ + {% if is_current_user_turn() %} + +
+

Your Action:

+
+ + + + + + + + +
+
+
+ {% else %} +

Waiting for other players...

+ {% endif %} +
+ {% endif %} +
+ + +{% endblock %} +``` + +**Realtime Pattern:** +- Subscribe to session document changes +- Update timer countdown locally +- Update party HP dynamically +- Reload combat UI section when turn changes +- Redirect to completion screen when session ends + +### Session Complete Screen + +**Route:** `/multiplayer/complete/{session_id}` + +**Template:** `templates/multiplayer/complete.html` + +```html +{% extends "base.html" %} + +{% block content %} +
+{% endblock %} +``` + +--- + +## HTMX Integration Patterns + +### Form Submissions + +All multiplayer actions use HTMX for seamless AJAX submissions: + +**Pattern:** +```html +
+ + +
+``` + +**Benefits:** +- No page reload +- Partial page updates +- Progressive enhancement (works without JS) + +### Realtime Updates + +Combine HTMX with Appwrite Realtime for optimal UX: + +**Pattern:** +```javascript +// Listen for realtime events +client.subscribe(`channel`, response => { + // Update via HTMX partial reload + htmx.ajax('GET', '/partial-url', { + target: '#target-div', + swap: 'outerHTML' + }); +}); +``` + +### Polling Fallback + +For browsers without WebSocket support, use HTMX polling: + +```html +
+ +
+``` + +--- + +## Template Organization + +### Directory Structure + +``` +templates/multiplayer/ +├── create.html # Session creation form +├── lobby.html # Lobby screen +├── session.html # Active session UI +├── complete.html # Session complete screen +├── partials/ +│ ├── party_list.html # Reusable party member list +│ ├── combat_ui.html # Combat interface partial +│ └── timer.html # Timer component +└── components/ + ├── invite_link.html # Invite link copy widget + └── ready_toggle.html # Ready status toggle button +``` + +### Partial Template Pattern + +**Example:** `templates/multiplayer/partials/party_list.html` + +```html + +
+ {% for member in party_members %} +
+ {% if member.is_host %} + 👑 + {% endif %} + {{ member.username }} + {{ member.character_snapshot.name }} - {{ member.character_snapshot.player_class.name }} Lvl {{ member.character_snapshot.level }} + + {% if member.is_ready %}✅ Ready{% else %}⏳ Not Ready{% endif %} + +
+ {% endfor %} +
+``` + +**Usage:** +```html +{% include 'multiplayer/partials/party_list.html' with party_members=session.party_members %} +``` + +--- + +## JavaScript/Appwrite Realtime + +### Setup + +**Base template** (`templates/base.html`): + +```html + + + + +``` + +### Subscription Patterns + +**Session Updates:** +```javascript +const client = new Appwrite.Client() + .setEndpoint('{{ config.appwrite_endpoint }}') + .setProject('{{ config.appwrite_project_id }}'); + +// Subscribe to specific session +client.subscribe( + `databases.{{ db_id }}.collections.multiplayer_sessions.documents.{{ session_id }}`, + response => { + console.log('Session updated:', response.payload); + + // Handle different event types + switch(response.events[0]) { + case 'databases.*.collections.*.documents.*.update': + handleSessionUpdate(response.payload); + break; + } + } +); +``` + +**Combat Updates:** +```javascript +client.subscribe( + `databases.{{ db_id }}.collections.combat_encounters.documents.{{ encounter_id }}`, + response => { + updateCombatUI(response.payload); + } +); +``` + +### Error Handling + +```javascript +client.subscribe(channel, response => { + // Handle updates +}, error => { + console.error('Realtime error:', error); + // Fallback to polling + startPolling(); +}); + +function startPolling() { + setInterval(() => { + htmx.ajax('GET', '/api/v1/sessions/multiplayer/{{ session_id }}', { + target: '#session-container', + swap: 'outerHTML' + }); + }, 5000); +} +``` + +--- + +## View Layer Implementation + +### Flask View Functions + +**Create Session View:** +```python +@multiplayer_bp.route('/create', methods=['GET', 'POST']) +@require_auth +def create_session(): + """Render session creation form.""" + + if request.method == 'POST': + # Forward to API backend + response = requests.post( + f"{API_BASE_URL}/api/v1/sessions/multiplayer/create", + json=request.form.to_dict(), + headers={"Authorization": f"Bearer {session['auth_token']}"} + ) + + if response.status_code == 200: + session_data = response.json()['result'] + return redirect(url_for('multiplayer.lobby', session_id=session_data['session_id'])) + else: + flash('Failed to create session', 'error') + + return render_template('multiplayer/create.html') + +@multiplayer_bp.route('/lobby/') +@require_auth +def lobby(session_id): + """Display lobby screen.""" + + # Fetch session from API + response = requests.get( + f"{API_BASE_URL}/api/v1/sessions/multiplayer/{session_id}", + headers={"Authorization": f"Bearer {session['auth_token']}"} + ) + + session_data = response.json()['result'] + return render_template('multiplayer/lobby.html', session=session_data) +``` + +--- + +## Testing Checklist + +### Manual Testing Tasks + +- [ ] Session creation form submits correctly +- [ ] Invite link copies to clipboard +- [ ] Lobby updates when players join +- [ ] Ready status toggles work +- [ ] Host can start session when all ready +- [ ] Timer displays and counts down correctly +- [ ] Party HP updates during combat +- [ ] Combat actions submit correctly +- [ ] Turn order highlights current player +- [ ] Realtime updates work across multiple browsers +- [ ] Session completion screen displays rewards +- [ ] Disconnection handling shows warnings + +--- + +## Related Documentation + +- **[/api/docs/MULTIPLAYER.md](../../api/docs/MULTIPLAYER.md)** - Backend API endpoints and business logic +- **[TEMPLATES.md](TEMPLATES.md)** - Template structure and conventions +- **[HTMX_PATTERNS.md](HTMX_PATTERNS.md)** - HTMX integration patterns +- **[TESTING.md](TESTING.md)** - Manual testing guide + +--- + +**Document Version:** 1.0 (Microservices Split) +**Created:** November 18, 2025 +**Last Updated:** November 18, 2025 diff --git a/public_web/docs/README.md b/public_web/docs/README.md new file mode 100644 index 0000000..13f2a9a --- /dev/null +++ b/public_web/docs/README.md @@ -0,0 +1,25 @@ +# Public Web Frontend Documentation + +This folder contains documentation specific to the public web frontend service. + +## Documents + +- **[TEMPLATES.md](TEMPLATES.md)** - Template structure, naming conventions, and Jinja2 best practices +- **[HTMX_PATTERNS.md](HTMX_PATTERNS.md)** - HTMX integration patterns and dynamic UI updates +- **[TESTING.md](TESTING.md)** - Manual testing checklist and browser testing guide +- **[MULTIPLAYER.md](MULTIPLAYER.md)** - Multiplayer lobby and session UI implementation + +## Quick Reference + +**Service Role:** Thin UI layer that makes HTTP requests to API backend + +**Tech Stack:** Flask + Jinja2 + HTMX + Vanilla CSS + +**Port:** 5001 (development), 8080 (production) + +## Related Documentation + +- **[../CLAUDE.md](../CLAUDE.md)** - Web frontend development guidelines +- **[../README.md](../README.md)** - Setup and usage guide +- **[../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md)** - System architecture overview +- **[../../api/docs/API_REFERENCE.md](../../api/docs/API_REFERENCE.md)** - API endpoints to call diff --git a/public_web/docs/TEMPLATES.md b/public_web/docs/TEMPLATES.md new file mode 100644 index 0000000..601bd4f --- /dev/null +++ b/public_web/docs/TEMPLATES.md @@ -0,0 +1,431 @@ +# Template Structure and Conventions - Public Web Frontend + +**Last Updated:** November 18, 2025 + +--- + +## Overview + +This document outlines the template structure, naming conventions, and best practices for Jinja2 templates in the Code of Conquest web frontend. + +**Template Philosophy:** +- Clean, semantic HTML +- Separation of concerns (templates, styles, scripts) +- Reusable components via includes and macros +- Responsive design patterns +- Accessibility-first + +--- + +## Directory Structure + +``` +templates/ +├── base.html # Base template (all pages extend this) +├── errors/ # Error pages +│ ├── 404.html +│ ├── 500.html +│ └── 403.html +├── auth/ # Authentication pages +│ ├── login.html +│ ├── register.html +│ └── forgot_password.html +├── dashboard/ # User dashboard +│ └── index.html +├── characters/ # Character management +│ ├── list.html +│ ├── create.html +│ ├── view.html +│ └── edit.html +├── sessions/ # Game sessions +│ ├── create.html +│ ├── active.html +│ ├── history.html +│ └── view.html +├── multiplayer/ # Multiplayer sessions +│ ├── create.html +│ ├── lobby.html +│ ├── session.html +│ └── complete.html +├── partials/ # Reusable partial templates +│ ├── navigation.html +│ ├── footer.html +│ ├── character_card.html +│ ├── combat_ui.html +│ └── session_summary.html +├── components/ # Reusable UI components (macros) +│ ├── forms.html +│ ├── buttons.html +│ ├── alerts.html +│ └── modals.html +└── macros/ # Jinja2 macros + ├── form_fields.html + └── ui_elements.html +``` + +--- + +## Base Template + +**File:** `templates/base.html` + +```html + + + + + + {% block title %}Code of Conquest{% endblock %} + + + + {% block extra_css %}{% endblock %} + + + + + + + + {% block extra_head %}{% endblock %} + + + {% include 'partials/navigation.html' %} + +
+ {% block flash_messages %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% endblock %} + + {% block content %} + + {% endblock %} +
+ + {% include 'partials/footer.html' %} + + + + {% block extra_js %}{% endblock %} + + +``` + +--- + +## Template Naming Conventions + +### File Names +- Use lowercase with underscores: `character_list.html`, `session_create.html` +- Partial templates prefix with underscore: `_card.html`, `_form.html` (optional) +- Component files describe what they contain: `forms.html`, `buttons.html` + +### Template Variables +- Use snake_case: `character`, `session_data`, `user_info` +- Prefix collections with descriptive names: `characters_list`, `sessions_active` +- Boolean flags use `is_` or `has_` prefix: `is_authenticated`, `has_premium` + +### Block Names +- Use descriptive names: `{% block sidebar %}`, `{% block page_header %}` +- Common blocks: + - `title` - Page title + - `content` - Main content area + - `extra_css` - Additional CSS files + - `extra_js` - Additional JavaScript files + - `extra_head` - Additional head elements + +--- + +## Template Patterns + +### Extending Base Template + +```html +{% extends "base.html" %} + +{% block title %}Character List - Code of Conquest{% endblock %} + +{% block content %} +
+

Your Characters

+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} +``` + +### Including Partial Templates + +```html +{% include 'partials/character_card.html' with character=char %} +``` + +**Or without context:** +```html +{% include 'partials/navigation.html' %} +``` + +### Using Macros + +**Define macro** in `templates/macros/form_fields.html`: + +```html +{% macro text_input(name, label, value="", required=False, placeholder="") %} +
+ + +
+{% endmacro %} +``` + +**Use macro:** +```html +{% from 'macros/form_fields.html' import text_input %} + +
+ {{ text_input('character_name', 'Character Name', required=True, placeholder='Enter name') }} +
+``` + +### Conditional Rendering + +```html +{% if user.is_authenticated %} +

Welcome, {{ user.username }}!

+{% else %} + Login +{% endif %} +``` + +### Loops + +```html +
+ {% for character in characters %} +
+

{{ character.name }}

+

Level {{ character.level }} {{ character.player_class.name }}

+
+ {% else %} +

No characters found. Create one?

+ {% endfor %} +
+``` + +--- + +## HTMX Integration in Templates + +### Basic HTMX Attributes + +```html + +
+ + +
+``` + +### HTMX with Confirmation + +```html + +``` + +### HTMX Polling + +```html +
+ Loading... +
+``` + +--- + +## Component Patterns + +### Character Card Component + +**File:** `templates/partials/character_card.html` + +```html +
+
+

{{ character.name }}

+ Lvl {{ character.level }} +
+
+

{{ character.player_class.name }}

+

+ HP: {{ character.current_hp }}/{{ character.max_hp }} | + Gold: {{ character.gold }} +

+
+
+ View + Play +
+
+``` + +**Usage:** +```html +{% for character in characters %} + {% include 'partials/character_card.html' with character=character %} +{% endfor %} +``` + +### Alert Component Macro + +**File:** `templates/components/alerts.html` + +```html +{% macro alert(message, category='info', dismissible=True) %} + +{% endmacro %} +``` + +**Usage:** +```html +{% from 'components/alerts.html' import alert %} + +{{ alert('Character created successfully!', 'success') }} +{{ alert('Invalid character name', 'error') }} +``` + +--- + +## Accessibility Guidelines + +### Semantic HTML +- Use proper heading hierarchy (`

`, `

`, etc.) +- Use `