"""World of Guildhall: characters, gear, places, quests, groups and dungeons. The world is one JSON-able dict. Time moves it forward: `process` runs everyone's hunts and dungeon runs up to now, fight by fight, so a player who looks away keeps adventuring for up to OFFLINE_CAP. """ from __future__ import annotations import random import combat from content import (ABILITIES, ARMOUR_TYPES, BASE_NAMES, BOSS_LOOT, BOSS_LOOT_CHANCE, CLASSES, DUNGEONS, FLIGHT_COST, ITEMS, MAX_LEVEL, MOBS, PETS, PLACES, QUALITY, QUESTS, ROADS, SLOT_ARMOUR, SLOT_BUDGET, SLOTS, SPECS, SUFFIXES, TRASH_LOOT, TRASH_LOOT_CHANCE, UNLOCK, WEAPON_SPEED, XP_TO_NEXT, ZONES) VERSION = 2 MINUTE = 60 HOUR = 3600 OFFLINE_CAP = 12 * HOUR REST = 0.06 # share of health and mana back per second out of combat REVIVE = 30 # seconds dead before running back DEATHS_TO_STOP = 3 # in a row on one hunt DUNGEON_REST = 25 # seconds between pulls before the next one starts by itself WIPE_DELAY = 15 PARTY_MAX = 5 MAX_QUESTS = 12 MAX_LOG = 80 MAX_CHAT = 200 SHOW_ENDED = 4 # seconds a finished fight stays on screen GREEN_ODDS = 0.025 JUNK_ODDS = 0.5 ARMOUR_RANK = {"cloth": 0, "leather": 1, "mail": 2} # --- items ----------------------------------------------------------------- def _band(ilvl: int) -> int: return 0 if ilvl < 8 else 1 if ilvl < 15 else 2 def _gear(item_id: str, name: str, slot: str, kind: str, ilvl: int, quality: str, share: float) -> dict: budget = ilvl * SLOT_BUDGET[slot] * QUALITY[quality] * 0.9 item = {"id": item_id, "name": name, "slot": slot, "kind": kind, "ilvl": ilvl, "q": quality, "req": max(1, ilvl - 3 if quality != "common" else ilvl - 1), "sta": round(budget * share), "power": round(budget * (1 - share)), "armor": 0, "value": round(ilvl ** 1.5 * 4 * (QUALITY[quality] + 0.3))} if slot == "weapon": item["dps"] = round((1 + 0.45 * ilvl) * (0.8 + 0.2 * QUALITY[quality]), 1) item["speed"] = WEAPON_SPEED[kind] else: item["armor"] = round(ilvl * ARMOUR_TYPES[kind] * SLOT_ARMOUR[slot] * (0.85 + 0.15 * QUALITY[quality])) return item def item(item_id: str) -> dict | None: """Every item is described by its id: named, generated (w/ white, g/ green), junk (j/) or quest (q/).""" if item_id in ITEMS: spec = ITEMS[item_id] return _gear(item_id, spec["name"], spec["slot"], spec["kind"], spec["ilvl"], spec["q"], spec["sta"]) parts = item_id.split("/") try: if parts[0] == "w": slot, kind, ilvl = parts[1], parts[2], int(parts[3]) return _gear(item_id, BASE_NAMES[(slot, kind)][_band(ilvl)], slot, kind, ilvl, "common", 0.5) if parts[0] == "g": slot, kind, suffix, ilvl = parts[1], parts[2], parts[3], int(parts[4]) name = f"{BASE_NAMES[(slot, kind)][_band(ilvl)]} {SUFFIXES[suffix][0]}" return _gear(item_id, name, slot, kind, ilvl, "uncommon", SUFFIXES[suffix][1]) if parts[0] == "j": mob = MOBS[parts[1]] return {"id": item_id, "name": mob["junk"], "q": "poor", "value": 2 + mob["lv"][1] * 4} except (KeyError, IndexError, ValueError): return None return None def random_green(level: int, rng: random.Random) -> str: slot = rng.choice(SLOTS) suffix = rng.choice(list(SUFFIXES)) ilvl = max(2, level + rng.randint(-1, 1)) if slot == "weapon": kind = rng.choice(list(WEAPON_SPEED)) elif slot == "back": kind = "cloth" else: kind = rng.choice(list(ARMOUR_TYPES)) return f"g/{slot}/{kind}/{suffix}/{ilvl}" def fits(player: dict, spec: dict) -> bool: """The class can use it at some level (best armour type only, like loot rules in Classic).""" cls = CLASSES[player["cls"]] if spec["slot"] == "weapon": return spec["kind"] in cls["weapons"] return spec["slot"] == "back" or spec["kind"] == cls["armour"] def can_wear(player: dict, spec: dict) -> str | None: cls = CLASSES[player["cls"]] if "slot" not in spec: return "That can't be worn" if spec["slot"] == "weapon": if spec["kind"] not in cls["weapons"]: return f"{cls['name']}s can't use a {spec['kind']}" elif ARMOUR_RANK[spec["kind"]] > ARMOUR_RANK[cls["armour"]]: return f"{cls['name']}s can't wear {spec['kind']}" if spec["req"] > player["level"]: return f"Requires level {spec['req']}" return None # --- characters ------------------------------------------------------------ def spec_of(player: dict) -> dict: return SPECS[CLASSES[player["cls"]]["spec"]] def known(player: dict) -> list[str]: return [ab for ab, level in zip(spec_of(player)["abilities"], UNLOCK) if player["level"] >= level] def stats(player: dict) -> dict: cls = CLASSES[player["cls"]] level = player["level"] worn = [item(i) for i in player["equip"].values() if i] worn = [w for w in worn if w] sta = sum(w["sta"] for w in worn) gear_power = sum(w["power"] for w in worn) armor = sum(w["armor"] for w in worn) if cls.get("shield"): armor += 8 * level if spec_of(player)["role"] == "tank": armor = round(armor * 1.6) weapon = item(player["equip"].get("weapon") or "") or {"dps": 1 + 0.3 * level, "speed": 2.0} power = 10 + 5 * level + gear_power max_hp = cls["hp"][0] + cls["hp"][1] * level + sta * 8 res = cls["res"] res_max = 100 if res in ("rage", "energy") else 40 + 15 * level + 3 * gear_power swing = weapon["speed"] * (weapon["dps"] + power * 0.05) return {"max_hp": max_hp, "power": power, "armor": armor, "sta": sta, "res": res, "res_max": res_max, "swing": round(swing, 1), "speed": weapon["speed"], "dps": weapon["dps"]} def hp_now(player: dict, now: float) -> tuple[float, float]: """Health and mana share, resting since the last fight ended.""" st = stats(player) if player["dead_until"] > now: return 0.0, 0.0 rest = player["rest"] gone = max(0.0, now - rest["at"]) hp = min(st["max_hp"], rest["hp"] + st["max_hp"] * REST * gone) mana = min(1.0, rest["mana"] + REST * gone) return hp, mana def new_player(uid: str, name: str, cls: str, now: float) -> dict: spec = CLASSES[cls] armour = spec["armour"] player = { "id": uid, "name": name, "cls": cls, "level": 1, "xp": 0, "copper": 0, "place": "northshire_abbey", "zones": ["elwynn"], "activity": None, "fight": None, "group": None, "rest": {"at": now, "hp": 1e9, "mana": 1.0}, "dead_until": 0, "next_pull": 0, "deaths": 0, "quests": {}, "done": [], "bags": {}, "manual": [], "equip": {slot: None for slot in SLOTS}, "last_active": now, "watch": 0, "log": [], "joined": now, } player["equip"]["weapon"] = f"w/weapon/{spec['weapons'][0]}/2" player["equip"]["chest"] = f"w/chest/{armour}/2" player["equip"]["legs"] = f"w/legs/{armour}/2" player["rest"]["hp"] = stats(player)["max_hp"] return player def player_unit(player: dict, now: float, group: bool) -> list[dict]: """The player (and their pet) as fight units.""" st = stats(player) spec = spec_of(player) hp, mana = hp_now(player, now) res = {"type": st["res"], "max": st["res_max"]} res["cur"] = {"rage": 20, "energy": 100}.get(st["res"], round(mana * st["res_max"])) ranged = player["cls"] == "hunter" unit = { "id": player["id"], "name": player["name"], "side": "party", "kind": "player", "cls": player["cls"], "role": spec["role"], "level": player["level"], "hp": max(1, round(hp)), "max_hp": st["max_hp"], "power": st["power"], "armor": st["armor"], "res": res, "swing": {"dmg": st["swing"] * (1.1 if ranged else 1), "every": st["speed"], "next": 0.25, "school": "phys"}, "abilities": known(player), "priority": spec["priority"], "manual": list(player["manual"]), "auras": [], "cast": None, "cds": {}, "busy": 0, "combo": 0, "target": None, "picked": False, "ftarget": None, "queued": None, "stun": 0, "cc": 0, "dead": False, "threat_mult": combat.TANK_THREAT if spec["role"] == "tank" else 1.0, } units = [unit] pet_kind = CLASSES[player["cls"]].get("pet") if pet_kind: pet_id = "wolf" if pet_kind == "wolf" else ("imp" if group else "voidwalker") pet = PETS[pet_id] tank = not group pet_hp = round(st["max_hp"] * pet["hp"]) units.append({ "id": f"{player['id']}:pet", "name": pet["name"], "icon": pet["icon"], "side": "party", "kind": "pet", "owner": player["id"], "level": player["level"], "hp": pet_hp, "max_hp": pet_hp, "power": st["power"], "armor": st["armor"] * (2 if tank else 1) + 10 * player["level"], "res": None, "swing": {"dmg": pet["dmg"] * st["power"], "every": pet["swing"], "next": 0.5, "school": pet["school"]}, "auras": [], "cast": None, "cds": {}, "busy": 0, "combo": 0, "target": None, "picked": False, "stun": 0, "cc": 0, "dead": False, "pet_tank": tank, "threat_mult": combat.PET_TANK_THREAT if tank else combat.PET_THREAT, }) return units def kill_xp(player_level: int, mob_level: int, mob: dict) -> int: diff = mob_level - player_level gray = 5 + player_level // 10 if diff <= -gray: return 0 mult = 1 + 0.05 * min(diff, 4) if diff >= 0 else 1 + diff / (gray + 1) if mob.get("elite"): mult *= 2 if mob.get("boss"): mult *= 3 if mob.get("rare"): mult *= 2 return round((45 + 5 * mob_level) * mult) def log(player: dict, now: float, text: str, kind: str = "info") -> None: player["log"].append({"t": now, "text": text, "kind": kind}) del player["log"][:-MAX_LOG] def world_log(world: dict, now: float, text: str, kind: str = "info", who: str | None = None) -> None: world["log"].append({"t": now, "text": text, "kind": kind, "who": who}) del world["log"][:-MAX_LOG] def gain_xp(world: dict, player: dict, xp: int, now: float) -> None: if player["level"] >= MAX_LEVEL or xp <= 0: return player["xp"] += xp while player["level"] < MAX_LEVEL and player["xp"] >= XP_TO_NEXT[player["level"]]: player["xp"] -= XP_TO_NEXT[player["level"]] player["level"] += 1 learned = [ABILITIES[ab]["name"] for ab, lv in zip(spec_of(player)["abilities"], UNLOCK) if lv == player["level"]] extra = f" ยท learned {', '.join(learned)}" if learned else "" log(player, now, f"Level {player['level']}!{extra}", "level") world_log(world, now, f"{player['name']} reached level {player['level']}", "level", player["id"]) if player["level"] >= MAX_LEVEL: player["xp"] = 0 def bag_add(player: dict, item_id: str, qty: int = 1) -> None: player["bags"][item_id] = player["bags"].get(item_id, 0) + qty def bag_take(player: dict, item_id: str, qty: int = 1) -> None: player["bags"][item_id] -= qty if player["bags"][item_id] <= 0: del player["bags"][item_id] # --- the world ------------------------------------------------------------- def new_world(now: float) -> dict: return {"version": VERSION, "players": {}, "fights": {}, "groups": {}, "next": 1, "log": [], "chat": [], "config": {"clock_offset": 0}, "created": now} def migrate(world: dict) -> dict: """Bring a saved world up to this version.""" if world.get("version", 1) < 2: # Places became subzones of zones; road travel became journeys. for player in world["players"].values(): activity = player["activity"] if activity and activity["kind"] == "travel": player["place"], player["activity"] = activity["to"], None player["zones"] = [zone_of(player["place"])] for qid, progress in player["quests"].items(): need = len(QUESTS[qid]["obj"]) player["quests"][qid] = (progress + [0] * need)[:need] world["version"] = 2 return world def zone_of(place: str) -> str: return PLACES[place]["zone"] def _next_id(world: dict, prefix: str) -> str: world["next"] += 1 return f"{prefix}{world['next']}" def watch_map(world: dict) -> dict: return {uid: p["watch"] for uid, p in world["players"].items()} def process(world: dict, now: float, rng: random.Random) -> None: for group in list(world["groups"].values()): if group.get("dungeon"): _process_dungeon(world, group, now, rng) for player in world["players"].values(): _process_player(world, player, now, rng) def _process_player(world: dict, player: dict, now: float, rng: random.Random) -> None: for _ in range(100000): activity = player["activity"] if activity and activity["kind"] == "dungeon": return if player["fight"]: fight = world["fights"][player["fight"]] combat.advance(fight, now, watch_map(world)) if fight["state"] == "on": return _end_field_fight(world, player, fight, rng) continue if not activity or activity["kind"] not in ("hunt", "journey"): return start = max(player["next_pull"], player["dead_until"]) if start > now: return if start > player["last_active"] + OFFLINE_CAP: player["activity"] = None log(player, start, "You made camp. Come back to keep going.", "info") return if activity["kind"] == "hunt": pull = rng.choice(PLACES[player["place"]]["hunts"][activity["hunt"]]) _start_field_fight(world, player, pull, "hunt", start, rng) else: road = ROADS[activity["road"]] stop = _stops(activity)[activity["leg"]] pull = rng.choice(stop["pulls"]) if rng.random() < road["rare_odds"]: pull = [road["rare"]] _start_field_fight(world, player, pull, "journey", start, rng, stop["name"]) def _stops(journey: dict) -> list[dict]: road = ROADS[journey["road"]] return road["stops"] if journey["to"] == road["zones"][1] else road["stops"][::-1] def _start_field_fight(world: dict, player: dict, pull: list, kind: str, at: float, rng: random.Random, where: str | None = None) -> None: enemies = [] for index, mob_id in enumerate(pull): low, high = MOBS[mob_id]["lv"] unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high)) unit["swing"]["next"] = 0.6 + 0.4 * index enemies.append(unit) fid = _next_id(world, "f") name = MOBS[pull[0]]["name"] if len(pull) == 1 else f"{MOBS[pull[0]]['name']} and friends" if where: name = f"{where}: {name}" world["fights"][fid] = combat.new_fight(fid, rng.randrange(2 ** 31), at, player_unit(player, at, False), enemies, kind, name) player["fight"] = fid def _end_field_fight(world: dict, player: dict, fight: dict, rng: random.Random) -> None: end = fight["ended"] me = fight["units"][player["id"]] player["fight"] = None player["last_fight"] = world["fights"].pop(fight["id"]) st = stats(player) if fight["state"] == "won": player["deaths"] = 0 _reward_kills(world, player, fight, 1, end, rng) player["rest"] = {"at": end, "hp": me["hp"], "mana": me["res"]["cur"] / me["res"]["max"] if me["res"]["type"] == "mana" else 1.0} hp, mana = hp_now(player, end) missing = max(1 - hp / st["max_hp"], 1 - mana) player["next_pull"] = end + max(2.0, missing / REST * 0.85) activity = player["activity"] if activity and activity["kind"] == "journey": activity["leg"] += 1 if activity["leg"] >= len(_stops(activity)): _arrive(world, player, activity["to"], end) else: player["deaths"] += 1 player["dead_until"] = end + REVIVE player["rest"] = {"at": end + REVIVE, "hp": st["max_hp"] * 0.5, "mana": 0.5} player["next_pull"] = end + REVIVE + 0.5 / REST log(player, end, f"Killed by {fight['name']}", "death") activity = player["activity"] if activity and activity["kind"] == "journey": # Driven back: you wake where the road began. player["activity"] = None player["deaths"] = 0 log(player, end, f"You never made it to {ZONES[activity['to']]['name']}", "death") elif player["deaths"] >= DEATHS_TO_STOP: player["activity"] = None player["deaths"] = 0 log(player, end, "Died three times in a row. You stopped hunting here.", "death") def _reward_kills(world: dict, player: dict, fight: dict, share: float, now: float, rng: random.Random, dungeon: bool = False) -> None: xp = 0 for unit in fight["units"].values(): if unit["side"] != "enemy" or not unit["dead"]: continue mob = MOBS[unit["mob"]] xp += round(kill_xp(player["level"], unit["level"], mob) * share) _quest_kill(player, unit["mob"], now, rng) if dungeon: loot = None if mob.get("boss") and unit["mob"] in BOSS_LOOT and rng.random() < BOSS_LOOT_CHANCE: usable = [i for i in BOSS_LOOT[unit["mob"]] if fits(player, item(i))] loot = rng.choice(usable or BOSS_LOOT[unit["mob"]]) elif not mob.get("boss") and rng.random() < TRASH_LOOT_CHANCE: loot = rng.choice(TRASH_LOOT) if loot: bag_add(player, loot) log(player, now, f"Loot: {item(loot)['name']}", "loot") world_log(world, now, f"{player['name']} looted {item(loot)['name']}", "loot", player["id"]) if not mob.get("boss") and rng.random() < JUNK_ODDS: bag_add(player, f"j/{unit['mob']}") if unit["kind"] == "humanoid": player["copper"] += round(rng.randint(unit["level"], unit["level"] * 4) * (2 if dungeon else 1)) if rng.random() < (0.5 if mob.get("rare") else GREEN_ODDS): green = random_green(unit["level"], rng) bag_add(player, green) log(player, now, f"Loot: {item(green)['name']}", "loot") if mob.get("rare") and not dungeon: world_log(world, now, f"{player['name']} slew {mob['name']}", "rare", player["id"]) gain_xp(world, player, xp, now) def _arrive(world: dict, player: dict, zone: str, at: float, flew: bool = False) -> None: player["place"] = ZONES[zone]["hub"] player["activity"] = None if zone not in player["zones"]: player["zones"].append(zone) world_log(world, at, f"{player['name']} reached {ZONES[zone]['name']}", "travel", player["id"]) log(player, at, f"{'Flew to' if flew else 'Arrived in'} {ZONES[zone]['name']}", "travel") _visit(player, zone) def _visit(player: dict, zone: str) -> None: for qid, progress in player["quests"].items(): for index, objective in enumerate(QUESTS[qid]["obj"]): if objective.get("visit") == zone: progress[index] = 1 # --- actions --------------------------------------------------------------- def _leave_fight(world: dict, player: dict, now: float) -> None: """Flee a solo fight: no rewards, keep what health you have.""" fid = player["fight"] if not fid: return fight = world["fights"].pop(fid, None) player["fight"] = None if fight: me = fight["units"][player["id"]] player["rest"] = {"at": now, "hp": max(1, me["hp"]), "mana": me["res"]["cur"] / me["res"]["max"] if me["res"]["type"] == "mana" else 1.0} def hunt_place(zone: str, hunt: str) -> str | None: return next((pid for pid, p in PLACES.items() if p["zone"] == zone and hunt in p.get("hunts", {})), None) def start_hunt(world: dict, player: dict, hunt: str, now: float) -> str | None: if _busy(player): return _busy(player) place = hunt_place(zone_of(player["place"]), hunt) if not place: return "Nothing to hunt like that here" _leave_fight(world, player, now) player["place"] = place player["activity"] = {"kind": "hunt", "hunt": hunt, "since": now} player["deaths"] = 0 player["next_pull"] = max(player["next_pull"], now) return None def stop(world: dict, player: dict, now: float) -> str | None: if player["activity"] and player["activity"]["kind"] == "dungeon": return "Leave the dungeon first" _leave_fight(world, player, now) player["activity"] = None return None def _busy(player: dict) -> str | None: activity = player["activity"] if activity and activity["kind"] == "dungeon": return "You're in a dungeon" if activity and activity["kind"] == "journey": return "You're on the road" return None def road_between(a: str, b: str) -> str | None: return next((rid for rid, road in ROADS.items() if set(road["zones"]) == {a, b}), None) def journey(world: dict, player: dict, to: str, now: float) -> str | None: """Set out on foot: a fight at every stop along the road.""" if _busy(player): return _busy(player) here = zone_of(player["place"]) road = road_between(here, to) if to in ZONES else None if not road: return "No road there" if player["dead_until"] > now: return "You're dead" _leave_fight(world, player, now) player["activity"] = {"kind": "journey", "road": road, "from": here, "to": to, "leg": 0, "since": now} player["deaths"] = 0 player["next_pull"] = max(player["next_pull"], now) return None def fly(world: dict, player: dict, to: str, now: float) -> str | None: if _busy(player): return _busy(player) if to not in player["zones"]: return "Reach it on foot first" if to == zone_of(player["place"]): return "You're already here" if player["dead_until"] > now: return "You're dead" if player["copper"] < FLIGHT_COST: return "Not enough money" _leave_fight(world, player, now) player["copper"] -= FLIGHT_COST _arrive(world, player, to, now, flew=True) return None def equip(player: dict, item_id: str) -> str | None: if player["bags"].get(item_id, 0) < 1: return "You don't have that" spec = item(item_id) if not spec: return "No such item" problem = can_wear(player, spec) if problem: return problem if player["fight"]: return "Not in the middle of a fight" old = player["equip"][spec["slot"]] bag_take(player, item_id) if old: bag_add(player, old) player["equip"][spec["slot"]] = item_id return None def unequip(player: dict, slot: str) -> str | None: if slot not in player["equip"] or not player["equip"][slot]: return "Nothing there" if player["fight"]: return "Not in the middle of a fight" bag_add(player, player["equip"][slot]) player["equip"][slot] = None return None def sell(player: dict, item_id: str, qty: int | str) -> str | None: have = player["bags"].get(item_id, 0) if qty == "all": qty = have if not isinstance(qty, int) or qty < 1 or qty > have: return "Bad quantity" spec = item(item_id) if not spec: return "No such item" bag_take(player, item_id, qty) player["copper"] += spec["value"] * qty return None def sell_junk(player: dict) -> str | None: junk = [i for i in player["bags"] if i.startswith("j/")] if not junk: return "No junk to sell" for item_id in junk: player["copper"] += item(item_id)["value"] * player["bags"].pop(item_id) return None def set_manual(world: dict, player: dict, ability: str, manual: bool) -> str | None: if ability not in spec_of(player)["abilities"]: return "Not one of yours" if manual and ability not in player["manual"]: player["manual"].append(ability) elif not manual and ability in player["manual"]: player["manual"].remove(ability) fight = current_fight(world, player) if fight and player["id"] in fight["units"]: fight["units"][player["id"]]["manual"] = list(player["manual"]) return None def current_fight(world: dict, player: dict) -> dict | None: if player["fight"]: return world["fights"].get(player["fight"]) group = world["groups"].get(player["group"] or "") if group and group.get("dungeon") and group["dungeon"].get("fight") and \ player["activity"] and player["activity"]["kind"] == "dungeon": return world["fights"].get(group["dungeon"]["fight"]) return None def shown_fight(world: dict, player: dict, now: float) -> dict | None: """The live fight, or the one that just ended so its last blows can be seen.""" fight = current_fight(world, player) if fight: return fight group = world["groups"].get(player["group"] or "") inside = player["activity"] and player["activity"]["kind"] == "dungeon" last = group["dungeon"].get("last_fight") if inside and group and group.get("dungeon") else \ player.get("last_fight") if last and now - last["ended"] < SHOW_ENDED: return last return None # --- quests ---------------------------------------------------------------- def available_quests(player: dict) -> list[str]: return [qid for qid, q in QUESTS.items() if zone_of(q["giver"]) == zone_of(player["place"]) and qid not in player["done"] and qid not in player["quests"] and all(req in player["done"] for req in q.get("req", []))] def quest_done(player: dict, qid: str) -> bool: progress = player["quests"][qid] return all(progress[i] >= o.get("n", 1) for i, o in enumerate(QUESTS[qid]["obj"])) def accept(player: dict, qid: str) -> str | None: if qid not in available_quests(player): return "Nobody here offers that" if len(player["quests"]) >= MAX_QUESTS: return f"Your quest log is full ({MAX_QUESTS})" player["quests"][qid] = [0] * len(QUESTS[qid]["obj"]) _visit(player, zone_of(player["place"])) return None def abandon(player: dict, qid: str) -> str | None: if qid not in player["quests"]: return "Not on that quest" del player["quests"][qid] return None def turn_in(world: dict, player: dict, qid: str, choice, now: float) -> str | None: quest = QUESTS.get(qid) if not quest or qid not in player["quests"]: return "Not on that quest" ender = quest.get("ender", quest["giver"]) if zone_of(player["place"]) != zone_of(ender): return f"Turn it in at {PLACES[ender]['name']}, {ZONES[zone_of(ender)]['name']}" if not quest_done(player, qid): return "Not finished yet" options = quest.get("choice", []) if options: if not isinstance(choice, int) or not 0 <= choice < len(options): return "Pick a reward" bag_add(player, options[choice]) log(player, now, f"Reward: {item(options[choice])['name']}", "loot") del player["quests"][qid] player["done"].append(qid) player["copper"] += quest.get("copper", 0) log(player, now, f"Quest complete: {quest['name']}", "quest") world_log(world, now, f"{player['name']} completed {quest['name']}", "quest", player["id"]) gain_xp(world, player, quest["xp"], now) return None def _quest_kill(player: dict, mob_id: str, now: float, rng: random.Random) -> None: for qid, progress in player["quests"].items(): for index, objective in enumerate(QUESTS[qid]["obj"]): if progress[index] >= objective.get("n", 1): continue if objective.get("kill") == mob_id: progress[index] += 1 elif mob_id in objective.get("from", []) and rng.random() < objective["chance"]: progress[index] += 1 else: continue if quest_done(player, qid): log(player, now, f"{QUESTS[qid]['name']}: done, turn it in", "quest") # --- groups and dungeons --------------------------------------------------- def create_group(world: dict, player: dict) -> str | None: if player["group"]: return "You're already in a group" gid = _next_id(world, "g") world["groups"][gid] = {"id": gid, "leader": player["id"], "members": [player["id"]], "dungeon": None} player["group"] = gid return None def join_group(world: dict, player: dict, gid: str) -> str | None: group = world["groups"].get(gid) if not group: return "That group is gone" if player["group"]: return "Leave your group first" if len(group["members"]) >= PARTY_MAX: return "That group is full" if group["dungeon"]: return "They're already inside" group["members"].append(player["id"]) player["group"] = gid return None def leave_group(world: dict, player: dict, now: float) -> str | None: group = world["groups"].get(player["group"] or "") if not group: return "You're not in a group" _exit_dungeon(world, group, player, now) group["members"].remove(player["id"]) player["group"] = None if not group["members"]: _close_dungeon(world, group) del world["groups"][group["id"]] elif group["leader"] == player["id"]: group["leader"] = group["members"][0] return None def _exit_dungeon(world: dict, group: dict, player: dict, now: float) -> None: if not (player["activity"] and player["activity"]["kind"] == "dungeon"): return dungeon = group["dungeon"] fight = world["fights"].get(dungeon["fight"] or "") if dungeon else None if fight and player["id"] in fight["units"]: unit = fight["units"][player["id"]] unit["dead"], unit["hp"] = True, 0 for pet in fight["units"].values(): if pet.get("owner") == player["id"]: pet["dead"] = True player["activity"] = None player["place"] = DUNGEONS[dungeon["id"]]["entrance"] if dungeon else player["place"] player["rest"] = {"at": now, "hp": stats(player)["max_hp"] * 0.5, "mana": 0.5} player["dead_until"] = 0 log(player, now, "Left the dungeon", "travel") if dungeon and not any(world["players"][m]["activity"] and world["players"][m]["activity"]["kind"] == "dungeon" for m in group["members"] if m != player["id"]): _close_dungeon(world, group) def _close_dungeon(world: dict, group: dict) -> None: dungeon = group.get("dungeon") if dungeon and dungeon.get("fight"): world["fights"].pop(dungeon["fight"], None) group["dungeon"] = None def min_party(world: dict) -> int: return 1 if world["config"].get("dev") else 2 def enter_dungeon(world: dict, player: dict, now: float) -> str | None: group = world["groups"].get(player["group"] or "") if not group: return "Form a group first" if group["leader"] != player["id"]: return "Only the leader can lead you in" if group["dungeon"]: return "You're already inside" zone = zone_of(player["place"]) dungeon_id = next((d for d, spec in DUNGEONS.items() if zone_of(spec["entrance"]) == zone), None) if not dungeon_id: return "There's no dungeon in this zone" if len(group["members"]) < min_party(world): return f"Dungeons need at least {min_party(world)} players" members = [world["players"][m] for m in group["members"]] away = [m["name"] for m in members if _busy(m)] if away: return f"Waiting on {', '.join(away)}" dead = [m["name"] for m in members if m["dead_until"] > now] if dead: return f"{', '.join(dead)} is dead" for member in members: # The meeting stone summons everyone, wherever they are. _leave_fight(world, member, now) member["place"] = DUNGEONS[dungeon_id]["entrance"] if zone not in member["zones"]: member["zones"].append(zone) member["activity"] = {"kind": "dungeon", "group": group["id"]} log(member, now, f"Entered {DUNGEONS[dungeon_id]['name']}", "travel") group["dungeon"] = {"id": dungeon_id, "pull": 0, "state": "rest", "next": now + DUNGEON_REST, "fight": None, "started": now, "wipes": 0, "last": None} world_log(world, now, f"{', '.join(m['name'] for m in members)} entered {DUNGEONS[dungeon_id]['name']}", "dungeon", player["id"]) return None def pull_now(world: dict, player: dict, now: float) -> str | None: group = world["groups"].get(player["group"] or "") if not group or not group["dungeon"]: return "You're not in a dungeon" if group["leader"] != player["id"]: return "Only the leader pulls" dungeon = group["dungeon"] if dungeon["state"] != "rest": return "Not now" dungeon["next"] = now return None def leave_dungeon(world: dict, player: dict, now: float) -> str | None: group = world["groups"].get(player["group"] or "") if not group or not group["dungeon"]: return "You're not in a dungeon" _exit_dungeon(world, group, player, now) return None def _dungeon_members(world: dict, group: dict) -> list[dict]: return [world["players"][m] for m in group["members"] if world["players"][m]["activity"] and world["players"][m]["activity"]["kind"] == "dungeon"] def dungeon_scale(size: int) -> tuple[float, float]: """Packs are tuned for five; smaller groups face lighter ones.""" return 0.1 + 0.18 * size, 0.5 + 0.1 * size def _process_dungeon(world: dict, group: dict, now: float, rng: random.Random) -> None: for _ in range(1000): dungeon = group["dungeon"] if not dungeon: return members = _dungeon_members(world, group) if not members: _close_dungeon(world, group) return if dungeon["fight"]: fight = world["fights"][dungeon["fight"]] combat.advance(fight, now, watch_map(world)) if fight["state"] == "on": return _end_dungeon_fight(world, group, fight, rng) continue if dungeon["state"] == "rest" and now >= dungeon["next"]: _start_pull(world, group, dungeon["next"], rng) continue return def _start_pull(world: dict, group: dict, at: float, rng: random.Random) -> None: dungeon = group["dungeon"] spec = DUNGEONS[dungeon["id"]] pull = spec["pulls"][dungeon["pull"]] members = _dungeon_members(world, group) hp_scale, dmg_scale = dungeon_scale(len(members)) party = [] for member in members: party += player_unit(member, at, True) enemies = [] for index, mob_id in enumerate(pull["mobs"]): low, high = MOBS[mob_id]["lv"] unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high), hp_scale, dmg_scale) unit["swing"]["next"] = 0.8 + 0.4 * index enemies.append(unit) fid = _next_id(world, "f") world["fights"][fid] = combat.new_fight(fid, rng.randrange(2 ** 31), at, party, enemies, "dungeon", pull["name"], (hp_scale, dmg_scale)) dungeon["fight"] = fid dungeon["state"] = "fight" def _end_dungeon_fight(world: dict, group: dict, fight: dict, rng: random.Random) -> None: dungeon = group["dungeon"] spec = DUNGEONS[dungeon["id"]] end = fight["ended"] members = _dungeon_members(world, group) dungeon["last_fight"] = world["fights"].pop(fight["id"]) dungeon["fight"] = None present = [m for m in members if m["id"] in fight["units"]] if fight["state"] == "won": share = (1 + 0.1 * (len(present) - 1)) / max(1, len(present)) for member in present: unit = fight["units"][member["id"]] hp = unit["hp"] if not unit["dead"] else stats(member)["max_hp"] * 0.3 mana = unit["res"]["cur"] / unit["res"]["max"] if unit["res"]["type"] == "mana" else 1.0 member["rest"] = {"at": end, "hp": hp, "mana": mana if not unit["dead"] else 0.3} _reward_kills(world, member, fight, share, end, rng, dungeon=True) dungeon["last"] = {"pull": spec["pulls"][dungeon["pull"]]["name"], "won": True, "t": round(fight["t"])} dungeon["pull"] += 1 if dungeon["pull"] >= len(spec["pulls"]): dungeon["state"] = "done" world_log(world, end, f"{', '.join(m['name'] for m in present)} cleared {spec['name']}!", "dungeon") for member in present: log(member, end, f"Cleared {spec['name']}", "dungeon") else: dungeon["state"] = "rest" dungeon["next"] = end + DUNGEON_REST else: dungeon["wipes"] += 1 dungeon["last"] = {"pull": spec["pulls"][dungeon["pull"]]["name"], "won": False, "t": round(fight["t"])} for member in present: member["rest"] = {"at": end + WIPE_DELAY, "hp": stats(member)["max_hp"] * 0.5, "mana": 0.5} log(member, end, f"Wiped on {spec['pulls'][dungeon['pull']]['name']}", "death") dungeon["state"] = "rest" dungeon["next"] = end + WIPE_DELAY + DUNGEON_REST # --- chat ------------------------------------------------------------------ def say(world: dict, player: dict, text: str, now: float) -> str | None: text = text.strip()[:300] if not text: return "Say something" world["chat"].append({"t": now, "who": player["id"], "text": text}) del world["chat"][:-MAX_CHAT] return None # --- dev helpers ----------------------------------------------------------- def dev_level(world: dict, player: dict, level: int, now: float) -> str | None: if not 1 <= level <= MAX_LEVEL: return "Bad level" player["level"], player["xp"] = level, 0 cls = CLASSES[player["cls"]] armour = cls["armour"] for slot in SLOTS: kind = cls["weapons"][0] if slot == "weapon" else ("cloth" if slot == "back" else armour) suffix = "bear" if spec_of(player)["role"] == "tank" else "eagle" player["equip"][slot] = f"g/{slot}/{kind}/{suffix}/{level}" player["rest"] = {"at": now, "hp": stats(player)["max_hp"], "mana": 1.0} return None