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:
@@ -712,8 +712,382 @@ class 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 ─────────────────────────────────────────────────
|
||||
|
||||
combat_manager.load()
|
||||
|
||||
class UserSession:
|
||||
"""Isolated conversation context for one user."""
|
||||
|
||||
@@ -1238,9 +1612,17 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
self._json_response(saved)
|
||||
else:
|
||||
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:
|
||||
self._json_response({"error": "not found"}, 404)
|
||||
|
||||
|
||||
def do_POST(self):
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
body = json.loads(self.rfile.read(content_length)) if content_length else {}
|
||||
@@ -1447,6 +1829,91 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
exclude_user=user_id, data=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:
|
||||
self._json_response({"error": "not found"}, 404)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user