Compare commits
1 Commits
805d04cf4e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 70b2b0f124 |
@@ -35,7 +35,8 @@ class Character:
|
|||||||
player_class: Character's class (determines base stats and skill trees)
|
player_class: Character's class (determines base stats and skill trees)
|
||||||
origin: Character's backstory origin (saved for AI DM narrative hooks)
|
origin: Character's backstory origin (saved for AI DM narrative hooks)
|
||||||
level: Current level
|
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)
|
base_stats: Base stats (from class + level-ups)
|
||||||
unlocked_skills: List of skill_ids that have been unlocked
|
unlocked_skills: List of skill_ids that have been unlocked
|
||||||
inventory: All items the character owns
|
inventory: All items the character owns
|
||||||
@@ -53,7 +54,8 @@ class Character:
|
|||||||
player_class: PlayerClass
|
player_class: PlayerClass
|
||||||
origin: Origin
|
origin: Origin
|
||||||
level: int = 1
|
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
|
# Stats and progression
|
||||||
base_stats: Stats = field(default_factory=Stats)
|
base_stats: Stats = field(default_factory=Stats)
|
||||||
@@ -315,6 +317,9 @@ class Character:
|
|||||||
"""
|
"""
|
||||||
Add experience points and check for level up.
|
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:
|
Args:
|
||||||
xp: Amount of experience to add
|
xp: Amount of experience to add
|
||||||
|
|
||||||
@@ -322,6 +327,7 @@ class Character:
|
|||||||
True if character leveled up, False otherwise
|
True if character leveled up, False otherwise
|
||||||
"""
|
"""
|
||||||
self.experience += xp
|
self.experience += xp
|
||||||
|
self.total_xp += xp # Track cumulative XP (never decreases)
|
||||||
required_xp = self._calculate_xp_for_next_level()
|
required_xp = self._calculate_xp_for_next_level()
|
||||||
|
|
||||||
if self.experience >= required_xp:
|
if self.experience >= required_xp:
|
||||||
@@ -334,15 +340,18 @@ class Character:
|
|||||||
"""
|
"""
|
||||||
Level up the character.
|
Level up the character.
|
||||||
|
|
||||||
- Increases level
|
- Increases level by 1
|
||||||
- Resets experience to overflow amount
|
- 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)
|
- Could grant stat increases (future enhancement)
|
||||||
"""
|
"""
|
||||||
required_xp = self._calculate_xp_for_next_level()
|
required_xp = self._calculate_xp_for_next_level()
|
||||||
overflow_xp = self.experience - required_xp
|
overflow_xp = self.experience - required_xp
|
||||||
|
|
||||||
self.level += 1
|
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
|
# Future: Apply stat increases based on class
|
||||||
# For now, stats are increased manually via skill points
|
# For now, stats are increased manually via skill points
|
||||||
@@ -359,6 +368,19 @@ class Character:
|
|||||||
"""
|
"""
|
||||||
return int(100 * (self.level ** 1.5))
|
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]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Serialize character to dictionary for JSON storage.
|
Serialize character to dictionary for JSON storage.
|
||||||
@@ -374,6 +396,9 @@ class Character:
|
|||||||
"origin": self.origin.to_dict(),
|
"origin": self.origin.to_dict(),
|
||||||
"level": self.level,
|
"level": self.level,
|
||||||
"experience": self.experience,
|
"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(),
|
"base_stats": self.base_stats.to_dict(),
|
||||||
"unlocked_skills": self.unlocked_skills,
|
"unlocked_skills": self.unlocked_skills,
|
||||||
"inventory": [item.to_dict() for item in self.inventory],
|
"inventory": [item.to_dict() for item in self.inventory],
|
||||||
@@ -465,6 +490,7 @@ class Character:
|
|||||||
origin=origin,
|
origin=origin,
|
||||||
level=data.get("level", 1),
|
level=data.get("level", 1),
|
||||||
experience=data.get("experience", 0),
|
experience=data.get("experience", 0),
|
||||||
|
total_xp=data.get("total_xp", 0), # Default 0 for legacy data
|
||||||
base_stats=base_stats,
|
base_stats=base_stats,
|
||||||
unlocked_skills=data.get("unlocked_skills", []),
|
unlocked_skills=data.get("unlocked_skills", []),
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
|
|||||||
@@ -1261,7 +1261,12 @@ class CombatService:
|
|||||||
xp_per_player = rewards.experience // max(1, len(player_combatants))
|
xp_per_player = rewards.experience // max(1, len(player_combatants))
|
||||||
gold_per_player = rewards.gold // 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():
|
if session.is_solo():
|
||||||
char_id = session.solo_character_id
|
char_id = session.solo_character_id
|
||||||
else:
|
else:
|
||||||
@@ -1278,6 +1283,18 @@ class CombatService:
|
|||||||
# Add gold
|
# Add gold
|
||||||
character.gold += gold_per_player
|
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
|
# Save character
|
||||||
self.character_service.update_character(character, user_id)
|
self.character_service.update_character(character, user_id)
|
||||||
|
|
||||||
|
|||||||
@@ -936,7 +936,8 @@ power = fireball.calculate_power(caster_stats)
|
|||||||
| `name` | str | Character name |
|
| `name` | str | Character name |
|
||||||
| `player_class` | PlayerClass | Character class |
|
| `player_class` | PlayerClass | Character class |
|
||||||
| `level` | int | Current level |
|
| `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 |
|
| `stats` | Stats | Current stats |
|
||||||
| `unlocked_skills` | List[str] | Unlocked skill_ids |
|
| `unlocked_skills` | List[str] | Unlocked skill_ids |
|
||||||
| `inventory` | List[Item] | All items |
|
| `inventory` | List[Item] | All items |
|
||||||
@@ -945,10 +946,17 @@ power = fireball.calculate_power(caster_stats)
|
|||||||
| `active_quests` | List[str] | Quest IDs |
|
| `active_quests` | List[str] | Quest IDs |
|
||||||
| `discovered_locations` | List[str] | Location 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:**
|
**Methods:**
|
||||||
- `to_dict()` - Serialize to dictionary for JSON storage
|
- `to_dict()` - Serialize to dictionary for JSON storage (includes computed fields)
|
||||||
- `from_dict(data)` - Deserialize from dictionary
|
- `from_dict(data)` - Deserialize from dictionary (handles legacy data)
|
||||||
- `get_effective_stats(active_effects)` - **THE CRITICAL METHOD** - Calculate final stats
|
- `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:**
|
**get_effective_stats() Details:**
|
||||||
|
|
||||||
|
|||||||
@@ -401,6 +401,12 @@ XP Required = 100 × (current_level ^ 1.5)
|
|||||||
- Level up triggers automatically when threshold reached
|
- Level up triggers automatically when threshold reached
|
||||||
- Base stats remain constant (progression via skill trees & equipment)
|
- 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:**
|
**Implementation:**
|
||||||
- Leveling logic lives in `Character` model (`add_experience()`, `level_up()` methods)
|
- Leveling logic lives in `Character` model (`add_experience()`, `level_up()` methods)
|
||||||
- No separate service needed (OOP design pattern)
|
- 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 leveled_up == False
|
||||||
assert basic_character.level == 1
|
assert basic_character.level == 1
|
||||||
assert basic_character.experience == 50
|
assert basic_character.experience == 50
|
||||||
|
assert basic_character.total_xp == 50 # Cumulative XP tracked
|
||||||
|
|
||||||
|
|
||||||
def test_add_experience_with_level_up(basic_character):
|
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 leveled_up == True
|
||||||
assert basic_character.level == 2
|
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):
|
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 leveled_up == True
|
||||||
assert basic_character.level == 2
|
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):
|
def test_xp_calculation(basic_origin):
|
||||||
@@ -386,6 +429,7 @@ def test_character_serialization(basic_character):
|
|||||||
basic_character.gold = 500
|
basic_character.gold = 500
|
||||||
basic_character.level = 3
|
basic_character.level = 3
|
||||||
basic_character.experience = 100
|
basic_character.experience = 100
|
||||||
|
basic_character.total_xp = 500 # Manually set for test
|
||||||
|
|
||||||
data = basic_character.to_dict()
|
data = basic_character.to_dict()
|
||||||
|
|
||||||
@@ -394,7 +438,13 @@ def test_character_serialization(basic_character):
|
|||||||
assert data["name"] == "Test Hero"
|
assert data["name"] == "Test Hero"
|
||||||
assert data["level"] == 3
|
assert data["level"] == 3
|
||||||
assert data["experience"] == 100
|
assert data["experience"] == 100
|
||||||
|
assert data["total_xp"] == 500
|
||||||
assert data["gold"] == 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):
|
def test_character_deserialization(basic_player_class, basic_origin):
|
||||||
|
|||||||
@@ -636,7 +636,15 @@ class TestRewardsCalculation:
|
|||||||
|
|
||||||
# Mock loot service to return mock items
|
# Mock loot service to return mock items
|
||||||
mock_item = Mock()
|
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]
|
service.loot_service.generate_loot_from_enemy.return_value = [mock_item]
|
||||||
|
|
||||||
mock_session = Mock()
|
mock_session = Mock()
|
||||||
@@ -647,6 +655,7 @@ class TestRewardsCalculation:
|
|||||||
mock_char.level = 1
|
mock_char.level = 1
|
||||||
mock_char.experience = 0
|
mock_char.experience = 0
|
||||||
mock_char.gold = 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.get_character.return_value = mock_char
|
||||||
service.character_service.update_character = Mock()
|
service.character_service.update_character = Mock()
|
||||||
|
|
||||||
@@ -655,3 +664,6 @@ class TestRewardsCalculation:
|
|||||||
assert rewards.experience == 50
|
assert rewards.experience == 50
|
||||||
assert rewards.gold == 25
|
assert rewards.gold == 25
|
||||||
assert len(rewards.items) == 1
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user