Combat foundation complete

This commit is contained in:
2025-11-27 22:18:58 -06:00
parent dd92cf5991
commit 6d3fb63355
33 changed files with 1870 additions and 85 deletions

View File

@@ -36,7 +36,7 @@ def combat_view(session_id: str):
# Check if combat is still active
if not result.get('in_combat'):
# Combat ended - redirect to game play
return redirect(url_for('game.play', session_id=session_id))
return redirect(url_for('game.play_session', session_id=session_id))
encounter = result.get('encounter') or {}
combat_log = result.get('combat_log', [])
@@ -171,9 +171,11 @@ def combat_action(session_id: str):
# Add any effect entries
for effect in result.get('effects_applied', []):
# API may use "name" or "effect" key for the effect name
effect_name = effect.get('name') or effect.get('effect') or 'Unknown'
log_entries.append({
'actor': '',
'message': effect.get('message', f'Effect applied: {effect.get("name")}'),
'message': effect.get('message', f'Effect applied: {effect_name}'),
'type': 'system'
})
@@ -417,15 +419,25 @@ def combat_flee(session_id: str):
result = response.get('result', {})
if result.get('success'):
# Flee successful - redirect to play page
return redirect(url_for('game.play_session', session_id=session_id))
# Flee successful - use HX-Redirect for HTMX
resp = make_response(f'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">{result.get('message', 'You fled from combat!')}</span>
</div>
''')
resp.headers['HX-Redirect'] = url_for('game.play_session', session_id=session_id)
return resp
else:
# Flee failed - return log entry
return f'''
# Flee failed - return log entry, trigger enemy turn
resp = make_response(f'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
</div>
'''
''')
# Failed flee consumes turn, so trigger enemy turn if needed
if not result.get('next_is_player', True):
resp.headers['HX-Trigger'] = 'enemyTurn'
return resp
except APIError as e:
logger.error("flee_failed", session_id=session_id, error=str(e))
@@ -468,18 +480,19 @@ def combat_enemy_turn(session_id: str):
)
# Format enemy action for log
action_result = result.get('action_result', {})
# API returns ActionResult directly in result, not nested under action_result
log_entries = [{
'actor': action_result.get('actor_name', 'Enemy'),
'message': action_result.get('message', 'attacks'),
'actor': 'Enemy',
'message': result.get('message', 'attacks'),
'type': 'enemy',
'is_crit': action_result.get('is_critical', False)
'is_crit': False
}]
# Add damage info
damage_results = action_result.get('damage_results', [])
# Add damage info - API returns total_damage, not damage
damage_results = result.get('damage_results', [])
if damage_results:
log_entries[0]['damage'] = damage_results[0].get('damage')
log_entries[0]['damage'] = damage_results[0].get('total_damage')
log_entries[0]['is_crit'] = damage_results[0].get('is_critical', False)
# Check if it's still enemy turn (multiple enemies)
resp = make_response(render_template(

View File

@@ -25,7 +25,6 @@ game_bp = Blueprint('game', __name__, url_prefix='/play')
DEFAULT_ACTIONS = {
'free': [
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
{'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']},
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
],
@@ -718,6 +717,243 @@ def do_travel(session_id: str):
return f'<div class="error">Travel failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/monster-modal')
@require_auth
def monster_modal(session_id: str):
"""
Get monster selection modal with encounter options.
Fetches random encounter groups appropriate for the current location
and character level from the API.
"""
client = get_api_client()
try:
# Get encounter options from API
response = client.get(f'/api/v1/combat/encounters?session_id={session_id}')
result = response.get('result', {})
location_name = result.get('location_name', 'Unknown Area')
encounters = result.get('encounters', [])
return render_template(
'game/partials/monster_modal.html',
session_id=session_id,
location_name=location_name,
encounters=encounters
)
except APINotFoundError as e:
# No enemies found for this location
return render_template(
'game/partials/monster_modal.html',
session_id=session_id,
location_name='this area',
encounters=[]
)
except APIError as e:
logger.error("failed_to_load_monster_modal", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">⚔️ Search for Monsters</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="error">Failed to search for monsters: {e}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/combat/start', methods=['POST'])
@require_auth
def start_combat(session_id: str):
"""
Start combat with selected enemies.
Called when player selects an encounter from the monster modal.
Initiates combat via API and redirects to combat UI.
If there's already an active combat session, shows a conflict modal
allowing the user to resume or abandon the existing combat.
"""
from flask import make_response
client = get_api_client()
# Get enemy_ids from request
# HTMX hx-vals sends as form data (not JSON), where arrays become multiple values
if request.is_json:
enemy_ids = request.json.get('enemy_ids', [])
else:
# Form data: array values come as multiple entries with the same key
enemy_ids = request.form.getlist('enemy_ids')
if not enemy_ids:
return '<div class="error">No enemies selected.</div>', 400
try:
# Start combat via API
response = client.post('/api/v1/combat/start', {
'session_id': session_id,
'enemy_ids': enemy_ids
})
result = response.get('result', {})
encounter_id = result.get('encounter_id')
if not encounter_id:
logger.error("combat_start_no_encounter_id", session_id=session_id)
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
logger.info("combat_started_from_modal",
session_id=session_id,
encounter_id=encounter_id,
enemy_count=len(enemy_ids))
# Close modal and redirect to combat page
resp = make_response('')
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
return resp
except APIError as e:
# Check if this is an "already in combat" error
error_str = str(e)
if 'already in combat' in error_str.lower() or 'ALREADY_IN_COMBAT' in error_str:
# Fetch existing combat info and show conflict modal
try:
check_response = client.get(f'/api/v1/combat/{session_id}/check')
combat_info = check_response.get('result', {})
if combat_info.get('has_active_combat'):
return render_template(
'game/partials/combat_conflict_modal.html',
session_id=session_id,
combat_info=combat_info,
pending_enemy_ids=enemy_ids
)
except APIError:
pass # Fall through to generic error
logger.error("failed_to_start_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to start combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/combat/check', methods=['GET'])
@require_auth
def check_combat_status(session_id: str):
"""
Check if the session has an active combat.
Returns JSON with combat status that can be used by HTMX
to decide whether to show the monster modal or conflict modal.
"""
client = get_api_client()
try:
response = client.get(f'/api/v1/combat/{session_id}/check')
result = response.get('result', {})
return result
except APIError as e:
logger.error("failed_to_check_combat", session_id=session_id, error=str(e))
return {'has_active_combat': False, 'error': str(e)}
@game_bp.route('/session/<session_id>/combat/abandon', methods=['POST'])
@require_auth
def abandon_combat(session_id: str):
"""
Abandon an existing combat session.
Called when player chooses to abandon their current combat
in order to start a fresh one.
"""
client = get_api_client()
try:
response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
result = response.get('result', {})
if result.get('success'):
logger.info("combat_abandoned", session_id=session_id)
# Return success - the frontend will then try to start new combat
return render_template(
'game/partials/combat_abandoned_success.html',
session_id=session_id,
message="Combat abandoned. You can now start a new encounter."
)
else:
return '<div class="error">No active combat to abandon.</div>', 400
except APIError as e:
logger.error("failed_to_abandon_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to abandon combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/combat/abandon-and-start', methods=['POST'])
@require_auth
def abandon_and_start_combat(session_id: str):
"""
Abandon existing combat and start a new one in a single action.
This is a convenience endpoint that combines abandon + start
for a smoother user experience in the conflict modal.
"""
from flask import make_response
client = get_api_client()
# Get enemy_ids from request
if request.is_json:
enemy_ids = request.json.get('enemy_ids', [])
else:
enemy_ids = request.form.getlist('enemy_ids')
if not enemy_ids:
return '<div class="error">No enemies selected.</div>', 400
try:
# First abandon the existing combat
abandon_response = client.post(f'/api/v1/combat/{session_id}/abandon', {})
abandon_result = abandon_response.get('result', {})
if not abandon_result.get('success'):
# No combat to abandon, but that's fine - proceed with start
logger.info("no_combat_to_abandon", session_id=session_id)
# Now start the new combat
start_response = client.post('/api/v1/combat/start', {
'session_id': session_id,
'enemy_ids': enemy_ids
})
result = start_response.get('result', {})
encounter_id = result.get('encounter_id')
if not encounter_id:
logger.error("combat_start_no_encounter_id_after_abandon", session_id=session_id)
return '<div class="error">Failed to start combat - no encounter ID returned.</div>', 500
logger.info("combat_started_after_abandon",
session_id=session_id,
encounter_id=encounter_id,
enemy_count=len(enemy_ids))
# Close modal and redirect to combat page
resp = make_response('')
resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id)
return resp
except APIError as e:
logger.error("failed_to_abandon_and_start_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to start combat: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>')
@require_auth
def npc_chat_page(session_id: str, npc_id: str):

View File

@@ -12,6 +12,10 @@
--combat-system: var(--text-muted); /* Gray for system messages */
--combat-heal: var(--accent-green); /* Green for healing */
/* Resource bar colors */
--hp-bar-fill: #ef4444; /* Red for HP */
--mp-bar-fill: #3b82f6; /* Blue for MP */
/* Combat panel sizing */
--combat-sidebar-width: 280px;
--combat-header-height: 60px;

View File

@@ -1119,6 +1119,161 @@
margin-top: 0.25rem;
}
/* Monster Selection Modal */
.monster-modal {
max-width: 500px;
}
.monster-modal-location {
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: var(--text-sm);
}
.monster-modal-hint {
color: var(--text-muted);
margin-top: 1rem;
text-align: center;
}
.encounter-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.encounter-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--play-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
border-left: 4px solid var(--text-muted);
}
.encounter-option:hover {
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Challenge level border colors */
.encounter-option--easy {
border-left-color: #2ecc71; /* Green for easy */
}
.encounter-option--easy:hover {
border-color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
.encounter-option--medium {
border-left-color: #f39c12; /* Gold/orange for medium */
}
.encounter-option--medium:hover {
border-color: #f39c12;
background: rgba(243, 156, 18, 0.1);
}
.encounter-option--hard {
border-left-color: #e74c3c; /* Red for hard */
}
.encounter-option--hard:hover {
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
}
.encounter-option--boss {
border-left-color: #9b59b6; /* Purple for boss */
}
.encounter-option--boss:hover {
border-color: #9b59b6;
background: rgba(155, 89, 182, 0.1);
}
.encounter-info {
flex: 1;
}
.encounter-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.encounter-enemies {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.enemy-badge {
font-size: var(--text-xs);
color: var(--text-muted);
background: var(--bg-card);
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.encounter-challenge {
font-size: var(--text-sm);
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.challenge--easy {
color: #2ecc71;
background: rgba(46, 204, 113, 0.15);
}
.challenge--medium {
color: #f39c12;
background: rgba(243, 156, 18, 0.15);
}
.challenge--hard {
color: #e74c3c;
background: rgba(231, 76, 60, 0.15);
}
.challenge--boss {
color: #9b59b6;
background: rgba(155, 89, 182, 0.15);
}
.encounter-empty {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
.encounter-empty p {
margin: 0.5rem 0;
}
/* Combat action button highlight */
.action-btn--combat {
background: linear-gradient(135deg, rgba(231, 76, 60, 0.2), rgba(155, 89, 182, 0.2));
border-color: rgba(231, 76, 60, 0.4);
}
.action-btn--combat:hover {
background: linear-gradient(135deg, rgba(231, 76, 60, 0.3), rgba(155, 89, 182, 0.3));
border-color: rgba(231, 76, 60, 0.6);
}
/* NPC Chat Modal */
.npc-chat-header {
display: flex;

View File

@@ -142,7 +142,7 @@
{% endif %}
</span>
<span class="effect-name">{{ effect.name }}</span>
<span class="effect-duration">{{ effect.remaining_duration }} {% if effect.remaining_duration == 1 %}turn{% else %}turns{% endif %}</span>
<span class="effect-duration">{{ effect.duration }} {% if effect.duration == 1 %}turn{% else %}turns{% endif %}</span>
</div>
{% endfor %}
</div>
@@ -206,43 +206,73 @@
}
});
// Guard against duplicate enemy turn requests
// Enemy turn handling with proper chaining for multiple enemies
let enemyTurnPending = false;
let enemyTurnTimeout = null;
function triggerEnemyTurn() {
// Prevent duplicate requests
if (enemyTurnPending) {
return;
}
// Clear any pending timeout
if (enemyTurnTimeout) {
clearTimeout(enemyTurnTimeout);
}
enemyTurnPending = true;
enemyTurnTimeout = setTimeout(function() {
htmx.ajax('POST', '{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
target: '#combat-log',
swap: 'beforeend'
}).then(function() {
setTimeout(function() {
// Use fetch instead of htmx.ajax for better control over response handling
fetch('{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'HX-Request': 'true'
},
credentials: 'same-origin'
})
.then(function(response) {
const hasMoreEnemies = response.headers.get('HX-Trigger')?.includes('enemyTurn');
return response.text().then(function(html) {
return { html: html, hasMoreEnemies: hasMoreEnemies };
});
})
.then(function(data) {
// Append the log entry
const combatLog = document.getElementById('combat-log');
if (combatLog) {
combatLog.insertAdjacentHTML('beforeend', data.html);
combatLog.scrollTop = combatLog.scrollHeight;
}
enemyTurnPending = false;
}).catch(function() {
if (data.hasMoreEnemies) {
// More enemies to go - trigger next enemy turn
triggerEnemyTurn();
} else {
// All enemies done - refresh page to update UI
setTimeout(function() {
window.location.reload();
}, 800);
}
})
.catch(function(error) {
console.error('Enemy turn failed:', error);
enemyTurnPending = false;
// Refresh anyway to recover from error state
setTimeout(function() {
window.location.reload();
}, 1000);
});
}, 1000);
}
// Handle enemy turn polling
// Handle player action triggering enemy turn
document.body.addEventListener('htmx:afterRequest', function(event) {
// Check if we need to trigger enemy turn
const response = event.detail.xhr;
if (response && response.getResponseHeader('HX-Trigger')) {
const triggers = response.getResponseHeader('HX-Trigger');
if (triggers && triggers.includes('enemyTurn')) {
triggerEnemyTurn();
}
if (!response) return;
const triggers = response.getResponseHeader('HX-Trigger') || '';
// Only trigger enemy turn from player actions (not from our fetch calls)
if (triggers.includes('enemyTurn') && !enemyTurnPending) {
triggerEnemyTurn();
}
});
@@ -254,5 +284,13 @@
// Let the full page swap happen for victory/defeat screen
}
});
// Auto-trigger enemy turn on page load if it's not the player's turn
{% if not is_player_turn %}
document.addEventListener('DOMContentLoaded', function() {
// Small delay to let the page render first
triggerEnemyTurn();
});
{% endif %}
</script>
{% endblock %}

View File

@@ -119,6 +119,14 @@ Displays character stats, resource bars, and action buttons
hx-swap="innerHTML">
🗺️ Travel to...
</button>
{# Search for Monsters - Opens modal with encounter options #}
<button class="action-btn action-btn--special action-btn--combat"
hx-get="{{ url_for('game.monster_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML">
⚔️ Search for Monsters
</button>
</div>
{# Actions Section #}

View File

@@ -0,0 +1,38 @@
{#
Combat Abandoned Success Message
Shows after successfully abandoning a combat session
#}
<div class="combat-abandoned-success">
<div class="success-icon">&#10004;</div>
<p class="success-message">{{ message }}</p>
<p class="success-hint">
<small>Click "Search for Monsters" to find a new encounter.</small>
</p>
<button class="btn btn-secondary" onclick="closeModal()">
Close
</button>
</div>
<style>
.combat-abandoned-success {
text-align: center;
padding: 2rem;
}
.success-icon {
font-size: 3rem;
color: var(--color-success, #28a745);
margin-bottom: 1rem;
}
.success-message {
font-size: 1.1rem;
color: var(--color-text, #e5e7eb);
margin-bottom: 0.5rem;
}
.success-hint {
color: var(--color-text-secondary, #aaa);
margin-bottom: 1.5rem;
}
</style>

View File

@@ -50,8 +50,8 @@
{# Flee Button - Direct action #}
<button class="combat-action-btn combat-action-btn--flee"
hx-post="{{ url_for('combat.combat_flee', session_id=session_id) }}"
hx-target="body"
hx-swap="innerHTML"
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
hx-confirm="Are you sure you want to flee from combat?"
title="Attempt to escape from battle">

View File

@@ -0,0 +1,220 @@
{#
Combat Conflict Modal
Shows when player tries to start combat but already has an active combat session
#}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content combat-conflict-modal">
<div class="modal-header">
<h3 class="modal-title">Active Combat Detected</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="conflict-warning">
<p>You have an active combat session in progress:</p>
</div>
<div class="combat-summary">
<div class="combat-summary-header">
<span class="combat-round">Round {{ combat_info.round_number }}</span>
<span class="combat-status combat-status--{{ combat_info.status }}">{{ combat_info.status|capitalize }}</span>
</div>
<div class="combatants-section">
<div class="combatants-group combatants-group--players">
<h4>Your Party</h4>
{% for player in combat_info.players %}
<div class="combatant-summary {% if not player.is_alive %}combatant-summary--dead{% endif %}">
<span class="combatant-name">{{ player.name }}</span>
<span class="combatant-hp">
{{ player.current_hp }}/{{ player.max_hp }} HP
</span>
</div>
{% endfor %}
</div>
<div class="combatants-vs">VS</div>
<div class="combatants-group combatants-group--enemies">
<h4>Enemies</h4>
{% for enemy in combat_info.enemies %}
<div class="combatant-summary {% if not enemy.is_alive %}combatant-summary--dead{% endif %}">
<span class="combatant-name">{{ enemy.name }}</span>
<span class="combatant-hp">
{% if enemy.is_alive %}
{{ enemy.current_hp }}/{{ enemy.max_hp }} HP
{% else %}
Defeated
{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="conflict-options">
<p>What would you like to do?</p>
</div>
</div>
<div class="modal-footer conflict-actions">
<a href="{{ url_for('combat.combat_view', session_id=session_id) }}"
class="btn btn-primary btn-resume">
Resume Combat
</a>
<button class="btn btn-danger btn-abandon"
hx-post="{{ url_for('game.abandon_and_start_combat', session_id=session_id) }}"
hx-vals='{"enemy_ids": {{ pending_enemy_ids|tojson }}}'
hx-swap="none"
hx-confirm="Are you sure you want to abandon your current combat? You will not receive any rewards.">
Abandon & Start New
</button>
<button class="btn btn-secondary" onclick="closeModal()">
Cancel
</button>
</div>
</div>
</div>
<style>
.combat-conflict-modal {
max-width: 500px;
}
.conflict-warning {
background: var(--color-warning-bg, rgba(255, 193, 7, 0.1));
border: 1px solid var(--color-warning, #ffc107);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.conflict-warning p {
margin: 0;
color: var(--color-warning, #ffc107);
font-weight: 500;
}
.combat-summary {
background: var(--color-surface, #2a2a2a);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.combat-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #444);
}
.combat-round {
font-weight: 600;
color: var(--color-text-secondary, #aaa);
}
.combat-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
}
.combat-status--active {
background: var(--color-success-bg, rgba(40, 167, 69, 0.2));
color: var(--color-success, #28a745);
}
.combatants-section {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.combatants-group {
flex: 1;
}
.combatants-group h4 {
font-size: 0.85rem;
color: var(--color-text-secondary, #aaa);
margin: 0 0 0.5rem 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.combatants-vs {
padding: 0.5rem;
color: var(--color-text-muted, #666);
font-weight: 600;
align-self: center;
}
.combatant-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.35rem 0.5rem;
background: var(--color-bg, #1a1a1a);
border-radius: 4px;
margin-bottom: 0.25rem;
font-size: 0.9rem;
}
.combatant-summary--dead {
opacity: 0.5;
text-decoration: line-through;
}
.combatant-name {
font-weight: 500;
}
.combatant-hp {
color: var(--color-text-secondary, #aaa);
font-size: 0.85rem;
}
.combatants-group--players .combatant-hp {
color: var(--color-health, #4ade80);
}
.combatants-group--enemies .combatant-hp {
color: var(--color-danger, #ef4444);
}
.conflict-options {
text-align: center;
color: var(--color-text-secondary, #aaa);
}
.conflict-options p {
margin: 0;
}
.conflict-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
.conflict-actions .btn {
flex: 1;
min-width: 120px;
}
.btn-resume {
background: var(--color-primary, #8b5cf6);
}
.btn-abandon {
background: var(--color-danger, #ef4444);
}
.btn-abandon:hover {
background: var(--color-danger-hover, #dc2626);
}
</style>

View File

@@ -1,12 +1,5 @@
{% extends "base.html" %}
{# Combat Defeat Partial - Swapped into combat log when player loses #}
{% block title %}Defeated - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
{% endblock %}
{% block content %}
<div class="combat-result combat-result--defeat">
<div class="combat-result__icon">&#128128;</div>
<h1 class="combat-result__title">Defeated</h1>
@@ -52,4 +45,3 @@
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,12 +1,5 @@
{% extends "base.html" %}
{# Combat Victory Partial - Swapped into combat log when player wins #}
{% block title %}Victory! - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
{% endblock %}
{% block content %}
<div class="combat-result combat-result--victory">
<div class="combat-result__icon">&#127942;</div>
<h1 class="combat-result__title">Victory!</h1>
@@ -47,12 +40,12 @@
{% endif %}
</div>
{# Loot Items #}
{% if rewards.items %}
{# Loot Items - use bracket notation to avoid conflict with dict.items() method #}
{% if rewards.get('items') %}
<div class="loot-section">
<h3 class="loot-title">Items Obtained</h3>
<div class="loot-list">
{% for item in rewards.items %}
{% for item in rewards.get('items', []) %}
<div class="loot-item loot-item--{{ item.rarity|default('common') }}">
<span>
{% if item.type == 'weapon' %}&#9876;
@@ -63,7 +56,7 @@
{% endif %}
</span>
<span>{{ item.name }}</span>
{% if item.quantity > 1 %}
{% if item.get('quantity', 1) > 1 %}
<span style="color: var(--text-muted);">x{{ item.quantity }}</span>
{% endif %}
</div>
@@ -81,4 +74,3 @@
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{#
Monster Selection Modal
Shows random encounter options for the current location
#}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content monster-modal">
<div class="modal-header">
<h3 class="modal-title">⚔️ Search for Monsters</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="monster-modal-location">
Searching near <strong>{{ location_name }}</strong>...
</p>
{% if encounters %}
<div class="encounter-options">
{% for enc in encounters %}
<button class="encounter-option encounter-option--{{ enc.challenge|lower }}"
hx-post="{{ url_for('game.start_combat', session_id=session_id) }}"
hx-vals='{"enemy_ids": {{ enc.enemies|tojson }}}'
hx-target="closest .modal-overlay"
hx-swap="outerHTML">
<div class="encounter-info">
<div class="encounter-name">{{ enc.display_name }}</div>
<div class="encounter-enemies">
{% for name in enc.enemy_names %}
<span class="enemy-badge">{{ name }}</span>
{% endfor %}
</div>
</div>
<div class="encounter-challenge challenge--{{ enc.challenge|lower }}">
{{ enc.challenge }}
</div>
</button>
{% endfor %}
</div>
<p class="monster-modal-hint">
<small>Select an encounter to begin combat. Challenge level indicates difficulty.</small>
</p>
{% else %}
<div class="encounter-empty">
<p>No monsters found in this area.</p>
<p><small>Try exploring somewhere more dangerous!</small></p>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal()">
Cancel
</button>
</div>
</div>
</div>