941 lines
38 KiB
Python
941 lines
38 KiB
Python
"""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
|