wog/game.py

942 lines
38 KiB
Python
Raw Normal View History

"""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