From 70b2b0f1244eeb5300dc3cdf916d08a77934d14b Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 30 Nov 2025 18:20:40 -0600 Subject: [PATCH] fixing leveling xp reporting --- api/app/models/character.py | 36 +++++++++++++++++--- api/app/services/combat_service.py | 19 ++++++++++- api/docs/DATA_MODELS.md | 14 ++++++-- api/docs/GAME_SYSTEMS.md | 6 ++++ api/tests/test_character.py | 54 ++++++++++++++++++++++++++++-- api/tests/test_combat_service.py | 14 +++++++- 6 files changed, 131 insertions(+), 12 deletions(-) diff --git a/api/app/models/character.py b/api/app/models/character.py index ae874a2..fefd7a3 100644 --- a/api/app/models/character.py +++ b/api/app/models/character.py @@ -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, diff --git a/api/app/services/combat_service.py b/api/app/services/combat_service.py index df9fb52..a53cf1d 100644 --- a/api/app/services/combat_service.py +++ b/api/app/services/combat_service.py @@ -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) diff --git a/api/docs/DATA_MODELS.md b/api/docs/DATA_MODELS.md index 2b824ed..d8be1c4 100644 --- a/api/docs/DATA_MODELS.md +++ b/api/docs/DATA_MODELS.md @@ -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:** diff --git a/api/docs/GAME_SYSTEMS.md b/api/docs/GAME_SYSTEMS.md index 05e3956..d8ec26e 100644 --- a/api/docs/GAME_SYSTEMS.md +++ b/api/docs/GAME_SYSTEMS.md @@ -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) diff --git a/api/tests/test_character.py b/api/tests/test_character.py index 30971bc..6d87ef6 100644 --- a/api/tests/test_character.py +++ b/api/tests/test_character.py @@ -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): diff --git a/api/tests/test_combat_service.py b/api/tests/test_combat_service.py index 16bdba1..25e5354 100644 --- a/api/tests/test_combat_service.py +++ b/api/tests/test_combat_service.py @@ -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"