Compare commits

...

39 Commits

Author SHA1 Message Date
45cfa25911 adding more enemies 2025-11-28 10:37:57 -06:00
7c0e257540 Merge pull request 'feat/phase4-combat-foundation' (#8) from feat/phase4-combat-foundation into dev
Reviewed-on: #8
2025-11-28 04:21:19 +00:00
6d3fb63355 Combat foundation complete 2025-11-27 22:18:58 -06:00
dd92cf5991 combat testing and polishing in the dev console, many bug fixes 2025-11-27 20:37:53 -06:00
94c4ca9e95 updating docs 2025-11-27 11:51:21 -06:00
19b537d8b0 updating docs 2025-11-27 11:50:06 -06:00
58f0c1b8f6 trimming phase 4 planning doc 2025-11-27 00:10:26 -06:00
29b4853c84 updating docs 2025-11-27 00:05:33 -06:00
fdd48034e4 feat(api): implement combat loot integration with hybrid static/procedural system
Add CombatLootService that orchestrates loot generation from combat,
supporting both static item drops (consumables, materials) and procedural
equipment generation (weapons, armor with affixes).

Key changes:
- Extend LootEntry model with LootType enum (STATIC/PROCEDURAL)
- Create StaticItemLoader service for consumables/materials from YAML
- Create CombatLootService with full rarity formula incorporating:
  - Party average level
  - Enemy difficulty tier (EASY: +0%, MEDIUM: +5%, HARD: +15%, BOSS: +30%)
  - Character luck stat
  - Per-entry rarity bonus
- Integrate with CombatService._calculate_rewards() for automatic loot gen
- Add boss guaranteed drops via generate_boss_loot()

New enemy variants (goblin family proof-of-concept):
- goblin_scout (Easy) - static drops only
- goblin_warrior (Medium) - static + procedural weapon drops
- goblin_chieftain (Hard) - static + procedural weapon/armor drops

Static items added:
- consumables.yaml: health/mana potions, elixirs, food
- materials.yaml: trophy items, crafting materials

Tests: 59 new tests across 3 test files (all passing)
2025-11-27 00:01:17 -06:00
a38906b445 feat(api): integrate equipment stats into combat damage system
Equipment-Combat Integration:
- Update Stats damage formula from STR//2 to int(STR*0.75) for better scaling
- Add spell_power system for magical weapons (staves, wands)
- Add spell_power_bonus field to Stats model with spell_power property
- Add spell_power field to Item model with is_magical_weapon() method
- Update Character.get_effective_stats() to populate spell_power_bonus

Combatant Model Updates:
- Add weapon property fields (crit_chance, crit_multiplier, damage_type)
- Add elemental weapon support (elemental_damage_type, physical_ratio, elemental_ratio)
- Update serialization to handle new weapon properties

DamageCalculator Refactoring:
- Remove weapon_damage parameter from calculate_physical_damage()
- Use attacker_stats.damage directly (includes weapon bonus)
- Use attacker_stats.spell_power for magical damage calculations

Combat Service Updates:
- Extract weapon properties in _create_combatant_from_character()
- Use stats.damage_bonus for enemy combatants from templates
- Remove hardcoded _get_weapon_damage() method
- Handle elemental weapons with split damage in _execute_attack()

Item Generation Updates:
- Add base_spell_power to BaseItemTemplate dataclass
- Add ARCANE damage type to DamageType enum
- Add magical weapon templates (wizard_staff, arcane_staff, wand, crystal_wand)

Test Updates:
- Update test_stats.py for new damage formula (0.75 scaling)
- Update test_character.py for equipment bonus calculations
- Update test_damage_calculator.py for new API signatures
- Update test_combat_service.py mock fixture for equipped attribute

Tests: 174 passing
2025-11-26 19:54:58 -06:00
4ced1b04df feat(api): implement inventory API endpoints
Add REST API endpoints for character inventory management:
- GET /api/v1/characters/<id>/inventory - Get inventory and equipped items
- POST /api/v1/characters/<id>/inventory/equip - Equip item to slot
- POST /api/v1/characters/<id>/inventory/unequip - Unequip from slot
- POST /api/v1/characters/<id>/inventory/use - Use consumable item
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop item

All endpoints include:
- Authentication via @require_auth decorator
- Ownership validation through CharacterService
- Comprehensive error handling with proper HTTP status codes
- Full logging for debugging

Includes 25 integration tests covering authentication requirements,
URL patterns, and response formats.

Task 2.4 of Phase 4 Combat Implementation complete.
2025-11-26 18:54:33 -06:00
76f67c4a22 feat(api): implement inventory service with equipment system
Add InventoryService for managing character inventory, equipment, and
consumable usage. Key features:

- Add/remove items with inventory capacity checks
- Equipment slot validation (weapon, off_hand, helmet, chest, gloves,
  boots, accessory_1, accessory_2)
- Level and class requirement validation for equipment
- Consumable usage with instant and duration-based effects
- Combat-specific consumable method returning effects for combat system
- Bulk operations (add_items, get_items_by_type, get_equippable_items)

Design decision: Uses full Item object storage (not IDs) to support
procedurally generated items with unique identifiers.

Files added:
- /api/app/services/inventory_service.py (560 lines)
- /api/tests/test_inventory_service.py (51 tests passing)

Task 2.3 of Phase 4 Combat Implementation complete.
2025-11-26 18:38:39 -06:00
185be7fee0 feat(api): implement Diablo-style item affix system
Add procedural item generation with affix naming system:
- Items with RARE/EPIC/LEGENDARY rarity get dynamic names
- Prefixes (e.g., "Flaming") add elemental damage, material bonuses
- Suffixes (e.g., "of Strength") add stat bonuses
- Affix count scales with rarity: RARE=1, EPIC=2, LEGENDARY=3

New files:
- models/affixes.py: Affix and BaseItemTemplate dataclasses
- services/affix_loader.py: YAML-based affix pool loading
- services/base_item_loader.py: Base item template loading
- services/item_generator.py: Main procedural generation service
- data/affixes/prefixes.yaml: 14 prefix definitions
- data/affixes/suffixes.yaml: 15 suffix definitions
- data/base_items/weapons.yaml: 12 weapon templates
- data/base_items/armor.yaml: 12 armor templates
- tests/test_item_generator.py: 34 comprehensive tests

Modified:
- enums.py: Added AffixType and AffixTier enums
- items.py: Added affix tracking fields (applied_affixes, generated_name)

Example output: "Frozen Dagger of the Bear" (EPIC with ice damage + STR/CON)
2025-11-26 17:57:34 -06:00
f3ac0c8647 feat(api): add ItemRarity enum to item system
- Add ItemRarity enum with 5 tiers (common, uncommon, rare, epic, legendary)
- Add rarity field to Item dataclass with COMMON default
- Update Item serialization (to_dict/from_dict) for rarity
- Export ItemRarity from models package
- Add 24 comprehensive unit tests for Item and ItemRarity

Part of Phase 4 Week 2: Inventory & Equipment System (Task 2.1)
2025-11-26 16:14:29 -06:00
03ab783eeb Combat Backend & Data Models
- Implement Combat Service
- Implement Damage Calculator
- Implement Effect Processor
- Implement Combat Actions
- Created Combat API Endpoints
2025-11-26 15:43:20 -06:00
30c3b800e6 feat(api): add luck (LUK) stat to character system
Add new Luck stat to the character stats system with class-specific values:
- Assassin: 12 (highest - critical specialists)
- Luminary: 11 (divine favor)
- Wildstrider/Lorekeeper: 10 (average)
- Arcanist/Oathkeeper: 9 (modest)
- Vanguard: 8 (default - relies on strength)
- Necromancer: 7 (lowest - dark arts cost)

Changes:
- Add luck field to Stats dataclass with default of 8
- Add LUCK to StatType enum
- Update all 8 class YAML files with luck values
- Display LUK in character panel (play page) and detail page
- Update DATA_MODELS.md documentation

Backward compatible: existing characters without luck default to 8.
2025-11-26 12:27:18 -06:00
d789b5df65 planning and docs update 2025-11-26 11:35:18 -06:00
e6e7cdb7b7 Merge pull request 'fix(api): delete orphaned sessions when character is deleted' (#7) from bug/orphaned-sessions-on-char-delete into dev
Reviewed-on: #7
2025-11-26 16:47:06 +00:00
98bb6ab589 fix(api): delete orphaned sessions when character is deleted
- Add delete_sessions_by_character() method to SessionService that
  cleans up all game sessions associated with a character
- Update delete_character() to hard delete instead of soft delete
- Call session cleanup before deleting character to prevent orphaned data
- Delete associated chat messages when cleaning up sessions
- Update API documentation to reflect new behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:46:35 -06:00
1b21465dc4 Merge pull request 'feat(web): add navigation menu bar for logged-in users' (#6) from feat/menu-bar into dev
Reviewed-on: #6
2025-11-26 16:22:25 +00:00
77d913fe50 feat(web): add navigation menu bar for logged-in users
- Add horizontal nav menu with 7 items: Profile, Characters, Sessions,
  Mechanics, Leaderboard, Settings, Help
- Implement responsive hamburger menu for mobile (≤768px)
- Create pages blueprint with stub routes for new pages
- Add "Coming Soon" styled stub templates with icons
- Include active state highlighting for current page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:21:46 -06:00
4d26c43d1d Merge pull request 'feat/session-management' (#5) from feat/session-management into dev
Reviewed-on: #5
2025-11-26 16:08:42 +00:00
51f6041ee4 fix(api): remove reference to non-existent TIER_LIMITS attribute
The RateLimiterService.__init__ was logging self.TIER_LIMITS which doesn't
exist after refactoring to config-based tier limits. Changed to log the
existing DM_QUESTION_LIMITS attribute instead.
2025-11-26 10:07:35 -06:00
19808dd44c docs: update rate limit values to match config-based system
- Update USAGE_TRACKING.md with new tier limits (50, 200, 1000, unlimited)
- Update AI_INTEGRATION.md with new tier limits
- Add note that limits are loaded from config (ai_calls_per_day)
- Document GET /api/v1/usage endpoint
- Update examples to show is_unlimited field
- Fix test examples with correct limit values

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:02:30 -06:00
61a42d3a77 feat(api,web): tier-based session limits and daily turn usage display
Backend Changes:
- Add tier-based max_sessions config (free: 1, basic: 2, premium: 3, elite: 5)
- Add DELETE /api/v1/sessions/{id} endpoint for hard session deletion
- Cascade delete chat messages when session is deleted
- Add GET /api/v1/usage endpoint for daily turn limit info
- Replace hardcoded TIER_LIMITS with config-based ai_calls_per_day
- Handle unlimited (-1) tier in rate limiter service

Frontend Changes:
- Add inline session delete buttons with HTMX on character list
- Add usage_display.html component showing remaining daily turns
- Display usage indicator on character list and game play pages
- Page refresh after session deletion to update UI state

Documentation:
- Update API_REFERENCE.md with new endpoints and tier limits
- Update API_TESTING.md with session endpoint examples
- Update SESSION_MANAGEMENT.md with tier-based limits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:00:45 -06:00
0a7156504f fixed usernames name at the top of the page when logged in 2025-11-26 09:04:26 -06:00
8312cfe13f Merge pull request 'fix(web): restore 3-column NPC chat modal layout for desktop' (#4) from bug/non-mobile-ui-fix-for-npcs into dev
Reviewed-on: #4
2025-11-26 04:17:13 +00:00
16171dc34a fix(web): restore 3-column NPC chat modal layout for desktop
- Fixed grid layout not applying to modal content (grid was on wrong parent element)
- Applied grid to .npc-chat-container instead of .npc-modal-body--three-col
- Removed htmx-indicator class from history panel (was causing content to disappear)
- Made history loading indicator visible by default
- Updated responsive breakpoints to target correct selectors
- Added warning to HTMX_PATTERNS.md about htmx-indicator hidden behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 22:16:33 -06:00
52b199ff10 Merge pull request 'feat(api): add Redis session cache to reduce Appwrite API calls by ~90%' (#3) from feat/optimize-api-auth-calls into dev
Reviewed-on: #3
2025-11-26 04:01:53 +00:00
8675f9bf75 feat(api): add Redis session cache to reduce Appwrite API calls by ~90%
- Add SessionCacheService with 5-minute TTL Redis cache
- Cache validated sessions to avoid redundant Appwrite calls
- Add /api/v1/auth/me endpoint for retrieving current user
- Invalidate cache on logout and password reset
- Add session_cache config to auth section (Redis db 2)
- Fix Docker Redis hostname (localhost -> redis)
- Handle timezone-aware datetime comparisons

Security: tokens hashed before use as cache keys, explicit
invalidation on logout/password change, graceful degradation
when Redis unavailable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 22:01:14 -06:00
a0635499a7 Merge pull request 'feat/chat-history-upgrade' (#2) from feat/chat-history-upgrade into dev
Reviewed-on: #2
2025-11-26 03:32:06 +00:00
2419dbeb34 feat(web): implement responsive modal pattern for mobile-friendly NPC chat
- Add hybrid modal/page navigation based on screen size (1024px breakpoint)
- Desktop (>1024px): Uses modal overlays for quick interactions
- Mobile (≤1024px): Navigates to dedicated full pages for better UX
- Extract shared NPC chat content into reusable partial template
- Add responsive navigation JavaScript (responsive-modals.js)
- Create dedicated NPC chat page route with back button navigation
- Add mobile-optimized CSS with sticky header and chat input
- Fix HTMX indicator errors by using htmx-indicator class pattern
- Document responsive modal pattern for future features

Addresses mobile UX issues: cramped space, nested scrolling, keyboard conflicts,
and lack of native back button support in modals.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 21:30:51 -06:00
196346165f chat history with the NPC modal 2025-11-25 21:16:01 -06:00
20cb279793 fix: resolve NPC chat database persistence and modal targeting
Fixed two critical bugs in NPC chat functionality:

  1. Database Persistence - Metadata serialization bug
     - Empty dict {} was falsy, preventing JSON conversion
     - Changed to unconditional serialization in ChatMessageService
     - Messages now successfully save to chat_messages collection

  2. Modal Targeting - HTMX targeting lost during polling
     - poll_job() wasn't preserving hx-target/hx-swap parameters
     - Pass targeting params through query string in polling cycle
     - Responses now correctly load in modal instead of main panel

  Files modified:
  - api/app/services/chat_message_service.py
  - public_web/templates/game/partials/job_polling.html
  - public_web/app/views/game_views.py
2025-11-25 20:44:24 -06:00
4353d112f4 feat(api): implement unlimited chat history system with hybrid storage
Replaces 10-message cap dialogue_history with scalable chat_messages collection.

New Features:
- Unlimited conversation history in dedicated chat_messages collection
- Hybrid storage: recent 3 messages cached in character docs for AI context
- 4 new REST API endpoints: conversations summary, full history, search, soft delete
- Full-text search with filters (NPC, context, date range)
- Quest and faction tracking ready via context enum and metadata field
- Soft delete support for privacy/moderation

Technical Changes:
- Created ChatMessage model with MessageContext enum
- Created ChatMessageService with 5 core methods
- Added chat_messages Appwrite collection with 5 composite indexes
- Updated NPC dialogue task to save to chat_messages
- Updated CharacterService.get_npc_dialogue_history() with backward compatibility
- Created /api/v1/characters/{char_id}/chats API endpoints
- Registered chat blueprint in Flask app

Documentation:
- Updated API_REFERENCE.md with 4 new endpoints
- Updated DATA_MODELS.md with ChatMessage model and NPCInteractionState changes
- Created comprehensive CHAT_SYSTEM.md architecture documentation

Performance:
- 50x faster AI context retrieval (reads from cache, no DB query)
- 67% reduction in character document size
- Query performance O(log n) with indexed searches

Backward Compatibility:
- dialogue_history field maintained during transition
- Graceful fallback for old character documents
- No forced migration required

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 16:32:21 -06:00
9c6eb770e5 Merge pull request 'feat/playscreen' (#1) from feat/playscreen into dev
Reviewed-on: #1
2025-11-25 22:06:06 +00:00
aaa69316c2 docs update 2025-11-25 16:05:42 -06:00
bda363de76 added npc images to API and frontend 2025-11-25 15:52:22 -06:00
e198d9ac8a api docs update 2025-11-24 23:20:27 -06:00
205 changed files with 35014 additions and 2206 deletions

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ Thumbs.db
logs/ logs/
app/logs/ app/logs/
*.log *.log
CLAUDE.md

View File

@@ -164,8 +164,22 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(npcs_bp) app.register_blueprint(npcs_bp)
logger.info("NPCs API blueprint registered") logger.info("NPCs API blueprint registered")
# Import and register Chat API blueprint
from app.api.chat import chat_bp
app.register_blueprint(chat_bp)
logger.info("Chat API blueprint registered")
# Import and register Combat API blueprint
from app.api.combat import combat_bp
app.register_blueprint(combat_bp)
logger.info("Combat API blueprint registered")
# Import and register Inventory API blueprint
from app.api.inventory import inventory_bp
app.register_blueprint(inventory_bp)
logger.info("Inventory API blueprint registered")
# TODO: Register additional blueprints as they are created # TODO: Register additional blueprints as they are created
# from app.api import combat, marketplace, shop # from app.api import 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(marketplace.bp, url_prefix='/api/v1/marketplace')
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop') # app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')

View File

@@ -15,6 +15,7 @@ from flask import Blueprint, request, make_response, render_template, redirect,
from appwrite.exception import AppwriteException from appwrite.exception import AppwriteException
from app.services.appwrite_service import AppwriteService from app.services.appwrite_service import AppwriteService
from app.services.session_cache_service import SessionCacheService
from app.utils.response import ( from app.utils.response import (
success_response, success_response,
created_response, created_response,
@@ -305,7 +306,11 @@ def api_logout():
if not token: if not token:
return unauthorized_response(message="No active session") return unauthorized_response(message="No active session")
# Logout user # Invalidate session cache before Appwrite logout
cache = SessionCacheService()
cache.invalidate_token(token)
# Logout user from Appwrite
appwrite = AppwriteService() appwrite = AppwriteService()
appwrite.logout_user(session_id=token) appwrite.logout_user(session_id=token)
@@ -340,6 +345,36 @@ def api_logout():
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR") return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
@auth_bp.route('/api/v1/auth/me', methods=['GET'])
@require_auth
def api_get_current_user():
"""
Get the currently authenticated user's data.
This endpoint is lightweight and uses cached session data when available,
making it suitable for frequent use (e.g., checking user tier, verifying
session is still valid).
Returns:
200: User data
401: Not authenticated
"""
user = get_current_user()
if not user:
return unauthorized_response(message="Not authenticated")
return success_response(
result={
"id": user.id,
"email": user.email,
"name": user.name,
"email_verified": user.email_verified,
"tier": user.tier
}
)
@auth_bp.route('/api/v1/auth/verify-email', methods=['GET']) @auth_bp.route('/api/v1/auth/verify-email', methods=['GET'])
def api_verify_email(): def api_verify_email():
""" """
@@ -480,6 +515,10 @@ def api_reset_password():
appwrite = AppwriteService() appwrite = AppwriteService()
appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password) appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password)
# Invalidate all cached sessions for this user (security: password changed)
cache = SessionCacheService()
cache.invalidate_user(user_id)
logger.info("Password reset successfully", user_id=user_id) logger.info("Password reset successfully", user_id=user_id)
return success_response( return success_response(

320
api/app/api/chat.py Normal file
View File

@@ -0,0 +1,320 @@
"""
Chat API Endpoints.
Provides REST API for accessing player-NPC conversation history,
searching messages, and managing chat data.
"""
from flask import Blueprint, request
from datetime import datetime, timezone
from app.utils.auth import require_auth, get_current_user
from app.services.chat_message_service import (
get_chat_message_service,
ChatMessageNotFound,
ChatMessagePermissionDenied
)
from app.services.npc_loader import get_npc_loader
from app.models.chat_message import MessageContext
from app.utils.response import (
success_response,
error_response,
not_found_response,
validation_error_response
)
from app.utils.logging import get_logger
logger = get_logger(__file__)
chat_bp = Blueprint('chat', __name__)
@chat_bp.route('/api/v1/characters/<character_id>/chats', methods=['GET'])
@require_auth
def get_conversations_summary(character_id: str):
"""
Get summary of all NPC conversations for a character.
Returns list of NPCs the character has talked to, with message counts,
last message timestamp, and preview of most recent exchange.
Path params:
character_id: Character ID
Query params:
None
Returns:
JSON response with list of conversation summaries
"""
try:
user = get_current_user()
# Get conversation summaries
chat_service = get_chat_message_service()
summaries = chat_service.get_all_conversations_summary(character_id, user.id)
# Convert to dict for JSON response
conversations = [summary.to_dict() for summary in summaries]
logger.info("Retrieved conversation summaries",
user_id=user.id,
character_id=character_id,
count=len(conversations))
return success_response({"conversations": conversations})
except ChatMessagePermissionDenied as e:
logger.warning("Permission denied getting conversations",
user_id=user.id if 'user' in locals() else None,
character_id=character_id)
return error_response(str(e), 403)
except Exception as e:
logger.error("Failed to get conversations summary",
character_id=character_id,
error=str(e))
return error_response(f"Failed to retrieve conversations: {str(e)}", 500)
@chat_bp.route('/api/v1/characters/<character_id>/chats/<npc_id>', methods=['GET'])
@require_auth
def get_conversation_history(character_id: str, npc_id: str):
"""
Get full conversation history between character and specific NPC.
Returns paginated list of messages ordered by timestamp DESC (most recent first).
Path params:
character_id: Character ID
npc_id: NPC ID
Query params:
limit: Maximum messages to return (default 50, max 100)
offset: Number of messages to skip (default 0)
Returns:
JSON response with messages and pagination info
"""
try:
user = get_current_user()
# Get query parameters
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
# Validate parameters
if limit < 1:
return validation_error_response("limit must be at least 1")
if offset < 0:
return validation_error_response("offset must be at least 0")
# Get conversation history
chat_service = get_chat_message_service()
messages = chat_service.get_conversation_history(
character_id=character_id,
user_id=user.id,
npc_id=npc_id,
limit=limit,
offset=offset
)
# Get NPC name if available
npc_loader = get_npc_loader()
npc = npc_loader.load_npc(npc_id)
npc_name = npc.name if npc else npc_id.replace('_', ' ').title()
# Convert messages to dict
message_dicts = [msg.to_dict() for msg in messages]
result = {
"npc_id": npc_id,
"npc_name": npc_name,
"total_messages": len(messages), # Count in this batch
"messages": message_dicts,
"pagination": {
"limit": limit,
"offset": offset,
"has_more": len(messages) == limit # If we got a full batch, there might be more
}
}
logger.info("Retrieved conversation history",
user_id=user.id,
character_id=character_id,
npc_id=npc_id,
count=len(messages))
return success_response(result)
except ChatMessagePermissionDenied as e:
logger.warning("Permission denied getting conversation",
user_id=user.id if 'user' in locals() else None,
character_id=character_id,
npc_id=npc_id)
return error_response(str(e), 403)
except Exception as e:
logger.error("Failed to get conversation history",
character_id=character_id,
npc_id=npc_id,
error=str(e))
return error_response(f"Failed to retrieve conversation: {str(e)}", 500)
@chat_bp.route('/api/v1/characters/<character_id>/chats/search', methods=['GET'])
@require_auth
def search_messages(character_id: str):
"""
Search messages by text with optional filters.
Query params:
q (required): Search text to find in player_message and npc_response
npc_id (optional): Filter by specific NPC
context (optional): Filter by message context (dialogue, quest_offered, shop, etc.)
date_from (optional): Start date ISO format (e.g., 2025-11-25T00:00:00Z)
date_to (optional): End date ISO format
limit (optional): Maximum messages to return (default 50, max 100)
offset (optional): Number of messages to skip (default 0)
Returns:
JSON response with matching messages
"""
try:
user = get_current_user()
# Get query parameters
search_text = request.args.get('q')
npc_id = request.args.get('npc_id')
context_str = request.args.get('context')
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
# Validate required parameters
if not search_text:
return validation_error_response("q (search text) is required")
# Validate optional parameters
if limit < 1:
return validation_error_response("limit must be at least 1")
if offset < 0:
return validation_error_response("offset must be at least 0")
# Parse context enum if provided
context = None
if context_str:
try:
context = MessageContext(context_str)
except ValueError:
valid_contexts = [c.value for c in MessageContext]
return validation_error_response(
f"Invalid context. Must be one of: {', '.join(valid_contexts)}"
)
# Search messages
chat_service = get_chat_message_service()
messages = chat_service.search_messages(
character_id=character_id,
user_id=user.id,
search_text=search_text,
npc_id=npc_id,
context=context,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset
)
# Convert messages to dict
message_dicts = [msg.to_dict() for msg in messages]
result = {
"search_text": search_text,
"filters": {
"npc_id": npc_id,
"context": context_str,
"date_from": date_from,
"date_to": date_to
},
"total_results": len(messages),
"messages": message_dicts,
"pagination": {
"limit": limit,
"offset": offset,
"has_more": len(messages) == limit
}
}
logger.info("Search completed",
user_id=user.id,
character_id=character_id,
search_text=search_text,
results=len(messages))
return success_response(result)
except ChatMessagePermissionDenied as e:
logger.warning("Permission denied searching messages",
user_id=user.id if 'user' in locals() else None,
character_id=character_id)
return error_response(str(e), 403)
except Exception as e:
logger.error("Failed to search messages",
character_id=character_id,
error=str(e))
return error_response(f"Search failed: {str(e)}", 500)
@chat_bp.route('/api/v1/characters/<character_id>/chats/<message_id>', methods=['DELETE'])
@require_auth
def delete_message(character_id: str, message_id: str):
"""
Soft delete a message (sets is_deleted=True).
Used for privacy/moderation. Message remains in database but filtered from queries.
Path params:
character_id: Character ID (for ownership validation)
message_id: Message ID to delete
Returns:
JSON response with success confirmation
"""
try:
user = get_current_user()
# Soft delete message
chat_service = get_chat_message_service()
success = chat_service.soft_delete_message(
message_id=message_id,
character_id=character_id,
user_id=user.id
)
logger.info("Message deleted",
user_id=user.id,
character_id=character_id,
message_id=message_id)
return success_response({
"message_id": message_id,
"deleted": success
})
except ChatMessageNotFound as e:
logger.warning("Message not found for deletion",
message_id=message_id,
character_id=character_id)
return not_found_response(str(e))
except ChatMessagePermissionDenied as e:
logger.warning("Permission denied deleting message",
user_id=user.id if 'user' in locals() else None,
character_id=character_id,
message_id=message_id)
return error_response(str(e), 403)
except Exception as e:
logger.error("Failed to delete message",
message_id=message_id,
character_id=character_id,
error=str(e))
return error_response(f"Delete failed: {str(e)}", 500)

1093
api/app/api/combat.py Normal file

File diff suppressed because it is too large Load Diff

639
api/app/api/inventory.py Normal file
View File

@@ -0,0 +1,639 @@
"""
Inventory API Blueprint
Endpoints for managing character inventory and equipment.
All endpoints require authentication and enforce ownership validation.
Endpoints:
- GET /api/v1/characters/<id>/inventory - Get character inventory and equipped items
- POST /api/v1/characters/<id>/inventory/equip - Equip an item
- POST /api/v1/characters/<id>/inventory/unequip - Unequip an item
- POST /api/v1/characters/<id>/inventory/use - Use a consumable item
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop an item
"""
from flask import Blueprint, request
from app.services.inventory_service import (
get_inventory_service,
ItemNotFoundError,
CannotEquipError,
InvalidSlotError,
CannotUseItemError,
InventoryFullError,
VALID_SLOTS,
MAX_INVENTORY_SIZE,
)
from app.services.character_service import (
get_character_service,
CharacterNotFound,
)
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
inventory_bp = Blueprint('inventory', __name__)
# =============================================================================
# API Endpoints
# =============================================================================
@inventory_bp.route('/api/v1/characters/<character_id>/inventory', methods=['GET'])
@require_auth
def get_inventory(character_id: str):
"""
Get character inventory and equipped items.
Args:
character_id: Character ID
Returns:
200: Inventory and equipment data
401: Not authenticated
404: Character not found or not owned by user
500: Internal server error
Example Response:
{
"result": {
"inventory": [
{
"item_id": "gen_abc123",
"name": "Flaming Dagger",
"item_type": "weapon",
"rarity": "rare",
...
}
],
"equipped": {
"weapon": {...},
"helmet": null,
...
},
"inventory_count": 5,
"max_inventory": 100
}
}
"""
try:
user = get_current_user()
logger.info("Getting inventory",
user_id=user.id,
character_id=character_id)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Get inventory items
inventory_items = inventory_service.get_inventory(character)
# Get equipped items
equipped_items = inventory_service.get_equipped_items(character)
# Build equipped dict with all slots (None for empty slots)
equipped_response = {}
for slot in VALID_SLOTS:
item = equipped_items.get(slot)
equipped_response[slot] = item.to_dict() if item else None
logger.info("Inventory retrieved successfully",
user_id=user.id,
character_id=character_id,
item_count=len(inventory_items))
return success_response(
result={
"inventory": [item.to_dict() for item in inventory_items],
"equipped": equipped_response,
"inventory_count": len(inventory_items),
"max_inventory": MAX_INVENTORY_SIZE,
}
)
except CharacterNotFound as e:
logger.warning("Character not found",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except Exception as e:
logger.error("Failed to get inventory",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="INVENTORY_GET_ERROR",
message="Failed to retrieve inventory",
status=500
)
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/equip', methods=['POST'])
@require_auth
def equip_item(character_id: str):
"""
Equip an item from inventory to a specified slot.
Args:
character_id: Character ID
Request Body:
{
"item_id": "gen_abc123",
"slot": "weapon"
}
Returns:
200: Item equipped successfully
400: Cannot equip item (wrong type, level requirement, etc.)
401: Not authenticated
404: Character or item not found
422: Validation error (invalid slot)
500: Internal server error
Example Response:
{
"result": {
"message": "Equipped Flaming Dagger to weapon slot",
"equipped": {...},
"unequipped_item": null
}
}
"""
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"}
)
item_id = data.get('item_id', '').strip()
slot = data.get('slot', '').strip().lower()
# Validate required fields
validation_errors = {}
if not item_id:
validation_errors['item_id'] = "item_id is required"
if not slot:
validation_errors['slot'] = "slot is required"
if validation_errors:
return validation_error_response(
message="Validation failed",
details=validation_errors
)
logger.info("Equipping item",
user_id=user.id,
character_id=character_id,
item_id=item_id,
slot=slot)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Equip item
previous_item = inventory_service.equip_item(
character, item_id, slot, user.id
)
# Get item name for message
equipped_item = character.equipped.get(slot)
item_name = equipped_item.get_display_name() if equipped_item else item_id
# Build equipped response
equipped_response = {}
for s in VALID_SLOTS:
item = character.equipped.get(s)
equipped_response[s] = item.to_dict() if item else None
logger.info("Item equipped successfully",
user_id=user.id,
character_id=character_id,
item_id=item_id,
slot=slot,
previous_item=previous_item.item_id if previous_item else None)
return success_response(
result={
"message": f"Equipped {item_name} to {slot} slot",
"equipped": equipped_response,
"unequipped_item": previous_item.to_dict() if previous_item else None,
}
)
except CharacterNotFound as e:
logger.warning("Character not found for equip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except ItemNotFoundError as e:
logger.warning("Item not found for equip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e))
return not_found_response(message=str(e))
except InvalidSlotError as e:
logger.warning("Invalid slot for equip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
slot=slot if 'slot' in locals() else 'unknown',
error=str(e))
return validation_error_response(
message=str(e),
details={"slot": str(e)}
)
except CannotEquipError as e:
logger.warning("Cannot equip item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e))
return error_response(
code="CANNOT_EQUIP",
message=str(e),
status=400
)
except Exception as e:
logger.error("Failed to equip item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="EQUIP_ERROR",
message="Failed to equip item",
status=500
)
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/unequip', methods=['POST'])
@require_auth
def unequip_item(character_id: str):
"""
Unequip an item from a specified slot (returns to inventory).
Args:
character_id: Character ID
Request Body:
{
"slot": "weapon"
}
Returns:
200: Item unequipped successfully (or slot was empty)
400: Inventory full, cannot unequip
401: Not authenticated
404: Character not found
422: Validation error (invalid slot)
500: Internal server error
Example Response:
{
"result": {
"message": "Unequipped Flaming Dagger from weapon slot",
"unequipped_item": {...},
"equipped": {...}
}
}
"""
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"}
)
slot = data.get('slot', '').strip().lower()
if not slot:
return validation_error_response(
message="Validation failed",
details={"slot": "slot is required"}
)
logger.info("Unequipping item",
user_id=user.id,
character_id=character_id,
slot=slot)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Unequip item
unequipped_item = inventory_service.unequip_item(character, slot, user.id)
# Build equipped response
equipped_response = {}
for s in VALID_SLOTS:
item = character.equipped.get(s)
equipped_response[s] = item.to_dict() if item else None
# Build message
if unequipped_item:
message = f"Unequipped {unequipped_item.get_display_name()} from {slot} slot"
else:
message = f"Slot '{slot}' was already empty"
logger.info("Item unequipped",
user_id=user.id,
character_id=character_id,
slot=slot,
unequipped_item=unequipped_item.item_id if unequipped_item else None)
return success_response(
result={
"message": message,
"unequipped_item": unequipped_item.to_dict() if unequipped_item else None,
"equipped": equipped_response,
}
)
except CharacterNotFound as e:
logger.warning("Character not found for unequip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except InvalidSlotError as e:
logger.warning("Invalid slot for unequip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
slot=slot if 'slot' in locals() else 'unknown',
error=str(e))
return validation_error_response(
message=str(e),
details={"slot": str(e)}
)
except InventoryFullError as e:
logger.warning("Inventory full, cannot unequip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="INVENTORY_FULL",
message=str(e),
status=400
)
except Exception as e:
logger.error("Failed to unequip item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="UNEQUIP_ERROR",
message="Failed to unequip item",
status=500
)
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/use', methods=['POST'])
@require_auth
def use_item(character_id: str):
"""
Use a consumable item from inventory.
Args:
character_id: Character ID
Request Body:
{
"item_id": "health_potion_small"
}
Returns:
200: Item used successfully
400: Cannot use item (not consumable)
401: Not authenticated
404: Character or item not found
500: Internal server error
Example Response:
{
"result": {
"item_used": "Small Health Potion",
"effects_applied": [
{
"effect_name": "Healing",
"effect_type": "hot",
"value": 25,
"message": "Restored 25 HP"
}
],
"hp_restored": 25,
"mp_restored": 0,
"message": "Used Small Health Potion: Restored 25 HP"
}
}
"""
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"}
)
item_id = data.get('item_id', '').strip()
if not item_id:
return validation_error_response(
message="Validation failed",
details={"item_id": "item_id is required"}
)
logger.info("Using item",
user_id=user.id,
character_id=character_id,
item_id=item_id)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Use consumable
result = inventory_service.use_consumable(character, item_id, user.id)
logger.info("Item used successfully",
user_id=user.id,
character_id=character_id,
item_id=item_id,
hp_restored=result.hp_restored,
mp_restored=result.mp_restored)
return success_response(result=result.to_dict())
except CharacterNotFound as e:
logger.warning("Character not found for use item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except ItemNotFoundError as e:
logger.warning("Item not found for use",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e))
return not_found_response(message=str(e))
except CannotUseItemError as e:
logger.warning("Cannot use item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e))
return error_response(
code="CANNOT_USE_ITEM",
message=str(e),
status=400
)
except Exception as e:
logger.error("Failed to use item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="USE_ITEM_ERROR",
message="Failed to use item",
status=500
)
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/<item_id>', methods=['DELETE'])
@require_auth
def drop_item(character_id: str, item_id: str):
"""
Drop (remove) an item from inventory.
Args:
character_id: Character ID
item_id: Item ID to drop
Returns:
200: Item dropped successfully
401: Not authenticated
404: Character or item not found
500: Internal server error
Example Response:
{
"result": {
"message": "Dropped Rusty Sword",
"dropped_item": {...},
"inventory_count": 4
}
}
"""
try:
user = get_current_user()
logger.info("Dropping item",
user_id=user.id,
character_id=character_id,
item_id=item_id)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Drop item
dropped_item = inventory_service.drop_item(character, item_id, user.id)
logger.info("Item dropped successfully",
user_id=user.id,
character_id=character_id,
item_id=item_id,
item_name=dropped_item.get_display_name())
return success_response(
result={
"message": f"Dropped {dropped_item.get_display_name()}",
"dropped_item": dropped_item.to_dict(),
"inventory_count": len(character.inventory),
}
)
except CharacterNotFound as e:
logger.warning("Character not found for drop",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except ItemNotFoundError as e:
logger.warning("Item not found for drop",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id,
error=str(e))
return not_found_response(message=str(e))
except Exception as e:
logger.error("Failed to drop item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id,
error=str(e))
return error_response(
code="DROP_ITEM_ERROR",
message="Failed to drop item",
status=500
)

View File

@@ -281,6 +281,7 @@ def get_npcs_at_location(location_id: str):
"role": npc.role, "role": npc.role,
"appearance": npc.appearance.brief, "appearance": npc.appearance.brief,
"tags": npc.tags, "tags": npc.tags,
"image_url": npc.image_url,
}) })
return success_response({ return success_response({

View File

@@ -132,23 +132,44 @@ def list_sessions():
user = get_current_user() user = get_current_user()
user_id = user.id user_id = user.id
session_service = get_session_service() session_service = get_session_service()
character_service = get_character_service()
# Get user's active sessions # Get user's active sessions
sessions = session_service.get_user_sessions(user_id, active_only=True) sessions = session_service.get_user_sessions(user_id, active_only=True)
# Build character name lookup for efficiency
character_ids = [s.solo_character_id for s in sessions if s.solo_character_id]
character_names = {}
for char_id in character_ids:
try:
char = character_service.get_character(char_id, user_id)
if char:
character_names[char_id] = char.name
except Exception:
pass # Character may have been deleted
# Build response with basic session info # Build response with basic session info
sessions_list = [] sessions_list = []
for session in sessions: for session in sessions:
# Get combat round if in combat
combat_round = None
if session.is_in_combat() and session.combat_encounter:
combat_round = session.combat_encounter.round_number
sessions_list.append({ sessions_list.append({
'session_id': session.session_id, 'session_id': session.session_id,
'character_id': session.solo_character_id, 'character_id': session.solo_character_id,
'character_name': character_names.get(session.solo_character_id),
'turn_number': session.turn_number, 'turn_number': session.turn_number,
'status': session.status.value, 'status': session.status.value,
'created_at': session.created_at, 'created_at': session.created_at,
'last_activity': session.last_activity, 'last_activity': session.last_activity,
'in_combat': session.is_in_combat(),
'game_state': { 'game_state': {
'current_location': session.game_state.current_location, 'current_location': session.game_state.current_location,
'location_type': session.game_state.location_type.value 'location_type': session.game_state.location_type.value,
'in_combat': session.is_in_combat(),
'combat_round': combat_round
} }
}) })
@@ -235,7 +256,7 @@ def create_session():
return error_response( return error_response(
status=409, status=409,
code="SESSION_LIMIT_EXCEEDED", code="SESSION_LIMIT_EXCEEDED",
message="Maximum active sessions limit reached (5). Please end an existing session first." message=str(e)
) )
except Exception as e: except Exception as e:
@@ -485,10 +506,12 @@ def get_session_state(session_id: str):
"character_id": session.get_character_id(), "character_id": session.get_character_id(),
"turn_number": session.turn_number, "turn_number": session.turn_number,
"status": session.status.value, "status": session.status.value,
"in_combat": session.is_in_combat(),
"game_state": { "game_state": {
"current_location": session.game_state.current_location, "current_location": session.game_state.current_location,
"location_type": session.game_state.location_type.value, "location_type": session.game_state.location_type.value,
"active_quests": session.game_state.active_quests "active_quests": session.game_state.active_quests,
"in_combat": session.is_in_combat()
}, },
"available_actions": available_actions "available_actions": available_actions
}) })
@@ -602,3 +625,111 @@ def get_history(session_id: str):
code="HISTORY_ERROR", code="HISTORY_ERROR",
message="Failed to get conversation history" message="Failed to get conversation history"
) )
@sessions_bp.route('/api/v1/sessions/<session_id>', methods=['DELETE'])
@require_auth
def delete_session(session_id: str):
"""
Permanently delete a game session.
This removes the session from the database entirely. The session cannot be
recovered after deletion. Use this to free up session slots for users who
have reached their tier limit.
Returns:
200: Session deleted successfully
401: Not authenticated
404: Session not found or not owned by user
500: Internal server error
"""
logger.info("Deleting session", session_id=session_id)
try:
# Get current user
user = get_current_user()
user_id = user.id
# Delete session (validates ownership internally)
session_service = get_session_service()
session_service.delete_session(session_id, user_id)
logger.info("Session deleted successfully",
session_id=session_id,
user_id=user_id)
return success_response({
"message": "Session deleted successfully",
"session_id": session_id
})
except SessionNotFound as e:
logger.warning("Session not found for deletion",
session_id=session_id,
error=str(e))
return not_found_response("Session not found")
except Exception as e:
logger.error("Failed to delete session",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status=500,
code="SESSION_DELETE_ERROR",
message="Failed to delete session"
)
@sessions_bp.route('/api/v1/usage', methods=['GET'])
@require_auth
def get_usage():
"""
Get user's daily usage information.
Returns the current daily turn usage, limit, remaining turns,
and reset time. Limits are based on user's subscription tier.
Returns:
200: Usage information
{
"user_id": "user_123",
"user_tier": "free",
"current_usage": 15,
"daily_limit": 50,
"remaining": 35,
"reset_time": "2025-11-27T00:00:00+00:00",
"is_limited": false,
"is_unlimited": false
}
401: Not authenticated
500: Internal server error
"""
logger.info("Getting usage info")
try:
# Get current user and tier
user = get_current_user()
user_id = user.id
user_tier = get_user_tier_from_user(user)
# Get usage info from rate limiter
rate_limiter = RateLimiterService()
usage_info = rate_limiter.get_usage_info(user_id, user_tier)
logger.debug("Usage info retrieved",
user_id=user_id,
current_usage=usage_info.get('current_usage'),
remaining=usage_info.get('remaining'))
return success_response(usage_info)
except Exception as e:
logger.error("Failed to get usage info",
error=str(e),
exc_info=True)
return error_response(
status=500,
code="USAGE_ERROR",
message="Failed to get usage information"
)

View File

@@ -76,6 +76,7 @@ class RateLimitTier:
ai_calls_per_day: int ai_calls_per_day: int
custom_actions_per_day: int # -1 for unlimited custom_actions_per_day: int # -1 for unlimited
custom_action_char_limit: int custom_action_char_limit: int
max_sessions: int = 1 # Maximum active game sessions allowed
@dataclass @dataclass
@@ -86,6 +87,14 @@ class RateLimitingConfig:
tiers: Dict[str, RateLimitTier] = field(default_factory=dict) tiers: Dict[str, RateLimitTier] = field(default_factory=dict)
@dataclass
class SessionCacheConfig:
"""Session cache configuration for reducing Appwrite API calls."""
enabled: bool = True
ttl_seconds: int = 300 # 5 minutes
redis_db: int = 2 # Separate from RQ (db 0) and rate limiting (db 1)
@dataclass @dataclass
class AuthConfig: class AuthConfig:
"""Authentication configuration.""" """Authentication configuration."""
@@ -104,6 +113,7 @@ class AuthConfig:
name_min_length: int name_min_length: int
name_max_length: int name_max_length: int
email_max_length: int email_max_length: int
session_cache: SessionCacheConfig = field(default_factory=SessionCacheConfig)
@dataclass @dataclass
@@ -229,7 +239,11 @@ class Config:
tiers=rate_limit_tiers tiers=rate_limit_tiers
) )
auth_config = AuthConfig(**config_data['auth']) # Parse auth config with nested session_cache
auth_data = config_data['auth'].copy()
session_cache_data = auth_data.pop('session_cache', {})
session_cache_config = SessionCacheConfig(**session_cache_data) if session_cache_data else SessionCacheConfig()
auth_config = AuthConfig(**auth_data, session_cache=session_cache_config)
session_config = SessionConfig(**config_data['session']) session_config = SessionConfig(**config_data['session'])
marketplace_config = MarketplaceConfig(**config_data['marketplace']) marketplace_config = MarketplaceConfig(**config_data['marketplace'])
cors_config = CORSConfig(**config_data['cors']) cors_config = CORSConfig(**config_data['cors'])

View File

@@ -0,0 +1,177 @@
# Item Prefix Affixes
# Prefixes appear before the item name: "Flaming Dagger"
#
# Affix Structure:
# affix_id: Unique identifier
# name: Display name (what appears in the item name)
# affix_type: "prefix"
# tier: "minor" (RARE), "major" (EPIC), "legendary" (LEGENDARY only)
# description: Flavor text describing the effect
# stat_bonuses: Dict of stat_name -> bonus value
# defense_bonus: Direct defense bonus
# resistance_bonus: Direct resistance bonus
# damage_bonus: Flat damage bonus (weapons)
# damage_type: Elemental damage type
# elemental_ratio: Portion converted to elemental (0.0-1.0)
# crit_chance_bonus: Added to crit chance
# crit_multiplier_bonus: Added to crit multiplier
# allowed_item_types: [] = all types, or ["weapon", "armor"]
# required_rarity: null = any, or "legendary"
prefixes:
# ==================== ELEMENTAL PREFIXES (FIRE) ====================
flaming:
affix_id: "flaming"
name: "Flaming"
affix_type: "prefix"
tier: "minor"
description: "Imbued with fire magic, dealing bonus fire damage"
damage_type: "fire"
elemental_ratio: 0.25
damage_bonus: 3
allowed_item_types: ["weapon"]
blazing:
affix_id: "blazing"
name: "Blazing"
affix_type: "prefix"
tier: "major"
description: "Wreathed in intense flames"
damage_type: "fire"
elemental_ratio: 0.35
damage_bonus: 6
allowed_item_types: ["weapon"]
# ==================== ELEMENTAL PREFIXES (ICE) ====================
frozen:
affix_id: "frozen"
name: "Frozen"
affix_type: "prefix"
tier: "minor"
description: "Enchanted with frost magic"
damage_type: "ice"
elemental_ratio: 0.25
damage_bonus: 3
allowed_item_types: ["weapon"]
glacial:
affix_id: "glacial"
name: "Glacial"
affix_type: "prefix"
tier: "major"
description: "Encased in eternal ice"
damage_type: "ice"
elemental_ratio: 0.35
damage_bonus: 6
allowed_item_types: ["weapon"]
# ==================== ELEMENTAL PREFIXES (LIGHTNING) ====================
shocking:
affix_id: "shocking"
name: "Shocking"
affix_type: "prefix"
tier: "minor"
description: "Crackles with electrical energy"
damage_type: "lightning"
elemental_ratio: 0.25
damage_bonus: 3
allowed_item_types: ["weapon"]
thundering:
affix_id: "thundering"
name: "Thundering"
affix_type: "prefix"
tier: "major"
description: "Charged with the power of storms"
damage_type: "lightning"
elemental_ratio: 0.35
damage_bonus: 6
allowed_item_types: ["weapon"]
# ==================== MATERIAL PREFIXES ====================
iron:
affix_id: "iron"
name: "Iron"
affix_type: "prefix"
tier: "minor"
description: "Reinforced with sturdy iron"
stat_bonuses:
constitution: 1
defense_bonus: 2
steel:
affix_id: "steel"
name: "Steel"
affix_type: "prefix"
tier: "major"
description: "Forged from fine steel"
stat_bonuses:
constitution: 2
strength: 1
defense_bonus: 4
# ==================== QUALITY PREFIXES ====================
sharp:
affix_id: "sharp"
name: "Sharp"
affix_type: "prefix"
tier: "minor"
description: "Honed to a fine edge"
damage_bonus: 3
crit_chance_bonus: 0.02
allowed_item_types: ["weapon"]
keen:
affix_id: "keen"
name: "Keen"
affix_type: "prefix"
tier: "major"
description: "Razor-sharp edge that finds weak points"
damage_bonus: 5
crit_chance_bonus: 0.04
allowed_item_types: ["weapon"]
# ==================== DEFENSIVE PREFIXES ====================
sturdy:
affix_id: "sturdy"
name: "Sturdy"
affix_type: "prefix"
tier: "minor"
description: "Built to withstand punishment"
defense_bonus: 3
allowed_item_types: ["armor"]
reinforced:
affix_id: "reinforced"
name: "Reinforced"
affix_type: "prefix"
tier: "major"
description: "Heavily reinforced for maximum protection"
defense_bonus: 5
resistance_bonus: 2
allowed_item_types: ["armor"]
# ==================== LEGENDARY PREFIXES ====================
infernal:
affix_id: "infernal"
name: "Infernal"
affix_type: "prefix"
tier: "legendary"
description: "Burns with hellfire"
damage_type: "fire"
elemental_ratio: 0.45
damage_bonus: 12
allowed_item_types: ["weapon"]
required_rarity: "legendary"
vorpal:
affix_id: "vorpal"
name: "Vorpal"
affix_type: "prefix"
tier: "legendary"
description: "Cuts through anything with supernatural precision"
damage_bonus: 10
crit_chance_bonus: 0.08
crit_multiplier_bonus: 0.5
allowed_item_types: ["weapon"]
required_rarity: "legendary"

View File

@@ -0,0 +1,155 @@
# Item Suffix Affixes
# Suffixes appear after the item name: "Dagger of Strength"
#
# Suffix naming convention:
# - Minor tier: "of [Stat]" (e.g., "of Strength")
# - Major tier: "of the [Animal/Element]" (e.g., "of the Bear")
# - Legendary tier: "of the [Mythical]" (e.g., "of the Titan")
suffixes:
# ==================== STAT SUFFIXES (MINOR) ====================
of_strength:
affix_id: "of_strength"
name: "of Strength"
affix_type: "suffix"
tier: "minor"
description: "Grants physical power"
stat_bonuses:
strength: 2
of_dexterity:
affix_id: "of_dexterity"
name: "of Dexterity"
affix_type: "suffix"
tier: "minor"
description: "Grants agility and precision"
stat_bonuses:
dexterity: 2
of_constitution:
affix_id: "of_constitution"
name: "of Fortitude"
affix_type: "suffix"
tier: "minor"
description: "Grants endurance"
stat_bonuses:
constitution: 2
of_intelligence:
affix_id: "of_intelligence"
name: "of Intelligence"
affix_type: "suffix"
tier: "minor"
description: "Grants magical aptitude"
stat_bonuses:
intelligence: 2
of_wisdom:
affix_id: "of_wisdom"
name: "of Wisdom"
affix_type: "suffix"
tier: "minor"
description: "Grants insight and perception"
stat_bonuses:
wisdom: 2
of_charisma:
affix_id: "of_charisma"
name: "of Charm"
affix_type: "suffix"
tier: "minor"
description: "Grants social influence"
stat_bonuses:
charisma: 2
of_luck:
affix_id: "of_luck"
name: "of Fortune"
affix_type: "suffix"
tier: "minor"
description: "Grants favor from fate"
stat_bonuses:
luck: 2
# ==================== ENHANCED STAT SUFFIXES (MAJOR) ====================
of_the_bear:
affix_id: "of_the_bear"
name: "of the Bear"
affix_type: "suffix"
tier: "major"
description: "Grants the might and endurance of a bear"
stat_bonuses:
strength: 4
constitution: 2
of_the_fox:
affix_id: "of_the_fox"
name: "of the Fox"
affix_type: "suffix"
tier: "major"
description: "Grants the cunning and agility of a fox"
stat_bonuses:
dexterity: 4
luck: 2
of_the_owl:
affix_id: "of_the_owl"
name: "of the Owl"
affix_type: "suffix"
tier: "major"
description: "Grants the wisdom and insight of an owl"
stat_bonuses:
intelligence: 3
wisdom: 3
# ==================== DEFENSIVE SUFFIXES ====================
of_protection:
affix_id: "of_protection"
name: "of Protection"
affix_type: "suffix"
tier: "minor"
description: "Offers physical protection"
defense_bonus: 3
of_warding:
affix_id: "of_warding"
name: "of Warding"
affix_type: "suffix"
tier: "major"
description: "Wards against physical and magical harm"
defense_bonus: 5
resistance_bonus: 3
# ==================== LEGENDARY SUFFIXES ====================
of_the_titan:
affix_id: "of_the_titan"
name: "of the Titan"
affix_type: "suffix"
tier: "legendary"
description: "Grants titanic strength and endurance"
stat_bonuses:
strength: 8
constitution: 4
required_rarity: "legendary"
of_the_wind:
affix_id: "of_the_wind"
name: "of the Wind"
affix_type: "suffix"
tier: "legendary"
description: "Swift as the wind itself"
stat_bonuses:
dexterity: 8
luck: 4
crit_chance_bonus: 0.05
required_rarity: "legendary"
of_invincibility:
affix_id: "of_invincibility"
name: "of Invincibility"
affix_type: "suffix"
tier: "legendary"
description: "Grants supreme protection"
defense_bonus: 10
resistance_bonus: 8
required_rarity: "legendary"

View File

@@ -0,0 +1,152 @@
# Base Armor Templates for Procedural Generation
#
# These templates define the foundation that affixes attach to.
# Example: "Leather Vest" + "Sturdy" prefix = "Sturdy Leather Vest"
#
# Armor categories:
# - Cloth: Low defense, high resistance (mages)
# - Leather: Balanced defense/resistance (rogues)
# - Chain: Medium defense, low resistance (versatile)
# - Plate: High defense, low resistance (warriors)
armor:
# ==================== CLOTH (MAGE ARMOR) ====================
cloth_robe:
template_id: "cloth_robe"
name: "Cloth Robe"
item_type: "armor"
description: "Simple cloth robes favored by spellcasters"
base_defense: 2
base_resistance: 5
base_value: 15
required_level: 1
drop_weight: 1.3
silk_robe:
template_id: "silk_robe"
name: "Silk Robe"
item_type: "armor"
description: "Fine silk robes that channel magical energy"
base_defense: 3
base_resistance: 8
base_value: 40
required_level: 3
drop_weight: 0.9
arcane_vestments:
template_id: "arcane_vestments"
name: "Arcane Vestments"
item_type: "armor"
description: "Robes woven with magical threads"
base_defense: 5
base_resistance: 12
base_value: 80
required_level: 5
drop_weight: 0.6
min_rarity: "uncommon"
# ==================== LEATHER (ROGUE ARMOR) ====================
leather_vest:
template_id: "leather_vest"
name: "Leather Vest"
item_type: "armor"
description: "Basic leather protection for agile fighters"
base_defense: 5
base_resistance: 2
base_value: 20
required_level: 1
drop_weight: 1.3
studded_leather:
template_id: "studded_leather"
name: "Studded Leather"
item_type: "armor"
description: "Leather armor reinforced with metal studs"
base_defense: 8
base_resistance: 3
base_value: 45
required_level: 3
drop_weight: 1.0
hardened_leather:
template_id: "hardened_leather"
name: "Hardened Leather"
item_type: "armor"
description: "Boiled and hardened leather for superior protection"
base_defense: 12
base_resistance: 5
base_value: 75
required_level: 5
drop_weight: 0.7
min_rarity: "uncommon"
# ==================== CHAIN (VERSATILE) ====================
chain_shirt:
template_id: "chain_shirt"
name: "Chain Shirt"
item_type: "armor"
description: "A shirt of interlocking metal rings"
base_defense: 7
base_resistance: 2
base_value: 35
required_level: 2
drop_weight: 1.0
chainmail:
template_id: "chainmail"
name: "Chainmail"
item_type: "armor"
description: "Full chainmail armor covering torso and arms"
base_defense: 10
base_resistance: 3
base_value: 50
required_level: 3
drop_weight: 1.0
heavy_chainmail:
template_id: "heavy_chainmail"
name: "Heavy Chainmail"
item_type: "armor"
description: "Thick chainmail with reinforced rings"
base_defense: 14
base_resistance: 4
base_value: 85
required_level: 5
drop_weight: 0.7
min_rarity: "uncommon"
# ==================== PLATE (WARRIOR ARMOR) ====================
scale_mail:
template_id: "scale_mail"
name: "Scale Mail"
item_type: "armor"
description: "Overlapping metal scales on leather backing"
base_defense: 12
base_resistance: 2
base_value: 60
required_level: 4
drop_weight: 0.8
half_plate:
template_id: "half_plate"
name: "Half Plate"
item_type: "armor"
description: "Plate armor protecting vital areas"
base_defense: 16
base_resistance: 2
base_value: 120
required_level: 6
drop_weight: 0.5
min_rarity: "rare"
plate_armor:
template_id: "plate_armor"
name: "Plate Armor"
item_type: "armor"
description: "Full metal plate protection"
base_defense: 22
base_resistance: 3
base_value: 200
required_level: 7
drop_weight: 0.4
min_rarity: "rare"

View File

@@ -0,0 +1,227 @@
# Base Weapon Templates for Procedural Generation
#
# These templates define the foundation that affixes attach to.
# Example: "Dagger" + "Flaming" prefix = "Flaming Dagger"
#
# Template Structure:
# template_id: Unique identifier
# name: Base item name
# item_type: "weapon"
# description: Flavor text
# base_damage: Weapon damage
# base_value: Gold value
# damage_type: "physical" (default)
# crit_chance: Critical hit chance (0.0-1.0)
# crit_multiplier: Crit damage multiplier
# required_level: Min level to use/drop
# drop_weight: Higher = more common (1.0 = standard)
# min_rarity: Minimum rarity for this template
weapons:
# ==================== ONE-HANDED SWORDS ====================
dagger:
template_id: "dagger"
name: "Dagger"
item_type: "weapon"
description: "A small, quick blade for close combat"
base_damage: 6
base_value: 15
damage_type: "physical"
crit_chance: 0.08
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.5
short_sword:
template_id: "short_sword"
name: "Short Sword"
item_type: "weapon"
description: "A versatile one-handed blade"
base_damage: 10
base_value: 30
damage_type: "physical"
crit_chance: 0.06
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.3
longsword:
template_id: "longsword"
name: "Longsword"
item_type: "weapon"
description: "A standard warrior's blade"
base_damage: 14
base_value: 50
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 3
drop_weight: 1.0
# ==================== TWO-HANDED WEAPONS ====================
greatsword:
template_id: "greatsword"
name: "Greatsword"
item_type: "weapon"
description: "A massive two-handed blade"
base_damage: 22
base_value: 100
damage_type: "physical"
crit_chance: 0.04
crit_multiplier: 2.5
required_level: 5
drop_weight: 0.7
min_rarity: "uncommon"
# ==================== AXES ====================
hatchet:
template_id: "hatchet"
name: "Hatchet"
item_type: "weapon"
description: "A small throwing axe"
base_damage: 8
base_value: 20
damage_type: "physical"
crit_chance: 0.06
crit_multiplier: 2.2
required_level: 1
drop_weight: 1.2
battle_axe:
template_id: "battle_axe"
name: "Battle Axe"
item_type: "weapon"
description: "A heavy axe designed for combat"
base_damage: 16
base_value: 60
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.3
required_level: 4
drop_weight: 0.9
# ==================== BLUNT WEAPONS ====================
club:
template_id: "club"
name: "Club"
item_type: "weapon"
description: "A simple wooden club"
base_damage: 7
base_value: 10
damage_type: "physical"
crit_chance: 0.04
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.5
mace:
template_id: "mace"
name: "Mace"
item_type: "weapon"
description: "A flanged mace for crushing armor"
base_damage: 12
base_value: 40
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 2
drop_weight: 1.0
# ==================== STAVES ====================
quarterstaff:
template_id: "quarterstaff"
name: "Quarterstaff"
item_type: "weapon"
description: "A simple wooden staff"
base_damage: 6
base_value: 10
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.2
wizard_staff:
template_id: "wizard_staff"
name: "Wizard Staff"
item_type: "weapon"
description: "A staff attuned to magical energy"
base_damage: 4
base_spell_power: 12
base_value: 45
damage_type: "arcane"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 3
drop_weight: 0.8
arcane_staff:
template_id: "arcane_staff"
name: "Arcane Staff"
item_type: "weapon"
description: "A powerful staff pulsing with arcane power"
base_damage: 6
base_spell_power: 18
base_value: 90
damage_type: "arcane"
crit_chance: 0.06
crit_multiplier: 2.0
required_level: 5
drop_weight: 0.6
min_rarity: "uncommon"
# ==================== WANDS ====================
wand:
template_id: "wand"
name: "Wand"
item_type: "weapon"
description: "A simple magical focus"
base_damage: 2
base_spell_power: 8
base_value: 30
damage_type: "arcane"
crit_chance: 0.06
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.0
crystal_wand:
template_id: "crystal_wand"
name: "Crystal Wand"
item_type: "weapon"
description: "A wand topped with a magical crystal"
base_damage: 3
base_spell_power: 14
base_value: 60
damage_type: "arcane"
crit_chance: 0.07
crit_multiplier: 2.2
required_level: 4
drop_weight: 0.8
# ==================== RANGED ====================
shortbow:
template_id: "shortbow"
name: "Shortbow"
item_type: "weapon"
description: "A compact bow for quick shots"
base_damage: 8
base_value: 25
damage_type: "physical"
crit_chance: 0.07
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.1
longbow:
template_id: "longbow"
name: "Longbow"
item_type: "weapon"
description: "A powerful bow with excellent range"
base_damage: 14
base_value: 55
damage_type: "physical"
crit_chance: 0.08
crit_multiplier: 2.2
required_level: 4
drop_weight: 0.9

View File

@@ -8,7 +8,7 @@ description: >
excel in devastating spell damage, capable of incinerating groups of foes or freezing 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. enemies in place. Choose your element: embrace the flames or command the frost.
# Base stats (total: 65) # Base stats (total: 65 + luck)
base_stats: base_stats:
strength: 8 # Low physical power strength: 8 # Low physical power
dexterity: 10 # Average agility dexterity: 10 # Average agility
@@ -16,6 +16,7 @@ base_stats:
intelligence: 15 # Exceptional magical power intelligence: 15 # Exceptional magical power
wisdom: 12 # Above average perception wisdom: 12 # Above average perception
charisma: 11 # Above average social charisma: 11 # Above average social
luck: 9 # Slight chaos magic boost
starting_equipment: starting_equipment:
- worn_staff - worn_staff

View File

@@ -8,7 +8,7 @@ description: >
capable of becoming an elusive phantom or a master of critical strikes. Choose your path: embrace capable of becoming an elusive phantom or a master of critical strikes. Choose your path: embrace
the shadows or perfect the killing blow. the shadows or perfect the killing blow.
# Base stats (total: 65) # Base stats (total: 65 + luck)
base_stats: base_stats:
strength: 11 # Above average physical power strength: 11 # Above average physical power
dexterity: 15 # Exceptional agility dexterity: 15 # Exceptional agility
@@ -16,6 +16,7 @@ base_stats:
intelligence: 9 # Below average magic intelligence: 9 # Below average magic
wisdom: 10 # Average perception wisdom: 10 # Average perception
charisma: 10 # Average social charisma: 10 # Average social
luck: 12 # High luck for crits and precision
starting_equipment: starting_equipment:
- rusty_dagger - rusty_dagger

View File

@@ -8,7 +8,7 @@ description: >
excel in supporting allies and controlling enemies through clever magic and mental manipulation. excel in supporting allies and controlling enemies through clever magic and mental manipulation.
Choose your art: weave arcane power or bend reality itself. Choose your art: weave arcane power or bend reality itself.
# Base stats (total: 67) # Base stats (total: 67 + luck)
base_stats: base_stats:
strength: 8 # Low physical power strength: 8 # Low physical power
dexterity: 11 # Above average agility dexterity: 11 # Above average agility
@@ -16,6 +16,7 @@ base_stats:
intelligence: 13 # Above average magical power intelligence: 13 # Above average magical power
wisdom: 11 # Above average perception wisdom: 11 # Above average perception
charisma: 14 # High social/performance charisma: 14 # High social/performance
luck: 10 # Knowledge is its own luck
starting_equipment: starting_equipment:
- tome - tome

View File

@@ -8,7 +8,7 @@ description: >
capable of becoming a guardian angel for their allies or a righteous crusader smiting evil. 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. Choose your calling: protect the innocent or judge the wicked.
# Base stats (total: 68) # Base stats (total: 68 + luck)
base_stats: base_stats:
strength: 9 # Below average physical power strength: 9 # Below average physical power
dexterity: 9 # Below average agility dexterity: 9 # Below average agility
@@ -16,6 +16,7 @@ base_stats:
intelligence: 12 # Above average magical power intelligence: 12 # Above average magical power
wisdom: 14 # High perception/divine power wisdom: 14 # High perception/divine power
charisma: 13 # Above average social charisma: 13 # Above average social
luck: 11 # Divine favor grants fortune
starting_equipment: starting_equipment:
- rusty_mace - rusty_mace

View File

@@ -8,7 +8,7 @@ description: >
excel in draining enemies over time or overwhelming foes with undead minions. 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. Choose your dark art: curse your enemies or raise an army of the dead.
# Base stats (total: 65) # Base stats (total: 65 + luck)
base_stats: base_stats:
strength: 8 # Low physical power strength: 8 # Low physical power
dexterity: 10 # Average agility dexterity: 10 # Average agility
@@ -16,6 +16,7 @@ base_stats:
intelligence: 14 # High magical power intelligence: 14 # High magical power
wisdom: 11 # Above average perception wisdom: 11 # Above average perception
charisma: 12 # Above average social (commands undead) charisma: 12 # Above average social (commands undead)
luck: 7 # Dark arts come with a cost
starting_equipment: starting_equipment:
- bone_wand - bone_wand

View File

@@ -8,7 +8,7 @@ description: >
capable of becoming an unyielding shield for their allies or a beacon of healing light. 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. Choose your oath: defend the weak or redeem the fallen.
# Base stats (total: 67) # Base stats (total: 67 + luck)
base_stats: base_stats:
strength: 12 # Above average physical power strength: 12 # Above average physical power
dexterity: 9 # Below average agility dexterity: 9 # Below average agility
@@ -16,6 +16,7 @@ base_stats:
intelligence: 10 # Average magic intelligence: 10 # Average magic
wisdom: 12 # Above average perception wisdom: 12 # Above average perception
charisma: 11 # Above average social charisma: 11 # Above average social
luck: 9 # Honorable, modest fortune
starting_equipment: starting_equipment:
- rusty_sword - rusty_sword

View File

@@ -8,7 +8,7 @@ description: >
capable of becoming an unbreakable shield for their allies or a relentless damage dealer. 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. Choose your path: become a stalwart defender or a devastating weapon master.
# Base stats (total: 65, average: 10.83) # Base stats (total: 65 + luck)
base_stats: base_stats:
strength: 14 # High physical power strength: 14 # High physical power
dexterity: 10 # Average agility dexterity: 10 # Average agility
@@ -16,6 +16,7 @@ base_stats:
intelligence: 8 # Low magic intelligence: 8 # Low magic
wisdom: 10 # Average perception wisdom: 10 # Average perception
charisma: 9 # Below average social charisma: 9 # Below average social
luck: 8 # Low luck, relies on strength
# Starting equipment (minimal) # Starting equipment (minimal)
starting_equipment: starting_equipment:

View File

@@ -8,7 +8,7 @@ description: >
can become elite marksmen with unmatched accuracy or beast masters commanding powerful can become elite marksmen with unmatched accuracy or beast masters commanding powerful
animal companions. Choose your path: perfect your aim or unleash the wild. animal companions. Choose your path: perfect your aim or unleash the wild.
# Base stats (total: 66) # Base stats (total: 66 + luck)
base_stats: base_stats:
strength: 10 # Average physical power strength: 10 # Average physical power
dexterity: 14 # High agility dexterity: 14 # High agility
@@ -16,6 +16,7 @@ base_stats:
intelligence: 9 # Below average magic intelligence: 9 # Below average magic
wisdom: 13 # Above average perception wisdom: 13 # Above average perception
charisma: 9 # Below average social charisma: 9 # Below average social
luck: 10 # Average luck, self-reliant
starting_equipment: starting_equipment:
- rusty_bow - rusty_bow

View File

@@ -0,0 +1,59 @@
# Bandit - Medium humanoid with weapon
# A highway robber armed with sword and dagger
enemy_id: bandit
name: Bandit Rogue
description: >
A rough-looking human in worn leather armor, their face partially hidden
by a tattered hood. They fight with a chipped sword and keep a dagger
ready for backstabs. Desperation has made them dangerous.
base_stats:
strength: 12
dexterity: 14
constitution: 10
intelligence: 10
wisdom: 8
charisma: 8
luck: 10
abilities:
- basic_attack
- quick_strike
- dirty_trick
loot_table:
- item_id: bandit_sword
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- item_id: leather_armor
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- item_id: lockpick
drop_chance: 0.25
quantity_min: 1
quantity_max: 3
- item_id: gold_coin
drop_chance: 0.80
quantity_min: 5
quantity_max: 20
experience_reward: 35
gold_reward_min: 10
gold_reward_max: 30
difficulty: medium
tags:
- humanoid
- rogue
- armed
location_tags:
- wilderness
- road
base_damage: 8
crit_chance: 0.12
flee_chance: 0.45

View File

@@ -0,0 +1,56 @@
# Dire Wolf - Medium beast enemy
# A large, ferocious predator
enemy_id: dire_wolf
name: Dire Wolf
description: >
A massive wolf the size of a horse, with matted black fur and eyes
that glow with predatory intelligence. Its fangs are as long as daggers,
and its growl rumbles like distant thunder.
base_stats:
strength: 14
dexterity: 14
constitution: 12
intelligence: 4
wisdom: 10
charisma: 6
luck: 8
abilities:
- basic_attack
- savage_bite
- pack_howl
loot_table:
- item_id: wolf_pelt
drop_chance: 0.60
quantity_min: 1
quantity_max: 1
- item_id: wolf_fang
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- item_id: beast_meat
drop_chance: 0.70
quantity_min: 1
quantity_max: 3
experience_reward: 40
gold_reward_min: 0
gold_reward_max: 5
difficulty: medium
tags:
- beast
- wolf
- large
- pack
location_tags:
- forest
- wilderness
base_damage: 10
crit_chance: 0.10
flee_chance: 0.40

View File

@@ -0,0 +1,50 @@
# Goblin - Easy melee enemy (STR-focused)
# A small, cunning creature that attacks in groups
enemy_id: goblin
name: Goblin Scout
description: >
A small, green-skinned creature with pointed ears and sharp teeth.
Goblins are cowardly alone but dangerous in groups, using crude weapons
and dirty tactics to overwhelm their prey.
base_stats:
strength: 8
dexterity: 12
constitution: 6
intelligence: 6
wisdom: 6
charisma: 4
luck: 8
abilities:
- basic_attack
loot_table:
- item_id: rusty_dagger
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.50
quantity_min: 1
quantity_max: 3
experience_reward: 15
gold_reward_min: 2
gold_reward_max: 8
difficulty: easy
tags:
- humanoid
- goblinoid
- small
location_tags:
- forest
- wilderness
- dungeon
base_damage: 4
crit_chance: 0.05
flee_chance: 0.60

View File

@@ -0,0 +1,90 @@
# Goblin Chieftain - Hard variant, elite tribe leader
# A cunning and powerful goblin leader, adorned with trophies.
# Commands respect through fear and violence, drops quality loot.
enemy_id: goblin_chieftain
name: Goblin Chieftain
description: >
A large, scarred goblin wearing a crown of teeth and bones.
The chieftain has clawed its way to leadership through countless
battles and betrayals. It wields a well-maintained weapon stolen
from a fallen adventurer and commands its tribe with an iron fist.
base_stats:
strength: 16
dexterity: 12
constitution: 14
intelligence: 10
wisdom: 10
charisma: 12
luck: 12
abilities:
- basic_attack
- shield_bash
- intimidating_shout
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: goblin_ear
drop_chance: 1.0
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: goblin_chieftain_token
drop_chance: 0.80
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: goblin_war_paint
drop_chance: 0.50
quantity_min: 1
quantity_max: 2
# Consumable drops
- loot_type: static
item_id: health_potion_medium
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_strength
drop_chance: 0.10
quantity_min: 1
quantity_max: 1
# Procedural equipment drops - higher chance and rarity bonus
- loot_type: procedural
item_type: weapon
drop_chance: 0.25
rarity_bonus: 0.10
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.15
rarity_bonus: 0.05
quantity_min: 1
quantity_max: 1
experience_reward: 75
gold_reward_min: 20
gold_reward_max: 50
difficulty: hard
tags:
- humanoid
- goblinoid
- leader
- elite
- armed
location_tags:
- forest
- wilderness
- dungeon
base_damage: 14
crit_chance: 0.15
flee_chance: 0.25

View File

@@ -0,0 +1,61 @@
# Goblin Scout - Easy variant, agile but fragile
# A fast, cowardly goblin that serves as a lookout for its tribe.
# Quick to flee, drops minor loot and the occasional small potion.
enemy_id: goblin_scout
name: Goblin Scout
description: >
A small, wiry goblin with oversized ears and beady yellow eyes.
Goblin scouts are the first line of awareness for their tribes,
often found lurking in shadows or perched in trees. They prefer
to run rather than fight, but will attack if cornered.
base_stats:
strength: 6
dexterity: 14
constitution: 5
intelligence: 6
wisdom: 10
charisma: 4
luck: 10
abilities:
- basic_attack
loot_table:
# Static drops - materials and consumables
- loot_type: static
item_id: goblin_ear
drop_chance: 0.60
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: goblin_trinket
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: health_potion_small
drop_chance: 0.08
quantity_min: 1
quantity_max: 1
experience_reward: 10
gold_reward_min: 1
gold_reward_max: 4
difficulty: easy
tags:
- humanoid
- goblinoid
- small
- scout
location_tags:
- forest
- wilderness
- dungeon
base_damage: 3
crit_chance: 0.08
flee_chance: 0.70

View File

@@ -0,0 +1,57 @@
# Goblin Shaman - Easy caster enemy (INT-focused)
# A goblin spellcaster that provides magical support
enemy_id: goblin_shaman
name: Goblin Shaman
description: >
A hunched goblin wrapped in tattered robes, clutching a staff adorned
with bones and feathers. It mutters dark incantations and hurls bolts
of sickly green fire at its enemies.
base_stats:
strength: 4
dexterity: 10
constitution: 6
intelligence: 12
wisdom: 10
charisma: 6
luck: 10
abilities:
- basic_attack
- fire_bolt
- minor_heal
loot_table:
- item_id: shaman_staff
drop_chance: 0.10
quantity_min: 1
quantity_max: 1
- item_id: mana_potion_small
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.60
quantity_min: 3
quantity_max: 8
experience_reward: 25
gold_reward_min: 5
gold_reward_max: 15
difficulty: easy
tags:
- humanoid
- goblinoid
- caster
- small
location_tags:
- forest
- wilderness
- dungeon
base_damage: 3
crit_chance: 0.08
flee_chance: 0.55

View File

@@ -0,0 +1,75 @@
# Goblin Warrior - Medium variant, trained fighter
# A battle-hardened goblin wielding crude but effective weapons.
# More dangerous than scouts, fights in organized groups.
enemy_id: goblin_warrior
name: Goblin Warrior
description: >
A muscular goblin clad in scavenged armor and wielding a crude
but deadly weapon. Goblin warriors are the backbone of any goblin
warband, trained to fight rather than flee. They attack with
surprising ferocity and coordination.
base_stats:
strength: 12
dexterity: 10
constitution: 10
intelligence: 6
wisdom: 6
charisma: 4
luck: 8
abilities:
- basic_attack
- shield_bash
loot_table:
# Static drops - materials and consumables
- loot_type: static
item_id: goblin_ear
drop_chance: 0.80
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: goblin_war_paint
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: health_potion_small
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: iron_ore
drop_chance: 0.10
quantity_min: 1
quantity_max: 2
# Procedural equipment drops
- loot_type: procedural
item_type: weapon
drop_chance: 0.08
rarity_bonus: 0.0
quantity_min: 1
quantity_max: 1
experience_reward: 25
gold_reward_min: 5
gold_reward_max: 15
difficulty: medium
tags:
- humanoid
- goblinoid
- warrior
- armed
location_tags:
- forest
- wilderness
- dungeon
base_damage: 8
crit_chance: 0.10
flee_chance: 0.45

View File

@@ -0,0 +1,58 @@
# Harpy - Easy flying humanoid
# A vicious bird-woman that attacks from above
enemy_id: harpy
name: Harpy
description: >
A creature with the body of a vulture and the head and torso
of a woman, though twisted by cruelty into something inhuman.
Matted feathers cover her body, and her hands end in razor-sharp
talons. She shrieks with maddening fury as she dives at her prey.
base_stats:
strength: 8
dexterity: 14
constitution: 8
intelligence: 6
wisdom: 10
charisma: 10
luck: 8
abilities:
- basic_attack
- talon_slash
- dive_attack
loot_table:
- item_id: harpy_feather
drop_chance: 0.70
quantity_min: 2
quantity_max: 5
- item_id: harpy_talon
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
- item_id: gold_coin
drop_chance: 0.40
quantity_min: 2
quantity_max: 10
experience_reward: 22
gold_reward_min: 3
gold_reward_max: 12
difficulty: easy
tags:
- monstrosity
- harpy
- flying
- female
location_tags:
- mountain
- cliff
- ruins
base_damage: 6
crit_chance: 0.10
flee_chance: 0.55

View File

@@ -0,0 +1,119 @@
# Harpy Matriarch - Hard elite leader
# The ancient ruler of a harpy flock
enemy_id: harpy_matriarch
name: Harpy Matriarch
description: >
An ancient harpy of terrible beauty and cruelty, her plumage
a striking mix of midnight black and blood red. She towers
over her lesser kin, her voice carrying both enchanting allure
and devastating power. The Matriarch rules her flock absolutely,
and her nest is decorated with the bones and treasures of
countless victims.
base_stats:
strength: 12
dexterity: 16
constitution: 14
intelligence: 12
wisdom: 14
charisma: 20
luck: 12
abilities:
- basic_attack
- talon_slash
- dive_attack
- stunning_screech
- luring_song
- sonic_blast
- call_flock
- wing_buffet
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: harpy_feather
drop_chance: 1.0
quantity_min: 5
quantity_max: 10
- loot_type: static
item_id: harpy_talon
drop_chance: 1.0
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: matriarch_plume
drop_chance: 0.80
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: screamer_vocal_cords
drop_chance: 0.60
quantity_min: 1
quantity_max: 1
# Nest treasures
- loot_type: static
item_id: gold_coin
drop_chance: 1.0
quantity_min: 30
quantity_max: 80
- loot_type: static
item_id: gemstone
drop_chance: 0.50
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: silver_ring
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
# Consumables
- loot_type: static
item_id: health_potion_medium
drop_chance: 0.35
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_grace
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
# Procedural equipment
- loot_type: procedural
item_type: accessory
drop_chance: 0.25
rarity_bonus: 0.15
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.20
rarity_bonus: 0.10
quantity_min: 1
quantity_max: 1
experience_reward: 90
gold_reward_min: 40
gold_reward_max: 100
difficulty: hard
tags:
- monstrosity
- harpy
- flying
- leader
- elite
- sonic
location_tags:
- mountain
- cliff
- ruins
base_damage: 12
crit_chance: 0.15
flee_chance: 0.20

View File

@@ -0,0 +1,64 @@
# Harpy Scout - Easy fast variant
# A swift harpy that spots prey from great distances
enemy_id: harpy_scout
name: Harpy Scout
description: >
A sleek harpy with dark plumage that blends with the sky.
Scouts are the eyes of their flock, ranging far ahead to
spot potential prey. They are faster and more agile than
common harpies, preferring quick strikes and retreat over
prolonged combat.
base_stats:
strength: 6
dexterity: 18
constitution: 6
intelligence: 8
wisdom: 12
charisma: 8
luck: 10
abilities:
- basic_attack
- talon_slash
- dive_attack
- evasive_flight
loot_table:
- loot_type: static
item_id: harpy_feather
drop_chance: 0.80
quantity_min: 3
quantity_max: 6
- loot_type: static
item_id: swift_wing_feather
drop_chance: 0.30
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: gold_coin
drop_chance: 0.45
quantity_min: 3
quantity_max: 12
experience_reward: 25
gold_reward_min: 5
gold_reward_max: 15
difficulty: easy
tags:
- monstrosity
- harpy
- flying
- scout
- fast
location_tags:
- mountain
- cliff
- wilderness
base_damage: 5
crit_chance: 0.15
flee_chance: 0.65

View File

@@ -0,0 +1,76 @@
# Harpy Screamer - Medium sonic attacker
# A harpy whose voice is a weapon
enemy_id: harpy_screamer
name: Harpy Screamer
description: >
A harpy with an unnaturally large throat that bulges when
she takes breath. Her voice is her deadliest weapon, capable
of shattering stone and stunning prey into helplessness.
The screamer's song lures victims close before unleashing
a devastating sonic assault.
base_stats:
strength: 6
dexterity: 12
constitution: 10
intelligence: 8
wisdom: 12
charisma: 16
luck: 10
abilities:
- basic_attack
- talon_slash
- stunning_screech
- luring_song
- sonic_blast
loot_table:
- loot_type: static
item_id: harpy_feather
drop_chance: 0.70
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: harpy_talon
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: screamer_vocal_cords
drop_chance: 0.35
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: gold_coin
drop_chance: 0.55
quantity_min: 5
quantity_max: 18
- loot_type: static
item_id: earwax_plug
drop_chance: 0.20
quantity_min: 1
quantity_max: 2
experience_reward: 40
gold_reward_min: 10
gold_reward_max: 25
difficulty: medium
tags:
- monstrosity
- harpy
- flying
- sonic
- dangerous
location_tags:
- mountain
- cliff
- ruins
- coast
base_damage: 5
crit_chance: 0.12
flee_chance: 0.45

View File

@@ -0,0 +1,64 @@
# Imp - Easy minor demon
# A small, mischievous fiend from the lower planes
enemy_id: imp
name: Imp
description: >
A tiny red-skinned devil no larger than a cat, with leathery
bat wings, a barbed tail, and small curved horns. Its beady
yellow eyes gleam with malicious intelligence, and it cackles
as it hurls tiny bolts of hellfire at its victims.
base_stats:
strength: 4
dexterity: 16
constitution: 6
intelligence: 10
wisdom: 10
charisma: 8
luck: 12
abilities:
- basic_attack
- fire_bolt
- invisibility
loot_table:
- item_id: imp_horn
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- item_id: hellfire_ember
drop_chance: 0.30
quantity_min: 1
quantity_max: 1
- item_id: demon_blood
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.35
quantity_min: 2
quantity_max: 8
experience_reward: 18
gold_reward_min: 3
gold_reward_max: 10
difficulty: easy
tags:
- fiend
- demon
- imp
- small
- flying
location_tags:
- dungeon
- ruins
- hellscape
- volcano
base_damage: 4
crit_chance: 0.10
flee_chance: 0.60

View File

@@ -0,0 +1,76 @@
# Fiery Imp - Medium fire-focused variant
# An imp that has absorbed excess hellfire
enemy_id: imp_fiery
name: Fiery Imp
description: >
An imp wreathed in flickering flames, its body glowing like
hot coals. Flames dance along its wings and trail from its
barbed tail. It has absorbed so much hellfire that its very
touch ignites whatever it touches, and it can unleash
devastating bursts of flame.
base_stats:
strength: 6
dexterity: 14
constitution: 10
intelligence: 12
wisdom: 10
charisma: 10
luck: 12
abilities:
- basic_attack
- fire_bolt
- flame_burst
- fire_shield
- immolate
loot_table:
- loot_type: static
item_id: imp_horn
drop_chance: 0.50
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: hellfire_ember
drop_chance: 0.60
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: demon_blood
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: fire_essence
drop_chance: 0.30
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: gold_coin
drop_chance: 0.45
quantity_min: 5
quantity_max: 15
experience_reward: 35
gold_reward_min: 8
gold_reward_max: 20
difficulty: medium
tags:
- fiend
- demon
- imp
- small
- flying
- fire
location_tags:
- dungeon
- hellscape
- volcano
base_damage: 6
crit_chance: 0.12
flee_chance: 0.50

View File

@@ -0,0 +1,116 @@
# Imp Overlord - Hard elite variant
# A greater imp that commands lesser fiends
enemy_id: imp_overlord
name: Imp Overlord
description: >
A massive imp standing three feet tall, its body crackling
with infernal power. Unlike its lesser kin, the Overlord
has earned power through cunning deals and brutal dominance.
It wears a tiny crown of blackened iron and commands squads
of lesser imps to do its bidding, all while hurling deadly
magic at its enemies.
base_stats:
strength: 10
dexterity: 16
constitution: 14
intelligence: 18
wisdom: 14
charisma: 16
luck: 14
abilities:
- basic_attack
- fire_bolt
- flame_burst
- invisibility
- summon_imps
- hellfire_blast
- dark_pact
- magic_shield
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: imp_horn
drop_chance: 1.0
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: hellfire_ember
drop_chance: 1.0
quantity_min: 3
quantity_max: 5
- loot_type: static
item_id: demon_blood
drop_chance: 0.80
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: overlord_crown
drop_chance: 0.60
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: infernal_contract
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
# Consumables
- loot_type: static
item_id: mana_potion_medium
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_intellect
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
# Treasure
- loot_type: static
item_id: gold_coin
drop_chance: 1.0
quantity_min: 25
quantity_max: 60
# Procedural equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.20
rarity_bonus: 0.15
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: accessory
drop_chance: 0.25
rarity_bonus: 0.20
quantity_min: 1
quantity_max: 1
experience_reward: 85
gold_reward_min: 35
gold_reward_max: 80
difficulty: hard
tags:
- fiend
- demon
- imp
- leader
- elite
- flying
- caster
location_tags:
- dungeon
- ruins
- hellscape
- volcano
base_damage: 10
crit_chance: 0.15
flee_chance: 0.25

View File

@@ -0,0 +1,77 @@
# Imp Trickster - Medium cunning variant
# A devious imp that excels in deception and mischief
enemy_id: imp_trickster
name: Imp Trickster
description: >
A clever imp with a knowing smirk and eyes that gleam with
cunning. It prefers tricks and illusions to direct combat,
creating phantom doubles, stealing equipment, and leading
adventurers into traps. It chatters constantly, mocking its
victims in a high-pitched voice.
base_stats:
strength: 4
dexterity: 18
constitution: 6
intelligence: 16
wisdom: 12
charisma: 14
luck: 16
abilities:
- basic_attack
- fire_bolt
- invisibility
- mirror_image
- steal_item
- confusion
loot_table:
- loot_type: static
item_id: imp_horn
drop_chance: 0.50
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: hellfire_ember
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: trickster_charm
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: arcane_dust
drop_chance: 0.30
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: gold_coin
drop_chance: 0.70
quantity_min: 10
quantity_max: 30
experience_reward: 40
gold_reward_min: 15
gold_reward_max: 35
difficulty: medium
tags:
- fiend
- demon
- imp
- small
- flying
- trickster
location_tags:
- dungeon
- ruins
- hellscape
base_damage: 4
crit_chance: 0.15
flee_chance: 0.65

View File

@@ -0,0 +1,58 @@
# Kobold - Easy small reptilian humanoid
# A cunning, trap-loving creature that serves dragons
enemy_id: kobold
name: Kobold
description: >
A small, dog-like reptilian creature with rusty orange scales
and a long snout filled with sharp teeth. Kobolds are cowardly
individually but cunning in groups, preferring traps and ambushes
to fair fights. They worship dragons with fanatical devotion.
base_stats:
strength: 6
dexterity: 14
constitution: 6
intelligence: 8
wisdom: 8
charisma: 4
luck: 10
abilities:
- basic_attack
- pack_tactics
loot_table:
- item_id: kobold_scale
drop_chance: 0.50
quantity_min: 1
quantity_max: 3
- item_id: crude_trap_parts
drop_chance: 0.30
quantity_min: 1
quantity_max: 2
- item_id: gold_coin
drop_chance: 0.40
quantity_min: 1
quantity_max: 5
experience_reward: 12
gold_reward_min: 2
gold_reward_max: 8
difficulty: easy
tags:
- humanoid
- kobold
- reptilian
- small
- pack
location_tags:
- dungeon
- cave
- mine
base_damage: 4
crit_chance: 0.06
flee_chance: 0.65

View File

@@ -0,0 +1,76 @@
# Kobold Sorcerer - Medium caster variant
# A kobold with innate draconic magic
enemy_id: kobold_sorcerer
name: Kobold Sorcerer
description: >
A kobold with scales that shimmer with an inner light, marking
it as one blessed by dragon blood. Small horns sprout from its
head, and its eyes glow with arcane power. Sorcerers are rare
among kobolds and treated with reverence, channeling the magic
of their dragon masters.
base_stats:
strength: 4
dexterity: 12
constitution: 8
intelligence: 14
wisdom: 12
charisma: 12
luck: 12
abilities:
- basic_attack
- fire_bolt
- dragon_breath
- magic_shield
- minor_heal
loot_table:
- loot_type: static
item_id: kobold_scale
drop_chance: 0.60
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: dragon_scale_fragment
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: mana_potion_small
drop_chance: 0.30
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: arcane_dust
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: gold_coin
drop_chance: 0.60
quantity_min: 5
quantity_max: 15
experience_reward: 35
gold_reward_min: 10
gold_reward_max: 25
difficulty: medium
tags:
- humanoid
- kobold
- reptilian
- small
- caster
- draconic
location_tags:
- dungeon
- cave
- mine
base_damage: 4
crit_chance: 0.10
flee_chance: 0.50

View File

@@ -0,0 +1,91 @@
# Kobold Taskmaster - Hard leader variant
# A brutal kobold leader that drives its minions to fight
enemy_id: kobold_taskmaster
name: Kobold Taskmaster
description: >
A larger-than-average kobold with scarred scales and a cruel
gleam in its eyes. It carries a whip in one hand and a curved
blade in the other, driving lesser kobolds before it with
threats and violence. Taskmasters answer only to the dragon
their warren serves, and rule their kin through fear.
base_stats:
strength: 12
dexterity: 14
constitution: 12
intelligence: 12
wisdom: 10
charisma: 14
luck: 10
abilities:
- basic_attack
- whip_crack
- rally_minions
- sneak_attack
- intimidating_shout
loot_table:
# Static drops
- loot_type: static
item_id: kobold_scale
drop_chance: 1.0
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: taskmaster_whip
drop_chance: 0.50
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: dragon_scale_fragment
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: kobold_chief_token
drop_chance: 0.70
quantity_min: 1
quantity_max: 1
# Consumables
- loot_type: static
item_id: health_potion_small
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: alchemist_fire
drop_chance: 0.25
quantity_min: 1
quantity_max: 2
# Procedural equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.20
rarity_bonus: 0.10
quantity_min: 1
quantity_max: 1
experience_reward: 60
gold_reward_min: 20
gold_reward_max: 45
difficulty: hard
tags:
- humanoid
- kobold
- reptilian
- leader
- elite
location_tags:
- dungeon
- cave
- mine
base_damage: 10
crit_chance: 0.12
flee_chance: 0.30

View File

@@ -0,0 +1,73 @@
# Kobold Trapper - Easy specialist variant
# A kobold expert in traps and ambush tactics
enemy_id: kobold_trapper
name: Kobold Trapper
description: >
A kobold with a bandolier of alchemical vials and pouches full
of trap components. Its scales are stained with various chemicals,
and it moves with practiced stealth. Trappers are valued members
of any kobold warren, turning simple tunnels into death mazes.
base_stats:
strength: 6
dexterity: 16
constitution: 6
intelligence: 12
wisdom: 10
charisma: 4
luck: 12
abilities:
- basic_attack
- lay_trap
- alchemist_fire
- sneak_attack
loot_table:
- loot_type: static
item_id: kobold_scale
drop_chance: 0.50
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: trap_kit
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: alchemist_fire
drop_chance: 0.30
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: caltrops
drop_chance: 0.35
quantity_min: 1
quantity_max: 3
- loot_type: static
item_id: gold_coin
drop_chance: 0.50
quantity_min: 2
quantity_max: 8
experience_reward: 20
gold_reward_min: 5
gold_reward_max: 15
difficulty: easy
tags:
- humanoid
- kobold
- reptilian
- small
- trapper
location_tags:
- dungeon
- cave
- mine
base_damage: 5
crit_chance: 0.12
flee_chance: 0.60

View File

@@ -0,0 +1,64 @@
# Lizardfolk - Easy reptilian humanoid
# A cold-blooded tribal warrior from the swamps
enemy_id: lizardfolk
name: Lizardfolk
description: >
A muscular humanoid covered in green and brown scales, with
a long tail and a snout filled with sharp teeth. Lizardfolk
are pragmatic hunters who view all creatures as either
predator, prey, or irrelevant. This one carries a bone-tipped
spear and a shield made from a giant turtle shell.
base_stats:
strength: 12
dexterity: 10
constitution: 12
intelligence: 6
wisdom: 10
charisma: 4
luck: 6
abilities:
- basic_attack
- bite
- tail_swipe
loot_table:
- item_id: lizard_scale
drop_chance: 0.65
quantity_min: 2
quantity_max: 5
- item_id: lizardfolk_spear
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
- item_id: beast_meat
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- item_id: gold_coin
drop_chance: 0.30
quantity_min: 2
quantity_max: 8
experience_reward: 25
gold_reward_min: 3
gold_reward_max: 12
difficulty: easy
tags:
- humanoid
- lizardfolk
- reptilian
- tribal
location_tags:
- swamp
- river
- coast
- jungle
base_damage: 7
crit_chance: 0.08
flee_chance: 0.40

View File

@@ -0,0 +1,109 @@
# Lizardfolk Champion - Hard elite warrior
# The mightiest warrior of a lizardfolk tribe
enemy_id: lizardfolk_champion
name: Lizardfolk Champion
description: >
A massive lizardfolk standing over seven feet tall, its body
rippling with corded muscle beneath scarred, battle-hardened
scales. The Champion has proven itself through countless hunts
and trials, earning the right to wield the tribe's sacred
weapons. Its cold, calculating eyes assess every opponent
as prey to be efficiently slaughtered.
base_stats:
strength: 18
dexterity: 12
constitution: 16
intelligence: 8
wisdom: 12
charisma: 10
luck: 8
abilities:
- basic_attack
- bite
- tail_swipe
- cleave
- shield_bash
- frenzy
- primal_roar
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: lizard_scale
drop_chance: 1.0
quantity_min: 5
quantity_max: 10
- loot_type: static
item_id: champion_fang
drop_chance: 0.80
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: sacred_lizardfolk_weapon
drop_chance: 0.50
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: tribal_champion_token
drop_chance: 0.70
quantity_min: 1
quantity_max: 1
# Consumables
- loot_type: static
item_id: health_potion_medium
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_strength
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
# Treasure
- loot_type: static
item_id: gold_coin
drop_chance: 0.80
quantity_min: 20
quantity_max: 55
# Procedural equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.25
rarity_bonus: 0.15
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.20
rarity_bonus: 0.10
quantity_min: 1
quantity_max: 1
experience_reward: 80
gold_reward_min: 30
gold_reward_max: 70
difficulty: hard
tags:
- humanoid
- lizardfolk
- reptilian
- warrior
- leader
- elite
location_tags:
- swamp
- river
- jungle
- dungeon
base_damage: 14
crit_chance: 0.12
flee_chance: 0.15

View File

@@ -0,0 +1,75 @@
# Lizardfolk Hunter - Easy ranged variant
# A skilled tracker and ambush predator
enemy_id: lizardfolk_hunter
name: Lizardfolk Hunter
description: >
A lean lizardfolk with mottled scales that blend perfectly
with swamp vegetation. Hunters are the elite scouts of their
tribes, tracking prey through marshes and striking from hiding
with poisoned javelins. Their patient, predatory nature makes
them terrifyingly effective ambushers.
base_stats:
strength: 10
dexterity: 14
constitution: 10
intelligence: 8
wisdom: 14
charisma: 4
luck: 8
abilities:
- basic_attack
- javelin_throw
- poison_dart
- camouflage
- sneak_attack
loot_table:
- loot_type: static
item_id: lizard_scale
drop_chance: 0.60
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: poison_dart
drop_chance: 0.50
quantity_min: 2
quantity_max: 5
- loot_type: static
item_id: hunter_javelin
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: swamp_poison
drop_chance: 0.30
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: gold_coin
drop_chance: 0.40
quantity_min: 3
quantity_max: 12
experience_reward: 30
gold_reward_min: 5
gold_reward_max: 18
difficulty: easy
tags:
- humanoid
- lizardfolk
- reptilian
- hunter
- ranged
location_tags:
- swamp
- river
- jungle
base_damage: 6
crit_chance: 0.12
flee_chance: 0.45

View File

@@ -0,0 +1,89 @@
# Lizardfolk Shaman - Medium caster variant
# A spiritual leader who communes with primal spirits
enemy_id: lizardfolk_shaman
name: Lizardfolk Shaman
description: >
An elderly lizardfolk adorned with feathers, bones, and
mystical totems. The shaman communes with the spirits of
the swamp, calling upon ancestral power to curse enemies
and bless allies. Its scales have faded to pale green with
age, but its eyes burn with primal wisdom and power.
base_stats:
strength: 8
dexterity: 10
constitution: 12
intelligence: 10
wisdom: 16
charisma: 12
luck: 10
abilities:
- basic_attack
- spirit_bolt
- entangling_vines
- healing_waters
- curse_of_weakness
- summon_swamp_creature
loot_table:
- loot_type: static
item_id: lizard_scale
drop_chance: 0.50
quantity_min: 1
quantity_max: 3
- loot_type: static
item_id: shaman_totem
drop_chance: 0.45
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: spirit_essence
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: swamp_herb
drop_chance: 0.50
quantity_min: 2
quantity_max: 4
# Consumables
- loot_type: static
item_id: mana_potion_small
drop_chance: 0.30
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: health_potion_small
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: gold_coin
drop_chance: 0.50
quantity_min: 8
quantity_max: 25
experience_reward: 45
gold_reward_min: 12
gold_reward_max: 35
difficulty: medium
tags:
- humanoid
- lizardfolk
- reptilian
- shaman
- caster
location_tags:
- swamp
- river
- jungle
base_damage: 5
crit_chance: 0.10
flee_chance: 0.35

View File

@@ -0,0 +1,64 @@
# Ogre - Medium brutish giant
# A dim-witted but dangerous giant humanoid
enemy_id: ogre
name: Ogre
description: >
A hulking brute standing ten feet tall with grayish-green skin,
a protruding gut, and a slack-jawed face of dull malevolence.
Ogres are simple creatures driven by hunger and greed, but their
immense strength makes them deadly opponents. This one carries
a massive club made from a tree trunk.
base_stats:
strength: 18
dexterity: 6
constitution: 16
intelligence: 4
wisdom: 6
charisma: 4
luck: 6
abilities:
- basic_attack
- crushing_blow
- grab
loot_table:
- item_id: ogre_hide
drop_chance: 0.60
quantity_min: 1
quantity_max: 2
- item_id: ogre_tooth
drop_chance: 0.40
quantity_min: 1
quantity_max: 3
- item_id: beast_meat
drop_chance: 0.50
quantity_min: 2
quantity_max: 4
- item_id: gold_coin
drop_chance: 0.60
quantity_min: 10
quantity_max: 30
experience_reward: 55
gold_reward_min: 15
gold_reward_max: 40
difficulty: medium
tags:
- giant
- ogre
- large
- brute
location_tags:
- wilderness
- cave
- swamp
- dungeon
base_damage: 14
crit_chance: 0.08
flee_chance: 0.20

View File

@@ -0,0 +1,85 @@
# Ogre Brute - Medium armored variant
# An ogre equipped with scavenged armor and weapons
enemy_id: ogre_brute
name: Ogre Brute
description: >
A massive ogre wearing a patchwork of stolen armor plates
and wielding a crude greataxe forged by enslaved smiths.
Smarter and more disciplined than common ogres, brutes
serve as shock troops for warlords or guard valuable
territory. Its armor bears the dents and bloodstains
of many battles.
base_stats:
strength: 20
dexterity: 6
constitution: 18
intelligence: 6
wisdom: 6
charisma: 4
luck: 6
abilities:
- basic_attack
- crushing_blow
- cleave
- intimidating_shout
- grab
loot_table:
- loot_type: static
item_id: ogre_hide
drop_chance: 0.70
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: ogre_tooth
drop_chance: 0.50
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: iron_ore
drop_chance: 0.40
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: gold_coin
drop_chance: 0.70
quantity_min: 20
quantity_max: 50
# Procedural equipment - scavenged gear
- loot_type: procedural
item_type: weapon
drop_chance: 0.15
rarity_bonus: 0.0
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.12
rarity_bonus: 0.0
quantity_min: 1
quantity_max: 1
experience_reward: 70
gold_reward_min: 25
gold_reward_max: 55
difficulty: medium
tags:
- giant
- ogre
- large
- brute
- armed
location_tags:
- wilderness
- cave
- dungeon
base_damage: 16
crit_chance: 0.10
flee_chance: 0.15

View File

@@ -0,0 +1,118 @@
# Ogre Chieftain - Hard elite leader
# The massive ruler of an ogre tribe
enemy_id: ogre_chieftain
name: Ogre Chieftain
description: >
A mountain of muscle and fury standing nearly fifteen feet
tall, this ancient ogre has crushed all challengers to claim
leadership of its tribe. Covered in ritual scars and trophy
piercings, the Chieftain wears a necklace of skulls from
its greatest kills. It wields a massive maul that has ended
the lives of countless heroes.
base_stats:
strength: 22
dexterity: 8
constitution: 22
intelligence: 8
wisdom: 10
charisma: 12
luck: 8
abilities:
- basic_attack
- crushing_blow
- cleave
- ground_slam
- intimidating_shout
- berserker_rage
- grab
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: ogre_hide
drop_chance: 1.0
quantity_min: 3
quantity_max: 5
- loot_type: static
item_id: ogre_tooth
drop_chance: 1.0
quantity_min: 3
quantity_max: 6
- loot_type: static
item_id: chieftain_skull_necklace
drop_chance: 0.80
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: ogre_warlord_token
drop_chance: 0.70
quantity_min: 1
quantity_max: 1
# Consumables
- loot_type: static
item_id: health_potion_large
drop_chance: 0.45
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_strength
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_fortitude
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
# Treasure
- loot_type: static
item_id: gold_coin
drop_chance: 1.0
quantity_min: 50
quantity_max: 120
- loot_type: static
item_id: gemstone
drop_chance: 0.50
quantity_min: 1
quantity_max: 3
# Procedural equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.30
rarity_bonus: 0.20
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.25
rarity_bonus: 0.15
quantity_min: 1
quantity_max: 1
experience_reward: 140
gold_reward_min: 60
gold_reward_max: 150
difficulty: hard
tags:
- giant
- ogre
- large
- leader
- elite
- brute
location_tags:
- wilderness
- cave
- dungeon
base_damage: 20
crit_chance: 0.12
flee_chance: 0.08

View File

@@ -0,0 +1,107 @@
# Ogre Magi - Hard caster variant
# A rare ogre with innate magical abilities
enemy_id: ogre_magi
name: Ogre Magi
description: >
A towering ogre with blue-tinged skin and eyes that glow
with arcane power. Unlike its brutish kin, the magi possesses
cunning intelligence and dangerous magical abilities. It wears
robes of stolen finery and carries a staff topped with a
glowing crystal. Ogre magi are natural leaders, using their
magic and intelligence to dominate lesser giants.
base_stats:
strength: 16
dexterity: 8
constitution: 16
intelligence: 14
wisdom: 12
charisma: 12
luck: 10
abilities:
- basic_attack
- crushing_blow
- frost_bolt
- darkness
- invisibility
- cone_of_cold
- fly
- regeneration
loot_table:
- loot_type: static
item_id: ogre_hide
drop_chance: 0.60
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: magi_crystal
drop_chance: 0.50
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: arcane_dust
drop_chance: 0.60
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: frost_essence
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
# Consumables
- loot_type: static
item_id: mana_potion_medium
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: health_potion_medium
drop_chance: 0.35
quantity_min: 1
quantity_max: 1
# Treasure
- loot_type: static
item_id: gold_coin
drop_chance: 0.80
quantity_min: 30
quantity_max: 70
- loot_type: static
item_id: gemstone
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
# Procedural equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.20
rarity_bonus: 0.15
quantity_min: 1
quantity_max: 1
experience_reward: 95
gold_reward_min: 40
gold_reward_max: 90
difficulty: hard
tags:
- giant
- ogre
- large
- caster
- intelligent
location_tags:
- wilderness
- cave
- dungeon
- ruins
base_damage: 12
crit_chance: 0.12
flee_chance: 0.25

View File

@@ -0,0 +1,62 @@
# Orc Berserker - Hard heavy hitter
# A fearsome orc warrior in a battle rage
enemy_id: orc_berserker
name: Orc Berserker
description: >
A towering mass of green muscle and fury, covered in tribal war paint
and scars from countless battles. Foam flecks at the corners of its
mouth as it swings a massive greataxe with terrifying speed. In its
battle rage, it feels no pain and shows no mercy.
base_stats:
strength: 18
dexterity: 10
constitution: 16
intelligence: 6
wisdom: 6
charisma: 4
luck: 8
abilities:
- basic_attack
- cleave
- berserker_rage
- intimidating_shout
loot_table:
- item_id: orc_greataxe
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- item_id: orc_war_paint
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
- item_id: beast_hide_armor
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.70
quantity_min: 15
quantity_max: 40
experience_reward: 80
gold_reward_min: 20
gold_reward_max: 50
difficulty: hard
tags:
- humanoid
- orc
- berserker
- large
location_tags:
- dungeon
- wilderness
base_damage: 15
crit_chance: 0.15
flee_chance: 0.30

View File

@@ -0,0 +1,50 @@
# Giant Rat - Very easy enemy for starting areas (town, village, tavern)
# A basic enemy for new players to learn combat mechanics
enemy_id: rat
name: Giant Rat
description: >
A mangy rat the size of a small dog. These vermin infest cellars,
sewers, and dark corners of civilization. Weak alone but annoying in packs.
base_stats:
strength: 4
dexterity: 14
constitution: 4
intelligence: 2
wisdom: 8
charisma: 2
luck: 6
abilities:
- basic_attack
loot_table:
- item_id: rat_tail
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
- item_id: gold_coin
drop_chance: 0.20
quantity_min: 1
quantity_max: 2
experience_reward: 5
gold_reward_min: 0
gold_reward_max: 2
difficulty: easy
tags:
- beast
- vermin
- small
location_tags:
- town
- village
- tavern
- dungeon
base_damage: 2
crit_chance: 0.03
flee_chance: 0.80

View File

@@ -0,0 +1,57 @@
# Skeleton Warrior - Medium undead melee
# An animated skeleton wielding ancient weapons
enemy_id: skeleton_warrior
name: Skeleton Warrior
description: >
The animated remains of a long-dead soldier, held together by dark magic.
Its empty eye sockets glow with pale blue fire, and it wields a rusted
but deadly sword with unnatural precision. It knows no fear and feels no pain.
base_stats:
strength: 12
dexterity: 10
constitution: 10
intelligence: 4
wisdom: 6
charisma: 2
luck: 6
abilities:
- basic_attack
- shield_bash
- bone_rattle
loot_table:
- item_id: ancient_sword
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
- item_id: bone_fragment
drop_chance: 0.80
quantity_min: 2
quantity_max: 5
- item_id: soul_essence
drop_chance: 0.10
quantity_min: 1
quantity_max: 1
experience_reward: 45
gold_reward_min: 0
gold_reward_max: 10
difficulty: medium
tags:
- undead
- skeleton
- armed
- fearless
location_tags:
- crypt
- ruins
- dungeon
base_damage: 9
crit_chance: 0.08
flee_chance: 0.50

View File

@@ -0,0 +1,55 @@
# Slime - Easy ooze creature
# A simple gelatinous creature that dissolves organic matter
enemy_id: slime
name: Green Slime
description: >
A quivering mass of translucent green ooze about the size of
a large dog. It moves by flowing across surfaces, extending
pseudopods to sense its surroundings. Its acidic body dissolves
organic matter on contact, making it a dangerous opponent
despite its mindless nature.
base_stats:
strength: 8
dexterity: 4
constitution: 14
intelligence: 1
wisdom: 4
charisma: 1
luck: 4
abilities:
- basic_attack
- acid_touch
- split
loot_table:
- item_id: slime_residue
drop_chance: 0.70
quantity_min: 1
quantity_max: 3
- item_id: acid_gland
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
experience_reward: 15
gold_reward_min: 0
gold_reward_max: 3
difficulty: easy
tags:
- ooze
- slime
- acidic
- mindless
location_tags:
- dungeon
- cave
- sewer
base_damage: 5
crit_chance: 0.02
flee_chance: 0.10

View File

@@ -0,0 +1,84 @@
# Giant Slime - Medium large variant
# A massive slime that has consumed many victims
enemy_id: slime_giant
name: Giant Slime
description: >
A massive blob of semi-transparent ooze the size of a wagon,
filled with the partially dissolved remains of its many victims.
Bones, armor, and the occasional glint of treasure can be seen
slowly dissolving within its acidic mass. It can engulf entire
creatures, digesting them alive.
base_stats:
strength: 14
dexterity: 2
constitution: 20
intelligence: 1
wisdom: 4
charisma: 1
luck: 6
abilities:
- basic_attack
- acid_touch
- engulf
- split
- slam
loot_table:
- loot_type: static
item_id: slime_residue
drop_chance: 1.0
quantity_min: 3
quantity_max: 6
- loot_type: static
item_id: acid_gland
drop_chance: 0.60
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: undigested_treasure
drop_chance: 0.50
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: gold_coin
drop_chance: 0.70
quantity_min: 10
quantity_max: 30
# Procedural - partially digested equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.15
rarity_bonus: -0.10
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.15
rarity_bonus: -0.10
quantity_min: 1
quantity_max: 1
experience_reward: 55
gold_reward_min: 15
gold_reward_max: 40
difficulty: medium
tags:
- ooze
- slime
- acidic
- large
- mindless
location_tags:
- dungeon
- cave
- sewer
base_damage: 10
crit_chance: 0.04
flee_chance: 0.00

View File

@@ -0,0 +1,122 @@
# Slime King - Hard elite variant
# An ancient slime of immense size and cunning
enemy_id: slime_king
name: Slime King
description: >
A gargantuan ooze that fills entire chambers, its body a
churning mass of emerald gel studded with the treasures and
bones of centuries of victims. Unlike lesser slimes, the
King possesses a malevolent intelligence, having absorbed
the minds of countless victims. A crown of corroded gold
floats within its core, the last remnant of a king it
consumed long ago.
base_stats:
strength: 18
dexterity: 2
constitution: 24
intelligence: 8
wisdom: 10
charisma: 4
luck: 10
abilities:
- basic_attack
- acid_touch
- engulf
- split
- slam
- acid_wave
- spawn_slimes
- regeneration
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: royal_slime_essence
drop_chance: 1.0
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: slime_residue
drop_chance: 1.0
quantity_min: 5
quantity_max: 10
- loot_type: static
item_id: acid_gland
drop_chance: 1.0
quantity_min: 3
quantity_max: 5
- loot_type: static
item_id: corroded_crown
drop_chance: 0.80
quantity_min: 1
quantity_max: 1
# Treasure from victims
- loot_type: static
item_id: gold_coin
drop_chance: 1.0
quantity_min: 50
quantity_max: 150
- loot_type: static
item_id: gemstone
drop_chance: 0.60
quantity_min: 1
quantity_max: 3
# Consumables
- loot_type: static
item_id: health_potion_large
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_fortitude
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
# Procedural equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.30
rarity_bonus: 0.20
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.30
rarity_bonus: 0.20
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: accessory
drop_chance: 0.25
rarity_bonus: 0.15
quantity_min: 1
quantity_max: 1
experience_reward: 150
gold_reward_min: 75
gold_reward_max: 200
difficulty: hard
tags:
- ooze
- slime
- acidic
- large
- elite
- boss
- intelligent
location_tags:
- dungeon
- cave
- sewer
base_damage: 16
crit_chance: 0.08
flee_chance: 0.00

View File

@@ -0,0 +1,70 @@
# Toxic Slime - Medium poisonous variant
# A slime that has absorbed toxic substances
enemy_id: slime_toxic
name: Toxic Slime
description: >
A sickly purple ooze that bubbles and hisses as it moves.
This slime has absorbed countless poisonous substances,
becoming a living vat of toxins. Its mere presence fouls
the air, and physical contact can cause paralysis or death.
base_stats:
strength: 10
dexterity: 4
constitution: 16
intelligence: 1
wisdom: 4
charisma: 1
luck: 4
abilities:
- basic_attack
- acid_touch
- poison_spray
- toxic_aura
- split
loot_table:
- loot_type: static
item_id: toxic_residue
drop_chance: 0.75
quantity_min: 1
quantity_max: 3
- loot_type: static
item_id: acid_gland
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: poison_sac
drop_chance: 0.35
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: antidote
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
experience_reward: 35
gold_reward_min: 0
gold_reward_max: 5
difficulty: medium
tags:
- ooze
- slime
- acidic
- poisonous
- mindless
location_tags:
- dungeon
- cave
- sewer
- swamp
base_damage: 7
crit_chance: 0.04
flee_chance: 0.05

View File

@@ -0,0 +1,56 @@
# Spider - Easy beast enemy (DEX-focused)
# A common dungeon-dwelling predator that lurks in shadows
enemy_id: spider
name: Giant Spider
description: >
A spider the size of a large dog, with glistening black chitin and
eight beady eyes that reflect the faintest light. It moves with
unsettling speed, its fangs dripping with paralyzing venom.
base_stats:
strength: 8
dexterity: 14
constitution: 8
intelligence: 2
wisdom: 10
charisma: 2
luck: 6
abilities:
- basic_attack
- venomous_bite
loot_table:
- item_id: spider_silk
drop_chance: 0.60
quantity_min: 1
quantity_max: 3
- item_id: spider_venom
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
- item_id: spider_fang
drop_chance: 0.30
quantity_min: 1
quantity_max: 2
experience_reward: 20
gold_reward_min: 0
gold_reward_max: 5
difficulty: easy
tags:
- beast
- spider
- venomous
- ambusher
location_tags:
- dungeon
- cave
- forest
base_damage: 5
crit_chance: 0.08
flee_chance: 0.50

View File

@@ -0,0 +1,90 @@
# Spider Broodmother - Hard elite spider
# A massive spider that rules over an entire nest
enemy_id: spider_broodmother
name: Spider Broodmother
description: >
A horrifying arachnid the size of a cart, her bloated abdomen
pulsing with unborn spawn. Ancient and cunning, the Broodmother
has survived countless adventurers, decorating her web with their
bones. She fights with terrifying intelligence and can call her
children to swarm any threat.
base_stats:
strength: 16
dexterity: 12
constitution: 18
intelligence: 6
wisdom: 14
charisma: 4
luck: 10
abilities:
- basic_attack
- venomous_bite
- web_trap
- summon_hatchlings
- poison_spray
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: spider_silk
drop_chance: 1.0
quantity_min: 5
quantity_max: 10
- loot_type: static
item_id: broodmother_fang
drop_chance: 0.80
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: potent_spider_venom
drop_chance: 0.70
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: spider_egg_sac
drop_chance: 0.50
quantity_min: 1
quantity_max: 1
# Consumable drops
- loot_type: static
item_id: health_potion_medium
drop_chance: 0.30
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: antidote
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
# Procedural equipment drops
- loot_type: procedural
item_type: armor
drop_chance: 0.20
rarity_bonus: 0.15
quantity_min: 1
quantity_max: 1
experience_reward: 100
gold_reward_min: 25
gold_reward_max: 60
difficulty: hard
tags:
- beast
- spider
- elite
- large
- venomous
location_tags:
- dungeon
- cave
base_damage: 14
crit_chance: 0.12
flee_chance: 0.15

View File

@@ -0,0 +1,51 @@
# Spider Hatchling - Easy swarm creature
# Young spiders that attack in overwhelming numbers
enemy_id: spider_hatchling
name: Spider Hatchling
description: >
A swarm of palm-sized spiderlings with pale, translucent bodies.
Individually weak, they overwhelm prey through sheer numbers,
skittering over victims in a horrifying wave of legs and fangs.
base_stats:
strength: 4
dexterity: 16
constitution: 4
intelligence: 1
wisdom: 6
charisma: 1
luck: 4
abilities:
- basic_attack
- swarm_attack
loot_table:
- item_id: spider_silk
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- item_id: small_spider_fang
drop_chance: 0.50
quantity_min: 2
quantity_max: 5
experience_reward: 10
gold_reward_min: 0
gold_reward_max: 2
difficulty: easy
tags:
- beast
- spider
- swarm
- small
location_tags:
- dungeon
- cave
base_damage: 3
crit_chance: 0.05
flee_chance: 0.70

View File

@@ -0,0 +1,66 @@
# Venomous Spider - Medium deadly variant
# A spider with especially potent venom
enemy_id: spider_venomous
name: Venomous Spider
description: >
A mottled brown and red spider with swollen venom glands visible
beneath its translucent exoskeleton. Its bite delivers a toxin
that burns through the veins like liquid fire, leaving victims
paralyzed and helpless.
base_stats:
strength: 10
dexterity: 14
constitution: 10
intelligence: 2
wisdom: 12
charisma: 2
luck: 8
abilities:
- basic_attack
- venomous_bite
- poison_spray
loot_table:
- loot_type: static
item_id: spider_silk
drop_chance: 0.70
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: potent_spider_venom
drop_chance: 0.45
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: spider_fang
drop_chance: 0.50
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: antidote
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
experience_reward: 35
gold_reward_min: 0
gold_reward_max: 8
difficulty: medium
tags:
- beast
- spider
- venomous
- dangerous
location_tags:
- dungeon
- cave
- swamp
base_damage: 7
crit_chance: 0.10
flee_chance: 0.40

View File

@@ -0,0 +1,59 @@
# Troll - Medium regenerating brute
# A hulking creature with remarkable healing abilities
enemy_id: troll
name: Troll
description: >
A towering creature with mottled green skin, long arms that
nearly drag on the ground, and a hunched posture. Its beady
yellow eyes peer out from beneath a heavy brow, and its mouth
is filled with jagged, broken teeth. Most terrifying is its
ability to regenerate wounds almost instantly.
base_stats:
strength: 16
dexterity: 8
constitution: 18
intelligence: 4
wisdom: 6
charisma: 4
luck: 6
abilities:
- basic_attack
- regeneration
- rending_claws
loot_table:
- item_id: troll_hide
drop_chance: 0.60
quantity_min: 1
quantity_max: 2
- item_id: troll_blood
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- item_id: beast_meat
drop_chance: 0.50
quantity_min: 2
quantity_max: 4
experience_reward: 50
gold_reward_min: 5
gold_reward_max: 20
difficulty: medium
tags:
- giant
- troll
- regenerating
- large
location_tags:
- swamp
- forest
- cave
base_damage: 12
crit_chance: 0.08
flee_chance: 0.25

View File

@@ -0,0 +1,68 @@
# Cave Troll - Medium variant adapted to underground life
# A blind but deadly subterranean hunter
enemy_id: troll_cave
name: Cave Troll
description: >
A pale, eyeless troll that has adapted to life in the deepest
caves. Its skin is white and rubbery, its ears enlarged to
hunt by sound alone. What it lacks in sight it makes up for
with uncanny hearing and a savage ferocity when cornered
in its territory.
base_stats:
strength: 18
dexterity: 6
constitution: 20
intelligence: 4
wisdom: 10
charisma: 2
luck: 6
abilities:
- basic_attack
- regeneration
- rending_claws
- echo_sense
loot_table:
- loot_type: static
item_id: cave_troll_hide
drop_chance: 0.65
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: troll_blood
drop_chance: 0.50
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: glowing_mushroom
drop_chance: 0.30
quantity_min: 1
quantity_max: 3
- loot_type: static
item_id: cave_crystal
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
experience_reward: 60
gold_reward_min: 5
gold_reward_max: 25
difficulty: medium
tags:
- giant
- troll
- regenerating
- large
- blind
location_tags:
- cave
- dungeon
base_damage: 14
crit_chance: 0.06
flee_chance: 0.20

View File

@@ -0,0 +1,75 @@
# Troll Shaman - Medium caster variant
# A rare troll with crude magical abilities
enemy_id: troll_shaman
name: Troll Shaman
description: >
An ancient troll draped in bones, feathers, and tribal fetishes.
Rare among its kind, this creature has developed a primitive
understanding of dark magic, calling upon primal spirits to
curse its enemies and heal its allies. Its regeneration is
enhanced by the dark powers it commands.
base_stats:
strength: 12
dexterity: 8
constitution: 16
intelligence: 10
wisdom: 14
charisma: 8
luck: 10
abilities:
- basic_attack
- regeneration
- curse_of_weakness
- dark_heal
- spirit_bolt
loot_table:
- loot_type: static
item_id: troll_hide
drop_chance: 0.50
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: troll_blood
drop_chance: 0.60
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: shaman_fetish
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: mana_potion_small
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: dark_essence
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
experience_reward: 65
gold_reward_min: 15
gold_reward_max: 35
difficulty: medium
tags:
- giant
- troll
- regenerating
- caster
- shaman
location_tags:
- swamp
- forest
- cave
base_damage: 8
crit_chance: 0.10
flee_chance: 0.30

View File

@@ -0,0 +1,101 @@
# Troll Warlord - Hard elite variant
# A massive, battle-scarred troll that leads others of its kind
enemy_id: troll_warlord
name: Troll Warlord
description: >
A massive troll standing nearly twelve feet tall, its body
covered in ritual scars and the marks of a hundred battles.
It wears crude armor made from the shields and weapons of
fallen warriors, and wields a massive club made from an
entire tree trunk. Its regeneration is legendary, and it
commands lesser trolls through sheer brutality.
base_stats:
strength: 20
dexterity: 8
constitution: 22
intelligence: 6
wisdom: 8
charisma: 10
luck: 8
abilities:
- basic_attack
- regeneration
- rending_claws
- ground_slam
- intimidating_shout
- berserker_rage
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: troll_hide
drop_chance: 1.0
quantity_min: 3
quantity_max: 5
- loot_type: static
item_id: troll_blood
drop_chance: 1.0
quantity_min: 3
quantity_max: 5
- loot_type: static
item_id: troll_warlord_trophy
drop_chance: 0.80
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: regeneration_gland
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
# Consumables
- loot_type: static
item_id: health_potion_large
drop_chance: 0.35
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_fortitude
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
# Procedural equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.25
rarity_bonus: 0.15
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.20
rarity_bonus: 0.10
quantity_min: 1
quantity_max: 1
experience_reward: 120
gold_reward_min: 30
gold_reward_max: 75
difficulty: hard
tags:
- giant
- troll
- regenerating
- large
- leader
- elite
location_tags:
- swamp
- forest
- cave
- dungeon
base_damage: 18
crit_chance: 0.12
flee_chance: 0.10

View File

@@ -0,0 +1,61 @@
# Wraith - Medium incorporeal undead
# A malevolent spirit of hatred and despair
enemy_id: wraith
name: Wraith
description: >
A shadowy figure shrouded in tattered darkness, its form
only vaguely humanoid. Where its face should be, two points
of cold blue light burn with hatred for the living. The
wraith's touch drains the very life force from its victims,
leaving them weakened and hollow.
base_stats:
strength: 4
dexterity: 14
constitution: 8
intelligence: 10
wisdom: 12
charisma: 14
luck: 10
abilities:
- basic_attack
- life_drain
- incorporeal_movement
- create_spawn
loot_table:
- item_id: soul_essence
drop_chance: 0.60
quantity_min: 1
quantity_max: 2
- item_id: shadow_residue
drop_chance: 0.50
quantity_min: 1
quantity_max: 3
- item_id: death_essence
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
experience_reward: 45
gold_reward_min: 0
gold_reward_max: 10
difficulty: medium
tags:
- undead
- incorporeal
- wraith
- drain
location_tags:
- crypt
- ruins
- dungeon
- graveyard
base_damage: 8
crit_chance: 0.10
flee_chance: 0.30

View File

@@ -0,0 +1,85 @@
# Banshee - Hard wailing spirit variant
# A female spirit whose scream can kill
enemy_id: wraith_banshee
name: Banshee
description: >
The spirit of a woman who died in terrible anguish, her face
frozen in an eternal scream of grief and rage. Her form is
more distinct than other wraiths, wearing tattered remnants
of a burial gown. When she wails, the sound is so filled
with despair that it can stop hearts and shatter minds.
base_stats:
strength: 4
dexterity: 12
constitution: 10
intelligence: 12
wisdom: 14
charisma: 18
luck: 12
abilities:
- basic_attack
- life_drain
- incorporeal_movement
- wail_of_despair
- horrifying_visage
- keening
loot_table:
- loot_type: static
item_id: soul_essence
drop_chance: 0.80
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: banshee_tear
drop_chance: 0.50
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: death_essence
drop_chance: 0.45
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: ectoplasm
drop_chance: 0.60
quantity_min: 2
quantity_max: 4
# Sometimes carries jewelry from her mortal life
- loot_type: static
item_id: silver_ring
drop_chance: 0.30
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: gemstone
drop_chance: 0.25
quantity_min: 1
quantity_max: 1
experience_reward: 75
gold_reward_min: 5
gold_reward_max: 25
difficulty: hard
tags:
- undead
- incorporeal
- wraith
- banshee
- sonic
- female
location_tags:
- crypt
- ruins
- graveyard
- haunted
base_damage: 6
crit_chance: 0.12
flee_chance: 0.20

View File

@@ -0,0 +1,139 @@
# Wraith Lord - Hard elite undead commander
# An ancient and powerful wraith that rules over lesser spirits
enemy_id: wraith_lord
name: Wraith Lord
description: >
An ancient spirit of terrible power, once a great lord or
mage in life, now a being of pure malevolence. The Wraith
Lord retains its intelligence and ambition, commanding
legions of lesser undead from its shadow throne. Its form
is more defined than common wraiths, wearing spectral armor
and wielding a blade of condensed darkness that severs both
flesh and soul.
base_stats:
strength: 8
dexterity: 14
constitution: 14
intelligence: 16
wisdom: 16
charisma: 20
luck: 14
abilities:
- basic_attack
- soul_blade
- life_drain
- incorporeal_movement
- create_spawn
- command_undead
- death_wave
- dark_aura
- fear
loot_table:
# Static drops - guaranteed materials
- loot_type: static
item_id: soul_essence
drop_chance: 1.0
quantity_min: 3
quantity_max: 5
- loot_type: static
item_id: shadow_residue
drop_chance: 1.0
quantity_min: 4
quantity_max: 8
- loot_type: static
item_id: death_essence
drop_chance: 0.80
quantity_min: 2
quantity_max: 3
- loot_type: static
item_id: wraith_lord_crown
drop_chance: 0.60
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: spectral_blade_shard
drop_chance: 0.50
quantity_min: 1
quantity_max: 1
# Treasure from mortal life
- loot_type: static
item_id: gold_coin
drop_chance: 0.80
quantity_min: 40
quantity_max: 100
- loot_type: static
item_id: gemstone
drop_chance: 0.60
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: ancient_jewelry
drop_chance: 0.40
quantity_min: 1
quantity_max: 1
# Consumables
- loot_type: static
item_id: health_potion_large
drop_chance: 0.35
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: mana_potion_large
drop_chance: 0.35
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: elixir_of_wisdom
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
# Procedural equipment
- loot_type: procedural
item_type: weapon
drop_chance: 0.30
rarity_bonus: 0.25
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: armor
drop_chance: 0.25
rarity_bonus: 0.20
quantity_min: 1
quantity_max: 1
- loot_type: procedural
item_type: accessory
drop_chance: 0.30
rarity_bonus: 0.20
quantity_min: 1
quantity_max: 1
experience_reward: 130
gold_reward_min: 50
gold_reward_max: 125
difficulty: hard
tags:
- undead
- incorporeal
- wraith
- leader
- elite
- boss
- intelligent
location_tags:
- crypt
- ruins
- dungeon
- haunted
base_damage: 14
crit_chance: 0.15
flee_chance: 0.05

View File

@@ -0,0 +1,72 @@
# Shadow Wraith - Medium stealth variant
# A wraith that lurks in darkness, striking unseen
enemy_id: wraith_shadow
name: Shadow Wraith
description: >
A wraith so deeply attuned to darkness that it becomes nearly
invisible in shadow. It moves silently through dim places,
reaching out to touch the living and drain their strength
before fading back into the gloom. Only in bright light does
its terrible form become visible.
base_stats:
strength: 4
dexterity: 18
constitution: 8
intelligence: 10
wisdom: 14
charisma: 12
luck: 12
abilities:
- basic_attack
- life_drain
- incorporeal_movement
- shadow_step
- darkness
- sneak_attack
loot_table:
- loot_type: static
item_id: soul_essence
drop_chance: 0.60
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: shadow_residue
drop_chance: 0.70
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: shadow_heart
drop_chance: 0.30
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: dark_essence
drop_chance: 0.35
quantity_min: 1
quantity_max: 1
experience_reward: 50
gold_reward_min: 0
gold_reward_max: 12
difficulty: medium
tags:
- undead
- incorporeal
- wraith
- shadow
- stealthy
location_tags:
- crypt
- ruins
- dungeon
- cave
base_damage: 7
crit_chance: 0.15
flee_chance: 0.35

View File

@@ -0,0 +1,58 @@
# Zombie - Easy undead shambler
# A reanimated corpse driven by dark magic
enemy_id: zombie
name: Zombie
description: >
A shambling corpse with rotting flesh hanging from its bones.
Its eyes are milky and unfocused, but it is drawn inexorably
toward the living, driven by an insatiable hunger. It moves
slowly but relentlessly, and feels no pain.
base_stats:
strength: 10
dexterity: 4
constitution: 12
intelligence: 2
wisdom: 4
charisma: 2
luck: 4
abilities:
- basic_attack
- infectious_bite
loot_table:
- item_id: rotting_flesh
drop_chance: 0.70
quantity_min: 1
quantity_max: 2
- item_id: bone_fragment
drop_chance: 0.50
quantity_min: 1
quantity_max: 3
- item_id: tattered_cloth
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
experience_reward: 18
gold_reward_min: 0
gold_reward_max: 5
difficulty: easy
tags:
- undead
- zombie
- shambler
- fearless
location_tags:
- crypt
- ruins
- dungeon
- graveyard
base_damage: 6
crit_chance: 0.05
flee_chance: 0.00

View File

@@ -0,0 +1,69 @@
# Zombie Brute - Medium powerful variant
# A massive reanimated corpse, possibly a former warrior
enemy_id: zombie_brute
name: Zombie Brute
description: >
The reanimated corpse of what was once a mighty warrior or
perhaps an ogre. Standing head and shoulders above normal
zombies, its massive frame is swollen with death-bloat and
unnatural strength. It swings its fists like clubs, crushing
anything in its path.
base_stats:
strength: 16
dexterity: 4
constitution: 18
intelligence: 2
wisdom: 4
charisma: 2
luck: 4
abilities:
- basic_attack
- crushing_blow
- infectious_bite
- undead_fortitude
loot_table:
- loot_type: static
item_id: rotting_flesh
drop_chance: 0.80
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: bone_fragment
drop_chance: 0.70
quantity_min: 2
quantity_max: 5
- loot_type: static
item_id: death_essence
drop_chance: 0.20
quantity_min: 1
quantity_max: 1
- loot_type: static
item_id: iron_ore
drop_chance: 0.15
quantity_min: 1
quantity_max: 2
experience_reward: 45
gold_reward_min: 5
gold_reward_max: 15
difficulty: medium
tags:
- undead
- zombie
- brute
- large
- fearless
location_tags:
- crypt
- ruins
- dungeon
base_damage: 12
crit_chance: 0.08
flee_chance: 0.00

View File

@@ -0,0 +1,85 @@
# Plague Zombie - Hard infectious variant
# A zombie carrying a deadly disease that spreads with each bite
enemy_id: zombie_plague
name: Plague Zombie
description: >
A bloated, pustule-covered corpse that exudes a noxious green
miasma. Every wound on its body weeps infectious fluid, and
its mere presence causes nausea in the living. Those bitten
by a plague zombie rarely survive the infection, and those
that die often rise again to join the horde.
base_stats:
strength: 12
dexterity: 6
constitution: 16
intelligence: 2
wisdom: 6
charisma: 2
luck: 6
abilities:
- basic_attack
- infectious_bite
- plague_breath
- death_burst
- disease_aura
loot_table:
# Static drops
- loot_type: static
item_id: rotting_flesh
drop_chance: 0.90
quantity_min: 3
quantity_max: 5
- loot_type: static
item_id: plague_ichor
drop_chance: 0.60
quantity_min: 1
quantity_max: 3
- loot_type: static
item_id: bone_fragment
drop_chance: 0.70
quantity_min: 2
quantity_max: 4
- loot_type: static
item_id: death_essence
drop_chance: 0.35
quantity_min: 1
quantity_max: 2
# Consumables
- loot_type: static
item_id: antidote
drop_chance: 0.30
quantity_min: 1
quantity_max: 2
- loot_type: static
item_id: cure_disease_potion
drop_chance: 0.15
quantity_min: 1
quantity_max: 1
experience_reward: 70
gold_reward_min: 10
gold_reward_max: 30
difficulty: hard
tags:
- undead
- zombie
- plague
- infectious
- fearless
- dangerous
location_tags:
- crypt
- ruins
- dungeon
- swamp
base_damage: 10
crit_chance: 0.10
flee_chance: 0.00

View File

@@ -0,0 +1,53 @@
# Zombie Shambler - Easy weak variant
# A decrepit zombie barely holding together
enemy_id: zombie_shambler
name: Zombie Shambler
description: >
A decrepit corpse in an advanced state of decay, missing limbs
and dragging itself forward with single-minded determination.
What it lacks in strength it makes up for in persistence,
continuing to crawl toward prey even when reduced to a torso.
base_stats:
strength: 6
dexterity: 2
constitution: 8
intelligence: 1
wisdom: 2
charisma: 1
luck: 2
abilities:
- basic_attack
- grasp
loot_table:
- item_id: rotting_flesh
drop_chance: 0.50
quantity_min: 1
quantity_max: 1
- item_id: bone_fragment
drop_chance: 0.40
quantity_min: 1
quantity_max: 2
experience_reward: 10
gold_reward_min: 0
gold_reward_max: 2
difficulty: easy
tags:
- undead
- zombie
- shambler
- weak
location_tags:
- crypt
- ruins
- graveyard
base_damage: 4
crit_chance: 0.02
flee_chance: 0.00

View File

@@ -3,6 +3,7 @@ npc_id: npc_blacksmith_hilda
name: Hilda Ironforge name: Hilda Ironforge
role: blacksmith role: blacksmith
location_id: crossville_village location_id: crossville_village
image_url: /static/images/npcs/crossville/blacksmith_hilda.png
personality: personality:
traits: traits:

View File

@@ -3,6 +3,7 @@ npc_id: npc_grom_ironbeard
name: Grom Ironbeard name: Grom Ironbeard
role: bartender role: bartender
location_id: crossville_tavern location_id: crossville_tavern
image_url: /static/images/npcs/crossville/grom_ironbeard.png
personality: personality:
traits: traits:

View File

@@ -3,6 +3,7 @@ npc_id: npc_mayor_aldric
name: Mayor Aldric Thornwood name: Mayor Aldric Thornwood
role: mayor role: mayor
location_id: crossville_village location_id: crossville_village
image_url: /static/images/npcs/crossville/mayor_aldric.png
personality: personality:
traits: traits:

View File

@@ -3,6 +3,7 @@ npc_id: npc_mira_swiftfoot
name: Mira Swiftfoot name: Mira Swiftfoot
role: rogue role: rogue
location_id: crossville_tavern location_id: crossville_tavern
image_url: /static/images/npcs/crossville/mira_swiftfoot.png
personality: personality:
traits: traits:

View File

@@ -0,0 +1,161 @@
# Consumable items that drop from enemies or are purchased from vendors
# These items have effects_on_use that trigger when consumed
items:
# ==========================================================================
# Health Potions
# ==========================================================================
health_potion_small:
name: "Small Health Potion"
item_type: consumable
rarity: common
description: "A small vial of red liquid that restores a modest amount of health."
value: 25
is_tradeable: true
effects_on_use:
- effect_id: heal_small
name: "Minor Healing"
effect_type: hot
power: 30
duration: 1
stacks: 1
health_potion_medium:
name: "Health Potion"
item_type: consumable
rarity: uncommon
description: "A standard healing potion used by adventurers across the realm."
value: 75
is_tradeable: true
effects_on_use:
- effect_id: heal_medium
name: "Healing"
effect_type: hot
power: 75
duration: 1
stacks: 1
health_potion_large:
name: "Large Health Potion"
item_type: consumable
rarity: rare
description: "A potent healing draught that restores significant health."
value: 150
is_tradeable: true
effects_on_use:
- effect_id: heal_large
name: "Major Healing"
effect_type: hot
power: 150
duration: 1
stacks: 1
# ==========================================================================
# Mana Potions
# ==========================================================================
mana_potion_small:
name: "Small Mana Potion"
item_type: consumable
rarity: common
description: "A small vial of blue liquid that restores mana."
value: 25
is_tradeable: true
# Note: MP restoration would need custom effect type or game logic
mana_potion_medium:
name: "Mana Potion"
item_type: consumable
rarity: uncommon
description: "A standard mana potion favored by spellcasters."
value: 75
is_tradeable: true
# ==========================================================================
# Status Effect Cures
# ==========================================================================
antidote:
name: "Antidote"
item_type: consumable
rarity: common
description: "A bitter herbal remedy that cures poison effects."
value: 30
is_tradeable: true
smelling_salts:
name: "Smelling Salts"
item_type: consumable
rarity: uncommon
description: "Pungent salts that can revive unconscious allies or cure stun."
value: 40
is_tradeable: true
# ==========================================================================
# Combat Buffs
# ==========================================================================
elixir_of_strength:
name: "Elixir of Strength"
item_type: consumable
rarity: rare
description: "A powerful elixir that temporarily increases strength."
value: 100
is_tradeable: true
effects_on_use:
- effect_id: str_buff
name: "Strength Boost"
effect_type: buff
power: 5
duration: 5
stacks: 1
elixir_of_agility:
name: "Elixir of Agility"
item_type: consumable
rarity: rare
description: "A shimmering elixir that enhances reflexes and speed."
value: 100
is_tradeable: true
effects_on_use:
- effect_id: dex_buff
name: "Agility Boost"
effect_type: buff
power: 5
duration: 5
stacks: 1
# ==========================================================================
# Food Items (simple healing, no combat use)
# ==========================================================================
ration:
name: "Trail Ration"
item_type: consumable
rarity: common
description: "Dried meat, hardtack, and nuts. Sustains an adventurer on long journeys."
value: 5
is_tradeable: true
effects_on_use:
- effect_id: ration_heal
name: "Nourishment"
effect_type: hot
power: 10
duration: 1
stacks: 1
cooked_meat:
name: "Cooked Meat"
item_type: consumable
rarity: common
description: "Freshly cooked meat that restores health."
value: 15
is_tradeable: true
effects_on_use:
- effect_id: meat_heal
name: "Hearty Meal"
effect_type: hot
power: 20
duration: 1
stacks: 1

View File

@@ -0,0 +1,138 @@
# Starting Equipment - Basic items given to new characters based on their class
# These are all common-quality items suitable for Level 1 characters
items:
# ==================== WEAPONS ====================
# Melee Weapons
rusty_sword:
name: Rusty Sword
item_type: weapon
rarity: common
description: >
A battered old sword showing signs of age and neglect.
Its edge is dull but it can still cut.
value: 5
damage: 4
damage_type: physical
is_tradeable: true
rusty_mace:
name: Rusty Mace
item_type: weapon
rarity: common
description: >
A worn mace with a tarnished head. The weight still
makes it effective for crushing blows.
value: 5
damage: 5
damage_type: physical
is_tradeable: true
rusty_dagger:
name: Rusty Dagger
item_type: weapon
rarity: common
description: >
A corroded dagger with a chipped blade. Quick and
deadly in the right hands despite its condition.
value: 4
damage: 3
damage_type: physical
crit_chance: 0.10 # Daggers have higher crit chance
is_tradeable: true
rusty_knife:
name: Rusty Knife
item_type: weapon
rarity: common
description: >
A simple utility knife, more tool than weapon. Every
adventurer keeps one handy for various tasks.
value: 2
damage: 2
damage_type: physical
is_tradeable: true
# Ranged Weapons
rusty_bow:
name: Rusty Bow
item_type: weapon
rarity: common
description: >
An old hunting bow with a frayed string. It still fires
true enough for an aspiring ranger.
value: 5
damage: 4
damage_type: physical
is_tradeable: true
# Magical Weapons (spell_power instead of damage)
worn_staff:
name: Worn Staff
item_type: weapon
rarity: common
description: >
A gnarled wooden staff weathered by time. Faint traces
of arcane energy still pulse through its core.
value: 6
damage: 2 # Low physical damage for staff strikes
spell_power: 4 # Boosts spell damage
damage_type: arcane
is_tradeable: true
bone_wand:
name: Bone Wand
item_type: weapon
rarity: common
description: >
A wand carved from ancient bone, cold to the touch.
It resonates with dark energy.
value: 6
damage: 1 # Minimal physical damage
spell_power: 5 # Higher spell power for dedicated casters
damage_type: shadow # Dark/shadow magic for necromancy
is_tradeable: true
tome:
name: Worn Tome
item_type: weapon
rarity: common
description: >
A leather-bound book filled with faded notes and arcane
formulas. Knowledge is power made manifest.
value: 6
damage: 1 # Can bonk someone with it
spell_power: 4 # Boosts spell damage
damage_type: arcane
is_tradeable: true
# ==================== ARMOR ====================
cloth_armor:
name: Cloth Armor
item_type: armor
rarity: common
description: >
Simple padded cloth garments offering minimal protection.
Better than nothing, barely.
value: 5
defense: 2
resistance: 1
is_tradeable: true
# ==================== SHIELDS/ACCESSORIES ====================
rusty_shield:
name: Rusty Shield
item_type: armor
rarity: common
description: >
A battered wooden shield with a rusted metal rim.
It can still block a blow or two.
value: 5
defense: 3
resistance: 0
stat_bonuses:
constitution: 1
is_tradeable: true

View File

@@ -0,0 +1,219 @@
# Trophy items, crafting materials, and quest items dropped by enemies
# These items don't have combat effects but are used for quests, crafting, or selling
items:
# ==========================================================================
# Goblin Drops
# ==========================================================================
goblin_ear:
name: "Goblin Ear"
item_type: quest_item
rarity: common
description: "A severed goblin ear. Proof of a kill, sometimes collected for bounties."
value: 2
is_tradeable: true
goblin_trinket:
name: "Goblin Trinket"
item_type: quest_item
rarity: common
description: "A crude piece of jewelry stolen by a goblin. Worth a few coins."
value: 8
is_tradeable: true
goblin_war_paint:
name: "Goblin War Paint"
item_type: quest_item
rarity: uncommon
description: "Pungent red and black paint used by goblin warriors before battle."
value: 15
is_tradeable: true
goblin_chieftain_token:
name: "Chieftain's Token"
item_type: quest_item
rarity: rare
description: "A carved bone token marking the authority of a goblin chieftain."
value: 50
is_tradeable: true
# ==========================================================================
# Wolf/Beast Drops
# ==========================================================================
wolf_pelt:
name: "Wolf Pelt"
item_type: quest_item
rarity: common
description: "A fur pelt from a wolf. Useful for crafting or selling to tanners."
value: 10
is_tradeable: true
dire_wolf_fang:
name: "Dire Wolf Fang"
item_type: quest_item
rarity: uncommon
description: "A large fang from a dire wolf. Prized by craftsmen for weapon making."
value: 25
is_tradeable: true
beast_hide:
name: "Beast Hide"
item_type: quest_item
rarity: common
description: "Thick hide from a large beast. Can be tanned into leather."
value: 12
is_tradeable: true
# ==========================================================================
# Vermin Drops
# ==========================================================================
rat_tail:
name: "Rat Tail"
item_type: quest_item
rarity: common
description: "A scaly tail from a giant rat. Sometimes collected for pest control bounties."
value: 1
is_tradeable: true
# ==========================================================================
# Undead Drops
# ==========================================================================
skeleton_bone:
name: "Skeleton Bone"
item_type: quest_item
rarity: common
description: "A bone from an animated skeleton. Retains faint magical energy."
value: 5
is_tradeable: true
bone_dust:
name: "Bone Dust"
item_type: quest_item
rarity: common
description: "Powdered bone from undead remains. Used in alchemy and rituals."
value: 8
is_tradeable: true
skull_fragment:
name: "Skull Fragment"
item_type: quest_item
rarity: uncommon
description: "A piece of an undead skull, still crackling with dark energy."
value: 20
is_tradeable: true
# ==========================================================================
# Orc Drops
# ==========================================================================
orc_tusk:
name: "Orc Tusk"
item_type: quest_item
rarity: uncommon
description: "A large tusk from an orc warrior. A trophy prized by collectors."
value: 25
is_tradeable: true
orc_war_banner:
name: "Orc War Banner"
item_type: quest_item
rarity: rare
description: "A bloodstained banner torn from an orc warband. Proof of a hard fight."
value: 45
is_tradeable: true
berserker_charm:
name: "Berserker Charm"
item_type: quest_item
rarity: rare
description: "A crude charm worn by orc berserkers. Said to enhance rage."
value: 60
is_tradeable: true
# ==========================================================================
# Bandit Drops
# ==========================================================================
bandit_mask:
name: "Bandit Mask"
item_type: quest_item
rarity: common
description: "A cloth mask worn by bandits to conceal their identity."
value: 8
is_tradeable: true
stolen_coin_pouch:
name: "Stolen Coin Pouch"
item_type: quest_item
rarity: common
description: "A small pouch of coins stolen by bandits. Should be returned."
value: 15
is_tradeable: true
wanted_poster:
name: "Wanted Poster"
item_type: quest_item
rarity: uncommon
description: "A crumpled wanted poster. May lead to bounty opportunities."
value: 5
is_tradeable: true
# ==========================================================================
# Generic/Currency Items
# ==========================================================================
gold_coin:
name: "Gold Coin"
item_type: quest_item
rarity: common
description: "A single gold coin. Standard currency across the realm."
value: 1
is_tradeable: true
silver_coin:
name: "Silver Coin"
item_type: quest_item
rarity: common
description: "A silver coin worth less than gold but still useful."
value: 1
is_tradeable: true
# ==========================================================================
# Crafting Materials (Generic)
# ==========================================================================
iron_ore:
name: "Iron Ore"
item_type: quest_item
rarity: common
description: "Raw iron ore that can be smelted into ingots."
value: 10
is_tradeable: true
leather_scraps:
name: "Leather Scraps"
item_type: quest_item
rarity: common
description: "Scraps of leather useful for crafting and repairs."
value: 5
is_tradeable: true
cloth_scraps:
name: "Cloth Scraps"
item_type: quest_item
rarity: common
description: "Torn cloth that can be sewn into bandages or used for crafting."
value: 3
is_tradeable: true
magic_essence:
name: "Magic Essence"
item_type: quest_item
rarity: uncommon
description: "Crystallized magical energy. Used in enchanting and alchemy."
value: 30
is_tradeable: true

View File

@@ -9,6 +9,7 @@ from app.models.enums import (
EffectType, EffectType,
DamageType, DamageType,
ItemType, ItemType,
ItemRarity,
StatType, StatType,
AbilityType, AbilityType,
CombatStatus, CombatStatus,
@@ -53,6 +54,7 @@ __all__ = [
"EffectType", "EffectType",
"DamageType", "DamageType",
"ItemType", "ItemType",
"ItemRarity",
"StatType", "StatType",
"AbilityType", "AbilityType",
"CombatStatus", "CombatStatus",

305
api/app/models/affixes.py Normal file
View File

@@ -0,0 +1,305 @@
"""
Item affix system for procedural item generation.
This module defines affixes (prefixes and suffixes) that can be attached to items
to provide stat bonuses and generate Diablo-style names like "Flaming Dagger of Strength".
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
from app.models.enums import AffixType, AffixTier, DamageType, ItemRarity
@dataclass
class Affix:
"""
Represents a single item affix (prefix or suffix).
Affixes provide stat bonuses and contribute to item naming.
Prefixes appear before the item name: "Flaming Dagger"
Suffixes appear after the item name: "Dagger of Strength"
Attributes:
affix_id: Unique identifier (e.g., "flaming", "of_strength")
name: Display name for the affix (e.g., "Flaming", "of Strength")
affix_type: PREFIX or SUFFIX
tier: MINOR, MAJOR, or LEGENDARY (determines bonus magnitude)
description: Human-readable description of the affix effect
Stat Bonuses:
stat_bonuses: Dict mapping stat name to bonus value
Example: {"strength": 2, "constitution": 1}
defense_bonus: Direct defense bonus
resistance_bonus: Direct resistance bonus
Weapon Properties (PREFIX only, elemental):
damage_bonus: Flat damage bonus added to weapon
damage_type: Elemental damage type (fire, ice, etc.)
elemental_ratio: Portion of damage converted to elemental (0.0-1.0)
crit_chance_bonus: Added to weapon crit chance
crit_multiplier_bonus: Added to crit damage multiplier
Restrictions:
allowed_item_types: Empty list = all types allowed
required_rarity: Minimum rarity to roll this affix (for legendary-only)
"""
affix_id: str
name: str
affix_type: AffixType
tier: AffixTier
description: str = ""
# Stat bonuses (applies to any item)
stat_bonuses: Dict[str, int] = field(default_factory=dict)
defense_bonus: int = 0
resistance_bonus: int = 0
# Weapon-specific bonuses
damage_bonus: int = 0
damage_type: Optional[DamageType] = None
elemental_ratio: float = 0.0
crit_chance_bonus: float = 0.0
crit_multiplier_bonus: float = 0.0
# Restrictions
allowed_item_types: List[str] = field(default_factory=list)
required_rarity: Optional[str] = None
def applies_elemental_damage(self) -> bool:
"""
Check if this affix converts damage to elemental.
Returns:
True if affix adds elemental damage component
"""
return self.damage_type is not None and self.elemental_ratio > 0.0
def is_legendary_only(self) -> bool:
"""
Check if this affix only rolls on legendary items.
Returns:
True if affix requires legendary rarity
"""
return self.required_rarity == "legendary"
def can_apply_to(self, item_type: str, rarity: str) -> bool:
"""
Check if this affix can be applied to an item.
Args:
item_type: Type of item ("weapon", "armor", etc.)
rarity: Item rarity ("common", "rare", "epic", "legendary")
Returns:
True if affix can be applied, False otherwise
"""
# Check rarity requirement
if self.required_rarity and rarity != self.required_rarity:
return False
# Check item type restriction
if self.allowed_item_types and item_type not in self.allowed_item_types:
return False
return True
def to_dict(self) -> Dict[str, Any]:
"""
Serialize affix to dictionary.
Returns:
Dictionary containing all affix data
"""
data = asdict(self)
data["affix_type"] = self.affix_type.value
data["tier"] = self.tier.value
if self.damage_type:
data["damage_type"] = self.damage_type.value
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Affix':
"""
Deserialize affix from dictionary.
Args:
data: Dictionary containing affix data
Returns:
Affix instance
"""
affix_type = AffixType(data["affix_type"])
tier = AffixTier(data["tier"])
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
return cls(
affix_id=data["affix_id"],
name=data["name"],
affix_type=affix_type,
tier=tier,
description=data.get("description", ""),
stat_bonuses=data.get("stat_bonuses", {}),
defense_bonus=data.get("defense_bonus", 0),
resistance_bonus=data.get("resistance_bonus", 0),
damage_bonus=data.get("damage_bonus", 0),
damage_type=damage_type,
elemental_ratio=data.get("elemental_ratio", 0.0),
crit_chance_bonus=data.get("crit_chance_bonus", 0.0),
crit_multiplier_bonus=data.get("crit_multiplier_bonus", 0.0),
allowed_item_types=data.get("allowed_item_types", []),
required_rarity=data.get("required_rarity"),
)
def __repr__(self) -> str:
"""String representation of the affix."""
bonuses = []
if self.stat_bonuses:
bonuses.append(f"stats={self.stat_bonuses}")
if self.damage_bonus:
bonuses.append(f"dmg+{self.damage_bonus}")
if self.defense_bonus:
bonuses.append(f"def+{self.defense_bonus}")
if self.applies_elemental_damage():
bonuses.append(f"{self.damage_type.value}={self.elemental_ratio:.0%}")
bonus_str = ", ".join(bonuses) if bonuses else "no bonuses"
return f"Affix({self.name}, {self.affix_type.value}, {self.tier.value}, {bonus_str})"
@dataclass
class BaseItemTemplate:
"""
Template for base items used in procedural generation.
Base items define the foundation (e.g., "Dagger", "Longsword", "Chainmail")
that affixes attach to during item generation.
Attributes:
template_id: Unique identifier (e.g., "dagger", "longsword")
name: Display name (e.g., "Dagger", "Longsword")
item_type: Category ("weapon", "armor")
description: Flavor text for the base item
Base Stats:
base_damage: Base weapon damage (weapons only)
base_defense: Base armor defense (armor only)
base_resistance: Base magic resistance (armor only)
base_value: Base gold value before rarity/affix modifiers
Weapon Properties:
damage_type: Primary damage type (usually "physical")
crit_chance: Base critical hit chance
crit_multiplier: Base critical damage multiplier
Generation:
required_level: Minimum character level for this template
drop_weight: Weighting for random selection (higher = more common)
min_rarity: Minimum rarity this template can generate at
"""
template_id: str
name: str
item_type: str # "weapon" or "armor"
description: str = ""
# Base stats
base_damage: int = 0
base_spell_power: int = 0 # For magical weapons (staves, wands)
base_defense: int = 0
base_resistance: int = 0
base_value: int = 10
# Weapon properties
damage_type: str = "physical"
crit_chance: float = 0.05
crit_multiplier: float = 2.0
# Generation settings
required_level: int = 1
drop_weight: float = 1.0
min_rarity: str = "common"
def can_generate_at_rarity(self, rarity: str) -> bool:
"""
Check if this template can generate at a given rarity.
Some templates (like greatswords) may only drop at rare+.
Args:
rarity: Target rarity to check
Returns:
True if template can generate at this rarity
"""
rarity_order = ["common", "uncommon", "rare", "epic", "legendary"]
min_index = rarity_order.index(self.min_rarity)
target_index = rarity_order.index(rarity)
return target_index >= min_index
def can_drop_for_level(self, character_level: int) -> bool:
"""
Check if this template can drop for a character level.
Args:
character_level: Character's current level
Returns:
True if template can drop for this level
"""
return character_level >= self.required_level
def to_dict(self) -> Dict[str, Any]:
"""
Serialize template to dictionary.
Returns:
Dictionary containing all template data
"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'BaseItemTemplate':
"""
Deserialize template from dictionary.
Args:
data: Dictionary containing template data
Returns:
BaseItemTemplate instance
"""
return cls(
template_id=data["template_id"],
name=data["name"],
item_type=data["item_type"],
description=data.get("description", ""),
base_damage=data.get("base_damage", 0),
base_spell_power=data.get("base_spell_power", 0),
base_defense=data.get("base_defense", 0),
base_resistance=data.get("base_resistance", 0),
base_value=data.get("base_value", 10),
damage_type=data.get("damage_type", "physical"),
crit_chance=data.get("crit_chance", 0.05),
crit_multiplier=data.get("crit_multiplier", 2.0),
required_level=data.get("required_level", 1),
drop_weight=data.get("drop_weight", 1.0),
min_rarity=data.get("min_rarity", "common"),
)
def __repr__(self) -> str:
"""String representation of the template."""
if self.item_type == "weapon":
return (
f"BaseItemTemplate({self.name}, weapon, dmg={self.base_damage}, "
f"crit={self.crit_chance*100:.0f}%, lvl={self.required_level})"
)
elif self.item_type == "armor":
return (
f"BaseItemTemplate({self.name}, armor, def={self.base_defense}, "
f"res={self.base_resistance}, lvl={self.required_level})"
)
else:
return f"BaseItemTemplate({self.name}, {self.item_type})"

View File

@@ -13,7 +13,7 @@ from app.models.stats import Stats
from app.models.items import Item from app.models.items import Item
from app.models.skills import PlayerClass, SkillNode from app.models.skills import PlayerClass, SkillNode
from app.models.effects import Effect from app.models.effects import Effect
from app.models.enums import EffectType, StatType from app.models.enums import EffectType, StatType, ItemType
from app.models.origins import Origin from app.models.origins import Origin
@@ -70,8 +70,20 @@ class Character:
current_location: Optional[str] = None # Set to origin starting location on creation current_location: Optional[str] = None # Set to origin starting location on creation
# NPC interaction tracking (persists across sessions) # NPC interaction tracking (persists across sessions)
# Each entry: {npc_id: {interaction_count, relationship_level, dialogue_history, ...}} # Each entry: {
# dialogue_history: List[{player_line: str, npc_response: str}] # npc_id: str,
# first_met: str (ISO timestamp),
# last_interaction: str (ISO timestamp),
# interaction_count: int,
# revealed_secrets: List[int],
# relationship_level: int (0-100, 50=neutral),
# custom_flags: Dict[str, Any],
# recent_messages: List[Dict] (last 3 messages for AI context),
# format: [{player_message: str, npc_response: str, timestamp: str}, ...],
# total_messages: int (total conversation message count),
# dialogue_history: List[Dict] (DEPRECATED, use ChatMessageService for full history)
# }
# Full conversation history stored in chat_messages collection (unlimited)
npc_interactions: Dict[str, Dict] = field(default_factory=dict) npc_interactions: Dict[str, Dict] = field(default_factory=dict)
def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats: def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats:
@@ -80,7 +92,11 @@ class Character:
This is the CRITICAL METHOD that combines: This is the CRITICAL METHOD that combines:
1. Base stats (from character) 1. Base stats (from character)
2. Equipment bonuses (from equipped items) 2. Equipment bonuses (from equipped items):
- stat_bonuses dict applied to corresponding stats
- Weapon damage added to damage_bonus
- Weapon spell_power added to spell_power_bonus
- Armor defense/resistance added to defense_bonus/resistance_bonus
3. Skill tree bonuses (from unlocked skills) 3. Skill tree bonuses (from unlocked skills)
4. Active effect modifiers (buffs/debuffs) 4. Active effect modifiers (buffs/debuffs)
@@ -88,18 +104,30 @@ class Character:
active_effects: Currently active effects on this character (from combat) active_effects: Currently active effects on this character (from combat)
Returns: Returns:
Stats instance with all modifiers applied Stats instance with all modifiers applied (including computed
damage, defense, resistance properties that incorporate bonuses)
""" """
# Start with a copy of base stats # Start with a copy of base stats
effective = self.base_stats.copy() effective = self.base_stats.copy()
# Apply equipment bonuses # Apply equipment bonuses
for item in self.equipped.values(): for item in self.equipped.values():
# Apply stat bonuses from item (e.g., +3 strength)
for stat_name, bonus in item.stat_bonuses.items(): for stat_name, bonus in item.stat_bonuses.items():
if hasattr(effective, stat_name): if hasattr(effective, stat_name):
current_value = getattr(effective, stat_name) current_value = getattr(effective, stat_name)
setattr(effective, stat_name, current_value + bonus) setattr(effective, stat_name, current_value + bonus)
# Add weapon damage and spell_power to bonus fields
if item.item_type == ItemType.WEAPON:
effective.damage_bonus += item.damage
effective.spell_power_bonus += item.spell_power
# Add armor defense and resistance to bonus fields
if item.item_type == ItemType.ARMOR:
effective.defense_bonus += item.defense
effective.resistance_bonus += item.resistance
# Apply skill tree bonuses # Apply skill tree bonuses
skill_bonuses = self._get_skill_bonuses() skill_bonuses = self._get_skill_bonuses()
for stat_name, bonus in skill_bonuses.items(): for stat_name, bonus in skill_bonuses.items():

View File

@@ -0,0 +1,172 @@
"""
Chat Message Data Models.
This module defines the data structures for player-NPC chat messages,
stored in the Appwrite chat_messages collection for unlimited conversation history.
"""
from dataclasses import dataclass, field, asdict
from datetime import datetime
from enum import Enum
from typing import Dict, Any, Optional
import uuid
class MessageContext(Enum):
"""
Context type for chat messages.
Indicates the type of interaction that generated this message,
useful for filtering and quest/faction tracking.
"""
DIALOGUE = "dialogue" # General conversation
QUEST_OFFERED = "quest_offered" # Quest offering dialogue
QUEST_COMPLETED = "quest_completed" # Quest completion dialogue
SHOP = "shop" # Merchant transaction
LOCATION_REVEALED = "location_revealed" # New location discovered through chat
LORE = "lore" # Lore/backstory reveals
@dataclass
class ChatMessage:
"""
Represents a single message exchange between a player and an NPC.
This is the core data model for the chat log system. Each message
represents a complete exchange: what the player said and how the NPC responded.
Stored in: Appwrite chat_messages collection
Indexed by: character_id, npc_id, timestamp, session_id, context
Attributes:
message_id: Unique identifier (UUID)
character_id: Player's character ID
npc_id: NPC identifier
player_message: What the player said to the NPC
npc_response: NPC's reply
timestamp: When the message was created (ISO 8601)
session_id: Game session reference (optional, for session-based queries)
location_id: Where conversation happened (optional)
context: Type of interaction (dialogue, quest, shop, etc.)
metadata: Extensible JSON field for quest_id, faction_id, item_id, etc.
is_deleted: Soft delete flag (for privacy/moderation)
"""
message_id: str
character_id: str
npc_id: str
player_message: str
npc_response: str
timestamp: str # ISO 8601 format
context: MessageContext
session_id: Optional[str] = None
location_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
is_deleted: bool = False
@staticmethod
def create(
character_id: str,
npc_id: str,
player_message: str,
npc_response: str,
context: MessageContext = MessageContext.DIALOGUE,
session_id: Optional[str] = None,
location_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> "ChatMessage":
"""
Factory method to create a new ChatMessage with auto-generated ID and timestamp.
Args:
character_id: Player's character ID
npc_id: NPC identifier
player_message: What the player said
npc_response: NPC's reply
context: Type of interaction (default: DIALOGUE)
session_id: Optional game session reference
location_id: Optional location where conversation happened
metadata: Optional extensible metadata (quest_id, faction_id, etc.)
Returns:
New ChatMessage instance with generated ID and current timestamp
"""
return ChatMessage(
message_id=str(uuid.uuid4()),
character_id=character_id,
npc_id=npc_id,
player_message=player_message,
npc_response=npc_response,
timestamp=datetime.utcnow().isoformat() + "Z",
context=context,
session_id=session_id,
location_id=location_id,
metadata=metadata or {},
is_deleted=False
)
def to_dict(self) -> Dict[str, Any]:
"""
Convert ChatMessage to dictionary for JSON serialization.
Returns:
Dictionary representation with MessageContext converted to string
"""
data = asdict(self)
data["context"] = self.context.value # Convert enum to string
return data
@staticmethod
def from_dict(data: Dict[str, Any]) -> "ChatMessage":
"""
Create ChatMessage from dictionary (Appwrite document).
Args:
data: Dictionary from Appwrite document
Returns:
ChatMessage instance
"""
# Convert context string to enum
if isinstance(data.get("context"), str):
data["context"] = MessageContext(data["context"])
return ChatMessage(**data)
def to_preview(self) -> Dict[str, str]:
"""
Convert to lightweight preview format for character.npc_interactions.recent_messages.
Returns:
Dictionary with only player_message, npc_response, timestamp
"""
return {
"player_message": self.player_message,
"npc_response": self.npc_response,
"timestamp": self.timestamp
}
@dataclass
class ConversationSummary:
"""
Summary of all messages with a specific NPC.
Used for the "conversations list" UI to show all NPCs
the character has talked to.
Attributes:
npc_id: NPC identifier
npc_name: NPC display name (fetched from NPC data)
last_message_timestamp: When the last message was sent
message_count: Total number of messages exchanged
recent_preview: Short preview of most recent NPC response
"""
npc_id: str
npc_name: str
last_message_timestamp: str
message_count: int
recent_preview: str
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return asdict(self)

View File

@@ -12,7 +12,7 @@ import random
from app.models.stats import Stats from app.models.stats import Stats
from app.models.effects import Effect from app.models.effects import Effect
from app.models.abilities import Ability from app.models.abilities import Ability
from app.models.enums import CombatStatus, EffectType from app.models.enums import CombatStatus, EffectType, DamageType
@dataclass @dataclass
@@ -36,6 +36,12 @@ class Combatant:
abilities: Available abilities for this combatant abilities: Available abilities for this combatant
cooldowns: Map of ability_id to turns remaining cooldowns: Map of ability_id to turns remaining
initiative: Turn order value (rolled at combat start) initiative: Turn order value (rolled at combat start)
weapon_crit_chance: Critical hit chance from equipped weapon
weapon_crit_multiplier: Critical hit damage multiplier
weapon_damage_type: Primary damage type of weapon
elemental_damage_type: Secondary damage type for elemental weapons
physical_ratio: Portion of damage that is physical (0.0-1.0)
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
""" """
combatant_id: str combatant_id: str
@@ -51,6 +57,16 @@ class Combatant:
cooldowns: Dict[str, int] = field(default_factory=dict) cooldowns: Dict[str, int] = field(default_factory=dict)
initiative: int = 0 initiative: int = 0
# Weapon properties (for combat calculations)
weapon_crit_chance: float = 0.05
weapon_crit_multiplier: float = 2.0
weapon_damage_type: Optional[DamageType] = None
# Elemental weapon properties (for split damage)
elemental_damage_type: Optional[DamageType] = None
physical_ratio: float = 1.0
elemental_ratio: float = 0.0
def is_alive(self) -> bool: def is_alive(self) -> bool:
"""Check if combatant is still alive.""" """Check if combatant is still alive."""
return self.current_hp > 0 return self.current_hp > 0
@@ -228,6 +244,12 @@ class Combatant:
"abilities": self.abilities, "abilities": self.abilities,
"cooldowns": self.cooldowns, "cooldowns": self.cooldowns,
"initiative": self.initiative, "initiative": self.initiative,
"weapon_crit_chance": self.weapon_crit_chance,
"weapon_crit_multiplier": self.weapon_crit_multiplier,
"weapon_damage_type": self.weapon_damage_type.value if self.weapon_damage_type else None,
"elemental_damage_type": self.elemental_damage_type.value if self.elemental_damage_type else None,
"physical_ratio": self.physical_ratio,
"elemental_ratio": self.elemental_ratio,
} }
@classmethod @classmethod
@@ -236,6 +258,15 @@ class Combatant:
stats = Stats.from_dict(data["stats"]) stats = Stats.from_dict(data["stats"])
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])] active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
# Parse damage types
weapon_damage_type = None
if data.get("weapon_damage_type"):
weapon_damage_type = DamageType(data["weapon_damage_type"])
elemental_damage_type = None
if data.get("elemental_damage_type"):
elemental_damage_type = DamageType(data["elemental_damage_type"])
return cls( return cls(
combatant_id=data["combatant_id"], combatant_id=data["combatant_id"],
name=data["name"], name=data["name"],
@@ -249,6 +280,12 @@ class Combatant:
abilities=data.get("abilities", []), abilities=data.get("abilities", []),
cooldowns=data.get("cooldowns", {}), cooldowns=data.get("cooldowns", {}),
initiative=data.get("initiative", 0), initiative=data.get("initiative", 0),
weapon_crit_chance=data.get("weapon_crit_chance", 0.05),
weapon_crit_multiplier=data.get("weapon_crit_multiplier", 2.0),
weapon_damage_type=weapon_damage_type,
elemental_damage_type=elemental_damage_type,
physical_ratio=data.get("physical_ratio", 1.0),
elemental_ratio=data.get("elemental_ratio", 0.0),
) )
@@ -312,14 +349,32 @@ class CombatEncounter:
return None return None
def advance_turn(self) -> None: def advance_turn(self) -> None:
"""Advance to the next combatant's turn.""" """Advance to the next alive combatant's turn, skipping dead combatants."""
self.current_turn_index += 1 # Track starting position to detect full cycle
start_index = self.current_turn_index
rounds_advanced = 0
# If we've cycled through all combatants, start a new round while True:
if self.current_turn_index >= len(self.turn_order): self.current_turn_index += 1
self.current_turn_index = 0
self.round_number += 1 # If we've cycled through all combatants, start a new round
self.log_action("round_start", None, f"Round {self.round_number} begins") if self.current_turn_index >= len(self.turn_order):
self.current_turn_index = 0
self.round_number += 1
rounds_advanced += 1
self.log_action("round_start", None, f"Round {self.round_number} begins")
# Get the current combatant
current = self.get_current_combatant()
# If combatant is alive, their turn starts
if current and current.is_alive():
break
# Safety check: if we've gone through all combatants twice without finding
# someone alive, break to avoid infinite loop (combat should end)
if rounds_advanced >= 2:
break
def start_turn(self) -> List[Dict[str, Any]]: def start_turn(self) -> List[Dict[str, Any]]:
""" """

View File

@@ -86,7 +86,12 @@ class Effect:
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
# Buff/Debuff: modify stats # Buff/Debuff: modify stats
result["stat_affected"] = self.stat_affected.value if self.stat_affected else None # Handle stat_affected being Enum or string
if self.stat_affected:
stat_value = self.stat_affected.value if hasattr(self.stat_affected, 'value') else self.stat_affected
else:
stat_value = None
result["stat_affected"] = stat_value
result["stat_modifier"] = self.power * self.stacks result["stat_modifier"] = self.power * self.stacks
if self.effect_type == EffectType.BUFF: if self.effect_type == EffectType.BUFF:
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}" result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
@@ -159,9 +164,17 @@ class Effect:
Dictionary containing all effect data Dictionary containing all effect data
""" """
data = asdict(self) data = asdict(self)
data["effect_type"] = self.effect_type.value # Handle effect_type (could be Enum or string)
if hasattr(self.effect_type, 'value'):
data["effect_type"] = self.effect_type.value
else:
data["effect_type"] = self.effect_type
# Handle stat_affected (could be Enum, string, or None)
if self.stat_affected: if self.stat_affected:
data["stat_affected"] = self.stat_affected.value if hasattr(self.stat_affected, 'value'):
data["stat_affected"] = self.stat_affected.value
else:
data["stat_affected"] = self.stat_affected
return data return data
@classmethod @classmethod
@@ -193,16 +206,21 @@ class Effect:
def __repr__(self) -> str: def __repr__(self) -> str:
"""String representation of the effect.""" """String representation of the effect."""
# Helper to safely get value from Enum or string
def safe_value(obj):
return obj.value if hasattr(obj, 'value') else obj
if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]: if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
stat_str = safe_value(self.stat_affected) if self.stat_affected else 'N/A'
return ( return (
f"Effect({self.name}, {self.effect_type.value}, " f"Effect({self.name}, {safe_value(self.effect_type)}, "
f"{self.stat_affected.value if self.stat_affected else 'N/A'} " f"{stat_str} "
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, " f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
f"{self.duration}t, {self.stacks}x)" f"{self.duration}t, {self.stacks}x)"
) )
else: else:
return ( return (
f"Effect({self.name}, {self.effect_type.value}, " f"Effect({self.name}, {safe_value(self.effect_type)}, "
f"power={self.power * self.stacks}, " f"power={self.power * self.stacks}, "
f"duration={self.duration}t, stacks={self.stacks}x)" f"duration={self.duration}t, stacks={self.stacks}x)"
) )

282
api/app/models/enemy.py Normal file
View File

@@ -0,0 +1,282 @@
"""
Enemy data models for combat encounters.
This module defines the EnemyTemplate dataclass representing enemies/monsters
that can be encountered in combat. Enemy definitions are loaded from YAML files
for data-driven game design.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional, Tuple
from enum import Enum
from app.models.stats import Stats
class EnemyDifficulty(Enum):
"""Enemy difficulty levels for scaling and encounter building."""
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
BOSS = "boss"
class LootType(Enum):
"""
Types of loot drops in enemy loot tables.
STATIC: Fixed item_id reference (consumables, quest items, materials)
PROCEDURAL: Procedurally generated equipment (weapons, armor with affixes)
"""
STATIC = "static"
PROCEDURAL = "procedural"
@dataclass
class LootEntry:
"""
Single entry in an enemy's loot table.
Supports two types of loot:
STATIC loot (default):
- item_id references a predefined item (health_potion, gold_coin, etc.)
- quantity_min/max define stack size
PROCEDURAL loot:
- item_type specifies "weapon" or "armor"
- rarity_bonus adds to rarity roll (difficulty contribution)
- Generated equipment uses the ItemGenerator system
Attributes:
loot_type: Type of loot (static or procedural)
drop_chance: Probability of dropping (0.0 to 1.0)
quantity_min: Minimum quantity if dropped
quantity_max: Maximum quantity if dropped
item_id: Reference to item definition (for STATIC loot)
item_type: Type of equipment to generate (for PROCEDURAL loot)
rarity_bonus: Added to rarity roll (0.0 to 0.5, for PROCEDURAL)
"""
# Common fields
loot_type: LootType = LootType.STATIC
drop_chance: float = 0.1
quantity_min: int = 1
quantity_max: int = 1
# Static loot fields
item_id: Optional[str] = None
# Procedural loot fields
item_type: Optional[str] = None # "weapon" or "armor"
rarity_bonus: float = 0.0 # Added to rarity roll (0.0 to 0.5)
def to_dict(self) -> Dict[str, Any]:
"""Serialize loot entry to dictionary."""
data = {
"loot_type": self.loot_type.value,
"drop_chance": self.drop_chance,
"quantity_min": self.quantity_min,
"quantity_max": self.quantity_max,
}
# Only include relevant fields based on loot type
if self.item_id is not None:
data["item_id"] = self.item_id
if self.item_type is not None:
data["item_type"] = self.item_type
data["rarity_bonus"] = self.rarity_bonus
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry':
"""
Deserialize loot entry from dictionary.
Backward compatible: entries without loot_type default to STATIC,
and item_id is required for STATIC entries (for backward compat).
"""
# Parse loot type with backward compatibility
loot_type_str = data.get("loot_type", "static")
loot_type = LootType(loot_type_str)
return cls(
loot_type=loot_type,
drop_chance=data.get("drop_chance", 0.1),
quantity_min=data.get("quantity_min", 1),
quantity_max=data.get("quantity_max", 1),
item_id=data.get("item_id"),
item_type=data.get("item_type"),
rarity_bonus=data.get("rarity_bonus", 0.0),
)
@dataclass
class EnemyTemplate:
"""
Template definition for an enemy type.
EnemyTemplates define the base characteristics of enemy types. When combat
starts, instances are created from templates with randomized variations.
Attributes:
enemy_id: Unique identifier (e.g., "goblin", "dire_wolf")
name: Display name (e.g., "Goblin Scout")
description: Flavor text about the enemy
base_stats: Base stat block for this enemy
abilities: List of ability_ids this enemy can use
loot_table: Potential drops on defeat
experience_reward: Base XP granted on defeat
gold_reward_min: Minimum gold dropped
gold_reward_max: Maximum gold dropped
difficulty: Difficulty classification for encounter building
tags: Classification tags (e.g., ["humanoid", "goblinoid"])
location_tags: Location types where this enemy appears (e.g., ["forest", "dungeon"])
image_url: Optional image reference for UI
Combat-specific attributes:
base_damage: Base damage for basic attack (no weapon)
crit_chance: Critical hit chance (0.0 to 1.0)
flee_chance: Chance to successfully flee from this enemy
"""
enemy_id: str
name: str
description: str
base_stats: Stats
abilities: List[str] = field(default_factory=list)
loot_table: List[LootEntry] = field(default_factory=list)
experience_reward: int = 10
gold_reward_min: int = 1
gold_reward_max: int = 5
difficulty: EnemyDifficulty = EnemyDifficulty.EASY
tags: List[str] = field(default_factory=list)
location_tags: List[str] = field(default_factory=list)
image_url: Optional[str] = None
# Combat attributes
base_damage: int = 5
crit_chance: float = 0.05
flee_chance: float = 0.5
def get_gold_reward(self) -> int:
"""
Roll random gold reward within range.
Returns:
Random gold amount between min and max
"""
import random
return random.randint(self.gold_reward_min, self.gold_reward_max)
def roll_loot(self) -> List[Dict[str, Any]]:
"""
Roll for loot drops based on loot table.
Returns:
List of dropped items with quantities
"""
import random
drops = []
for entry in self.loot_table:
if random.random() < entry.drop_chance:
quantity = random.randint(entry.quantity_min, entry.quantity_max)
drops.append({
"item_id": entry.item_id,
"quantity": quantity,
})
return drops
def is_boss(self) -> bool:
"""Check if this enemy is a boss."""
return self.difficulty == EnemyDifficulty.BOSS
def has_tag(self, tag: str) -> bool:
"""Check if enemy has a specific tag."""
return tag.lower() in [t.lower() for t in self.tags]
def has_location_tag(self, location_type: str) -> bool:
"""Check if enemy can appear at a specific location type."""
return location_type.lower() in [t.lower() for t in self.location_tags]
def to_dict(self) -> Dict[str, Any]:
"""
Serialize enemy template to dictionary.
Returns:
Dictionary containing all enemy data
"""
return {
"enemy_id": self.enemy_id,
"name": self.name,
"description": self.description,
"base_stats": self.base_stats.to_dict(),
"abilities": self.abilities,
"loot_table": [entry.to_dict() for entry in self.loot_table],
"experience_reward": self.experience_reward,
"gold_reward_min": self.gold_reward_min,
"gold_reward_max": self.gold_reward_max,
"difficulty": self.difficulty.value,
"tags": self.tags,
"location_tags": self.location_tags,
"image_url": self.image_url,
"base_damage": self.base_damage,
"crit_chance": self.crit_chance,
"flee_chance": self.flee_chance,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'EnemyTemplate':
"""
Deserialize enemy template from dictionary.
Args:
data: Dictionary containing enemy data (from YAML or JSON)
Returns:
EnemyTemplate instance
"""
# Parse base stats
stats_data = data.get("base_stats", {})
base_stats = Stats.from_dict(stats_data)
# Parse loot table
loot_table = [
LootEntry.from_dict(entry)
for entry in data.get("loot_table", [])
]
# Parse difficulty
difficulty_value = data.get("difficulty", "easy")
if isinstance(difficulty_value, str):
difficulty = EnemyDifficulty(difficulty_value)
else:
difficulty = difficulty_value
return cls(
enemy_id=data["enemy_id"],
name=data["name"],
description=data.get("description", ""),
base_stats=base_stats,
abilities=data.get("abilities", []),
loot_table=loot_table,
experience_reward=data.get("experience_reward", 10),
gold_reward_min=data.get("gold_reward_min", 1),
gold_reward_max=data.get("gold_reward_max", 5),
difficulty=difficulty,
tags=data.get("tags", []),
location_tags=data.get("location_tags", []),
image_url=data.get("image_url"),
base_damage=data.get("base_damage", 5),
crit_chance=data.get("crit_chance", 0.05),
flee_chance=data.get("flee_chance", 0.5),
)
def __repr__(self) -> str:
"""String representation of the enemy template."""
return (
f"EnemyTemplate({self.enemy_id}, {self.name}, "
f"difficulty={self.difficulty.value}, "
f"xp={self.experience_reward})"
)

View File

@@ -29,6 +29,7 @@ class DamageType(Enum):
HOLY = "holy" # Holy/divine damage HOLY = "holy" # Holy/divine damage
SHADOW = "shadow" # Dark/shadow magic damage SHADOW = "shadow" # Dark/shadow magic damage
POISON = "poison" # Poison damage (usually DoT) POISON = "poison" # Poison damage (usually DoT)
ARCANE = "arcane" # Pure magical damage (staves, wands)
class ItemType(Enum): class ItemType(Enum):
@@ -40,6 +41,31 @@ class ItemType(Enum):
QUEST_ITEM = "quest_item" # Story-related, non-tradeable QUEST_ITEM = "quest_item" # Story-related, non-tradeable
class ItemRarity(Enum):
"""Item rarity tiers affecting drop rates, value, and visual styling."""
COMMON = "common" # White/gray - basic items
UNCOMMON = "uncommon" # Green - slightly better
RARE = "rare" # Blue - noticeably better
EPIC = "epic" # Purple - powerful items
LEGENDARY = "legendary" # Orange/gold - best items
class AffixType(Enum):
"""Types of item affixes for procedural item generation."""
PREFIX = "prefix" # Appears before item name: "Flaming Dagger"
SUFFIX = "suffix" # Appears after item name: "Dagger of Strength"
class AffixTier(Enum):
"""Affix power tiers determining bonus magnitudes."""
MINOR = "minor" # Weaker bonuses, rolls on RARE items
MAJOR = "major" # Medium bonuses, rolls on EPIC items
LEGENDARY = "legendary" # Strongest bonuses, LEGENDARY only
class StatType(Enum): class StatType(Enum):
"""Character attribute types.""" """Character attribute types."""
@@ -49,6 +75,7 @@ class StatType(Enum):
INTELLIGENCE = "intelligence" # Magical power INTELLIGENCE = "intelligence" # Magical power
WISDOM = "wisdom" # Perception and insight WISDOM = "wisdom" # Perception and insight
CHARISMA = "charisma" # Social influence CHARISMA = "charisma" # Social influence
LUCK = "luck" # Fortune and fate
class AbilityType(Enum): class AbilityType(Enum):

View File

@@ -8,7 +8,7 @@ including weapons, armor, consumables, and quest items.
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from app.models.enums import ItemType, DamageType from app.models.enums import ItemType, ItemRarity, DamageType
from app.models.effects import Effect from app.models.effects import Effect
@@ -24,6 +24,7 @@ class Item:
item_id: Unique identifier item_id: Unique identifier
name: Display name name: Display name
item_type: Category (weapon, armor, consumable, quest_item) item_type: Category (weapon, armor, consumable, quest_item)
rarity: Rarity tier (common, uncommon, rare, epic, legendary)
description: Item lore and information description: Item lore and information
value: Gold value for buying/selling value: Gold value for buying/selling
is_tradeable: Whether item can be sold on marketplace is_tradeable: Whether item can be sold on marketplace
@@ -32,7 +33,8 @@ class Item:
effects_on_use: Effects applied when consumed (consumables only) effects_on_use: Effects applied when consumed (consumables only)
Weapon-specific attributes: Weapon-specific attributes:
damage: Base weapon damage damage: Base weapon damage (physical/melee/ranged)
spell_power: Spell power for staves/wands (boosts spell damage)
damage_type: Type of damage (physical, fire, etc.) damage_type: Type of damage (physical, fire, etc.)
crit_chance: Probability of critical hit (0.0 to 1.0) crit_chance: Probability of critical hit (0.0 to 1.0)
crit_multiplier: Damage multiplier on critical hit crit_multiplier: Damage multiplier on critical hit
@@ -49,7 +51,8 @@ class Item:
item_id: str item_id: str
name: str name: str
item_type: ItemType item_type: ItemType
description: str rarity: ItemRarity = ItemRarity.COMMON
description: str = ""
value: int = 0 value: int = 0
is_tradeable: bool = True is_tradeable: bool = True
@@ -60,11 +63,18 @@ class Item:
effects_on_use: List[Effect] = field(default_factory=list) effects_on_use: List[Effect] = field(default_factory=list)
# Weapon-specific # Weapon-specific
damage: int = 0 damage: int = 0 # Physical damage for melee/ranged weapons
spell_power: int = 0 # Spell power for staves/wands (boosts spell damage)
damage_type: Optional[DamageType] = None damage_type: Optional[DamageType] = None
crit_chance: float = 0.05 # 5% default critical hit chance crit_chance: float = 0.05 # 5% default critical hit chance
crit_multiplier: float = 2.0 # 2x damage on critical hit crit_multiplier: float = 2.0 # 2x damage on critical hit
# Elemental weapon properties (for split damage like Fire Sword)
# These enable weapons to deal both physical AND elemental damage
elemental_damage_type: Optional[DamageType] = None # Secondary damage type (fire, ice, etc.)
physical_ratio: float = 1.0 # Portion of damage that is physical (0.0-1.0)
elemental_ratio: float = 0.0 # Portion of damage that is elemental (0.0-1.0)
# Armor-specific # Armor-specific
defense: int = 0 defense: int = 0
resistance: int = 0 resistance: int = 0
@@ -73,6 +83,24 @@ class Item:
required_level: int = 1 required_level: int = 1
required_class: Optional[str] = None required_class: Optional[str] = None
# Affix tracking (for procedurally generated items)
applied_affixes: List[str] = field(default_factory=list) # List of affix_ids
base_template_id: Optional[str] = None # ID of base item template used
generated_name: Optional[str] = None # Full generated name with affixes
is_generated: bool = False # True if created by item generator
def get_display_name(self) -> str:
"""
Get the item's display name.
For generated items, returns the affix-enhanced name.
For static items, returns the base name.
Returns:
Display name string
"""
return self.generated_name or self.name
def is_weapon(self) -> bool: def is_weapon(self) -> bool:
"""Check if this item is a weapon.""" """Check if this item is a weapon."""
return self.item_type == ItemType.WEAPON return self.item_type == ItemType.WEAPON
@@ -89,6 +117,39 @@ class Item:
"""Check if this item is a quest item.""" """Check if this item is a quest item."""
return self.item_type == ItemType.QUEST_ITEM return self.item_type == ItemType.QUEST_ITEM
def is_elemental_weapon(self) -> bool:
"""
Check if this weapon deals elemental damage (split damage).
Elemental weapons deal both physical AND elemental damage,
calculated separately against DEF and RES.
Examples:
Fire Sword: 70% physical / 30% fire
Frost Blade: 60% physical / 40% ice
Lightning Spear: 50% physical / 50% lightning
Returns:
True if weapon has elemental damage component
"""
return (
self.is_weapon() and
self.elemental_ratio > 0.0 and
self.elemental_damage_type is not None
)
def is_magical_weapon(self) -> bool:
"""
Check if this weapon is a spell-casting weapon (staff, wand, tome).
Magical weapons provide spell_power which boosts spell damage,
rather than physical damage for melee/ranged attacks.
Returns:
True if weapon has spell_power (staves, wands, etc.)
"""
return self.is_weapon() and self.spell_power > 0
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool: def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
""" """
Check if a character can equip this item. Check if a character can equip this item.
@@ -131,9 +192,14 @@ class Item:
""" """
data = asdict(self) data = asdict(self)
data["item_type"] = self.item_type.value data["item_type"] = self.item_type.value
data["rarity"] = self.rarity.value
if self.damage_type: if self.damage_type:
data["damage_type"] = self.damage_type.value data["damage_type"] = self.damage_type.value
if self.elemental_damage_type:
data["elemental_damage_type"] = self.elemental_damage_type.value
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use] data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
# Include display_name for convenience
data["display_name"] = self.get_display_name()
return data return data
@classmethod @classmethod
@@ -149,7 +215,13 @@ class Item:
""" """
# Convert string values back to enums # Convert string values back to enums
item_type = ItemType(data["item_type"]) item_type = ItemType(data["item_type"])
rarity = ItemRarity(data.get("rarity", "common"))
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
elemental_damage_type = (
DamageType(data["elemental_damage_type"])
if data.get("elemental_damage_type")
else None
)
# Deserialize effects # Deserialize effects
effects = [] effects = []
@@ -160,7 +232,8 @@ class Item:
item_id=data["item_id"], item_id=data["item_id"],
name=data["name"], name=data["name"],
item_type=item_type, item_type=item_type,
description=data["description"], rarity=rarity,
description=data.get("description", ""),
value=data.get("value", 0), value=data.get("value", 0),
is_tradeable=data.get("is_tradeable", True), is_tradeable=data.get("is_tradeable", True),
stat_bonuses=data.get("stat_bonuses", {}), stat_bonuses=data.get("stat_bonuses", {}),
@@ -169,15 +242,29 @@ class Item:
damage_type=damage_type, damage_type=damage_type,
crit_chance=data.get("crit_chance", 0.05), crit_chance=data.get("crit_chance", 0.05),
crit_multiplier=data.get("crit_multiplier", 2.0), crit_multiplier=data.get("crit_multiplier", 2.0),
elemental_damage_type=elemental_damage_type,
physical_ratio=data.get("physical_ratio", 1.0),
elemental_ratio=data.get("elemental_ratio", 0.0),
defense=data.get("defense", 0), defense=data.get("defense", 0),
resistance=data.get("resistance", 0), resistance=data.get("resistance", 0),
required_level=data.get("required_level", 1), required_level=data.get("required_level", 1),
required_class=data.get("required_class"), required_class=data.get("required_class"),
# Affix tracking fields
applied_affixes=data.get("applied_affixes", []),
base_template_id=data.get("base_template_id"),
generated_name=data.get("generated_name"),
is_generated=data.get("is_generated", False),
) )
def __repr__(self) -> str: def __repr__(self) -> str:
"""String representation of the item.""" """String representation of the item."""
if self.is_weapon(): if self.is_weapon():
if self.is_elemental_weapon():
return (
f"Item({self.name}, elemental_weapon, dmg={self.damage}, "
f"phys={self.physical_ratio:.0%}/{self.elemental_damage_type.value}={self.elemental_ratio:.0%}, "
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
)
return ( return (
f"Item({self.name}, weapon, dmg={self.damage}, " f"Item({self.name}, weapon, dmg={self.damage}, "
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)" f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"

View File

@@ -296,6 +296,7 @@ class NPC:
location_id: str location_id: str
personality: NPCPersonality personality: NPCPersonality
appearance: NPCAppearance appearance: NPCAppearance
image_url: Optional[str] = None
knowledge: Optional[NPCKnowledge] = None knowledge: Optional[NPCKnowledge] = None
relationships: List[NPCRelationship] = field(default_factory=list) relationships: List[NPCRelationship] = field(default_factory=list)
inventory_for_sale: List[NPCInventoryItem] = field(default_factory=list) inventory_for_sale: List[NPCInventoryItem] = field(default_factory=list)
@@ -316,6 +317,7 @@ class NPC:
"name": self.name, "name": self.name,
"role": self.role, "role": self.role,
"location_id": self.location_id, "location_id": self.location_id,
"image_url": self.image_url,
"personality": self.personality.to_dict(), "personality": self.personality.to_dict(),
"appearance": self.appearance.to_dict(), "appearance": self.appearance.to_dict(),
"knowledge": self.knowledge.to_dict() if self.knowledge else None, "knowledge": self.knowledge.to_dict() if self.knowledge else None,
@@ -400,6 +402,7 @@ class NPC:
name=data["name"], name=data["name"],
role=data["role"], role=data["role"],
location_id=data["location_id"], location_id=data["location_id"],
image_url=data.get("image_url"),
personality=personality, personality=personality,
appearance=appearance, appearance=appearance,
knowledge=knowledge, knowledge=knowledge,

View File

@@ -167,7 +167,8 @@ class GameSession:
user_id: Owner of the session user_id: Owner of the session
party_member_ids: Character IDs in this party (multiplayer only) party_member_ids: Character IDs in this party (multiplayer only)
config: Session configuration settings config: Session configuration settings
combat_encounter: Current combat (None if not in combat) combat_encounter: Legacy inline combat data (None if not in combat)
active_combat_encounter_id: Reference to combat_encounters table (new system)
conversation_history: Turn-by-turn log of actions and DM responses conversation_history: Turn-by-turn log of actions and DM responses
game_state: Current world/quest state game_state: Current world/quest state
turn_order: Character turn order turn_order: Character turn order
@@ -184,7 +185,8 @@ class GameSession:
user_id: str = "" user_id: str = ""
party_member_ids: List[str] = field(default_factory=list) party_member_ids: List[str] = field(default_factory=list)
config: SessionConfig = field(default_factory=SessionConfig) config: SessionConfig = field(default_factory=SessionConfig)
combat_encounter: Optional[CombatEncounter] = None combat_encounter: Optional[CombatEncounter] = None # Legacy: inline combat data
active_combat_encounter_id: Optional[str] = None # New: reference to combat_encounters table
conversation_history: List[ConversationEntry] = field(default_factory=list) conversation_history: List[ConversationEntry] = field(default_factory=list)
game_state: GameState = field(default_factory=GameState) game_state: GameState = field(default_factory=GameState)
turn_order: List[str] = field(default_factory=list) turn_order: List[str] = field(default_factory=list)
@@ -202,8 +204,13 @@ class GameSession:
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def is_in_combat(self) -> bool: def is_in_combat(self) -> bool:
"""Check if session is currently in combat.""" """
return self.combat_encounter is not None Check if session is currently in combat.
Checks both the new database reference and legacy inline storage
for backward compatibility.
"""
return self.active_combat_encounter_id is not None or self.combat_encounter is not None
def start_combat(self, encounter: CombatEncounter) -> None: def start_combat(self, encounter: CombatEncounter) -> None:
""" """
@@ -341,6 +348,7 @@ class GameSession:
"party_member_ids": self.party_member_ids, "party_member_ids": self.party_member_ids,
"config": self.config.to_dict(), "config": self.config.to_dict(),
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None, "combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None,
"active_combat_encounter_id": self.active_combat_encounter_id,
"conversation_history": [entry.to_dict() for entry in self.conversation_history], "conversation_history": [entry.to_dict() for entry in self.conversation_history],
"game_state": self.game_state.to_dict(), "game_state": self.game_state.to_dict(),
"turn_order": self.turn_order, "turn_order": self.turn_order,
@@ -382,6 +390,7 @@ class GameSession:
party_member_ids=data.get("party_member_ids", []), party_member_ids=data.get("party_member_ids", []),
config=config, config=config,
combat_encounter=combat_encounter, combat_encounter=combat_encounter,
active_combat_encounter_id=data.get("active_combat_encounter_id"),
conversation_history=conversation_history, conversation_history=conversation_history,
game_state=game_state, game_state=game_state,
turn_order=data.get("turn_order", []), turn_order=data.get("turn_order", []),

View File

@@ -21,12 +21,19 @@ class Stats:
intelligence: Magical power, affects spell damage and MP intelligence: Magical power, affects spell damage and MP
wisdom: Perception and insight, affects magical resistance wisdom: Perception and insight, affects magical resistance
charisma: Social influence, affects NPC interactions charisma: Social influence, affects NPC interactions
luck: Fortune and fate, affects critical hits, loot, and random outcomes
damage_bonus: Flat damage bonus from equipped weapons (default 0)
spell_power_bonus: Flat spell power bonus from staves/wands (default 0)
defense_bonus: Flat defense bonus from equipped armor (default 0)
resistance_bonus: Flat resistance bonus from equipped armor (default 0)
Computed Properties: Computed Properties:
hit_points: Maximum HP = 10 + (constitution × 2) hit_points: Maximum HP = 10 + (constitution × 2)
mana_points: Maximum MP = 10 + (intelligence × 2) mana_points: Maximum MP = 10 + (intelligence × 2)
defense: Physical defense = constitution // 2 damage: Physical damage = int(strength × 0.75) + damage_bonus
resistance: Magical resistance = wisdom // 2 spell_power: Spell power = int(intelligence × 0.75) + spell_power_bonus
defense: Physical defense = (constitution // 2) + defense_bonus
resistance: Magical resistance = (wisdom // 2) + resistance_bonus
""" """
strength: int = 10 strength: int = 10
@@ -35,6 +42,13 @@ class Stats:
intelligence: int = 10 intelligence: int = 10
wisdom: int = 10 wisdom: int = 10
charisma: int = 10 charisma: int = 10
luck: int = 8
# Equipment bonus fields (populated by get_effective_stats())
damage_bonus: int = 0 # From weapons (physical damage)
spell_power_bonus: int = 0 # From staves/wands (magical damage)
defense_bonus: int = 0 # From armor
resistance_bonus: int = 0 # From armor
@property @property
def hit_points(self) -> int: def hit_points(self) -> int:
@@ -60,29 +74,122 @@ class Stats:
""" """
return 10 + (self.intelligence * 2) return 10 + (self.intelligence * 2)
@property
def damage(self) -> int:
"""
Calculate total physical damage from strength and equipment.
Formula: int(strength * 0.75) + damage_bonus
The damage_bonus comes from equipped weapons and is populated
by Character.get_effective_stats().
Returns:
Total physical damage value
"""
return int(self.strength * 0.75) + self.damage_bonus
@property
def spell_power(self) -> int:
"""
Calculate spell power from intelligence and equipment.
Formula: int(intelligence * 0.75) + spell_power_bonus
The spell_power_bonus comes from equipped staves/wands and is
populated by Character.get_effective_stats().
Returns:
Total spell power value
"""
return int(self.intelligence * 0.75) + self.spell_power_bonus
@property @property
def defense(self) -> int: def defense(self) -> int:
""" """
Calculate physical defense from constitution. Calculate physical defense from constitution and equipment.
Formula: constitution // 2 Formula: (constitution // 2) + defense_bonus
The defense_bonus comes from equipped armor and is populated
by Character.get_effective_stats().
Returns: Returns:
Physical defense value (damage reduction) Physical defense value (damage reduction)
""" """
return self.constitution // 2 return (self.constitution // 2) + self.defense_bonus
@property @property
def resistance(self) -> int: def resistance(self) -> int:
""" """
Calculate magical resistance from wisdom. Calculate magical resistance from wisdom and equipment.
Formula: wisdom // 2 Formula: (wisdom // 2) + resistance_bonus
The resistance_bonus comes from equipped armor and is populated
by Character.get_effective_stats().
Returns: Returns:
Magical resistance value (spell damage reduction) Magical resistance value (spell damage reduction)
""" """
return self.wisdom // 2 return (self.wisdom // 2) + self.resistance_bonus
@property
def crit_bonus(self) -> float:
"""
Calculate critical hit chance bonus from luck.
Formula: luck * 0.5% (0.005)
This bonus is added to the weapon's base crit chance.
The total crit chance is capped at 25% in the DamageCalculator.
Returns:
Crit chance bonus as a decimal (e.g., 0.04 for LUK 8)
Examples:
LUK 8: 0.04 (4% bonus)
LUK 12: 0.06 (6% bonus)
"""
return self.luck * 0.005
@property
def hit_bonus(self) -> float:
"""
Calculate hit chance bonus (miss reduction) from luck.
Formula: luck * 0.5% (0.005)
This reduces the base 10% miss chance. The minimum miss
chance is hard capped at 5% to prevent frustration.
Returns:
Miss reduction as a decimal (e.g., 0.04 for LUK 8)
Examples:
LUK 8: 0.04 (reduces miss from 10% to 6%)
LUK 12: 0.06 (reduces miss from 10% to 4%, capped at 5%)
"""
return self.luck * 0.005
@property
def lucky_roll_chance(self) -> float:
"""
Calculate chance for a "lucky" high damage variance roll.
Formula: 5% + (luck * 0.25%)
When triggered, damage variance uses 100%-110% instead of 95%-105%.
This gives LUK characters more frequent high damage rolls.
Returns:
Lucky roll chance as a decimal
Examples:
LUK 8: 0.07 (7% chance for lucky roll)
LUK 12: 0.08 (8% chance for lucky roll)
"""
return 0.05 + (self.luck * 0.0025)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
""" """
@@ -111,6 +218,11 @@ class Stats:
intelligence=data.get("intelligence", 10), intelligence=data.get("intelligence", 10),
wisdom=data.get("wisdom", 10), wisdom=data.get("wisdom", 10),
charisma=data.get("charisma", 10), charisma=data.get("charisma", 10),
luck=data.get("luck", 8),
damage_bonus=data.get("damage_bonus", 0),
spell_power_bonus=data.get("spell_power_bonus", 0),
defense_bonus=data.get("defense_bonus", 0),
resistance_bonus=data.get("resistance_bonus", 0),
) )
def copy(self) -> 'Stats': def copy(self) -> 'Stats':
@@ -127,6 +239,11 @@ class Stats:
intelligence=self.intelligence, intelligence=self.intelligence,
wisdom=self.wisdom, wisdom=self.wisdom,
charisma=self.charisma, charisma=self.charisma,
luck=self.luck,
damage_bonus=self.damage_bonus,
spell_power_bonus=self.spell_power_bonus,
defense_bonus=self.defense_bonus,
resistance_bonus=self.resistance_bonus,
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@@ -134,7 +251,9 @@ class Stats:
return ( return (
f"Stats(STR={self.strength}, DEX={self.dexterity}, " f"Stats(STR={self.strength}, DEX={self.dexterity}, "
f"CON={self.constitution}, INT={self.intelligence}, " f"CON={self.constitution}, INT={self.intelligence}, "
f"WIS={self.wisdom}, CHA={self.charisma}, " f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, "
f"HP={self.hit_points}, MP={self.mana_points}, " f"HP={self.hit_points}, MP={self.mana_points}, "
f"DEF={self.defense}, RES={self.resistance})" f"DMG={self.damage}, SP={self.spell_power}, "
f"DEF={self.defense}, RES={self.resistance}, "
f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})"
) )

View File

@@ -0,0 +1,315 @@
"""
Affix Loader Service - YAML-based affix pool loading.
This service loads prefix and suffix affix definitions from YAML files,
providing a data-driven approach to item generation.
"""
from pathlib import Path
from typing import Dict, List, Optional
import random
import yaml
from app.models.affixes import Affix
from app.models.enums import AffixType, AffixTier
from app.utils.logging import get_logger
logger = get_logger(__file__)
class AffixLoader:
"""
Loads and manages item affixes from YAML configuration files.
This allows game designers to define affixes without touching code.
Affixes are organized into prefixes.yaml and suffixes.yaml files.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the affix loader.
Args:
data_dir: Path to directory containing affix YAML files
Defaults to /app/data/affixes/
"""
if data_dir is None:
# Default to app/data/affixes relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "affixes")
self.data_dir = Path(data_dir)
self._prefix_cache: Dict[str, Affix] = {}
self._suffix_cache: Dict[str, Affix] = {}
self._loaded = False
logger.info("AffixLoader initialized", data_dir=str(self.data_dir))
def _ensure_loaded(self) -> None:
"""Ensure affixes are loaded before any operation."""
if not self._loaded:
self.load_all()
def load_all(self) -> None:
"""Load all affixes from YAML files."""
if not self.data_dir.exists():
logger.warning("Affix data directory not found", path=str(self.data_dir))
return
# Load prefixes
prefixes_file = self.data_dir / "prefixes.yaml"
if prefixes_file.exists():
self._load_affixes_from_file(prefixes_file, self._prefix_cache)
# Load suffixes
suffixes_file = self.data_dir / "suffixes.yaml"
if suffixes_file.exists():
self._load_affixes_from_file(suffixes_file, self._suffix_cache)
self._loaded = True
logger.info(
"Affixes loaded",
prefix_count=len(self._prefix_cache),
suffix_count=len(self._suffix_cache)
)
def _load_affixes_from_file(
self,
yaml_file: Path,
cache: Dict[str, Affix]
) -> None:
"""
Load affixes from a YAML file into the cache.
Args:
yaml_file: Path to the YAML file
cache: Cache dictionary to populate
"""
try:
with open(yaml_file, 'r') as f:
data = yaml.safe_load(f)
# Get the top-level key (prefixes or suffixes)
affix_key = "prefixes" if "prefixes" in data else "suffixes"
affixes_data = data.get(affix_key, {})
for affix_id, affix_data in affixes_data.items():
# Ensure affix_id is set
affix_data["affix_id"] = affix_id
# Set defaults for missing optional fields
affix_data.setdefault("stat_bonuses", {})
affix_data.setdefault("defense_bonus", 0)
affix_data.setdefault("resistance_bonus", 0)
affix_data.setdefault("damage_bonus", 0)
affix_data.setdefault("elemental_ratio", 0.0)
affix_data.setdefault("crit_chance_bonus", 0.0)
affix_data.setdefault("crit_multiplier_bonus", 0.0)
affix_data.setdefault("allowed_item_types", [])
affix_data.setdefault("required_rarity", None)
affix = Affix.from_dict(affix_data)
cache[affix.affix_id] = affix
logger.debug(
"Affixes loaded from file",
file=str(yaml_file),
count=len(affixes_data)
)
except Exception as e:
logger.error(
"Failed to load affix file",
file=str(yaml_file),
error=str(e)
)
def get_affix(self, affix_id: str) -> Optional[Affix]:
"""
Get a specific affix by ID.
Args:
affix_id: Unique affix identifier
Returns:
Affix instance or None if not found
"""
self._ensure_loaded()
if affix_id in self._prefix_cache:
return self._prefix_cache[affix_id]
if affix_id in self._suffix_cache:
return self._suffix_cache[affix_id]
return None
def get_eligible_prefixes(
self,
item_type: str,
rarity: str,
tier: Optional[AffixTier] = None
) -> List[Affix]:
"""
Get all prefixes eligible for an item.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Item rarity ("rare", "epic", "legendary")
tier: Optional tier filter
Returns:
List of eligible Affix instances
"""
self._ensure_loaded()
eligible = []
for affix in self._prefix_cache.values():
# Check if affix can apply to this item
if not affix.can_apply_to(item_type, rarity):
continue
# Apply tier filter if specified
if tier and affix.tier != tier:
continue
eligible.append(affix)
return eligible
def get_eligible_suffixes(
self,
item_type: str,
rarity: str,
tier: Optional[AffixTier] = None
) -> List[Affix]:
"""
Get all suffixes eligible for an item.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Item rarity ("rare", "epic", "legendary")
tier: Optional tier filter
Returns:
List of eligible Affix instances
"""
self._ensure_loaded()
eligible = []
for affix in self._suffix_cache.values():
# Check if affix can apply to this item
if not affix.can_apply_to(item_type, rarity):
continue
# Apply tier filter if specified
if tier and affix.tier != tier:
continue
eligible.append(affix)
return eligible
def get_random_prefix(
self,
item_type: str,
rarity: str,
tier: Optional[AffixTier] = None,
exclude_ids: Optional[List[str]] = None
) -> Optional[Affix]:
"""
Get a random eligible prefix.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Item rarity
tier: Optional tier filter
exclude_ids: Affix IDs to exclude (for avoiding duplicates)
Returns:
Random eligible Affix or None if none available
"""
eligible = self.get_eligible_prefixes(item_type, rarity, tier)
# Filter out excluded IDs
if exclude_ids:
eligible = [a for a in eligible if a.affix_id not in exclude_ids]
if not eligible:
return None
return random.choice(eligible)
def get_random_suffix(
self,
item_type: str,
rarity: str,
tier: Optional[AffixTier] = None,
exclude_ids: Optional[List[str]] = None
) -> Optional[Affix]:
"""
Get a random eligible suffix.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Item rarity
tier: Optional tier filter
exclude_ids: Affix IDs to exclude (for avoiding duplicates)
Returns:
Random eligible Affix or None if none available
"""
eligible = self.get_eligible_suffixes(item_type, rarity, tier)
# Filter out excluded IDs
if exclude_ids:
eligible = [a for a in eligible if a.affix_id not in exclude_ids]
if not eligible:
return None
return random.choice(eligible)
def get_all_prefixes(self) -> Dict[str, Affix]:
"""
Get all cached prefixes.
Returns:
Dictionary of prefix affixes
"""
self._ensure_loaded()
return self._prefix_cache.copy()
def get_all_suffixes(self) -> Dict[str, Affix]:
"""
Get all cached suffixes.
Returns:
Dictionary of suffix affixes
"""
self._ensure_loaded()
return self._suffix_cache.copy()
def clear_cache(self) -> None:
"""Clear the affix cache, forcing reload on next access."""
self._prefix_cache.clear()
self._suffix_cache.clear()
self._loaded = False
logger.debug("Affix cache cleared")
# Global instance for convenience
_loader_instance: Optional[AffixLoader] = None
def get_affix_loader() -> AffixLoader:
"""
Get the global AffixLoader instance.
Returns:
Singleton AffixLoader instance
"""
global _loader_instance
if _loader_instance is None:
_loader_instance = AffixLoader()
return _loader_instance

View File

@@ -0,0 +1,274 @@
"""
Base Item Loader Service - YAML-based base item template loading.
This service loads base item templates (weapons, armor) from YAML files,
providing the foundation for procedural item generation.
"""
from pathlib import Path
from typing import Dict, List, Optional
import random
import yaml
from app.models.affixes import BaseItemTemplate
from app.utils.logging import get_logger
logger = get_logger(__file__)
# Rarity order for comparison
RARITY_ORDER = {
"common": 0,
"uncommon": 1,
"rare": 2,
"epic": 3,
"legendary": 4
}
class BaseItemLoader:
"""
Loads and manages base item templates from YAML configuration files.
This allows game designers to define base items without touching code.
Templates are organized into weapons.yaml and armor.yaml files.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the base item loader.
Args:
data_dir: Path to directory containing base item YAML files
Defaults to /app/data/base_items/
"""
if data_dir is None:
# Default to app/data/base_items relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "base_items")
self.data_dir = Path(data_dir)
self._weapon_cache: Dict[str, BaseItemTemplate] = {}
self._armor_cache: Dict[str, BaseItemTemplate] = {}
self._loaded = False
logger.info("BaseItemLoader initialized", data_dir=str(self.data_dir))
def _ensure_loaded(self) -> None:
"""Ensure templates are loaded before any operation."""
if not self._loaded:
self.load_all()
def load_all(self) -> None:
"""Load all base item templates from YAML files."""
if not self.data_dir.exists():
logger.warning("Base item data directory not found", path=str(self.data_dir))
return
# Load weapons
weapons_file = self.data_dir / "weapons.yaml"
if weapons_file.exists():
self._load_templates_from_file(weapons_file, "weapons", self._weapon_cache)
# Load armor
armor_file = self.data_dir / "armor.yaml"
if armor_file.exists():
self._load_templates_from_file(armor_file, "armor", self._armor_cache)
self._loaded = True
logger.info(
"Base item templates loaded",
weapon_count=len(self._weapon_cache),
armor_count=len(self._armor_cache)
)
def _load_templates_from_file(
self,
yaml_file: Path,
key: str,
cache: Dict[str, BaseItemTemplate]
) -> None:
"""
Load templates from a YAML file into the cache.
Args:
yaml_file: Path to the YAML file
key: Top-level key in YAML (e.g., "weapons", "armor")
cache: Cache dictionary to populate
"""
try:
with open(yaml_file, 'r') as f:
data = yaml.safe_load(f)
templates_data = data.get(key, {})
for template_id, template_data in templates_data.items():
# Ensure template_id is set
template_data["template_id"] = template_id
# Set defaults for missing optional fields
template_data.setdefault("description", "")
template_data.setdefault("base_damage", 0)
template_data.setdefault("base_spell_power", 0)
template_data.setdefault("base_defense", 0)
template_data.setdefault("base_resistance", 0)
template_data.setdefault("base_value", 10)
template_data.setdefault("damage_type", "physical")
template_data.setdefault("crit_chance", 0.05)
template_data.setdefault("crit_multiplier", 2.0)
template_data.setdefault("required_level", 1)
template_data.setdefault("drop_weight", 1.0)
template_data.setdefault("min_rarity", "common")
template = BaseItemTemplate.from_dict(template_data)
cache[template.template_id] = template
logger.debug(
"Templates loaded from file",
file=str(yaml_file),
count=len(templates_data)
)
except Exception as e:
logger.error(
"Failed to load base item file",
file=str(yaml_file),
error=str(e)
)
def get_template(self, template_id: str) -> Optional[BaseItemTemplate]:
"""
Get a specific template by ID.
Args:
template_id: Unique template identifier
Returns:
BaseItemTemplate instance or None if not found
"""
self._ensure_loaded()
if template_id in self._weapon_cache:
return self._weapon_cache[template_id]
if template_id in self._armor_cache:
return self._armor_cache[template_id]
return None
def get_eligible_templates(
self,
item_type: str,
rarity: str,
character_level: int = 1
) -> List[BaseItemTemplate]:
"""
Get all templates eligible for generation.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Target rarity
character_level: Player level for eligibility
Returns:
List of eligible BaseItemTemplate instances
"""
self._ensure_loaded()
# Select the appropriate cache
if item_type == "weapon":
cache = self._weapon_cache
elif item_type == "armor":
cache = self._armor_cache
else:
logger.warning("Unknown item type", item_type=item_type)
return []
eligible = []
for template in cache.values():
# Check level requirement
if not template.can_drop_for_level(character_level):
continue
# Check rarity requirement
if not template.can_generate_at_rarity(rarity):
continue
eligible.append(template)
return eligible
def get_random_template(
self,
item_type: str,
rarity: str,
character_level: int = 1
) -> Optional[BaseItemTemplate]:
"""
Get a random eligible template, weighted by drop_weight.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Target rarity
character_level: Player level for eligibility
Returns:
Random eligible BaseItemTemplate or None if none available
"""
eligible = self.get_eligible_templates(item_type, rarity, character_level)
if not eligible:
logger.warning(
"No templates match criteria",
item_type=item_type,
rarity=rarity,
level=character_level
)
return None
# Weighted random selection based on drop_weight
weights = [t.drop_weight for t in eligible]
return random.choices(eligible, weights=weights, k=1)[0]
def get_all_weapons(self) -> Dict[str, BaseItemTemplate]:
"""
Get all cached weapon templates.
Returns:
Dictionary of weapon templates
"""
self._ensure_loaded()
return self._weapon_cache.copy()
def get_all_armor(self) -> Dict[str, BaseItemTemplate]:
"""
Get all cached armor templates.
Returns:
Dictionary of armor templates
"""
self._ensure_loaded()
return self._armor_cache.copy()
def clear_cache(self) -> None:
"""Clear the template cache, forcing reload on next access."""
self._weapon_cache.clear()
self._armor_cache.clear()
self._loaded = False
logger.debug("Base item template cache cleared")
# Global instance for convenience
_loader_instance: Optional[BaseItemLoader] = None
def get_base_item_loader() -> BaseItemLoader:
"""
Get the global BaseItemLoader instance.
Returns:
Singleton BaseItemLoader instance
"""
global _loader_instance
if _loader_instance is None:
_loader_instance = BaseItemLoader()
return _loader_instance

View File

@@ -21,6 +21,7 @@ from app.services.database_service import get_database_service
from app.services.appwrite_service import AppwriteService from app.services.appwrite_service import AppwriteService
from app.services.class_loader import get_class_loader from app.services.class_loader import get_class_loader
from app.services.origin_service import get_origin_service from app.services.origin_service import get_origin_service
from app.services.static_item_loader import get_static_item_loader
from app.utils.logging import get_logger from app.utils.logging import get_logger
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -173,6 +174,23 @@ class CharacterService:
current_location=starting_location_id # Set starting location current_location=starting_location_id # Set starting location
) )
# Add starting equipment to inventory
if player_class.starting_equipment:
item_loader = get_static_item_loader()
for item_id in player_class.starting_equipment:
item = item_loader.get_item(item_id)
if item:
character.add_item(item)
logger.debug("Added starting equipment",
character_id=character_id,
item_id=item_id,
item_name=item.name)
else:
logger.warning("Starting equipment item not found",
character_id=character_id,
item_id=item_id,
class_id=class_id)
# Serialize character to JSON # Serialize character to JSON
character_dict = character.to_dict() character_dict = character.to_dict()
character_json = json.dumps(character_dict) character_json = json.dumps(character_dict)
@@ -334,7 +352,10 @@ class CharacterService:
def delete_character(self, character_id: str, user_id: str) -> bool: def delete_character(self, character_id: str, user_id: str) -> bool:
""" """
Delete a character (soft delete by marking inactive). Permanently delete a character from the database.
Also cleans up any game sessions associated with the character
to prevent orphaned sessions.
Args: Args:
character_id: Character ID character_id: Character ID
@@ -354,11 +375,20 @@ class CharacterService:
if not character: if not character:
raise CharacterNotFound(f"Character not found: {character_id}") raise CharacterNotFound(f"Character not found: {character_id}")
# Soft delete by marking inactive # Clean up associated sessions before deleting the character
self.db.update_document( # Local import to avoid circular dependency (session_service imports character_service)
from app.services.session_service import get_session_service
session_service = get_session_service()
deleted_sessions = session_service.delete_sessions_by_character(character_id)
if deleted_sessions > 0:
logger.info("Cleaned up sessions for deleted character",
character_id=character_id,
sessions_deleted=deleted_sessions)
# Hard delete - permanently remove from database
self.db.delete_document(
collection_id=self.collection_id, collection_id=self.collection_id,
document_id=character_id, document_id=character_id
data={'is_active': False}
) )
logger.info("Character deleted successfully", character_id=character_id) logger.info("Character deleted successfully", character_id=character_id)
@@ -982,7 +1012,14 @@ class CharacterService:
limit: int = 5 limit: int = 5
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
""" """
Get recent dialogue history with an NPC. Get recent dialogue history with an NPC from recent_messages cache.
This method reads from character.npc_interactions[npc_id].recent_messages
which contains the last 3 messages for quick AI context. For full conversation
history, use ChatMessageService.get_conversation_history().
Backward Compatibility: Falls back to dialogue_history if recent_messages
doesn't exist (for characters created before chat_messages system).
Args: Args:
character_id: Character ID character_id: Character ID
@@ -991,16 +1028,46 @@ class CharacterService:
limit: Maximum number of recent exchanges to return (default 5) limit: Maximum number of recent exchanges to return (default 5)
Returns: Returns:
List of dialogue exchanges [{player_line: str, npc_response: str}, ...] List of dialogue exchanges [{player_message: str, npc_response: str, timestamp: str}, ...]
OR legacy format [{player_line: str, npc_response: str}, ...]
""" """
try: try:
character = self.get_character(character_id, user_id) character = self.get_character(character_id, user_id)
interaction = character.npc_interactions.get(npc_id, {}) interaction = character.npc_interactions.get(npc_id, {})
dialogue_history = interaction.get("dialogue_history", [])
# Return most recent exchanges (up to limit) # NEW: Try recent_messages first (last 3 messages cache)
return dialogue_history[-limit:] if dialogue_history else [] recent_messages = interaction.get("recent_messages")
if recent_messages is not None:
# Return most recent exchanges (up to limit, but recent_messages is already capped at 3)
return recent_messages[-limit:] if recent_messages else []
# DEPRECATED: Fall back to dialogue_history for backward compatibility
# This field will be removed after full migration to chat_messages system
dialogue_history = interaction.get("dialogue_history", [])
if dialogue_history:
logger.debug("Using deprecated dialogue_history field",
character_id=character_id,
npc_id=npc_id)
# Convert old format to new format if needed
# Old format: {player_line, npc_response}
# New format: {player_message, npc_response, timestamp}
converted = []
for entry in dialogue_history[-limit:]:
if "player_message" in entry:
# Already new format
converted.append(entry)
else:
# Old format, convert
converted.append({
"player_message": entry.get("player_line", ""),
"npc_response": entry.get("npc_response", ""),
"timestamp": "" # No timestamp available in old format
})
return converted
# No dialogue history at all
return []
except CharacterNotFound: except CharacterNotFound:
raise raise
@@ -1025,9 +1092,9 @@ class CharacterService:
character_json = json.dumps(character_dict) character_json = json.dumps(character_dict)
# Update in database # Update in database
self.db.update_document( self.db.update_row(
collection_id=self.collection_id, table_id=self.collection_id,
document_id=character.character_id, row_id=character.character_id,
data={'characterData': character_json} data={'characterData': character_json}
) )

View File

@@ -0,0 +1,617 @@
"""
Chat Message Service.
This service handles all business logic for player-NPC conversation history,
including saving messages, retrieving conversations, searching, and managing
the recent_messages cache in character documents.
"""
import json
from typing import List, Dict, Any, Optional
from datetime import datetime
from appwrite.query import Query
from appwrite.exception import AppwriteException
from appwrite.id import ID
from app.models.chat_message import ChatMessage, MessageContext, ConversationSummary
from app.services.database_service import get_database_service
from app.services.character_service import CharacterService
from app.utils.logging import get_logger
logger = get_logger(__file__)
# Custom Exceptions
class ChatMessageNotFound(Exception):
"""Raised when a chat message is not found."""
pass
class ChatMessagePermissionDenied(Exception):
"""Raised when user doesn't have permission to access/modify a chat message."""
pass
class ChatMessageService:
"""
Service for managing player-NPC chat messages.
This service provides:
- Saving dialogue exchanges to chat_messages collection
- Updating recent_messages cache in character documents
- Retrieving conversation history with pagination
- Searching messages by text, NPC, context, date range
- Getting conversation summaries for UI
- Soft deleting messages for privacy/moderation
"""
def __init__(self):
"""Initialize the chat message service with database and character service."""
self.db = get_database_service()
self.character_service = CharacterService()
logger.info("ChatMessageService initialized")
def save_dialogue_exchange(
self,
character_id: str,
user_id: str,
npc_id: str,
player_message: str,
npc_response: str,
context: MessageContext = MessageContext.DIALOGUE,
metadata: Optional[Dict[str, Any]] = None,
session_id: Optional[str] = None,
location_id: Optional[str] = None
) -> ChatMessage:
"""
Save a dialogue exchange to chat_messages collection and update character's recent_messages cache.
This method:
1. Creates a ChatMessage document in the chat_messages collection
2. Updates character.npc_interactions[npc_id].recent_messages with the last 3 messages
3. Updates character.npc_interactions[npc_id].total_messages counter
Args:
character_id: Player's character ID
user_id: User ID (for ownership validation)
npc_id: NPC identifier
player_message: What the player said
npc_response: NPC's reply
context: Type of interaction (default: DIALOGUE)
metadata: Optional extensible metadata (quest_id, faction_id, etc.)
session_id: Optional game session reference
location_id: Optional location where conversation happened
Returns:
Saved ChatMessage instance
Raises:
ChatMessagePermissionDenied: If user doesn't own the character
AppwriteException: If database operation fails
"""
try:
# Validate ownership
self._validate_ownership(character_id, user_id)
# Create chat message
chat_message = ChatMessage.create(
character_id=character_id,
npc_id=npc_id,
player_message=player_message,
npc_response=npc_response,
context=context,
session_id=session_id,
location_id=location_id,
metadata=metadata
)
# Save to database
message_data = chat_message.to_dict()
# Convert metadata dict to JSON string for storage (Appwrite requires string type)
# Always convert, even if empty dict (empty dict {} is falsy in Python!)
message_data['metadata'] = json.dumps(message_data.get('metadata') or {})
self.db.create_row(
table_id='chat_messages',
data=message_data,
row_id=chat_message.message_id
)
logger.info("Chat message saved",
message_id=chat_message.message_id,
character_id=character_id,
npc_id=npc_id,
context=context.value)
# Update character's recent_messages cache
self._update_recent_messages_preview(character_id, user_id, npc_id, chat_message)
return chat_message
except ChatMessagePermissionDenied:
raise
except Exception as e:
logger.error("Failed to save dialogue exchange",
character_id=character_id,
npc_id=npc_id,
error=str(e))
raise
def get_conversation_history(
self,
character_id: str,
user_id: str,
npc_id: str,
limit: int = 50,
offset: int = 0
) -> List[ChatMessage]:
"""
Get paginated conversation history between character and specific NPC.
Args:
character_id: Player's character ID
user_id: User ID (for ownership validation)
npc_id: NPC identifier
limit: Maximum messages to return (default 50, max 100)
offset: Number of messages to skip for pagination
Returns:
List of ChatMessage instances ordered by timestamp DESC (most recent first)
Raises:
ChatMessagePermissionDenied: If user doesn't own the character
AppwriteException: If database query fails
"""
try:
# Validate ownership
self._validate_ownership(character_id, user_id)
# Clamp limit to max 100
limit = min(limit, 100)
# Build query
queries = [
Query.equal('character_id', character_id),
Query.equal('npc_id', npc_id),
Query.equal('is_deleted', False),
Query.order_desc('timestamp'),
Query.limit(limit),
Query.offset(offset)
]
# Fetch messages
rows = self.db.list_rows(
table_id='chat_messages',
queries=queries
)
# Convert to ChatMessage objects
messages = []
for row in rows:
message_data = row.data
# Parse JSON metadata if present
if message_data.get('metadata') and isinstance(message_data['metadata'], str):
message_data['metadata'] = json.loads(message_data['metadata'])
messages.append(ChatMessage.from_dict(message_data))
logger.info("Retrieved conversation history",
character_id=character_id,
npc_id=npc_id,
count=len(messages),
limit=limit,
offset=offset)
return messages
except ChatMessagePermissionDenied:
raise
except Exception as e:
logger.error("Failed to get conversation history",
character_id=character_id,
npc_id=npc_id,
error=str(e))
raise
def search_messages(
self,
character_id: str,
user_id: str,
search_text: str,
npc_id: Optional[str] = None,
context: Optional[MessageContext] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[ChatMessage]:
"""
Search messages by text with optional filters.
Note: Appwrite's full-text search may be limited. This performs basic filtering.
For production use, consider implementing a dedicated search service.
Args:
character_id: Player's character ID
user_id: User ID (for ownership validation)
search_text: Text to search for in player_message and npc_response
npc_id: Optional filter by specific NPC
context: Optional filter by message context type
date_from: Optional start date (ISO format)
date_to: Optional end date (ISO format)
limit: Maximum messages to return (default 50)
offset: Number of messages to skip for pagination
Returns:
List of matching ChatMessage instances
Raises:
ChatMessagePermissionDenied: If user doesn't own the character
AppwriteException: If database query fails
"""
try:
# Validate ownership
self._validate_ownership(character_id, user_id)
# Build base query
queries = [
Query.equal('character_id', character_id),
Query.equal('is_deleted', False)
]
# Add optional filters
if npc_id:
queries.append(Query.equal('npc_id', npc_id))
if context:
queries.append(Query.equal('context', context.value))
if date_from:
queries.append(Query.greater_than_equal('timestamp', date_from))
if date_to:
queries.append(Query.less_than_equal('timestamp', date_to))
# Add search (Appwrite uses Query.search() if available)
if search_text:
try:
queries.append(Query.search('player_message', search_text))
except:
# Fallback: will filter in Python if search not supported
pass
queries.extend([
Query.order_desc('timestamp'),
Query.limit(min(limit, 100)),
Query.offset(offset)
])
# Fetch messages
rows = self.db.list_rows(
table_id='chat_messages',
queries=queries
)
# Convert to ChatMessage objects and apply text filter if needed
messages = []
for row in rows:
message_data = row.data
# Parse JSON metadata if present
if message_data.get('metadata') and isinstance(message_data['metadata'], str):
message_data['metadata'] = json.loads(message_data['metadata'])
# Filter by search text in Python if not handled by database
if search_text:
player_msg = message_data.get('player_message', '').lower()
npc_msg = message_data.get('npc_response', '').lower()
if search_text.lower() not in player_msg and search_text.lower() not in npc_msg:
continue
messages.append(ChatMessage.from_dict(message_data))
logger.info("Search completed",
character_id=character_id,
search_text=search_text,
npc_id=npc_id,
context=context.value if context else None,
results=len(messages))
return messages
except ChatMessagePermissionDenied:
raise
except Exception as e:
logger.error("Failed to search messages",
character_id=character_id,
search_text=search_text,
error=str(e))
raise
def get_all_conversations_summary(
self,
character_id: str,
user_id: str
) -> List[ConversationSummary]:
"""
Get summary of all NPC conversations for a character.
Returns list of NPCs the character has talked to, with message counts,
last message timestamp, and preview of most recent exchange.
Args:
character_id: Player's character ID
user_id: User ID (for ownership validation)
Returns:
List of ConversationSummary instances
Raises:
ChatMessagePermissionDenied: If user doesn't own the character
"""
try:
# Validate ownership
self._validate_ownership(character_id, user_id)
# Get character to access npc_interactions
character = self.character_service.get_character(character_id, user_id)
# Build summaries from character's npc_interactions
summaries = []
for npc_id, interaction in character.npc_interactions.items():
# Get NPC name (if available from NPC data, otherwise use npc_id)
# TODO: When NPC service is available, fetch NPC name
npc_name = npc_id.replace('_', ' ').title()
# Get last message timestamp and preview from recent_messages
recent_messages = interaction.get('recent_messages', [])
last_timestamp = interaction.get('last_interaction', '')
recent_preview = ''
if recent_messages:
last_msg = recent_messages[-1]
last_timestamp = last_msg.get('timestamp', last_timestamp)
recent_preview = last_msg.get('npc_response', '')[:100] # First 100 chars
# Get total message count
total_messages = interaction.get('total_messages', interaction.get('interaction_count', 0))
summary = ConversationSummary(
npc_id=npc_id,
npc_name=npc_name,
last_message_timestamp=last_timestamp,
message_count=total_messages,
recent_preview=recent_preview
)
summaries.append(summary)
# Sort by most recent first
summaries.sort(key=lambda s: s.last_message_timestamp, reverse=True)
logger.info("Retrieved conversation summaries",
character_id=character_id,
conversation_count=len(summaries))
return summaries
except ChatMessagePermissionDenied:
raise
except Exception as e:
logger.error("Failed to get conversation summaries",
character_id=character_id,
error=str(e))
raise
def soft_delete_message(
self,
message_id: str,
character_id: str,
user_id: str
) -> bool:
"""
Soft delete a message by setting is_deleted=True.
Used for privacy/moderation. Message remains in database but filtered from queries.
Args:
message_id: Message ID to delete
character_id: Character ID (for ownership validation)
user_id: User ID (for ownership validation)
Returns:
True if successful
Raises:
ChatMessageNotFound: If message not found
ChatMessagePermissionDenied: If user doesn't own the character/message
"""
try:
# Validate ownership
self._validate_ownership(character_id, user_id)
# Fetch message to verify it belongs to this character
message_row = self.db.get_row(table_id='chat_messages', row_id=message_id)
if not message_row:
raise ChatMessageNotFound(f"Message {message_id} not found")
if message_row.data.get('character_id') != character_id:
raise ChatMessagePermissionDenied("Message does not belong to this character")
# Update message to set is_deleted=True
self.db.update_row(
table_id='chat_messages',
row_id=message_id,
data={'is_deleted': True}
)
logger.info("Message soft deleted",
message_id=message_id,
character_id=character_id)
return True
except (ChatMessageNotFound, ChatMessagePermissionDenied):
raise
except Exception as e:
logger.error("Failed to soft delete message",
message_id=message_id,
error=str(e))
raise
def delete_messages_by_session(self, session_id: str) -> int:
"""
Permanently delete all chat messages associated with a session.
Used when a session is deleted to clean up associated messages.
This is a hard delete - messages are removed from the database entirely.
Args:
session_id: Session ID whose messages should be deleted
Returns:
Number of messages deleted
Note:
This method does not validate ownership because it's called from
session_service after ownership has already been validated.
"""
try:
# Query all messages with this session_id
messages = self.db.list_rows(
table_id='chat_messages',
queries=[Query.equal('session_id', session_id)],
limit=1000 # Handle up to 1000 messages per session
)
deleted_count = 0
for message in messages:
try:
self.db.delete_document(
collection_id='chat_messages',
document_id=message.id
)
deleted_count += 1
except Exception as e:
logger.warning("Failed to delete individual message",
message_id=message.id,
session_id=session_id,
error=str(e))
# Continue deleting other messages
logger.info("Deleted messages for session",
session_id=session_id,
deleted_count=deleted_count)
return deleted_count
except Exception as e:
logger.error("Failed to delete messages by session",
session_id=session_id,
error=str(e))
# Don't raise - session deletion should still proceed
return 0
# Helper Methods
def _update_recent_messages_preview(
self,
character_id: str,
user_id: str,
npc_id: str,
new_message: ChatMessage
) -> None:
"""
Update the recent_messages cache in character.npc_interactions[npc_id].
Maintains the last 3 messages for quick AI context retrieval without querying
the chat_messages collection.
Args:
character_id: Character ID
user_id: User ID
npc_id: NPC ID
new_message: New message to add to preview
"""
try:
# Get character
character = self.character_service.get_character(character_id, user_id)
# Get or create NPC interaction
if npc_id not in character.npc_interactions:
character.npc_interactions[npc_id] = {
'npc_id': npc_id,
'first_met': new_message.timestamp,
'last_interaction': new_message.timestamp,
'interaction_count': 0,
'revealed_secrets': [],
'relationship_level': 50,
'custom_flags': {},
'recent_messages': [],
'total_messages': 0
}
interaction = character.npc_interactions[npc_id]
# Add new message to recent_messages
recent_messages = interaction.get('recent_messages', [])
recent_messages.append(new_message.to_preview())
# Keep only last 3 messages
interaction['recent_messages'] = recent_messages[-3:]
# Update total message count
interaction['total_messages'] = interaction.get('total_messages', 0) + 1
# Update last_interaction timestamp
interaction['last_interaction'] = new_message.timestamp
# Save character
self.character_service.update_character(character_id, user_id, character)
logger.debug("Updated recent_messages preview",
character_id=character_id,
npc_id=npc_id,
recent_count=len(interaction['recent_messages']))
except Exception as e:
logger.error("Failed to update recent_messages preview",
character_id=character_id,
npc_id=npc_id,
error=str(e))
# Don't raise - this is a cache update, not critical
def _validate_ownership(self, character_id: str, user_id: str) -> None:
"""
Validate that the user owns the character.
Args:
character_id: Character ID
user_id: User ID
Raises:
ChatMessagePermissionDenied: If user doesn't own the character
"""
try:
self.character_service.get_character(character_id, user_id)
except Exception as e:
logger.warning("Ownership validation failed",
character_id=character_id,
user_id=user_id)
raise ChatMessagePermissionDenied(f"User {user_id} does not own character {character_id}")
# Global instance for convenience
_service_instance: Optional[ChatMessageService] = None
def get_chat_message_service() -> ChatMessageService:
"""
Get the global ChatMessageService instance.
Returns:
Singleton ChatMessageService instance
"""
global _service_instance
if _service_instance is None:
_service_instance = ChatMessageService()
return _service_instance

View File

@@ -0,0 +1,359 @@
"""
Combat Loot Service - Orchestrates loot generation from combat encounters.
This service bridges the EnemyTemplate loot tables with both the StaticItemLoader
(for consumables and materials) and ItemGenerator (for procedural equipment).
The service calculates effective rarity based on:
- Party average level
- Enemy difficulty tier
- Character luck stat
- Optional loot bonus modifiers (from abilities, buffs, etc.)
"""
import random
from dataclasses import dataclass
from typing import List, Optional
from app.models.enemy import EnemyTemplate, LootEntry, LootType, EnemyDifficulty
from app.models.items import Item
from app.services.item_generator import get_item_generator, ItemGenerator
from app.services.static_item_loader import get_static_item_loader, StaticItemLoader
from app.utils.logging import get_logger
logger = get_logger(__file__)
# Difficulty tier rarity bonuses (converted to effective luck points)
# Higher difficulty enemies have better chances of dropping rare items
DIFFICULTY_RARITY_BONUS = {
EnemyDifficulty.EASY: 0.0,
EnemyDifficulty.MEDIUM: 0.05,
EnemyDifficulty.HARD: 0.15,
EnemyDifficulty.BOSS: 0.30,
}
# Multiplier for converting rarity bonus to effective luck points
# Each 0.05 bonus translates to +1 effective luck
LUCK_CONVERSION_FACTOR = 20
@dataclass
class LootContext:
"""
Context for loot generation calculations.
Provides all the factors that influence loot quality and rarity.
Attributes:
party_average_level: Average level of player characters in the encounter
enemy_difficulty: Difficulty tier of the enemy being looted
luck_stat: Party's luck stat (typically average or leader's luck)
loot_bonus: Additional bonus from abilities, buffs, or modifiers (0.0 to 1.0)
"""
party_average_level: int = 1
enemy_difficulty: EnemyDifficulty = EnemyDifficulty.EASY
luck_stat: int = 8
loot_bonus: float = 0.0
class CombatLootService:
"""
Service for generating combat loot drops.
Supports two types of loot:
- STATIC: Predefined items loaded from YAML (consumables, materials)
- PROCEDURAL: Generated equipment with affixes (weapons, armor)
The service handles:
- Rolling for drops based on drop_chance
- Loading static items via StaticItemLoader
- Generating procedural items via ItemGenerator
- Calculating effective rarity based on context
"""
def __init__(
self,
item_generator: Optional[ItemGenerator] = None,
static_loader: Optional[StaticItemLoader] = None
):
"""
Initialize the combat loot service.
Args:
item_generator: ItemGenerator instance (uses global singleton if None)
static_loader: StaticItemLoader instance (uses global singleton if None)
"""
self.item_generator = item_generator or get_item_generator()
self.static_loader = static_loader or get_static_item_loader()
logger.info("CombatLootService initialized")
def generate_loot_from_enemy(
self,
enemy: EnemyTemplate,
context: LootContext
) -> List[Item]:
"""
Generate all loot drops from a defeated enemy.
Iterates through the enemy's loot table, rolling for each entry
and generating appropriate items based on loot type.
Args:
enemy: The defeated enemy template
context: Loot generation context (party level, luck, etc.)
Returns:
List of Item objects to add to player inventory
"""
items = []
for entry in enemy.loot_table:
# Roll for drop chance
if random.random() >= entry.drop_chance:
continue
# Determine quantity
quantity = random.randint(entry.quantity_min, entry.quantity_max)
if entry.loot_type == LootType.STATIC:
# Static item: load from predefined templates
static_items = self._generate_static_items(entry, quantity)
items.extend(static_items)
elif entry.loot_type == LootType.PROCEDURAL:
# Procedural equipment: generate with ItemGenerator
procedural_items = self._generate_procedural_items(
entry, quantity, context
)
items.extend(procedural_items)
logger.info(
"Loot generated from enemy",
enemy_id=enemy.enemy_id,
enemy_difficulty=enemy.difficulty.value,
item_count=len(items),
party_level=context.party_average_level,
luck=context.luck_stat
)
return items
def _generate_static_items(
self,
entry: LootEntry,
quantity: int
) -> List[Item]:
"""
Generate static items from a loot entry.
Args:
entry: The loot table entry
quantity: Number of items to generate
Returns:
List of Item instances
"""
items = []
if not entry.item_id:
logger.warning(
"Static loot entry missing item_id",
entry=entry.to_dict()
)
return items
for _ in range(quantity):
item = self.static_loader.get_item(entry.item_id)
if item:
items.append(item)
else:
logger.warning(
"Failed to load static item",
item_id=entry.item_id
)
return items
def _generate_procedural_items(
self,
entry: LootEntry,
quantity: int,
context: LootContext
) -> List[Item]:
"""
Generate procedural items from a loot entry.
Calculates effective luck based on:
- Base luck stat
- Entry-specific rarity bonus
- Difficulty bonus
- Loot bonus from abilities/buffs
Args:
entry: The loot table entry
quantity: Number of items to generate
context: Loot generation context
Returns:
List of generated Item instances
"""
items = []
if not entry.item_type:
logger.warning(
"Procedural loot entry missing item_type",
entry=entry.to_dict()
)
return items
# Calculate effective luck for rarity roll
effective_luck = self._calculate_effective_luck(entry, context)
for _ in range(quantity):
item = self.item_generator.generate_loot_drop(
character_level=context.party_average_level,
luck_stat=effective_luck,
item_type=entry.item_type
)
if item:
items.append(item)
else:
logger.warning(
"Failed to generate procedural item",
item_type=entry.item_type,
level=context.party_average_level
)
return items
def _calculate_effective_luck(
self,
entry: LootEntry,
context: LootContext
) -> int:
"""
Calculate effective luck for rarity rolling.
Combines multiple factors:
- Base luck stat from party
- Entry-specific rarity bonus (defined per loot entry)
- Difficulty bonus (based on enemy tier)
- Loot bonus (from abilities, buffs, etc.)
The formula:
effective_luck = base_luck + (entry_bonus + difficulty_bonus + loot_bonus) * FACTOR
Args:
entry: The loot table entry
context: Loot generation context
Returns:
Effective luck stat for rarity calculations
"""
# Get difficulty bonus
difficulty_bonus = DIFFICULTY_RARITY_BONUS.get(
context.enemy_difficulty, 0.0
)
# Sum all bonuses
total_bonus = (
entry.rarity_bonus +
difficulty_bonus +
context.loot_bonus
)
# Convert bonus to effective luck points
bonus_luck = int(total_bonus * LUCK_CONVERSION_FACTOR)
effective_luck = context.luck_stat + bonus_luck
logger.debug(
"Effective luck calculated",
base_luck=context.luck_stat,
entry_bonus=entry.rarity_bonus,
difficulty_bonus=difficulty_bonus,
loot_bonus=context.loot_bonus,
total_bonus=total_bonus,
effective_luck=effective_luck
)
return effective_luck
def generate_boss_loot(
self,
enemy: EnemyTemplate,
context: LootContext,
guaranteed_drops: int = 1
) -> List[Item]:
"""
Generate loot from a boss enemy with guaranteed drops.
Boss enemies are guaranteed to drop at least one piece of equipment
in addition to their normal loot table rolls.
Args:
enemy: The boss enemy template
context: Loot generation context
guaranteed_drops: Number of guaranteed equipment drops
Returns:
List of Item objects including guaranteed drops
"""
# Generate normal loot first
items = self.generate_loot_from_enemy(enemy, context)
# Add guaranteed procedural drops for bosses
if enemy.is_boss():
context_for_boss = LootContext(
party_average_level=context.party_average_level,
enemy_difficulty=EnemyDifficulty.BOSS,
luck_stat=context.luck_stat,
loot_bonus=context.loot_bonus + 0.1 # Extra bonus for bosses
)
for _ in range(guaranteed_drops):
# Alternate between weapon and armor
item_type = random.choice(["weapon", "armor"])
effective_luck = self._calculate_effective_luck(
LootEntry(
loot_type=LootType.PROCEDURAL,
item_type=item_type,
rarity_bonus=0.15 # Boss-tier bonus
),
context_for_boss
)
item = self.item_generator.generate_loot_drop(
character_level=context.party_average_level,
luck_stat=effective_luck,
item_type=item_type
)
if item:
items.append(item)
logger.info(
"Boss loot generated",
enemy_id=enemy.enemy_id,
guaranteed_drops=guaranteed_drops,
total_items=len(items)
)
return items
# Global singleton
_service_instance: Optional[CombatLootService] = None
def get_combat_loot_service() -> CombatLootService:
"""
Get the global CombatLootService instance.
Returns:
Singleton CombatLootService instance
"""
global _service_instance
if _service_instance is None:
_service_instance = CombatLootService()
return _service_instance

View File

@@ -0,0 +1,578 @@
"""
Combat Repository - Database operations for combat encounters.
This service handles all CRUD operations for combat data stored in
dedicated database tables (combat_encounters, combat_rounds).
Separates combat persistence from the CombatService which handles
business logic and game mechanics.
"""
import json
from typing import Dict, Any, List, Optional
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from appwrite.query import Query
from app.models.combat import CombatEncounter, Combatant
from app.models.enums import CombatStatus
from app.services.database_service import get_database_service, DatabaseService
from app.utils.logging import get_logger
logger = get_logger(__file__)
# =============================================================================
# Exceptions
# =============================================================================
class CombatEncounterNotFound(Exception):
"""Raised when combat encounter is not found in database."""
pass
class CombatRoundNotFound(Exception):
"""Raised when combat round is not found in database."""
pass
# =============================================================================
# Combat Repository
# =============================================================================
class CombatRepository:
"""
Repository for combat encounter database operations.
Handles:
- Creating and reading combat encounters
- Updating combat state during actions
- Saving per-round history for logging and replay
- Time-based cleanup of old combat data
Tables:
- combat_encounters: Main encounter state and metadata
- combat_rounds: Per-round action history
"""
# Table IDs
ENCOUNTERS_TABLE = "combat_encounters"
ROUNDS_TABLE = "combat_rounds"
# Default retention period for cleanup (days)
DEFAULT_RETENTION_DAYS = 7
def __init__(self, db: Optional[DatabaseService] = None):
"""
Initialize the combat repository.
Args:
db: Optional DatabaseService instance (for testing/injection)
"""
self.db = db or get_database_service()
logger.info("CombatRepository initialized")
# =========================================================================
# Encounter CRUD Operations
# =========================================================================
def create_encounter(
self,
encounter: CombatEncounter,
session_id: str,
user_id: str
) -> str:
"""
Create a new combat encounter record.
Args:
encounter: CombatEncounter instance to persist
session_id: Game session ID this encounter belongs to
user_id: Owner user ID for authorization
Returns:
encounter_id of created record
"""
created_at = self._get_timestamp()
data = {
'sessionId': session_id,
'userId': user_id,
'status': encounter.status.value,
'roundNumber': encounter.round_number,
'currentTurnIndex': encounter.current_turn_index,
'turnOrder': json.dumps(encounter.turn_order),
'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]),
'combatLog': json.dumps(encounter.combat_log),
'created_at': created_at,
}
self.db.create_row(
table_id=self.ENCOUNTERS_TABLE,
data=data,
row_id=encounter.encounter_id
)
logger.info("Combat encounter created",
encounter_id=encounter.encounter_id,
session_id=session_id,
combatant_count=len(encounter.combatants))
return encounter.encounter_id
def get_encounter(self, encounter_id: str) -> Optional[CombatEncounter]:
"""
Get a combat encounter by ID.
Args:
encounter_id: Encounter ID to fetch
Returns:
CombatEncounter or None if not found
"""
logger.info("Fetching encounter from database",
encounter_id=encounter_id)
row = self.db.get_row(self.ENCOUNTERS_TABLE, encounter_id)
if not row:
logger.warning("Encounter not found", encounter_id=encounter_id)
return None
logger.info("Raw database row data",
encounter_id=encounter_id,
currentTurnIndex=row.data.get('currentTurnIndex'),
roundNumber=row.data.get('roundNumber'))
encounter = self._row_to_encounter(row.data, encounter_id)
logger.info("Encounter object created",
encounter_id=encounter_id,
current_turn_index=encounter.current_turn_index,
turn_order=encounter.turn_order)
return encounter
def get_encounter_by_session(
self,
session_id: str,
active_only: bool = True
) -> Optional[CombatEncounter]:
"""
Get combat encounter for a session.
Args:
session_id: Game session ID
active_only: If True, only return active encounters
Returns:
CombatEncounter or None if not found
"""
queries = [Query.equal('sessionId', session_id)]
if active_only:
queries.append(Query.equal('status', CombatStatus.ACTIVE.value))
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=queries,
limit=1
)
if not rows:
return None
row = rows[0]
return self._row_to_encounter(row.data, row.id)
def get_user_active_encounters(self, user_id: str) -> List[CombatEncounter]:
"""
Get all active encounters for a user.
Args:
user_id: User ID to query
Returns:
List of active CombatEncounter instances
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[
Query.equal('userId', user_id),
Query.equal('status', CombatStatus.ACTIVE.value)
],
limit=25
)
return [self._row_to_encounter(row.data, row.id) for row in rows]
def update_encounter(self, encounter: CombatEncounter) -> None:
"""
Update an existing combat encounter.
Call this after each action to persist the updated state.
Args:
encounter: CombatEncounter with updated state
"""
data = {
'status': encounter.status.value,
'roundNumber': encounter.round_number,
'currentTurnIndex': encounter.current_turn_index,
'turnOrder': json.dumps(encounter.turn_order),
'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]),
'combatLog': json.dumps(encounter.combat_log),
}
logger.info("Saving encounter to database",
encounter_id=encounter.encounter_id,
current_turn_index=encounter.current_turn_index,
combat_log_entries=len(encounter.combat_log))
self.db.update_row(
table_id=self.ENCOUNTERS_TABLE,
row_id=encounter.encounter_id,
data=data
)
logger.info("Encounter saved successfully",
encounter_id=encounter.encounter_id)
def end_encounter(
self,
encounter_id: str,
status: CombatStatus
) -> None:
"""
Mark an encounter as ended.
Args:
encounter_id: Encounter ID to end
status: Final status (VICTORY, DEFEAT, FLED)
"""
ended_at = self._get_timestamp()
data = {
'status': status.value,
'ended_at': ended_at,
}
self.db.update_row(
table_id=self.ENCOUNTERS_TABLE,
row_id=encounter_id,
data=data
)
logger.info("Combat encounter ended",
encounter_id=encounter_id,
status=status.value)
def delete_encounter(self, encounter_id: str) -> bool:
"""
Delete an encounter and all its rounds.
Args:
encounter_id: Encounter ID to delete
Returns:
True if deleted successfully
"""
# Delete rounds first
self._delete_rounds_for_encounter(encounter_id)
# Delete encounter
result = self.db.delete_row(self.ENCOUNTERS_TABLE, encounter_id)
logger.info("Combat encounter deleted", encounter_id=encounter_id)
return result
# =========================================================================
# Round Operations
# =========================================================================
def save_round(
self,
encounter_id: str,
session_id: str,
round_number: int,
actions: List[Dict[str, Any]],
states_start: List[Combatant],
states_end: List[Combatant]
) -> str:
"""
Save a completed round's data for history/replay.
Call this at the end of each round (after all combatants have acted).
Args:
encounter_id: Parent encounter ID
session_id: Game session ID (denormalized for queries)
round_number: Round number (1-indexed)
actions: List of all actions taken this round
states_start: Combatant states at round start
states_end: Combatant states at round end
Returns:
round_id of created record
"""
round_id = f"rnd_{uuid4().hex[:12]}"
created_at = self._get_timestamp()
data = {
'encounterId': encounter_id,
'sessionId': session_id,
'roundNumber': round_number,
'actionsData': json.dumps(actions),
'combatantStatesStart': json.dumps([c.to_dict() for c in states_start]),
'combatantStatesEnd': json.dumps([c.to_dict() for c in states_end]),
'created_at': created_at,
}
self.db.create_row(
table_id=self.ROUNDS_TABLE,
data=data,
row_id=round_id
)
logger.debug("Combat round saved",
round_id=round_id,
encounter_id=encounter_id,
round_number=round_number,
action_count=len(actions))
return round_id
def get_encounter_rounds(
self,
encounter_id: str,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get all rounds for an encounter, ordered by round number.
Args:
encounter_id: Encounter ID to fetch rounds for
limit: Maximum number of rounds to return
Returns:
List of round data dictionaries
"""
rows = self.db.list_rows(
table_id=self.ROUNDS_TABLE,
queries=[Query.equal('encounterId', encounter_id)],
limit=limit
)
rounds = []
for row in rows:
rounds.append({
'round_id': row.id,
'round_number': row.data.get('roundNumber'),
'actions': json.loads(row.data.get('actionsData', '[]')),
'states_start': json.loads(row.data.get('combatantStatesStart', '[]')),
'states_end': json.loads(row.data.get('combatantStatesEnd', '[]')),
'created_at': row.data.get('created_at'),
})
# Sort by round number
return sorted(rounds, key=lambda r: r['round_number'])
def get_session_combat_history(
self,
session_id: str,
limit: int = 50
) -> List[Dict[str, Any]]:
"""
Get combat history for a session.
Returns summary of all encounters for the session.
Args:
session_id: Game session ID
limit: Maximum encounters to return
Returns:
List of encounter summaries
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[Query.equal('sessionId', session_id)],
limit=limit
)
history = []
for row in rows:
history.append({
'encounter_id': row.id,
'status': row.data.get('status'),
'round_count': row.data.get('roundNumber', 1),
'created_at': row.data.get('created_at'),
'ended_at': row.data.get('ended_at'),
})
# Sort by created_at descending (newest first)
return sorted(history, key=lambda h: h['created_at'] or '', reverse=True)
# =========================================================================
# Cleanup Operations
# =========================================================================
def delete_encounters_by_session(self, session_id: str) -> int:
"""
Delete all encounters for a session.
Call this when a session is deleted.
Args:
session_id: Session ID to clean up
Returns:
Number of encounters deleted
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[Query.equal('sessionId', session_id)],
limit=100
)
deleted = 0
for row in rows:
# Delete rounds first
self._delete_rounds_for_encounter(row.id)
# Delete encounter
self.db.delete_row(self.ENCOUNTERS_TABLE, row.id)
deleted += 1
if deleted > 0:
logger.info("Deleted encounters for session",
session_id=session_id,
deleted_count=deleted)
return deleted
def delete_old_encounters(
self,
older_than_days: int = DEFAULT_RETENTION_DAYS
) -> int:
"""
Delete ended encounters older than specified days.
This is the main cleanup method for time-based retention.
Should be scheduled to run periodically (daily recommended).
Args:
older_than_days: Delete encounters ended more than this many days ago
Returns:
Number of encounters deleted
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
cutoff_str = cutoff.isoformat().replace("+00:00", "Z")
# Find old ended encounters
# Note: We only delete ended encounters, not active ones
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[
Query.notEqual('status', CombatStatus.ACTIVE.value),
Query.lessThan('created_at', cutoff_str)
],
limit=100
)
deleted = 0
for row in rows:
self._delete_rounds_for_encounter(row.id)
self.db.delete_row(self.ENCOUNTERS_TABLE, row.id)
deleted += 1
if deleted > 0:
logger.info("Deleted old combat encounters",
deleted_count=deleted,
older_than_days=older_than_days)
return deleted
# =========================================================================
# Helper Methods
# =========================================================================
def _delete_rounds_for_encounter(self, encounter_id: str) -> int:
"""
Delete all rounds for an encounter.
Args:
encounter_id: Encounter ID
Returns:
Number of rounds deleted
"""
rows = self.db.list_rows(
table_id=self.ROUNDS_TABLE,
queries=[Query.equal('encounterId', encounter_id)],
limit=100
)
for row in rows:
self.db.delete_row(self.ROUNDS_TABLE, row.id)
return len(rows)
def _row_to_encounter(
self,
data: Dict[str, Any],
encounter_id: str
) -> CombatEncounter:
"""
Convert database row data to CombatEncounter object.
Args:
data: Row data dictionary
encounter_id: Encounter ID
Returns:
Deserialized CombatEncounter
"""
# Parse JSON fields
combatants_data = json.loads(data.get('combatantsData', '[]'))
combatants = [Combatant.from_dict(c) for c in combatants_data]
turn_order = json.loads(data.get('turnOrder', '[]'))
combat_log = json.loads(data.get('combatLog', '[]'))
# Parse status enum
status_str = data.get('status', 'active')
status = CombatStatus(status_str)
return CombatEncounter(
encounter_id=encounter_id,
combatants=combatants,
turn_order=turn_order,
current_turn_index=data.get('currentTurnIndex', 0),
round_number=data.get('roundNumber', 1),
combat_log=combat_log,
status=status,
)
def _get_timestamp(self) -> str:
"""Get current UTC timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# =============================================================================
# Global Instance
# =============================================================================
_repository_instance: Optional[CombatRepository] = None
def get_combat_repository() -> CombatRepository:
"""
Get the global CombatRepository instance.
Returns:
Singleton CombatRepository instance
"""
global _repository_instance
if _repository_instance is None:
_repository_instance = CombatRepository()
return _repository_instance

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,590 @@
"""
Damage Calculator Service
A comprehensive, formula-driven damage calculation system for Code of Conquest.
Handles physical, magical, and elemental damage with LUK stat integration
for variance, critical hits, and accuracy.
Formulas:
Physical: (effective_stats.damage + ability_power) * Variance * Crit_Mult - DEF
where effective_stats.damage = int(STR * 0.75) + damage_bonus (from weapon)
Magical: (effective_stats.spell_power + ability_power) * Variance * Crit_Mult - RES
where effective_stats.spell_power = int(INT * 0.75) + spell_power_bonus (from staff/wand)
Elemental: Split between physical and magical components using ratios
LUK Integration:
- Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss
- Crit bonus: Base 5% + (LUK * 0.5%), max 25%
- Lucky variance: 5% + (LUK * 0.25%) chance for higher damage roll
"""
import random
from dataclasses import dataclass, field
from typing import Dict, Any, Optional, List
from app.models.stats import Stats
from app.models.enums import DamageType
class CombatConstants:
"""
Combat system tuning constants.
These values control the balance of combat mechanics and can be
adjusted for game balance without modifying formula logic.
"""
# Stat Scaling
# How much primary stats (STR/INT) contribute to damage
# 0.75 means STR 14 adds +10.5 damage
STAT_SCALING_FACTOR: float = 0.75
# Hit/Miss System
BASE_MISS_CHANCE: float = 0.10 # 10% base miss rate
LUK_MISS_REDUCTION: float = 0.005 # 0.5% per LUK point
DEX_EVASION_BONUS: float = 0.0025 # 0.25% per DEX above 10
MIN_MISS_CHANCE: float = 0.05 # Hard cap: 5% minimum miss
# Critical Hits
DEFAULT_CRIT_CHANCE: float = 0.05 # 5% base crit
LUK_CRIT_BONUS: float = 0.005 # 0.5% per LUK point
MAX_CRIT_CHANCE: float = 0.25 # 25% cap (before skills)
DEFAULT_CRIT_MULTIPLIER: float = 2.0
# Damage Variance
BASE_VARIANCE_MIN: float = 0.95 # Minimum variance roll
BASE_VARIANCE_MAX: float = 1.05 # Maximum variance roll
LUCKY_VARIANCE_MIN: float = 1.00 # Lucky roll minimum
LUCKY_VARIANCE_MAX: float = 1.10 # Lucky roll maximum (10% bonus)
BASE_LUCKY_CHANCE: float = 0.05 # 5% base lucky roll chance
LUK_LUCKY_BONUS: float = 0.0025 # 0.25% per LUK point
# Defense Mitigation
# Ensures high-DEF targets still take meaningful damage
MIN_DAMAGE_RATIO: float = 0.20 # 20% of raw always goes through
MIN_DAMAGE: int = 1 # Absolute minimum damage
@dataclass
class DamageResult:
"""
Result of a damage calculation.
Contains the calculated damage values, whether the attack was a crit or miss,
and a human-readable message for the combat log.
Attributes:
total_damage: Final damage after all calculations
physical_damage: Physical component (for split damage)
elemental_damage: Elemental component (for split damage)
damage_type: Primary damage type (physical, fire, etc.)
is_critical: Whether the attack was a critical hit
is_miss: Whether the attack missed entirely
variance_roll: The variance multiplier that was applied
raw_damage: Damage before defense mitigation
message: Human-readable description for combat log
"""
total_damage: int = 0
physical_damage: int = 0
elemental_damage: int = 0
damage_type: DamageType = DamageType.PHYSICAL
elemental_type: Optional[DamageType] = None
is_critical: bool = False
is_miss: bool = False
variance_roll: float = 1.0
raw_damage: int = 0
message: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Serialize damage result to dictionary."""
return {
"total_damage": self.total_damage,
"physical_damage": self.physical_damage,
"elemental_damage": self.elemental_damage,
"damage_type": self.damage_type.value if self.damage_type else "physical",
"elemental_type": self.elemental_type.value if self.elemental_type else None,
"is_critical": self.is_critical,
"is_miss": self.is_miss,
"variance_roll": round(self.variance_roll, 3),
"raw_damage": self.raw_damage,
"message": self.message,
}
class DamageCalculator:
"""
Formula-driven damage calculator for combat.
This class provides static methods for calculating all types of damage
in the combat system, including hit/miss chances, critical hits,
damage variance, and defense mitigation.
All formulas integrate the LUK stat for meaningful randomness while
maintaining a hard cap on miss chance to prevent frustration.
"""
@staticmethod
def calculate_hit_chance(
attacker_luck: int,
defender_dexterity: int,
skill_bonus: float = 0.0
) -> float:
"""
Calculate hit probability for an attack.
Formula:
miss_chance = max(0.05, 0.10 - (LUK * 0.005) + ((DEX - 10) * 0.0025))
hit_chance = 1.0 - miss_chance
Args:
attacker_luck: Attacker's LUK stat
defender_dexterity: Defender's DEX stat
skill_bonus: Additional hit chance from skills (0.0 to 1.0)
Returns:
Hit probability as a float between 0.0 and 1.0
Examples:
LUK 8, DEX 10: miss = 10% - 4% + 0% = 6%
LUK 12, DEX 10: miss = 10% - 6% + 0% = 4% -> capped at 5%
LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25%
"""
# Base miss rate
base_miss = CombatConstants.BASE_MISS_CHANCE
# LUK reduces miss chance
luk_reduction = attacker_luck * CombatConstants.LUK_MISS_REDUCTION
# High DEX increases evasion (only DEX above 10 counts)
dex_above_base = max(0, defender_dexterity - 10)
dex_evasion = dex_above_base * CombatConstants.DEX_EVASION_BONUS
# Calculate final miss chance with hard cap
miss_chance = base_miss - luk_reduction + dex_evasion - skill_bonus
miss_chance = max(CombatConstants.MIN_MISS_CHANCE, miss_chance)
return 1.0 - miss_chance
@staticmethod
def calculate_crit_chance(
attacker_luck: int,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
skill_bonus: float = 0.0
) -> float:
"""
Calculate critical hit probability.
Formula:
crit_chance = min(0.25, weapon_crit + (LUK * 0.005) + skill_bonus)
Args:
attacker_luck: Attacker's LUK stat
weapon_crit_chance: Base crit chance from weapon (default 5%)
skill_bonus: Additional crit chance from skills
Returns:
Crit probability as a float (capped at 25%)
Examples:
LUK 8, weapon 5%: crit = 5% + 4% = 9%
LUK 12, weapon 5%: crit = 5% + 6% = 11%
LUK 12, weapon 10%: crit = 10% + 6% = 16%
"""
# LUK bonus to crit
luk_bonus = attacker_luck * CombatConstants.LUK_CRIT_BONUS
# Total crit chance with cap
total_crit = weapon_crit_chance + luk_bonus + skill_bonus
return min(CombatConstants.MAX_CRIT_CHANCE, total_crit)
@staticmethod
def calculate_variance(attacker_luck: int) -> float:
"""
Calculate damage variance multiplier with LUK bonus.
Hybrid variance system:
- Base roll: 95% to 105% of damage
- LUK grants chance for "lucky roll": 100% to 110% instead
Args:
attacker_luck: Attacker's LUK stat
Returns:
Variance multiplier (typically 0.95 to 1.10)
Examples:
LUK 8: 7% chance for lucky roll (100-110%)
LUK 12: 8% chance for lucky roll
"""
# Calculate lucky roll chance
lucky_chance = (
CombatConstants.BASE_LUCKY_CHANCE +
(attacker_luck * CombatConstants.LUK_LUCKY_BONUS)
)
# Roll for lucky variance
if random.random() < lucky_chance:
# Lucky roll: higher damage range
return random.uniform(
CombatConstants.LUCKY_VARIANCE_MIN,
CombatConstants.LUCKY_VARIANCE_MAX
)
else:
# Normal roll
return random.uniform(
CombatConstants.BASE_VARIANCE_MIN,
CombatConstants.BASE_VARIANCE_MAX
)
@staticmethod
def apply_defense(
raw_damage: int,
defense: int,
min_damage_ratio: float = CombatConstants.MIN_DAMAGE_RATIO
) -> int:
"""
Apply defense mitigation with minimum damage guarantee.
Ensures at least 20% of raw damage always goes through,
preventing high-DEF tanks from becoming unkillable.
Absolute minimum is always 1 damage.
Args:
raw_damage: Damage before defense
defense: Target's defense value
min_damage_ratio: Minimum % of raw damage that goes through
Returns:
Final damage after mitigation (minimum 1)
Examples:
raw=20, def=5: 20 - 5 = 15 damage
raw=20, def=18: max(4, 2) = 4 damage (20% minimum)
raw=10, def=100: max(2, -90) = 2 damage (20% minimum)
"""
# Calculate mitigated damage
mitigated = raw_damage - defense
# Minimum damage is 20% of raw, or 1, whichever is higher
min_damage = max(CombatConstants.MIN_DAMAGE, int(raw_damage * min_damage_ratio))
return max(min_damage, mitigated)
@classmethod
def calculate_physical_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
ability_base_power: int = 0,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate physical damage for a melee/ranged attack.
Formula:
Base = attacker_stats.damage + ability_base_power
where attacker_stats.damage = int(STR * 0.75) + damage_bonus
Damage = Base * Variance * Crit_Mult - DEF
Args:
attacker_stats: Attacker's Stats (includes weapon damage via damage property)
defender_stats: Defender's Stats (DEX, CON used)
weapon_crit_chance: Crit chance from weapon (default 5%)
weapon_crit_multiplier: Crit damage multiplier (default 2.0x)
ability_base_power: Additional base power from ability
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with calculated damage and metadata
"""
result = DamageResult(damage_type=DamageType.PHYSICAL)
# Step 1: Check for miss
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Attack missed!"
return result
# Step 2: Calculate base damage
# attacker_stats.damage already includes: int(STR * 0.75) + damage_bonus (weapon)
base_damage = attacker_stats.damage + ability_base_power
# Step 3: Apply variance
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
damage = base_damage * variance
# Step 4: Check for critical hit
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
if random.random() < crit_chance:
result.is_critical = True
damage *= weapon_crit_multiplier
# Store raw damage before defense
result.raw_damage = int(damage)
# Step 5: Apply defense mitigation
final_damage = cls.apply_defense(int(damage), defender_stats.defense)
result.total_damage = final_damage
result.physical_damage = final_damage
# Build message
crit_text = " CRITICAL HIT!" if result.is_critical else ""
result.message = f"Dealt {final_damage} physical damage.{crit_text}"
return result
@classmethod
def calculate_magical_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
ability_base_power: int,
damage_type: DamageType = DamageType.FIRE,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate magical damage for a spell.
Spells CAN critically hit (same formula as physical).
LUK benefits all classes equally.
Formula:
Base = attacker_stats.spell_power + ability_base_power
where attacker_stats.spell_power = int(INT * 0.75) + spell_power_bonus
Damage = Base * Variance * Crit_Mult - RES
Args:
attacker_stats: Attacker's Stats (includes staff/wand spell_power via spell_power property)
defender_stats: Defender's Stats (DEX, WIS used)
ability_base_power: Base power of the spell
damage_type: Type of magical damage (fire, ice, etc.)
weapon_crit_chance: Crit chance (from focus/staff)
weapon_crit_multiplier: Crit damage multiplier
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with calculated damage and metadata
"""
result = DamageResult(damage_type=damage_type)
# Step 1: Check for miss (spells can miss too)
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Spell missed!"
return result
# Step 2: Calculate base damage
# attacker_stats.spell_power already includes: int(INT * 0.75) + spell_power_bonus (staff/wand)
base_damage = attacker_stats.spell_power + ability_base_power
# Step 3: Apply variance
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
damage = base_damage * variance
# Step 4: Check for critical hit (spells CAN crit)
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
if random.random() < crit_chance:
result.is_critical = True
damage *= weapon_crit_multiplier
# Store raw damage before resistance
result.raw_damage = int(damage)
# Step 5: Apply resistance mitigation
final_damage = cls.apply_defense(int(damage), defender_stats.resistance)
result.total_damage = final_damage
result.elemental_damage = final_damage
# Build message
crit_text = " CRITICAL HIT!" if result.is_critical else ""
result.message = f"Dealt {final_damage} {damage_type.value} damage.{crit_text}"
return result
@classmethod
def calculate_elemental_weapon_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
weapon_crit_chance: float,
weapon_crit_multiplier: float,
physical_ratio: float,
elemental_ratio: float,
elemental_type: DamageType,
ability_base_power: int = 0,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate split damage for elemental weapons (e.g., Fire Sword).
Elemental weapons deal both physical AND elemental damage,
calculated separately against DEF and RES respectively.
Formula:
Physical = (attacker_stats.damage + ability_power) * PHYS_RATIO - DEF
Elemental = (attacker_stats.spell_power + ability_power) * ELEM_RATIO - RES
Total = Physical + Elemental
Recommended Split Ratios:
- Pure Physical: 100% / 0%
- Fire Sword: 70% / 30%
- Frost Blade: 60% / 40%
- Lightning Spear: 50% / 50%
Args:
attacker_stats: Attacker's Stats (damage and spell_power include equipment)
defender_stats: Defender's Stats
weapon_crit_chance: Crit chance from weapon
weapon_crit_multiplier: Crit damage multiplier
physical_ratio: Portion of damage that is physical (0.0-1.0)
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
elemental_type: Type of elemental damage
ability_base_power: Additional base power from ability
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with split physical/elemental damage
"""
result = DamageResult(
damage_type=DamageType.PHYSICAL,
elemental_type=elemental_type
)
# Step 1: Check for miss (single roll for entire attack)
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Attack missed!"
return result
# Step 2: Check for critical (single roll applies to both components)
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
is_crit = random.random() < crit_chance
result.is_critical = is_crit
crit_mult = weapon_crit_multiplier if is_crit else 1.0
# Step 3: Calculate physical component
# attacker_stats.damage includes: int(STR * 0.75) + damage_bonus (weapon)
phys_base = (attacker_stats.damage + ability_base_power) * physical_ratio
phys_damage = phys_base * variance * crit_mult
phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense)
# Step 4: Calculate elemental component
# attacker_stats.spell_power includes: int(INT * 0.75) + spell_power_bonus (staff/wand)
elem_base = (attacker_stats.spell_power + ability_base_power) * elemental_ratio
elem_damage = elem_base * variance * crit_mult
elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance)
# Step 5: Combine results
result.physical_damage = phys_final
result.elemental_damage = elem_final
result.total_damage = phys_final + elem_final
result.raw_damage = int(phys_damage + elem_damage)
# Build message
crit_text = " CRITICAL HIT!" if is_crit else ""
result.message = (
f"Dealt {result.total_damage} damage "
f"({phys_final} physical + {elem_final} {elemental_type.value}).{crit_text}"
)
return result
@classmethod
def calculate_aoe_damage(
cls,
attacker_stats: Stats,
defender_stats_list: List[Stats],
ability_base_power: int,
damage_type: DamageType = DamageType.FIRE,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> List[DamageResult]:
"""
Calculate AoE spell damage against multiple targets.
AoE spells deal FULL damage to all targets (balanced by higher mana costs).
Each target has independent hit/crit rolls but shares the base calculation.
Args:
attacker_stats: Attacker's Stats
defender_stats_list: List of defender Stats (one per target)
ability_base_power: Base power of the AoE spell
damage_type: Type of magical damage
weapon_crit_chance: Crit chance from focus/staff
weapon_crit_multiplier: Crit damage multiplier
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
List of DamageResult, one per target
"""
results = []
# Each target gets independent damage calculation
for defender_stats in defender_stats_list:
result = cls.calculate_magical_damage(
attacker_stats=attacker_stats,
defender_stats=defender_stats,
ability_base_power=ability_base_power,
damage_type=damage_type,
weapon_crit_chance=weapon_crit_chance,
weapon_crit_multiplier=weapon_crit_multiplier,
skill_hit_bonus=skill_hit_bonus,
skill_crit_bonus=skill_crit_bonus,
)
results.append(result)
return results

View File

@@ -97,6 +97,33 @@ class DatabaseInitService:
logger.error("Failed to initialize ai_usage_logs table", error=str(e)) logger.error("Failed to initialize ai_usage_logs table", error=str(e))
results['ai_usage_logs'] = False results['ai_usage_logs'] = False
# Initialize chat_messages table
try:
self.init_chat_messages_table()
results['chat_messages'] = True
logger.info("Chat messages table initialized successfully")
except Exception as e:
logger.error("Failed to initialize chat_messages table", error=str(e))
results['chat_messages'] = False
# Initialize combat_encounters table
try:
self.init_combat_encounters_table()
results['combat_encounters'] = True
logger.info("Combat encounters table initialized successfully")
except Exception as e:
logger.error("Failed to initialize combat_encounters table", error=str(e))
results['combat_encounters'] = False
# Initialize combat_rounds table
try:
self.init_combat_rounds_table()
results['combat_rounds'] = True
logger.info("Combat rounds table initialized successfully")
except Exception as e:
logger.error("Failed to initialize combat_rounds table", error=str(e))
results['combat_rounds'] = False
success_count = sum(1 for v in results.values() if v) success_count = sum(1 for v in results.values() if v)
total_count = len(results) total_count = len(results)
@@ -536,6 +563,527 @@ class DatabaseInitService:
code=e.code) code=e.code)
raise raise
def init_chat_messages_table(self) -> bool:
"""
Initialize the chat_messages table for storing player-NPC conversation history.
Table schema:
- message_id (string, required): Unique message identifier (UUID)
- character_id (string, required): Player's character ID
- npc_id (string, required): NPC identifier
- player_message (string, required): What the player said
- npc_response (string, required): NPC's reply
- timestamp (string, required): ISO timestamp when message was created
- session_id (string, optional): Game session reference
- location_id (string, optional): Where conversation happened
- context (string, required): Message context type (dialogue, quest, shop, etc.)
- metadata (string, optional): JSON metadata for quest_id, faction_id, etc.
- is_deleted (boolean, default=False): Soft delete flag
Indexes:
- character_id + npc_id + timestamp: Primary query pattern (conversation history)
- character_id + timestamp: All character messages
- session_id + timestamp: Session-based queries
- context: Filter by interaction type
- timestamp: Date range queries
Returns:
True if successful
Raises:
AppwriteException: If table creation fails
"""
table_id = 'chat_messages'
logger.info("Initializing chat_messages 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("Chat messages table already exists", table_id=table_id)
return True
except AppwriteException as e:
if e.code != 404:
raise
logger.info("Chat messages table does not exist, creating...")
# Create table
logger.info("Creating chat_messages table")
table = self.tables_db.create_table(
database_id=self.database_id,
table_id=table_id,
name='Chat Messages'
)
logger.info("Chat messages table created", table_id=table['$id'])
# Create columns
self._create_column(
table_id=table_id,
column_id='message_id',
column_type='string',
size=36, # UUID length
required=True
)
self._create_column(
table_id=table_id,
column_id='character_id',
column_type='string',
size=100,
required=True
)
self._create_column(
table_id=table_id,
column_id='npc_id',
column_type='string',
size=100,
required=True
)
self._create_column(
table_id=table_id,
column_id='player_message',
column_type='string',
size=2000, # Player input limit
required=True
)
self._create_column(
table_id=table_id,
column_id='npc_response',
column_type='string',
size=5000, # AI-generated response
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='session_id',
column_type='string',
size=100,
required=False
)
self._create_column(
table_id=table_id,
column_id='location_id',
column_type='string',
size=100,
required=False
)
self._create_column(
table_id=table_id,
column_id='context',
column_type='string',
size=50, # MessageContext enum values
required=True
)
self._create_column(
table_id=table_id,
column_id='metadata',
column_type='string',
size=1000, # JSON metadata
required=False
)
self._create_column(
table_id=table_id,
column_id='is_deleted',
column_type='boolean',
required=False,
default=False
)
# Wait for columns to fully propagate
logger.info("Waiting for columns to propagate before creating indexes...")
time.sleep(2)
# Create indexes for efficient querying
# Most common query: get conversation between character and specific NPC
self._create_index(
table_id=table_id,
index_id='idx_character_npc_time',
index_type='key',
attributes=['character_id', 'npc_id', 'timestamp']
)
# Get all messages for a character (across all NPCs)
self._create_index(
table_id=table_id,
index_id='idx_character_time',
index_type='key',
attributes=['character_id', 'timestamp']
)
# Session-based queries
self._create_index(
table_id=table_id,
index_id='idx_session_time',
index_type='key',
attributes=['session_id', 'timestamp']
)
# Filter by context (quest, shop, lore, etc.)
self._create_index(
table_id=table_id,
index_id='idx_context',
index_type='key',
attributes=['context']
)
# Date range queries
self._create_index(
table_id=table_id,
index_id='idx_timestamp',
index_type='key',
attributes=['timestamp']
)
logger.info("Chat messages table initialized successfully", table_id=table_id)
return True
except AppwriteException as e:
logger.error("Failed to initialize chat_messages table",
table_id=table_id,
error=str(e),
code=e.code)
raise
def init_combat_encounters_table(self) -> bool:
"""
Initialize the combat_encounters table for storing combat encounter state.
Table schema:
- sessionId (string, required): Game session ID (FK to game_sessions)
- userId (string, required): Owner user ID for authorization
- status (string, required): Combat status (active, victory, defeat, fled)
- roundNumber (integer, required): Current round number
- currentTurnIndex (integer, required): Index in turn_order for current turn
- turnOrder (string, required): JSON array of combatant IDs in initiative order
- combatantsData (string, required): JSON array of Combatant objects (full state)
- combatLog (string, optional): JSON array of all combat log entries
- created_at (string, required): ISO timestamp of combat start
- ended_at (string, optional): ISO timestamp when combat ended
Indexes:
- idx_sessionId: Session-based lookups
- idx_userId_status: User's active combats query
- idx_status_created_at: Time-based cleanup queries
Returns:
True if successful
Raises:
AppwriteException: If table creation fails
"""
table_id = 'combat_encounters'
logger.info("Initializing combat_encounters 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("Combat encounters table already exists", table_id=table_id)
return True
except AppwriteException as e:
if e.code != 404:
raise
logger.info("Combat encounters table does not exist, creating...")
# Create table
logger.info("Creating combat_encounters table")
table = self.tables_db.create_table(
database_id=self.database_id,
table_id=table_id,
name='Combat Encounters'
)
logger.info("Combat encounters table created", table_id=table['$id'])
# Create columns
self._create_column(
table_id=table_id,
column_id='sessionId',
column_type='string',
size=255,
required=True
)
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='status',
column_type='string',
size=20,
required=True
)
self._create_column(
table_id=table_id,
column_id='roundNumber',
column_type='integer',
required=True
)
self._create_column(
table_id=table_id,
column_id='currentTurnIndex',
column_type='integer',
required=True
)
self._create_column(
table_id=table_id,
column_id='turnOrder',
column_type='string',
size=2000, # JSON array of combatant IDs
required=True
)
self._create_column(
table_id=table_id,
column_id='combatantsData',
column_type='string',
size=65535, # Large text field for JSON combatant array
required=True
)
self._create_column(
table_id=table_id,
column_id='combatLog',
column_type='string',
size=65535, # Large text field for combat log
required=False
)
self._create_column(
table_id=table_id,
column_id='created_at',
column_type='string',
size=50, # ISO timestamp format
required=True
)
self._create_column(
table_id=table_id,
column_id='ended_at',
column_type='string',
size=50, # ISO timestamp format
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_sessionId',
index_type='key',
attributes=['sessionId']
)
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_status_created_at',
index_type='key',
attributes=['status', 'created_at']
)
logger.info("Combat encounters table initialized successfully", table_id=table_id)
return True
except AppwriteException as e:
logger.error("Failed to initialize combat_encounters table",
table_id=table_id,
error=str(e),
code=e.code)
raise
def init_combat_rounds_table(self) -> bool:
"""
Initialize the combat_rounds table for storing per-round action history.
Table schema:
- encounterId (string, required): FK to combat_encounters
- sessionId (string, required): Denormalized for efficient queries
- roundNumber (integer, required): Round number (1-indexed)
- actionsData (string, required): JSON array of all actions in this round
- combatantStatesStart (string, required): JSON snapshot of combatant states at round start
- combatantStatesEnd (string, required): JSON snapshot of combatant states at round end
- created_at (string, required): ISO timestamp when round completed
Indexes:
- idx_encounterId: Encounter-based lookups
- idx_encounterId_roundNumber: Ordered retrieval of rounds
- idx_sessionId: Session-based queries
- idx_created_at: Time-based cleanup
Returns:
True if successful
Raises:
AppwriteException: If table creation fails
"""
table_id = 'combat_rounds'
logger.info("Initializing combat_rounds 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("Combat rounds table already exists", table_id=table_id)
return True
except AppwriteException as e:
if e.code != 404:
raise
logger.info("Combat rounds table does not exist, creating...")
# Create table
logger.info("Creating combat_rounds table")
table = self.tables_db.create_table(
database_id=self.database_id,
table_id=table_id,
name='Combat Rounds'
)
logger.info("Combat rounds table created", table_id=table['$id'])
# Create columns
self._create_column(
table_id=table_id,
column_id='encounterId',
column_type='string',
size=36, # UUID format: enc_xxxxxxxxxxxx
required=True
)
self._create_column(
table_id=table_id,
column_id='sessionId',
column_type='string',
size=255,
required=True
)
self._create_column(
table_id=table_id,
column_id='roundNumber',
column_type='integer',
required=True
)
self._create_column(
table_id=table_id,
column_id='actionsData',
column_type='string',
size=65535, # JSON array of action objects
required=True
)
self._create_column(
table_id=table_id,
column_id='combatantStatesStart',
column_type='string',
size=65535, # JSON snapshot of combatant states
required=True
)
self._create_column(
table_id=table_id,
column_id='combatantStatesEnd',
column_type='string',
size=65535, # JSON snapshot of combatant states
required=True
)
self._create_column(
table_id=table_id,
column_id='created_at',
column_type='string',
size=50, # ISO timestamp format
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_encounterId',
index_type='key',
attributes=['encounterId']
)
self._create_index(
table_id=table_id,
index_id='idx_encounterId_roundNumber',
index_type='key',
attributes=['encounterId', 'roundNumber']
)
self._create_index(
table_id=table_id,
index_id='idx_sessionId',
index_type='key',
attributes=['sessionId']
)
self._create_index(
table_id=table_id,
index_id='idx_created_at',
index_type='key',
attributes=['created_at']
)
logger.info("Combat rounds table initialized successfully", table_id=table_id)
return True
except AppwriteException as e:
logger.error("Failed to initialize combat_rounds table",
table_id=table_id,
error=str(e),
code=e.code)
raise
def _create_column( def _create_column(
self, self,
table_id: str, table_id: str,

View File

@@ -0,0 +1,308 @@
"""
Encounter Generator Service - Generate random combat encounters.
This service generates location-appropriate, level-scaled encounter groups
for the "Search for Monsters" feature. Players can select from generated
encounter options to initiate combat.
"""
import random
import uuid
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from collections import Counter
from app.models.enemy import EnemyTemplate, EnemyDifficulty
from app.services.enemy_loader import get_enemy_loader
from app.utils.logging import get_logger
logger = get_logger(__file__)
@dataclass
class EncounterGroup:
"""
A generated encounter option for the player to choose.
Attributes:
group_id: Unique identifier for this encounter option
enemies: List of enemy_ids that will spawn
enemy_names: Display names for the UI
display_name: Formatted display string (e.g., "3 Goblin Scouts")
challenge: Difficulty label ("Easy", "Medium", "Hard", "Boss")
total_xp: Total XP reward (not displayed to player, used internally)
"""
group_id: str
enemies: List[str] # List of enemy_ids
enemy_names: List[str] # Display names
display_name: str # Formatted for display
challenge: str # "Easy", "Medium", "Hard", "Boss"
total_xp: int # Internal tracking, not displayed
def to_dict(self) -> Dict[str, Any]:
"""Serialize encounter group for API response."""
return {
"group_id": self.group_id,
"enemies": self.enemies,
"enemy_names": self.enemy_names,
"display_name": self.display_name,
"challenge": self.challenge,
}
class EncounterGenerator:
"""
Generates random encounter groups for a given location and character level.
Encounter difficulty is determined by:
- Character level (higher level = more enemies, harder varieties)
- Location type (different monsters in different areas)
- Random variance (some encounters harder/easier than average)
"""
def __init__(self):
"""Initialize the encounter generator."""
self.enemy_loader = get_enemy_loader()
def generate_encounters(
self,
location_type: str,
character_level: int,
num_encounters: int = 4
) -> List[EncounterGroup]:
"""
Generate multiple encounter options for the player to choose from.
Args:
location_type: Type of location (e.g., "forest", "town", "dungeon")
character_level: Current character level (1-20+)
num_encounters: Number of encounter options to generate (default 4)
Returns:
List of EncounterGroup options, each with different difficulty
"""
# Get enemies available at this location
available_enemies = self.enemy_loader.get_enemies_by_location(location_type)
if not available_enemies:
logger.warning(
"No enemies found for location",
location_type=location_type
)
return []
# Generate a mix of difficulties
# Always try to include: 1 Easy, 1-2 Medium, 0-1 Hard
encounters = []
difficulty_mix = self._get_difficulty_mix(character_level, num_encounters)
for target_difficulty in difficulty_mix:
encounter = self._generate_single_encounter(
available_enemies=available_enemies,
character_level=character_level,
target_difficulty=target_difficulty
)
if encounter:
encounters.append(encounter)
logger.info(
"Generated encounters",
location_type=location_type,
character_level=character_level,
num_encounters=len(encounters)
)
return encounters
def _get_difficulty_mix(
self,
character_level: int,
num_encounters: int
) -> List[str]:
"""
Determine the mix of encounter difficulties to generate.
Lower-level characters see more easy encounters.
Higher-level characters see more hard encounters.
Args:
character_level: Character's current level
num_encounters: Total encounters to generate
Returns:
List of target difficulty strings
"""
if character_level <= 2:
# Very low level: mostly easy
mix = ["Easy", "Easy", "Medium", "Easy"]
elif character_level <= 5:
# Low level: easy and medium
mix = ["Easy", "Medium", "Medium", "Hard"]
elif character_level <= 10:
# Mid level: balanced
mix = ["Easy", "Medium", "Hard", "Hard"]
else:
# High level: harder encounters
mix = ["Medium", "Hard", "Hard", "Boss"]
return mix[:num_encounters]
def _generate_single_encounter(
self,
available_enemies: List[EnemyTemplate],
character_level: int,
target_difficulty: str
) -> Optional[EncounterGroup]:
"""
Generate a single encounter group.
Args:
available_enemies: Pool of enemies to choose from
character_level: Character's level for scaling
target_difficulty: Target difficulty ("Easy", "Medium", "Hard", "Boss")
Returns:
EncounterGroup or None if generation fails
"""
# Map target difficulty to enemy difficulty levels
difficulty_mapping = {
"Easy": [EnemyDifficulty.EASY],
"Medium": [EnemyDifficulty.EASY, EnemyDifficulty.MEDIUM],
"Hard": [EnemyDifficulty.MEDIUM, EnemyDifficulty.HARD],
"Boss": [EnemyDifficulty.HARD, EnemyDifficulty.BOSS],
}
allowed_difficulties = difficulty_mapping.get(target_difficulty, [EnemyDifficulty.EASY])
# Filter enemies by difficulty
candidates = [
e for e in available_enemies
if e.difficulty in allowed_difficulties
]
if not candidates:
# Fall back to any available enemy
candidates = available_enemies
if not candidates:
return None
# Determine enemy count based on difficulty and level
enemy_count = self._calculate_enemy_count(
target_difficulty=target_difficulty,
character_level=character_level
)
# Select enemies (allowing duplicates for packs)
selected_enemies = random.choices(candidates, k=enemy_count)
# Build encounter group
enemy_ids = [e.enemy_id for e in selected_enemies]
enemy_names = [e.name for e in selected_enemies]
total_xp = sum(e.experience_reward for e in selected_enemies)
# Create display name (e.g., "3 Goblin Scouts" or "2 Goblins, 1 Goblin Shaman")
display_name = self._format_display_name(enemy_names)
return EncounterGroup(
group_id=f"enc_{uuid.uuid4().hex[:8]}",
enemies=enemy_ids,
enemy_names=enemy_names,
display_name=display_name,
challenge=target_difficulty,
total_xp=total_xp
)
def _calculate_enemy_count(
self,
target_difficulty: str,
character_level: int
) -> int:
"""
Calculate how many enemies should be in the encounter.
Args:
target_difficulty: Target difficulty level
character_level: Character's level
Returns:
Number of enemies to include
"""
# Base counts by difficulty
base_counts = {
"Easy": (1, 2), # 1-2 enemies
"Medium": (2, 3), # 2-3 enemies
"Hard": (2, 4), # 2-4 enemies
"Boss": (1, 3), # 1 boss + 0-2 adds
}
min_count, max_count = base_counts.get(target_difficulty, (1, 2))
# Scale slightly with level (higher level = can handle more)
level_bonus = min(character_level // 5, 2) # +1 enemy every 5 levels, max +2
max_count = min(max_count + level_bonus, 6) # Cap at 6 enemies
return random.randint(min_count, max_count)
def _format_display_name(self, enemy_names: List[str]) -> str:
"""
Format enemy names for display.
Examples:
["Goblin Scout"] -> "Goblin Scout"
["Goblin Scout", "Goblin Scout", "Goblin Scout"] -> "3 Goblin Scouts"
["Goblin Scout", "Goblin Shaman"] -> "Goblin Scout, Goblin Shaman"
Args:
enemy_names: List of enemy display names
Returns:
Formatted display string
"""
if len(enemy_names) == 1:
return enemy_names[0]
# Count occurrences
counts = Counter(enemy_names)
if len(counts) == 1:
# All same enemy type
name = list(counts.keys())[0]
count = list(counts.values())[0]
# Simple pluralization
if count > 1:
if name.endswith('f'):
# wolf -> wolves
plural_name = name[:-1] + "ves"
elif name.endswith('s') or name.endswith('x') or name.endswith('ch'):
plural_name = name + "es"
else:
plural_name = name + "s"
return f"{count} {plural_name}"
return name
else:
# Mixed enemy types - list them
parts = []
for name, count in counts.items():
if count > 1:
parts.append(f"{count}x {name}")
else:
parts.append(name)
return ", ".join(parts)
# Global instance
_generator_instance: Optional[EncounterGenerator] = None
def get_encounter_generator() -> EncounterGenerator:
"""
Get the global EncounterGenerator instance.
Returns:
Singleton EncounterGenerator instance
"""
global _generator_instance
if _generator_instance is None:
_generator_instance = EncounterGenerator()
return _generator_instance

Some files were not shown because too many files have changed in this diff Show More