M2.6: server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat, admin commands, server console
This commit is contained in:
@@ -113,7 +113,7 @@ What each rule means:
|
|||||||
| `raiding` | false / true | Whether players can damage or dismantle **other players'** pieces. |
|
| `raiding` | false / true | Whether players can damage or dismantle **other players'** pieces. |
|
||||||
| `locks` | `off` / `owner` / `owner_and_friends` | Who may open or take from a chest, use a door or bed, or dismantle a piece. Pieces remember who placed them. |
|
| `locks` | `off` / `owner` / `owner_and_friends` | Who may open or take from a chest, use a door or bed, or dismantle a piece. Pieces remember who placed them. |
|
||||||
| `death_penalty` | `grave` / `keep` / `drop_all` | Today's grave bag; keep everything; or drop it all on the ground (hardcore-ish). |
|
| `death_penalty` | `grave` / `keep` / `drop_all` | Today's grave bag; keep everything; or drop it all on the ground (hardcore-ish). |
|
||||||
| `sleep` | `all_online` / `half` / `off` | When a night can be slept through. |
|
| `sleep` | `all_online` / `half` / `anyone` / `off` | When a night can be slept through (how many of those online must be in bed). |
|
||||||
| `pause_when_empty` | true / false | Freeze the world when nobody's on (fires don't burn out, no time passes). |
|
| `pause_when_empty` | true / false | Freeze the world when nobody's on (fires don't burn out, no time passes). |
|
||||||
| `wolf_grace_days` | 0-10 | Days before wolves arrive; hunts start the night after. |
|
| `wolf_grace_days` | 0-10 | Days before wolves arrive; hunts start the night after. |
|
||||||
| `difficulty` | `easy` / `normal` / `hard` | Multipliers on hunger and diet drain, animal damage and pack size. |
|
| `difficulty` | `easy` / `normal` / `hard` | Multipliers on hunger and diet drain, animal damage and pack size. |
|
||||||
@@ -265,17 +265,46 @@ entity to everyone at first. Distance-based interest is a later
|
|||||||
optimisation.
|
optimisation.
|
||||||
|
|
||||||
## 6. Admin and console
|
## 6. Admin and console
|
||||||
In-game chat starts with `/`; the server console takes the same commands
|
*(Built in M2.6: `chat.gd`, autoload Chat; `rules.gd`.)* In-game chat
|
||||||
without it.
|
starts with `/`; the server console takes the same commands without it
|
||||||
- `/players`, `/kick <name>`, `/ban <name>`, `/unban`, `/admin <name>`
|
(the server reads its terminal when started with `--console`, which
|
||||||
- `/save`, `/backup`, `/time 08:00`, `/weather clear|rain|storm|snow`
|
`server/run_server.sh` does).
|
||||||
- `/rules` (show) and `/set pvp on` (change at runtime, saved to
|
- Anyone: `/help`, `/players`, `/rules`, `/friend <name>`,
|
||||||
`server.json`)
|
`/unfriend <name>`, `/friends`.
|
||||||
- `/rename <old> <new>`, `/release-name <name>`
|
- Admins: `/kick <name>`, `/ban <name>`, `/unban <name>`, `/admin <name>`,
|
||||||
- `/say <message>` (a server message)
|
`/save`, `/time 08:00`, `/weather clear|cloudy|rain|storm|snow|blizzard|auto`,
|
||||||
|
`/set <rule> <value>` (changes it for everyone at once and saves it to
|
||||||
|
`server.json`: pvp, raiding, locks, death_penalty, sleep,
|
||||||
|
pause_when_empty, difficulty, wolf_grace_days, motd, server_name; bad
|
||||||
|
values are refused with what's allowed), `/say <message>`,
|
||||||
|
`/release-name <name>`.
|
||||||
|
- Not built: `/backup` (comes with backups in M2.7) and `/rename` (the name
|
||||||
|
is the player's own, set in their profile; `/release-name` frees one).
|
||||||
|
|
||||||
Chat: `T` to talk. It's global, so everyone on the server sees it (no
|
Chat: `T` to talk, Enter to send, Esc to cancel. It's global, so everyone
|
||||||
proximity chat in v1).
|
on the server sees it (no proximity chat in v1). Lines fade after 14 s and
|
||||||
|
all come back while you type. Plain chat is limited to a line every 0.4 s
|
||||||
|
per player (commands aren't).
|
||||||
|
|
||||||
|
**How the rules work** *(M2.6)*:
|
||||||
|
- Pieces remember who placed them (and graves who died). Everyone's game
|
||||||
|
knows, so the prompt says "Bob's · Chest". Pieces from before M2.6 are
|
||||||
|
anyone's.
|
||||||
|
- `locks`: who may open or take from chests and graves, open doors and use
|
||||||
|
beds. `owner_and_friends` (the default) lets the people you've
|
||||||
|
`/friend`ed in; `owner` keeps everyone else out; `off` opens everything.
|
||||||
|
- `raiding`: taking down a piece you aren't allowed to use. Off by default.
|
||||||
|
- `pvp`: with it off, weapons and arrows go straight through other players.
|
||||||
|
On, a swing at someone's avatar is checked on the server (reach,
|
||||||
|
weapon, stamina, cooldown) and arrows and thrown spears hit people.
|
||||||
|
Deaths say who did it ("Killed by Bob").
|
||||||
|
- `death_penalty`: `grave` (default), `keep` (wake with everything) or
|
||||||
|
`drop_all` (it's all scattered where you fell, for anyone).
|
||||||
|
- `difficulty`: easy / normal / hard scales hunger and diet drain (x0.75 /
|
||||||
|
1 / 1.3), animal and wolf damage (x0.7 / 1 / 1.4) and the pack (-1 / 0 /
|
||||||
|
+1 wolf).
|
||||||
|
- `wolf_grace_days`: the day wolves move in; they hunt from the next night.
|
||||||
|
- `sleep`: `all_online` (default), `half`, `anyone` or `off`.
|
||||||
|
|
||||||
## 7. Saves on the server
|
## 7. Saves on the server
|
||||||
```
|
```
|
||||||
@@ -333,7 +362,7 @@ player" or "this player":
|
|||||||
| **M2.3** *(done)* | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it |
|
| **M2.3** *(done)* | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it |
|
||||||
| **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
|
| **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
|
||||||
| **M2.5** *(done)* | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens |
|
| **M2.5** *(done)* | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens |
|
||||||
| **M2.6** | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 |
|
| **M2.6** *(done)* | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 |
|
||||||
| **M2.7** | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour |
|
| **M2.7** | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour |
|
||||||
|
|
||||||
## 10. Testing
|
## 10. Testing
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ phases M2.0-M2.7).
|
|||||||
- [x] Persistence: world save/load on the server *(M2.3)*
|
- [x] Persistence: world save/load on the server *(M2.3)*
|
||||||
- [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)*
|
- [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)*
|
||||||
- [x] Animals, wolves, hunting, fishing and sleeping on the server *(M2.5)*
|
- [x] Animals, wolves, hunting, fishing and sleeping on the server *(M2.5)*
|
||||||
|
- [x] Server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat and admin commands, server console *(M2.6)*
|
||||||
- **Done when:** a friend joins your self-hosted server and you build together.
|
- **Done when:** a friend joins your self-hosted server and you build together.
|
||||||
|
|
||||||
## M3 — Addon SDK + sandbox · ~2–3 months
|
## M3 — Addon SDK + sandbox · ~2–3 months
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Net="*res://scripts/net/net.gd"
|
|||||||
WorldNet="*res://scripts/net/world_net.gd"
|
WorldNet="*res://scripts/net/world_net.gd"
|
||||||
PlayerNet="*res://scripts/net/player_net.gd"
|
PlayerNet="*res://scripts/net/player_net.gd"
|
||||||
CreatureNet="*res://scripts/net/creature_net.gd"
|
CreatureNet="*res://scripts/net/creature_net.gd"
|
||||||
|
Chat="*res://scripts/net/chat.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,38 @@ func melee(player: Node3D, target: Node3D) -> bool:
|
|||||||
notify.emit("Killed a %s" % target.display_name().to_lower())
|
notify.emit("Killed a %s" % target.display_name().to_lower())
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
## A melee blow at another player (online, with PvP on). Online only.
|
||||||
|
func hit_player(player: Node3D, peer: int) -> bool:
|
||||||
|
if _online():
|
||||||
|
if not melee_stats(player).armed:
|
||||||
|
notify.emit(NEED_WEAPON)
|
||||||
|
return false
|
||||||
|
return _ask("hit_player", [peer])
|
||||||
|
if not Net.is_server() or not PlayerNet.proxies.has(peer) or player.get("peer") == peer:
|
||||||
|
return false
|
||||||
|
var target: Node3D = PlayerNet.proxies[peer]
|
||||||
|
if not preload("res://scripts/net/rules.gd").pvp():
|
||||||
|
notify.emit("PvP is off on this server")
|
||||||
|
return false
|
||||||
|
if target.survival.dead:
|
||||||
|
return false
|
||||||
|
var m := melee_stats(player)
|
||||||
|
if not m.armed:
|
||||||
|
notify.emit(NEED_WEAPON)
|
||||||
|
return false
|
||||||
|
# (Positions are where the server last heard you both were: a little slack.)
|
||||||
|
if player.global_position.distance_to(target.global_position) > m.reach + 2.5:
|
||||||
|
return false
|
||||||
|
if Time.get_ticks_msec() < int(player.melee_ready_at):
|
||||||
|
return false
|
||||||
|
if not player.survival.use_stamina(m.stamina):
|
||||||
|
notify.emit("Too tired")
|
||||||
|
return false
|
||||||
|
player.melee_ready_at = Time.get_ticks_msec() + int(m.cooldown * 1000.0 * 0.8)
|
||||||
|
target.survival.damage(m.damage, player.global_position, String(player.display))
|
||||||
|
notify.emit("Hit %s" % String(target.display))
|
||||||
|
return true
|
||||||
|
|
||||||
## An arrow or thrown spear reaching an animal (projectile.gd).
|
## An arrow or thrown spear reaching an animal (projectile.gd).
|
||||||
func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, embed_id: String) -> void:
|
func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, embed_id: String) -> void:
|
||||||
if not is_instance_valid(target) or not target.is_in_group("creature"):
|
if not is_instance_valid(target) or not target.is_in_group("creature"):
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ func _think(dt: float) -> void:
|
|||||||
_state_t = 4.0
|
_state_t = 4.0
|
||||||
elif d < def.attack_range and _attack_cd <= 0.0:
|
elif d < def.attack_range and _attack_cd <= 0.0:
|
||||||
_attack_cd = def.attack_cd
|
_attack_cd = def.attack_cd
|
||||||
player.survival.damage(def.damage, global_position, "a " + String(def.name).to_lower())
|
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a " + String(def.name).to_lower())
|
||||||
|
|
||||||
func _start_flee(from: Vector3) -> void:
|
func _start_flee(from: Vector3) -> void:
|
||||||
state = "flee"
|
state = "flee"
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ func step(dt: float) -> void:
|
|||||||
var ex: Array[RID] = []
|
var ex: Array[RID] = []
|
||||||
if shooter != null and is_instance_valid(shooter) and shooter.has_method("get_rid"):
|
if shooter != null and is_instance_valid(shooter) and shooter.has_method("get_rid"):
|
||||||
ex.append(shooter.get_rid())
|
ex.append(shooter.get_rid())
|
||||||
var q := PhysicsRayQueryParameters3D.create(from, to, 1 | 8, ex)
|
var mask := 1 | 8
|
||||||
|
if world.server_mode and preload("res://scripts/net/rules.gd").pvp():
|
||||||
|
mask |= 16 # other players (server_player.gd); with PvP off arrows pass through them
|
||||||
|
var q := PhysicsRayQueryParameters3D.create(from, to, mask, ex)
|
||||||
var hit := get_world_3d().direct_space_state.intersect_ray(q)
|
var hit := get_world_3d().direct_space_state.intersect_ray(q)
|
||||||
if not hit.is_empty():
|
if not hit.is_empty():
|
||||||
_on_hit(hit)
|
_on_hit(hit)
|
||||||
@@ -69,6 +72,15 @@ func _on_hit(hit: Dictionary) -> void:
|
|||||||
if visual:
|
if visual:
|
||||||
queue_free()
|
queue_free()
|
||||||
return
|
return
|
||||||
|
if c != null and c.get("player_id") != null and c.get("survival") != null:
|
||||||
|
# Another player, on a server with PvP on.
|
||||||
|
var k := clampf(velocity.length() / _start_speed, 0.4, 1.0)
|
||||||
|
var by: String = String(shooter.display) if shooter != null and is_instance_valid(shooter) and shooter.get("display") != null else "an arrow"
|
||||||
|
(c as Node).survival.damage(damage * k, global_position - dir, by)
|
||||||
|
Actions.tell(shooter, "Hit %s" % String(c.display))
|
||||||
|
var at: Vector3 = (c as Node3D).global_position + Vector3(0, 0.05, 0)
|
||||||
|
_drop(Transform3D(Basis(Vector3.UP, randf() * TAU) * Basis(Vector3.FORWARD, PI * 0.5), at))
|
||||||
|
return
|
||||||
if c != null and (c as Node).is_in_group("creature"):
|
if c != null and (c as Node).is_in_group("creature"):
|
||||||
var k := clampf(velocity.length() / _start_speed, 0.4, 1.0)
|
var k := clampf(velocity.length() / _start_speed, 0.4, 1.0)
|
||||||
Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if item_id == "arrow" else "")
|
Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if item_id == "arrow" else "")
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func _attack(pp: Vector3, d: float, as_state: String) -> void:
|
|||||||
steer(atan2(-to.x, -to.z))
|
steer(atan2(-to.x, -to.z))
|
||||||
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player():
|
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player():
|
||||||
_attack_cd = def.attack_cd
|
_attack_cd = def.attack_cd
|
||||||
player.survival.damage(def.damage, global_position, "a " + String(def.name).to_lower())
|
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a " + String(def.name).to_lower())
|
||||||
if as_state == "lunge":
|
if as_state == "lunge":
|
||||||
state = "retreat"
|
state = "retreat"
|
||||||
_state_t = 1.6
|
_state_t = 1.6
|
||||||
|
|||||||
@@ -67,14 +67,22 @@ func is_night() -> bool:
|
|||||||
var h: float = world.day_night.hours()
|
var h: float = world.day_night.hours()
|
||||||
return h >= 19.5 or h < 5.5
|
return h >= 19.5 or h < 5.5
|
||||||
|
|
||||||
static func pack_size(day: int) -> int:
|
## The pack grows 3 and 6 days after it arrives (2, 3, then 4 wolves), plus
|
||||||
if day < DEN_DAY:
|
## or minus one for a server's difficulty.
|
||||||
|
func pack_size(day: int) -> int:
|
||||||
|
if day < den_day:
|
||||||
return 0
|
return 0
|
||||||
if day < 6:
|
var n := 4
|
||||||
return 2
|
if day < den_day + 3:
|
||||||
if day < 9:
|
n = 2
|
||||||
return 3
|
elif day < den_day + 6:
|
||||||
return 4
|
n = 3
|
||||||
|
return clampi(n + pack_bonus, 1, 5)
|
||||||
|
|
||||||
|
## The schedule (a server sets it from its rules; single-player keeps 3/4).
|
||||||
|
var den_day := DEN_DAY
|
||||||
|
var hunt_day := HUNT_DAY
|
||||||
|
var pack_bonus := 0
|
||||||
|
|
||||||
func alive() -> Array:
|
func alive() -> Array:
|
||||||
var out := []
|
var out := []
|
||||||
@@ -95,8 +103,8 @@ func step(dt: float) -> void:
|
|||||||
var day := effective_day()
|
var day := effective_day()
|
||||||
var night := is_night()
|
var night := is_night()
|
||||||
var want := "none"
|
var want := "none"
|
||||||
if day >= DEN_DAY:
|
if day >= den_day:
|
||||||
want = "hunt" if night and day >= HUNT_DAY else "den"
|
want = "hunt" if night and day >= hunt_day else "den"
|
||||||
if want != mode:
|
if want != mode:
|
||||||
_enter(want, day)
|
_enter(want, day)
|
||||||
if mode == "none":
|
if mode == "none":
|
||||||
@@ -123,7 +131,7 @@ func step(dt: float) -> void:
|
|||||||
m.global_position = den.slot_pos(m.slot)
|
m.global_position = den.slot_pos(m.slot)
|
||||||
m.wounded = false
|
m.wounded = false
|
||||||
m.state = "rest"
|
m.state = "rest"
|
||||||
if night and day >= DEN_DAY:
|
if night and day >= den_day:
|
||||||
_howl_left -= dt
|
_howl_left -= dt
|
||||||
if _howl_left <= 0.0:
|
if _howl_left <= 0.0:
|
||||||
_howl_left = _rng.randf_range(50.0, 110.0)
|
_howl_left = _rng.randf_range(50.0, 110.0)
|
||||||
@@ -148,9 +156,9 @@ func _warn(day: int) -> void:
|
|||||||
if _warned.has(day):
|
if _warned.has(day):
|
||||||
return
|
return
|
||||||
_warned[day] = true
|
_warned[day] = true
|
||||||
if day == DEN_DAY:
|
if day == den_day:
|
||||||
Actions.tell_all("Wolves are howling up in the hills. They'll come hunting soon.")
|
Actions.tell_all("Wolves are howling up in the hills. They'll come hunting soon.")
|
||||||
elif day == HUNT_DAY:
|
elif day == hunt_day:
|
||||||
Actions.tell_all("The wolves are hunting tonight. Stay near a fire or behind closed walls.")
|
Actions.tell_all("The wolves are hunting tonight. Stay near a fire or behind closed walls.")
|
||||||
_howl_left = _rng.randf_range(2.0, 6.0)
|
_howl_left = _rng.randf_range(2.0, 6.0)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var info: Dictionary = {}
|
|||||||
var anim := Anim.IDLE
|
var anim := Anim.IDLE
|
||||||
var held := ""
|
var held := ""
|
||||||
var tag: Label3D
|
var tag: Label3D
|
||||||
|
var hitbox: AnimatableBody3D
|
||||||
|
|
||||||
var _buf: Array = [] ## [t, pos, yaw, pitch, anim, held], oldest first
|
var _buf: Array = [] ## [t, pos, yaw, pitch, anim, held], oldest first
|
||||||
var _body: Node3D
|
var _body: Node3D
|
||||||
@@ -57,6 +58,22 @@ func setup(p: int, d: Dictionary) -> void:
|
|||||||
_held_mi.rotation = Vector3(-PI * 0.5, 0, 0) # item handles run along +Y; point them forward
|
_held_mi.rotation = Vector3(-PI * 0.5, 0, 0) # item handles run along +Y; point them forward
|
||||||
_held_mi.scale = Vector3.ONE * 0.85
|
_held_mi.scale = Vector3.ONE * 0.85
|
||||||
_arms[1].add_child(_held_mi)
|
_arms[1].add_child(_held_mi)
|
||||||
|
# A hitbox for swinging at them when the server has PvP on (hands.gd
|
||||||
|
# looks for it; the server decides what the hit does). Layer 5 (value
|
||||||
|
# 16): nothing walks into it.
|
||||||
|
hitbox = AnimatableBody3D.new()
|
||||||
|
hitbox.sync_to_physics = false
|
||||||
|
hitbox.collision_layer = 16
|
||||||
|
hitbox.collision_mask = 0
|
||||||
|
hitbox.set_meta("avatar_peer", p)
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var cap := CapsuleShape3D.new()
|
||||||
|
cap.radius = 0.4
|
||||||
|
cap.height = 1.8
|
||||||
|
cs.shape = cap
|
||||||
|
cs.position = Vector3(0, 0.9, 0)
|
||||||
|
hitbox.add_child(cs)
|
||||||
|
add_child(hitbox)
|
||||||
tag = Label3D.new()
|
tag = Label3D.new()
|
||||||
tag.text = String(d.get("name", "?"))
|
tag.text = String(d.get("name", "?"))
|
||||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
|||||||
228
scripts/net/chat.gd
Normal file
228
scripts/net/chat.gd
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
extends Node
|
||||||
|
## Autoload "Chat": talking on a server, and its commands
|
||||||
|
## (docs/MULTIPLAYER_PLAN.md §6).
|
||||||
|
##
|
||||||
|
## T opens the chat line. Plain text goes to everyone (global chat). A line
|
||||||
|
## starting with / is a command; the server console takes the same commands
|
||||||
|
## without the /, when started with --console (server/run_server.sh does).
|
||||||
|
##
|
||||||
|
## Anyone: /help /players /rules /friend <name> /unfriend <name> /friends
|
||||||
|
## Admins: /kick <name> /ban <name> /unban <name> /admin <name> /save
|
||||||
|
## /time 08:00 /weather clear|cloudy|rain|storm|snow|blizzard|auto
|
||||||
|
## /set <rule> <value> /say <message> /release-name <name>
|
||||||
|
|
||||||
|
signal message(text: String, kind: String) ## client: show this (kind: chat | server | reply)
|
||||||
|
|
||||||
|
const MAX_LEN := 200
|
||||||
|
const GAP := 0.4 ## seconds between one player's lines
|
||||||
|
const ADMIN_ONLY := ["kick", "ban", "unban", "admin", "save", "time", "weather", "set", "say", "release-name"]
|
||||||
|
|
||||||
|
var _last: Dictionary = {} ## peer -> ticks of their last line
|
||||||
|
var _console: Thread
|
||||||
|
var heard: Array = [] ## client: everything shown (tests read it)
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if Net.parse_args(OS.get_cmdline_user_args()).has("console"):
|
||||||
|
Net.state_changed.connect(func(s):
|
||||||
|
if s == "server" and _console == null:
|
||||||
|
_console = Thread.new()
|
||||||
|
_console.start(_read_console))
|
||||||
|
|
||||||
|
# --- client ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func say(text: String) -> void:
|
||||||
|
text = text.strip_edges()
|
||||||
|
if text.is_empty() or Net.state != "connected":
|
||||||
|
return
|
||||||
|
_c2s_say.rpc_id(1, text.substr(0, MAX_LEN))
|
||||||
|
|
||||||
|
func _show(text: String, kind: String) -> void:
|
||||||
|
heard.append(text)
|
||||||
|
message.emit(text, kind)
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_line(text: String, kind: String) -> void:
|
||||||
|
_show(text, kind)
|
||||||
|
|
||||||
|
# --- server ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_say(text: String) -> void:
|
||||||
|
var peer := multiplayer.get_remote_sender_id()
|
||||||
|
if not Net.is_server() or not Net.players.has(peer):
|
||||||
|
return
|
||||||
|
text = text.strip_edges().substr(0, MAX_LEN).replace("\n", " ")
|
||||||
|
if text.is_empty():
|
||||||
|
return
|
||||||
|
var who: String = Net.players[peer].name
|
||||||
|
if text.begins_with("/"):
|
||||||
|
print("[server] %s: %s" % [who, text])
|
||||||
|
run(text.substr(1), peer)
|
||||||
|
return
|
||||||
|
# Plain chat only: a line every GAP seconds, so nobody can flood it.
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
if now - int(_last.get(peer, -100000)) < GAP * 1000.0:
|
||||||
|
_reply(peer, "(Too fast: that line wasn't sent)")
|
||||||
|
return
|
||||||
|
_last[peer] = now
|
||||||
|
print("[chat] %s: %s" % [who, text])
|
||||||
|
_all("%s: %s" % [who, text], "chat")
|
||||||
|
|
||||||
|
func _all(text: String, kind: String) -> void:
|
||||||
|
if kind == "server":
|
||||||
|
print("[server] ", text)
|
||||||
|
for peer in Net.players:
|
||||||
|
_s2c_line.rpc_id(peer, text, kind)
|
||||||
|
|
||||||
|
## An answer to whoever gave the command (peer 0 = the console).
|
||||||
|
func _reply(peer: int, text: String) -> void:
|
||||||
|
if peer == 0:
|
||||||
|
print(text)
|
||||||
|
else:
|
||||||
|
_s2c_line.rpc_id(peer, text, "reply")
|
||||||
|
|
||||||
|
func _is_admin(peer: int) -> bool:
|
||||||
|
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
|
||||||
|
|
||||||
|
## The player id a name belongs to (online or reserved), or "".
|
||||||
|
func _id_of(n: String) -> String:
|
||||||
|
var peer := Net.peer_by_name(n)
|
||||||
|
if peer != 0:
|
||||||
|
return String(Net.players[peer].id)
|
||||||
|
return Net.config.name_owner(n)
|
||||||
|
|
||||||
|
## Runs a command line (without the /) from player `peer`, or the console (0).
|
||||||
|
func run(line: String, peer: int) -> void:
|
||||||
|
var parts := line.strip_edges().split(" ", false)
|
||||||
|
if parts.is_empty():
|
||||||
|
return
|
||||||
|
var cmd := parts[0].to_lower()
|
||||||
|
var rest := " ".join(parts.slice(1))
|
||||||
|
var arg: String = parts[1] if parts.size() > 1 else ""
|
||||||
|
if cmd in ADMIN_ONLY and not _is_admin(peer):
|
||||||
|
_reply(peer, "Only admins can use /%s" % cmd)
|
||||||
|
return
|
||||||
|
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else ""
|
||||||
|
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else "The server"
|
||||||
|
match cmd:
|
||||||
|
"help":
|
||||||
|
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
|
||||||
|
(" · admin: /kick /ban /unban /admin /save /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
|
||||||
|
"players":
|
||||||
|
var names: Array[String] = []
|
||||||
|
for p in Net.players.values():
|
||||||
|
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else ""))
|
||||||
|
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
|
||||||
|
"rules":
|
||||||
|
var c: RefCounted = Net.config
|
||||||
|
_reply(peer, "PvP %s · raiding %s · locks %s · death: %s · sleep: %s · difficulty %s · wolves after day %d" % [
|
||||||
|
"on" if bool(c.v("pvp")) else "off", "on" if bool(c.v("raiding")) else "off", c.v("locks"),
|
||||||
|
c.v("death_penalty"), c.v("sleep"), c.v("difficulty"), int(c.v("wolf_grace_days"))])
|
||||||
|
"friend", "unfriend":
|
||||||
|
if me.is_empty():
|
||||||
|
_reply(peer, "Only players have friends")
|
||||||
|
return
|
||||||
|
var fid := _id_of(arg)
|
||||||
|
if fid.is_empty() or fid == me:
|
||||||
|
_reply(peer, "No player called '%s' here" % arg)
|
||||||
|
elif cmd == "friend":
|
||||||
|
Net.config.add_friend(me, fid)
|
||||||
|
_reply(peer, "%s can now use your doors, chests and beds" % arg)
|
||||||
|
var fp := Net.peer_by_name(arg)
|
||||||
|
if fp != 0:
|
||||||
|
_reply(fp, "%s added you as a friend: you can use their doors, chests and beds" % by)
|
||||||
|
else:
|
||||||
|
_reply(peer, "%s is no longer a friend" % arg if Net.config.remove_friend(me, fid) else "%s wasn't a friend" % arg)
|
||||||
|
"friends":
|
||||||
|
if me.is_empty():
|
||||||
|
return
|
||||||
|
var names: Array[String] = []
|
||||||
|
for fid in Net.config.friends.get(me, []):
|
||||||
|
names.append(Net.config.name_of(String(fid)))
|
||||||
|
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
|
||||||
|
"kick":
|
||||||
|
var kp := Net.peer_by_name(arg)
|
||||||
|
if kp == 0:
|
||||||
|
_reply(peer, "Nobody called '%s' is online" % arg)
|
||||||
|
return
|
||||||
|
Net.kick(kp, "You were kicked by %s" % by)
|
||||||
|
_all("%s was kicked" % arg, "server")
|
||||||
|
"ban":
|
||||||
|
var bid := _id_of(arg)
|
||||||
|
if bid.is_empty():
|
||||||
|
_reply(peer, "No player called '%s'" % arg)
|
||||||
|
return
|
||||||
|
Net.config.ban(bid)
|
||||||
|
var bp := Net.peer_by_name(arg)
|
||||||
|
if bp != 0:
|
||||||
|
Net.kick(bp, "You were banned by %s" % by)
|
||||||
|
_all("%s was banned" % arg, "server")
|
||||||
|
"unban":
|
||||||
|
var uid: String = Net.config.name_owner(arg)
|
||||||
|
_reply(peer, "%s can join again" % arg if not uid.is_empty() and Net.config.unban(uid) else "'%s' isn't banned" % arg)
|
||||||
|
"admin":
|
||||||
|
var aid := _id_of(arg)
|
||||||
|
if aid.is_empty():
|
||||||
|
_reply(peer, "No player called '%s'" % arg)
|
||||||
|
return
|
||||||
|
Net.config.add_admin(aid)
|
||||||
|
_reply(peer, "%s is now an admin" % arg)
|
||||||
|
var ap := Net.peer_by_name(arg)
|
||||||
|
if ap != 0:
|
||||||
|
Net.players[ap].admin = true
|
||||||
|
_reply(ap, "%s made you an admin (/help for the commands)" % by)
|
||||||
|
"save":
|
||||||
|
WorldNet.save()
|
||||||
|
_reply(peer, "Saved")
|
||||||
|
"time":
|
||||||
|
var hm := arg.split(":")
|
||||||
|
if hm.size() < 1 or not hm[0].is_valid_int() or int(hm[0]) > 23 or (hm.size() > 1 and not hm[1].is_valid_int()):
|
||||||
|
_reply(peer, "Use /time HH:MM, e.g. /time 08:00")
|
||||||
|
return
|
||||||
|
var w: Node3D = WorldNet.world
|
||||||
|
w.day_night.time = (int(hm[0]) + (int(hm[1]) if hm.size() > 1 else 0) / 60.0) / 24.0
|
||||||
|
w.day_night.apply(true)
|
||||||
|
w.weather.snap()
|
||||||
|
Net.push_clock()
|
||||||
|
_all("%s set the time to %02d:%02d" % [by, int(hm[0]), int(hm[1]) if hm.size() > 1 else 0], "server")
|
||||||
|
"weather":
|
||||||
|
var states: Array = preload("res://scripts/world/weather.gd").STATES
|
||||||
|
if arg != "auto" and not arg in states:
|
||||||
|
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
|
||||||
|
return
|
||||||
|
Net.set_weather("" if arg == "auto" else arg)
|
||||||
|
_all("%s set the weather to %s" % [by, arg], "server")
|
||||||
|
"set":
|
||||||
|
if parts.size() < 3:
|
||||||
|
_reply(peer, "Use /set <rule> <value>, e.g. /set pvp true (/rules shows them)")
|
||||||
|
return
|
||||||
|
var why: String = Net.config.set_rule(parts[1], " ".join(parts.slice(2)))
|
||||||
|
if not why.is_empty():
|
||||||
|
_reply(peer, why)
|
||||||
|
return
|
||||||
|
Net.broadcast_rules()
|
||||||
|
_all("%s set %s to %s" % [by, parts[1], " ".join(parts.slice(2))], "server")
|
||||||
|
"say":
|
||||||
|
_all("[Server] %s" % rest, "server")
|
||||||
|
"release-name":
|
||||||
|
_reply(peer, "'%s' is free for anyone now" % arg if Net.config.release_name(arg) else "Nobody has the name '%s'" % arg)
|
||||||
|
_:
|
||||||
|
_reply(peer, "Unknown command /%s (/help lists them)" % cmd)
|
||||||
|
|
||||||
|
# --- server console ------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Reads commands typed into the server's terminal (a thread: reading blocks).
|
||||||
|
func _read_console() -> void:
|
||||||
|
var empty := 0
|
||||||
|
while empty < 20:
|
||||||
|
var line := OS.read_string_from_stdin(1024).strip_edges()
|
||||||
|
if line.is_empty():
|
||||||
|
empty += 1
|
||||||
|
OS.delay_msec(250)
|
||||||
|
continue
|
||||||
|
empty = 0
|
||||||
|
_console_line.call_deferred(line.trim_prefix("/"))
|
||||||
|
|
||||||
|
func _console_line(line: String) -> void:
|
||||||
|
if Net.is_server():
|
||||||
|
run(line, 0)
|
||||||
@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
|
|||||||
const Identity = preload("res://scripts/net/identity.gd")
|
const Identity = preload("res://scripts/net/identity.gd")
|
||||||
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
||||||
|
|
||||||
const PROTOCOL := 5 ## 3: shared world (M2.3); 4: the server decides (M2.4); 5: wildlife (M2.5)
|
const PROTOCOL := 6 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6)
|
||||||
const DEFAULT_PORT := 24242
|
const DEFAULT_PORT := 24242
|
||||||
const HANDSHAKE_TIMEOUT := 10.0
|
const HANDSHAKE_TIMEOUT := 10.0
|
||||||
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
||||||
@@ -272,6 +272,45 @@ func _tick_clock(delta: float) -> void:
|
|||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
||||||
|
|
||||||
|
# --- admin (chat.gd) ---------------------------------------------------------------
|
||||||
|
|
||||||
|
signal rules_changed ## clients: the server's rules changed (/set)
|
||||||
|
|
||||||
|
## The peer of the player online called `n` (any case), or 0.
|
||||||
|
func peer_by_name(n: String) -> int:
|
||||||
|
for peer in players:
|
||||||
|
if String(players[peer].name).to_lower() == n.strip_edges().to_lower():
|
||||||
|
return peer
|
||||||
|
return 0
|
||||||
|
|
||||||
|
## Disconnects a player with a reason (their title screen shows it).
|
||||||
|
func kick(peer: int, reason: String) -> void:
|
||||||
|
if players.has(peer):
|
||||||
|
_reject(peer, reason)
|
||||||
|
|
||||||
|
## Everyone gets the rules again (after /set) and the weather override.
|
||||||
|
func broadcast_rules() -> void:
|
||||||
|
_world.apply_rules()
|
||||||
|
for peer in players:
|
||||||
|
_s2c_rules.rpc_id(peer, config.public_info())
|
||||||
|
|
||||||
|
func set_weather(state: String) -> void:
|
||||||
|
_world.weather.force(state)
|
||||||
|
for peer in players:
|
||||||
|
_s2c_weather.rpc_id(peer, state)
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_rules(info: Dictionary) -> void:
|
||||||
|
server_info = info
|
||||||
|
rules_changed.emit()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_weather(state: String) -> void:
|
||||||
|
world_info.weather = state
|
||||||
|
weather_changed.emit(state)
|
||||||
|
|
||||||
|
signal weather_changed(state: String)
|
||||||
|
|
||||||
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
||||||
func push_clock() -> void:
|
func push_clock() -> void:
|
||||||
_clock_left = CLOCK_INTERVAL
|
_clock_left = CLOCK_INTERVAL
|
||||||
@@ -454,7 +493,8 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
|||||||
players.size(), int(config.v("max_players"))])
|
players.size(), int(config.v("max_players"))])
|
||||||
# (The world's own seed: an existing world keeps the seed it was made with.)
|
# (The world's own seed: an existing world keeps the seed it was made with.)
|
||||||
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw,
|
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw,
|
||||||
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time}
|
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
|
||||||
|
"weather": _world.weather.override}
|
||||||
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
||||||
for other in players:
|
for other in players:
|
||||||
if other != peer:
|
if other != peer:
|
||||||
|
|||||||
@@ -36,10 +36,17 @@ func setup(w: Node3D) -> void:
|
|||||||
Net.player_left.connect(func(n): Actions.notify.emit("%s left" % n))
|
Net.player_left.connect(func(n): Actions.notify.emit("%s left" % n))
|
||||||
if Net.world_info.has("day"):
|
if Net.world_info.has("day"):
|
||||||
_on_clock(int(Net.world_info.day), float(Net.world_info.time))
|
_on_clock(int(Net.world_info.day), float(Net.world_info.time))
|
||||||
|
# An admin's /weather (otherwise the weather follows from seed and clock).
|
||||||
|
Net.weather_changed.connect(_on_weather)
|
||||||
|
if not String(Net.world_info.get("weather", "")).is_empty():
|
||||||
|
_on_weather(String(Net.world_info.weather))
|
||||||
|
|
||||||
|
func _on_weather(state: String) -> void:
|
||||||
|
world.weather.force(state)
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
for sig in [[Net.snapshot, _on_snapshot], [Net.corrected, _on_corrected], [Net.clock, _on_clock],
|
for sig in [[Net.snapshot, _on_snapshot], [Net.corrected, _on_corrected], [Net.clock, _on_clock],
|
||||||
[Net.players_changed, _sync_avatars]]:
|
[Net.players_changed, _sync_avatars], [Net.weather_changed, _on_weather]]:
|
||||||
if (sig[0] as Signal).is_connected(sig[1]):
|
if (sig[0] as Signal).is_connected(sig[1]):
|
||||||
(sig[0] as Signal).disconnect(sig[1])
|
(sig[0] as Signal).disconnect(sig[1])
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
|
|||||||
p.inventory.changed.connect(func(): _dirty[peer] = true; WorldNet.mark_changed())
|
p.inventory.changed.connect(func(): _dirty[peer] = true; WorldNet.mark_changed())
|
||||||
p.worn_changed.connect(func(): _dirty[peer] = true)
|
p.worn_changed.connect(func(): _dirty[peer] = true)
|
||||||
p.survival.died.connect(_on_died.bind(peer))
|
p.survival.died.connect(_on_died.bind(peer))
|
||||||
|
p.survival.drain_mult = Rules.difficulty("drain")
|
||||||
# A wolf bite or a boar: their screen flashes red, from the right side.
|
# A wolf bite or a boar: their screen flashes red, from the right side.
|
||||||
p.survival.hurt.connect(func(amount, from):
|
p.survival.hurt.connect(func(amount, from):
|
||||||
if proxies.has(peer):
|
if proxies.has(peer):
|
||||||
@@ -210,14 +211,28 @@ func _on_died(peer: int) -> void:
|
|||||||
for st in p.inventory.slots:
|
for st in p.inventory.slots:
|
||||||
if st != null:
|
if st != null:
|
||||||
has_items = true
|
has_items = true
|
||||||
if has_items:
|
# What dying costs (the server's death_penalty rule).
|
||||||
|
var penalty := Rules.death_penalty()
|
||||||
|
if has_items and penalty == "grave":
|
||||||
var g: Node3D = w.spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, p.rotation.y), pos), {"owner_id": p.player_id, "owner_name": p.display})
|
var g: Node3D = w.spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, p.rotation.y), pos), {"owner_id": p.player_id, "owner_name": p.display})
|
||||||
g.inventory.load_data(p.inventory.to_data())
|
g.inventory.load_data(p.inventory.to_data())
|
||||||
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
|
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
|
||||||
|
elif has_items and penalty == "drop_all":
|
||||||
|
# Everything scattered on the ground where you fell, for anyone.
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
for st in p.inventory.slots:
|
||||||
|
if st != null:
|
||||||
|
var off := Vector3(rng.randf_range(-1.2, 1.2), 0.0, rng.randf_range(-1.2, 1.2))
|
||||||
|
var at: Vector3 = pos + off
|
||||||
|
at.y = w.terrain.sample_height(at.x, at.z) + 0.05
|
||||||
|
w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at))
|
||||||
|
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
|
||||||
|
# ("keep": you keep everything.)
|
||||||
# They stay down on the death screen for a few seconds, then ask to respawn.
|
# They stay down on the death screen for a few seconds, then ask to respawn.
|
||||||
p.died_at = Time.get_ticks_msec()
|
p.died_at = Time.get_ticks_msec()
|
||||||
_dirty[peer] = true
|
_dirty[peer] = true
|
||||||
_s2c_died.rpc_id(peer, p.survival.cause if not p.survival.cause.is_empty() else "You died", has_items, p.bed_piece(w) != null)
|
_s2c_died.rpc_id(peer, p.survival.cause if not p.survival.cause.is_empty() else "You died",
|
||||||
|
has_items and penalty != "keep", p.bed_piece(w) != null, penalty)
|
||||||
_acting = 0
|
_acting = 0
|
||||||
|
|
||||||
const RESPAWN_DELAY := 3.0
|
const RESPAWN_DELAY := 3.0
|
||||||
@@ -301,11 +316,12 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
Actions.place(p, _i(a, 0), xf.orthonormalized(), w)
|
Actions.place(p, _i(a, 0), xf.orthonormalized(), w)
|
||||||
"dismantle":
|
"dismantle":
|
||||||
var piece := WorldNet.piece(_i(a, 0))
|
var piece := WorldNet.piece(_i(a, 0))
|
||||||
if piece != null:
|
if piece != null and _allowed(Rules.dismantle_problem(p.player_id, piece)):
|
||||||
Actions.dismantle(p, piece)
|
Actions.dismantle(p, piece)
|
||||||
"door":
|
"door":
|
||||||
var d := WorldNet.piece(_i(a, 0))
|
var d := WorldNet.piece(_i(a, 0))
|
||||||
if d != null and d.get("open") != null and Actions.toggle_door(p, d):
|
if d != null and d.get("open") != null and _allowed(Rules.use_problem(p.player_id, d)) \
|
||||||
|
and Actions.toggle_door(p, d):
|
||||||
WorldNet.piece_state(d)
|
WorldNet.piece_state(d)
|
||||||
"fuel":
|
"fuel":
|
||||||
var f := WorldNet.piece(_i(a, 0))
|
var f := WorldNet.piece(_i(a, 0))
|
||||||
@@ -313,7 +329,7 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
WorldNet.piece_state(f)
|
WorldNet.piece_state(f)
|
||||||
"bed":
|
"bed":
|
||||||
var b := WorldNet.piece(_i(a, 0))
|
var b := WorldNet.piece(_i(a, 0))
|
||||||
if b != null and b.item_id == "bed":
|
if b != null and b.item_id == "bed" and _allowed(Rules.use_problem(p.player_id, b)):
|
||||||
Actions.use_bed(p, b)
|
Actions.use_bed(p, b)
|
||||||
"inv_click":
|
"inv_click":
|
||||||
# Slot `a[1]` of your own inventory (a[0] = 0) or a chest's.
|
# Slot `a[1]` of your own inventory (a[0] = 0) or a chest's.
|
||||||
@@ -373,6 +389,8 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
var c: Node3D = CreatureNet.creature(_i(a, 0))
|
var c: Node3D = CreatureNet.creature(_i(a, 0))
|
||||||
if c != null:
|
if c != null:
|
||||||
Actions.melee(p, c)
|
Actions.melee(p, c)
|
||||||
|
"hit_player":
|
||||||
|
Actions.hit_player(p, _i(a, 0))
|
||||||
"butcher":
|
"butcher":
|
||||||
var c: Node3D = CreatureNet.creature(_i(a, 0))
|
var c: Node3D = CreatureNet.creature(_i(a, 0))
|
||||||
if c != null:
|
if c != null:
|
||||||
@@ -455,6 +473,9 @@ func _beds_needed(n: int) -> int:
|
|||||||
## Actions.use_bed decided `p` may sleep: they get into bed, and the night
|
## Actions.use_bed decided `p` may sleep: they get into bed, and the night
|
||||||
## passes once enough players are in bed.
|
## passes once enough players are in bed.
|
||||||
func go_to_bed(p: Node3D) -> void:
|
func go_to_bed(p: Node3D) -> void:
|
||||||
|
if String(Net.config.v("sleep")) == "off":
|
||||||
|
tell(p.peer, "Sleeping is off on this server: the nights have to be lived through")
|
||||||
|
return
|
||||||
p.in_bed = true
|
p.in_bed = true
|
||||||
p.bed_at = p.global_position
|
p.bed_at = p.global_position
|
||||||
if not _maybe_pass_night():
|
if not _maybe_pass_night():
|
||||||
@@ -495,11 +516,16 @@ func _container(p: Node3D, nid: int) -> Node3D:
|
|||||||
var c := WorldNet.piece(nid)
|
var c := WorldNet.piece(nid)
|
||||||
if c == null or c.get("inventory") == null or not Actions._in_reach(p, c, 1.0):
|
if c == null or c.get("inventory") == null or not Actions._in_reach(p, c, 1.0):
|
||||||
return null
|
return null
|
||||||
var why: String = c.use_block_reason(p.player_id) if c.has_method("use_block_reason") else ""
|
return c if _allowed(Rules.use_problem(p.player_id, c)) else null
|
||||||
if not why.is_empty():
|
|
||||||
|
const Rules = preload("res://scripts/net/rules.gd")
|
||||||
|
|
||||||
|
## Tells the player why not, if there's a reason.
|
||||||
|
func _allowed(why: String) -> bool:
|
||||||
|
if why.is_empty():
|
||||||
|
return true
|
||||||
Actions.notify.emit(why)
|
Actions.notify.emit(why)
|
||||||
return null
|
return false
|
||||||
return c
|
|
||||||
|
|
||||||
static func _i(a: Array, k: int) -> int:
|
static func _i(a: Array, k: int) -> int:
|
||||||
return int(a[k]) if a.size() > k and (a[k] is int or a[k] is float) else -1
|
return int(a[k]) if a.size() > k and (a[k] is int or a[k] is float) else -1
|
||||||
@@ -567,9 +593,9 @@ func _s2c_stats(stats: Dictionary, statuses: Array, env: Array, bed: Array) -> v
|
|||||||
world.bed_spawn = bed[1]
|
world.bed_spawn = bed[1]
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_died(cause: String, has_items: bool, to_bed: bool) -> void:
|
func _s2c_died(cause: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
|
||||||
if world != null and is_instance_valid(world):
|
if world != null and is_instance_valid(world):
|
||||||
world.died_online(cause, has_items, to_bed)
|
world.died_online(cause, has_items, to_bed, penalty)
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void:
|
func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void:
|
||||||
|
|||||||
74
scripts/net/rules.gd
Normal file
74
scripts/net/rules.gd
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## A dedicated server's rules (docs/MULTIPLAYER_PLAN.md §4): who may use and
|
||||||
|
## take down whose things, whether players can hurt each other, what death
|
||||||
|
## costs, and how hard the island is. All checked on the server; single
|
||||||
|
## player has no rules (everything is yours).
|
||||||
|
##
|
||||||
|
## Pieces remember who placed them (meta "owner" = player id, "owner_name").
|
||||||
|
## Pieces with no owner (placed before M2.6, or by the world) are anyone's.
|
||||||
|
|
||||||
|
const DIFFICULTY := {
|
||||||
|
# hunger and diet drain, animal and wolf damage, extra wolves in the pack
|
||||||
|
"easy": {"drain": 0.75, "damage": 0.7, "pack": -1},
|
||||||
|
"normal": {"drain": 1.0, "damage": 1.0, "pack": 0},
|
||||||
|
"hard": {"drain": 1.3, "damage": 1.4, "pack": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _cfg() -> RefCounted:
|
||||||
|
var net: Node = (Engine.get_main_loop() as SceneTree).root.get_node_or_null("Net")
|
||||||
|
return net.config if net != null and net.is_server() else null
|
||||||
|
|
||||||
|
static func v(k: String) -> Variant:
|
||||||
|
var c := _cfg()
|
||||||
|
return c.v(k) if c != null else null
|
||||||
|
|
||||||
|
static func owner_of(piece: Node) -> String:
|
||||||
|
return String(piece.get_meta("owner", "")) if piece != null else ""
|
||||||
|
|
||||||
|
static func owner_name(piece: Node) -> String:
|
||||||
|
return String(piece.get_meta("owner_name", "someone"))
|
||||||
|
|
||||||
|
## Whether player `id` may open, take from or use `piece` (chests, doors,
|
||||||
|
## beds, graves), going by `locks`.
|
||||||
|
static func may_use(id: String, piece: Node) -> bool:
|
||||||
|
var c := _cfg()
|
||||||
|
var owner := owner_of(piece)
|
||||||
|
if c == null or owner.is_empty() or owner == id:
|
||||||
|
return true
|
||||||
|
match String(c.v("locks")):
|
||||||
|
"off":
|
||||||
|
return true
|
||||||
|
"owner_and_friends":
|
||||||
|
return c.is_friend(owner, id)
|
||||||
|
return false # "owner"
|
||||||
|
|
||||||
|
## Why `id` can't use `piece` ("" = they can).
|
||||||
|
static func use_problem(id: String, piece: Node) -> String:
|
||||||
|
if may_use(id, piece):
|
||||||
|
return ""
|
||||||
|
var what: String = piece.display_name().to_lower() if piece.has_method("display_name") else "that"
|
||||||
|
if piece.item_id == "grave":
|
||||||
|
return "That's %s's grave" % owner_name(piece)
|
||||||
|
return "That %s is %s's (locked)" % [what, owner_name(piece)]
|
||||||
|
|
||||||
|
## Taking a piece down: your own, anything you're allowed to use (friends,
|
||||||
|
## or locks off), or anyone's if raiding is on.
|
||||||
|
static func dismantle_problem(id: String, piece: Node) -> String:
|
||||||
|
if may_use(id, piece) or bool(v("raiding")):
|
||||||
|
return ""
|
||||||
|
return "That's %s's, and raiding is off on this server" % owner_name(piece)
|
||||||
|
|
||||||
|
static func pvp() -> bool:
|
||||||
|
return bool(v("pvp"))
|
||||||
|
|
||||||
|
static func death_penalty() -> String:
|
||||||
|
var d: Variant = v("death_penalty")
|
||||||
|
return String(d) if d != null else "grave"
|
||||||
|
|
||||||
|
static func difficulty(k: String) -> float:
|
||||||
|
var d: Variant = v("difficulty")
|
||||||
|
return float(DIFFICULTY.get(String(d) if d != null else "normal", DIFFICULTY.normal)[k])
|
||||||
|
|
||||||
|
static func grace_days() -> int:
|
||||||
|
var g: Variant = v("wolf_grace_days")
|
||||||
|
return clampi(int(g), 0, 10) if g != null else 3
|
||||||
@@ -54,6 +54,7 @@ static func open(p: String = DEFAULT_PATH) -> RefCounted:
|
|||||||
if FileAccess.file_exists(c.names_path()) else null
|
if FileAccess.file_exists(c.names_path()) else null
|
||||||
if n is Dictionary:
|
if n is Dictionary:
|
||||||
c.names = n
|
c.names = n
|
||||||
|
c.load_friends()
|
||||||
return c
|
return c
|
||||||
|
|
||||||
func names_path() -> String:
|
func names_path() -> String:
|
||||||
@@ -104,3 +105,104 @@ func add_admin(id: String) -> void:
|
|||||||
if not id in data.admins:
|
if not id in data.admins:
|
||||||
data.admins.append(id)
|
data.admins.append(id)
|
||||||
save()
|
save()
|
||||||
|
|
||||||
|
func ban(id: String) -> void:
|
||||||
|
if not id in data.banned:
|
||||||
|
data.banned.append(id)
|
||||||
|
save()
|
||||||
|
|
||||||
|
func unban(id: String) -> bool:
|
||||||
|
if not id in data.banned:
|
||||||
|
return false
|
||||||
|
data.banned.erase(id)
|
||||||
|
save()
|
||||||
|
return true
|
||||||
|
|
||||||
|
## A reserved name back up for grabs (the player who had it keeps their
|
||||||
|
## character, and picks another name next time they join).
|
||||||
|
func release_name(n: String) -> bool:
|
||||||
|
if not names.has(n.to_lower()):
|
||||||
|
return false
|
||||||
|
names.erase(n.to_lower())
|
||||||
|
save_names()
|
||||||
|
return true
|
||||||
|
|
||||||
|
## The name a player id goes by here ("" if none).
|
||||||
|
func name_of(id: String) -> String:
|
||||||
|
for k in names:
|
||||||
|
if names[k].id == id:
|
||||||
|
return String(names[k].name)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# --- rules that can change while the server runs (/set) --------------------------
|
||||||
|
|
||||||
|
## What each can be set to. The others in DEFAULTS are fixed at start-up.
|
||||||
|
const SETTABLE := {
|
||||||
|
"pvp": ["false", "true"],
|
||||||
|
"raiding": ["false", "true"],
|
||||||
|
"locks": ["off", "owner", "owner_and_friends"],
|
||||||
|
"death_penalty": ["grave", "keep", "drop_all"],
|
||||||
|
"sleep": ["all_online", "half", "anyone", "off"],
|
||||||
|
"pause_when_empty": ["false", "true"],
|
||||||
|
"difficulty": ["easy", "normal", "hard"],
|
||||||
|
"wolf_grace_days": "0-10",
|
||||||
|
"motd": "text",
|
||||||
|
"server_name": "text",
|
||||||
|
}
|
||||||
|
|
||||||
|
## Changes a rule and saves server.json. Returns "" or what's wrong.
|
||||||
|
func set_rule(k: String, value: String) -> String:
|
||||||
|
if not SETTABLE.has(k):
|
||||||
|
return "Can't set '%s'. These can be set: %s" % [k, ", ".join(PackedStringArray(SETTABLE.keys()))]
|
||||||
|
var allowed: Variant = SETTABLE[k]
|
||||||
|
var val: Variant
|
||||||
|
if allowed is Array:
|
||||||
|
if not value in allowed:
|
||||||
|
return "%s can be: %s" % [k, " / ".join(PackedStringArray(allowed))]
|
||||||
|
val = (value == "true") if value in ["true", "false"] else value
|
||||||
|
elif allowed == "0-10":
|
||||||
|
if not value.is_valid_int() or int(value) < 0 or int(value) > 10:
|
||||||
|
return "%s is a number from 0 to 10" % k
|
||||||
|
val = int(value)
|
||||||
|
else:
|
||||||
|
if value.strip_edges().is_empty() or value.length() > 80:
|
||||||
|
return "%s needs some text (up to 80 characters)" % k
|
||||||
|
val = value.strip_edges()
|
||||||
|
data[k] = val
|
||||||
|
overrides.erase(k) # (a flag no longer wins over what the admin just set)
|
||||||
|
save()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# --- friends (docs/MULTIPLAYER_PLAN.md §4) ----------------------------------------
|
||||||
|
# Per player: whose doors and chests they share. Stored as owner id -> [ids],
|
||||||
|
# in friends.json next to server.json.
|
||||||
|
|
||||||
|
var friends: Dictionary = {}
|
||||||
|
|
||||||
|
func friends_path() -> String:
|
||||||
|
return path.get_base_dir().path_join("friends.json")
|
||||||
|
|
||||||
|
func load_friends() -> void:
|
||||||
|
var f: Variant = JSON.parse_string(FileAccess.get_file_as_string(friends_path())) \
|
||||||
|
if FileAccess.file_exists(friends_path()) else null
|
||||||
|
friends = f if f is Dictionary else {}
|
||||||
|
|
||||||
|
## `owner` lets `friend` use their things.
|
||||||
|
func add_friend(owner: String, friend: String) -> void:
|
||||||
|
var l: Array = friends.get(owner, [])
|
||||||
|
if not friend in l:
|
||||||
|
l.append(friend)
|
||||||
|
friends[owner] = l
|
||||||
|
_write(friends_path(), friends)
|
||||||
|
|
||||||
|
func remove_friend(owner: String, friend: String) -> bool:
|
||||||
|
var l: Array = friends.get(owner, [])
|
||||||
|
if not friend in l:
|
||||||
|
return false
|
||||||
|
l.erase(friend)
|
||||||
|
friends[owner] = l
|
||||||
|
_write(friends_path(), friends)
|
||||||
|
return true
|
||||||
|
|
||||||
|
func is_friend(owner: String, friend: String) -> bool:
|
||||||
|
return friend in friends.get(owner, [])
|
||||||
|
|||||||
@@ -33,12 +33,24 @@ var has_bed_spawn := false
|
|||||||
var bed_spawn := Vector3.ZERO
|
var bed_spawn := Vector3.ZERO
|
||||||
var anim := 0 ## last movement state reported (avatar.gd Anim)
|
var anim := 0 ## last movement state reported (avatar.gd Anim)
|
||||||
var died_at := 0 ## ticks (ms) when they last died
|
var died_at := 0 ## ticks (ms) when they last died
|
||||||
|
var melee_ready_at := 0 ## ticks (ms): PvP swings wait for the weapon's cooldown
|
||||||
|
|
||||||
|
## Physics layer 5 (value 16): only arrows and thrown spears look for it,
|
||||||
|
## and only when PvP is on (projectile.gd). Nothing else collides with it.
|
||||||
|
const PLAYER_LAYER := 16
|
||||||
|
|
||||||
func setup(world: Node3D) -> void:
|
func setup(world: Node3D) -> void:
|
||||||
hands = self
|
hands = self
|
||||||
collision_layer = 0
|
collision_layer = PLAYER_LAYER
|
||||||
collision_mask = 0
|
collision_mask = 0
|
||||||
set_physics_process(false)
|
set_physics_process(false)
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var cap := CapsuleShape3D.new()
|
||||||
|
cap.radius = 0.4
|
||||||
|
cap.height = 1.8
|
||||||
|
cs.shape = cap
|
||||||
|
cs.position = Vector3(0, 0.9, 0)
|
||||||
|
add_child(cs)
|
||||||
water_level = world.terrain.water_level
|
water_level = world.terrain.water_level
|
||||||
survival = preload("res://scripts/player/survival.gd").new()
|
survival = preload("res://scripts/player/survival.gd").new()
|
||||||
survival.name = "Survival"
|
survival.name = "Survival"
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ func piece_state(p: Node3D) -> void:
|
|||||||
_all("_s2c_piece_state", [int(p.get_meta("nid", 0)), p.save_extra()])
|
_all("_s2c_piece_state", [int(p.get_meta("nid", 0)), p.save_extra()])
|
||||||
|
|
||||||
func _on_piece_added(p: Node3D) -> void:
|
func _on_piece_added(p: Node3D) -> void:
|
||||||
|
# Whoever's request placed it owns it (graves: whoever died).
|
||||||
|
var by: Node = PlayerNet.proxies.get(PlayerNet._acting)
|
||||||
|
if by != null and not p.has_meta("owner"):
|
||||||
|
p.set_meta("owner", String(by.player_id))
|
||||||
|
p.set_meta("owner_name", String(by.display))
|
||||||
_register(p)
|
_register(p)
|
||||||
_all("_s2c_piece_added", [world.piece_entry(p)])
|
_all("_s2c_piece_added", [world.piece_entry(p)])
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ func _physics_process(delta: float) -> void:
|
|||||||
var p: String = t.interact_prompt(player)
|
var p: String = t.interact_prompt(player)
|
||||||
if t.is_in_group("piece") and t.item_id != "grave":
|
if t.is_in_group("piece") and t.item_id != "grave":
|
||||||
p += " [X] Dismantle"
|
p += " [X] Dismantle"
|
||||||
|
# Online: someone else's piece says whose it is (the server decides
|
||||||
|
# whether you may use it, by its rules).
|
||||||
|
var owner := String(t.get_meta("owner", ""))
|
||||||
|
if not owner.is_empty() and Net.state == "connected" and owner != Net.get_identity().player_id:
|
||||||
|
p = "%s's · %s" % [String(t.get_meta("owner_name", "someone")), p]
|
||||||
_set_prompt(p)
|
_set_prompt(p)
|
||||||
elif def.has("food"):
|
elif def.has("food"):
|
||||||
_set_prompt("[RMB] Eat %s" % def.name)
|
_set_prompt("[RMB] Eat %s" % def.name)
|
||||||
@@ -346,6 +351,9 @@ func _apply_hit() -> void:
|
|||||||
# Animals first: a short box sweep in front of the camera, so you don't
|
# Animals first: a short box sweep in front of the camera, so you don't
|
||||||
# need pixel-perfect aim at a running rabbit.
|
# need pixel-perfect aim at a running rabbit.
|
||||||
var animal := melee_target()
|
var animal := melee_target()
|
||||||
|
if animal != null and animal.has_meta("avatar_peer"):
|
||||||
|
Actions.hit_player(player, int(animal.get_meta("avatar_peer")))
|
||||||
|
return
|
||||||
if animal != null:
|
if animal != null:
|
||||||
Actions.melee(player, animal)
|
Actions.melee(player, animal)
|
||||||
return
|
return
|
||||||
@@ -366,12 +374,14 @@ func melee_target() -> Node3D:
|
|||||||
var q := PhysicsShapeQueryParameters3D.new()
|
var q := PhysicsShapeQueryParameters3D.new()
|
||||||
q.shape = box
|
q.shape = box
|
||||||
q.transform = Transform3D(cam.global_basis, cam.global_position - cam.global_basis.z * reach * 0.5 - cam.global_basis.y * 0.5)
|
q.transform = Transform3D(cam.global_basis, cam.global_position - cam.global_basis.z * reach * 0.5 - cam.global_basis.y * 0.5)
|
||||||
q.collision_mask = 8
|
# Online with PvP on, other players' avatars too (their hitbox, layer 16).
|
||||||
|
var pvp: bool = Net.state == "connected" and bool(Net.server_info.get("pvp", false))
|
||||||
|
q.collision_mask = 8 | (16 if pvp else 0)
|
||||||
var best: Node3D = null
|
var best: Node3D = null
|
||||||
var best_d := INF
|
var best_d := INF
|
||||||
for h in cam.get_world_3d().direct_space_state.intersect_shape(q, 8):
|
for h in cam.get_world_3d().direct_space_state.intersect_shape(q, 8):
|
||||||
var c: Node3D = h.collider
|
var c: Node3D = h.collider
|
||||||
if c.is_in_group("creature"):
|
if c.is_in_group("creature") or c.has_meta("avatar_peer"):
|
||||||
var d := c.global_position.distance_to(cam.global_position)
|
var d := c.global_position.distance_to(cam.global_position)
|
||||||
if d < best_d:
|
if d < best_d:
|
||||||
best_d = d
|
best_d = d
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ var _rest_build := 0.0
|
|||||||
var buffs: Dictionary = {}
|
var buffs: Dictionary = {}
|
||||||
var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
|
var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
|
||||||
var cause := "" ## what last hurt you, as the death screen says it ("You froze")
|
var cause := "" ## what last hurt you, as the death screen says it ("You froze")
|
||||||
|
var drain_mult := 1.0 ## hunger and diet drain (a server's difficulty, rules.gd)
|
||||||
|
|
||||||
signal hurt(amount: float, from: Vector3)
|
signal hurt(amount: float, from: Vector3)
|
||||||
const HURT_NO_REGEN := 6.0
|
const HURT_NO_REGEN := 6.0
|
||||||
@@ -111,7 +112,7 @@ func step(dt: float) -> void:
|
|||||||
# Warm up faster than you cool down: getting to a fire should feel good.
|
# Warm up faster than you cool down: getting to a fire should feel good.
|
||||||
warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * dt)
|
warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * dt)
|
||||||
|
|
||||||
hunger = maxf(hunger - HUNGER_DRAIN * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
|
hunger = maxf(hunger - HUNGER_DRAIN * drain_mult * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
|
||||||
|
|
||||||
if _stamina_wait > 0.0:
|
if _stamina_wait > 0.0:
|
||||||
_stamina_wait -= dt
|
_stamina_wait -= dt
|
||||||
@@ -121,7 +122,7 @@ func step(dt: float) -> void:
|
|||||||
* (0.7 if malnourished else 1.0) * dt, MAX)
|
* (0.7 if malnourished else 1.0) * dt, MAX)
|
||||||
|
|
||||||
for g in DIET_GROUPS:
|
for g in DIET_GROUPS:
|
||||||
diet[g] = maxf(float(diet[g]) - DIET_DRAIN * dt, 0.0)
|
diet[g] = maxf(float(diet[g]) - DIET_DRAIN * drain_mult * dt, 0.0)
|
||||||
var lacking := diet_lacking()
|
var lacking := diet_lacking()
|
||||||
|
|
||||||
for id in buffs.keys():
|
for id in buffs.keys():
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
root.move_child(_hurt, 0)
|
root.move_child(_hurt, 0)
|
||||||
player.survival.hurt.connect(_on_hurt)
|
player.survival.hurt.connect(_on_hurt)
|
||||||
Actions.notify.connect(toast)
|
Actions.notify.connect(toast)
|
||||||
|
_build_chat(root)
|
||||||
ItemDB.icons_ready.connect(_refresh)
|
ItemDB.icons_ready.connect(_refresh)
|
||||||
_refresh()
|
_refresh()
|
||||||
|
|
||||||
@@ -175,6 +176,7 @@ func _build_stats(root: Control) -> void:
|
|||||||
root.add_child(_clock)
|
root.add_child(_clock)
|
||||||
|
|
||||||
func _process(_d: float) -> void:
|
func _process(_d: float) -> void:
|
||||||
|
_fade_chat()
|
||||||
var s = player.survival
|
var s = player.survival
|
||||||
for k in _bars:
|
for k in _bars:
|
||||||
_bars[k].value = s.get(k)
|
_bars[k].value = s.get(k)
|
||||||
@@ -211,6 +213,70 @@ func fade(mid: Callable) -> void:
|
|||||||
|
|
||||||
var _death: Control
|
var _death: Control
|
||||||
|
|
||||||
|
# --- chat (online: T to talk) ----------------------------------------------------------
|
||||||
|
|
||||||
|
const CHAT_LINES := 8
|
||||||
|
const CHAT_FADE := 14.0 ## seconds a line stays up (all show while typing)
|
||||||
|
var _chat_log: VBoxContainer
|
||||||
|
var _chat_input: LineEdit
|
||||||
|
|
||||||
|
func _build_chat(root: Control) -> void:
|
||||||
|
_chat_log = VBoxContainer.new()
|
||||||
|
_chat_log.position = Vector2(16, 64)
|
||||||
|
_chat_log.custom_minimum_size = Vector2(520, 0)
|
||||||
|
_chat_log.add_theme_constant_override("separation", 2)
|
||||||
|
_chat_log.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
root.add_child(_chat_log)
|
||||||
|
_chat_input = LineEdit.new()
|
||||||
|
_chat_input.position = Vector2(16, 64 + CHAT_LINES * 24 + 6)
|
||||||
|
_chat_input.custom_minimum_size = Vector2(520, 34)
|
||||||
|
_chat_input.placeholder_text = "Say something (Enter), or /help for commands. Esc to cancel"
|
||||||
|
_chat_input.max_length = 200
|
||||||
|
_chat_input.visible = false
|
||||||
|
_chat_input.text_submitted.connect(func(t):
|
||||||
|
Chat.say(t)
|
||||||
|
close_chat())
|
||||||
|
root.add_child(_chat_input)
|
||||||
|
Chat.message.connect(chat_line)
|
||||||
|
|
||||||
|
func chat_line(text: String, kind: String) -> void:
|
||||||
|
var col := UITheme.TEXT
|
||||||
|
if kind == "server":
|
||||||
|
col = Color(0.95, 0.82, 0.45)
|
||||||
|
elif kind == "reply":
|
||||||
|
col = UITheme.TEXT_DIM
|
||||||
|
var l := UITheme.label(text, 15, col)
|
||||||
|
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
l.custom_minimum_size = Vector2(520, 0)
|
||||||
|
l.set_meta("t", Time.get_ticks_msec())
|
||||||
|
_chat_log.add_child(l)
|
||||||
|
while _chat_log.get_child_count() > CHAT_LINES:
|
||||||
|
var old := _chat_log.get_child(0)
|
||||||
|
_chat_log.remove_child(old)
|
||||||
|
old.queue_free()
|
||||||
|
|
||||||
|
func open_chat() -> void:
|
||||||
|
_chat_input.text = ""
|
||||||
|
_chat_input.visible = true
|
||||||
|
player.ui_open = true
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
_chat_input.grab_focus()
|
||||||
|
|
||||||
|
func close_chat() -> void:
|
||||||
|
_chat_input.visible = false
|
||||||
|
_chat_input.release_focus()
|
||||||
|
player.ui_open = false
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
|
|
||||||
|
func _fade_chat() -> void:
|
||||||
|
if _chat_log == null:
|
||||||
|
return
|
||||||
|
var typing := _chat_input.visible
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
for l in _chat_log.get_children():
|
||||||
|
var age := (now - int(l.get_meta("t", now))) / 1000.0
|
||||||
|
l.modulate.a = 1.0 if typing else clampf((CHAT_FADE - age) / 2.0, 0.0, 1.0)
|
||||||
|
|
||||||
## The death screen: the world fades to dark red, it says what killed you
|
## The death screen: the world fades to dark red, it says what killed you
|
||||||
## and where your things are, and after `delay` seconds Respawn (or Enter)
|
## and where your things are, and after `delay` seconds Respawn (or Enter)
|
||||||
## calls `respawn`.
|
## calls `respawn`.
|
||||||
@@ -286,6 +352,16 @@ func _on_inventory_requested(container: Node) -> void:
|
|||||||
|
|
||||||
## ESC: close the inventory if it's open, otherwise toggle the pause menu.
|
## ESC: close the inventory if it's open, otherwise toggle the pause menu.
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
|
if _chat_input != null and _chat_input.visible:
|
||||||
|
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||||
|
close_chat()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return # (typing: keys go to the chat line)
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo and event.physical_keycode == KEY_T \
|
||||||
|
and Net.state == "connected" and not player.ui_open and not is_dead_screen():
|
||||||
|
open_chat()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
|
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
|
||||||
if is_dead_screen():
|
if is_dead_screen():
|
||||||
return # (respawn first)
|
return # (respawn first)
|
||||||
|
|||||||
@@ -204,9 +204,14 @@ func _on_player_died() -> void:
|
|||||||
const RESPAWN_DELAY := 3.0 ## seconds on the death screen before you can respawn
|
const RESPAWN_DELAY := 3.0 ## seconds on the death screen before you can respawn
|
||||||
var dead_waiting := false ## dead, on the death screen
|
var dead_waiting := false ## dead, on the death screen
|
||||||
|
|
||||||
static func _death_note(has_items: bool, to_bed: bool) -> String:
|
static func _death_note(has_items: bool, to_bed: bool, penalty: String = "grave") -> String:
|
||||||
return "%s\nYou'll wake %s." % ["Your belongings are in a grave where you fell." if has_items else "You had nothing on you.",
|
var things := "You had nothing on you."
|
||||||
"at your bed" if to_bed else "at the island start"]
|
if penalty == "keep":
|
||||||
|
things = "You keep everything you were carrying."
|
||||||
|
elif has_items:
|
||||||
|
things = "Everything you carried is scattered where you fell: anyone can pick it up." if penalty == "drop_all" \
|
||||||
|
else "Your belongings are in a grave where you fell."
|
||||||
|
return "%s\nYou'll wake %s." % [things, "at your bed" if to_bed else "at the island start"]
|
||||||
|
|
||||||
## Respawn (the death screen's button): at your bed, or the island start.
|
## Respawn (the death screen's button): at your bed, or the island start.
|
||||||
func respawn_after_death() -> void:
|
func respawn_after_death() -> void:
|
||||||
@@ -225,13 +230,13 @@ func respawn_after_death() -> void:
|
|||||||
player.survival.reset_after_death()
|
player.survival.reset_after_death()
|
||||||
|
|
||||||
## Online: the server says we died. Same screen; Respawn asks the server.
|
## Online: the server says we died. Same screen; Respawn asks the server.
|
||||||
func died_online(text: String, has_items: bool, to_bed: bool) -> void:
|
func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
|
||||||
dead_waiting = true
|
dead_waiting = true
|
||||||
player.velocity = Vector3.ZERO
|
player.velocity = Vector3.ZERO
|
||||||
player.set_physics_process(false)
|
player.set_physics_process(false)
|
||||||
if hud != null:
|
if hud != null:
|
||||||
hud.close_screens()
|
hud.close_screens()
|
||||||
hud.show_death(text, _death_note(has_items, to_bed), RESPAWN_DELAY, func(): PlayerNet.ask("respawn"))
|
hud.show_death(text, _death_note(has_items, to_bed, penalty), RESPAWN_DELAY, func(): PlayerNet.ask("respawn"))
|
||||||
else:
|
else:
|
||||||
get_tree().create_timer(RESPAWN_DELAY + 0.1).timeout.connect(func(): PlayerNet.ask("respawn"))
|
get_tree().create_timer(RESPAWN_DELAY + 0.1).timeout.connect(func(): PlayerNet.ask("respawn"))
|
||||||
|
|
||||||
@@ -349,6 +354,7 @@ func _on_terrain_ready() -> void:
|
|||||||
wolves.name = "Wolves"
|
wolves.name = "Wolves"
|
||||||
add_child(wolves)
|
add_child(wolves)
|
||||||
wolves.setup(self, den)
|
wolves.setup(self, den)
|
||||||
|
apply_rules()
|
||||||
return
|
return
|
||||||
_respawn()
|
_respawn()
|
||||||
if Saves.has_world():
|
if Saves.has_world():
|
||||||
@@ -373,6 +379,16 @@ func _on_terrain_ready() -> void:
|
|||||||
wolves.setup(self, den)
|
wolves.setup(self, den)
|
||||||
_maybe_capture()
|
_maybe_capture()
|
||||||
|
|
||||||
|
## A server's rules for the island (at start-up, and after an admin's /set).
|
||||||
|
func apply_rules() -> void:
|
||||||
|
var R = preload("res://scripts/net/rules.gd")
|
||||||
|
if wolves != null:
|
||||||
|
wolves.den_day = R.grace_days()
|
||||||
|
wolves.hunt_day = R.grace_days() + 1
|
||||||
|
wolves.pack_bonus = int(R.difficulty("pack"))
|
||||||
|
for p in PlayerNet.proxies.values():
|
||||||
|
p.survival.drain_mult = R.difficulty("drain")
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if menu_mode:
|
if menu_mode:
|
||||||
# Slow orbit over the valley, looking toward the lake.
|
# Slow orbit over the valley, looking toward the lake.
|
||||||
@@ -514,6 +530,9 @@ func piece_entry(piece: Node3D) -> Dictionary:
|
|||||||
entry["extra"] = piece.save_extra()
|
entry["extra"] = piece.save_extra()
|
||||||
if piece.has_meta("nid"):
|
if piece.has_meta("nid"):
|
||||||
entry["nid"] = int(piece.get_meta("nid"))
|
entry["nid"] = int(piece.get_meta("nid"))
|
||||||
|
if piece.has_meta("owner"):
|
||||||
|
entry["owner"] = String(piece.get_meta("owner"))
|
||||||
|
entry["owner_name"] = String(piece.get_meta("owner_name", ""))
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
func dropped_entry(d: Node3D) -> Dictionary:
|
func dropped_entry(d: Node3D) -> Dictionary:
|
||||||
@@ -543,6 +562,9 @@ func add_piece_entry(e: Dictionary) -> Node3D:
|
|||||||
piece.load_extra(e.extra)
|
piece.load_extra(e.extra)
|
||||||
if e.has("nid"):
|
if e.has("nid"):
|
||||||
piece.set_meta("nid", int(e.nid))
|
piece.set_meta("nid", int(e.nid))
|
||||||
|
if e.has("owner"):
|
||||||
|
piece.set_meta("owner", String(e.owner))
|
||||||
|
piece.set_meta("owner_name", String(e.get("owner_name", "")))
|
||||||
return piece
|
return piece
|
||||||
|
|
||||||
func add_dropped_entry(e: Dictionary) -> Node3D:
|
func add_dropped_entry(e: Dictionary) -> Node3D:
|
||||||
@@ -730,7 +752,7 @@ func _go_online() -> void:
|
|||||||
PlayerNet.join_world(self) # and our inventory and stats
|
PlayerNet.join_world(self) # and our inventory and stats
|
||||||
CreatureNet.join_world(self) # the server's animals, wolves, arrows and floats
|
CreatureNet.join_world(self) # the server's animals, wolves, arrows and floats
|
||||||
if hud != null:
|
if hud != null:
|
||||||
var bar := UITheme.label("Online: %s" % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM)
|
var bar := UITheme.label("Online: %s · T to chat (/help)" % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM)
|
||||||
bar.position = Vector2(16, 40)
|
bar.position = Vector2(16, 40)
|
||||||
$HUD.add_child(bar)
|
$HUD.add_child(bar)
|
||||||
Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server"))
|
Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server"))
|
||||||
@@ -799,6 +821,10 @@ var bobbers: Dictionary = {} ## player -> their float
|
|||||||
|
|
||||||
# --- who's here ------------------------------------------------------------
|
# --- who's here ------------------------------------------------------------
|
||||||
|
|
||||||
|
## A server's difficulty for one thing (rules.gd); 1 in single-player.
|
||||||
|
func difficulty_mult(k: String) -> float:
|
||||||
|
return preload("res://scripts/net/rules.gd").difficulty(k) if server_mode else 1.0
|
||||||
|
|
||||||
## Everyone animals react to: the local player in single-player; on a
|
## Everyone animals react to: the local player in single-player; on a
|
||||||
## server, every player's character that's alive (player_net.gd).
|
## server, every player's character that's alive (player_net.gd).
|
||||||
func players() -> Array:
|
func players() -> Array:
|
||||||
|
|||||||
@@ -13,4 +13,4 @@
|
|||||||
cd "$(dirname "$0")/.." || exit 1
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
. tools/godot_env.sh
|
. tools/godot_env.sh
|
||||||
|
|
||||||
exec "$GODOT" --headless --path . -- --server "$@"
|
exec "$GODOT" --headless --path . -- --server --console "$@"
|
||||||
|
|||||||
@@ -131,13 +131,21 @@ func _do(cmd: Dictionary) -> Dictionary:
|
|||||||
return {"asked": true}
|
return {"asked": true}
|
||||||
"life":
|
"life":
|
||||||
return life(world, root.get_node("CreatureNet"), root.get_node("PlayerNet"))
|
return life(world, root.get_node("CreatureNet"), root.get_node("PlayerNet"))
|
||||||
|
# M2.6 (tests/test_net_rules.gd).
|
||||||
|
"say":
|
||||||
|
root.get_node("Chat").say(String(cmd.text))
|
||||||
|
await create_timer(0.6).timeout
|
||||||
|
return {"heard": root.get_node("Chat").heard.duplicate()}
|
||||||
|
"heard":
|
||||||
|
return {"heard": root.get_node("Chat").heard.duplicate(), "pvp": bool(net.server_info.get("pvp", false))}
|
||||||
return {"error": "unknown command"}
|
return {"error": "unknown command"}
|
||||||
|
|
||||||
## What this player's game shows of the server's wildlife.
|
## What this player's game shows of the server's wildlife.
|
||||||
static func life(w: Node3D, CN: Node, PN: Node) -> Dictionary:
|
static func life(w: Node3D, CN: Node, PN: Node) -> Dictionary:
|
||||||
var out := {"puppets": {}, "floats": [], "projectiles": CN.projectiles_seen, "howls": CN.howls_heard,
|
var out := {"puppets": {}, "floats": [], "projectiles": CN.projectiles_seen, "howls": CN.howls_heard,
|
||||||
"hurts": PN.hurts, "health": w.player.survival.health, "day": w.day_night.day,
|
"hurts": PN.hurts, "health": w.player.survival.health, "day": w.day_night.day,
|
||||||
"hours": w.day_night.hours(), "statuses": w.player.survival.statuses.duplicate()}
|
"hours": w.day_night.hours(), "statuses": w.player.survival.statuses.duplicate(),
|
||||||
|
"weather": w.weather.state}
|
||||||
for cid in CN.puppets:
|
for cid in CN.puppets:
|
||||||
var c: Node3D = CN.puppets[cid]
|
var c: Node3D = CN.puppets[cid]
|
||||||
if is_instance_valid(c):
|
if is_instance_valid(c):
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
|||||||
echo "Importing the project first (fresh copy)..."
|
echo "Importing the project first (fresh copy)..."
|
||||||
godot --headless --path . --import >/dev/null 2>&1
|
godot --headless --path . --import >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_net_life test_terrain_edges test_boundary; do
|
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_terrain_edges test_boundary; do
|
||||||
extra=""
|
extra=""
|
||||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
# A suite that waits on a world that failed to load would hang forever,
|
# A suite that waits on a world that failed to load would hang forever,
|
||||||
|
|||||||
419
tests/test_net_rules.gd
Normal file
419
tests/test_net_rules.gd
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## M2.6: server rules, chat and admin commands. Two players on a dedicated
|
||||||
|
## server: chat; who may use and take down whose things (locks, friends,
|
||||||
|
## raiding); PvP off and on; what dying costs (drop_all, keep); the clock
|
||||||
|
## and the weather; kicking, banning and unbanning.
|
||||||
|
##
|
||||||
|
## A real headless server (with --debug-cheats), Alice (this test: the first
|
||||||
|
## player, so the admin) and Bob (tests/net_world_bot.gd).
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_net_rules.gd
|
||||||
|
|
||||||
|
const PORT := 24349
|
||||||
|
const DIR := "user://test_net"
|
||||||
|
const SRV := DIR + "/server7"
|
||||||
|
const BOBS := DIR + "/bob_rules"
|
||||||
|
const SEED := 777
|
||||||
|
const Bot = preload("res://tests/net_world_bot.gd")
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var _pids: Array[int] = []
|
||||||
|
var Net: Node
|
||||||
|
var WN: Node
|
||||||
|
var PN: Node
|
||||||
|
var A: Node
|
||||||
|
var C: Node
|
||||||
|
var world: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var _bob_dir := ""
|
||||||
|
var _cmd := 0
|
||||||
|
var _bobs := 0
|
||||||
|
var _toasts: Array[String] = []
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
Engine.max_fps = 60
|
||||||
|
Net = root.get_node("Net")
|
||||||
|
WN = root.get_node("WorldNet")
|
||||||
|
PN = root.get_node("PlayerNet")
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
C = root.get_node("Chat")
|
||||||
|
A.notify.connect(func(t): _toasts.append(t))
|
||||||
|
for d in [SRV, BOBS]:
|
||||||
|
_rm_tree(d)
|
||||||
|
_spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json",
|
||||||
|
"--server-name", "Rules Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"])
|
||||||
|
await _sleep(4.0)
|
||||||
|
await _body()
|
||||||
|
Net.stop()
|
||||||
|
for pid in _pids:
|
||||||
|
if OS.is_process_running(pid):
|
||||||
|
OS.kill(pid)
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
_rm_tree(SRV)
|
||||||
|
_rm_tree(BOBS)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
var me = load("res://scripts/net/identity.gd").open(DIR + "/a")
|
||||||
|
me.name = "Alice"
|
||||||
|
Net.identity = me
|
||||||
|
var r := await _join()
|
||||||
|
check(r == "", "Alice joins, the first player, so she's the admin (%s)" % r)
|
||||||
|
if r != "":
|
||||||
|
return
|
||||||
|
world = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
player = world.player
|
||||||
|
player.set_physics_process(false)
|
||||||
|
await _until(func(): return WN.received >= 1, 10.0)
|
||||||
|
var b0 := await _new_bob()
|
||||||
|
check(bool(b0.get("ok", false)), "Bob joins (%s)" % b0.get("why", "ok"))
|
||||||
|
if not bool(b0.get("ok", false)):
|
||||||
|
return
|
||||||
|
await _chat()
|
||||||
|
await _locks()
|
||||||
|
await _pvp()
|
||||||
|
await _death()
|
||||||
|
await _clock_and_weather()
|
||||||
|
await _kick_and_ban()
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
Net.leave()
|
||||||
|
_finished = true
|
||||||
|
|
||||||
|
# --- chat ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _chat() -> void:
|
||||||
|
print("chat")
|
||||||
|
C.say("hello everyone")
|
||||||
|
var h := await _bob_heard("Alice: hello everyone")
|
||||||
|
check(h, "Alice says hello: Bob sees 'Alice: hello everyone'")
|
||||||
|
await _bob({"do": "say", "text": "hi Alice"})
|
||||||
|
await _until(func(): return C.heard.has("Bob: hi Alice"), 2.0)
|
||||||
|
check(C.heard.has("Bob: hi Alice"), "and Alice sees Bob's answer")
|
||||||
|
await _bob({"do": "say", "text": "/rules"})
|
||||||
|
check(await _bob_heard_prefix("PvP off · raiding off · locks owner_and_friends"), "Bob asks for /rules: co-op by default (PvP off, raiding off, locks for owner and friends)")
|
||||||
|
await _bob({"do": "say", "text": "/set pvp true"})
|
||||||
|
check(await _bob_heard("Only admins can use /set"), "Bob isn't an admin: he can't /set")
|
||||||
|
|
||||||
|
# --- locks, friends, raiding ---------------------------------------------------------
|
||||||
|
|
||||||
|
func _locks() -> void:
|
||||||
|
print("locks and friends")
|
||||||
|
var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(8, 0, 0), 3.0)
|
||||||
|
await _tp(site + Vector3(-2.5, 0, 0))
|
||||||
|
var chest := await _place("chest", site)
|
||||||
|
var door := await _place("wood_door", site + Vector3(0, 0, 4))
|
||||||
|
check(chest != null and door != null, "Alice places a chest and a door")
|
||||||
|
if chest == null or door == null:
|
||||||
|
return
|
||||||
|
check(String(chest.get_meta("owner_name", "")) == "Alice", "they're hers (the server says so, and everyone's game knows)")
|
||||||
|
var ss: int = await _give("stone", 6)
|
||||||
|
A.transfer(player, player.inventory, ss, chest.inventory, chest)
|
||||||
|
await _until(func(): return chest.inventory.count("stone") == 6, 2.0)
|
||||||
|
await _bob({"do": "tp", "to": _v(site + Vector3(2.0, 0, 0))})
|
||||||
|
var took := await _bob({"do": "chest_take", "nid": WN.nid(chest), "slot": 0})
|
||||||
|
check(_count(took.get("inv", []), "stone") == 0 and chest.inventory.count("stone") == 6, "Bob can't take from Alice's chest (locked)")
|
||||||
|
await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]})
|
||||||
|
await _sleep(0.3)
|
||||||
|
check(not door.open, "or open her door")
|
||||||
|
await _bob({"do": "ask", "what": "dismantle", "args": [WN.nid(door)]})
|
||||||
|
await _sleep(0.3)
|
||||||
|
check(WN.pieces.has(WN.nid(door)), "or take it down (raiding is off)")
|
||||||
|
C.say("/friend Bob")
|
||||||
|
check(await _bob_heard("Alice added you as a friend: you can use their doors, chests and beds"), "Alice makes Bob a friend")
|
||||||
|
took = await _bob({"do": "chest_take", "nid": WN.nid(chest), "slot": 0})
|
||||||
|
check(_count(took.get("inv", []), "stone") == 6, "now he can take from her chest")
|
||||||
|
await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]})
|
||||||
|
await _until(func(): return door.open, 2.0)
|
||||||
|
check(door.open, "and open her door")
|
||||||
|
C.say("/unfriend Bob")
|
||||||
|
await _sleep(0.4)
|
||||||
|
await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]})
|
||||||
|
await _sleep(0.4)
|
||||||
|
check(door.open, "unfriended: the door stays as it was")
|
||||||
|
print("raiding")
|
||||||
|
C.say("/set raiding true")
|
||||||
|
check(await _bob_heard("Alice set raiding to true"), "Alice turns raiding on (everyone's told)")
|
||||||
|
var dn: int = WN.nid(door)
|
||||||
|
await _bob({"do": "ask", "what": "dismantle", "args": [dn]})
|
||||||
|
await _until(func(): return not WN.pieces.has(dn), 2.0)
|
||||||
|
check(not WN.pieces.has(dn), "now Bob can take her door down")
|
||||||
|
C.say("/set raiding false")
|
||||||
|
await _sleep(0.4)
|
||||||
|
|
||||||
|
## Places `id` on the ground at `at` (build pieces snap) through Actions.
|
||||||
|
func _place(id: String, at: Vector3) -> Node3D:
|
||||||
|
var def: Dictionary = root.get_node("ItemDB").get_item(id)
|
||||||
|
var xf := Transform3D(Basis(), Vector3(at.x, world.terrain.sample_height(at.x, at.z), at.z))
|
||||||
|
if def.place.has("kind"):
|
||||||
|
var hit := {"position": xf.origin, "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")}
|
||||||
|
var res: Dictionary = world.build.placement(def, hit, 0.0)
|
||||||
|
if not res.ok:
|
||||||
|
print(" (can't place %s: %s)" % [id, res.reason])
|
||||||
|
return null
|
||||||
|
xf = res.xf
|
||||||
|
var slot: int = await _give(id, 1)
|
||||||
|
var before: Array = WN.pieces.keys()
|
||||||
|
A.place(player, slot, xf, world)
|
||||||
|
var found := {"p": null}
|
||||||
|
await _until(func():
|
||||||
|
for k in WN.pieces:
|
||||||
|
if not k in before and WN.pieces[k].item_id == id:
|
||||||
|
found.p = WN.pieces[k]
|
||||||
|
return true
|
||||||
|
return false, 3.0)
|
||||||
|
return found.p
|
||||||
|
|
||||||
|
# --- PvP ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _pvp() -> void:
|
||||||
|
print("PvP")
|
||||||
|
PN.ask("debug_stats", [{"health": 100.0}])
|
||||||
|
await _bob({"do": "ask", "what": "debug_give", "args": ["club", 1]})
|
||||||
|
await _bob({"do": "ask", "what": "debug_stats", "args": [{"stamina": 100.0}]})
|
||||||
|
var near := player.global_position + Vector3(1.4, 0, 0)
|
||||||
|
near.y = world.terrain.sample_height(near.x, near.z)
|
||||||
|
await _bob({"do": "tp", "to": _v(near)})
|
||||||
|
var slot := await _bob_slot("club")
|
||||||
|
await _bob({"do": "ask", "what": "select", "args": [slot]})
|
||||||
|
await _until(func(): return player.survival.health >= 99.0, 2.0)
|
||||||
|
var h0: float = player.survival.health
|
||||||
|
var hurts0: int = PN.hurts
|
||||||
|
await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]})
|
||||||
|
await _sleep(0.6)
|
||||||
|
check(PN.hurts == hurts0 and player.survival.health >= h0 - 0.5, "with PvP off, Bob's club does nothing to Alice")
|
||||||
|
C.say("/set pvp true")
|
||||||
|
await _until(func(): return bool(Net.server_info.get("pvp", false)), 2.0)
|
||||||
|
check(bool(Net.server_info.get("pvp", false)), "Alice turns PvP on, and every game hears the new rules")
|
||||||
|
await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]})
|
||||||
|
await _until(func(): return PN.hurts > hurts0, 2.0)
|
||||||
|
await _sleep(0.3)
|
||||||
|
check(PN.hurts > hurts0 and player.survival.health < h0 - 5.0, "now his club hurts her (health %.0f)" % player.survival.health)
|
||||||
|
# An arrow, too.
|
||||||
|
await _bob({"do": "ask", "what": "debug_give", "args": ["bow", 1]})
|
||||||
|
await _bob({"do": "ask", "what": "debug_give", "args": ["arrow", 3]})
|
||||||
|
var far := player.global_position + Vector3(7.0, 0, 0)
|
||||||
|
far.y = world.terrain.sample_height(far.x, far.z)
|
||||||
|
await _bob({"do": "tp", "to": _v(far)})
|
||||||
|
await _bob({"do": "ask", "what": "select", "args": [await _bob_slot("bow")]})
|
||||||
|
var h1: float = player.survival.health
|
||||||
|
var aim: Vector3 = (player.global_position + Vector3(0, 1.0, 0)) - (far + Vector3(0, 1.6, 0))
|
||||||
|
await _bob({"do": "ask", "what": "shoot", "args": [{"v": _v(aim.normalized())}, 1.0]})
|
||||||
|
await _until(func(): return player.survival.health < h1 - 1.0, 3.0)
|
||||||
|
check(player.survival.health < h1 - 1.0, "and so does an arrow (health %.0f)" % player.survival.health)
|
||||||
|
C.say("/set pvp false")
|
||||||
|
await _sleep(0.4)
|
||||||
|
|
||||||
|
# --- death penalties -------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _death() -> void:
|
||||||
|
print("death penalties")
|
||||||
|
C.say("/set death_penalty drop_all")
|
||||||
|
await _sleep(0.4)
|
||||||
|
await _clear()
|
||||||
|
await _give("stone", 5)
|
||||||
|
var at := player.global_position
|
||||||
|
var graves0 := _graves()
|
||||||
|
var d0: int = WN.dropped.size()
|
||||||
|
PN.ask("debug_die")
|
||||||
|
await _until(func(): return WN.dropped.size() > d0, 3.0)
|
||||||
|
check(_graves() == graves0 and WN.dropped.size() > d0 and player.inventory.count("stone") == 0,
|
||||||
|
"drop_all: no grave, her stone is lying on the ground where she fell, for anyone")
|
||||||
|
var near_drop: bool = WN.dropped.values().any(func(d): return is_instance_valid(d) and d.item_id == "stone" and d.global_position.distance_to(at) < 3.0)
|
||||||
|
check(near_drop, "(right where she died)")
|
||||||
|
await _until(func(): return not world.dead_waiting, 6.0)
|
||||||
|
C.say("/set death_penalty keep")
|
||||||
|
await _sleep(0.4)
|
||||||
|
await _give("flint", 4)
|
||||||
|
PN.ask("debug_die")
|
||||||
|
await _until(func(): return world.dead_waiting, 3.0)
|
||||||
|
await _until(func(): return not world.dead_waiting, 6.0)
|
||||||
|
await _sleep(0.4)
|
||||||
|
check(player.inventory.count("flint") == 4 and _graves() == graves0, "keep: she wakes with her flint, and there's no grave")
|
||||||
|
C.say("/set death_penalty grave")
|
||||||
|
await _sleep(0.4)
|
||||||
|
|
||||||
|
func _graves() -> int:
|
||||||
|
return WN.pieces.values().filter(func(p): return is_instance_valid(p) and p.item_id == "grave").size()
|
||||||
|
|
||||||
|
# --- clock and weather ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func _clock_and_weather() -> void:
|
||||||
|
print("the clock and the weather")
|
||||||
|
C.say("/time 22:30")
|
||||||
|
await _until(func(): return absf(world.day_night.hours() - 22.5) < 0.2, 2.0)
|
||||||
|
check(absf(world.day_night.hours() - 22.5) < 0.2, "/time 22:30: it's night for Alice (%s)" % world.day_night.clock_text())
|
||||||
|
var life := await _bob({"do": "life"})
|
||||||
|
check(absf(float(life.get("hours", 0.0)) - 22.5) < 0.2, "and for Bob (%.2f h)" % float(life.get("hours", 0.0)))
|
||||||
|
C.say("/weather storm")
|
||||||
|
await _until(func(): return world.weather.state == "storm", 3.0)
|
||||||
|
life = await _bob({"do": "life"})
|
||||||
|
check(world.weather.state == "storm" and String(life.get("weather", "")) == "storm", "/weather storm: a storm for both (Alice %s, Bob %s)" % [world.weather.state, life.get("weather", "?")])
|
||||||
|
C.say("/weather auto")
|
||||||
|
await _sleep(0.4)
|
||||||
|
check(world.weather.override == "", "/weather auto: back to the island's own weather")
|
||||||
|
|
||||||
|
# --- kick, ban ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _kick_and_ban() -> void:
|
||||||
|
print("kick and ban")
|
||||||
|
C.say("/kick Bob")
|
||||||
|
await _until(func(): return Net.players.values().all(func(p): return p.name != "Bob"), 4.0)
|
||||||
|
check(Net.players.values().all(func(p): return p.name != "Bob"), "/kick Bob: he's gone")
|
||||||
|
var b := await _new_bob()
|
||||||
|
check(bool(b.get("ok", false)), "he can come back")
|
||||||
|
C.say("/ban Bob")
|
||||||
|
await _until(func(): return Net.players.values().all(func(p): return p.name != "Bob"), 4.0)
|
||||||
|
check(Net.players.values().all(func(p): return p.name != "Bob"), "/ban Bob: gone again")
|
||||||
|
b = await _new_bob()
|
||||||
|
check(not bool(b.get("ok", true)) and String(b.get("why", "")).contains("banned"), "and can't come back (%s)" % b.get("why", ""))
|
||||||
|
C.say("/unban Bob")
|
||||||
|
await _sleep(0.5)
|
||||||
|
b = await _new_bob()
|
||||||
|
check(bool(b.get("ok", false)), "/unban Bob: he can join again")
|
||||||
|
await _bob({"do": "leave"})
|
||||||
|
|
||||||
|
# --- helpers --------------------------------------------------------------------
|
||||||
|
|
||||||
|
## A fresh Bob (his own command folder; the same identity every time).
|
||||||
|
func _new_bob() -> Dictionary:
|
||||||
|
_bobs += 1
|
||||||
|
_bob_dir = "%s/b%d" % [BOBS, _bobs]
|
||||||
|
DirAccess.make_dir_recursive_absolute(_bob_dir)
|
||||||
|
_cmd = 0
|
||||||
|
_spawn(["--identity", DIR + "/bob", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", _bob_dir],
|
||||||
|
"res://tests/net_world_bot.gd")
|
||||||
|
return await _bob_result(0, 30.0)
|
||||||
|
|
||||||
|
func _bob_heard(line: String) -> bool:
|
||||||
|
for _i in range(15):
|
||||||
|
var h := await _bob({"do": "heard"})
|
||||||
|
if (h.get("heard", []) as Array).has(line):
|
||||||
|
return true
|
||||||
|
await _sleep(0.2)
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _bob_heard_prefix(start: String) -> bool:
|
||||||
|
for _i in range(15):
|
||||||
|
var h := await _bob({"do": "heard"})
|
||||||
|
if (h.get("heard", []) as Array).any(func(l): return String(l).begins_with(start)):
|
||||||
|
return true
|
||||||
|
await _sleep(0.2)
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _bob_slot(id: String) -> int:
|
||||||
|
var rep := await _bob({"do": "report"})
|
||||||
|
var inv: Array = rep.get("inv", [])
|
||||||
|
for i in range(inv.size()):
|
||||||
|
if inv[i] != null and inv[i].id == id:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
func _tp(at: Vector3) -> void:
|
||||||
|
PN.ask("debug_tp", [at + Vector3(0, 0.05, 0)])
|
||||||
|
await _until(func(): return player.global_position.distance_to(at) < 1.0, 2.0)
|
||||||
|
player.set_physics_process(false)
|
||||||
|
await _sleep(0.3)
|
||||||
|
|
||||||
|
func _give(id: String, n: int) -> int:
|
||||||
|
var before: int = player.inventory.count(id)
|
||||||
|
PN.ask("debug_give", [id, n])
|
||||||
|
await _until(func(): return player.inventory.count(id) >= before + n, 3.0)
|
||||||
|
return _slot_of(id)
|
||||||
|
|
||||||
|
func _clear() -> void:
|
||||||
|
PN.ask("debug_clear")
|
||||||
|
await _until(func(): return player.inventory.slots.all(func(s): return s == null), 3.0)
|
||||||
|
|
||||||
|
func _slot_of(id: String) -> int:
|
||||||
|
for i in range(player.inventory.size()):
|
||||||
|
var s = player.inventory.get_slot(i)
|
||||||
|
if s != null and s.id == id:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
func _until(cond: Callable, limit: float) -> bool:
|
||||||
|
var t := 0.0
|
||||||
|
while not cond.call() and t < limit:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
return cond.call()
|
||||||
|
|
||||||
|
static func _count(inv: Array, id: String) -> int:
|
||||||
|
var n := 0
|
||||||
|
for s in inv:
|
||||||
|
if s != null and s.id == id:
|
||||||
|
n += int(s.count)
|
||||||
|
return n
|
||||||
|
|
||||||
|
static func _v(p: Vector3) -> Array:
|
||||||
|
return [p.x, p.y, p.z]
|
||||||
|
|
||||||
|
func _bob(cmd: Dictionary) -> Dictionary:
|
||||||
|
_cmd += 1
|
||||||
|
var f := FileAccess.open("%s/cmd_%d.json" % [_bob_dir, _cmd], FileAccess.WRITE)
|
||||||
|
f.store_string(JSON.stringify(cmd))
|
||||||
|
f.close()
|
||||||
|
return await _bob_result(_cmd, 20.0)
|
||||||
|
|
||||||
|
func _bob_result(n: int, limit: float) -> Dictionary:
|
||||||
|
var path := "%s/res_%d.json" % [_bob_dir, n]
|
||||||
|
var t := 0.0
|
||||||
|
while not FileAccess.file_exists(path) and t < limit:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
await _sleep(0.05)
|
||||||
|
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null
|
||||||
|
return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n, "heard": [], "inv": [], "pieces": {}}
|
||||||
|
|
||||||
|
func _join() -> String:
|
||||||
|
var state := {"done": false, "reason": ""}
|
||||||
|
var ok := func(): state.done = true
|
||||||
|
var bad := func(why): state.done = true; state.reason = why
|
||||||
|
Net.joined.connect(ok)
|
||||||
|
Net.join_failed.connect(bad)
|
||||||
|
Net.join("127.0.0.1:%d" % PORT)
|
||||||
|
var t := 0.0
|
||||||
|
while not state.done and t < 25.0:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
Net.joined.disconnect(ok)
|
||||||
|
Net.join_failed.disconnect(bad)
|
||||||
|
return state.reason if state.done else "test timed out"
|
||||||
|
|
||||||
|
func _spawn(user_args: Array, script: String = "") -> int:
|
||||||
|
var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")])
|
||||||
|
if not script.is_empty():
|
||||||
|
args.append_array(["--script", script])
|
||||||
|
args.append("--")
|
||||||
|
for x in user_args:
|
||||||
|
args.append(str(x))
|
||||||
|
args.append_array(["--exit-with", str(OS.get_process_id())])
|
||||||
|
var pid := OS.create_process(OS.get_executable_path(), args)
|
||||||
|
_pids.append(pid)
|
||||||
|
return pid
|
||||||
|
|
||||||
|
func _sleep(s: float) -> void:
|
||||||
|
await create_timer(s).timeout
|
||||||
|
|
||||||
|
static func _rm_tree(d: String) -> void:
|
||||||
|
var da := DirAccess.open(d)
|
||||||
|
if da == null:
|
||||||
|
return
|
||||||
|
for sub in da.get_directories():
|
||||||
|
_rm_tree(d.path_join(sub))
|
||||||
|
for f in da.get_files():
|
||||||
|
da.remove(f)
|
||||||
|
DirAccess.remove_absolute(d)
|
||||||
@@ -307,8 +307,12 @@ func _containers_and_fires() -> void:
|
|||||||
var s = _chest.inventory.get_slot(i)
|
var s = _chest.inventory.get_slot(i)
|
||||||
if s != null and s.id == "wood":
|
if s != null and s.id == "wood":
|
||||||
slot = i
|
slot = i
|
||||||
|
# (Chests are locked to their owner and friends by default: M2.6.)
|
||||||
|
root.get_node("Chat").say("/friend Bob")
|
||||||
|
await _sleep(0.5)
|
||||||
var took := await _bob({"do": "chest_take", "nid": nid, "slot": slot})
|
var took := await _bob({"do": "chest_take", "nid": nid, "slot": slot})
|
||||||
check(_count(took.get("inv", []), "wood") == 10, "Bob takes the wood out (shift-click)")
|
check(_count(took.get("inv", []), "wood") == 10, "Bob (her friend) takes the wood out (shift-click)")
|
||||||
|
root.get_node("Chat").say("/unfriend Bob") # (friends can loot each other's graves too)
|
||||||
await _sleep(0.3)
|
await _sleep(0.3)
|
||||||
check(_chest.inventory.count("wood") == 0 and _chest.inventory.count("stone") == 3, "and it's gone from Alice's view of the chest")
|
check(_chest.inventory.count("wood") == 0 and _chest.inventory.count("stone") == 3, "and it's gone from Alice's view of the chest")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user