Zones instead of road timers: journeys, flights, meeting stone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7150b2f89c
commit
0e396ee5d4
7 changed files with 363 additions and 230 deletions
196
game.py
196
game.py
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue