Add combat system: NPCs, fights, loot, respawns

NPC class + CombatManager + CombatEncounter
3 NPCs: Shadow Wraith (Bridge), Iron Golem (Forge), Garden Serpent (Garden)
6 endpoints: combat/start, attack, defend, flee, status, npcs
Health bars visible to room, loot drops on death
This commit is contained in:
Alexander Whitestone
2026-04-12 21:15:12 -04:00
parent c210b06a35
commit a5ab75eaa5

View File

@@ -712,8 +712,382 @@ class InventoryManager:
inventory_manager = InventoryManager() inventory_manager = InventoryManager()
# ── Combat System ──────────────────────────────────────────────────────
COMBAT_FILE = WORLD_DIR / 'combat.json'
class NPC:
"""A non-player character that can be fought."""
def __init__(self, npc_id: str, name: str, room: str, description: str,
health: int, max_health: int, attack: int, defense: int,
loot: list[dict], respawn_ticks: int = 5):
self.npc_id = npc_id
self.name = name
self.room = room
self.description = description
self.health = health
self.max_health = max_health
self.attack = attack
self.defense = defense
self.loot = loot # [{name, description, drop_chance}]
self.respawn_ticks = respawn_ticks
self.alive = True
self._dead_tick: int | None = None
def to_dict(self) -> dict:
return {
"npc_id": self.npc_id,
"name": self.name,
"room": self.room,
"description": self.description,
"health": self.health,
"max_health": self.max_health,
"attack": self.attack,
"defense": self.defense,
"loot": self.loot,
"alive": self.alive,
"dead_tick": self._dead_tick,
"respawn_ticks": self.respawn_ticks,
}
@classmethod
def from_dict(cls, data: dict) -> 'NPC':
npc = cls(
data["npc_id"], data["name"], data["room"], data["description"],
data["health"], data["max_health"], data["attack"], data["defense"],
data["loot"], data.get("respawn_ticks", 5),
)
npc.alive = data.get("alive", True)
npc._dead_tick = data.get("dead_tick")
return npc
def health_bar(self) -> str:
"""Return a text health bar like [████████░░] 80/100."""
width = 10
filled = int((self.health / self.max_health) * width) if self.max_health > 0 else 0
bar = "" * filled + "" * (width - filled)
return f"[{bar}] {self.health}/{self.max_health}"
def die(self, tick: int):
"""Mark NPC as dead."""
self.alive = False
self.health = 0
self._dead_tick = tick
def respawn(self):
"""Respawn the NPC at full health."""
self.alive = True
self.health = self.max_health
self._dead_tick = None
class CombatEncounter:
"""An active fight between a player and an NPC."""
def __init__(self, user_id: str, username: str, room: str, npc_id: str):
self.user_id = user_id
self.username = username
self.room = room
self.npc_id = npc_id
self.player_hp = 100
self.player_max_hp = 100
self.player_defending = False
self.log: list[str] = []
self.active = True
self.created_at = datetime.now().isoformat()
def to_dict(self) -> dict:
return {
"user_id": self.user_id,
"username": self.username,
"room": self.room,
"npc_id": self.npc_id,
"player_hp": self.player_hp,
"player_max_hp": self.player_max_hp,
"player_defending": self.player_defending,
"log": self.log,
"active": self.active,
}
class CombatManager:
"""Manages NPCs, encounters, and combat resolution."""
def __init__(self):
self._npcs: dict[str, NPC] = {} # npc_id -> NPC
self._encounters: dict[str, CombatEncounter] = {} # user_id -> encounter
self._counter = 0
self._lock = threading.Lock()
# ── NPC management ──────────────────────────────────────────────
def create_npc(self, name: str, room: str, description: str,
health: int, attack: int, defense: int,
loot: list[dict], respawn_ticks: int = 5) -> NPC:
with self._lock:
self._counter += 1
npc_id = f"npc_{self._counter}"
npc = NPC(npc_id, name, room, description,
health, health, attack, defense, loot, respawn_ticks)
self._npcs[npc_id] = npc
self._save()
return npc
def get_npc(self, npc_id: str) -> NPC | None:
return self._npcs.get(npc_id)
def get_npcs_in_room(self, room: str) -> list[NPC]:
with self._lock:
return [n for n in self._npcs.values() if n.room == room and n.alive]
def get_all_npcs(self) -> list[dict]:
with self._lock:
return [n.to_dict() for n in self._npcs.values()]
def check_respawns(self, current_tick: int):
"""Called each world tick — respawn NPCs whose timer has elapsed."""
with self._lock:
for npc in self._npcs.values():
if not npc.alive and npc._dead_tick is not None:
if current_tick - npc._dead_tick >= npc.respawn_ticks:
npc.respawn()
self._save()
# ── Encounter lifecycle ─────────────────────────────────────────
def start_fight(self, user_id: str, username: str, room: str, npc_id: str) -> dict:
"""Begin combat with an NPC."""
with self._lock:
if user_id in self._encounters and self._encounters[user_id].active:
return {"error": "You are already in a fight! Attack or defend."}
npc = self._npcs.get(npc_id)
if not npc:
return {"error": f"NPC '{npc_id}' not found."}
if not npc.alive:
return {"error": f"{npc.name} is dead. It will respawn later."}
if npc.room != room:
return {"error": f"{npc.name} is not in this room."}
encounter = CombatEncounter(user_id, username, room, npc_id)
encounter.log.append(f"Combat started! {username} vs {npc.name}.")
self._encounters[user_id] = encounter
return {"ok": True, "encounter": encounter.to_dict(), "npc": npc.to_dict()}
def attack(self, user_id: str) -> dict:
"""Player attacks NPC; NPC counter-attacks."""
with self._lock:
enc = self._encounters.get(user_id)
if not enc or not enc.active:
return {"error": "No active fight. Start one first."}
npc = self._npcs.get(enc.npc_id)
if not npc or not npc.alive:
enc.active = False
return {"error": "The enemy is already dead."}
import random
# Player attack
player_dmg = max(1, random.randint(8, 16) - npc.defense // 3)
npc.health -= player_dmg
enc.log.append(f"You strike {npc.name} for {player_dmg} damage.")
npc_dmg = 0
if npc.health > 0:
# NPC counter-attack
base_npc_dmg = random.randint(npc.attack // 2, npc.attack)
if enc.player_defending:
npc_dmg = max(1, base_npc_dmg // 2)
enc.log.append(f"{npc.name} attacks for {npc_dmg} (blocked — half damage).")
else:
npc_dmg = max(1, base_npc_dmg - random.randint(0, 4))
enc.log.append(f"{npc.name} attacks you for {npc_dmg} damage.")
enc.player_hp -= npc_dmg
else:
npc.die(self._get_tick())
loot_dropped = self._roll_loot(npc)
enc.log.append(f"{npc.name} is slain!")
if loot_dropped:
for item in loot_dropped:
inventory_manager.add_room_item(enc.room, item["name"], item["description"], dropped_by=npc.name)
enc.log.append(f"Loot dropped: {item['name']}")
enc.active = False
self._save()
enc.player_defending = False
# Check player death
if enc.player_hp <= 0:
enc.player_hp = 0
enc.log.append("You have been defeated!")
enc.active = False
return {"ok": True, "encounter": enc.to_dict(), "npc": npc.to_dict(),
"player_dmg": player_dmg, "npc_dmg": npc_dmg,
"loot": loot_dropped if not npc.alive else []}
def defend(self, user_id: str) -> dict:
"""Player defends — next NPC attack does half damage."""
with self._lock:
enc = self._encounters.get(user_id)
if not enc or not enc.active:
return {"error": "No active fight."}
npc = self._npcs.get(enc.npc_id)
if not npc or not npc.alive:
enc.active = False
return {"error": "The enemy is already dead."}
import random
enc.player_defending = True
enc.log.append("You brace for the next attack (defending).")
# NPC still attacks
base_npc_dmg = random.randint(npc.attack // 2, npc.attack)
npc_dmg = max(1, base_npc_dmg // 2)
enc.log.append(f"{npc.name} attacks for {npc_dmg} (blocked — half damage).")
enc.player_hp -= npc_dmg
if enc.player_hp <= 0:
enc.player_hp = 0
enc.log.append("You have been defeated!")
enc.active = False
return {"ok": True, "encounter": enc.to_dict(), "npc": npc.to_dict(), "npc_dmg": npc_dmg}
def flee(self, user_id: str) -> dict:
"""Player flees combat."""
with self._lock:
enc = self._encounters.get(user_id)
if not enc or not enc.active:
return {"error": "No active fight."}
enc.active = False
enc.log.append("You flee from combat!")
return {"ok": True, "encounter": enc.to_dict()}
def get_encounter(self, user_id: str) -> CombatEncounter | None:
return self._encounters.get(user_id)
def get_room_combat_status(self, room: str) -> list[dict]:
"""Get all active fights and NPC health in a room (for health bars)."""
with self._lock:
status = []
# NPCs
for npc in self._npcs.values():
if npc.room == room:
status.append({
"type": "npc",
"name": npc.name,
"npc_id": npc.npc_id,
"alive": npc.alive,
"health_bar": npc.health_bar() if npc.alive else "[dead]",
"health": npc.health,
"max_health": npc.max_health,
})
# Active player fights
for enc in self._encounters.values():
if enc.room == room and enc.active:
status.append({
"type": "player",
"username": enc.username,
"user_id": enc.user_id,
"hp": enc.player_hp,
"max_hp": enc.player_max_hp,
"fighting": enc.npc_id,
})
return status
def _roll_loot(self, npc: NPC) -> list[dict]:
"""Roll for loot drops."""
import random
dropped = []
for item in npc.loot:
chance = item.get("drop_chance", 1.0)
if random.random() < chance:
dropped.append({"name": item["name"], "description": item["description"]})
return dropped
def _get_tick(self) -> int:
"""Read current world tick."""
state_file = WORLD_DIR / 'world_state.json'
if state_file.exists():
try:
state = json.loads(state_file.read_text())
return state.get("tick", 0)
except Exception:
return 0
return 0
# ── Persistence ─────────────────────────────────────────────────
def _save(self):
try:
data = {
"counter": self._counter,
"npcs": {nid: n.to_dict() for nid, n in self._npcs.items()},
}
COMBAT_FILE.parent.mkdir(parents=True, exist_ok=True)
COMBAT_FILE.write_text(json.dumps(data, indent=2))
except Exception as e:
print(f"[CombatManager] Save failed: {e}")
def load(self):
if not COMBAT_FILE.exists():
self._seed_npcs()
return
try:
data = json.loads(COMBAT_FILE.read_text())
self._counter = data.get("counter", 0)
for nid, ndata in data.get("npcs", {}).items():
self._npcs[nid] = NPC.from_dict(ndata)
print(f"[CombatManager] Loaded {len(self._npcs)} NPCs.")
except Exception as e:
print(f"[CombatManager] Load failed: {e}")
self._seed_npcs()
def _seed_npcs(self):
"""Seed starter NPCs."""
self.create_npc(
"Shadow Wraith", "The Bridge",
"A swirling mass of darkness with glowing eyes. It haunts the bridge, "
"feeding on the fears of those who cross.",
health=60, attack=14, defense=4,
loot=[
{"name": "Wraith Essence", "description": "A vial of shimmering dark energy.", "drop_chance": 0.8},
{"name": "Shadow Cloak", "description": "A cloak woven from pure shadow.", "drop_chance": 0.3},
],
respawn_ticks=5,
)
self.create_npc(
"Iron Golem", "The Forge",
"A hulking automaton of iron and fire. It guards the forge with tireless vigilance, "
"its joints grinding with every step.",
health=100, attack=10, defense=10,
loot=[
{"name": "Golem Core", "description": "A warm crystal that powered the golem.", "drop_chance": 0.7},
{"name": "Iron Shard", "description": "A shard of enchanted iron, still warm.", "drop_chance": 0.5},
],
respawn_ticks=7,
)
self.create_npc(
"Garden Serpent", "The Garden",
"A massive vine serpent camouflaged among the herbs. Its fangs drip with "
"a sickly green venom.",
health=45, attack=18, defense=2,
loot=[
{"name": "Serpent Fang", "description": "A curved fang, still coated in venom.", "drop_chance": 0.9},
{"name": "Enchanted Vine", "description": "A living vine that moves on its own.", "drop_chance": 0.4},
],
respawn_ticks=4,
)
print(f"[CombatManager] Seeded {len(self._npcs)} NPCs.")
combat_manager = CombatManager()
# ── Session Management ───────────────────────────────────────────────── # ── Session Management ─────────────────────────────────────────────────
combat_manager.load()
class UserSession: class UserSession:
"""Isolated conversation context for one user.""" """Isolated conversation context for one user."""
@@ -1238,6 +1612,14 @@ class BridgeHandler(BaseHTTPRequestHandler):
self._json_response(saved) self._json_response(saved)
else: else:
self._json_response({"error": "no session or saved summary"}, 404) self._json_response({"error": "no session or saved summary"}, 404)
elif self.path.startswith('/bridge/combat/status/'):
# GET /bridge/combat/status/<room> — NPC health bars + active fights in room
room = self.path.split('/bridge/combat/status/')[-1].rstrip('/')
status = combat_manager.get_room_combat_status(room)
self._json_response({"room": room, "combat_status": status})
elif self.path == '/bridge/combat/npcs':
# GET /bridge/combat/npcs — list all NPCs
self._json_response({"npcs": combat_manager.get_all_npcs()})
else: else:
self._json_response({"error": "not found"}, 404) self._json_response({"error": "not found"}, 404)
@@ -1447,6 +1829,91 @@ class BridgeHandler(BaseHTTPRequestHandler):
exclude_user=user_id, data=result) exclude_user=user_id, data=result)
self._json_response(result) self._json_response(result)
elif self.path == '/bridge/combat/start':
# POST /bridge/combat/start — begin fight with NPC
# Body: { user_id, username, room, npc_id }
user_id = body.get('user_id', 'anonymous')
username = body.get('username', 'Anonymous')
room = body.get('room', 'The Threshold')
npc_id = body.get('npc_id', '')
if not npc_id:
self._json_response({"error": "npc_id required"}, 400)
return
result = combat_manager.start_fight(user_id, username, room, npc_id)
if "error" in result:
self._json_response(result, 400)
else:
npc = result.get("npc", {})
notification_manager.broadcast_room(
room, "combat_start",
f"{username} has engaged {npc.get('name', 'an enemy')} in combat!",
exclude_user=user_id, data=result)
self._json_response(result)
elif self.path == '/bridge/combat/attack':
# POST /bridge/combat/attack — attack NPC in active fight
# Body: { user_id, username, room }
user_id = body.get('user_id', 'anonymous')
username = body.get('username', 'Anonymous')
room = body.get('room', 'The Threshold')
result = combat_manager.attack(user_id)
if "error" in result:
self._json_response(result, 400)
else:
enc = result.get("encounter", {})
npc = result.get("npc", {})
loot = result.get("loot", [])
# Broadcast combat action to room
combat_msg = f"{username} strikes {npc.get('name', 'the enemy')} for {result.get('player_dmg', 0)} damage!"
notification_manager.broadcast_room(
room, "combat_action", combat_msg,
exclude_user=user_id, data=result)
# If NPC died, broadcast death
if not npc.get("alive", True):
death_msg = f"{npc.get('name', 'The enemy')} has been slain by {username}!"
if loot:
death_msg += f" Loot: {', '.join(i['name'] for i in loot)}"
notification_manager.broadcast_room(
room, "combat_death", death_msg, data=result)
# If player died
if enc.get("player_hp", 1) <= 0:
notification_manager.broadcast_room(
room, "combat_defeat",
f"{username} has been defeated in combat!", data=result)
self._json_response(result)
elif self.path == '/bridge/combat/defend':
# POST /bridge/combat/defend — defend (half damage next hit)
# Body: { user_id, username, room }
user_id = body.get('user_id', 'anonymous')
username = body.get('username', 'Anonymous')
room = body.get('room', 'The Threshold')
result = combat_manager.defend(user_id)
if "error" in result:
self._json_response(result, 400)
else:
notification_manager.broadcast_room(
room, "combat_action",
f"{username} braces defensively.",
exclude_user=user_id, data=result)
self._json_response(result)
elif self.path == '/bridge/combat/flee':
# POST /bridge/combat/flee — flee from combat
# Body: { user_id, username, room }
user_id = body.get('user_id', 'anonymous')
username = body.get('username', 'Anonymous')
room = body.get('room', 'The Threshold')
result = combat_manager.flee(user_id)
if "error" in result:
self._json_response(result, 400)
else:
notification_manager.broadcast_room(
room, "combat_flee",
f"{username} flees from combat!",
exclude_user=user_id, data=result)
self._json_response(result)
else: else:
self._json_response({"error": "not found"}, 404) self._json_response({"error": "not found"}, 404)