Compare commits
4 Commits
72cf92021e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 70b2b0f124 | |||
| 805d04cf4e | |||
| f9e463bfc6 | |||
| 06ef8f6f0b |
@@ -189,6 +189,11 @@ def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(quests_bp)
|
||||
logger.info("Quests API blueprint registered")
|
||||
|
||||
# Import and register Abilities API blueprint
|
||||
from app.api.abilities import abilities_bp
|
||||
app.register_blueprint(abilities_bp)
|
||||
logger.info("Abilities API blueprint registered")
|
||||
|
||||
# TODO: Register additional blueprints as they are created
|
||||
# from app.api import marketplace
|
||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||
|
||||
129
api/app/api/abilities.py
Normal file
129
api/app/api/abilities.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Abilities API Blueprint
|
||||
|
||||
This module provides API endpoints for fetching ability information:
|
||||
- List all available abilities
|
||||
- Get details for a specific ability
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from app.models.abilities import AbilityLoader
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
not_found_response,
|
||||
)
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
abilities_bp = Blueprint('abilities', __name__, url_prefix='/api/v1/abilities')
|
||||
|
||||
# Initialize ability loader (singleton pattern)
|
||||
_ability_loader = None
|
||||
|
||||
|
||||
def get_ability_loader() -> AbilityLoader:
|
||||
"""
|
||||
Get the singleton AbilityLoader instance.
|
||||
|
||||
Returns:
|
||||
AbilityLoader: The ability loader instance
|
||||
"""
|
||||
global _ability_loader
|
||||
if _ability_loader is None:
|
||||
_ability_loader = AbilityLoader()
|
||||
return _ability_loader
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ability Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@abilities_bp.route('', methods=['GET'])
|
||||
def list_abilities():
|
||||
"""
|
||||
List all available abilities.
|
||||
|
||||
Returns all abilities defined in the system with their full details.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"abilities": [
|
||||
{
|
||||
"ability_id": "smite",
|
||||
"name": "Smite",
|
||||
"description": "Call down holy light...",
|
||||
"ability_type": "spell",
|
||||
"base_power": 20,
|
||||
"damage_type": "holy",
|
||||
"mana_cost": 10,
|
||||
"cooldown": 0,
|
||||
...
|
||||
},
|
||||
...
|
||||
],
|
||||
"count": 5
|
||||
}
|
||||
"""
|
||||
logger.info("Listing all abilities")
|
||||
|
||||
loader = get_ability_loader()
|
||||
abilities = loader.load_all_abilities()
|
||||
|
||||
# Convert to list of dicts for JSON serialization
|
||||
abilities_list = [ability.to_dict() for ability in abilities.values()]
|
||||
|
||||
logger.info("Abilities listed", count=len(abilities_list))
|
||||
|
||||
return success_response({
|
||||
"abilities": abilities_list,
|
||||
"count": len(abilities_list)
|
||||
})
|
||||
|
||||
|
||||
@abilities_bp.route('/<ability_id>', methods=['GET'])
|
||||
def get_ability(ability_id: str):
|
||||
"""
|
||||
Get details for a specific ability.
|
||||
|
||||
Args:
|
||||
ability_id: The unique identifier for the ability (e.g., "smite")
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ability_id": "smite",
|
||||
"name": "Smite",
|
||||
"description": "Call down holy light to smite your enemies",
|
||||
"ability_type": "spell",
|
||||
"base_power": 20,
|
||||
"damage_type": "holy",
|
||||
"scaling_stat": "wisdom",
|
||||
"scaling_factor": 0.5,
|
||||
"mana_cost": 10,
|
||||
"cooldown": 0,
|
||||
"effects_applied": [],
|
||||
"is_aoe": false,
|
||||
"target_count": 1
|
||||
}
|
||||
|
||||
Errors:
|
||||
404: Ability not found
|
||||
"""
|
||||
logger.info("Getting ability", ability_id=ability_id)
|
||||
|
||||
loader = get_ability_loader()
|
||||
ability = loader.load_ability(ability_id)
|
||||
|
||||
if ability is None:
|
||||
logger.warning("Ability not found", ability_id=ability_id)
|
||||
return not_found_response(
|
||||
message=f"Ability '{ability_id}' not found"
|
||||
)
|
||||
|
||||
logger.info("Ability retrieved", ability_id=ability_id, name=ability.name)
|
||||
|
||||
return success_response(ability.to_dict())
|
||||
@@ -35,7 +35,8 @@ class Character:
|
||||
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
|
||||
experience: Current XP progress toward next level (resets on level-up)
|
||||
total_xp: Cumulative XP earned across all levels (never decreases)
|
||||
base_stats: Base stats (from class + level-ups)
|
||||
unlocked_skills: List of skill_ids that have been unlocked
|
||||
inventory: All items the character owns
|
||||
@@ -53,7 +54,8 @@ class Character:
|
||||
player_class: PlayerClass
|
||||
origin: Origin
|
||||
level: int = 1
|
||||
experience: int = 0
|
||||
experience: int = 0 # Current level progress (resets on level-up)
|
||||
total_xp: int = 0 # Cumulative XP (never decreases)
|
||||
|
||||
# Stats and progression
|
||||
base_stats: Stats = field(default_factory=Stats)
|
||||
@@ -315,6 +317,9 @@ class Character:
|
||||
"""
|
||||
Add experience points and check for level up.
|
||||
|
||||
Updates both current level progress (experience) and cumulative total (total_xp).
|
||||
The cumulative total never decreases, providing players a sense of overall progression.
|
||||
|
||||
Args:
|
||||
xp: Amount of experience to add
|
||||
|
||||
@@ -322,6 +327,7 @@ class Character:
|
||||
True if character leveled up, False otherwise
|
||||
"""
|
||||
self.experience += xp
|
||||
self.total_xp += xp # Track cumulative XP (never decreases)
|
||||
required_xp = self._calculate_xp_for_next_level()
|
||||
|
||||
if self.experience >= required_xp:
|
||||
@@ -334,15 +340,18 @@ class Character:
|
||||
"""
|
||||
Level up the character.
|
||||
|
||||
- Increases level
|
||||
- Resets experience to overflow amount
|
||||
- Increases level by 1
|
||||
- Resets experience to overflow amount (XP beyond requirement carries over)
|
||||
- Preserves total_xp (cumulative XP is never modified here)
|
||||
- Grants 1 skill point (level - unlocked_skills count)
|
||||
- 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
|
||||
self.experience = overflow_xp # Reset current level progress
|
||||
# total_xp remains unchanged - it's cumulative and never decreases
|
||||
|
||||
# Future: Apply stat increases based on class
|
||||
# For now, stats are increased manually via skill points
|
||||
@@ -359,6 +368,19 @@ class Character:
|
||||
"""
|
||||
return int(100 * (self.level ** 1.5))
|
||||
|
||||
@property
|
||||
def xp_to_next_level(self) -> int:
|
||||
"""
|
||||
Get XP remaining until next level.
|
||||
|
||||
This is a computed property for UI display showing progress bars
|
||||
and "X/Y XP to next level" displays.
|
||||
|
||||
Returns:
|
||||
Amount of XP needed to reach next level
|
||||
"""
|
||||
return self._calculate_xp_for_next_level() - self.experience
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize character to dictionary for JSON storage.
|
||||
@@ -374,6 +396,9 @@ class Character:
|
||||
"origin": self.origin.to_dict(),
|
||||
"level": self.level,
|
||||
"experience": self.experience,
|
||||
"total_xp": self.total_xp,
|
||||
"xp_to_next_level": self.xp_to_next_level,
|
||||
"xp_required_for_next_level": self._calculate_xp_for_next_level(),
|
||||
"base_stats": self.base_stats.to_dict(),
|
||||
"unlocked_skills": self.unlocked_skills,
|
||||
"inventory": [item.to_dict() for item in self.inventory],
|
||||
@@ -465,6 +490,7 @@ class Character:
|
||||
origin=origin,
|
||||
level=data.get("level", 1),
|
||||
experience=data.get("experience", 0),
|
||||
total_xp=data.get("total_xp", 0), # Default 0 for legacy data
|
||||
base_stats=base_stats,
|
||||
unlocked_skills=data.get("unlocked_skills", []),
|
||||
inventory=inventory,
|
||||
|
||||
@@ -1261,7 +1261,12 @@ class CombatService:
|
||||
xp_per_player = rewards.experience // max(1, len(player_combatants))
|
||||
gold_per_player = rewards.gold // max(1, len(player_combatants))
|
||||
|
||||
for player in player_combatants:
|
||||
# Distribute items evenly among players
|
||||
# For simplicity, give all items to each player in solo mode,
|
||||
# or distribute round-robin in multiplayer
|
||||
items_to_distribute = [Item.from_dict(item_dict) for item_dict in rewards.items]
|
||||
|
||||
for i, player in enumerate(player_combatants):
|
||||
if session.is_solo():
|
||||
char_id = session.solo_character_id
|
||||
else:
|
||||
@@ -1278,6 +1283,18 @@ class CombatService:
|
||||
# Add gold
|
||||
character.gold += gold_per_player
|
||||
|
||||
# Distribute items
|
||||
if session.is_solo():
|
||||
# Solo mode: give all items to the character
|
||||
for item in items_to_distribute:
|
||||
character.add_item(item)
|
||||
else:
|
||||
# Multiplayer: distribute items round-robin
|
||||
# Player i gets items at indices i, i+n, i+2n, etc.
|
||||
for idx, item in enumerate(items_to_distribute):
|
||||
if idx % len(player_combatants) == i:
|
||||
character.add_item(item)
|
||||
|
||||
# Save character
|
||||
self.character_service.update_character(character, user_id)
|
||||
|
||||
|
||||
@@ -936,7 +936,8 @@ power = fireball.calculate_power(caster_stats)
|
||||
| `name` | str | Character name |
|
||||
| `player_class` | PlayerClass | Character class |
|
||||
| `level` | int | Current level |
|
||||
| `experience` | int | XP points |
|
||||
| `experience` | int | Current XP progress toward next level (resets on level-up) |
|
||||
| `total_xp` | int | Cumulative XP earned across all levels (never decreases) |
|
||||
| `stats` | Stats | Current stats |
|
||||
| `unlocked_skills` | List[str] | Unlocked skill_ids |
|
||||
| `inventory` | List[Item] | All items |
|
||||
@@ -945,10 +946,17 @@ power = fireball.calculate_power(caster_stats)
|
||||
| `active_quests` | List[str] | Quest IDs |
|
||||
| `discovered_locations` | List[str] | Location IDs |
|
||||
|
||||
**Computed Properties:**
|
||||
- `xp_to_next_level` - XP remaining until next level (calculated: `required_xp - experience`)
|
||||
- `current_hp` / `max_hp` - Health points (calculated from constitution)
|
||||
- `defense` / `resistance` - Damage reduction (calculated from stats)
|
||||
|
||||
**Methods:**
|
||||
- `to_dict()` - Serialize to dictionary for JSON storage
|
||||
- `from_dict(data)` - Deserialize from dictionary
|
||||
- `to_dict()` - Serialize to dictionary for JSON storage (includes computed fields)
|
||||
- `from_dict(data)` - Deserialize from dictionary (handles legacy data)
|
||||
- `get_effective_stats(active_effects)` - **THE CRITICAL METHOD** - Calculate final stats
|
||||
- `add_experience(xp)` - Add XP and check for level-up (updates both `experience` and `total_xp`)
|
||||
- `level_up()` - Level up character (resets `experience`, preserves `total_xp`)
|
||||
|
||||
**get_effective_stats() Details:**
|
||||
|
||||
|
||||
@@ -401,6 +401,12 @@ XP Required = 100 × (current_level ^ 1.5)
|
||||
- Level up triggers automatically when threshold reached
|
||||
- Base stats remain constant (progression via skill trees & equipment)
|
||||
|
||||
**XP Tracking:**
|
||||
- **`experience`**: Current progress toward next level (0 to required XP, resets on level-up)
|
||||
- **`total_xp`**: Cumulative XP earned across all levels (never decreases)
|
||||
- **UI Display**: Shows both values (e.g., "Total XP: 150 | Progress: 50/282 to Level 3")
|
||||
- **Legacy data**: Characters without `total_xp` default to 0, will track from next XP gain
|
||||
|
||||
**Implementation:**
|
||||
- Leveling logic lives in `Character` model (`add_experience()`, `level_up()` methods)
|
||||
- No separate service needed (OOP design pattern)
|
||||
|
||||
@@ -324,6 +324,7 @@ def test_add_experience_no_level_up(basic_character):
|
||||
assert leveled_up == False
|
||||
assert basic_character.level == 1
|
||||
assert basic_character.experience == 50
|
||||
assert basic_character.total_xp == 50 # Cumulative XP tracked
|
||||
|
||||
|
||||
def test_add_experience_with_level_up(basic_character):
|
||||
@@ -333,7 +334,8 @@ def test_add_experience_with_level_up(basic_character):
|
||||
|
||||
assert leveled_up == True
|
||||
assert basic_character.level == 2
|
||||
assert basic_character.experience == 0 # Reset
|
||||
assert basic_character.experience == 0 # Current level progress resets
|
||||
assert basic_character.total_xp == 100 # Cumulative XP preserved
|
||||
|
||||
|
||||
def test_add_experience_with_overflow(basic_character):
|
||||
@@ -343,7 +345,48 @@ def test_add_experience_with_overflow(basic_character):
|
||||
|
||||
assert leveled_up == True
|
||||
assert basic_character.level == 2
|
||||
assert basic_character.experience == 50 # Overflow
|
||||
assert basic_character.experience == 50 # Overflow preserved
|
||||
assert basic_character.total_xp == 150 # All XP tracked cumulatively
|
||||
|
||||
|
||||
def test_xp_to_next_level_property(basic_character):
|
||||
"""Test xp_to_next_level property calculation."""
|
||||
# At level 1 with 0 XP, need 100 to level up
|
||||
assert basic_character.xp_to_next_level == 100
|
||||
|
||||
# Add 30 XP
|
||||
basic_character.add_experience(30)
|
||||
assert basic_character.xp_to_next_level == 70 # 100 - 30
|
||||
|
||||
# Add 70 more to level up
|
||||
basic_character.add_experience(70)
|
||||
assert basic_character.level == 2
|
||||
assert basic_character.experience == 0
|
||||
# At level 2, need 282 XP to level up
|
||||
assert basic_character.xp_to_next_level == 282
|
||||
|
||||
|
||||
def test_total_xp_never_decreases(basic_character):
|
||||
"""Test that total_xp is cumulative and never decreases."""
|
||||
# Start at 0
|
||||
assert basic_character.total_xp == 0
|
||||
|
||||
# Add XP multiple times
|
||||
basic_character.add_experience(50)
|
||||
assert basic_character.total_xp == 50
|
||||
|
||||
basic_character.add_experience(50)
|
||||
assert basic_character.total_xp == 100
|
||||
# Should have leveled up to level 2
|
||||
assert basic_character.level == 2
|
||||
assert basic_character.experience == 0 # Current progress reset
|
||||
|
||||
# Add more XP
|
||||
basic_character.add_experience(100)
|
||||
assert basic_character.total_xp == 200 # Still cumulative
|
||||
|
||||
# Even though experience resets on level-up, total_xp keeps growing
|
||||
assert basic_character.total_xp >= basic_character.experience
|
||||
|
||||
|
||||
def test_xp_calculation(basic_origin):
|
||||
@@ -386,6 +429,7 @@ def test_character_serialization(basic_character):
|
||||
basic_character.gold = 500
|
||||
basic_character.level = 3
|
||||
basic_character.experience = 100
|
||||
basic_character.total_xp = 500 # Manually set for test
|
||||
|
||||
data = basic_character.to_dict()
|
||||
|
||||
@@ -394,7 +438,13 @@ def test_character_serialization(basic_character):
|
||||
assert data["name"] == "Test Hero"
|
||||
assert data["level"] == 3
|
||||
assert data["experience"] == 100
|
||||
assert data["total_xp"] == 500
|
||||
assert data["gold"] == 500
|
||||
# Check computed fields
|
||||
assert "xp_to_next_level" in data
|
||||
assert "xp_required_for_next_level" in data
|
||||
assert data["xp_required_for_next_level"] == 519 # Level 3 requires 519 XP
|
||||
assert data["xp_to_next_level"] == 419 # 519 - 100
|
||||
|
||||
|
||||
def test_character_deserialization(basic_player_class, basic_origin):
|
||||
|
||||
@@ -636,7 +636,15 @@ class TestRewardsCalculation:
|
||||
|
||||
# Mock loot service to return mock items
|
||||
mock_item = Mock()
|
||||
mock_item.to_dict.return_value = {"item_id": "sword", "quantity": 1}
|
||||
mock_item.to_dict.return_value = {
|
||||
"item_id": "test_sword",
|
||||
"name": "Test Sword",
|
||||
"item_type": "weapon",
|
||||
"rarity": "common",
|
||||
"description": "A test sword",
|
||||
"value": 10,
|
||||
"damage": 5,
|
||||
}
|
||||
service.loot_service.generate_loot_from_enemy.return_value = [mock_item]
|
||||
|
||||
mock_session = Mock()
|
||||
@@ -647,6 +655,7 @@ class TestRewardsCalculation:
|
||||
mock_char.level = 1
|
||||
mock_char.experience = 0
|
||||
mock_char.gold = 0
|
||||
mock_char.add_item = Mock() # Mock the add_item method
|
||||
service.character_service.get_character.return_value = mock_char
|
||||
service.character_service.update_character = Mock()
|
||||
|
||||
@@ -655,3 +664,6 @@ class TestRewardsCalculation:
|
||||
assert rewards.experience == 50
|
||||
assert rewards.gold == 25
|
||||
assert len(rewards.items) == 1
|
||||
# Verify items were added to character inventory
|
||||
assert mock_char.add_item.called, "Items should be added to character inventory"
|
||||
assert mock_char.add_item.call_count == 1, "Should add 1 item to inventory"
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
# Phase 5 Quest System - NPC Integration Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a quest-centric system where quests define their NPC givers, and NPCs automatically offer eligible quests during conversations using a probability roll + AI selection approach.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Data ownership | Quest-centric | Quests define `quest_giver_npc_ids`. Adding new quests doesn't touch NPC files. |
|
||||
| Offer trigger | Probability + AI select | Location-based roll first, then AI naturally weaves selected quest into dialogue. |
|
||||
| Lore integration | Stub service | Create `LoreService` interface with mock data now; swap to Weaviate in Phase 6. |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
NPC Conversation Flow with Quest Integration:
|
||||
|
||||
POST /api/v1/npcs/{npc_id}/talk
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ 1. Load Context │
|
||||
│ - NPC, Character, Location│
|
||||
└────────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ 2. Quest Eligibility Check │
|
||||
│ QuestEligibilityService │
|
||||
│ - Find quests where NPC │
|
||||
│ is quest_giver │
|
||||
│ - Filter by char level, │
|
||||
│ completed, active, etc │
|
||||
└────────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ 3. Probability Roll │
|
||||
│ - Town: 30%, Tavern: 35% │
|
||||
│ - Wilderness: 5% │
|
||||
│ - If fail → no offer │
|
||||
└────────────┬────────────────┘
|
||||
│ (if success)
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ 4. Get Lore Context (stub) │
|
||||
│ LoreService.get_context()│
|
||||
│ - Quest embedded lore │
|
||||
│ - Mock regional lore │
|
||||
└────────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ 5. Build AI Prompt │
|
||||
│ - NPC persona + knowledge│
|
||||
│ - Quest offering context │
|
||||
│ - Lore context │
|
||||
└────────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ 6. AI Generates Dialogue │
|
||||
│ Naturally weaves quest │
|
||||
│ offer into conversation │
|
||||
│ Includes [QUEST_OFFER:id]│
|
||||
└────────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ 7. Parse Response │
|
||||
│ Extract quest_offered │
|
||||
│ Return to frontend │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## YAML Schema Designs
|
||||
|
||||
### Quest YAML (Quest-Centric Approach)
|
||||
|
||||
```yaml
|
||||
# /api/app/data/quests/easy/cellar_rats.yaml
|
||||
quest_id: quest_cellar_rats
|
||||
name: "Rat Problem in the Cellar"
|
||||
description: |
|
||||
Giant rats have infested the Rusty Anchor's cellar.
|
||||
difficulty: easy
|
||||
|
||||
# NPC Quest Givers (quest defines this, not NPC)
|
||||
quest_giver_npc_ids:
|
||||
- npc_grom_ironbeard
|
||||
quest_giver_name: "Grom Ironbeard" # Display fallback
|
||||
|
||||
# Location context
|
||||
location_id: crossville_tavern
|
||||
region_id: crossville
|
||||
|
||||
# Offering conditions
|
||||
offering_triggers:
|
||||
location_types: ["tavern"]
|
||||
min_character_level: 1
|
||||
max_character_level: 5
|
||||
required_quests_completed: []
|
||||
probability_weights:
|
||||
tavern: 0.35
|
||||
town: 0.20
|
||||
|
||||
# NPC-specific offer dialogue (keyed by NPC ID)
|
||||
npc_offer_dialogues:
|
||||
npc_grom_ironbeard:
|
||||
dialogue: |
|
||||
*leans in conspiratorially* Got a problem, friend. Giant rats
|
||||
in me cellar. Been scaring off customers. 50 gold for whoever
|
||||
clears 'em out.
|
||||
conditions:
|
||||
min_relationship: 30
|
||||
required_flags: []
|
||||
forbidden_flags: ["refused_rat_quest"]
|
||||
|
||||
# What NPCs know about this quest (for AI context)
|
||||
npc_quest_knowledge:
|
||||
npc_grom_ironbeard:
|
||||
- "The rats started appearing about a week ago"
|
||||
- "They seem bigger than normal rats"
|
||||
- "Old smuggling tunnels under the cellar"
|
||||
|
||||
# Embedded lore (used before Weaviate exists)
|
||||
lore_context:
|
||||
backstory: |
|
||||
The cellar connects to old smuggling tunnels from Captain
|
||||
Morgath's days. Recent earthquakes may have reopened them.
|
||||
world_connections:
|
||||
- "The earthquakes also disturbed the Old Mines"
|
||||
regional_hints:
|
||||
- "Smuggling was common 50 years ago in Crossville"
|
||||
|
||||
# Narrative hooks for natural dialogue
|
||||
dialogue_templates:
|
||||
narrative_hooks:
|
||||
- "mentions unusual scratching sounds from below"
|
||||
- "complains about spoiled food supplies"
|
||||
- "nervously glances toward the cellar door"
|
||||
|
||||
# Objectives
|
||||
objectives:
|
||||
- objective_id: kill_rats
|
||||
description: "Clear out the giant rats (0/10)"
|
||||
objective_type: kill
|
||||
required_progress: 10
|
||||
target_enemy_type: giant_rat
|
||||
|
||||
# Rewards
|
||||
rewards:
|
||||
gold: 50
|
||||
experience: 100
|
||||
items: []
|
||||
relationship_bonuses:
|
||||
npc_grom_ironbeard: 10
|
||||
unlocks_quests: ["quest_tunnel_mystery"]
|
||||
|
||||
# Completion
|
||||
completion_dialogue:
|
||||
npc_grom_ironbeard: |
|
||||
*actually smiles* Well done! Rats are gone, cellar's safe.
|
||||
Here's your coin. Drink's on the house tonight.
|
||||
```
|
||||
|
||||
### NPC YAML (Simplified - No Quest Lists)
|
||||
|
||||
NPCs no longer need `quest_giver_for` since quests define their givers.
|
||||
|
||||
```yaml
|
||||
# /api/app/data/npcs/crossville/npc_grom_ironbeard.yaml
|
||||
npc_id: npc_grom_ironbeard
|
||||
name: Grom Ironbeard
|
||||
role: bartender
|
||||
location_id: crossville_tavern
|
||||
image_url: /static/images/npcs/crossville/grom_ironbeard.png
|
||||
|
||||
# All other existing fields remain unchanged
|
||||
personality: { ... }
|
||||
appearance: { ... }
|
||||
knowledge: { ... }
|
||||
dialogue_hooks: { ... }
|
||||
# NO quest_giver_for field needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Group 1: Quest Data Models (3 tasks)
|
||||
|
||||
| ID | Task | File | Notes |
|
||||
|----|------|------|-------|
|
||||
| 5.1 | Create Quest dataclass | `/api/app/models/quest.py` | Include all fields from YAML schema |
|
||||
| 5.2 | Create QuestObjective, QuestReward, QuestTriggers | `/api/app/models/quest.py` | Objective types: kill, collect, travel, interact, discover |
|
||||
| 5.3 | Add `to_dict()`/`from_dict()` serialization | `/api/app/models/quest.py` | Test round-trip JSON serialization |
|
||||
|
||||
### Group 2: Quest Loading Service (3 tasks)
|
||||
|
||||
| ID | Task | File | Notes |
|
||||
|----|------|------|-------|
|
||||
| 5.4 | Create `/api/app/data/quests/` directory structure | directories | `easy/`, `medium/`, `hard/`, `epic/` |
|
||||
| 5.5 | Implement QuestService (loader) | `/api/app/services/quest_service.py` | Follow `npc_loader.py` pattern with caching |
|
||||
| 5.6 | Write 5 example quests | `/api/app/data/quests/*.yaml` | 2 easy, 2 medium, 1 hard |
|
||||
|
||||
### Group 3: Quest Eligibility Service (3 tasks)
|
||||
|
||||
| ID | Task | File | Notes |
|
||||
|----|------|------|-------|
|
||||
| 5.7 | Create QuestEligibilityService | `/api/app/services/quest_eligibility_service.py` | Core eligibility logic |
|
||||
| 5.8 | Implement `get_quests_for_npc()` | same file | Find quests where NPC is quest_giver |
|
||||
| 5.9 | Implement probability roll + filtering | same file | Location-based weights, level check, status check |
|
||||
|
||||
### Group 4: Lore Service Stub (2 tasks)
|
||||
|
||||
| ID | Task | File | Notes |
|
||||
|----|------|------|-------|
|
||||
| 5.10 | Create LoreService interface | `/api/app/services/lore_service.py` | Abstract interface for Phase 6 |
|
||||
| 5.11 | Implement MockLoreService | same file | Returns quest's embedded lore_context |
|
||||
|
||||
### Group 5: AI Prompt Integration (3 tasks)
|
||||
|
||||
| ID | Task | File | Notes |
|
||||
|----|------|------|-------|
|
||||
| 5.12 | Add quest offering section to template | `/api/app/ai/templates/npc_dialogue.j2` | Quest context + natural weaving instructions |
|
||||
| 5.13 | Add lore context section to template | same file | Filtered lore for NPC knowledge |
|
||||
| 5.14 | Implement quest offer parsing | `/api/app/ai/response_parser.py` | Extract `[QUEST_OFFER:quest_id]` markers |
|
||||
|
||||
### Group 6: NPC API Integration (4 tasks)
|
||||
|
||||
| ID | Task | File | Notes |
|
||||
|----|------|------|-------|
|
||||
| 5.15 | Integrate eligibility check into `talk_to_npc` | `/api/app/api/npcs.py` | Check before building AI context |
|
||||
| 5.16 | Add quest context to AI task | `/api/app/tasks/ai_tasks.py` | Modify `_process_npc_dialogue_task` |
|
||||
| 5.17 | Handle quest_offered in response | `/api/app/api/npcs.py` | Parse and include in API response |
|
||||
| 5.18 | Remove `quest_giver_for` from NPC model | `/api/app/models/npc.py` | Clean up old field if exists |
|
||||
|
||||
### Group 7: Quest Accept/Manage Endpoints (4 tasks)
|
||||
|
||||
| ID | Task | File | Notes |
|
||||
|----|------|------|-------|
|
||||
| 5.19 | Create quests blueprint | `/api/app/api/quests.py` | Register in `__init__.py` |
|
||||
| 5.20 | Implement `POST /api/v1/quests/accept` | same file | Add to active_quests, update relationship |
|
||||
| 5.21 | Implement `POST /api/v1/quests/decline` | same file | Set `refused_{quest_id}` flag |
|
||||
| 5.22 | Implement `GET /api/v1/characters/{id}/quests` | same file | List active and completed quests |
|
||||
|
||||
### Group 8: Testing & Validation (3 tasks)
|
||||
|
||||
| ID | Task | File | Notes |
|
||||
|----|------|------|-------|
|
||||
| 5.23 | Unit tests for Quest models | `/api/tests/test_quest_models.py` | Serialization, validation |
|
||||
| 5.24 | Unit tests for QuestEligibilityService | `/api/tests/test_quest_eligibility.py` | Filtering logic |
|
||||
| 5.25 | Integration test: full quest offer flow | `/api/tests/test_quest_integration.py` | NPC talk → offer → accept |
|
||||
|
||||
---
|
||||
|
||||
## Critical Files to Read Before Implementation
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `/api/app/services/npc_loader.py` | Pattern for YAML loading with caching |
|
||||
| `/api/app/models/npc.py` | Current NPC dataclass structure |
|
||||
| `/api/app/api/npcs.py` | Current `talk_to_npc` endpoint implementation |
|
||||
| `/api/app/tasks/ai_tasks.py` | `_process_npc_dialogue_task` function (lines 667-795) |
|
||||
| `/api/app/ai/templates/npc_dialogue.j2` | Current prompt template structure |
|
||||
| `/api/app/models/character.py` | `active_quests`, `completed_quests` fields |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
1. Player talks to NPC → POST /api/v1/npcs/{npc_id}/talk
|
||||
|
||||
2. Backend:
|
||||
a. QuestService.get_quests_for_npc(npc_id)
|
||||
→ Find quests where npc_id in quest_giver_npc_ids
|
||||
|
||||
b. QuestEligibilityService.filter_eligible(quests, character)
|
||||
→ Remove: already active, completed, wrong level, flags block
|
||||
|
||||
c. Probability roll based on location
|
||||
→ 35% chance in tavern, 5% in wilderness, etc.
|
||||
|
||||
d. If roll succeeds + eligible quests exist:
|
||||
→ Pick quest (first eligible or AI-selected if multiple)
|
||||
→ Build QuestOfferContext with dialogue + lore
|
||||
|
||||
e. Add quest context to AI prompt
|
||||
|
||||
f. AI generates dialogue, naturally mentions quest
|
||||
→ Includes [QUEST_OFFER:quest_cellar_rats] if offering
|
||||
|
||||
3. Parse response, return to frontend:
|
||||
{
|
||||
"dialogue": "NPC's natural dialogue...",
|
||||
"quest_offered": {
|
||||
"quest_id": "quest_cellar_rats",
|
||||
"name": "Rat Problem",
|
||||
"description": "...",
|
||||
"rewards": {...}
|
||||
}
|
||||
}
|
||||
|
||||
4. Frontend shows quest offer UI
|
||||
→ Player clicks Accept
|
||||
|
||||
5. POST /api/v1/quests/accept
|
||||
→ Add to character.active_quests
|
||||
→ Update NPC relationship (+5)
|
||||
→ Return acceptance dialogue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 Integration Points
|
||||
|
||||
When implementing Phase 6 (Weaviate lore), these touchpoints enable integration:
|
||||
|
||||
1. **LoreService interface** - Replace `MockLoreService` with `WeaviateLoreService`
|
||||
2. **Quest.lore_context** - Supplement embedded lore with Weaviate queries
|
||||
3. **NPC dialogue template** - Lore section already prepared
|
||||
4. **Knowledge filtering** - `LoreService.filter_for_npc()` method exists
|
||||
|
||||
---
|
||||
|
||||
## NPC YAML Migration
|
||||
|
||||
Existing NPC files need these changes:
|
||||
|
||||
**Remove (if exists):**
|
||||
- `quest_giver_for: [...]` - No longer needed
|
||||
|
||||
**Keep unchanged:**
|
||||
- `location_id` - Required
|
||||
- `image_url` - Required
|
||||
- All other fields - Unchanged
|
||||
|
||||
The `quest_giver_for` field in `npc_mayor_aldric.yaml` will be removed since quests now define their givers.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Quest YAML schema implemented and validated
|
||||
- [ ] QuestService loads quests from YAML with caching
|
||||
- [ ] QuestEligibilityService filters correctly by all conditions
|
||||
- [ ] Probability roll works per location type
|
||||
- [ ] AI prompt includes quest context when offering
|
||||
- [ ] AI naturally weaves quest offers into dialogue
|
||||
- [ ] Quest offer parsing extracts `[QUEST_OFFER:id]` correctly
|
||||
- [ ] Accept endpoint adds quest to active_quests
|
||||
- [ ] Max 2 active quests enforced
|
||||
- [ ] Relationship bonus applied on quest completion
|
||||
- [ ] LoreService stub returns embedded lore_context
|
||||
@@ -145,27 +145,29 @@ def combat_action(session_id: str):
|
||||
# API returns data directly in result, not nested under 'action_result'
|
||||
log_entries = []
|
||||
|
||||
# Player action entry
|
||||
player_entry = {
|
||||
'actor': 'You',
|
||||
'message': result.get('message', f'used {action_type}'),
|
||||
'type': 'player',
|
||||
'is_crit': False
|
||||
}
|
||||
|
||||
# Add damage info if present
|
||||
# The API message is self-contained (includes actor name and damage)
|
||||
# Don't add separate actor/damage to avoid duplication
|
||||
message = result.get('message', f'Used {action_type}')
|
||||
damage_results = result.get('damage_results', [])
|
||||
if damage_results:
|
||||
for dmg in damage_results:
|
||||
player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage')
|
||||
player_entry['is_crit'] = dmg.get('is_critical', False)
|
||||
if player_entry['is_crit']:
|
||||
player_entry['type'] = 'crit'
|
||||
|
||||
# Add healing info if present
|
||||
# Determine entry type based on damage results
|
||||
entry_type = 'player'
|
||||
is_crit = False
|
||||
if damage_results:
|
||||
is_crit = any(dmg.get('is_critical', False) for dmg in damage_results)
|
||||
if is_crit:
|
||||
entry_type = 'crit'
|
||||
|
||||
if result.get('healing'):
|
||||
player_entry['heal'] = result.get('healing')
|
||||
player_entry['type'] = 'heal'
|
||||
entry_type = 'heal'
|
||||
|
||||
player_entry = {
|
||||
'actor': '', # API message already includes character name
|
||||
'message': message,
|
||||
'type': entry_type,
|
||||
'is_crit': is_crit
|
||||
# Don't add 'damage' - it's already in the message
|
||||
}
|
||||
|
||||
log_entries.append(player_entry)
|
||||
|
||||
@@ -179,18 +181,32 @@ def combat_action(session_id: str):
|
||||
'type': 'system'
|
||||
})
|
||||
|
||||
# Return log entries HTML
|
||||
resp = make_response(render_template(
|
||||
# Check if it's now enemy's turn
|
||||
next_combatant = result.get('next_combatant_id')
|
||||
next_is_player = result.get('next_is_player', True)
|
||||
|
||||
logger.info("combat_action_result",
|
||||
next_combatant=next_combatant,
|
||||
next_is_player=next_is_player,
|
||||
combat_ended=combat_ended)
|
||||
|
||||
# Render log entries
|
||||
log_html = render_template(
|
||||
'game/partials/combat_log.html',
|
||||
combat_log=log_entries
|
||||
))
|
||||
)
|
||||
|
||||
# Trigger enemy turn if it's no longer player's turn
|
||||
next_combatant = result.get('next_combatant_id')
|
||||
if next_combatant and not result.get('next_is_player', True):
|
||||
resp.headers['HX-Trigger'] = 'enemyTurn'
|
||||
# Add script to trigger page refresh after showing the action result
|
||||
# This is more reliable than headers which can be modified by HTMX
|
||||
refresh_script = '''
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1200);
|
||||
</script>
|
||||
'''
|
||||
|
||||
return resp
|
||||
return log_html + refresh_script
|
||||
|
||||
except APIError as e:
|
||||
logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e))
|
||||
@@ -233,8 +249,8 @@ def combat_abilities(session_id: str):
|
||||
ability_response = client.get(f'/api/v1/abilities/{ability_id}')
|
||||
ability_data = ability_response.get('result', {})
|
||||
|
||||
# Check availability
|
||||
mp_cost = ability_data.get('mp_cost', 0)
|
||||
# Check availability (API returns 'mana_cost', template uses 'mp_cost')
|
||||
mp_cost = ability_data.get('mana_cost', ability_data.get('mp_cost', 0))
|
||||
cooldown = cooldowns.get(ability_id, 0)
|
||||
available = current_mp >= mp_cost and cooldown == 0
|
||||
|
||||
|
||||
@@ -1323,6 +1323,10 @@ def inventory_equip(session_id: str):
|
||||
if not item_id:
|
||||
return '<div class="error">No item selected</div>', 400
|
||||
|
||||
if not slot:
|
||||
logger.warning("equip_missing_slot", item_id=item_id)
|
||||
return '<div class="error">No equipment slot specified</div>', 400
|
||||
|
||||
try:
|
||||
# Get session to find character
|
||||
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||
@@ -1333,17 +1337,14 @@ def inventory_equip(session_id: str):
|
||||
return '<div class="error">No character found</div>', 400
|
||||
|
||||
# Equip the item via API
|
||||
payload = {'item_id': item_id}
|
||||
if slot:
|
||||
payload['slot'] = slot
|
||||
|
||||
payload = {'item_id': item_id, 'slot': slot}
|
||||
client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload)
|
||||
|
||||
# Return updated character panel
|
||||
return redirect(url_for('game.character_panel', session_id=session_id))
|
||||
|
||||
except APIError as e:
|
||||
logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, error=str(e))
|
||||
logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, slot=slot, error=str(e))
|
||||
return f'<div class="error">Failed to equip item: {e}</div>', 500
|
||||
|
||||
|
||||
|
||||
@@ -263,16 +263,23 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Handle player action triggering enemy turn
|
||||
// Handle player action - refresh page to update UI
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
const response = event.detail.xhr;
|
||||
if (!response) return;
|
||||
|
||||
const triggers = response.getResponseHeader('HX-Trigger') || '';
|
||||
// Check response status - only refresh on success
|
||||
if (response.status !== 200) return;
|
||||
|
||||
// Only trigger enemy turn from player actions (not from our fetch calls)
|
||||
if (triggers.includes('enemyTurn') && !enemyTurnPending) {
|
||||
triggerEnemyTurn();
|
||||
// Check custom header for combat refresh signal
|
||||
const shouldRefresh = response.getResponseHeader('X-Combat-Refresh');
|
||||
console.log('X-Combat-Refresh header:', shouldRefresh);
|
||||
|
||||
if (shouldRefresh === 'true') {
|
||||
// Short delay to let user see their action result
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
hx-target="#combat-log"
|
||||
hx-swap="beforeend"
|
||||
hx-disabled-elt="this"
|
||||
{% if not ability.available %}disabled{% endif %}
|
||||
onclick="closeModal()">
|
||||
hx-on::before-request="closeModal()"
|
||||
{% if not ability.available %}disabled{% endif %}>
|
||||
<span class="ability-icon">
|
||||
{% if ability.damage_type == 'fire' %}🔥
|
||||
{% elif ability.damage_type == 'ice' %}❄
|
||||
|
||||
@@ -12,14 +12,17 @@ Displays character's equipped gear and inventory summary
|
||||
|
||||
{# Modal Body #}
|
||||
<div class="modal-body">
|
||||
{# Equipment Grid #}
|
||||
{# Equipment Grid - All 8 slots matching API #}
|
||||
<div class="equipment-grid">
|
||||
{% set slots = [
|
||||
('weapon', 'Weapon'),
|
||||
('armor', 'Armor'),
|
||||
('off_hand', 'Off-Hand'),
|
||||
('helmet', 'Helmet'),
|
||||
('chest', 'Chest Armor'),
|
||||
('gloves', 'Gloves'),
|
||||
('boots', 'Boots'),
|
||||
('accessory', 'Accessory')
|
||||
('accessory_1', 'Accessory 1'),
|
||||
('accessory_2', 'Accessory 2')
|
||||
] %}
|
||||
|
||||
{% for slot_id, slot_name in slots %}
|
||||
@@ -34,11 +37,15 @@ Displays character's equipped gear and inventory summary
|
||||
{# Equipped Item #}
|
||||
<div class="slot-item">
|
||||
<div class="slot-icon">
|
||||
{% if item.item_type == 'weapon' %}⚔️
|
||||
{% elif item.item_type == 'armor' %}🛡️
|
||||
{% elif item.item_type == 'helmet' %}⛑️
|
||||
{% elif item.item_type == 'boots' %}👢
|
||||
{% elif item.item_type == 'accessory' %}💍
|
||||
{# Icon based on slot_id since item_type doesn't distinguish armor slots #}
|
||||
{% if slot_id == 'weapon' %}⚔️
|
||||
{% elif slot_id == 'off_hand' %}🛡️
|
||||
{% elif slot_id == 'helmet' %}⛑️
|
||||
{% elif slot_id == 'chest' %}🎽
|
||||
{% elif slot_id == 'gloves' %}🧤
|
||||
{% elif slot_id == 'boots' %}👢
|
||||
{% elif slot_id == 'accessory_1' %}💍
|
||||
{% elif slot_id == 'accessory_2' %}📿
|
||||
{% else %}📦{% endif %}
|
||||
</div>
|
||||
<div class="slot-details">
|
||||
@@ -63,10 +70,13 @@ Displays character's equipped gear and inventory summary
|
||||
<div class="slot-empty">
|
||||
<div class="slot-icon slot-icon--empty">
|
||||
{% if slot_id == 'weapon' %}⚔️
|
||||
{% elif slot_id == 'armor' %}🛡️
|
||||
{% elif slot_id == 'off_hand' %}🛡️
|
||||
{% elif slot_id == 'helmet' %}⛑️
|
||||
{% elif slot_id == 'chest' %}🎽
|
||||
{% elif slot_id == 'gloves' %}🧤
|
||||
{% elif slot_id == 'boots' %}👢
|
||||
{% elif slot_id == 'accessory' %}💍
|
||||
{% elif slot_id == 'accessory_1' %}💍
|
||||
{% elif slot_id == 'accessory_2' %}📿
|
||||
{% else %}📦{% endif %}
|
||||
</div>
|
||||
<div class="slot-empty-text">Empty</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ Partial template loaded via HTMX when an item is selected
|
||||
hx-vals='{"item_id": "{{ item.item_id }}"{% if suggested_slot %}, "slot": "{{ suggested_slot }}"{% endif %}}'
|
||||
hx-target="#character-panel"
|
||||
hx-swap="innerHTML"
|
||||
onclick="closeModal()">
|
||||
hx-on::after-request="closeModal()">
|
||||
Equip
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user