diff --git a/lang/en.toml b/lang/en.toml index 413bffd..ce2e98f 100644 --- a/lang/en.toml +++ b/lang/en.toml @@ -133,6 +133,12 @@ other = "§aGave {{.Count}}x {{.Item}} to {{.Name}}§r" [cmd.give.received] other = "§aYou received {{.Count}}x {{.Item}}§r" +[cmd.spawnpoint.usage] +other = "Usage: /spawnpoint [player: target] [spawnPos: x y z]" + +[cmd.spawnpoint.success] +other = "Set {{.Name}}'s spawnpoint to {{.X}}, {{.Y}}, {{.Z}}" + [cmd.difficulty.usage] other = "§cUsage: /difficulty §r" diff --git a/server/cmd/default/command_test.v b/server/cmd/default/command_test.v index c96455d..24b8d83 100644 --- a/server/cmd/default/command_test.v +++ b/server/cmd/default/command_test.v @@ -46,6 +46,10 @@ mut: pos_z f32 cleared bool given_id string + spawn_x f32 + spawn_y f32 + spawn_z f32 + spawn_set bool given_count int given_ok bool = true whitelisted []string @@ -123,6 +127,13 @@ fn (mut s RecordingSender) clear_inventory() { s.cleared = true } +fn (mut s RecordingSender) set_spawn_point(x f32, y f32, z f32) { + s.spawn_x = x + s.spawn_y = y + s.spawn_z = z + s.spawn_set = true +} + fn (mut s RecordingSender) give_item(id string, count int) bool { if !s.given_ok { return false @@ -390,7 +401,7 @@ fn test_available_commands_roundtrip() { mut sender := RecordingSender{} sender.perm.set_op(true) pkt := r.available_commands(sender) - assert pkt.commands.len == 14 + assert pkt.commands.len == 15 encoded := protocol.encode_packet_to_bytes(pkt) mut pool := proto.new_packet_pool() mut reader := serializer.new_reader(encoded) @@ -398,7 +409,7 @@ fn test_available_commands_roundtrip() { assert decoded.name() == 'AvailableCommandsPacket' mut available := proto.AvailableCommandsPacket{} decode_into(pkt, mut available)! - assert available.commands.len == 14 + assert available.commands.len == 15 assert available.commands[0].alias_enum == -1 assert available.commands[0].overloads.len == 1 } @@ -541,3 +552,45 @@ fn decode_into[T](p protocol.Packet, mut out T) ! { protocol.read_packet_header(mut r)! out.decode_payload(mut r)! } + +fn test_spawnpoint_defaults_to_the_senders_position() { + r := full_registry() + mut sender := RecordingSender{ + pos_x: 10 + pos_y: 64 + pos_z: -3 + } + sender.perm.set_op(true) + r.dispatch('/spawnpoint', mut sender, base_ctx())! + assert sender.spawn_set + assert sender.spawn_x == 10 + assert sender.spawn_y == 64 + assert sender.spawn_z == -3 +} + +fn test_spawnpoint_takes_explicit_coordinates() { + r := full_registry() + mut target := RecordingSender{ + sender_name: 'Alex' + } + mut sender := RecordingSender{ + peers: { + 'alex': cmd.Sender(&target) + } + } + sender.perm.set_op(true) + r.dispatch('/spawnpoint Alex 1 2 3', mut sender, base_ctx())! + assert target.spawn_set + assert target.spawn_x == 1 + assert target.spawn_y == 2 + assert target.spawn_z == 3 + assert !sender.spawn_set +} + +fn test_spawnpoint_denied_without_op() { + r := full_registry() + mut sender := RecordingSender{} + r.dispatch('/spawnpoint', mut sender, base_ctx())! + assert !sender.spawn_set + assert sender.messages[0].contains('permission') +} diff --git a/server/cmd/default/register.v b/server/cmd/default/register.v index 7df611f..5dee800 100644 --- a/server/cmd/default/register.v +++ b/server/cmd/default/register.v @@ -13,6 +13,7 @@ pub fn register_all(mut r cmd.Registry) { r.register(TeleportCommand{}) r.register(ClearCommand{}) r.register(GiveCommand{}) + r.register(SpawnpointCommand{}) r.register(DifficultyCommand{}) r.register(SayCommand{}) r.register(TitleCommand{}) diff --git a/server/cmd/default/spawnpoint.v b/server/cmd/default/spawnpoint.v new file mode 100644 index 0000000..9370e03 --- /dev/null +++ b/server/cmd/default/spawnpoint.v @@ -0,0 +1,87 @@ +module default + +import strconv +import server.permission +import server.cmd + +pub struct SpawnpointCommand {} + +pub fn (c SpawnpointCommand) name() string { + return 'spawnpoint' +} + +pub fn (c SpawnpointCommand) description() string { + return 'Sets where a player respawns' +} + +pub fn (c SpawnpointCommand) aliases() []string { + return [] +} + +pub fn (c SpawnpointCommand) permission() string { + return permission.command_spawnpoint +} + +pub fn (c SpawnpointCommand) arguments() []cmd.Argument { + return [ + cmd.StringArgument{ + arg_name: 'player' + arg_optional: true + }, + cmd.IntArgument{ + arg_name: 'x' + arg_optional: true + }, + cmd.IntArgument{ + arg_name: 'y' + arg_optional: true + }, + cmd.IntArgument{ + arg_name: 'z' + arg_optional: true + }, + ] +} + +// execute takes the target's current position when no coordinates are given, +// which is what the command is for most of the time. +pub fn (c SpawnpointCommand) execute(mut sender cmd.Sender, ctx cmd.Context) ! { + mut target := sender + if ctx.args.len > 0 { + target_name := ctx.args[0] + target = sender.find_player(target_name) or { + sender.send_message(ctx.lang.tf('cmd.player_not_found', { + 'Name': target_name + }))! + return + } + } else if !sender.is_player() { + sender.send_message(ctx.lang.t('cmd.player_only'))! + return + } + mut pos := target.position() + if ctx.args.len >= 4 { + x := strconv.atoi(ctx.args[1]) or { + sender.send_message(ctx.lang.t('cmd.spawnpoint.usage'))! + return + } + y := strconv.atoi(ctx.args[2]) or { + sender.send_message(ctx.lang.t('cmd.spawnpoint.usage'))! + return + } + z := strconv.atoi(ctx.args[3]) or { + sender.send_message(ctx.lang.t('cmd.spawnpoint.usage'))! + return + } + pos.x = f32(x) + pos.y = f32(y) + pos.z = f32(z) + } + target.set_spawn_point(pos.x, pos.y, pos.z) + sender.send_message(ctx.lang.tf('cmd.spawnpoint.success', { + 'Name': target.name() + 'X': int(pos.x).str() + 'Y': int(pos.y).str() + 'Z': int(pos.z).str() + }))! +} diff --git a/server/permission/permission.v b/server/permission/permission.v index 01c5c5e..ea834f1 100644 --- a/server/permission/permission.v +++ b/server/permission/permission.v @@ -37,6 +37,7 @@ pub const command_teleport_other = 'vedrock.cmd.teleport.other' pub const command_clear_self = 'vedrock.cmd.clear.self' pub const command_clear_other = 'vedrock.cmd.clear.other' pub const command_give = 'vedrock.cmd.give' +pub const command_spawnpoint = 'vedrock.cmd.spawnpoint' pub const command_difficulty = 'vedrock.cmd.difficulty' pub const command_say = 'vedrock.cmd.say' pub const command_title = 'vedrock.cmd.title' @@ -120,6 +121,11 @@ fn new_registry() &Registry { description: 'Allows giving items to players' default: .op }) + r.register(Permission{ + name: command_spawnpoint + description: 'Allows setting where a player respawns' + default: .op + }) r.register(Permission{ name: command_difficulty description: "Allows changing the world's difficulty" diff --git a/server/player/player.v b/server/player/player.v index adafda0..24d8173 100644 --- a/server/player/player.v +++ b/server/player/player.v @@ -49,6 +49,10 @@ mut: effects effect.Manager has_last_death bool last_death_pos types.Vector3 + // spawn_point is where this player respawns. Unset until they sleep in a + // bed or an operator places it, and the world spawn answers until then. + has_spawn_point bool + spawn_point types.Vector3 air_supply_ticks i64 = max_air_supply_ticks fire_ticks i64 // give_next_slot round robins give_item across the hotbar. Only the diff --git a/server/player/playerdb/player.v b/server/player/playerdb/player.v index 952a92f..751c9ef 100644 --- a/server/player/playerdb/player.v +++ b/server/player/playerdb/player.v @@ -15,17 +15,21 @@ pub mut: pub struct PlayerData { pub mut: - x f32 - y f32 - z f32 - yaw f32 - pitch f32 - gamemode int - items []InvItem - has_last_death bool - last_death_x f32 - last_death_y f32 - last_death_z f32 + x f32 + y f32 + z f32 + yaw f32 + pitch f32 + gamemode int + items []InvItem + has_spawn_point bool + spawn_x f32 + spawn_y f32 + spawn_z f32 + has_last_death bool + last_death_x f32 + last_death_y f32 + last_death_z f32 } // safe_key strips anything that could let a key (which may come from an diff --git a/server/player/spawn_point.v b/server/player/spawn_point.v new file mode 100644 index 0000000..b12e2e3 --- /dev/null +++ b/server/player/spawn_point.v @@ -0,0 +1,33 @@ +module player + +import bedrock_v.protocol.types + +// spawn_point is where this player comes back after dying, or none when they +// have never set one and the world's own spawn still decides. +pub fn (p &Player) spawn_point() ?types.Vector3 { + mut m := p.state_mutex + m.lock() + defer { + m.unlock() + } + if !p.has_spawn_point { + return none + } + return p.spawn_point +} + +pub fn (mut p Player) set_spawn_point(pos types.Vector3) { + p.state_mutex.lock() + p.spawn_point = pos + p.has_spawn_point = true + p.state_mutex.unlock() +} + +// clear_spawn_point sends the player back to the world spawn, as a bed that no +// longer exists does. +pub fn (mut p Player) clear_spawn_point() { + p.state_mutex.lock() + p.has_spawn_point = false + p.spawn_point = types.Vector3{} + p.state_mutex.unlock() +} diff --git a/server/player/spawn_point_test.v b/server/player/spawn_point_test.v new file mode 100644 index 0000000..083db69 --- /dev/null +++ b/server/player/spawn_point_test.v @@ -0,0 +1,27 @@ +module player + +import bedrock_v.protocol.types + +fn test_a_player_starts_without_a_spawn_point() { + p := new_player() + if _ := p.spawn_point() { + assert false, 'a fresh player already had a spawn point' + } +} + +fn test_setting_and_clearing_a_spawn_point() { + mut p := new_player() + p.set_spawn_point(types.Vector3{1.5, 64.0, -2.5}) + point := p.spawn_point() or { + assert false, 'spawn point was not recorded' + return + } + assert point.x == 1.5 + assert point.y == 64.0 + assert point.z == -2.5 + + p.clear_spawn_point() + if _ := p.spawn_point() { + assert false, 'spawn point survived being cleared' + } +} diff --git a/server/player/view.v b/server/player/view.v index ea131d6..7726b5b 100644 --- a/server/player/view.v +++ b/server/player/view.v @@ -25,6 +25,8 @@ mut: teleport(x f32, y f32, z f32) clear_inventory() give_item(id string, count int) bool + // set_spawn_point moves where this player comes back after dying. + set_spawn_point(x f32, y f32, z f32) send_form(f form.Form) ! send_scoreboard(board &scoreboard.Scoreboard) remove_scoreboard() diff --git a/server/session/combat.v b/server/session/combat.v index 4593cae..97f69b3 100644 --- a/server/session/combat.v +++ b/server/session/combat.v @@ -285,12 +285,12 @@ fn (mut s NetworkSession) apply_respawn(mut tx worldrt.WorldTx) { } s.player.set_dead(false) s.player.set_health(20.0) - spawn_y := s.generator.spawn_y() + spawn_pos := s.respawn_position() mut ctx := event.new_context(player.RespawnData{ player: s.player - x: 0.0 - y: f32(spawn_y) + player_eye_height - z: 0.0 + x: spawn_pos.x + y: spawn_pos.y + z: spawn_pos.z }) s.player.handler.on_player_respawn(mut ctx) s.player.reset_position(types.Vector3{ctx.val.x, ctx.val.y, ctx.val.z}) diff --git a/server/session/console.v b/server/session/console.v index c633c16..b769c12 100644 --- a/server/session/console.v +++ b/server/session/console.v @@ -84,6 +84,8 @@ fn (mut c ConsoleSender) give_item(_ string, _ int) bool { return false } +fn (mut c ConsoleSender) set_spawn_point(_ f32, _ f32, _ f32) {} + fn (mut c ConsoleSender) send_form(_ form.Form) ! { return error('the console cannot display forms') } diff --git a/server/session/items.v b/server/session/items.v index 638805b..15acfa1 100644 --- a/server/session/items.v +++ b/server/session/items.v @@ -255,18 +255,24 @@ fn (mut s NetworkSession) save_player_data() { } } current := s.player.movement() + spawn_point := s.player.spawn_point() + saved_spawn := spawn_point or { types.Vector3{} } mut provider := s.hub.player_data_provider provider.save(s.player_key(), playerdb.PlayerData{ - x: current.position.x - y: current.position.y - z: current.position.z - yaw: current.yaw - pitch: current.pitch - gamemode: player.gamemode_to_wire(s.player.game_mode()) - items: items - has_last_death: s.player.has_last_death() - last_death_x: s.player.last_death_pos().x - last_death_y: s.player.last_death_pos().y - last_death_z: s.player.last_death_pos().z + x: current.position.x + y: current.position.y + z: current.position.z + yaw: current.yaw + pitch: current.pitch + gamemode: player.gamemode_to_wire(s.player.game_mode()) + items: items + has_spawn_point: spawn_point != none + spawn_x: saved_spawn.x + spawn_y: saved_spawn.y + spawn_z: saved_spawn.z + has_last_death: s.player.has_last_death() + last_death_x: s.player.last_death_pos().x + last_death_y: s.player.last_death_pos().y + last_death_z: s.player.last_death_pos().z }) or { s.log.warn('Failed to save player ${s.player_key()}: ${err}') } } diff --git a/server/session/sleep.v b/server/session/sleep.v new file mode 100644 index 0000000..56f8313 --- /dev/null +++ b/server/session/sleep.v @@ -0,0 +1,74 @@ +module session + +import bedrock_v.protocol.types +import server.block +import server.world +import server.worldrt + +// bed_block is the one block id every bed colour shares; the colour lives in +// the block entity rather than in the block itself. +const bed_block = 'minecraft:bed' + +// is_bed reports whether a block runtime id is a bed. +fn is_bed(block_id int) bool { + b := block.get(block_id) or { return false } + return b.identifier() == bed_block +} + +// use_bed is what right clicking a bed does: it becomes the player's spawn +// point. Beds do not work outside the overworld, which the client already +// knows, so the attempt is refused rather than silently accepted. +fn use_bed(mut tx worldrt.WorldTx, mut s NetworkSession, pos types.BlockPosition) bool { + if tx.wr.world.dimension.id != world.overworld.id { + s.player.send_translation('%tile.bed.noSleep', []) + return true + } + bed_side := types.Vector3{f32(pos.x) + 0.5, f32(pos.y) + 1.0, f32(pos.z) + 0.5} + s.player.set_spawn_point(bed_side) + s.player.send_translation('%tile.bed.respawnSet', []) + return true +} + +// respawn_position is where a player comes back: their own spawn point when +// they have one that is still somewhere they can stand, and the world's spawn +// otherwise. A bed that has since been mined into is not a place to arrive. +fn (s &NetworkSession) respawn_position() types.Vector3 { + world_spawn := types.Vector3{0.0, f32(s.generator.spawn_y()) + player_eye_height, 0.0} + own := s.player.spawn_point() or { return world_spawn } + position := types.Vector3{own.x, own.y + player_eye_height, own.z} + if !safe_player_position_in_world(s.world, s.generator, position) { + return world_spawn + } + return position +} + +// PlayerSpawnPointTask sets a player's spawn point on their own world runtime, +// since the caller may be another player's session thread or the console. +struct PlayerSpawnPointTask { + runtime_id u64 + epoch i64 + pos types.Vector3 +} + +fn (t PlayerSpawnPointTask) name() string { + return 'PlayerSpawnPointTask' +} + +fn (t PlayerSpawnPointTask) run(mut tx worldrt.WorldTx) { + mut target := player_for_epoch(mut tx, t.runtime_id, t.epoch) or { return } + target.player.set_spawn_point(t.pos) +} + +// set_spawn_point is the View entry point for moving where this player comes +// back after dying. +pub fn (mut s NetworkSession) set_spawn_point(x f32, y f32, z f32) { + mut wr := s.current_world_runtime() + if isnil(wr) { + return + } + wr.submit(PlayerSpawnPointTask{ + runtime_id: s.runtime_id + epoch: s.world_binding().epoch + pos: types.Vector3{x, y, z} + }) +} diff --git a/server/session/spawn.v b/server/session/spawn.v index 3d949ac..a14b294 100644 --- a/server/session/spawn.v +++ b/server/session/spawn.v @@ -133,6 +133,9 @@ fn (mut s NetworkSession) resolve_spawn_state() SpawnState { yaw = data.yaw s.player.set_loaded_items(data.items) s.player.set_game_mode(player.gamemode_from_wire(data.gamemode)) + if data.has_spawn_point { + s.player.set_spawn_point(types.Vector3{data.spawn_x, data.spawn_y, data.spawn_z}) + } if data.has_last_death { s.player.set_last_death(types.Vector3{data.last_death_x, data.last_death_y, data.last_death_z}) } diff --git a/server/session/world_place_ops.v b/server/session/world_place_ops.v index a9e270b..eb1f71b 100644 --- a/server/session/world_place_ops.v +++ b/server/session/world_place_ops.v @@ -88,7 +88,8 @@ fn merged_slab(tx &worldrt.WorldTx, existing_id int, placing_id int, click_face if existing_id == world.air.network_id || isnil(tx.wr.services.block_palette()) { return none } - return tx.wr.services.block_palette().merged_slab(existing_id, placing_id, click_face, click_y, clicked) + return tx.wr.services.block_palette().merged_slab(existing_id, placing_id, click_face, click_y, + clicked) } fn door_placement(mut tx worldrt.WorldTx, runtime_id int, pos types.BlockPosition, click_face int, yaw f32) ?world.DoorPlacement { @@ -218,6 +219,9 @@ fn interact_block(mut tx worldrt.WorldTx, mut s NetworkSession, pos types.BlockP return true } } + if is_bed(old_id) { + return use_bed(mut tx, mut s, pos) + } if isnil(tx.wr.services.block_palette()) { return false }