extends RefCounted
## Guild War (docs/research/DRACONIA_PVP.md 3.2 mode 4), server-authoritative. Commands (routed by server/pvp.gd):
##  - gw_info: schedule (weekly prime time, CFG weekday / hour / minute, 40 min), the running war, test path allowed;
##    gw_join: a guild member (Base Lv 30+) enters the battlefield while the war is open (two guilds per field, up to
##    max_side each); gw_leave: back where you came from; gw_bearer {name}: the side's leader names the flag-bearer;
##    gw_test: (config "guild_war_test", or test_ok in selftests) a war right now against a bot guild ("Shadow
##    Legion", mirror bots) with a few ally bots - the solo / bots test path.
##  - the hidden map "guild_warfield": each side has a base (side A south +z, side B north -z), 3 shrines on the middle
##    line (west / centre / east) and the relic altar in the centre.
##  - points: every shrine a side holds pays shrine_pps per second; carrying the relic flag from the altar to your own
##    base pays flag_pts (the core objective); kills pay kill_pts (more for the relic carrier / the enemy leader);
##    first side to point_cap wins, else the higher score when the 40 minutes end.
##  - shrines: stand inside (shrine_r) with no living enemy there; cap_s seconds turn it (the flag-bearer counts double).
##  - roles: the LEADER (guild leader, else the highest rank present) carries an aura: allies within aura_r deal +10%
##    and take -10% damage; the FLAG-BEARER (named by the leader, else the second member) picks the relic up at once
##    (others channel relic_pick_s) and captures shrines twice as fast.
##  - a fallen fighter loses no EXP and returns at the side's base after respawn_s; at the end everyone is revived and
##    sent back; rewards: gold for everyone who fought, more for the winners and per personal point.
## Events: {"t": "gw", "state": "start" | "update" | "end" | "left", ...} to the fighters.

const CMDS := ["gw_join", "gw_leave", "gw_info", "gw_test", "gw_bearer"]
const MAP := "guild_warfield"
const BOT_GID := "bot"
## (a static var so tests can shorten the waits)
static var CFG := {
	"weekday": 6,            # Saturday (Time weekday: 0 = Sunday)
	"hour": 20,
	"minute": 0,
	"time": 2400.0,          # 40 minutes
	"min_lv": 30,
	"max_side": 20,
	"countdown": 10.0,
	"point_cap": 1500,
	"shrine_pps": 1.0,
	"shrine_r": 6.0,
	"cap_s": 8.0,
	"flag_pts": 150,
	"kill_pts": 10,
	"carrier_kill_pts": 25,
	"leader_kill_pts": 40,
	"relic_r": 2.2,
	"relic_pick_s": 2.0,
	"relic_respawn_s": 15.0,
	"home_r": 6.0,
	"respawn_s": 8.0,
	"aura_r": 12.0,
	"aura_dmg": 1.1,
	"aura_def": 0.9,
	"base_z": 44.0,
	"shrines": [[-34.0, 0.0], [0.0, 0.0], [34.0, 0.0]],
	"altar": [0.0, 6.0],
	"bots": 5,               # test path: enemy bots
	"ally_bots": 3,          # test path: bots on your side
	"test_time": 600.0,
	"test_ok": false,        # selftests allow gw_test without the config flag
	"bot_ai": true,          # selftests switch the bots off for the scripted checks
	"gold_all": 8000,
	"gold_win": 20000,
	"gold_per_pt": 20,
}


static func now() -> float:
	return Time.get_ticks_msec() / 1000.0


static func _st(server) -> Dictionary:
	if not server.has_meta("guild_war"):
		server.set_meta("guild_war", {"war": null, "next": 1})
	return server.get_meta("guild_war")


static func v3(a: Array) -> Vector3:
	return Vector3(float(a[0]), 0.0, float(a[1]))


static func base_of(side_index: int) -> Vector3:
	return Vector3(0, 0, float(CFG["base_z"]) * (1.0 if side_index == 0 else -1.0))


## The running war (any state) or null.
static func war(server) -> Variant:
	return _st(server)["war"] if server != null else null


## The war `pe` fights in, or null.
static func war_of(server, pe) -> Variant:
	var w = war(server)
	if w == null or pe == null:
		return null
	return w if side_of(w, pe.id) != "" else null


static func side_of(w: Dictionary, pid: int) -> String:
	for k in w["order"]:
		if pid in (w["sides"][k]["members"] as Array):
			return str(k)
	return ""


static func test_allowed() -> bool:
	return bool(CFG["test_ok"]) or bool(GameData.config.get("guild_war_test", false))


## True inside the weekly window (local time).
static func open_now() -> bool:
	var d := Time.get_datetime_dict_from_system()
	if int(d["weekday"]) != int(CFG["weekday"]):
		return false
	var m := int(d["hour"]) * 60 + int(d["minute"])
	var s := int(CFG["hour"]) * 60 + int(CFG["minute"])
	return m >= s and m < s + int(float(CFG["time"]) / 60.0)


## Seconds left in this week's window (0 outside it).
static func window_left() -> float:
	if not open_now():
		return 0.0
	var d := Time.get_datetime_dict_from_system()
	var t := int(d["hour"]) * 3600 + int(d["minute"]) * 60 + int(d["second"])
	var e := (int(CFG["hour"]) * 60 + int(CFG["minute"])) * 60 + int(float(CFG["time"]))
	return float(maxi(0, e - t))


static func schedule_text() -> String:
	var days := ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
	var s := int(CFG["hour"]) * 60 + int(CFG["minute"])
	var e := s + int(float(CFG["time"]) / 60.0)
	return "%s %02d:%02d-%02d:%02d" % [days[int(CFG["weekday"]) % 7], s / 60, s % 60, e / 60, e % 60]


static func cmd(server, pe, c: Dictionary) -> Dictionary:
	match str(c.get("t", "")):
		"gw_join":
			return _join(server, pe)
		"gw_leave":
			return _leave(server, pe)
		"gw_bearer":
			return _bearer(server, pe, str(c.get("name", "")))
		"gw_test":
			if not test_allowed():
				pe.msg("The test war is off (config guild_war_test).", Color(1, 0.6, 0.5))
				return {"ok": false}
			return start_test(server, pe)
		"gw_info":
			var w = war(server)
			var r := {"ok": true, "schedule": schedule_text(), "open": open_now() or (w != null and not bool(w["over"])), "test": test_allowed(),
				"in": war_of(server, pe) != null}
			if w != null:
				r["sides"] = _side_view(w, "")
			return r
	return {"ok": false}


# ---------------------------------------------------------------- joining

static func _new_war(server, test: bool) -> Dictionary:
	var st := _st(server)
	var dur := float(CFG["test_time"]) if test else maxf(60.0, window_left() - float(CFG["countdown"]))
	var shr: Array = []
	for s in CFG["shrines"]:
		shr.append({"at": v3(s), "owner": "", "prog": 0.0, "who": ""})
	var w := {"id": int(st["next"]), "test": test, "start": now() + float(CFG["countdown"]), "end": now() + float(CFG["countdown"]) + dur,
		"over": false, "sides": {}, "order": [], "shrines": shr, "relic": {"state": "altar", "at": v3(CFG["altar"]), "holder": 0, "pick": {}, "back_at": 0.0},
		"dead": {}, "pts": {}, "kills": {}, "back": {}, "bots": [], "push_t": 0.0, "bot_t": 0.0}
	st["next"] = int(st["next"]) + 1
	st["war"] = w
	return w


static func _side(server, w: Dictionary, gid: String, gname: String, col: String) -> String:
	if w["sides"].has(gid):
		return gid
	if (w["order"] as Array).size() >= 2:
		return ""
	w["sides"][gid] = {"gid": gid, "name": gname, "color": col, "base": base_of((w["order"] as Array).size()), "pts": 0.0,
		"members": [], "leader": 0, "bearer": 0, "bearer_name": ""}
	w["order"].append(gid)
	return gid


static func _join(server, pe, quiet: bool = false) -> Dictionary:
	var Guild = load("res://scripts/server/guild.gd")
	var Arena = load("res://scripts/server/arena.gd")
	var w = war(server)
	if war_of(server, pe) != null:
		return {"ok": false, "error": "already"}
	if w == null or bool(w["over"]):
		if not open_now():
			pe.msg("The guild war opens %s." % schedule_text(), Color(1, 0.7, 0.5))
			return {"ok": false}
		w = _new_war(server, false)
	var g = Guild.of(server, pe)
	if g == null:
		pe.msg("Only guild members fight in the guild war.", Color(1, 0.6, 0.5))
		return {"ok": false}
	if int(pe.ch.base_lv) < int(CFG["min_lv"]) or pe.dead:
		pe.msg("The guild war needs Base Lv %d." % int(CFG["min_lv"]), Color(1, 0.6, 0.5))
		return {"ok": false}
	if Arena.match_of(server, pe) != null or load("res://scripts/server/pvp.gd").duel_of(server, pe) != null:
		pe.msg("Finish your match first.", Color(1, 0.6, 0.5))
		return {"ok": false}
	var key := _side(server, w, str(g["id"]), str(g["name"]), str(g.get("color", "#c8902a")))
	if key == "":
		pe.msg("This battlefield is full (two guilds).", Color(1, 0.6, 0.5))
		return {"ok": false}
	if (w["sides"][key]["members"] as Array).size() >= int(CFG["max_side"]):
		pe.msg("Your guild's side is full (%d)." % int(CFG["max_side"]), Color(1, 0.6, 0.5))
		return {"ok": false}
	_add(server, w, key, pe)
	if not quiet:
		pe.msg("Guild war! Hold the shrines and carry the relic flag home.", Color(1, 0.85, 0.4))
	return {"ok": true, "side": key}


static func _add(server, w: Dictionary, key: String, pe) -> void:
	var s: Dictionary = w["sides"][key]
	s["members"].append(pe.id)
	if not pe.has_meta("gw_bot"):
		w["back"][pe.id] = [str(pe.map.map_id) if pe.map != null else str(pe.ch.save_map), pe.global_position]
	_roles(server, w, key)
	pe.dead = false
	pe.ch.hp = pe.ch.max_hp()
	pe.ch.changed.emit()
	pe._clear_goals()
	var at: Vector3 = s["base"] + Vector3(randf_range(-3.0, 3.0), 0, randf_range(-2.0, 2.0))
	if pe.has_meta("gw_bot"):
		server.enter_map(pe, MAP, at)
	else:
		server.warp(pe, MAP, at)
		server.emit_ev({"t": "gw", "state": "start", "side": key, "start_in": maxf(0.0, float(w["start"]) - now()), "end_in": float(w["end"]) - now(),
			"cap": int(CFG["point_cap"]), "_to": pe.id})


## Leader = the guild leader if present, else the best rank present, else the first in; bearer = named, else the
## second member in.
static func _roles(server, w: Dictionary, key: String) -> void:
	var s: Dictionary = w["sides"][key]
	var Guild = load("res://scripts/server/guild.gd")
	var g = (Guild.reg(server)["guilds"] as Dictionary).get(str(s["gid"]), null)
	var best := 0
	var best_r := 9
	for pid in s["members"]:
		var p = server.player(int(pid))
		if p == null:
			continue
		var rk := 3
		if g != null:
			rk = {"leader": 0, "officer": 1, "member": 2}.get(Guild.rank_of(g, str(p.ch.char_name)), 3)
		if rk < best_r:
			best_r = rk
			best = int(pid)
	s["leader"] = best
	var bearer := 0
	if str(s["bearer_name"]) != "":
		for pid in s["members"]:
			var p2 = server.player(int(pid))
			if p2 != null and str(p2.ch.char_name) == str(s["bearer_name"]):
				bearer = int(pid)
	if bearer == 0:
		for pid in s["members"]:
			if int(pid) != best:
				bearer = int(pid)
				break
	s["bearer"] = bearer


static func _bearer(server, pe, nm: String) -> Dictionary:
	var w = war_of(server, pe)
	if w == null:
		return {"ok": false}
	var key := side_of(w, pe.id)
	var s: Dictionary = w["sides"][key]
	if int(s["leader"]) != pe.id:
		pe.msg("Only your side's leader names the flag-bearer.", Color(1, 0.6, 0.5))
		return {"ok": false}
	s["bearer_name"] = nm
	_roles(server, w, key)
	var b = server.player(int(s["bearer"]))
	if b != null:
		pe.msg("Flag-bearer: %s." % str(b.ch.char_name), Color(1, 0.85, 0.4))
	return {"ok": true, "bearer": int(s["bearer"])}


static func _leave(server, pe) -> Dictionary:
	var w = war_of(server, pe)
	if w == null:
		return {"ok": false}
	_drop_relic(w, pe)
	var key := side_of(w, pe.id)
	w["sides"][key]["members"].erase(pe.id)
	_roles(server, w, key)
	_send_back(server, w, pe)
	server.emit_ev({"t": "gw", "state": "left", "_to": pe.id})
	return {"ok": true}


## The test path: a war right now, your guild (or a stand-in side when you have none) + ally bots against the bot
## guild "Shadow Legion".
static func start_test(server, pe) -> Dictionary:
	var w = war(server)
	if w != null and not bool(w["over"]):
		if war_of(server, pe) != null:
			return {"ok": false, "error": "already"}
		_finish(server, w)
	w = _new_war(server, true)
	var Guild = load("res://scripts/server/guild.gd")
	var g = Guild.of(server, pe)
	var key := _side(server, w, str(g["id"]) if g != null else "solo", str(g["name"]) if g != null else "Test Brigade", str(g.get("color", "#3a6fd8")) if g != null else "#3a6fd8")
	_side(server, w, BOT_GID, "Shadow Legion", "#b8322a")
	_add(server, w, key, pe)
	var n := 0
	for i in int(CFG["ally_bots"]):
		_bot(server, w, key, pe, "Ally %d" % (i + 1), n)
		n += 1
	for i in int(CFG["bots"]):
		_bot(server, w, BOT_GID, pe, "Legionnaire %d" % (i + 1), n)
		n += 1
	_roles(server, w, BOT_GID)
	pe.msg("Test guild war: %d vs %d (bots)." % [(w["sides"][key]["members"] as Array).size(), (w["sides"][BOT_GID]["members"] as Array).size()], Color(1, 0.85, 0.4))
	return {"ok": true, "side": key}


static func _bot(server, w: Dictionary, key: String, src, nm: String, n: int) -> void:
	var CS = load("res://scripts/server/character_state.gd")
	var c = CS.new()
	c.from_dict(src.ch.to_dict())
	c.char_name = nm
	c.guild = ""
	c.arena = {}
	c.hp = c.max_hp()
	var bot = server.add_player(c, true, 9300 + n)
	bot.set_meta("gw_bot", true)
	w["bots"].append(bot.id)
	_add(server, w, key, bot)


# ---------------------------------------------------------------- rules (server/pvp.gd asks these)

static func can_hit(server, a, b) -> bool:
	var w = war_of(server, a)
	if w == null or war_of(server, b) != w or now() < float(w["start"]) or bool(w["over"]):
		return false
	return side_of(w, a.id) != side_of(w, b.id) and not a.dead and not b.dead


static func hostiles_near(server, pe, center: Vector3, r: float) -> Array:
	var out: Array = []
	var w = war_of(server, pe)
	if w == null:
		return out
	for k in w["order"]:
		for pid in w["sides"][k]["members"]:
			var o = server.player(int(pid))
			if o != null and o != pe and can_hit(server, pe, o) and Vector2(o.global_position.x - center.x, o.global_position.z - center.z).length() <= r:
				out.append(o)
	return out


## Within aura_r of the side's living leader.
static func in_aura(server, w: Dictionary, pe) -> bool:
	var key := side_of(w, pe.id)
	if key == "":
		return false
	var ld = server.player(int(w["sides"][key]["leader"]))
	return ld != null and not ld.dead and ld.map == pe.map and ld.global_position.distance_to(pe.global_position) <= float(CFG["aura_r"])


## A hit inside the war: the leaders' auras, then a kill pays points (more for the relic carrier / the leader).
static func on_damage(server, b, a, dmg: int) -> int:
	var w = war_of(server, b)
	if w == null:
		return dmg
	var f := 1.0
	if a != null and war_of(server, a) == w and in_aura(server, w, a):
		f *= float(CFG["aura_dmg"])
	if in_aura(server, w, b):
		f *= float(CFG["aura_def"])
	dmg = maxi(1, int(round(float(dmg) * f)))
	if dmg >= int(b.ch.hp):
		b.set_meta("gw_down", true)
		w["dead"][b.id] = now()
		var bk := side_of(w, b.id)
		var pts := int(CFG["kill_pts"])
		if int(w["relic"]["holder"]) == b.id:
			pts = int(CFG["carrier_kill_pts"])
		if int(w["sides"][bk]["leader"]) == b.id:
			pts = maxi(pts, int(CFG["leader_kill_pts"]))
		_drop_relic(w, b)
		if a != null and war_of(server, a) == w:
			_score(server, w, side_of(w, a.id), a.id, float(pts))
			w["kills"][a.id] = int(w["kills"].get(a.id, 0)) + 1
	return dmg


static func on_death(server, b) -> bool:
	if b == null or not b.has_meta("gw_down"):
		return false
	b.remove_meta("gw_down")
	b.msg("Fallen - back at your base in %d s (no EXP lost)." % int(CFG["respawn_s"]), Color(1, 0.75, 0.5))
	return true


static func _score(server, w: Dictionary, key: String, pid: int, pts: float) -> void:
	if key == "" or bool(w["over"]):
		return
	w["sides"][key]["pts"] = float(w["sides"][key]["pts"]) + pts
	if pid != 0:
		w["pts"][pid] = float(w["pts"].get(pid, 0.0)) + pts


static func _drop_relic(w: Dictionary, pe) -> void:
	var r: Dictionary = w["relic"]
	if int(r["holder"]) == pe.id:
		r["holder"] = 0
		r["state"] = "dropped"
		r["at"] = Vector3(pe.global_position.x, 0, pe.global_position.z)
		r["pick"] = {}


# ---------------------------------------------------------------- tick

static func tick(server, delta: float) -> void:
	var st := _st(server)
	var w = st["war"]
	if w == null:
		return
	var t := now()
	if bool(w["over"]):
		if t >= float(w["over_at"]) + 4.0:
			_finish(server, w)
		return
	# fighters who left the map / disconnected leave the war; the fallen return at their base
	for k in w["order"]:
		var s: Dictionary = w["sides"][k]
		for pid in (s["members"] as Array).duplicate():
			var p = server.player(int(pid))
			if p == null or (t >= float(w["start"]) and p.map != null and str(p.map.map_id) != MAP):
				s["members"].erase(pid)
				if p != null:
					_drop_relic(w, p)
				_roles(server, w, k)
				continue
			if p.dead and t - float(w["dead"].get(p.id, t)) >= float(CFG["respawn_s"]):
				w["dead"].erase(p.id)
				p.dead = false
				p.ch.hp = p.ch.max_hp()
				p.ch.changed.emit()
				p._clear_goals()
				p.ev({"t": "respawned", "id": p.id})
				server.warp(p, MAP, s["base"] + Vector3(randf_range(-3.0, 3.0), 0, randf_range(-2.0, 2.0)))
	if t >= float(w["start"]):
		_tick_shrines(server, w, delta)
		_tick_relic(server, w, delta)
		w["bot_t"] = float(w["bot_t"]) - delta
		if float(w["bot_t"]) <= 0.0:
			w["bot_t"] = 0.5
			_bots(server, w)
	# the end: point cap or time
	var cap := float(CFG["point_cap"])
	var best := ""
	var top := -1.0
	var tie := false
	for k in w["order"]:
		var pts := float(w["sides"][k]["pts"])
		if pts > top + 0.001:
			top = pts
			best = str(k)
			tie = false
		elif absf(pts - top) <= 0.001:
			tie = true
	if top >= cap or t >= float(w["end"]):
		_over(server, w, "" if tie else best)
		return
	w["push_t"] = float(w["push_t"]) - delta
	if float(w["push_t"]) <= 0.0:
		w["push_t"] = 0.5
		_push(server, w)


static func _alive_near(server, w: Dictionary, at: Vector3, r: float) -> Dictionary:
	var out := {}
	for k in w["order"]:
		for pid in w["sides"][k]["members"]:
			var p = server.player(int(pid))
			if p != null and not p.dead and p.map != null and str(p.map.map_id) == MAP and Vector2(p.global_position.x - at.x, p.global_position.z - at.z).length() <= r:
				if not out.has(k):
					out[k] = []
				out[k].append(p)
	return out


static func _tick_shrines(server, w: Dictionary, delta: float) -> void:
	for sh in w["shrines"]:
		var near := _alive_near(server, w, sh["at"], float(CFG["shrine_r"]))
		if near.size() == 1:
			var k: String = near.keys()[0]
			if str(sh["owner"]) != k:
				var rate := 1.0
				for p in near[k]:
					if int(w["sides"][k]["bearer"]) == p.id:
						rate = 2.0
				if str(sh["who"]) != k:
					sh["who"] = k
					sh["prog"] = 0.0
				sh["prog"] = float(sh["prog"]) + delta * rate / float(CFG["cap_s"])
				if float(sh["prog"]) >= 1.0:
					sh["owner"] = k
					sh["prog"] = 0.0
					sh["who"] = ""
					_say(server, w, "%s took a shrine!" % str(w["sides"][k]["name"]))
		elif near.is_empty():
			sh["prog"] = maxf(0.0, float(sh["prog"]) - delta * 0.25 / float(CFG["cap_s"]))
		if str(sh["owner"]) != "":
			_score(server, w, str(sh["owner"]), 0, float(CFG["shrine_pps"]) * delta)


static func _tick_relic(server, w: Dictionary, delta: float) -> void:
	var r: Dictionary = w["relic"]
	match str(r["state"]):
		"cooldown":
			if now() >= float(r["back_at"]):
				r["state"] = "altar"
				r["at"] = v3(CFG["altar"])
				_say(server, w, "The relic flag is back on its altar!")
		"carried":
			var h = server.player(int(r["holder"]))
			if h == null or h.dead:
				r["state"] = "dropped"
				r["holder"] = 0
				return
			r["at"] = Vector3(h.global_position.x, 0, h.global_position.z)
			var k := side_of(w, h.id)
			if k != "" and (r["at"] as Vector3).distance_to(w["sides"][k]["base"]) <= float(CFG["home_r"]):
				_score(server, w, k, h.id, float(CFG["flag_pts"]))
				r["state"] = "cooldown"
				r["holder"] = 0
				r["back_at"] = now() + float(CFG["relic_respawn_s"])
				_say(server, w, "%s carried the relic flag home! +%d" % [str(w["sides"][k]["name"]), int(CFG["flag_pts"])])
		"altar", "dropped":
			var near := _alive_near(server, w, r["at"], float(CFG["relic_r"]))
			var pick: Dictionary = r["pick"]
			for k in near:
				for p in near[k]:
					var need := 0.0 if int(w["sides"][k]["bearer"]) == p.id else float(CFG["relic_pick_s"])
					pick[p.id] = float(pick.get(p.id, 0.0)) + delta
					if float(pick[p.id]) >= need:
						r["state"] = "carried"
						r["holder"] = p.id
						r["pick"] = {}
						_say(server, w, "%s (%s) has the relic flag!" % [str(p.ch.char_name), str(w["sides"][k]["name"])])
						return
			for pid in pick.keys():
				var still := false
				for k in near:
					for p in near[k]:
						if p.id == int(pid):
							still = true
				if not still:
					pick.erase(pid)


## Bot AI: fight what is close, else play the objective (relic, enemy / neutral shrines, home with the relic).
static func _bots(server, w: Dictionary) -> void:
	var r: Dictionary = w["relic"]
	if not bool(CFG["bot_ai"]):
		return
	for bid in w["bots"]:
		var bot = server.player(int(bid))
		if bot == null or bot.dead:
			continue
		var k := side_of(w, bot.id)
		if k == "":
			continue
		var s: Dictionary = w["sides"][k]
		if int(r["holder"]) == bot.id:
			bot.cmd_move(s["base"])
			continue
		var foe = null
		var fd := 9.0
		for k2 in w["order"]:
			if str(k2) == k:
				continue
			for pid in w["sides"][k2]["members"]:
				var o = server.player(int(pid))
				if o == null or o.dead or o.map != bot.map:
					continue
				var d: float = bot.global_position.distance_to(o.global_position)
				if d < fd:
					fd = d
					foe = o
		if foe != null:
			if bot.attack_target != foe:
				bot.cmd_attack(foe, true, false)
			continue
		var goal := Vector3.INF
		if str(r["state"]) in ["altar", "dropped"] and (int(s["bearer"]) == bot.id or bot.global_position.distance_to(r["at"]) < 16.0):
			goal = r["at"]
		else:
			var gd := INF
			for sh in w["shrines"]:
				if str(sh["owner"]) == k:
					continue
				var d2: float = bot.global_position.distance_to(sh["at"])
				if d2 < gd:
					gd = d2
					goal = sh["at"]
		if goal == Vector3.INF:
			goal = v3(CFG["altar"])
		var off := Vector3(fposmod(float(bot.id) * 1.7, 3.0) - 1.5, 0, fposmod(float(bot.id) * 2.3, 3.0) - 1.5)
		if bot.global_position.distance_to(goal + off) > 1.2:
			bot.cmd_move(goal + off)


static func _say(server, w: Dictionary, text: String) -> void:
	for k in w["order"]:
		for pid in w["sides"][k]["members"]:
			var p = server.player(int(pid))
			if p != null and not p.has_meta("gw_bot"):
				p.msg(text, Color(1, 0.85, 0.45))


static func _side_view(w: Dictionary, mine: String) -> Array:
	var out: Array = []
	for k in w["order"]:
		var s: Dictionary = w["sides"][k]
		out.append({"key": str(k), "name": str(s["name"]), "color": str(s["color"]), "pts": int(s["pts"]), "n": (s["members"] as Array).size(),
			"me": str(k) == mine, "leader": int(s["leader"]), "bearer": int(s["bearer"]), "base": s["base"]})
	return out


## Every human fighter gets the war state (scores, shrines, relic, roles, who is friend or foe).
static func _push(server, w: Dictionary) -> void:
	var shr: Array = []
	for sh in w["shrines"]:
		shr.append({"at": sh["at"], "owner": str(sh["owner"]), "prog": float(sh["prog"]), "who": str(sh["who"])})
	var rel := {"state": str(w["relic"]["state"]), "at": w["relic"]["at"], "holder": int(w["relic"]["holder"])}
	for k in w["order"]:
		var allies: Array = w["sides"][k]["members"]
		var enemies: Array = []
		for k2 in w["order"]:
			if str(k2) != str(k):
				enemies.append_array(w["sides"][k2]["members"])
		for pid in allies:
			var p = server.player(int(pid))
			if p == null or p.has_meta("gw_bot"):
				continue
			server.emit_ev({"t": "gw", "state": "update", "side": str(k), "sides": _side_view(w, str(k)), "shrines": shr, "relic": rel,
				"allies": allies.duplicate(), "enemies": enemies, "start_in": maxf(0.0, float(w["start"]) - now()), "end_in": maxf(0.0, float(w["end"]) - now()),
				"cap": int(CFG["point_cap"]), "my_pts": int(w["pts"].get(int(pid), 0.0)), "dead_in": maxf(0.0, float(CFG["respawn_s"]) - (now() - float(w["dead"].get(int(pid), now())))) if p.dead else 0.0,
				"_to": int(pid)})


static func _over(server, w: Dictionary, winner: String) -> void:
	w["over"] = true
	w["over_at"] = now()
	w["winner"] = winner
	_push(server, w)
	for k in w["order"]:
		var won := str(k) == winner
		for pid in w["sides"][k]["members"]:
			var p = server.player(int(pid))
			if p == null or p.has_meta("gw_bot"):
				continue
			var mine := int(w["pts"].get(int(pid), 0.0))
			var gold := int(CFG["gold_all"]) + (int(CFG["gold_win"]) if won else 0) + mine * int(CFG["gold_per_pt"])
			p.ch.zeny += gold
			p.ch.changed.emit()
			var res := "win" if won else ("draw" if winner == "" else "loss")
			server.emit_ev({"t": "gw", "state": "end", "result": res, "winner": str(w["sides"][winner]["name"]) if winner != "" else "", "gold": gold,
				"my_pts": mine, "kills": int(w["kills"].get(int(pid), 0)), "sides": _side_view(w, str(k)), "_to": int(pid)})
			p.msg("Guild war: %s! +%d gold (your points %d)." % [{"win": "Victory", "loss": "Defeat"}.get(res, "Draw"), gold, mine], Color(1, 0.85, 0.35))
			# guild activities: contribution + guild EXP (server/guild_act.gd)
			load("res://scripts/server/guild_act.gd").add_contrib(server, p, 30 + mine / 10 + (50 if won else 0), "war")


static func _send_back(server, w: Dictionary, p) -> void:
	if p == null:
		return
	if p.has_meta("gw_down"):
		p.remove_meta("gw_down")
	var was_dead: bool = p.dead
	p.dead = false
	p.ch.hp = p.ch.max_hp()
	p.ch.changed.emit()
	p._clear_goals()
	if was_dead:
		p.ev({"t": "respawned", "id": p.id})
	var back: Array = w["back"].get(p.id, [str(p.ch.save_map), p.ch.save_pos])
	server.warp(p, str(back[0]), back[1])


static func _finish(server, w: Dictionary) -> void:
	for k in w["order"]:
		for pid in (w["sides"][k]["members"] as Array).duplicate():
			var p = server.player(int(pid))
			if p == null:
				continue
			if p.has_meta("gw_bot"):
				continue
			_send_back(server, w, p)
			server.emit_ev({"t": "gw", "state": "left", "_to": int(pid)})
	for bid in w["bots"]:
		var b = server.player(int(bid))
		if b != null:
			server.remove_player(b)
	_st(server)["war"] = null
