Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input

This commit is contained in:
Ludovic Marie committed 2026-10-27 21:13:23 +01:00
commit 64c174118a
46 files changed
+1798 -499

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
# Trusted reviewers: they pick the pull requests the Nightly holds with `/nightly 2640 2642` in any issue or pull
# request (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). Ludovic is always allowed and isn't
# listed. One GitHub login per line, with who added it and when. Removing a line revokes that login at once.
# A line with only `paused` stops every reviewer's `/nightly` (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
+8
View File
@@ -0,0 +1,8 @@
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
+2 -2
View File
@@ -1,5 +1,5 @@
name: nightly command
# `/nightly …` comments: the trusted reviewers in .github/nightly-reviewers.txt (and Ludovic) pick the pull requests
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
#
@@ -33,7 +33,7 @@ jobs:
persist-credentials: false
sparse-checkout: |
.github/CODEOWNERS
.github/nightly-reviewers.txt
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
sparse-checkout-cone-mode: false
- name: Run the command
+48
View File
@@ -0,0 +1,48 @@
name: triage command
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
# tools/ci/triage_command.py).
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever.
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: >-
github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/close') ||
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
tools/ci/triage_command.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/triage_command.py command
+1
View File
@@ -24,4 +24,5 @@ pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub mod who;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
+8
View File
@@ -0,0 +1,8 @@
//! Who an action came from or is aimed at: the player or a mob by id.
/// Who caused damage or is being targeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Who {
Player,
Mob(u32),
}
+1 -14
View File
@@ -3,6 +3,7 @@
//! the rain, nor through a blocked front, unless the hive is emptied in an
//! emergency), honey for every bee coming home with nectar. The host (the game
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
pub use crate::chunk::block_entity::{Beehive, Occupant};
use crate::mobs::{Mob, Species, bee};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::nbt::{Compound, Tag};
@@ -38,20 +39,6 @@ const IGNORED_BEE_TAGS: [&str; 25] = [
"UUID",
];
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
/// `BeehiveBlockEntity.BeeReleaseStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Release {
+1 -1
View File
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::containers::BlockEntity;
use crate::chunk::block_entity::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
+2 -13
View File
@@ -2,12 +2,13 @@
//! in vanilla, ported as tables), data-driven `minecraft:brewing` recipes from
//! `data/minecraft/recipe/brewing`, potion colours and tooltips, and
//! `BrewingStandBlockEntity.serverTick`.
pub use crate::chunk::block_entity::BrewingStand;
use crate::{
containers::Providers,
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path, sync::Arc};
use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
@@ -190,18 +191,6 @@ impl Brewing {
}
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// What a brewing tick asks the world to do.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BrewTick {
+3 -1
View File
@@ -6,6 +6,8 @@ use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::{Arc, OnceLock};
pub mod block_entity;
pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
@@ -149,7 +151,7 @@ pub struct Chunk {
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), crate::containers::BlockEntity)>,
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
pub block_names: Vec<((i32, i32, i32), String)>,
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
@@ -0,0 +1,409 @@
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
//! and its payloads). Plain data, as vanilla's block entities are world data;
//! their behaviour lives with the features that use them (`containers`,
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
use horizon_client_core::{text::Text, who::Who};
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{collections::HashMap, sync::Arc};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
/// by (`ResolvableProfile.nameOrId`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum NameOrId {
Name(String),
Id(u128),
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// One sign side (`SignText`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignText {
pub lines: [String; 4],
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SignData {
pub hanging: bool,
pub front: SignText,
pub back: SignText,
pub waxed: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Info {
pub event: &'static str,
pub distance: f32,
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
pub(crate) current: Option<(Info, i64)>,
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SculkKind {
Sensor,
Calibrated,
Shrieker,
Catalyst,
}
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
#[derive(Clone, Debug)]
pub struct SculkEntity {
pub kind: SculkKind,
pub vibration: Data,
/// `last_vibration_frequency` (sensors).
pub last_frequency: i32,
/// `warning_level` (shriekers).
pub warning_level: i32,
/// The catalyst's charge cursors.
pub spreader: SculkSpreader,
}
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
/// must have (inclusive ranges within 0..=15).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CustomSpawnRules {
pub block: (i32, i32),
pub sky: (i32, i32),
}
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
/// table (kept as loaded).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpawnData {
pub entity: Compound,
pub custom_spawn_rules: Option<CustomSpawnRules>,
pub equipment: Option<Tag>,
}
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Next {
Potential(usize),
Own(SpawnData),
}
/// `BaseSpawner`.
#[derive(Clone, Debug, PartialEq)]
pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
pub(crate) next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
pub max_nearby_entities: i32,
pub required_player_range: i32,
pub spawn_range: i32,
/// The client copy's delay (`spawnDelay` on the client level), which
/// speeds the spin up as the next spawn nears.
pub client_delay: i32,
pub spin: f64,
pub o_spin: f64,
}
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
pub spawn_range: i32,
pub total_mobs: f32,
pub simultaneous_mobs: f32,
pub total_mobs_added_per_player: f32,
pub simultaneous_mobs_added_per_player: f32,
pub ticks_between_spawn: i32,
pub spawn_potentials: Vec<(SpawnData, i32)>,
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ConfigSlot {
/// The value as loaded, written back as is; None for the default.
pub saved: Option<Tag>,
pub config: TrialConfig,
}
/// `TrialSpawner` with its `TrialSpawnerStateData`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialSpawner {
pub normal: ConfigSlot,
pub ominous: ConfigSlot,
pub target_cooldown_length: i32,
pub required_player_range: i32,
pub detected_players: Vec<Uuid>,
pub current_mobs: Vec<Uuid>,
pub cooldown_ends_at: i64,
pub next_mob_spawns_at: i64,
pub total_mobs_spawned: i32,
pub next_spawn_data: Option<SpawnData>,
pub ejecting_loot_table: Option<String>,
/// `isOminous`, set from the block on every server tick.
pub is_ominous: bool,
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
/// made once per loaded block entity and kept.
pub has_display: bool,
pub spin: f64,
pub o_spin: f64,
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<ItemStack>>,
}
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
pub loot_table: String,
pub activation_range: f64,
pub deactivation_range: f64,
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
pub key_item: Option<ItemStack>,
pub override_loot_table_to_display: Option<String>,
}
/// `VaultBlockEntity`: its config, server data and shared data.
#[derive(Clone, Debug, PartialEq)]
pub struct Vault {
pub config: VaultConfig,
/// `VaultServerData`, oldest rewarded player first.
pub rewarded_players: Vec<Uuid>,
pub state_updating_resumes_at: i64,
/// Popped from the end.
pub items_to_eject: Vec<ItemStack>,
pub total_ejections_needed: i32,
pub last_insert_fail_timestamp: i64,
/// `VaultSharedData`.
pub display_item: Option<ItemStack>,
pub connected_players: Vec<Uuid>,
pub connected_particles_range: f64,
/// The shared data changed since the last tick sent it.
pub shared_dirty: bool,
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
pub spin: f32,
pub o_spin: f32,
}
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
+1 -16
View File
@@ -1,6 +1,7 @@
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
//! count comparators read, the output it keeps, and the powered, automatic
//! and condition flags `CommandBlock.tick` runs on.
pub use crate::chunk::block_entity::CommandBlock;
use crate::{
save::{number, text},
text::Text,
@@ -76,22 +77,6 @@ pub fn automatic_block(name: &str) -> bool {
name == "minecraft:chain_command_block"
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
impl Default for CommandBlock {
fn default() -> Self {
Self {
+4 -170
View File
@@ -1,6 +1,10 @@
//! Container block entities: chests/barrels (27 slots) and the three furnace
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
@@ -8,12 +12,6 @@ use crate::{
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
@@ -34,21 +32,6 @@ impl FurnaceKind {
}
}
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
@@ -63,78 +46,6 @@ pub fn recipe_used_key(id: &str) -> String {
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<crate::brewing::BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(crate::signs::SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<crate::sculk::SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<crate::mobs::spawner::Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<crate::vault::Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
@@ -182,17 +93,6 @@ impl Campfire {
changed
}
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
impl Skull {
pub fn new(
profile: Option<horizon_world::nbt::Tag>,
@@ -208,18 +108,6 @@ impl Skull {
}
}
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<crate::skins::NameOrId>,
pub fallback: &'static str,
}
impl SkullSkin {
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
@@ -248,18 +136,6 @@ impl SkullSkin {
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
impl Crafter {
/// `slotCanBeDisabled`: only empty slots.
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
@@ -299,48 +175,6 @@ impl Crafter {
})
}
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<crate::banner::BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
impl BlockEntity {
pub fn for_block(name: &str) -> Option<Self> {
if let Some(hanging) = crate::signs::sign_kind(name) {
+59
View File
@@ -4339,6 +4339,14 @@ impl Game<'_> {
self.use_spyglass();
return Ok(true);
}
// BundleItem.use: startUsingItem, then onUseTick empties it.
if !on_block && info.bundle {
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.swing_arm();
return Ok(true);
}
if !on_block && &*stack.item == "minecraft:bow" {
if self.using.is_none()
&& (self.creative || self.projectile_slot("minecraft:bow").is_some())
@@ -4623,6 +4631,10 @@ impl Game<'_> {
self.tick_brush(slot, ticks);
return;
}
if info.bundle {
self.tick_bundle(slot, ticks);
return;
}
let Some(consumable) = info.consumable.as_ref() else {
self.using = None;
return;
@@ -4645,6 +4657,53 @@ impl Game<'_> {
self.using = Some((slot, ticks));
}
}
/// `BundleItem.onUseTick` and `getUseDuration` (200): the first tick and
/// then every second tick from the 11th on drop one stack out of the
/// bundle in `slot`; the use ends after 200 ticks.
fn tick_bundle(&mut self, slot: usize, ticks: u32) {
const USE_DURATION: u32 = 200;
let remaining = USE_DURATION - ticks.min(USE_DURATION);
if ticks == 0 || (remaining < USE_DURATION - 10 && remaining.is_multiple_of(2)) {
self.drop_bundle_content(slot);
}
self.using = (ticks + 1 < USE_DURATION).then_some((slot, ticks + 1));
}
/// `BundleItem.dropContent`: `removeOneItemFromBundle` and `Player.drop`,
/// then the drop sound and the used statistic.
fn drop_bundle_content(&mut self, slot: usize) {
let items = self.res.items();
let Some(mut bundle) = self.stack_in(slot).cloned() else {
return;
};
let Some(mut contents) = horizon_client::bundle::Contents::of(items, &bundle) else {
return;
};
let Some(removed) = contents.remove_one(items) else {
return;
};
contents.store(&mut bundle);
let item = Arc::clone(&bundle.item);
*self.stack_in_mut(slot) = Some(bundle);
let Some(position) = self.player.as_ref().map(|p| p.position) else {
return;
};
let at = [position.x, position.y, position.z];
// playRemoveOneSound, then Player.drop and playDropContentsSound at
// the player's block.
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio
.play(horizon_client::bundle::REMOVE_ONE, Some(at), 0.8, pitch);
self.player_drop(removed);
let block = BlockPos::containing(position);
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio.play(
horizon_client::bundle::DROP_CONTENTS,
Some([block.x, block.y, block.z].map(|v| f64::from(v) + 0.5)),
0.8,
pitch,
);
self.stats.add("used", &item, 1);
}
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
/// when its hand changed), a spyglass stops scoping.
+6 -1
View File
@@ -3658,7 +3658,12 @@ impl Game<'_> {
}
// Parrot.mobInteract: the owner makes a tame parrot on the ground
// sit or stand (poisonous food is handled apart).
Species::Parrot if m.extra.tame && m.on_ground && !parrot_poisonous => {
Species::Parrot
if m.extra.tame
&& m.extra.owner == Some(acting)
&& m.on_ground
&& !parrot_poisonous =>
{
m.extra.sitting = !m.extra.sitting;
self.world.modified.insert((
m.position.x.floor() as i32 >> 4,
+2 -2
View File
@@ -42,12 +42,12 @@ pub(super) fn player_for(
}
let targeting = m.ai.target == Some(Who::Player)
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
// A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they
// A tame wolf, cat or parrot sees its owner (`TamableAnimal.getOwner`), while they
// are in play, whoever is nearer. Not while it targets or was just hurt by
// a player: then `Who::Player` is that attacker, whoever it is, and the
// owner goals ignore it until the wolf lets go.
if !targeting
&& matches!(m.species(), Species::Wolf | Species::Cat)
&& matches!(m.species(), Species::Wolf | Species::Cat | Species::Parrot)
&& m.extra.tame
&& let Some(owner) = m.extra.owner
{
+11
View File
@@ -4910,7 +4910,10 @@ impl Game<'_> {
if self.using.is_some() {
return;
}
let bundle = self.res.items().get(&held.item).is_some_and(|i| i.bundle);
let start = match &*held.item {
// BundleItem.use: always starts; the server drops the contents.
_ if bundle => true,
// BowItem.use: with an arrow, or in creative.
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
// CrossbowItem.use: charging when not charged and with ammunition;
@@ -5013,6 +5016,14 @@ impl Game<'_> {
self.using = hit.and(Some((slot, elapsed))).filter(|_| elapsed < 200);
return;
}
// BundleItem.getUseDuration: 200 ticks, the server drops the contents.
if stack
.as_ref()
.is_some_and(|s| self.res.items().get(&s.item).is_some_and(|i| i.bundle))
{
self.using = (in_hand && ticks + 1 < 200).then_some((slot, ticks + 1));
return;
}
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go.
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
+1 -6
View File
@@ -504,12 +504,7 @@ impl MobTypes {
Ok(Self { types: out })
}
}
/// Who caused damage or is being targeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Who {
Player,
Mob(u32),
}
pub use horizon_client_core::who::Who;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DamageKind {
PlayerAttack,
+2 -42
View File
@@ -12,24 +12,11 @@ use super::{
spawn_rules::{self, Reason},
spawn_selection::{self, Category, NaturalLevel},
};
use crate::chunk::block_entity::Next;
pub use crate::chunk::block_entity::{CustomSpawnRules, SpawnData, Spawner};
use horizon_core::{geometry::Aabb, geometry::Vec3, random::RandomSource};
use horizon_world::nbt::{Compound, JavaString, Tag};
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
/// must have (inclusive ranges within 0..=15).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CustomSpawnRules {
pub block: (i32, i32),
pub sky: (i32, i32),
}
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
/// table (kept as loaded).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpawnData {
pub entity: Compound,
pub custom_spawn_rules: Option<CustomSpawnRules>,
pub equipment: Option<Tag>,
}
impl SpawnData {
/// The `SpawnData` constructor: an `id` that is a valid identifier is
/// stored normalised (`zombie` becomes `minecraft:zombie`), any other is
@@ -228,33 +215,6 @@ fn encode_range((lo, hi): (i32, i32)) -> Tag {
}
}
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
enum Next {
Potential(usize),
Own(SpawnData),
}
/// `BaseSpawner`.
#[derive(Clone, Debug, PartialEq)]
pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
pub max_nearby_entities: i32,
pub required_player_range: i32,
pub spawn_range: i32,
/// The client copy's delay (`spawnDelay` on the client level), which
/// speeds the spin up as the next spawn nears.
pub client_delay: i32,
pub spin: f64,
pub o_spin: f64,
}
impl Default for Spawner {
fn default() -> Self {
Self {
@@ -13,6 +13,7 @@ use super::{
spawn_selection,
spawner::{self, SpawnData, SpawnerLevel, SpawnerSpawn},
};
pub use crate::chunk::block_entity::{ConfigSlot, TrialConfig, TrialSpawner};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::nbt::{Compound, JavaString, Tag};
use std::{collections::HashMap, path::Path, sync::OnceLock};
@@ -32,19 +33,6 @@ const TICKS_BETWEEN_ITEM_SPAWNERS: i64 = 160;
/// Bad Omen.
pub const TRIAL_OMEN_TICKS_PER_LEVEL: i32 = 18000;
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
pub spawn_range: i32,
pub total_mobs: f32,
pub simultaneous_mobs: f32,
pub total_mobs_added_per_player: f32,
pub simultaneous_mobs_added_per_player: f32,
pub ticks_between_spawn: i32,
pub spawn_potentials: Vec<(SpawnData, i32)>,
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
impl Default for TrialConfig {
/// `TrialSpawnerConfig.Builder`'s defaults.
fn default() -> Self {
@@ -202,15 +190,6 @@ fn identifier(t: Option<&Tag>) -> Option<String> {
}
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ConfigSlot {
/// The value as loaded, written back as is; None for the default.
pub saved: Option<Tag>,
pub config: TrialConfig,
}
/// `TrialSpawnerState`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TrialState {
@@ -335,31 +314,6 @@ pub trait TrialSpawnerLevel: SpawnerLevel {
fn seed(&self) -> i64;
}
/// `TrialSpawner` with its `TrialSpawnerStateData`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialSpawner {
pub normal: ConfigSlot,
pub ominous: ConfigSlot,
pub target_cooldown_length: i32,
pub required_player_range: i32,
pub detected_players: Vec<Uuid>,
pub current_mobs: Vec<Uuid>,
pub cooldown_ends_at: i64,
pub next_mob_spawns_at: i64,
pub total_mobs_spawned: i32,
pub next_spawn_data: Option<SpawnData>,
pub ejecting_loot_table: Option<String>,
/// `isOminous`, set from the block on every server tick.
pub is_ominous: bool,
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
/// made once per loaded block entity and kept.
pub has_display: bool,
pub spin: f64,
pub o_spin: f64,
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<crate::items::ItemStack>>,
}
impl Default for TrialSpawner {
fn default() -> Self {
Self {
+1 -42
View File
@@ -1,6 +1,7 @@
//! Game events and the vibration system (`GameEvent`, `VibrationSystem`,
//! `VibrationSelector`) shared by sculk sensors, shriekers and wardens, the
//! sculk block entities and the per-player warden spawn tracker.
pub use crate::chunk::block_entity::{Data, Info, SculkEntity, SculkKind, Selector};
use crate::mobs::Who;
use horizon_core::{
geometry::{Direction, Vec3, traverse_blocks},
@@ -185,20 +186,6 @@ pub fn valid_vibration(listens: bool, event: &str, ctx: &Context) -> bool {
!ctx.dampening_state
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Info {
pub event: &'static str,
pub distance: f32,
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
current: Option<(Info, i64)>,
}
impl Selector {
pub fn add_candidate(&mut self, new: Info, tick: i64) {
if self.should_replace(&new, tick) {
@@ -233,13 +220,6 @@ impl Selector {
self.current
}
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
}
/// What one `VibrationSystem.Ticker.tick` did.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Tick {
@@ -382,27 +362,6 @@ pub fn is_occluded(
true
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SculkKind {
Sensor,
Calibrated,
Shrieker,
Catalyst,
}
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
#[derive(Clone, Debug)]
pub struct SculkEntity {
pub kind: SculkKind,
pub vibration: Data,
/// `last_vibration_frequency` (sensors).
pub last_frequency: i32,
/// `warning_level` (shriekers).
pub warning_level: i32,
/// The catalyst's charge cursors.
pub spreader: SculkSpreader,
}
impl SculkEntity {
pub fn new(kind: SculkKind) -> Self {
Self {
+1 -16
View File
@@ -1,6 +1,7 @@
//! Sign block entities (`SignBlockEntity`, `SignText`): two four-line text
//! sides with a dye colour and glow, saved in vanilla's `front_text` /
//! `back_text` NBT layout.
pub use crate::chunk::block_entity::{SignData, SignText};
use horizon_world::nbt::Tag;
/// `DyeColor` names and `getTextColor` values by id.
@@ -24,14 +25,6 @@ pub const DYE_TEXT: [(&str, u32); 16] = [
];
const BLACK: u8 = 15;
/// One sign side (`SignText`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignText {
pub lines: [String; 4],
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
}
impl Default for SignText {
fn default() -> Self {
Self {
@@ -61,14 +54,6 @@ impl SignText {
}
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SignData {
pub hanging: bool,
pub front: SignText,
pub back: SignText,
pub waxed: bool,
}
impl SignData {
pub fn id(&self) -> &'static str {
if self.hanging {
+1 -7
View File
@@ -160,13 +160,7 @@ pub(crate) fn nbt_text(tag: Option<&horizon_world::nbt::Tag>) -> Option<String>
}
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
/// by (`ResolvableProfile.nameOrId`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum NameOrId {
Name(String),
Id(u128),
}
pub use crate::chunk::block_entity::NameOrId;
/// `GameProfile` as `fetchProfile` returns it: the account's id, name and
/// property map (its `textures` property is the skin).
+1 -33
View File
@@ -7,6 +7,7 @@
//! the next one. The level's random draws the display item and throws the
//! rewards; the loot tables go through the caller, so the game and a
//! differential test world roll them the same way.
pub use crate::chunk::block_entity::{Vault, VaultConfig};
use crate::items::ItemStack;
use horizon_core::random::RandomSource;
use horizon_world::nbt::{Compound, Tag};
@@ -26,17 +27,6 @@ const UNLOCKING_DELAY: i64 = 14;
const INSERT_FAIL_SOUND_BUFFER: i64 = 15;
const STATE_DELAY: i64 = 20;
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
pub loot_table: String,
pub activation_range: f64,
pub deactivation_range: f64,
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
pub key_item: Option<ItemStack>,
pub override_loot_table_to_display: Option<String>,
}
impl Default for VaultConfig {
/// `VaultConfig.DEFAULT`: the trial key and the trial chambers' reward.
fn default() -> Self {
@@ -169,28 +159,6 @@ pub trait VaultLevel {
/// the table has one.
pub type Roll<'a, R> = dyn FnMut(&str, &mut R, bool) -> Vec<ItemStack> + 'a;
/// `VaultBlockEntity`: its config, server data and shared data.
#[derive(Clone, Debug, PartialEq)]
pub struct Vault {
pub config: VaultConfig,
/// `VaultServerData`, oldest rewarded player first.
pub rewarded_players: Vec<Uuid>,
pub state_updating_resumes_at: i64,
/// Popped from the end.
pub items_to_eject: Vec<ItemStack>,
pub total_ejections_needed: i32,
pub last_insert_fail_timestamp: i64,
/// `VaultSharedData`.
pub display_item: Option<ItemStack>,
pub connected_players: Vec<Uuid>,
pub connected_particles_range: f64,
/// The shared data changed since the last tick sent it.
pub shared_dirty: bool,
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
pub spin: f32,
pub o_spin: f32,
}
impl Default for Vault {
fn default() -> Self {
Self {
+3 -1
View File
@@ -50,10 +50,12 @@ impl Weight {
/// The sounds of `BundleItem` (`Entity.playSound` at the player): insert
/// and remove one at volume 0.8 and pitch `0.8 + random * 0.4`, a failed
/// insert at 1, 1.
/// insert at 1, 1; the drop of its contents is played at the player's block
/// like remove one.
pub const INSERT: &str = "minecraft:item.bundle.insert";
pub const INSERT_FAIL: &str = "minecraft:item.bundle.insert_fail";
pub const REMOVE_ONE: &str = "minecraft:item.bundle.remove_one";
pub const DROP_CONTENTS: &str = "minecraft:item.bundle.drop_contents";
fn max_stack(items: &Items, stack: &ItemStack) -> u32 {
items.get(&stack.item).map_or(64, |i| i.max_stack).max(1)
+2 -1
View File
@@ -36,11 +36,12 @@ MCP: `remove`, needs `confirm: true`.
## `horizon-launcher prepare`
Prepare a version's resources from your own Minecraft install.
Prepare a version's resources from your own Minecraft install, or from Mojang.
horizon-launcher prepare
- `--minecraft`: The game folder of the official launcher, Prism Launcher, MultiMC, the Modrinth App, ATLauncher or GDLauncher (default: the launcher's, then every launcher's default)
- `--download`: Download Minecraft from Mojang for the signed-in account that owns it, instead of using an install
MCP: `prepare`.
+18 -1
View File
@@ -77,6 +77,11 @@ possible; the launcher just doesn't offer it on its own.
again for each account, the selected one first, until one owns the game: the token renewal
(below) if due, then entitlements and profile. Without an owner the game doesn't start and Play
says "Sign in with a Microsoft account that owns Minecraft: Java Edition to play", with Sign in.
- **The window says it once.** A player without an owner (one who played offline until now) sees
the gate's screen once when the launcher opens: why Horizon needs the account ("like other
launchers"), that worlds, offline names and settings stay where they are, and Sign in, which shows
the device code on the same screen. It comes back whenever Play is refused; Not now closes it,
and Home keeps a Sign in row (`launcher/src/owner_page.rs`).
- **The answer lives with the tokens.** Each account's keychain item keeps the last answer
(`owns`), so no plain file can say yes. With no network (or a sign-in that can't be renewed) the
last answer stands: being offline never blocks a player who passed before, and the game starts
@@ -88,6 +93,17 @@ possible; the launcher just doesn't offer it on its own.
the launcher's tests use accounts in a memory store (`MemoryStore`, and `HORIZON_NO_KEYCHAIN`
for the CLI and MCP tests, which then have no owner).
## Downloading the game for an owner
Decided 2026-10-27. This is the only exception to "Horizon never downloads the game", and it
applies only on the player's machine, for an account the owner gate just asked about. The
launcher's "Download and prepare" (`horizon-launcher prepare --download`) fetches the pinned
version straight from Mojang (`launcher/src/mojang.rs`): the version file, the client jar,
libraries, the asset index and the assets, each checked against the size and sha1 Mojang lists.
They go to `minecraft/` in the launcher's data folder, and the preparation runs as usual. The
download can be cancelled and goes on where it stopped. CI, agents, harnesses and the repo never
download or keep Mojang's files.
Refresh: the Minecraft token is renewed from the Microsoft refresh token (steps 2-5 with
`grant_type=refresh_token`) when it has less than an hour left, before every launch; steps 6-7 run
at every launch (the owner gate). A refused
@@ -100,7 +116,8 @@ one is selected. "Sign out" deletes the account's secrets from the keychain.
ownership answer) go to the OS
keychain, one item per account: Keychain on macOS, Credential Manager on Windows, the Secret
Service (GNOME Keyring, KWallet) on Linux. Service name `dev.playhorizon.launcher`, account = the
Minecraft UUID.
Minecraft UUID. Credential Manager takes at most 2560 bytes per item, which the secrets of a sign-in
exceed: on macOS and Windows a longer secret is split over the items `<uuid>`, `<uuid>.1`, `<uuid>.2`...
- `accounts.json` in the launcher's data folder keeps only public data: UUID, name, skin URL, which
account is selected. Never a token.
- No keychain available (a Linux box without a Secret Service, a locked keychain): sign-in still
+30 -4
View File
@@ -420,10 +420,36 @@ than main's head and holds something main lacks, otherwise main (`tools/ci/night
new publishes nothing. A Nightly can include pull requests not reviewed yet: that is what it is for, playing them
before the gate.
### Trusted reviewers pick the pull requests
### Trusted reviewers
One list, `.github/trusted-reviewers.txt`, governs two things for the people on it (kacpherek, rdbtCVS and
Online5277 since 2026-10-27) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
```text
/close closes an issue as completed; a pull request is closed without merging
/close not planned closes an issue as not planned
/close duplicate of #123 comments "Duplicate of #123", closes as a duplicate
/reopen
/label bug, good first issue existing labels only; never creates one
/unlabel bug
```
- **Where.** On the first line of a comment on the issue or pull request (`.github/workflows/triage-command.yml`,
`tools/ci/triage_command.py`). The workflow answers with one comment, `🤖 Triage: <action>, on behalf of @login`,
linking the comment that asked; a refusal answers too.
- **Refused.** Any label with `release` in its name (`release-now`, `release-blocker`, `skip-release`) and
`full-parity`; anything on Ludovic's own issues and pull requests; a merged pull request; a locked thread; more
than 60 actions per reviewer in 24 hours (counted from the workflow's own comments).
- **Safety.** Same as `/nightly`: main's copy of the workflow runs, no pull request's code is checked out or run,
the comment reaches the script as env vars, and the token has `issues` and `pull-requests` write only.
- **Ludovic stays in charge.** A one-line pull request removing a login from the list revokes it as soon as it merges
(the file is his under CODEOWNERS). A line `paused` stops `/nightly` and the triage commands for every reviewer;
his own still work.
### Trusted reviewers pick the Nightly's pull requests
The Nightly is where contributors test pull requests before the gate. The trusted reviewers listed in
`.github/nightly-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27) and Ludovic pick them with a
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27) and Ludovic pick them with a
comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `tools/ci/nightly_set.py`):
```text
@@ -458,9 +484,9 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
- **Ludovic stays in charge.**
- Ludovic's `/nightly clear` or `/nightly remove` always applies. A pull request he removed can only come back
when he adds it.
- A one-line pull request removing a login from `.github/nightly-reviewers.txt` revokes it as soon as it merges
- A one-line pull request removing a login from `.github/trusted-reviewers.txt` revokes it as soon as it merges
(the file is his under CODEOWNERS).
- A line `paused` in that file stops every reviewer's `/nightly`; his own still work.
- A line `paused` in that file stops every reviewer's `/nightly` (and triage commands); his own still work.
- Main, the gate and releases don't change: picks reach only players who turned Nightly on.
- **Jobs.** `pick` chooses or assembles the commit and writes the notes (each pull request in it with its title,
+9
View File
@@ -24,6 +24,15 @@ The opening, then Play ([clip](docs/opening.webm)):
preparation reuses checksum-verified game jars and libraries from the current
or another installed Horizon version, so the original Minecraft install is
only required when no valid imported copy exists.
- **Download and prepare**: for a signed-in account that owns the game (asked again first),
the launcher downloads the pinned version from Mojang itself (piston-meta, piston-data,
libraries.minecraft.net, resources.download.minecraft.net; never a mirror) into `minecraft/`
in its data folder, in the official launcher's layout: the version file, the client jar, this
system's libraries, the asset index and the assets. Every file must have the size and sha1
Mojang lists. The Play page shows how far it is and has Cancel; files already whole are kept,
so the next try goes on from there. **From my install** prepares from the player's own launcher
instead, and an install that lacks the version falls back to the download. Without an owner,
the Play page asks to sign in first. `horizon-launcher prepare --download`.
- **Mods**: search Modrinth (CurseForge support is coming soon) for resource
packs and modpacks, each marked Works, Partly or Doesn't run in
Horizon; Java mod jars are never offered. Install puts a pack and what it needs in
+10 -1
View File
@@ -167,5 +167,14 @@
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Connectez-vous avec un compte Microsoft qui possède Minecraft: Java Edition pour jouer",
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Horizon a besoin d'un compte Microsoft qui possède Minecraft: Java Edition, comme les autres launchers. Il permet aussi de jouer sur les serveurs en ligne avec votre nom et votre skin.",
"Sign in": "Se connecter",
"Sign in with Microsoft": "Se connecter avec Microsoft"
"Sign in with Microsoft": "Se connecter avec Microsoft",
"Not now": "Pas maintenant",
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers.": "Horizon a besoin d'un compte Microsoft qui possède Minecraft: Java Edition, comme les autres launchers.",
"Your worlds, offline names and settings stay where they are.": "Vos mondes, vos noms hors ligne et vos réglages restent où ils sont.",
"Open {link} in your browser and enter this code:": "Ouvrez {link} dans votre navigateur et entrez ce code :",
"From my install": "Depuis mon installation",
"Download and prepare": "Télécharger et préparer",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Pas préparé. Horizon peut télécharger Minecraft {version} depuis Mojang pour votre compte, ou utiliser votre propre installation.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Pas préparé. Connectez-vous d'abord avec le compte Microsoft qui possède Minecraft {version}.",
"Cancel": "Annuler"
}
+10 -1
View File
@@ -167,5 +167,14 @@
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Чтобы играть, войдите в аккаунт Microsoft, у которого есть Minecraft: Java Edition",
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Как и другим лаунчерам, Horizon нужен аккаунт Microsoft, у которого есть Minecraft: Java Edition. С ним вы также играете на онлайн-серверах под своим именем и со своим скином.",
"Sign in": "Войти",
"Sign in with Microsoft": "Войти через Microsoft"
"Sign in with Microsoft": "Войти через Microsoft",
"Not now": "Не сейчас",
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers.": "Как и другим лаунчерам, Horizon нужен аккаунт Microsoft, у которого есть Minecraft: Java Edition.",
"Your worlds, offline names and settings stay where they are.": "Ваши миры, офлайн-имена и настройки остаются на месте.",
"Open {link} in your browser and enter this code:": "Откройте {link} в браузере и введите этот код:",
"From my install": "Из моей установки",
"Download and prepare": "Скачать и подготовить",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Не подготовлено. Horizon может скачать Minecraft {version} у Mojang для вашего аккаунта или взять вашу установку.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Не подготовлено. Сначала войдите в аккаунт Microsoft, у которого есть Minecraft {version}.",
"Cancel": "Отмена"
}
+85 -11
View File
@@ -625,28 +625,102 @@ impl SecretStore for SystemStore {
}
}
/// Keychain on macOS, Credential Manager on Windows (the keyring crate).
/// Keychain on macOS, Credential Manager on Windows (the keyring crate). Credential Manager takes at
/// most 2560 bytes of UTF-16 per item and a sign-in's secrets are longer, so a secret is kept in
/// pieces: the first under the account's key, the rest under `key.1`, `key.2`...
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub struct SystemStore;
/// Bytes of UTF-8 per keychain item: at most twice as many in UTF-16, well under 2560.
#[cfg(any(target_os = "macos", target_os = "windows"))]
const PIECE: usize = 1000;
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn piece_key(key: &str, n: usize) -> String {
if n == 0 {
key.to_string()
} else {
format!("{key}.{n}")
}
}
/// `secret` cut into pieces of at most `PIECE` bytes, on character boundaries.
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn split_secret(mut secret: &str) -> Vec<&str> {
let mut pieces = Vec::new();
while secret.len() > PIECE {
let mut cut = PIECE;
while !secret.is_char_boundary(cut) {
cut -= 1;
}
let (head, tail) = secret.split_at(cut);
pieces.push(head);
secret = tail;
}
pieces.push(secret);
pieces
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn read_item(key: &str) -> Result<Option<String>, String> {
match keyring::Entry::new(SERVICE, key).and_then(|e| e.get_password()) {
Ok(secret) => Ok(Some(secret)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn write_item(key: &str, secret: &str) -> Result<(), String> {
keyring::Entry::new(SERVICE, key)
.and_then(|e| e.set_password(secret))
.map_err(|e| e.to_string())
}
/// Removes one item; false when there was none.
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn remove_item(key: &str) -> Result<bool, String> {
match keyring::Entry::new(SERVICE, key).and_then(|e| e.delete_credential()) {
Ok(()) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(e) => Err(e.to_string()),
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
impl SecretStore for SystemStore {
fn get(&self, key: &str) -> Result<Option<String>, String> {
match keyring::Entry::new(SERVICE, key).and_then(|e| e.get_password()) {
Ok(secret) => Ok(Some(secret)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
let Some(mut secret) = read_item(key)? else {
return Ok(None);
};
for n in 1.. {
match read_item(&piece_key(key, n))? {
Some(piece) => secret.push_str(&piece),
None => break,
}
}
Ok(Some(secret))
}
fn set(&self, key: &str, secret: &str) -> Result<(), String> {
keyring::Entry::new(SERVICE, key)
.and_then(|e| e.set_password(secret))
.map_err(|e| e.to_string())
let pieces = split_secret(secret);
for (n, piece) in pieces.iter().enumerate() {
write_item(&piece_key(key, n), piece)?;
}
// Pieces left over from a longer secret would be read back as part of this one.
for n in pieces.len().. {
if !remove_item(&piece_key(key, n))? {
break;
}
}
Ok(())
}
fn delete(&self, key: &str) -> Result<(), String> {
match keyring::Entry::new(SERVICE, key).and_then(|e| e.delete_credential()) {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
for n in 0.. {
if !remove_item(&piece_key(key, n))? {
break;
}
}
Ok(())
}
fn persistent(&self) -> bool {
true
+30
View File
@@ -509,6 +509,36 @@ fn accounts_keep_secrets_in_the_store_only() {
assert!(!store.persistent());
}
/// The OS keychain itself: Credential Manager and the Keychain take one secret per item, and a
/// sign-in's secrets are longer than one item holds. Ignored by default: it writes to the keychain
/// (a test key, removed again).
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
#[ignore = "writes to the OS keychain"]
fn system_keychain_keeps_long_secrets() {
let key = "test-long-secret";
let secret = format!("{{\"refresh_token\":\"{}\"}}", "r".repeat(3000));
SystemStore.set(key, &secret).unwrap();
assert_eq!(SystemStore.get(key).unwrap(), Some(secret.clone()));
SystemStore.delete(key).unwrap();
assert_eq!(SystemStore.get(key).unwrap(), None);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn long_secrets_split_into_keychain_sized_pieces() {
// Mixed 1-, 2- and 4-byte characters, so the cuts land inside some of them.
let secret = "aé🔑".repeat(300);
let pieces = split_secret(&secret);
assert!(pieces.len() > 1);
assert_eq!(pieces.concat(), secret);
for piece in &pieces {
assert!(piece.len() <= PIECE);
assert!(piece.encode_utf16().count() * 2 <= 2560);
}
assert_eq!(split_secret("short"), vec!["short"]);
}
#[test]
fn session_files_are_private_and_complete() {
let dir = std::env::temp_dir().join(format!("horizon-launcher-session-{}", std::process::id()));
+34 -11
View File
@@ -1227,7 +1227,7 @@ fn download_for_owner(
sources::Os::MacOs => "osx",
sources::Os::Windows => "windows",
};
mojang::download(pins, dir, os, 8, fetch, log).map_err(|e| {
mojang::download(pins, dir, os, 8, fetch, &mojang::CANCEL, log).map_err(|e| {
format!("\n • Horizon tried to download {v} from Mojang for {name}, but: {e}")
})
}
@@ -1299,7 +1299,8 @@ fn java(
Ok(body)
};
let runtime = data_dir().join("minecraft").join("runtime");
let home = mojang::java_runtime(&component, &runtime, 8, &fetch, log).map_err(|e| {
let home = mojang::java_runtime(&component, &runtime, 8, &fetch, &mojang::CANCEL, log)
.map_err(|e| {
format!("{missing}\n • Horizon also tried to download Minecraft's own Java from Mojang, but: {e}")
})?;
find(major, &[home]).map(|jdk| jdk.home())
@@ -1351,6 +1352,16 @@ fn assets_folder(
}
}
/// Where the preparation takes Minecraft's files from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MinecraftFrom {
/// The player's own install (any launcher's); Mojang only when none has the version and an
/// account that owns the game is signed in.
Install,
/// Mojang, for the signed-in account that owns the game ("Download and prepare").
Mojang,
}
/// Checks what the preparation needs and picks how to run it. PLAY.txt describes the same
/// steps: the player's own launcher install, the server jar from that version's own json, and
/// Java (and Python, for versions before horizon-prepare). Also returns the Minecraft folder
@@ -1359,6 +1370,7 @@ fn assets_folder(
pub fn preparer(
tag: &str,
minecraft: &Path,
from: MinecraftFrom,
log: &dyn Fn(String),
) -> Result<(Preparer, PathBuf), String> {
let version = game_version(tag);
@@ -1386,16 +1398,23 @@ pub fn preparer(
Ok(body)
};
let linked = data_dir().join("linked-minecraft");
let resolved = sources::resolve(minecraft, &pins, &known, &linked, &fetch, log);
if reused {
assets_folder(resolved, &chosen, &version, log)
if from == MinecraftFrom::Mojang {
// Straight from Mojang (what is already there and whole is kept), for an owner only.
download_for_owner(&pins, &own, &fetch, log)
.map_err(|e| e.trim_start_matches("\n • ").to_string())?;
own
} else {
match resolved? {
sources::Resolved::Folder(dir) => dir,
sources::Resolved::NotFound(checked) => {
let not_found = sources::not_found(&version, &checked, minecraft);
download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?;
own
let resolved = sources::resolve(minecraft, &pins, &known, &linked, &fetch, log);
if reused {
assets_folder(resolved, &chosen, &version, log)
} else {
match resolved? {
sources::Resolved::Folder(dir) => dir,
sources::Resolved::NotFound(checked) => {
let not_found = sources::not_found(&version, &checked, minecraft);
download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?;
own
}
}
}
}
@@ -2625,6 +2644,10 @@ pub struct Settings {
/// first-run question, or on the Play page once if they answered that before it existed.
#[serde(default)]
pub ping_notice_seen: bool,
/// The player without an account that owns the game has read, once, why the launcher needs
/// one now (the owner gate's screen).
#[serde(default)]
pub owner_notice_seen: bool,
/// Seconds the sidebar's partner banner has been on screen, over every start, up to
/// `partner::SEEN_BEFORE_HIDE`: only then can the player hide it.
#[serde(default)]
+17 -4
View File
@@ -548,12 +548,16 @@ enum Cmd {
Install { tag: String },
/// Remove an installed version
Remove { tag: String },
/// Prepare a version's resources from your own Minecraft install
/// Prepare a version's resources from your own Minecraft install, or from Mojang
Prepare {
/// The game folder of the official launcher, Prism Launcher, MultiMC, the Modrinth App,
/// ATLauncher or GDLauncher (default: the launcher's, then every launcher's default)
#[arg(long)]
#[arg(long, conflicts_with = "download")]
minecraft: Option<PathBuf>,
/// Download Minecraft from Mojang for the signed-in account that owns it, instead of
/// using an install
#[arg(long)]
download: bool,
/// The installed version (default: the selected one)
#[arg(long)]
version: Option<String>,
@@ -974,11 +978,20 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
backend::remove_version(&tag)?;
println!("Removed {tag}.");
}
Cmd::Prepare { minecraft, version } => {
Cmd::Prepare {
minecraft,
download,
version,
} => {
let tag = backend::resolve_version(version.as_deref())?;
let minecraft = backend::minecraft_dir(minecraft)?;
let from = if download {
backend::MinecraftFrom::Mojang
} else {
backend::MinecraftFrom::Install
};
let (preparer, minecraft) =
backend::preparer(&tag, &minecraft, &|line| eprintln!("{line}"))?;
backend::preparer(&tag, &minecraft, from, &|line| eprintln!("{line}"))?;
backend::sounds(&tag, &minecraft, &|line| println!("{line}"));
match preparer {
backend::Preparer::Process(cmd) => attached(cmd)?,
+96 -14
View File
@@ -27,6 +27,7 @@ mod modpacks_page;
mod mods_page;
mod mods_project;
mod multiplayer_page;
mod owner_page;
mod packs_page;
mod partner;
mod perf;
@@ -210,6 +211,8 @@ struct Launcher {
/// Whether an account here owns the game, by the keychain's last answers (the owner gate);
/// none until the keychain has been read.
owner: Option<bool>,
/// Play was refused (or Home's Sign in pressed): the owner gate's screen is open.
owner_screen_open: bool,
/// The game is waiting for its session (a token renewal) before it starts.
starting_client: Option<String>,
/// Join a friend: the code typed on the Play page, and the one the game
@@ -415,6 +418,7 @@ impl Launcher {
sign_in_state: None,
keychain: true,
owner: None,
owner_screen_open: false,
starting_client: None,
friend_code: String::new(),
friend_focused: false,
@@ -648,6 +652,7 @@ impl Launcher {
Ok(pass) => pass,
Err(account::NoOwner) => {
self.owner = Some(false);
self.owner_screen_open = true;
self.client_error = Some(format!("{}.", tr(account::NEEDS_OWNER)));
self.starting_pack = None;
self.joining = None;
@@ -917,7 +922,12 @@ impl Launcher {
self.accounts = Accounts::load();
// Sign-in refuses accounts that don't own the game.
self.owner = Some(true);
self.owner_screen_open = false;
self.client_error = None;
if !self.settings.owner_notice_seen {
self.settings.owner_notice_seen = true;
self.settings.save();
}
}
self.sign_in_state = Some(state);
changed = true;
@@ -1011,10 +1021,13 @@ impl Launcher {
});
}
fn prepare(&mut self) {
/// Prepares the selected version, its Minecraft files from the player's install or, for an
/// owner who asks, from Mojang.
fn prepare(&mut self, from: backend::MinecraftFrom) {
let Some(tag) = self.selected().cloned() else {
return;
};
horizon_launcher::mojang::CANCEL.store(false, Ordering::Relaxed);
let minecraft = self
.settings
.minecraft_dir
@@ -1030,7 +1043,7 @@ impl Launcher {
let log = |line| {
let _ = tx.send(Msg::Line(Job::Prepare, line));
};
let result = backend::preparer(&tag, &minecraft, &log)
let result = backend::preparer(&tag, &minecraft, from, &log)
.inspect(|(_, minecraft)| backend::sounds(&tag, minecraft, &log))
.and_then(|(p, _)| match p {
backend::Preparer::Process(cmd) => {
@@ -1088,7 +1101,7 @@ impl Launcher {
});
}
/// Starts the Microsoft sign-in; Settings shows its code.
/// Starts the Microsoft sign-in; Settings and the owner gate's screen show its code.
fn start_sign_in(&mut self) {
if let Some(old) = self.sign_in.replace(SignIn::start()) {
old.cancel();
@@ -2222,10 +2235,22 @@ impl Launcher {
.child(play),
);
let resources_value = if self.preparing {
tr("Preparing…").to_string()
} else {
backend::resources_summary(prepared, !silent, &version)
// Not prepared: an owner can have Horizon download Minecraft from Mojang; without an
// owner there is nothing to play, so signing in comes first.
let first = (!prepared && !self.preparing)
.then_some(self.owner)
.flatten();
let resources_value = match first {
_ if self.preparing => tr("Preparing…").to_string(),
Some(true) => trf(
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.",
&[("version", &version)],
),
Some(false) => trf(
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.",
&[("version", &version)],
),
None => backend::resources_summary(prepared, !silent, &version),
};
let mut prepare = button(
"prepare",
@@ -2241,10 +2266,44 @@ impl Launcher {
prepare = disabled(prepare);
} else {
prepare = prepare.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.prepare();
this.prepare(backend::MinecraftFrom::Install);
cx.notify();
}));
}
let prepare = match first {
Some(true) => div()
.flex()
.gap(px(8.))
.child(
button("prepare-install", "From my install", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.prepare(backend::MinecraftFrom::Install);
cx.notify();
}),
),
)
.child(
button(
"prepare-download",
"Download and prepare",
Kind::Secondary,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.prepare(backend::MinecraftFrom::Mojang);
cx.notify();
})),
),
Some(false) => div().child(
button("prepare-sign-in", "Sign in", Kind::Secondary, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.sign_in_to_play();
cx.notify();
},
)),
),
None => div().child(prepare),
};
let minecraft = self.settings.minecraft_dir.as_deref().map_or_else(
|| {
div()
@@ -2301,8 +2360,7 @@ impl Launcher {
Some(
button("gate-sign-in", "Sign in", Kind::Primary, &t)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.start_sign_in();
this.go(Page::Settings);
this.sign_in_to_play();
cx.notify();
}))
.into_any_element(),
@@ -2385,12 +2443,33 @@ impl Launcher {
// looks after pressing Prepare, not below the fold (#666).
if self.preparing {
let last = self.prepare_log.back().cloned().unwrap_or_default();
// A download from Mojang shows how far it is, and can stop (it goes on from there
// next time).
let download = horizon_launcher::mojang::progress();
let cancel = download.map(|_| {
button("download-cancel", "Cancel", Kind::Ghost, &t).on_click(|_, _, _| {
horizon_launcher::mojang::CANCEL.store(true, Ordering::Relaxed)
})
});
page = page.child(
div()
.flex()
.flex_col()
.gap(px(10.))
.child(progress_bar(None, &t))
.child(
div()
.flex()
.items_center()
.gap(px(12.))
.child(progress_bar(
download.map(|(done, total)| {
let f = done as f32 / total as f32;
(f, f)
}),
&t,
))
.children(cancel),
)
.child(
text(theme::MICRO)
.text_color(t.muted)
@@ -3201,9 +3280,9 @@ impl Launcher {
.flex()
.flex_col()
.gap(px(6.))
.child(format!(
"Open {} in your browser and enter this code:",
code.verification_uri
.child(trf(
"Open {link} in your browser and enter this code:",
&[("link", &code.verification_uri)],
))
.child(
text(theme::HEADING)
@@ -3907,6 +3986,9 @@ impl Render for Launcher {
if self.asking_consent() {
root = root.child(self.consent_screen(cx));
}
if self.asking_owner() {
root = root.child(self.owner_screen(cx));
}
if let Some(opening) = &self.opening {
// The wordmark arrives letter by letter, then gives way to the launcher; a click or
// a key skips ahead.
+70 -15
View File
@@ -3,12 +3,16 @@
//! jar, this system's libraries and the assets (the sounds), each checked against its sha1,
//! in the official launcher's layout in the launcher's data folder. sources.rs then finds them
//! there like any launcher's. Nothing of Mojang's is part of Horizon.
//!
//! Each file is checked against the size and sha1 Mojang lists, and written whole. A download
//! stopped half way (cancelled, no network) keeps the files it finished: the next one fetches
//! only the rest.
use crate::sources::{fetch_checked, fetch_manifest, sha1_file, write_file, Fetch, Pins};
use crate::sources::{fetch_checked, fetch_manifest, sha1_file, sha1_hex, write_file, Fetch, Pins};
use serde_json::Value;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed};
use std::sync::Mutex;
use std::time::Duration;
@@ -16,22 +20,38 @@ const OBJECTS: &str = "https://resources.download.minecraft.net";
/// The index of Mojang's Java runtimes, the one the official launcher reads.
const JAVA_RUNTIMES: &str = "https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json";
/// One file to download.
/// Set to stop the download under way (the window's Cancel); cleared when a preparation starts.
pub static CANCEL: AtomicBool = AtomicBool::new(false);
/// The download under way: files done, of how many (0 when none runs).
static DONE: AtomicUsize = AtomicUsize::new(0);
static TOTAL: AtomicUsize = AtomicUsize::new(0);
/// How far the download under way is, files done of how many; none when none runs.
pub fn progress() -> Option<(usize, usize)> {
let total = TOTAL.load(Relaxed);
(total > 0).then(|| (DONE.load(Relaxed).min(total), total))
}
const CANCELLED: &str = "Download cancelled. Download again to go on where it stopped.";
/// One file to download, with the size Mojang lists for it when it lists one.
struct File {
url: String,
sha1: String,
size: Option<u64>,
path: PathBuf,
}
/// Downloads the pinned version into `dir`, `jobs` files at a time; files already there and
/// whole are kept. An asset that fails is a line in the log (the game is then partly silent),
/// anything else an error.
/// anything else an error. `cancel` stops it between files.
pub fn download(
pins: &Pins,
dir: &Path,
os: &str,
jobs: usize,
fetch: Fetch,
cancel: &AtomicBool,
log: &dyn Fn(String),
) -> Result<(), String> {
let v = &pins.version;
@@ -43,6 +63,7 @@ pub fn download(
let mut game = vec![File {
url: text(client, "url")?,
sha1: pins.client_sha1.clone(),
size: client["size"].as_u64(),
path: versions.join(format!("{v}.jar")),
}];
let libraries = manifest["libraries"]
@@ -56,6 +77,7 @@ pub fn download(
game.push(File {
url: text(artifact, "url")?,
sha1: text(artifact, "sha1")?,
size: artifact["size"].as_u64(),
path: dir
.join("libraries")
.join(relative(&text(artifact, "path")?)?),
@@ -65,23 +87,26 @@ pub fn download(
"Downloading Minecraft {v} from Mojang: the game and {} libraries",
game.len() - 1
));
fetch_all(&game, jobs, fetch, log)?;
fetch_all(&game, jobs, fetch, cancel, log)?;
let index = &manifest["assetIndex"];
let index = File {
url: text(index, "url")?,
sha1: text(index, "sha1")?,
size: index["size"].as_u64(),
path: dir
.join("assets/indexes")
.join(relative(&format!("{}.json", text(index, "id")?))?),
};
let assets = fetch_all(std::slice::from_ref(&index), 1, fetch, log)
let assets = fetch_all(std::slice::from_ref(&index), 1, fetch, cancel, log)
.and_then(|_| objects(&read_json(&index.path)?, dir))
.and_then(|objects| {
log(format!("Downloading the sounds ({} files)", objects.len()));
fetch_all(&objects, jobs, fetch, log)
fetch_all(&objects, jobs, fetch, cancel, log)
});
if let Err(e) = assets {
log(format!("The sounds could not all be downloaded: {e}"));
match assets {
Err(e) if e == CANCELLED => return Err(e),
Err(e) => log(format!("The sounds could not all be downloaded: {e}")),
Ok(()) => {}
}
Ok(())
}
@@ -110,6 +135,7 @@ pub fn java_runtime(
runtime: &Path,
jobs: usize,
fetch: Fetch,
cancel: &AtomicBool,
log: &dyn Fn(String),
) -> Result<PathBuf, String> {
let platform = java_platform().ok_or("Mojang has no Java for this system")?;
@@ -144,6 +170,7 @@ pub fn java_runtime(
downloads.push(File {
url: text(raw, "url")?,
sha1: text(raw, "sha1")?,
size: raw["size"].as_u64(),
path,
});
}
@@ -156,7 +183,7 @@ pub fn java_runtime(
"Downloading Minecraft's Java {name} from Mojang ({} files)",
downloads.len()
));
fetch_all(&downloads, jobs, fetch, log)?;
fetch_all(&downloads, jobs, fetch, cancel, log)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -243,6 +270,10 @@ fn objects(index: &Value, dir: &Path) -> Result<Vec<File>, String> {
let objects = index["objects"]
.as_object()
.ok_or("The asset index has no objects")?;
let sizes: std::collections::HashMap<&str, u64> = objects
.values()
.filter_map(|o| Some((o["hash"].as_str()?, o["size"].as_u64()?)))
.collect();
let mut hashes: Vec<&str> = objects
.values()
.filter_map(|o| o["hash"].as_str())
@@ -259,23 +290,42 @@ fn objects(index: &Value, dir: &Path) -> Result<Vec<File>, String> {
Ok(File {
url: format!("{OBJECTS}/{path}"),
sha1: hash.to_string(),
size: sizes.get(hash).copied(),
path: dir.join("assets/objects").join(path),
})
})
.collect()
}
/// Downloads the files not already there, `jobs` at a time, with a line every few seconds;
/// stops at the first error.
/// One file from Mojang, refused unless it has the size and sha1 Mojang lists.
fn fetch_file(fetch: Fetch, file: &File) -> Result<Vec<u8>, String> {
let body = fetch(&file.url)?;
if let Some(size) = file.size.filter(|&size| size != body.len() as u64) {
return Err(format!(
"is {} bytes, not the {size} Mojang lists",
body.len()
));
}
match sha1_hex(&body) {
actual if actual == file.sha1 => Ok(body),
actual => Err(format!("is not the one Horizon pins (sha1 {actual})")),
}
}
/// Downloads the files not already there, `jobs` at a time, with a line every second; stops at
/// the first error, or between files once `cancel` is set.
fn fetch_all(
files: &[File],
jobs: usize,
fetch: Fetch,
cancel: &AtomicBool,
log: &dyn Fn(String),
) -> Result<(), String> {
let next = AtomicUsize::new(0);
let done = AtomicUsize::new(0);
let failed = Mutex::new(None);
DONE.store(0, Relaxed);
TOTAL.store(files.len(), Relaxed);
std::thread::scope(|scope| {
let workers: Vec<_> = (0..jobs.clamp(1, files.len().max(1)))
.map(|_| {
@@ -284,15 +334,19 @@ fn fetch_all(
if failed.lock().unwrap().is_some() {
break;
}
if cancel.load(Relaxed) {
failed.lock().unwrap().get_or_insert(CANCELLED.to_string());
break;
}
if sha1_file(&file.path).as_ref() != Some(&file.sha1) {
let got = fetch_checked(fetch, &file.url, &file.sha1)
let got = fetch_file(fetch, file)
.map_err(|e| format!("{} {e}", file.url))
.and_then(|body| write_file(&file.path, &body));
if let Err(e) = got {
failed.lock().unwrap().get_or_insert(e);
}
}
done.fetch_add(1, Relaxed);
DONE.store(done.fetch_add(1, Relaxed) + 1, Relaxed);
}
})
})
@@ -301,11 +355,12 @@ fn fetch_all(
while !workers.iter().all(|w| w.is_finished()) {
std::thread::sleep(Duration::from_millis(100));
waited += 1;
if waited % 50 == 0 {
if waited % 10 == 0 {
log(format!("{} of {} files", done.load(Relaxed), files.len()));
}
}
});
TOTAL.store(0, Relaxed);
failed.into_inner().unwrap().map_or(Ok(()), Err)
}
+137 -11
View File
@@ -8,6 +8,8 @@ const COMMON: &[u8] = b"a library every system uses";
const LINUX: &[u8] = b"a linux native";
const SOUND: &[u8] = b"a fake ogg";
const LANG: &[u8] = b"{}";
/// Never cancelled.
static GO: AtomicBool = AtomicBool::new(false);
/// The fake version file, asset index and files, by URL.
fn mojang() -> (Pins, HashMap<String, Vec<u8>>) {
@@ -28,7 +30,7 @@ fn mojang() -> (Pins, HashMap<String, Vec<u8>>) {
"id": "26.3",
"assetIndex": {"id": "34", "sha1": sha1_hex(index.as_bytes()),
"url": "https://piston-meta.mojang.com/v1/packages/i/34.json"},
"downloads": {"client": {"sha1": sha1_hex(CLIENT), "url": "https://piston-data.mojang.com/client.jar"}},
"downloads": {"client": {"sha1": sha1_hex(CLIENT), "size": CLIENT.len(), "url": "https://piston-data.mojang.com/client.jar"}},
"libraries": [
{"name": "org.fake:common:1", "downloads": {"artifact": artifact("common", COMMON)}},
{"name": "org.fake:linux:1", "downloads": {"artifact": artifact("linux", LINUX)},
@@ -112,9 +114,15 @@ fn an_owner_gets_the_official_layout_with_this_systems_libraries() {
};
let dir = scratch("layout");
let logged = Mutex::new(Vec::new());
download(&pins, &dir, "linux", 3, &|url| server.fetch(url), &|line| {
logged.lock().unwrap().push(line)
})
download(
&pins,
&dir,
"linux",
3,
&|url| server.fetch(url),
&GO,
&|line| logged.lock().unwrap().push(line),
)
.unwrap();
assert_eq!(
fs::read(dir.join("versions/26.3/26.3.jar")).unwrap(),
@@ -156,7 +164,16 @@ fn an_owner_gets_the_official_layout_with_this_systems_libraries() {
&|_| {},
);
assert_eq!(found, Ok(Resolved::Folder(dir.clone())));
download(&pins, &dir, "linux", 3, &|url| server.fetch(url), &|_| {}).unwrap();
download(
&pins,
&dir,
"linux",
3,
&|url| server.fetch(url),
&GO,
&|_| {},
)
.unwrap();
assert_eq!(server.asked(), Vec::<String>::new());
fs::remove_dir_all(&dir).unwrap();
}
@@ -169,7 +186,16 @@ fn macos_skips_what_its_rules_disallow() {
asked: Mutex::new(Vec::new()),
};
let dir = scratch("osx");
download(&pins, &dir, "osx", 2, &|url| server.fetch(url), &|_| {}).unwrap();
download(
&pins,
&dir,
"osx",
2,
&|url| server.fetch(url),
&GO,
&|_| {},
)
.unwrap();
let libraries = dir.join("libraries/org/fake");
assert!(libraries.join("common/1/common-1.jar").is_file());
assert!(!libraries.join("linux").exists());
@@ -182,14 +208,23 @@ fn a_file_that_is_not_the_pinned_one_stops_the_download() {
let (pins, mut files) = mojang();
files.insert(
"https://piston-data.mojang.com/client.jar".into(),
b"something else".to_vec(),
b"fake client JAR".to_vec(),
);
let server = Server {
files,
asked: Mutex::new(Vec::new()),
};
let dir = scratch("bad-jar");
let error = download(&pins, &dir, "linux", 2, &|url| server.fetch(url), &|_| {}).unwrap_err();
let error = download(
&pins,
&dir,
"linux",
2,
&|url| server.fetch(url),
&GO,
&|_| {},
)
.unwrap_err();
assert!(
error.starts_with("https://piston-data.mojang.com/client.jar is not the one Horizon pins"),
"{error}"
@@ -209,9 +244,15 @@ fn missing_sounds_are_a_line_in_the_log_not_an_error() {
};
let dir = scratch("no-sound");
let logged = Mutex::new(Vec::new());
download(&pins, &dir, "linux", 1, &|url| server.fetch(url), &|line| {
logged.lock().unwrap().push(line)
})
download(
&pins,
&dir,
"linux",
1,
&|url| server.fetch(url),
&GO,
&|line| logged.lock().unwrap().push(line),
)
.unwrap();
assert!(dir.join("versions/26.3/26.3.jar").is_file());
let last = logged.lock().unwrap().last().cloned().unwrap();
@@ -290,6 +331,7 @@ fn java_runtime_lays_out_mojangs_runtime_with_its_executables_and_links() {
&dir,
2,
&|url| server.fetch(url),
&GO,
&|_| {},
)
.unwrap();
@@ -316,6 +358,7 @@ fn java_runtime_lays_out_mojangs_runtime_with_its_executables_and_links() {
&dir,
2,
&|url| server.fetch(url),
&GO,
&|_| {},
)
.unwrap();
@@ -325,8 +368,91 @@ fn java_runtime_lays_out_mojangs_runtime_with_its_executables_and_links() {
&dir,
2,
&|url| server.fetch(url),
&GO,
&|_| {}
)
.is_err());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_file_of_another_size_than_mojang_lists_is_refused() {
let (pins, mut files) = mojang();
// A cut-off body: its size says so before its sha1.
files.insert(
"https://piston-data.mojang.com/client.jar".into(),
CLIENT[..4].to_vec(),
);
let server = Server {
files,
asked: Mutex::new(Vec::new()),
};
let dir = scratch("size");
let error = download(
&pins,
&dir,
"linux",
2,
&|url| server.fetch(url),
&GO,
&|_| {},
)
.unwrap_err();
assert!(
error.ends_with(&format!(
"is 4 bytes, not the {} Mojang lists",
CLIENT.len()
)),
"{error}"
);
assert!(!dir.join("versions/26.3/26.3.jar").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_cancelled_download_goes_on_where_it_stopped() {
let (pins, files) = mojang();
let server = Server {
files,
asked: Mutex::new(Vec::new()),
};
let dir = scratch("cancel");
let cancel = AtomicBool::new(false);
// Cancelled once the client jar has arrived: one job, so nothing after it starts.
let fetch = |url: &str| {
let body = server.fetch(url);
if url.ends_with("client.jar") {
cancel.store(true, Relaxed);
}
body
};
let error = download(&pins, &dir, "linux", 1, &fetch, &cancel, &|_| {}).unwrap_err();
assert_eq!(error, CANCELLED);
assert_eq!(
fs::read(dir.join("versions/26.3/26.3.jar")).unwrap(),
CLIENT
);
assert!(!dir.join("libraries").exists());
// Again: the version file and the jar are kept, only the rest is fetched.
cancel.store(false, Relaxed);
server.asked();
download(
&pins,
&dir,
"linux",
1,
&|url| server.fetch(url),
&cancel,
&|_| {},
)
.unwrap();
let asked = server.asked();
assert!(
!asked
.iter()
.any(|u| u.ends_with("client.jar") || u.ends_with("26.3.json")),
"{asked:#?}"
);
assert_eq!(asked.len(), 6, "{asked:#?}");
fs::remove_dir_all(&dir).unwrap();
}
+157
View File
@@ -0,0 +1,157 @@
//! The owner gate in the window (`horizon_launcher::account::launch`): the screen that asks for a
//! Microsoft account that owns Minecraft: Java Edition. It covers the launcher once for players
//! without one (those who played offline before the gate), and again whenever Play is refused.
//! The sign-in runs on it, code and all.
use super::*;
impl Launcher {
/// Whether the screen covers the window: no owner, and either Play was refused or the player
/// hasn't read it yet. The data question comes first.
pub(crate) fn asking_owner(&self) -> bool {
self.owner == Some(false)
&& (self.owner_screen_open || !self.settings.owner_notice_seen)
&& !self.asking_consent()
&& self.opening.is_none()
}
/// Opens the screen and starts the sign-in on it.
pub(crate) fn sign_in_to_play(&mut self) {
self.owner_screen_open = true;
self.start_sign_in();
}
/// Closes it; it comes back with the next refused Play.
fn close_owner_screen(&mut self) {
if let Some(s) = self.sign_in.take() {
s.cancel();
}
self.sign_in_state = None;
self.owner_screen_open = false;
if !self.settings.owner_notice_seen {
self.settings.owner_notice_seen = true;
self.settings.save();
}
}
pub(crate) fn owner_screen(&mut self, cx: &mut Context<Self>) -> Stateful<Div> {
let t = self.theme;
let configured = account::client_id().is_some();
let mut body = div()
.flex()
.flex_col()
.gap(px(12.))
.p(px(28.))
.child(caps(tr("Microsoft account")).text_color(t.muted))
.child(
text(theme::HEADING)
.text_color(t.ink)
.child(format!("{}.", tr(account::NEEDS_OWNER))),
)
.child(text(theme::UI).text_color(t.muted).child(tr(
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers.",
)))
.child(text(theme::UI).text_color(t.muted).child(tr(
"Your worlds, offline names and settings stay where they are.",
)));
let status = match &self.sign_in_state {
_ if !configured => Some(div().child(tr(account::NOT_CONFIGURED))),
Some(SignInState::Starting) => Some(div().child(tr("Asking Microsoft…"))),
Some(SignInState::Code(code)) => Some(
div()
.flex()
.flex_col()
.gap(px(6.))
.child(trf(
"Open {link} in your browser and enter this code:",
&[("link", &code.verification_uri)],
))
.child(
text(theme::HEADING)
.font_family(theme::MONO)
.text_color(t.ink)
.child(code.user_code.clone()),
),
),
Some(SignInState::Done(Err(e))) => Some(div().text_color(t.danger).child(e.clone())),
Some(SignInState::Done(Ok(_))) | None => None,
};
body = body.children(status.map(|s| {
plate(&t)
.p(px(16.))
.text_size(px(theme::UI.px))
.text_color(t.ink_soft)
.child(s)
}));
let mut answers = div()
.flex()
.justify_end()
.gap(px(12.))
.px(px(28.))
.pb(px(28.))
.child(
button("owner-later", "Not now", Kind::Secondary, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.close_owner_screen();
cx.notify();
},
)),
);
answers = match &self.sign_in_state {
Some(SignInState::Code(code)) => {
let (uri, user_code) = (code.verification_uri.clone(), code.user_code.clone());
answers
.child(
button("owner-copy", "Copy code", Kind::Secondary, &t).on_click(
move |_, _, cx| {
copy_to_clipboard(
cx,
gpui::ClipboardItem::new_string(user_code.clone()),
)
},
),
)
.child(
button("owner-open", "Open link", Kind::Primary, &t)
.on_click(move |_, _, cx| cx.open_url(&uri)),
)
}
Some(SignInState::Starting) => answers,
_ if !configured => answers,
_ => answers.child(
button("owner-sign-in", "Sign in", Kind::Primary, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.sign_in_to_play();
cx.notify();
},
)),
),
};
let card = plate(&t)
.flex()
.flex_col()
// A set width: text measured at it wraps where it is drawn.
.w(px(560.))
.max_w_full()
.bg(t.page)
.border_1()
.border_color(t.rule)
.child(body)
.child(answers);
div()
.id("owner-gate")
.absolute()
.top_0()
.left_0()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.px(px(32.))
.py(px(24.))
.bg(t.page.opacity(0.92))
.on_scroll_wheel(|_, _, cx| cx.stop_propagation())
.occlude()
.child(card)
}
}
+1 -1
View File
@@ -782,7 +782,7 @@ impl Launcher {
self.servers.create = None;
self.go(Page::Play);
if !self.preparing {
self.prepare();
self.prepare(backend::MinecraftFrom::Install);
}
}
},
+239
View File
@@ -0,0 +1,239 @@
import unittest
from datetime import datetime, timezone
from pathlib import Path
from tools.ci import nightly_set as ns
from tools.ci import triage_command as tc
ROOT = Path(__file__).resolve().parents[1]
NOW = datetime(2026, 10, 28, 12, tzinfo=timezone.utc)
URL = "https://github.com/ludovic111/horizon/issues/5#issuecomment-1"
LIST = ns.read_reviewers("kacpherek\nrdbtCVS\n")
def thread(state="open", user="dev", labels=("bug",), pr=False, merged=False, locked=False):
t = {"state": state, "user": {"login": user}, "labels": [{"name": n} for n in labels], "locked": locked}
if pr:
t["pull_request"] = {"merged_at": "2026-10-27T00:00:00Z" if merged else None}
return t
class Fake:
def __init__(self, threads=None, labels=("bug", "good first issue", "release-now", "skip-release"), past=()):
self.threads = {5: thread()} if threads is None else threads
self.labels, self.past, self.calls = list(labels), list(past), []
def issue(self, n):
return self.threads.get(n)
def label(self, name):
return next((l for l in self.labels if l.lower() == name.lower()), None)
def add_labels(self, n, names):
self.calls.append(("add", n, names))
def remove_label(self, n, name):
self.calls.append(("remove", n, name))
def close(self, n, reason):
self.calls.append(("close", n, reason))
def reopen(self, n):
self.calls.append(("reopen", n))
def comment(self, n, text):
self.calls.append(("comment", n, text))
def recent_comments(self, since):
return self.past
def do(text, api=None, login="kacpherek", reviewers=LIST, n=5):
api = api or Fake()
return tc.run(api, tc.parse(text), login, n, URL, reviewers, NOW), api
class ParseTests(unittest.TestCase):
def test_commands_and_case(self):
self.assertEqual(tc.parse("/close"), tc.Command("close", "completed"))
self.assertEqual(tc.parse(" /CLOSE Not Planned "), tc.Command("close", "not_planned"))
self.assertEqual(tc.parse("/close completed"), tc.Command("close", "completed"))
self.assertEqual(tc.parse("/close duplicate of #123"), tc.Command("close", "duplicate", 123))
self.assertEqual(tc.parse("/Close duplicate of 7"), tc.Command("close", "duplicate", 7))
self.assertEqual(tc.parse("/reopen"), tc.Command("reopen"))
self.assertEqual(tc.parse("/label bug, good first issue ,bug"), tc.Command("label", labels=("bug", "good first issue")))
self.assertEqual(tc.parse("/unlabel bug"), tc.Command("unlabel", labels=("bug",)))
def test_first_line_only(self):
self.assertEqual(tc.parse("/close\n/label release-now"), tc.Command("close", "completed"))
self.assertIsNone(tc.parse("thanks\n/close"))
self.assertEqual(tc.parse("\n\n/reopen\nwhy"), tc.Command("reopen"))
def test_not_commands(self):
for text in ("", "hello", "/nightly 5", "/closed", "/labels bug", "/closer", "close", "/unlabeled x"):
self.assertIsNone(tc.parse(text), text)
def test_malformed(self):
for text in ("/close maybe", "/close duplicate of", "/reopen now", "/label", "/label ,", "/unlabel a, b",
"/label " + ",".join(str(i) for i in range(11))):
with self.assertRaises(tc.Refused, msg=text):
tc.parse(text)
def test_quoting_is_safe(self):
with self.assertRaises(tc.Refused) as e:
tc.parse("/close @ludovic111 [x](http://evil)")
self.assertNotIn("@", str(e.exception))
self.assertNotIn("](", str(e.exception))
class AllowTests(unittest.TestCase):
def test_listed_and_ludovic(self):
do("/close", login="RDBTcvs")
do("/close", login="ludovic111")
def test_stranger_refused(self):
with self.assertRaisesRegex(tc.Refused, "Only Ludovic"):
do("/close", login="someone")
def test_paused(self):
paused = ns.read_reviewers("kacpherek\npaused\n")
with self.assertRaisesRegex(tc.Refused, "paused"):
do("/close", reviewers=paused)
do("/close", reviewers=paused, login="ludovic111")
def test_the_repos_list_is_the_nightlys(self):
self.assertEqual(ns.REVIEWERS, ".github/trusted-reviewers.txt")
listed = ns.read_reviewers((ROOT / ns.REVIEWERS).read_text())
self.assertEqual(listed.logins, {"kacpherek", "rdbtcvs", "online5277"})
self.assertFalse(listed.paused)
class ActionTests(unittest.TestCase):
def test_close_issue(self):
text, api = do("/close not planned")
self.assertEqual(api.calls, [("close", 5, "not_planned")])
self.assertIn("on behalf of @kacpherek", text)
self.assertIn(URL, text)
self.assertTrue(text.startswith("🤖 Triage: "))
def test_close_pr_has_no_reason(self):
api = Fake({5: thread(pr=True)})
text, _ = do("/close", api)
self.assertEqual(api.calls, [("close", 5, None)])
self.assertIn("without merging", text)
def test_duplicate(self):
api = Fake({5: thread(), 9: thread()})
text, _ = do("/close duplicate of #9", api)
self.assertEqual(api.calls, [("comment", 5, "Duplicate of #9"), ("close", 5, "duplicate")])
self.assertIn("#9", text)
def test_duplicate_of_missing_or_itself(self):
with self.assertRaisesRegex(tc.Refused, "no #9"):
do("/close duplicate of #9")
with self.assertRaisesRegex(tc.Refused, "itself"):
do("/close duplicate of #5")
def test_reopen(self):
_, api = do("/reopen", Fake({5: thread("closed")}))
self.assertEqual(api.calls, [("reopen", 5)])
with self.assertRaisesRegex(tc.Refused, "already open"):
do("/reopen")
with self.assertRaisesRegex(tc.Refused, "already closed"):
do("/close", Fake({5: thread("closed")}))
def test_label_uses_the_real_name_and_skips_present(self):
_, api = do("/label Good First Issue, BUG")
self.assertEqual(api.calls, [("add", 5, ["good first issue"])])
with self.assertRaisesRegex(tc.Refused, "already has"):
do("/label bug")
def test_label_must_exist(self):
with self.assertRaisesRegex(tc.Refused, "no label"):
do("/label nonsense")
def test_unlabel(self):
_, api = do("/unlabel BUG")
self.assertEqual(api.calls, [("remove", 5, "bug")])
with self.assertRaisesRegex(tc.Refused, "doesn't have"):
do("/unlabel good first issue")
class RefusalTests(unittest.TestCase):
def test_release_and_parity_labels(self):
for name in ("release-now", "Release-Blocker", "skip-release", "full-parity", "prerelease"):
for verb in ("/label", "/unlabel"):
with self.assertRaisesRegex(tc.Refused, "release or parity", msg=name):
do(f"{verb} {name}", Fake(labels=(name,)))
with self.assertRaises(tc.Refused):
do("/label bug, release-now")
def test_ludovics_threads(self):
for text in ("/close", "/label bug", "/unlabel bug", "/reopen"):
for t in (thread(user="ludovic111", state="closed" if text == "/reopen" else "open"),
thread(user="Ludovic111", pr=True)):
with self.assertRaisesRegex(tc.Refused, "Ludovic's"):
do(text, Fake({5: t}))
def test_merged_pr(self):
for text in ("/close", "/reopen", "/label bug", "/unlabel bug"):
with self.assertRaisesRegex(tc.Refused, "merged"):
do(text, Fake({5: thread("closed", pr=True, merged=True)}))
def test_locked(self):
for text in ("/close", "/label good first issue", "/unlabel bug"):
with self.assertRaisesRegex(tc.Refused, "locked"):
do(text, Fake({5: thread(locked=True)}))
def test_missing_thread(self):
with self.assertRaisesRegex(tc.Refused, "no such"):
do("/close", Fake({}))
def test_nothing_written_on_refusal(self):
api = Fake({5: thread(user="ludovic111")})
with self.assertRaises(tc.Refused):
do("/close", api)
self.assertEqual(api.calls, [])
def past(login, n, who="github-actions[bot]"):
return [{"user": who, "body": f"🤖 Triage: closed as completed, on behalf of @{login} ([their comment](u))."}
for _ in range(n)]
class LimitTests(unittest.TestCase):
def test_sixty_a_day(self):
with self.assertRaisesRegex(tc.Refused, "60"):
do("/close", Fake(past=past("kacpherek", 60)))
do("/close", Fake(past=past("kacpherek", 59)))
def test_other_reviewers_and_forged_comments_dont_count(self):
do("/close", Fake(past=past("rdbtcvs", 80)))
do("/close", Fake(past=past("kacpherek", 80, who="someone")))
def test_refusals_dont_count(self):
refusals = [{"user": "github-actions[bot]", "body": "🤖 Triage: refused, nope (asked by @kacpherek)"}] * 70
do("/close", Fake(past=refusals))
def test_counts_case_insensitively(self):
with self.assertRaises(tc.Refused):
do("/close", Fake(past=past("kacpherek", 60)), login="Kacpherek")
def test_ludovic_is_unlimited(self):
do("/close", Fake(past=past("ludovic111", 100)), login="ludovic111")
class WorkflowTests(unittest.TestCase):
def test_workflow_is_safe(self):
text = (ROOT / ".github/workflows/triage-command.yml").read_text()
run_lines = [l for l in text.splitlines() if l.strip().startswith("run:")]
self.assertEqual(run_lines, [" run: python3 tools/ci/triage_command.py command"])
self.assertIn("persist-credentials: false", text)
self.assertNotIn("pull_request_target", text)
for perm in ("issues: write", "pull-requests: write", "contents: read"):
self.assertIn(perm, text)
self.assertNotIn("contents: write", text)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -4,7 +4,7 @@
nightly_set.py command one `/nightly …` comment (nightly-command.yml): checks who asked, updates the set,
starts the build and answers in the thread
A comment by Ludovic or by a login in .github/nightly-reviewers.txt, on any issue or pull request:
A comment by Ludovic or by a login in .github/trusted-reviewers.txt, on any issue or pull request:
/nightly 2640 2642 the set becomes these pull requests, merged onto main in this order; builds now
/nightly add 2650 adds one, or takes its newest head again; builds now
@@ -35,7 +35,7 @@ from pathlib import Path
from typing import Callable
OWNER = "ludovic111"
REVIEWERS = ".github/nightly-reviewers.txt"
REVIEWERS = ".github/trusted-reviewers.txt"
CODEOWNERS = ".github/CODEOWNERS"
# What CODEOWNERS guards today, refused even if the file moved: what builds, signs and checks releases.
ALWAYS_GUARDED = ["/.github/", "/tools/ci/", "/crates/horizon-release-sign/", "/crates/horizon-server/src/update.rs",
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Trusted reviewers triage issues and pull requests with comment commands (docs/release-flow.md "Trusted reviewers").
triage_command.py command one comment (triage-command.yml): checks who asked, acts, answers in the thread
A comment by Ludovic or by a login in .github/trusted-reviewers.txt, on the first line:
/close [completed|not planned] closes an issue (a pull request: closes it without merging)
/close duplicate of #123 comments "Duplicate of #123" and closes it as a duplicate
/reopen
/label bug, good first issue existing labels only; never creates one
/unlabel bug
Refused with a one-line answer: a label with `release` in it or `full-parity`; anything by Ludovic (his issues and
pull requests); a merged pull request; a locked thread; more than 60 actions per reviewer in 24 hours. No code of any
pull request is read or run: only the comment's text, its author and the API's answers, which reach this script as
env vars.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Callable
sys.path.insert(0, str(Path(__file__).resolve().parent))
import nightly_set as ns # noqa: E402 (the same list, owner and paused rule as /nightly)
MAX_PER_DAY = 60
PREFIX = "🤖 Triage: "
COMMANDS = ("close", "reopen", "label", "unlabel")
REASONS = {"completed": "completed", "not planned": "not_planned", "not_planned": "not_planned"}
DUPLICATE = re.compile(r"^(?:as\s+)?(?:a\s+)?duplicate\s+of\s+#?(\d{1,7})$", re.I)
USAGE = ("Use `/close`, `/close not planned`, `/close duplicate of #123`, `/reopen`, `/label bug, good first issue` "
"or `/unlabel bug`.")
class Refused(Exception):
"""A command that changes nothing; its text is the answer."""
@dataclass
class Command:
verb: str # close, reopen, label, unlabel
reason: str | None = None # close: completed, not_planned, duplicate
original: int | None = None # close duplicate of #N
labels: tuple[str, ...] = ()
def safe(text: str) -> str:
"""Text of a comment, fit to quote back: no markdown, no mentions."""
return re.sub(r"[^\w#:. -]", "", text)[:60].strip() or "?"
def parse(body: str) -> Command | None:
"""The command on a comment's first line; None when it isn't one. Raises Refused when it is one, but wrong."""
line = next((line.strip() for line in body.splitlines() if line.strip()), "")
m = re.match(r"^/(\w+)(?:\s+(.*))?$", line)
if not m or m.group(1).lower() not in COMMANDS:
return None
verb, rest = m.group(1).lower(), (m.group(2) or "").strip()
if verb == "reopen":
if rest:
raise Refused(f"`/reopen` takes no arguments. {USAGE}")
return Command("reopen")
if verb == "close":
if not rest:
return Command("close", "completed")
dup = DUPLICATE.match(rest)
if dup:
return Command("close", "duplicate", int(dup.group(1)))
reason = REASONS.get(" ".join(rest.lower().split()))
if not reason:
raise Refused(f"`{safe(rest)}` isn't a way to close. {USAGE}")
return Command("close", reason)
names = tuple(dict.fromkeys(n.strip() for n in rest.split(",") if n.strip()))
if not names:
raise Refused(f"`/{verb}` needs a label name. {USAGE}")
if verb == "unlabel" and len(names) > 1:
raise Refused(f"`/unlabel` takes one label. {USAGE}")
if len(names) > 10:
raise Refused("At most 10 labels at once.")
return Command(verb, labels=names)
def label_refusal(name: str) -> str | None:
low = name.lower()
if "release" in low or low == "full-parity":
return f"`{safe(name)}` is a release or parity label: only Ludovic's side touches those."
return None
def authorize(login: str, reviewers: ns.Reviewers) -> None:
"""Ludovic always; a listed reviewer unless the list is paused; nobody else."""
if ns.is_owner(login):
return
if login.lower() not in reviewers.logins:
raise Refused(f"Only Ludovic and the trusted reviewers listed in `{ns.REVIEWERS}` can triage this way.")
if reviewers.paused:
raise Refused(f"Triage commands are paused: Ludovic stopped them in `{ns.REVIEWERS}`.")
def target_refusal(issue: dict, cmd: Command, login: str) -> str | None:
"""Why this thread can't be acted on (None when it can). `issue` is GitHub's issue object."""
is_pr = "pull_request" in issue
kind = "pull request" if is_pr else "issue"
if not ns.is_owner(login) and ns.is_owner((issue.get("user") or {}).get("login", "")):
return f"This {kind} is Ludovic's: triage commands don't touch it."
if is_pr and (issue["pull_request"] or {}).get("merged_at"):
return "That pull request is already merged."
if issue.get("locked"):
return f"This {kind} is locked: triage commands don't act on it."
closed = issue.get("state") == "closed"
if cmd.verb == "close" and closed:
return f"This {kind} is already closed."
if cmd.verb == "reopen" and not closed:
return f"This {kind} is already open."
return None
def names_of(issue: dict) -> set[str]:
return {str(l.get("name", "") if isinstance(l, dict) else l).lower() for l in issue.get("labels", [])}
# --- GitHub ------------------------------------------------------------------------------------------------------
def gh(*args: str, check: bool = True) -> str:
r = subprocess.run(["gh", *args], capture_output=True, text=True)
if check and r.returncode != 0:
raise RuntimeError(f"gh {args[0]} {args[1] if len(args) > 1 else ''}: {r.stderr.strip()[:300]}")
return r.stdout.strip() if r.returncode == 0 else ""
class Api:
"""The few GitHub calls this needs; tests replace it."""
def __init__(self, repo: str):
self.repo = repo
def issue(self, n: int) -> dict | None:
raw = gh("api", f"repos/{self.repo}/issues/{n}", check=False)
return json.loads(raw) if raw else None
def label(self, name: str) -> str | None:
"""The label's real name, or None when the repository has no such label."""
raw = gh("api", f"repos/{self.repo}/labels/{name.replace('/', '%2F').replace(' ', '%20')}", check=False)
return json.loads(raw)["name"] if raw else None
def add_labels(self, n: int, names: list[str]) -> None:
args = ["api", f"repos/{self.repo}/issues/{n}/labels"]
for name in names:
args += ["-f", f"labels[]={name}"]
gh(*args)
def remove_label(self, n: int, name: str) -> None:
gh("api", "-X", "DELETE", f"repos/{self.repo}/issues/{n}/labels/{name.replace(' ', '%20')}")
def close(self, n: int, reason: str | None) -> None:
args = ["api", "-X", "PATCH", f"repos/{self.repo}/issues/{n}", "-f", "state=closed"]
if reason:
args += ["-f", f"state_reason={reason}"]
gh(*args)
def reopen(self, n: int) -> None:
gh("api", "-X", "PATCH", f"repos/{self.repo}/issues/{n}", "-f", "state=open")
def comment(self, n: int, text: str) -> None:
gh("api", f"repos/{self.repo}/issues/{n}/comments", "-f", f"body={text}")
def recent_comments(self, since: str) -> list[dict]:
"""Comments of the whole repository since a time (ISO 8601), as {user, body}."""
raw = gh("api", "--paginate", f"repos/{self.repo}/issues/comments?since={since}&per_page=100", "--jq",
'.[] | {user: .user.login, body: .body}')
return [json.loads(line) for line in raw.splitlines() if line.strip()]
def actions_by(api, login: str, now: datetime) -> int:
"""Actions this workflow answered for `login` in the last 24 h, counted from its own comments."""
since = (now - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ")
tag = f"on behalf of @{login.lower()}"
return sum(1 for c in api.recent_comments(since)
if c["user"] == "github-actions[bot]" and c["body"].startswith(PREFIX)
and c["body"].split("\n", 1)[0].lower().count(tag))
def say(action: str, login: str, url: str) -> str:
return f"{PREFIX}{action}, on behalf of @{login} ([their comment]({url}))."
def run(api, cmd: Command, login: str, number: int, url: str, reviewers: ns.Reviewers, now: datetime) -> str:
"""Does the command; returns the comment to post. Raises Refused for a refusal."""
authorize(login, reviewers)
for name in cmd.labels:
if (why := label_refusal(name)):
raise Refused(why)
issue = api.issue(number)
if issue is None:
raise Refused("There is no such issue or pull request.")
if (why := target_refusal(issue, cmd, login)):
raise Refused(why)
if cmd.original is not None:
if cmd.original == number:
raise Refused("An item cannot be a duplicate of itself.")
if api.issue(cmd.original) is None:
raise Refused(f"There is no #{cmd.original} in Horizon.")
if not ns.is_owner(login) and actions_by(api, login, now) >= MAX_PER_DAY:
raise Refused(f"You used your {MAX_PER_DAY} triage actions of the last 24 hours. Try again later.")
is_pr = "pull_request" in issue
if cmd.verb == "reopen":
api.reopen(number)
return say("reopened", login, url)
if cmd.verb == "close":
if cmd.reason == "duplicate":
api.comment(number, f"Duplicate of #{cmd.original}")
api.close(number, None if is_pr else "duplicate")
return say(f"closed as a duplicate of #{cmd.original}", login, url)
api.close(number, None if is_pr else cmd.reason)
how = "closed without merging" if is_pr else f"closed as {cmd.reason.replace('_', ' ')}"
return say(how, login, url)
have = names_of(issue)
real: list[str] = []
for name in cmd.labels:
found = api.label(name)
if found is None:
raise Refused(f"There is no label `{safe(name)}` in Horizon: triage doesn't create new ones.")
if (why := label_refusal(found)):
raise Refused(why)
real.append(found)
if cmd.verb == "label":
new = [n for n in real if n.lower() not in have]
if not new:
raise Refused("It already has " + ("that label." if len(real) == 1 else "those labels."))
api.add_labels(number, new)
return say("added " + ", ".join(f"`{n}`" for n in new), login, url)
name = real[0]
if name.lower() not in have:
raise Refused(f"It doesn't have `{name}`.")
api.remove_label(number, name)
return say(f"removed `{name}`", login, url)
def command() -> int:
body, login = os.environ["BODY"], os.environ["AUTHOR"]
number, url, repo = int(os.environ["ISSUE"]), os.environ.get("COMMENT_URL", ""), os.environ["REPO"]
api: Api = Api(repo)
try:
cmd = parse(body)
if cmd is None:
return 0
reviewers = ns.read_reviewers(Path(ns.REVIEWERS).read_text())
text = run(api, cmd, login, number, url, reviewers, datetime.now(timezone.utc))
except Refused as e:
text = f"{PREFIX}refused, {e} (asked by @{safe(login)})"
except RuntimeError as e:
print(e, file=sys.stderr)
text = f"{PREFIX}GitHub refused that, nothing more was done (asked by @{safe(login)})."
api.comment(number, text)
return 0
if __name__ == "__main__":
if sys.argv[1:] != ["command"]:
sys.exit(__doc__)
sys.exit(command())
+1
View File
@@ -232,6 +232,7 @@ def main():
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java'
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/probe/natural_spawn_fixture.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawner.rs',
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
tested = base.source_hashes(sources)
+1
View File
@@ -197,6 +197,7 @@ def main():
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/probe/natural_spawn_fixture.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/trial_spawner.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawner.rs',
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
tested = base.source_hashes(sources)
+2 -1
View File
@@ -174,7 +174,8 @@ def main():
resources = lab.LOCAL / 'horizon-client/26.3'
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java'
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/probe/natural_spawn_fixture.rs',
lab.ROOT / 'crates/horizon-client/src/vault.rs']
lab.ROOT / 'crates/horizon-client/src/vault.rs',
lab.ROOT / 'crates/horizon-client/src/chunk/block_entity.rs']
tested = base.source_hashes(sources)
inputs = build / 'inputs.txt'
cases = corpus()