Initial commit: Echoes of the Ashes - Telegram RPG Bot

This commit is contained in:
Joan
2025-10-18 19:21:19 +02:00
commit 3ab412bc09
65 changed files with 14484 additions and 0 deletions

0
data/__init__.py Normal file
View File

71
data/items.py Normal file
View File

@@ -0,0 +1,71 @@
# Definitions for all items in the game.
# Now loaded from JSON file
import json
from pathlib import Path
def load_items():
"""Load items from JSON file"""
json_path = Path(__file__).parent.parent / 'gamedata' / 'items.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
return data['items']
except FileNotFoundError:
print(f"⚠️ Warning: {json_path} not found, using fallback items")
return _get_fallback_items()
except Exception as e:
print(f"⚠️ Warning: Error loading items from JSON: {e}")
return _get_fallback_items()
def _get_fallback_items():
"""Fallback items if JSON loading fails"""
return {
# Resources
"scrap_metal": {"name": "Scrap Metal", "weight": 0.5, "volume": 0.2, "type": "resource", "emoji": "⚙️"},
"rusty_nails": {"name": "Rusty Nails", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "📌"},
"wood_planks": {"name": "Wood Planks", "weight": 3.0, "volume": 2.0, "type": "resource", "emoji": "🪵"},
"cloth_scraps": {"name": "Cloth Scraps", "weight": 0.1, "volume": 0.2, "type": "resource", "emoji": "🧵"},
"cloth": {"name": "Cloth", "weight": 0.1, "volume": 0.2, "type": "resource", "emoji": "🧵"},
"plastic_bottles": {"name": "Plastic Bottles", "weight": 0.05, "volume": 0.3, "type": "resource", "emoji": "🍶"},
"bone": {"name": "Bone", "weight": 0.3, "volume": 0.1, "type": "resource", "emoji": "🦴"},
"raw_meat": {"name": "Raw Meat", "weight": 0.5, "volume": 0.2, "type": "resource", "emoji": "🥩"},
"animal_hide": {"name": "Animal Hide", "weight": 0.4, "volume": 0.3, "type": "resource", "emoji": "🧤"},
"mutant_tissue": {"name": "Mutant Tissue", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "🧬"},
"infected_tissue": {"name": "Infected Tissue", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "☣️"},
# Consumables - Food
"stale_chocolate_bar": {"name": "Stale Chocolate Bar", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 10, "emoji": "🍫"},
"canned_beans": {"name": "Canned Beans", "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 20, "stamina_restore": 5, "emoji": "🥫"},
"canned_food": {"name": "Canned Food", "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 25, "stamina_restore": 5, "emoji": "🥫"},
"bottled_water": {"name": "Bottled Water", "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, "emoji": "💧"},
"water_bottle": {"name": "Water Bottle", "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, "emoji": "💧"},
"energy_bar": {"name": "Energy Bar", "weight": 0.1, "volume": 0.1, "type": "consumable", "stamina_restore": 15, "emoji": "🍫"},
"mystery_pills": {"name": "Mystery Pills", "weight": 0.05, "volume": 0.05, "type": "consumable", "hp_restore": 30, "emoji": "💊"},
# Consumables - Medical
"first_aid_kit": {"name": "First Aid Kit", "description": "A professional medical kit with bandages, antiseptic, and pain relievers.", "weight": 0.8, "volume": 0.5, "type": "consumable", "hp_restore": 50, "emoji": "🩹"},
"bandage": {"name": "Bandage", "description": "Clean cloth bandages for treating minor wounds.", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 15, "emoji": "🩹"},
"medical_supplies": {"name": "Medical Supplies", "description": "Assorted medical supplies scavenged from a clinic.", "weight": 0.6, "volume": 0.4, "type": "consumable", "hp_restore": 40, "emoji": "⚕️"},
"antibiotics": {"name": "Antibiotics", "description": "Pills that fight infections. Expired, but better than nothing.", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 20, "emoji": "💊"},
# Weapons & Tools
"tire_iron": {"name": "Tire Iron", "description": "A heavy metal tool. Makes a decent improvised weapon.", "weight": 2.0, "volume": 1.0, "type": "weapon", "slot": "hand", "damage_min": 3, "damage_max": 7, "emoji": "🔧"},
"baseball_bat": {"name": "Baseball Bat", "description": "Wooden bat with dents and bloodstains. Someone used this before you.", "weight": 1.0, "volume": 1.5, "type": "weapon", "slot": "hand", "damage_min": 2, "damage_max": 6, "emoji": ""},
"rusty_knife": {"name": "Rusty Knife", "description": "A dull, rusted blade. Better than your fists.", "weight": 0.3, "volume": 0.2, "type": "weapon", "slot": "hand", "damage_min": 2, "damage_max": 5, "emoji": "🔪"},
"knife": {"name": "Knife", "description": "A sharp survival knife in decent condition.", "weight": 0.3, "volume": 0.2, "type": "weapon", "slot": "hand", "damage_min": 3, "damage_max": 6, "emoji": "🔪"},
"rusty_pipe": {"name": "Rusty Pipe", "description": "Heavy metal pipe. Crude but effective.", "weight": 1.5, "volume": 0.8, "type": "weapon", "slot": "hand", "damage_min": 4, "damage_max": 8, "emoji": "🔩"},
# Equipment
"tattered_rucksack": {"name": "Tattered Rucksack", "description": "An old backpack with torn straps. Still functional.", "weight": 1.0, "volume": 0, "type": "equipment", "slot": "back", "capacity_weight": 10, "capacity_volume": 10, "emoji": "🎒"},
"hiking_backpack": {"name": "Hiking Backpack", "description": "A quality backpack with multiple compartments.", "weight": 1.5, "volume": 0, "type": "equipment", "slot": "back", "capacity_weight": 20, "capacity_volume": 20, "emoji": "🎒"},
"flashlight": {"name": "Flashlight", "description": "A battery-powered flashlight. Batteries low but working.", "weight": 0.3, "volume": 0.2, "type": "equipment", "slot": "tool", "emoji": "🔦"},
# Quest Items
"old_photograph": {"name": "Old Photograph", "weight": 0.01, "volume": 0.01, "type": "quest", "emoji": "📷"},
"key_ring": {"name": "Key Ring", "weight": 0.1, "volume": 0.05, "type": "quest", "emoji": "🔑"},
}
# Load items from JSON on module import
ITEMS = load_items()

57
data/models.py Normal file
View File

@@ -0,0 +1,57 @@
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class Outcome:
text: str
items_reward: Dict[str, int] = field(default_factory=dict)
damage_taken: int = 0
@dataclass
class Action:
id: str
label: str
stamina_cost: int
outcomes: Dict[str, Outcome] = field(default_factory=dict)
def add_outcome(self, name: str, outcome: Outcome): self.outcomes[name] = outcome
@dataclass
class Interactable:
id: str
name: str
actions: Dict[str, Action] = field(default_factory=dict)
image_path: Optional[str] = None
def add_action(self, action: Action): self.actions[action.id] = action
def get_action(self, action_id: str) -> Optional[Action]: return self.actions.get(action_id)
@dataclass
class Location:
id: str
name: str
description: str
exits: Dict[str, str] = field(default_factory=dict)
interactables: Dict[str, Interactable] = field(default_factory=dict) # Key is now the INSTANCE_ID
image_path: Optional[str] = None
x: float = 0.0 # X coordinate for map positioning
y: float = 0.0 # Y coordinate for map positioning
def add_exit(self, direction: str, destination_id: str):
self.exits[direction] = destination_id
def add_interactable(self, instance_id: str, interactable_template: Interactable):
"""Adds an instance of an interactable template to the location."""
self.interactables[instance_id] = interactable_template
def get_interactable(self, instance_id: str) -> Optional[Interactable]:
return self.interactables.get(instance_id)
class World:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(World, cls).__new__(cls)
cls._instance.locations = {}
return cls._instance
def add_location(self, location: Location): self.locations[location.id] = location
def get_location(self, location_id: str) -> Optional[Location]: return self.locations.get(location_id)

236
data/npcs.py Normal file
View File

@@ -0,0 +1,236 @@
"""
NPC definitions for combat encounters - NOW LOADED FROM JSON
Each NPC has stats, loot tables, and combat behavior.
"""
from dataclasses import dataclass
from typing import Dict, List, Optional
import json
from pathlib import Path
@dataclass
class LootItem:
"""Item that can be dropped by NPCs"""
item_id: str
quantity_min: int
quantity_max: int
drop_chance: float # 0.0 to 1.0
@dataclass
class CorpseLoot:
"""Item that can be scavenged from a corpse"""
item_id: str
quantity_min: int
quantity_max: int
required_tool: Optional[str] = None # item_id of required tool
@dataclass
class StatusEffect:
"""Status effect data"""
name: str
duration_turns: int
damage_per_turn: int = 0 # For bleeding
stun: bool = False # Prevents action
@dataclass
class NPCDefinition:
"""Complete NPC definition"""
npc_id: str
name: str
description: str
emoji: str
# Combat stats
hp_min: int
hp_max: int
damage_min: int
damage_max: int
defense: int # Reduces incoming damage
# Rewards
xp_reward: int
loot_table: List[LootItem]
corpse_loot: List[CorpseLoot]
# Behavior
flee_chance: float # NPC's chance to flee if low HP
status_inflict_chance: float # Chance to inflict status on player
# Visuals
image_url: Optional[str] = None
death_message: str = "The enemy falls defeated."
def load_npcs_from_json():
"""Load NPCs, danger levels, and spawn tables from JSON"""
json_path = Path(__file__).parent.parent / 'gamedata' / 'npcs.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
# Convert JSON to NPCDefinition objects
npcs = {}
for npc_id, npc_data in data['npcs'].items():
# Convert loot tables
loot_table = [
LootItem(**loot) for loot in npc_data.get('loot_table', [])
]
corpse_loot = [
CorpseLoot(**loot) for loot in npc_data.get('corpse_loot', [])
]
# Create NPC definition
npcs[npc_id] = NPCDefinition(
npc_id=npc_data['npc_id'],
name=npc_data['name'],
description=npc_data['description'],
emoji=npc_data['emoji'],
hp_min=npc_data['hp_min'],
hp_max=npc_data['hp_max'],
damage_min=npc_data['damage_min'],
damage_max=npc_data['damage_max'],
defense=npc_data['defense'],
xp_reward=npc_data['xp_reward'],
loot_table=loot_table,
corpse_loot=corpse_loot,
flee_chance=npc_data['flee_chance'],
status_inflict_chance=npc_data['status_inflict_chance'],
image_url=npc_data.get('image_url'),
death_message=npc_data.get('death_message', "The enemy falls defeated.")
)
# Load danger levels - convert to tuple format (danger_level, encounter_rate, wandering_chance)
danger_levels = {}
for loc_id, danger_data in data['danger_levels'].items():
danger_levels[loc_id] = (
danger_data['danger_level'],
danger_data['encounter_rate'],
danger_data['wandering_chance']
)
# Load spawn tables - convert to list of tuples format
spawn_tables = {}
for loc_id, spawns in data['spawn_tables'].items():
spawn_tables[loc_id] = [
(spawn['npc_id'], spawn['weight']) for spawn in spawns
]
print(f"✅ Loaded {len(npcs)} NPCs, {len(danger_levels)} danger configs, {len(spawn_tables)} spawn tables from JSON")
return npcs, danger_levels, spawn_tables
except FileNotFoundError:
print(f"⚠️ Warning: {json_path} not found, using fallback NPCs")
return _get_fallback_npcs()
except Exception as e:
print(f"⚠️ Warning: Error loading NPCs from JSON: {e}")
import traceback
traceback.print_exc()
return _get_fallback_npcs()
def _get_fallback_npcs():
"""Fallback NPCs if JSON loading fails"""
npcs = {
"feral_dog": NPCDefinition(
npc_id="feral_dog",
name="Feral Dog",
description="A wild, mangy dog with desperate hunger in its eyes.",
emoji="🐕",
hp_min=15,
hp_max=25,
damage_min=3,
damage_max=7,
defense=0,
xp_reward=10,
flee_chance=0.3,
status_inflict_chance=0.15,
loot_table=[
LootItem("raw_meat", 1, 2, 0.6),
LootItem("bone", 1, 1, 0.4),
LootItem("animal_hide", 1, 1, 0.3)
],
corpse_loot=[
CorpseLoot("raw_meat", 1, 2),
CorpseLoot("bone", 1, 1),
CorpseLoot("animal_hide", 1, 1, required_tool="knife")
],
image_url=None,
death_message="The feral dog whimpers and collapses."
)
}
danger_levels = {
"start_point": (0, 0.0, 0.0),
}
spawn_tables = {
"start_point": [],
}
return npcs, danger_levels, spawn_tables
# Load on module import
NPCS, LOCATION_DANGER, LOCATION_SPAWNS = load_npcs_from_json()
# Status effects that can be applied in combat
STATUS_EFFECTS = {
"bleeding": StatusEffect(
name="Bleeding",
duration_turns=3,
damage_per_turn=2,
stun=False
),
"stunned": StatusEffect(
name="Stunned",
duration_turns=1,
damage_per_turn=0,
stun=True
),
"infected": StatusEffect(
name="Infected",
duration_turns=5,
damage_per_turn=1,
stun=False
),
}
# Helper functions
def get_danger_level(location_id: str) -> int:
"""Get danger level for a location (0-4)"""
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[0]
def get_location_encounter_rate(location_id: str) -> float:
"""Get base encounter rate for a location"""
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[1]
def get_wandering_enemy_chance(location_id: str) -> float:
"""Get chance for wandering enemy to spawn"""
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[2]
def get_random_npc_for_location(location_id: str) -> str:
"""
Get a random NPC ID for the given location based on spawn weights.
Returns None if no NPCs can spawn at this location.
"""
import random
spawn_table = LOCATION_SPAWNS.get(location_id, [])
if not spawn_table:
return None
# Extract NPCs and weights
npcs = [npc_id for npc_id, weight in spawn_table]
weights = [weight for npc_id, weight in spawn_table]
# Use random.choices with weights
return random.choices(npcs, weights=weights, k=1)[0] if npcs else None

31
data/travel_helpers.py Normal file
View File

@@ -0,0 +1,31 @@
"""
Shared travel/stamina calculation helpers used by both game logic and map visualization
"""
import math
def calculate_base_stamina_cost(from_location, to_location) -> int:
"""
Calculate base stamina cost for traveling between two locations.
Based purely on Euclidean distance.
This is the base cost used by:
- Map visualization (to show connection costs)
- Game logic (as starting point before player modifiers)
Args:
from_location: Location object with x, y coordinates
to_location: Location object with x, y coordinates
Returns:
int: Base stamina cost (minimum 1)
"""
# Calculate Euclidean distance
dx = to_location.x - from_location.x
dy = to_location.y - from_location.y
distance = math.sqrt(dx**2 + dy**2)
# Base cost: 3 stamina per distance unit (rounded)
# Minimum cost is 1 stamina
base_cost = max(1, int(distance * 3 + 0.5))
return base_cost

313
data/world_loader.py Normal file
View File

@@ -0,0 +1,313 @@
"""
World loader - NOW LOADS FROM JSON
Creates and connects all the game world objects from gamedata/locations.json
Uses template-based interactables from gamedata/interactables.json
"""
from .models import World, Location, Interactable, Action, Outcome
import json
from pathlib import Path
def load_interactable_templates():
"""Load interactable templates from interactables.json."""
templates = {}
json_path = Path(__file__).parent.parent / 'gamedata' / 'interactables.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
for template_id, template_data in data.get('interactables', {}).items():
templates[template_id] = template_data
print(f"📦 Loaded {len(templates)} interactable templates")
except FileNotFoundError:
print("⚠️ interactables.json not found, using inline data only")
except Exception as e:
print(f"⚠️ Error loading interactables.json: {e}")
return templates
def create_interactable_from_template(template_id, template_data, instance_data):
"""Create an Interactable object from template and instance data."""
interactable = Interactable(
id=template_id,
name=template_data.get('name', 'Unknown'),
image_path=template_data.get('image_path', '')
)
# Get actions from template
template_actions = template_data.get('actions', {})
# Get outcomes from instance (template-based format)
instance_outcomes = instance_data.get('outcomes', {})
# Build actions by merging template actions with instance outcomes
for action_id, action_template in template_actions.items():
action = Action(
id=action_template['id'],
label=action_template['label'],
stamina_cost=action_template.get('stamina_cost', 2)
)
# Get the instance-specific outcome data for this action
if action_id in instance_outcomes:
outcome_data = instance_outcomes[action_id]
# Build outcomes from the instance data
text_dict = outcome_data.get('text', {})
rewards = outcome_data.get('rewards', {})
# Add success outcome
if text_dict.get('success'):
items_reward = {}
if 'items' in rewards:
for item in rewards['items']:
items_reward[item['item_id']] = item.get('quantity', 1)
outcome = Outcome(
text=text_dict['success'],
items_reward=items_reward,
damage_taken=rewards.get('damage', 0)
)
action.add_outcome('success', outcome)
# Add failure outcome
if text_dict.get('failure'):
outcome = Outcome(
text=text_dict['failure'],
items_reward={},
damage_taken=0
)
action.add_outcome('failure', outcome)
# Add critical failure outcome
if text_dict.get('crit_failure'):
outcome = Outcome(
text=text_dict['crit_failure'],
items_reward={},
damage_taken=rewards.get('crit_damage', 0)
)
action.add_outcome('critical_failure', outcome)
interactable.add_action(action)
return interactable
def load_world() -> World:
"""Creates and connects all the game world objects from JSON."""
world = World()
# Load interactable templates first
templates = load_interactable_templates()
json_path = Path(__file__).parent.parent / 'gamedata' / 'locations.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
print(f"📍 Loading {len(data['locations'])} locations from JSON...")
# First pass: Create all locations
for loc_data in data['locations']:
location = Location(
id=loc_data['id'],
name=loc_data['name'],
description=loc_data['description'],
image_path=loc_data['image_path'],
x=loc_data.get('x', 0.0),
y=loc_data.get('y', 0.0)
)
# Add interactables using template-based format
for instance_id, instance_data in loc_data.get('interactables', {}).items():
template_id = instance_data.get('template_id')
if not template_id:
print(f"⚠️ Skipping interactable {instance_id} - no template_id")
continue
# Get template data
template_data = templates.get(template_id)
if not template_data:
print(f"⚠️ Template '{template_id}' not found for {instance_id}")
continue
# Create interactable from template + instance data
interactable = create_interactable_from_template(
template_id,
template_data,
instance_data
)
location.add_interactable(instance_id, interactable)
world.add_location(location)
# Second pass: Add connections
print(f"🔗 Adding {len(data['connections'])} connections...")
for conn in data['connections']:
from_loc = world.get_location(conn['from'])
if from_loc:
from_loc.add_exit(conn['direction'], conn['to'])
print(f"✅ World loaded successfully!")
return world
except FileNotFoundError:
print(f"⚠️ Warning: {json_path} not found, loading fallback world")
return _load_fallback_world()
except Exception as e:
print(f"⚠️ Error loading world from JSON: {e}")
import traceback
traceback.print_exc()
return _load_fallback_world()
def _load_fallback_world() -> World:
"""Fallback world with minimal locations if JSON loading fails"""
world = World()
# Create a simple fallback location
start = Location(
id="start_point",
name="🌆 Ruined Downtown Core",
description="The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt.",
image_path="images/locations/downtown.png",
x=0.0,
y=0.0
)
# Add a simple interactable
rubble = Interactable(
id="rubble",
name="Pile of Rubble",
image_path="images/interactables/rubble.png"
)
search_action = Action(id="search", label="🔎 Search Rubble", stamina_cost=2)
search_action.add_outcome("success", Outcome(
text="You find some scrap metal.",
items_reward={"scrap_metal": 2}
))
search_action.add_outcome("failure", Outcome(
text="Nothing useful here."
))
rubble.add_action(search_action)
start.add_interactable("start_rubble", rubble)
world.add_location(start)
return world
def export_map_data() -> dict:
"""
Export map data for external visualization.
Returns a dictionary with locations, connections, interactables, and enemy spawns.
Can be saved as JSON for use in web-based or other visualizers.
"""
from data.npcs import LOCATION_SPAWNS, NPCS, LOCATION_DANGER, get_danger_level, get_location_encounter_rate, get_wandering_enemy_chance
map_data = {
"locations": [],
"connections": [],
"interactables": [],
"spawn_tables": {}
}
for location in game_world.locations.values():
# Get danger information
danger_level = get_danger_level(location.id)
encounter_rate = get_location_encounter_rate(location.id)
wandering_chance = get_wandering_enemy_chance(location.id)
# Add location data with image and danger info
map_data["locations"].append({
"id": location.id,
"name": location.name,
"description": location.description,
"x": location.x,
"y": location.y,
"image_path": location.image_path,
"interactable_count": len(location.interactables),
"danger_level": danger_level,
"encounter_rate": round(encounter_rate * 100, 1), # As percentage
"wandering_chance": round(wandering_chance * 100, 1) # As percentage
})
# Add interactable data with images and loot chances
for instance_id, interactable in location.interactables.items():
interactable_data = {
"instance_id": instance_id,
"location_id": location.id,
"id": interactable.id,
"name": interactable.name,
"image_path": interactable.image_path,
"actions": []
}
# Process each action and its outcomes
for action in interactable.actions.values():
action_data = {
"id": action.id,
"label": action.label,
"stamina_cost": action.stamina_cost,
"outcomes": []
}
# Add outcome information (for loot chances)
for outcome_name, outcome in action.outcomes.items():
outcome_data = {
"type": outcome_name,
"text": outcome.text,
"items": outcome.items_reward,
"damage": outcome.damage_taken
}
action_data["outcomes"].append(outcome_data)
interactable_data["actions"].append(action_data)
map_data["interactables"].append(interactable_data)
# Add connections
for direction, destination in location.exits.items():
# Calculate stamina cost based on distance between locations
dest_loc = game_world.get_location(destination)
if dest_loc:
from .travel_helpers import calculate_base_stamina_cost
stamina_cost = calculate_base_stamina_cost(location, dest_loc)
else:
stamina_cost = 5 # Fallback
map_data["connections"].append({
"from": location.id,
"to": destination,
"direction": direction,
"stamina_cost": stamina_cost
})
# Add spawn table for this location
if location.id in LOCATION_SPAWNS:
spawn_data = []
for npc_id, weight in LOCATION_SPAWNS[location.id]:
if npc_id in NPCS:
npc = NPCS[npc_id]
spawn_data.append({
"npc_id": npc_id,
"name": npc.name,
"emoji": npc.emoji,
"weight": weight,
"level_range": f"{npc.hp_min}-{npc.hp_max} HP"
})
map_data["spawn_tables"][location.id] = spawn_data
return map_data
# Create singleton world instance on module import
game_world = load_world()