Zones instead of road timers: journeys, flights, meeting stone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 08:24:50 +01:00
parent 7150b2f89c
commit 0e396ee5d4
7 changed files with 363 additions and 230 deletions

196
game.py
View file

@ -7,15 +7,15 @@ adventuring for up to OFFLINE_CAP.
from __future__ import annotations
import heapq
import random
import combat
from content import (ABILITIES, ARMOUR_TYPES, BASE_NAMES, BOSS_LOOT, BOSS_LOOT_CHANCE, CLASSES, DUNGEONS,
GRAVEYARD, ITEMS, MAX_LEVEL, MOBS, PETS, PLACES, QUALITY, QUESTS, SLOT_ARMOUR, SLOT_BUDGET,
SLOTS, SPECS, SUFFIXES, TRASH_LOOT, TRASH_LOOT_CHANCE, UNLOCK, WEAPON_SPEED, XP_TO_NEXT)
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 = 1
VERSION = 2
MINUTE = 60
HOUR = 3600
OFFLINE_CAP = 12 * HOUR
@ -160,7 +160,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
armour = spec["armour"]
player = {
"id": uid, "name": name, "cls": cls, "level": 1, "xp": 0, "copper": 0,
"place": "northshire_abbey", "activity": None, "fight": None, "group": None,
"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},
@ -269,6 +269,26 @@ def new_world(now: float) -> dict:
"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']}"
@ -296,31 +316,36 @@ def _process_player(world: dict, player: dict, now: float, rng: random.Random) -
combat.advance(fight, now, watch_map(world))
if fight["state"] == "on":
return
_end_hunt_fight(world, player, fight, rng)
_end_field_fight(world, player, fight, rng)
continue
if not activity:
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"] == "travel":
if now < activity["end"]:
return
_arrive(world, player, activity["to"], activity["end"])
continue
if activity["kind"] == "hunt":
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
_start_hunt_fight(world, player, activity["hunt"], start, rng)
continue
return
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 _start_hunt_fight(world: dict, player: dict, hunt: str, at: float, rng: random.Random) -> None:
pulls = PLACES[player["place"]]["hunts"][hunt]
pull = rng.choice(pulls)
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"]
@ -329,12 +354,14 @@ def _start_hunt_fight(world: dict, player: dict, hunt: str, at: float, rng: rand
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, "hunt", name)
enemies, kind, name)
player["fight"] = fid
def _end_hunt_fight(world: dict, player: dict, fight: dict, rng: random.Random) -> None:
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
@ -348,13 +375,24 @@ def _end_hunt_fight(world: dict, player: dict, fight: dict, rng: random.Random)
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")
if player["deaths"] >= DEATHS_TO_STOP:
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")
@ -393,13 +431,20 @@ def _reward_kills(world: dict, player: dict, fight: dict, share: float, now: flo
gain_xp(world, player, xp, now)
def _arrive(world: dict, player: dict, place: str, at: float) -> None:
player["place"] = place
def _arrive(world: dict, player: dict, zone: str, at: float, flew: bool = False) -> None:
player["place"] = ZONES[zone]["hub"]
player["activity"] = None
log(player, at, f"Arrived at {PLACES[place]['name']}", "travel")
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") == place:
if objective.get("visit") == zone:
progress[index] = 1
@ -418,12 +463,18 @@ def _leave_fight(world: dict, player: dict, now: float) -> None:
"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)
if hunt not in PLACES[player["place"]].get("hunts", {}):
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)
@ -442,41 +493,46 @@ 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"] == "travel":
if activity and activity["kind"] == "journey":
return "You're on the road"
return None
def route(start: str, goal: str) -> tuple[float, list[str]] | None:
queue = [(0.0, start, [start])]
seen = set()
while queue:
cost, here, path = heapq.heappop(queue)
if here == goal:
return cost, path
if here in seen:
continue
seen.add(here)
for nxt, minutes in PLACES[here]["links"].items():
if nxt not in seen:
heapq.heappush(queue, (cost + minutes, nxt, path + [nxt]))
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 travel(world: dict, player: dict, to: str, now: float) -> str | 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)
if to not in PLACES or to == player["place"]:
return "Pick somewhere else"
found = route(player["place"], to)
if not found:
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"
minutes, path = found
_leave_fight(world, player, now)
player["activity"] = {"kind": "travel", "from": player["place"], "to": to, "path": path,
"start": now, "end": now + minutes * MINUTE}
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
@ -510,8 +566,6 @@ def unequip(player: dict, slot: str) -> str | None:
def sell(player: dict, item_id: str, qty: int | str) -> str | None:
if not PLACES[player["place"]].get("hub"):
return "Find a vendor in a town"
have = player["bags"].get(item_id, 0)
if qty == "all":
qty = have
@ -526,8 +580,6 @@ def sell(player: dict, item_id: str, qty: int | str) -> str | None:
def sell_junk(player: dict) -> str | None:
if not PLACES[player["place"]].get("hub"):
return "Find a vendor in a town"
junk = [i for i in player["bags"] if i.startswith("j/")]
if not junk:
return "No junk to sell"
@ -577,7 +629,7 @@ def shown_fight(world: dict, player: dict, now: float) -> dict | None:
def available_quests(player: dict) -> list[str]:
return [qid for qid, q in QUESTS.items()
if q["giver"] == player["place"] and qid not in player["done"] and qid not in player["quests"]
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", []))]
@ -592,9 +644,7 @@ def accept(player: dict, qid: str) -> str | None:
if len(player["quests"]) >= MAX_QUESTS:
return f"Your quest log is full ({MAX_QUESTS})"
player["quests"][qid] = [0] * len(QUESTS[qid]["obj"])
for index, objective in enumerate(QUESTS[qid]["obj"]):
if objective.get("visit") == player["place"]:
player["quests"][qid][index] = 1
_visit(player, zone_of(player["place"]))
return None
@ -609,8 +659,9 @@ def turn_in(world: dict, player: dict, qid: str, choice, now: float) -> str | No
quest = QUESTS.get(qid)
if not quest or qid not in player["quests"]:
return "Not on that quest"
if player["place"] != quest.get("ender", quest["giver"]):
return f"Turn it in at {PLACES[quest.get('ender', quest['giver'])]['name']}"
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", [])
@ -724,20 +775,25 @@ def enter_dungeon(world: dict, player: dict, now: float) -> str | None:
return "Only the leader can lead you in"
if group["dungeon"]:
return "You're already inside"
dungeon_id = PLACES[player["place"]].get("dungeon")
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 here"
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 m["place"] != player["place"] or _busy(m)]
away = [m["name"] for m in members if _busy(m)]
if away:
return f"Waiting on {', '.join(away)} to reach {PLACES[player['place']]['name']}"
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,