Compare commits

...
Author SHA1 Message Date
Ludovic MarieandDelete228 9844193547 Add create 0.7.0-alpha.2 (#252)
Submitted by @Delete228 through the form.

Submission: Xw99Fin1SWw-wzTtC0kzA5T3PqXaLz3V_cCH3wvnNmU

Co-authored-by: Delete228 <[email protected]>
2026-11-05 12:50:07 +01:00
Ludovic Marieandtomasmendes01 e9d402b60c Update wisdom_horizon_lite 1.3.7 (#244)
Submitted by @tomasmendes01 through the form.

Submission: -p-AKjDZoFl_Kou4es3JCbFL84AL8_02MDTCf4ZsUoY

Co-authored-by: tomasmendes01 <[email protected]>
2026-11-05 12:48:49 +01:00
Ludovic Marieandtomasmendes01 71323383d8 Update wisdom_horizon 1.3.7 (#245)
Submitted by @tomasmendes01 through the form.

Submission: T6p3r_WvUBM66p1RumW8TjWkXawwML_QUuN9_pda2LE

Co-authored-by: tomasmendes01 <[email protected]>
2026-11-05 12:48:42 +01:00
Ludovic Marieandkeksi2k 3b34bef27e Update horizonmatica 0.2.0 (#226)
Submitted by @keksi2k through the form.

Submission: 9WU886yY97mH3Qd_G7XFc7D90_gi-n-eYDkVbzCjGJk

Co-authored-by: keksi2k <[email protected]>
2026-11-05 12:48:18 +01:00
Ludovic MarieandLauch2 23402771cf Update waymap 0.2.0 (#246)
Submitted by @Lauch2 through the form.

Submission: g1Kq_w-QIXd5YfEanpqysPmBFDPmMJxedjrLOWImc9Q

Co-authored-by: Lauch2 <[email protected]>
2026-11-05 12:47:48 +01:00
Ludovic MarieandBioneisme 6480bf8b8d Add deepbore_quarry_native 0.1.0 (#259)
Submitted by @Bioneisme through the form.

Submission: JZJtjV0G2UmbhsCp03CRLav3G7az40BHgvFL0OMFhDM

Co-authored-by: Bioneisme <[email protected]>
2026-11-05 12:46:00 +01:00
Ludovic MarieandBioneisme f64587952e Add deepbore_quarry 0.1.0 (#258)
Submitted by @Bioneisme through the form.

Submission: 4gUZ0dPDAcrsO5-uE1ePHUVZLMpkconw6Gob8wP8SbU

Co-authored-by: Bioneisme <[email protected]>
2026-11-05 12:45:55 +01:00
ad5e3b12b7 Add dynamic_fps_for_rust 0.1.0 (#225)
* Add dynamic_fps_for_rust 0.1.0

Submitted by @Lyvt-Dev through the form.

Submission: MAyCnE4TcnIo3B05CCph3VzW4RMBdFYO_0S76ynJA-M
Co-authored-by: Lyvt-Dev <[email protected]>

* dynamic_fps_for_rust: describe what 0.1.0 does

The page listed Dynamic FPS's toast pausing and battery HUD and toasts, which this build doesn't have. It now names the caps it sets, the JSON config file, and that it has no settings screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Lyvt-Dev <[email protected]>
Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:45:14 +01:00
f1cdfcb3e8 Add connected_textures 0.1.2 (#236)
* Add connected_textures 0.1.2

Submitted by @tariksabanuz through the form.

Submission: 8z1xdnIhXYT7sDm0v6CCif30RGP2lYOLB6q2V6C9ef8
Co-authored-by: tariksabanuz <[email protected]>

* connected_textures: icon from raw.githubusercontent.com (the blob page is HTML, not the image)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: tariksabanuz <[email protected]>
Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:45:08 +01:00
4931f3a819 Add chat_animation 0.1.0 (#237)
* Add chat_animation 0.1.0

Submitted by @tariksabanuz through the form.

Submission: gypApON8d6DU7sdJ81St9xAxOONKxb_edS-LUEh95yQ
Co-authored-by: tariksabanuz <[email protected]>

* chat_animation: icon from raw.githubusercontent.com (the blob page is HTML, not the image)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: tariksabanuz <[email protected]>
Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:45:02 +01:00
7b74fe93b1 Update horizon_physics 0.18.0 (#247)
* Update horizon_physics 0.18.0

Submitted by @tariksabanuz through the form.

Submission: dUwtD3FcOST_rD7zK-b-0LW83RvCQatWATzx8eZplT0
Co-authored-by: tariksabanuz <[email protected]>

* horizon_physics: keep the hosted icon, and say it is a clean-room project not made by PhysicsMod's author

The new icon was a GitHub blob page (HTML) of a 250 KB animated preview; the entry's own icons/horizon_physics.png stays. The summary and description keep the 0.15.1 page's statement that it holds none of PhysicsMod's code or assets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: tariksabanuz <[email protected]>
Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:44:57 +01:00
4ccc44d612 Update mod_menu 0.3.3 (#249)
* Update mod_menu 0.3.3

Submitted by @tariksabanuz through the form.

Submission: l0GzXMA1DQh20OewDGgMOQvMM_CnMnPSnV7Imjga354
Co-authored-by: tariksabanuz <[email protected]>

* mod_menu: icon from raw.githubusercontent.com (the blob page is HTML, not the image)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: tariksabanuz <[email protected]>
Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:44:51 +01:00
Ludovic Marieandsoufied 08e63434d8 Add elytratrails 0.1.1 (#243)
Submitted by @soufied through the form.

Submission: s-vCQDWUb2ERuokdGrsb7f0aqZNnC0RFvIDDTpNA4-0

Co-authored-by: soufied <[email protected]>
2026-11-05 12:44:23 +01:00
Ludovic Marieandumbx-dot 40dc440cbb Add skins_3d 1.0.1 (#234)
Submitted by @umbx-dot through the form.

Submission: iq1lwNozVHP5zvlI6m_6dkYCzKr7r9aqelCutm6TPoQ

Co-authored-by: umbx-dot <[email protected]>
2026-11-05 12:44:17 +01:00
Ludovic MarieandDemerobit 7581c129bb Add speedrun_timer 1.0.1 (#235)
Submitted by @Demerobit through the form.

Submission: V9L2aX5TnIMFZh4odJG2RZ3epEL8ioxf9RWAz1f8HVQ

Co-authored-by: Demerobit <[email protected]>
2026-11-05 12:44:10 +01:00
Ludovic MarieandCGRLabs 259d022945 Add gyro 1.4.1 (#250)
Submitted by @CGRLabs through the form.

Submission: XVefLjtdKX_5c0lXClOAPfR4M89zoIQkU6l36mtbd7I

Co-authored-by: CGRLabs <[email protected]>
2026-11-05 12:44:05 +01:00
78853c19a6 Registry: record owner_ids for the 78 entries that predate them (#251)
Filled by horizon's tools/registry_backfill_owner_ids.py (horizon#9688): every owner login resolved and
matched the IDs the submission form recorded; nothing was left alone.

Co-authored-by: Horizon agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 07:30:52 +01:00
e53ad8220b SMP 0.33.0: no daily sale or /pay caps, no resale delay; a 20% price floor instead (#228)
* Remove SMP daily coin cap on sales and payments

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>

* Remove SMP daily per-item sale limit

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>

* Allow immediate resale of SMP market purchases

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>

* SMP 0.33.0: a 20% price floor carries what the daily caps did

With no daily sale caps, the market's moving price is what bounds a farm:
- min_factor 0.6 -> 0.2 (a config still on the old default gets 0.2 at load)
- an item's depth is at most 50 items a 1% (was up to 400), so the cheap
  items farms make reach the floor within 10,000 sold; a family takes its
  step from its cheapest member, so every member does
- tests: 10,000 of any item sold a minute at a time end at the floor and
  bring under half their base value (diamond 21%, wheat 44%), and the price
  comes back within a day; buying and selling straight back (no resale
  delay) never earns, for every item traded both ways, 5 amounts, from a
  fresh price, the floor and the cap (495 round trips)
- proof: 1,000 wheat sold over four minutes, 250 a minute, the price lower
  after each sale

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* SMP proof: the cobblestone stack's comment for the new depth

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Codex gpt-6.1-sol <[email protected]>
Co-authored-by: Ludovic Marie <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 00:14:07 +01:00
Ludovic MarieandClaude Opus 5.5 43fb2513a2 smp: /rtp (/wild): random safe spot 500-5000 blocks out, 30 min cooldown (#241)
* smp: /rtp (/wild) to a random safe spot 500-5000 blocks from the spawn, 30 min cooldown

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: /rtp draws until enough points fall inside a close border

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: /rtp undo as a struct; rustfmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: /rtp proof (2 clients, flat world) and SMP_PROOF=rtp tick cost on a normal world; per-/rtp read cost in the log

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* smp: proof checks the /rtp landing with execute ... run say (block tags aside)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 21:02:45 +01:00
Ludovic MarieandThomas-Slot dfd2102274 Add armorhud 1.0.0 (#207)
Submitted by @Thomas-Slot through the form.

Submission: 76bkxGdXpb4ErocsWQ1eS7V4X2HKbITdM-3LpERXGcM

Co-authored-by: Thomas-Slot <[email protected]>
2026-11-04 18:12:52 +05:00
Ludovic Marieandtomasmendes01 35ce93db53 Add wisdom_horizon_lite 1.1.0 (#208)
Submitted by @tomasmendes01 through the form.

Submission: QMPtuLy0qMsJH6Y0KP2iDIvzdYTpPS0FOLtPdmcSYd8

Co-authored-by: tomasmendes01 <[email protected]>
2026-11-04 18:12:45 +05:00
Ludovic Marieandtomasmendes01 3f3103d070 Add wisdom_horizon 1.1.0 (#209)
Submitted by @tomasmendes01 through the form.

Submission: XSR34WRLB30OrdD_82S40jKedlhtfgeaXXKyxlVPyis

Co-authored-by: tomasmendes01 <[email protected]>
2026-11-04 18:12:38 +05:00
Ludovic Marieandeltanschauung ed4aa85442 Add tectonic_horizon 0.3.4 (#210)
Submitted by @eltanschauung through the form.

Submission: kwCxRPKxqLPLNKKKt1Y3GT_vcpxGHQAPWgryPVRQq2s

Co-authored-by: eltanschauung <[email protected]>
2026-11-04 18:12:31 +05:00
Ludovic MarieandLyvt-Dev 43010d3eb2 Add fast_items 0.1.0 (#211)
Submitted by @Lyvt-Dev through the form.

Submission: deFN4Tw-EYAlMF5JrYvuWRKVeE70Nz8_RLfc3kFSyaA

Co-authored-by: Lyvt-Dev <[email protected]>
2026-11-04 18:12:25 +05:00
Ludovic MarieandBioneisme 546deac561 Add wayfarer_stones_native 0.6.0 (#216)
Submitted by @Bioneisme through the form.

Submission: fY38YPhqMsck59uJmF_l2k0rbzIoxvjj_NXKnGXK-uE

Co-authored-by: Bioneisme <[email protected]>
2026-11-04 18:12:18 +05:00
Ludovic MarieandBioneisme 2eca4bc2ec Add wayfarer_stones 0.6.0 (#215)
Submitted by @Bioneisme through the form.

Submission: krIkX2eCFc4Ty6wnSxmrnN2eSsO80WFuPorufUdY52k

Co-authored-by: Bioneisme <[email protected]>
2026-11-04 18:12:10 +05:00
Ludovic Marieandtariksabanuz a2ae910123 Update resourcify 0.1.1 (#218)
Submitted by @tariksabanuz through the form.

Submission: 37-NCw4vlb5QvsaAc75XvzDGeWa_EI_NcX66Dhyeid8

Co-authored-by: tariksabanuz <[email protected]>
2026-11-04 18:11:57 +05:00
101 changed files with 2252 additions and 583 deletions

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ Some servers see fast, accurate placing as a cheat: check their rules, or turn "
A port of [Accurate Block Placement: Reborn](https://modrinth.com/mod/accurate-block-placement-reborn) (MIT) by Clayborn, Flourick, schwar and KadTheHunter, rewritten in Rust for Horizon."""
authors = ["Lutziano888"]
owners = ["Lutziano888"]
owner_ids = [129792568]
license = "MIT"
source = "https://github.com/Lutziano888/horizon-accurate-block-placement"
icon = "https://raw.githubusercontent.com/Lutziano888/horizon-accurate-block-placement/main/icon.png"
+1
View File
@@ -11,6 +11,7 @@ Based on AppleSkin by squeek502. See the LICENSE in the source for the
original copyright and licence notices."""
authors = ["soufi"]
owners = ["soufied"]
owner_ids = [47915535]
license = "MIT"
source = "https://github.com/soufied/AppleSkin-Horizon"
icon = "https://raw.githubusercontent.com/soufied/AppleSkin-Horizon/refs/heads/main/icons/appleskin.png"
+1
View File
@@ -11,6 +11,7 @@ Food Value Preview: Pulsing preview drumsticks and preview saturation border whe
Clean Integration: Easily toggled via Options > Mods > AppleSkin."""
authors = ["CayMac"]
owners = ["CayMac97"]
owner_ids = [197672048]
license = "MIT"
source = "https://github.com/CayMac97/appleskin"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
+1
View File
@@ -12,6 +12,7 @@ Item count display for non-damageable held items (torches, food, blocks).
Default position: Bottom Right (freely customizable in Options > Mods)."""
authors = ["CayMac"]
owners = ["CayMac97"]
owner_ids = [197672048]
license = "MIT"
source = "https://github.com/CayMac97/armor-status"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
+25
View File
@@ -0,0 +1,25 @@
id = "armorhud"
kind = "native-mod"
name = "Armor HUD"
summary = "Armor HUD mod"
description = "A mod that adds 4 armor durability viewing slots to the hotbar."
authors = ["Thomas9950"]
owners = ["Thomas-Slot"]
owner_ids = [265566914]
license = "MIT"
source = "https://github.com/Thomas-Slot/horizon-armor-hud"
icon = "https://github.com/Thomas-Slot/horizon-armor-hud/blob/main/icon.png"
key = "RWQNLUB+Hf9pa54kEx/ItgJHmDivzpAZxK9lqvv9wnecj34yzNVQHQur"
[[versions]]
version = "1.0.0"
published = "2026-11-04"
game = "26.3"
file = "armorhud-1.0.0.hnmod"
sha256 = "8bd85e1584f307201bce5de2e60466d603b61d4807803d4ebc02772b4f6b704b"
signature = """
untrusted comment: signature from minisign secret key
RUQNLUB+Hf9pa0KnzbNFmdhyi6xpMr56two0l9g3QlnoBYyrHFtJt5luXXEl9FnJQpP6GH7Y9KDqYUSAac7QZEQDejqrM2gA7A4=
trusted comment: timestamp:1793750475\tfile:armorhud-1.0.0.hnmod\thashed
q840egwiCqF5Ptn8MCP/0VqaPUQQgaWzyXwhnFhD47GLeblsZWdvPyaTnEUbsGxHCgHGT5FFrqJ54Mu1OSz/DQ=="""
changelog = "First upload."
+1
View File
@@ -20,6 +20,7 @@ Weapons hurt the mobs they hit. With blasting turned on in the control panel (of
Built for this Horizon commit only: `68ac6f647e7022b2c696e81863d7cface28c9416`."""
authors = ["repositorykreml1n"]
owners = ["repositorykreml1n"]
owner_ids = [271232376]
license = "MIT"
source = "https://github.com/repositorykreml1n/astral-cruiser"
+1
View File
@@ -20,6 +20,7 @@ Client-side only: unknown health stays unknown, unobserved totem uses cannot be
Release notes and verification: https://github.com/repositorykreml1n/the-basic-hud/releases/tag/v0.5.0"""
authors = ["repositorykreml1n"]
owners = ["repositorykreml1n"]
owner_ids = [271232376]
license = "MIT"
source = "https://github.com/repositorykreml1n/the-basic-hud"
icon = "https://raw.githubusercontent.com/repositorykreml1n/the-basic-hud/v0.5.0/icon.png"
+1
View File
@@ -14,6 +14,7 @@ Craft a **Biome Compass** (a compass with a sapling on each of its four sides, a
Inspired by [Nature's Compass](https://modrinth.com/mod/natures-compass); not affiliated, no code from it."""
authors = ["d3grando"]
owners = ["d3grando"]
owner_ids = [36463198]
license = "MIT"
source = "https://github.com/d3grando/biomecompass"
icon = "icons/biomecompass.png"
+1
View File
@@ -5,6 +5,7 @@ summary = "The block under your crosshair: its name, id, distance and icon, plus
description = "A Jade-style tooltip for Horizon: the block under your crosshair with its name in your language, block id, distance and icon, plus its hardness and the tool it wants, its state properties, a crop's growth and, for a container, its slots. Ten switches on its own settings page."
authors = ["Domanffe"]
owners = ["Domanffe"]
owner_ids = [94391234]
license = "MIT"
source = "https://github.com/Domanffe/blockscope"
icon = "https://horizon-rust.github.io/horizon-mods/icons/blockscope.png"
+1
View File
@@ -15,6 +15,7 @@ Sneak and right-click with an empty hand to pick up a block with its contents (c
Inspired by [Carry On](https://modrinth.com/mod/carry-on); not affiliated, no code from it."""
authors = ["d3grando"]
owners = ["d3grando"]
owner_ids = [36463198]
license = "MIT"
source = "https://github.com/d3grando/carryblock"
icon = "icons/carryblock.png"
+30
View File
@@ -0,0 +1,30 @@
id = "chat_animation"
kind = "native-mod"
name = "ChatAnimationHorizon"
summary = "ChatAnimation for Horizon"
description = """
By tariksabanuz
ChatAnimationHorizon brings smooth chat animations to Horizon, recreating Ezzenix's Chat Animation mod. New chat messages slide and fade in instead of popping onto the screen.
Features
- Messages animate in smoothly when they arrive
- Works with the game's normal chat, no setup needed
Notes
- Native Horizon mod, built from source by the launcher.
- Based on Ezzenix's Chat Animation (MIT licensed), recreated for Horizon.
Source code: https://github.com/tariksabanuz/ChatAnimationHorizon"""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/ChatAnimationHorizon"
icon = "https://raw.githubusercontent.com/tariksabanuz/ChatAnimationHorizon/3794c3c1ede07a1c7ebb80856b246a1ad4339101/icon.png"
[[versions]]
version = "0.1.0"
published = "2026-11-04"
game = "26.3"
file = "chat_animation-0.1.0.hnmod"
sha256 = "aee273e2861a185ed2667679c0f943dcc73693b46eded6c078cde8bdf13ee49a"
+1
View File
@@ -5,6 +5,7 @@ summary = "A player's head left of every chat line they wrote."
description = "Each chat line from a player gets their 8x8 skin face in the strip to its left; lines without a player stay as they are, and the face fades with its line. The switch lives in Options > Mods."
authors = ["Domanffe"]
owners = ["Domanffe"]
owner_ids = [94391234]
license = "MIT"
source = "https://github.com/Domanffe/chatheads"
icon = "https://horizon-rust.github.io/horizon-mods/icons/chatheads.png"
+1
View File
@@ -22,6 +22,7 @@ Native source mod: install it on the launcher's Mods page, press Build there, an
Original mod by Mayonesu0. All rights reserved, except the grants in its [LICENSE](https://github.com/Mayonesu0/chop/blob/main/LICENSE): players may use it unchanged, Horizon's registry may distribute it, and Horizon's reviewers may read it to review it."""
authors = ["Mayonesu0", "Claude"]
owners = ["Mayonesu0"]
owner_ids = [350298764]
license = "LicenseRef-Chop-1.0"
source = "https://github.com/Mayonesu0/chop"
icon = "https://raw.githubusercontent.com/Mayonesu0/chop/main/registry/icon.png"
+1
View File
@@ -37,6 +37,7 @@ Both can be installed at once: they use different keys.
Source and issues: [github.com/SU4G3/classic-zoom](https://github.com/SU4G3/classic-zoom)"""
authors = ["Surge"]
owners = ["SU4G3"]
owner_ids = [92383635]
license = "GPL-3.0-or-later"
source = "https://github.com/SU4G3/classic-zoom"
icon = "icons/classic_zoom.png"
+1
View File
@@ -16,6 +16,7 @@ A compass bar at the top of your screen that shows where you are facing, in degr
Settings are in Options → Mods → Compass Bar. Death points are not saved: they vanish when you close the game."""
authors = ["Isaac"]
owners = ["Fernandes585"]
owner_ids = [344517364]
license = "MIT"
source = "https://github.com/Fernandes585/Compass-Enhanced"
key = "RWScU5xtTnTnu55tXSTPbhM2BQ6KG2FLeMD9aTeX111NlQPh7cCafm1w"
+32
View File
@@ -0,0 +1,32 @@
id = "connected_textures"
kind = "native-mod"
name = "ContinuityHorizon"
summary = "Continuity for Horizon"
description = """
By tariksabanuz
ContinuityHorizon brings Continuity-style connected textures to Horizon. Neighbouring blocks of the same kind join up into one seamless surface instead of showing a border on every block.
What it connects
- Glass and glass panes
- Bookshelves
- Sandstone
Notes
- Works with the game's own textures; no resource pack needed.
- Native Horizon mod, built from source by the launcher.
- Inspired by Continuity (MIT licensed), recreated for Horizon.
Source code: https://github.com/tariksabanuz/ContinuityHorizon"""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/ContinuityHorizon"
icon = "https://raw.githubusercontent.com/tariksabanuz/ContinuityHorizon/cd5301759efef6b1819ed7a8590a0d76e15d6d7e/icon.png"
[[versions]]
version = "0.1.2"
published = "2026-11-04"
game = "26.3"
file = "connected_textures-0.1.2.hnmod"
sha256 = "e86d1d3fd92045737b90218de51c158b60519afc6618c063c87f64ca68d36adf"
+1
View File
@@ -18,6 +18,7 @@ rests at once, and the next build leaves it out. Its source is
"""
authors = ["Horizon"]
owners = ["ludovic111"]
owner_ids = [136255825]
license = "MIT"
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/coords-hud"
icon = "icons/coords_hud.png"
+34
View File
@@ -0,0 +1,34 @@
id = "create"
kind = "native-mod"
name = "Create for Horizon"
summary = "Build rotating machines and small factories in Horizon with gears, belts, presses and fans."
description = """
An experimental Rust port of [Create](https://github.com/Creators-of-Create/Create).
- Drive machines with water wheels, hand cranks or a Creative Motor.
- Route power through shafts, gears, gearboxes, clutches and encased chain drives.
- Move items on basic belts and depots. Press sheets, mill ingredients, mix in a Basin or process items with a fan.
Install it as a native mod in Horizon Launcher and build the instance. It uses simple built-in models and standard Minecraft textures out of the box.
**The original models and textures require the original Create 6.0.10 JAR for Minecraft 1.21.1 (NeoForge).** Place one copy in the instance's `horizon-mods/create-resources/` folder and restart Horizon. The JAR is optional and resources are imported locally. [Supported JAR](https://modrinth.com/mod/create/version/UjX6dr61)
Moving contraptions, trains and Ponder are planned. [Setup and compatibility](https://github.com/Delete228/create-horizon)"""
authors = ["Delete228"]
owners = ["Delete228"]
owner_ids = [31237303]
license = "MIT"
source = "https://github.com/Delete228/create-horizon/commit/236c87a889cd76f7e663d1d182c28dc1145f7b14"
[[versions]]
version = "0.7.0-alpha.2"
published = "2026-11-05"
game = "26.3"
file = "create-0.7.0-alpha.2.hnmod"
sha256 = "13638c5571ef7ac32be5bb19ddac90959796d6c102122de09f6a81d39be74d97"
changelog = """
First experimental registry submission for Minecraft 26.3. This build includes kinetic transmission, basic belts, depots, pressing, milling, Basin recipes, fans and stationary drilling. Built-in models use standard Minecraft textures. The optional original JAR is supplied by the player.
Horizon is pinned to `9c7f9b2215dfed497aba5522c44f38926628648d` and Horizon Mixins to `e8cd0aa38cffef49cb2e088ef716baa107148c02`. The macOS ARM64 Launcher 0.2.42 Performance build completed after restoring a truncated source download. The player verified the built-in appearance and the original resources in that profile.
Archive SHA-256: `13638c5571ef7ac32be5bb19ddac90959796d6c102122de09f6a81d39be74d97`. [Validation](https://github.com/Delete228/create-horizon/blob/236c87a889cd76f7e663d1d182c28dc1145f7b14/VALIDATION.md#optional-resources-in-070-alpha2)"""
+1
View File
@@ -15,6 +15,7 @@ Cooldown Bar: Optional subtle weapon cooldown progress indicator below the cross
Configurable: Easily customize styles, themes, and keybinds in Options > Mods > Crosshair Indicator."""
authors = ["CayMac"]
owners = ["CayMac97"]
owner_ids = [197672048]
license = "MIT"
source = "https://github.com/CayMac97/crosshair-indicator"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
+1
View File
@@ -7,6 +7,7 @@ Hop versions, offline.
Uses ViaProxy as an inbetween for now."""
authors = ["JustKarkat"]
owners = ["JustKarkat"]
owner_ids = [50185858]
license = "MIT"
source = "https://github.com/JustKarkat/horizon-crossversion/"
icon = "https://github.com/JustKarkat/horizon-crossversion/blob/master/icon.png?raw=true"
+1
View File
@@ -15,6 +15,7 @@ Shows the current survival day, an in-game clock and a color-shifting bar that f
- English, Italian, German, Spanish, French and Portuguese."""
authors = ["SCARABOCCHIO100"]
owners = ["sonoFrangu"]
owner_ids = [205944444]
license = "MIT"
source = "https://github.com/sonoFrangu/CustomHUD-Horizon"
icon = "icons/customhud.png"
+24
View File
@@ -0,0 +1,24 @@
id = "deepbore_quarry"
kind = "mod"
name = "Deepbore Quarry (assets)"
summary = "The textures, text and recipes of Deepbore Quarry. Needs the Deepbore Quarry native mod."
description = """
**This is one of the two parts of [Deepbore Quarry](https://playhorizon.dev/mods/deepbore_quarry_native), and does nothing alone.** Install both: this one, the assets, and the native part. If one is missing, the game says so in chat.
It holds the mod's models and textures, its English and Russian text, its recipes and drops, and its screen's menu. It has no code. The code, and the description of what the mod does, are in Deepbore Quarry.
All the art is original. No Minecraft assets are included. MIT licensed."""
authors = ["bioneisme"]
owners = ["Bioneisme"]
owner_ids = [92920845]
license = "MIT"
source = "https://github.com/Bioneisme/deepbore-quarry"
icon = "https://raw.githubusercontent.com/Bioneisme/deepbore-quarry/main/icon.png"
[[versions]]
version = "0.1.0"
published = "2026-11-05"
game = "26.3"
file = "deepbore_quarry-0.1.0.hmod"
sha256 = "2a75f3adc59aaf273fd833545c50a63a7f829916156b74d85bebbb6273b0d8b6"
changelog = "First release."
+34
View File
@@ -0,0 +1,34 @@
id = "deepbore_quarry_native"
kind = "native-mod"
name = "Deepbore Quarry"
summary = "A machine that digs a square pit down to bedrock on furnace fuel and fills a chest, under a moving gantry with a drill."
description = """
**This mod has two parts, and you need both.** Install this one, the native part, and [Deepbore Quarry (assets)](https://playhorizon.dev/mods/deepbore_quarry), then press Build. If one is missing, the game says so in chat.
- **Place** a quarry. The pit lies beyond it, the way you are looking, and goes from the quarry's own level down to bedrock.
- **Put a chest** against another side of it, and fuel in its slot: anything a furnace burns. One coal digs eight blocks.
- **Watch** the gantry: a drill runs over the pit on a moving bridge, row by row. The screen says what the quarry is doing, and why when it stands still.
- **Choose** four upgrades: Area up to 17×17, Speed up to a block in 0.25 s, Fluids to plug water and lava and dig on, Chunk to keep working when you are away.
- **Keep** what matters: it leaves bedrock and any block with contents, and only its owner or an operator can open or break it.
English and Russian.
**Commands:** operators have `/deepbore maxsize` and `/deepbore maxsize <5|9|13|17>`, the largest area a world allows.
Native source mod: install it on the launcher's Mods page, press Build there, and confirm the native-code warning. It runs with your account's permissions. It is pinned to one Horizon Nightly and Horizon Mixins revision, named in each version's changelog.
No Minecraft code or assets are included. MIT licensed."""
authors = ["bioneisme"]
owners = ["Bioneisme"]
owner_ids = [92920845]
license = "MIT"
source = "https://github.com/Bioneisme/deepbore-quarry"
icon = "https://raw.githubusercontent.com/Bioneisme/deepbore-quarry/main/icon.png"
[[versions]]
version = "0.1.0"
published = "2026-11-05"
game = "26.3"
file = "deepbore_quarry_native-0.1.0.hnmod"
sha256 = "bda03ffe35cca7973ec8a19cbcd5b50dade5f698c30faea8f84b9343cfeb31ef"
changelog = "First release. Pinned to Nightly 2026-11-04 (67dad0d) and Horizon Mixins e8cd0aa."
+1
View File
@@ -21,6 +21,7 @@ Known limits: heavy at 256+ chunks (about 30–40 fps on an Apple M5 MacBook Pro
LGPL-3.0, as Distant Horizons; its copyright notices are kept. Not made or endorsed by the Distant Horizons team. No Minecraft code or assets are included. Ported by Deniz Şahin with Claude (Anthropic)."""
authors = ["denizsahin7744", "Deniz7744"]
owners = ["denizsahin7744"]
owner_ids = [144704661]
license = "LGPL-3.0-only"
source = "https://github.com/denizsahin7744/distant-horizons-for-horizon-client"
+33
View File
@@ -0,0 +1,33 @@
id = "dynamic_fps_for_rust"
kind = "native-mod"
name = "Dynamic FPS for rust"
summary = "Reduce resource usage while Minecraft is in the background, idle, or on battery"
description = """
Dynamic FPS for Horizon: it lowers the frame rate, and optionally the volume, while the game window is in the background, hovered but not focused, minimised or idle, or while Horizon's battery mode is on.
- Caps by default: 30 FPS hovered, 10 unfocused (after half a second), 1 minimised, 20 after a minute idle, 30 on battery; no cap while you play.
- At 1 FPS the game still runs its input, network and ticks up to 20 times a second.
- Volume lowering is off by default.
- Settings: `config/dynamic-fps-for-rust.json` in the game folder, written with the defaults on first start. There is no settings screen yet.
An independent implementation after the public description of Dynamic FPS; it holds none of its code. It changes no shared game settings and has no network access."""
authors = ["Lyvt (dc: _.lyvt)"]
owners = ["Lyvt-Dev"]
license = "MIT"
source = "https://github.com/Lyvt-Dev/dynamic-fps-for-rust"
icon = "https://raw.githubusercontent.com/Lyvt-Dev/dynamic-fps-for-rust/main/logo/dynamic-fps.png"
key = "RWQ84d8BvD1EaarVDgHgXyz+MDiL7ONsCZc0BUrJqTmqzXBItnJy918A"
[[versions]]
version = "0.1.0"
published = "2026-11-04"
game = "26.3"
file = "dynamic_fps_for_rust-0.1.0.hnmod"
sha256 = "50fad9a8d1d481a3862479b313c04e0dc6f8494a84d4cd007583822f5671e17e"
signature = """
untrusted comment: signature from minisign secret key\r
RUQ84d8BvD1EaVhURO1FuYGMB/oFMSego05WqzetlxYu+Xqw833gPsVaui5/duBbZ3lGVErA0gcc/vxslYvhhaGkPRciB+/40wg=\r
trusted comment: Dynamic FPS for rust 0.1.0; dynamic-fps-for-rust-0.1.0.hnmod\r
V1fosOt0MvcQQm5uFTaFubXeoX1nt3411VAeox/pIV+R/GbcsiJGGRO/p1NcY39sOFOFkF3qWg9pNt/J4EXaDA=="""
changelog = """
First release: frame rate and optional volume caps for an unfocused, hovered, minimised or idle window, and on battery."""
+1
View File
@@ -12,6 +12,7 @@ The frames come from a resource pack you build from your own copy of the EatingA
~~Or you can just download it from GitHub Releases...~~"""
authors = ["soufi"]
owners = ["soufied"]
owner_ids = [47915535]
license = "MIT"
source = "https://github.com/soufied/EatingAnimation-Horizon"
icon = "icons/eatinganimation.png"
+70
View File
@@ -0,0 +1,70 @@
id = "elytratrails"
kind = "native-mod"
name = "ElytraTrails"
summary = "A Horizon mod that adds elytra trails with additional features."
description = """
A native mod for Horizon. The idea comes from [ElytraTrails](https://github.com/dbrighthd/elytratrails) by dbrighthd.
What it does
Lines stream from the outer tip of each wing while you glide and fade out over time. Only players get trails.
- Colors: ice, gold, rose, violet, white, rainbow, or speed (cold blue when slow, orange and thicker when fast).
- Patterns: solid, dashed or wavy. Built in only.
- Pulse: gently breathing brightness.
- Night: lines are brighter at night and dimmer in the day.
- Boost: a sudden speed jump, such as a firework, lights the whole trail up for two seconds.
- Formation: gliding players close to each other are joined by white lines between their wingtips.
- Lines taper toward the tail, the wingtip has a soft glow, and fading eases in and out.
- Singleplayer: without LAN or a server, all code for other players is skipped.
- Sharing: your color and pattern are sent through the server, so other players see them (single player, LAN and servers). (Untested, I don't have friends)
Settings
Open Options > Mods > ElytraTrails:
- **Trails**: turns every line on or off.
- **Your trail**: your own lines.
- **Your trail in first person**: on by default. Turn it off to see your lines only in third person (F5).
- **Other players' trails**: the lines of other players within 48 blocks.
- **Color**: see the list above.
- **Duration**: how long a line lasts, 1 to 12 seconds (default 5).
- **Width**: thin, normal or wide.
- **Pattern**: solid, dashed or wavy.
- **Pulse**: on or off.
- **Day/night brightness**: off by default (same brightness at any time). On: lines are brighter at night and dimmer in the day.
- **Optimization** (submenu):
- **Your wings**: dynamic (the lines follow the real wing movement) or fixed (the line is computed once when gliding starts and then only moves with your body, which is cheaper).
- **Other players' wings**: dynamic or fixed, the same for other players.
- **Line detail**: full or half (half draws every second segment, about half the lines).
- **Other players' range**: 24, 48 or 96 blocks.
- **Formation lines**: on or off.
- **Boost flash**: on or off.
## Credits
- ElytraTrails by [dbrighthd](https://github.com/dbrighthd/elytratrails) (MPL-2.0), the original mod. The idea of wing trails comes from it.
- This is a separate implementation for Horizon, written from scratch in Rust. It does not contain code from the original mod.
License
MIT. See LICENSE."""
authors = ["soufi"]
owners = ["soufied"]
license = "MIT"
source = "https://github.com/soufied/ElytraTrails-Horizon"
icon = "https://raw.githubusercontent.com/soufied/ElytraTrails-Horizon/refs/heads/main/icons/elytratrails.png"
key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760"
[[versions]]
version = "0.1.1"
published = "2026-11-04"
game = "26.3"
file = "elytratrails-0.1.1.hnmod"
sha256 = "feea003d336b3275be563c1a1ae7c4d455385d6c1b75712ef879861e121ba198"
signature = """
untrusted comment: signature from minisign secret key
RUT6eNq4e5WfVKBjiTqR7gbjglmDfuhOIdATC/awPevLUyV9sM16EwDCZwn4VxOgfNuuN0TmYr2ibtINbtsiO5NM2H8yzVwmkAw=
trusted comment: timestamp:1793818562\tfile:elytratrails-0.1.1.hnmod\thashed
+74DRK4ausg/UhM7gcmF5VgafBkcLHJ8TmIk2V1dRk/h+9rMuNhLV7YIeCnplg8BOstJUuBRauVxNkcn9uEnAw=="""
changelog = "Rewritten."
+1
View File
@@ -13,6 +13,7 @@ Every enchantment on books and gear gets a one-line description in its tooltip:
Settings: Options → Mods → Enchantment Hints. Inspired by [Enchantment Descriptions](https://modrinth.com/mod/enchantment-descriptions); not affiliated, no code or text from it."""
authors = ["d3grando"]
owners = ["d3grando"]
owner_ids = [36463198]
license = "MIT"
source = "https://github.com/d3grando/enchantmenthints"
icon = "icons/enchantmenthints.png"
+1
View File
@@ -21,6 +21,7 @@ While flying, your player doesn't move, attack, mine or use items.
Some servers don't allow freecam, so check their rules first."""
authors = ["Lutziano888"]
owners = ["Lutziano888"]
owner_ids = [129792568]
license = "MIT"
source = "https://github.com/Lutziano888/horizon-freecam"
icon = "icons/fair_freecam.png"
+36
View File
@@ -0,0 +1,36 @@
id = "fast_items"
kind = "native-mod"
name = "Fast Items for Horizon"
summary = "Optimize item rendering by transforming item entities to 2D"
description = """
Fast Items (2D Items)
Optimizes item rendering by transforming items to 2D forms.
For 2D items(such as potions), this mod avoids rendering their sides, so they are completely flat
For 3D items(such as cube blocks), this mod makes them always face to camera, just as the same behaviour in Lunar Client/BLC."""
authors = ["Lyvt (dc: _.lyvt)"]
owners = ["Lyvt-Dev"]
owner_ids = [138261776]
license = "MIT"
source = "https://github.com/Lyvt-Dev/FastItems-for-Horizon"
icon = "https://github.com/Lyvt-Dev/FastItems-for-Horizon/tree/main/logo/fastitems.png"
key = "RWQ84d8BvD1EaarVDgHgXyz+MDiL7ONsCZc0BUrJqTmqzXBItnJy918A"
[[versions]]
version = "0.1.0"
published = "2026-11-04"
game = "26.3"
file = "fast_items-0.1.0.hnmod"
sha256 = "c3acd8adfeac6450c3a13b5172c8abb298cb3ceb355dfc539b4bdd653ef15502"
signature = """
untrusted comment: signature from minisign secret key\r
RUQ84d8BvD1EaR6fPq1+iCBwAhUosfQDHMjVQLYQkIRcmU7I9Ls7Z3nC8wFUF4BOzgB4DaP5UonvlkAG4wqNMZH6EnRLmwaGqgw=\r
trusted comment: Fast Items for Horizon 0.1.0; fast-items-0.1.0.hnmod\r
iqcDMYeA2LekavHGy7yMhtCtH6kCyjq7x76cVPD994O+CYj0lfW4tZL1HSgtXQJFM+3Gw3h2xr55DndjcqFiAA=="""
changelog = """
Optimizes item rendering by transforming items to 2D forms.
For 2D items(such as potions), this mod avoids rendering their sides, so they are completely flat
For 3D items(such as cube blocks), this mod makes them always face to camera, just as the same behaviour in Lunar Client/BLC."""
+1
View File
@@ -5,6 +5,7 @@ summary = "Custom accessory mod"
description = "A cosmetics catalog for Horizon. Open it from the title screen or the pause menu and wear HD capes that move like cloth, animated bandanas with Japanese script, hats, wings, shoulder pets and auras with colour themes. Turn your player in a live preview, hide your armor, and add your own capes and Blockbench cosmetics with animations. Only you see them."
authors = ["_idom_"]
owners = ["umbx-dot"]
owner_ids = [203141410]
license = "MIT"
source = "https://github.com/umbx-dot/horizon-flair"
icon = "https://raw.githubusercontent.com/umbx-dot/horizon-flair/main/icon.png"
+1
View File
@@ -5,6 +5,7 @@ summary = "Allows you to change your fly speed"
description = "Fly 0.1x to 20x your normal speed: /flyspeed, /flyspeed +1 to step, /flyspeed reset for 1x. Native Horizon mod."
authors = ["ImmortalDev"]
owners = ["ImmortalWhisper"]
owner_ids = [147957682]
license = "All Rights Reserved"
source = "https://github.com/ImmortalWhisper/Fly-Speed"
key = "RWSy3RAfbzD2a0WMm0tQlVl+3GHigjwvwXKvzvnwfmoYrIAoIffbrSbP"
+1
View File
@@ -18,6 +18,7 @@ Options > Mods: it rests at once, and the next build leaves it out. Its source i
"""
authors = ["Horizon"]
owners = ["ludovic111"]
owner_ids = [136255825]
license = "MIT"
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/fps-overlay"
+1
View File
@@ -5,6 +5,7 @@ summary = "Freecam mod ported for Horizon"
description = "A rewrite for Horizon of the Freecam mod by the Freecam authors (https://github.com/MinecraftFreecam/Freecam, MIT; on Modrinth as freecam). Its licence notice is kept in the source."
authors = ["hakergeniusz"]
owners = ["hakergeniusz"]
owner_ids = [117102435]
license = "MIT"
source = "https://github.com/hakergeniusz/freecam"
key = "RWR4cJC6Jj3EWGi26o29Rs3hAIPWqf8koFdXV4bBLF/Tm84QPfRAYjO5"
+1
View File
@@ -8,6 +8,7 @@ Press U and your camera leaves your body. Fly it with WASD, Space and Shift, hol
Since this is a native mod it will not work in v0.2.97 as there are no native hooks possible. This works on all nightly releases and LIKELY on version upwards from v0.2.97."""
authors = ["_idom_"]
owners = ["umbx-dot"]
owner_ids = [203141410]
license = "MIT"
source = "https://github.com/umbx-dot/horizon-freecam"
icon = "icons/freecam_plus.png"
+1
View File
@@ -7,6 +7,7 @@ Freelook is a Freelook/Perspective Mod for Fabric, allowing you to move your cam
Camera distance can be adjusted by scrolling."""
authors = ["Krzysix17"]
owners = ["Krzysix17"]
owner_ids = [350813197]
license = "MIT"
source = "https://github.com/Krzysix17/horizon-freelook"
+1
View File
@@ -16,6 +16,7 @@ See in the dark without torches or night vision. MoreGamma lets you set the brig
Client-side: the commands are handled by your game before anything reaches a server, so it works in single player, on LAN and on servers. Your brightness slider and `options.txt` are never changed, so turning it off gives your own brightness back. Settings are remembered between sessions."""
authors = ["bezi5kt"]
owners = ["bezi5kt"]
owner_ids = [45046654]
license = "MIT"
source = "https://github.com/bezi5kt/gamma-rust"
icon = "icons/fullbright.png"
+1
View File
@@ -11,6 +11,7 @@ Smooth 250ms brightness fade transition and HUD status notification.
100% client-side lightmap modification, safe for multiplayer."""
authors = ["CayMac"]
owners = ["CayMac97"]
owner_ids = [197672048]
license = "MIT"
source = "https://github.com/CayMac97/fullbright"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
+1
View File
@@ -13,6 +13,7 @@ A native Horizon mod: the launcher builds it into the game (install it on the la
Toggling is silent (no chat messages). Client-side: works in single player, on LAN and on servers. Settings are remembered between sessions."""
authors = ["Nirokx"]
owners = ["Nirokx"]
owner_ids = [95123456]
license = "MIT"
source = "https://github.com/Nirokx/horizon-fullbright-nofog"
+50
View File
@@ -0,0 +1,50 @@
id = "gyro"
kind = "native-mod"
name = "Gyro"
summary = "Full 6DOF flight physics and immersive gyro camera tilt for Minecraft Horizon, inspired by Do a Barrel Roll."
description = """
**Fly an elytra like an aircraft.** Gyro swaps Horizon's locked horizon for full six-degree-of-freedom control: bank into a roll, pull through it to carve a turn, push the nose past vertical and loop. Inspired by the feel of the Java mod *Do a Barrel Roll*, written from scratch on Horizon's native hook points.
| While gliding | What it does |
|---|---|
| Mouse left / right | Roll — a full 360° with no snap-back |
| Mouse up / down | Pitch through your current bank, so a wing-down pull carves a turn |
| `A` / `D` | Rudder yaw for fine heading corrections |
In third person your avatar, elytra and held item stay pinned to the camera while the world, sky and entities roll around you. When you stop gliding, the view eases back to level and the mouse is an ordinary camera again. Everything else stays vanilla — no keybinds, no config, no setup, just one row in **Options > Mods**.
**What it actually touches.** Client-side camera matrices and the player's own model only. There is no network, filesystem or process access anywhere in the source, no server hooks and no server component; the server never hears about the roll. It declares `hooks: 3`, so the launcher builds it on any Horizon whose native hook API is ≥ 3 rather than one pinned commit. Built and tested against Horizon `ba6344a` with horizon-mixins `d9c83d70`. The whole mod is seven short Rust files plus one mixin manifest under `mixins/gyro/` — read them before you build.
*Native mods are unsandboxed code running with your account's permissions; install only from authors you trust.*
Source & build guide: https://github.com/CGRLabs/gyro
Community discovery & mod docs: https://horizon-hub-seven.vercel.app (independent, unofficial — indexes the official registry)
"""
authors = ["CGRLabs"]
owners = ["CGRLabs"]
license = "MIT"
source = "https://github.com/CGRLabs/gyro"
icon = "https://weaigubwhdajcehreyau.supabase.co/storage/v1/object/public/icons/gyro.png"
key = "RWSjKGIJUKKXHvvhxzNN2VPPrXSEr47JXVgGpCwrOOHoneuQjTWWvk1g"
[[versions]]
version = "1.4.1"
published = "2026-11-05"
game = "26.3"
file = "gyro-1.4.1.hnmod"
sha256 = "2271d5b7e1559544a12e9198523c643bddd2551828ecf0a39b92f7d120b1921b"
signature = """
untrusted comment: signature from Horizon Hub signing key (CGRLabs)
RUSjKGIJUKKXHmpe6jlROY1yCZ3ZOf3ud+zshvosQahcCBCr1PIsSnnLgeLiuaxjxr55MkO9i9r4pKIUqRkcIY8y/R3pfi+QSgY=
trusted comment: timestamp:1793853001\tfile:gyro-1.4.1.hnmod
uqpsuNh06lQVrKGSKBbJ9J6y4A3bg0TlGEepMy+5F3G3Iuokw6bu2WqSOjNWS7u8qZdzVBLXontxfjb6gzUkBQ=="""
changelog = """
The bundle now declares native hook points (`hooks: 3`), so the launcher builds it on any Horizon with native hook API v3 or newer instead of only the pinned commit.
- Deterministic packaging: identical sources give identical bytes and SHA-256; `tools/package.py --check-hooks` cross-checks the pinned commit's real API.
- Mod row (id, version, description) now comes from `runtime.rs` constants.
- Added `proof.py`, an in-game proof harness driving the modded client on throwaway data folders.
- Player-facing README rewrite.
Client-side camera and player-model changes only; no network writes and no server component."""
+1
View File
@@ -5,6 +5,7 @@ summary = "Selected-item durability, count and enchantment names above the hotba
description = "Minimal client-only selected-item text HUD for Horizon 26.3. Displays remaining/maximum durability, stack count and up to six translated enchantment names; long strings are shortened to GUI width. Original MIT implementation inspired by A5b84/held-item-info; no LGPL source was read or included. Potion details, layout options and fading are not implemented. Existing Armor Status covers equipped-item icons/durability; this mod focuses on selected-item text and enchantment names. See the source for validation and exact engine/mixin pins."
authors = ["AltMugen"]
owners = ["AltMugen"]
owner_ids = [108084724]
license = "MIT"
source = "https://github.com/AltMugen/horizon-held-item-info"
+1
View File
@@ -21,6 +21,7 @@ Press H to toggle hitboxes on/off.
Access Options > Mods > Hitbox+ to customize all colors, thickness, and HUD positions."""
authors = ["taxtii", "itzglecni"]
owners = ["taxtii"]
owner_ids = [347672997]
license = "MIT"
source = "https://github.com/taxtii/hitbox_plus.git"
+28 -13
View File
@@ -1,27 +1,42 @@
id = "horizon_physics"
kind = "native-mod"
name = "Horizon Physics"
summary = "Physics for blocks, mobs, items, snow, water, smoke, capes and more, with its own settings screen."
name = "PhysicsModHorizon"
summary = "Physics for Horizon, inspired by PhysicsMod: ragdolls, waves, physical particles and capes."
description = """
By tariksabanuz. Physics for Horizon in the spirit of PhysicsMod: broken blocks fall apart into pieces, mobs die as
ragdolls, snow gives way under your feet, water waves, smoke rises and plants sway in the wind.
By tariksabanuz
- Broken blocks crumble into pieces (blocky, particles, crumbled voxel or fractured). Mobs die as ragdolls or come apart.
- Structures and trees fall when what holds them up is broken. Dropped items tumble and settle.
- Snow layers are pressed down by you and the mobs, water has waves that boats (and you in them) ride, and leaves, plants and vines sway.
- Hit a ragdoll or a piece and it flies off; grass and flowers bend away as you walk through them.
- Fire, campfires and lava give off smoke and embers that gather and darken under ceilings, capes and banners are cloth, doors swing open and shut, leads and fishing lines are ropes.
- Rain splashes and rings the water, snow falls, and the wind blows harder with the weather.
- A Physics Settings button in Options opens a screen with a page for every part, and a Quick Setup the first time you start.
Physics for Horizon, inspired by PhysicsMod by Haubna. It is a clean-room version for Horizon: it contains none of PhysicsMod's code or assets and is not made or endorsed by Haubna. Everything is built as a native Horizon mod, so no other mods or loaders are needed.
Needs the Performance build in the launcher. Inspired by PhysicsMod by Haubna; this is a clean-room version for
Horizon and contains none of its code or assets. It uses your own game's textures at runtime, and the icon is my own."""
Features
- **Realistic water**: waves that never repeat, built from several swells with wave groups, wind gusts, sun glints, foam on the crests and along the shore, and rain rings on the surface. Boats and the camera follow the same waves you see.
- **Ragdolls**: mobs fall and tumble with their bodies instead of just disappearing.
- **Physical particles**: block cracks, sprint dust and broken or eaten items become small physical pieces that fall, bounce and settle.
- **Capes** that move with you.
- **Quick Setup**: on first launch everything is off, so the game starts fast. A short setup lets you pick how much you want, and then turns it on.
- **Physics Settings** in Options, with categories, switches and sliders for every effect, so you can tune it to your PC.
Notes
- Built for Horizon Nightly `6475f62`. Other versions may need an updated build.
- Works with Vulkan and DirectX 12.
- If the game feels slow, lower the wave height or turn effects off in Physics Settings.
Source code and issues: https://github.com/tariksabanuz/PhysicsModHorizon"""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
owner_ids = [332662152]
license = "MIT"
source = "https://github.com/tariksabanuz/PhysicsModHorizon"
icon = "icons/horizon_physics.png"
[[versions]]
version = "0.18.0"
published = "2026-11-04"
game = "26.3"
file = "horizon_physics-0.18.0.hnmod"
sha256 = "213f6264a20bed7fef64f12f539d0b2364f710857a3acaa351b96e78b3239dc1"
[[versions]]
version = "0.15.1"
published = "2026-11-03"
+43 -6
View File
@@ -10,35 +10,72 @@ Horizonmatica is a re-creation of **Litematica** (with its **printer**) for Hori
- **Overlay:** missing blocks as translucent real block models (chests, banners, heads and other block entities included), optionally visible through walls.
- **Verifier:** wrong block (red), wrong state (orange), extra block (pink), plus a progress HUD.
- **Layers:** all / one layer / above / below / range.
- **Placement:** move, rotate, mirror; a move mode for mouse wheel and arrow keys. Each placement is remembered per world and dimension and restored when you join again.
- **Material list:** missing, total and carried, with item icons, in the HUD, in chat or in the menu.
- **Placements:** several at once; move, rotate, mirror, show or hide each; a move mode for mouse wheel and arrow keys. The placements are remembered per world and dimension and restored when you join again.
- **Save your own:** select an area with the selection tool (a stick: left and right click set the corners, the wheel grows and moves the box) and save it as a `.litematic` (blocks and their states), chunk by chunk as in Litematica: chunks not loaded yet show as frames to fly to.
- **Material list:** missing, total and carried, with item icons, in the HUD or in the menu, and copied to the clipboard as a table for spreadsheets.
- **Menu (M):** a screen of its own, drawn with the game's widgets.
- **Pick block:** middle-click a ghost block to take that block in hand, for building by hand.
- **Highlights:** mark missing and wrong blocks through everything, however far away, for all items or just one you pick in the material list.
- **Printer:** places one missing block within reach every few ticks, from the bottom up. It only clicks when Horizon's own placement logic says the click produces exactly the wanted state (stairs, slabs, logs, furnaces…). Redstone-aware order, repeater, comparator and note block setup after placing, double slabs and stacks, chests, falling blocks. The camera never moves.
Usage: put schematics into `schematics/` next to `options.txt` of your instance (`/hm folder` shows the path), then `/hm load <name>` or press **M**. All commands and keys are in the [README](https://github.com/keksi2k/horizonmatica#usage).
## What it does and doesn't do
- **Reads** only files in the instance's `schematics/` folder, and creates that folder if it's missing.
- **Writes** nothing itself: settings and remembered placements go through Horizon's mod settings.
- **Writes** only the `.litematic` files you save (`/hm save`), into that folder. Settings and remembered placements go through Horizon's mod settings.
- **Network:** no connections of its own. The only thing sent to a server is what the printer does: the same block-placing clicks a player would make (with the matching look direction), and hotbar slot changes.
- **"Open folder"** in the menu opens the schematics folder in your file manager through Horizon's own function.
- No telemetry, no downloads, no code run from schematic files. Schematics are parsed as data only.
⚠️ On other people's servers, automatic placing may break the rules or trip anti-cheat. The printer is off until you turn it on (`K` or `/hm printer on`). Its *air place* option (on by default) is noticed by anti-cheat plugins such as Grim.
⚠️ On other people's servers, automatic placing may break the rules or trip anti-cheat. The printer is off until you turn it on (`K` or `/hm printer on`). Its *air place* option (off by default) is noticed by anti-cheat plugins such as Grim.
**Version:** native mods use game internals, so Horizonmatica is pinned to one Horizon commit (currently Nightly 2026-11-01, `4aa6854`). A new Nightly needs a new release.
**Version:** native mods use game internals, so Horizonmatica is pinned to one Horizon commit (currently Nightly 2026-11-04, `67dad0d`). A new Nightly needs a new release.
## Credits
The idea, the way it's used, the file format and the behaviour come from **[Litematica](https://github.com/maruohon/litematica)** by masa (maruohon), maintained by **[Sakura-Ryoko](https://github.com/sakura-ryoko/litematica)**, and from **[Litematica Printer](https://github.com/aleksilassila/litematica-printer)** by aleksilassila. Horizonmatica is not an official project of these authors, isn't made or endorsed by them, and contains none of their code. Only the file format and the behaviour are re-created. If you like it, go and support the originals.
Menus built with [horizon-menus](https://github.com/keksi2k/horizon-menus). MIT licensed."""
Menus built with [horizon-menus](https://github.com/keksi2k/horizon-menus). MIT licensed.
"""
authors = ["keksi2k"]
owners = ["keksi2k"]
owner_ids = [32579030]
license = "MIT"
source = "https://github.com/keksi2k/horizonmatica"
icon = "https://raw.githubusercontent.com/keksi2k/horizonmatica/main/icon.png"
key = "RWS+BRoCJslNCFapck3cbG2VFQcjlr1MUH5m3+CWA8zhJecoBoja7X94"
[[versions]]
version = "0.2.0"
published = "2026-11-04"
game = "26.3"
file = "horizonmatica-0.2.0.hnmod"
sha256 = "5afe38269722867cb60b9b7cc1ba9efa2efb998580eae2949ab7f7e5399ebb7b"
signature = """
untrusted comment: signature from minisign secret key\r
RUS+BRoCJslNCEWE8zNb6tQ44aUFyzfzbB8nRghjhfhf5s5NQSRSKgo9YknwdPAX5WtloUfJNMMHFNzl6WaK9yHBLeeNBHU4ggo=\r
trusted comment: timestamp:1793803241\tfile:horizonmatica-0.2.0.hnmod\thashed\r
85Peoj95TjmdBwJ2xoerobEzqhmT6RNT2m+4RaaKtbParx/MnEXYmXpEw5XqYzw8Z/u1+trBQULwmNuY8MWKAQ=="""
changelog = """
### Added
- Highlights: mark missing and wrong blocks through everything, however far away, for all items or just one you pick in the material list (`/hm highlight`).
- Pick block: middle-click a ghost block to take that block in hand.
- Several placements at once: select, show, hide, lock and unload each.
- Select an area with the selection tool (a stick) and save it as a `.litematic` (`/hm save`).
- Copy the material list to the clipboard as a table for spreadsheets (`/hm materials copy`).
- Command completion for `/hm` in chat.
- A wiki in `docs/wiki`: every tab, button and key, with the matching command.
### Changed
- Air place is off by default.
- Printer without air place: only blocks next to a real block whose face you can see are tried, blocks that can't be placed from where you stand aren't tried again until you move or a neighbour changes, and in each layer the item in hand comes first.
- Logs, froglights and other pillars get their direction from the clicked face alone. Without air place they need a real block on the matching side, and the printer's note says so.
- Pinned to Nightly 2026-11-04 `67dad0d` (was `c9d474d`).
### Fixed
- The printer places bamboo, crops, stems, berry bushes and vines. They count as correct at any stage of growth, and the material list includes them.
Built for Horizon Nightly 2026-11-04 (67dad0d, hook API 359)."""
[[versions]]
version = "0.1.1"
published = "2026-11-02"
+1
View File
@@ -5,6 +5,7 @@ summary = "R-key & UI sorting (Category, A-Z, Count, ID), Auto-Refill depleted s
description = "A 1:1 native Rust port of Inventory Profiles Next & Inventory Sorter for Horizon. Press R or click the UI sort buttons to merge partial stacks and sort chests or your inventory using 4 selectable modes: Category (Creative Tab), Alphabetical (A-Z), Stack Count (High-Low), or Item ID. Includes automatic hand stack refilling when placing blocks or consuming items, Tool Break Protection that swaps out low-durability tools before they break, and G-key Quick-Stacking to move matching items into open containers."
authors = ["CayMac"]
owners = ["CayMac97"]
owner_ids = [197672048]
license = "MIT"
source = "https://github.com/CayMac97/inventory-profiles"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
+1
View File
@@ -20,6 +20,7 @@ description = """
server**: every sort is made of ordinary left clicks, as a player could click them, so a vanilla server accepts it"""
authors = ["VanishingTacos"]
owners = ["VanishingTacos"]
owner_ids = [16919284]
license = "MIT"
source = "https://github.com/VanishingTacos/InventoryWizard-Horizon"
icon = "https://cdn.modrinth.com/data/cU0ynk3p/21b58402405493964af438e3e3b9551676b68433.png"
+1
View File
@@ -5,6 +5,7 @@ summary = "Sneak-right-click a filled frame to hide and lock it, or restore it."
description = "Server-side frame visibility toggling for Horizon 26.3. Ordinary and glowing frames are supported. Empty visible frames remain visible. Original MIT implementation inspired by ADHDMC/InvisibleFrames; no Java source or assets included. See the source repository for tested engine/mixin pins, build instructions and validation scope."
authors = ["AltMugen"]
owners = ["AltMugen"]
owner_ids = [108084724]
license = "MIT"
source = "https://github.com/AltMugen/horizon-invisible-frames"
+1
View File
@@ -13,6 +13,7 @@ Items you just got (picked up, crafted, taken from a chest, given) get a soft gl
Settings: Options → Mods → Item New. Inspired by [Item Highlighter](https://modrinth.com/mod/item-highlighter); not affiliated, no code from it."""
authors = ["d3grando"]
owners = ["d3grando"]
owner_ids = [36463198]
license = "MIT"
source = "https://github.com/d3grando/itemnew"
icon = "icons/itemnew.png"
+1
View File
@@ -21,6 +21,7 @@ Tested on Windows, in single player and on LAN with one guest. Dedicated servers
Licence: CC BY-NC-SA 4.0, noncommercial use only, the same licence as Jade."""
authors = ["Snownee", "Joeypc6"]
owners = ["Joeypc6"]
owner_ids = [35495484]
license = "CC-BY-NC-SA-4.0"
source = "https://github.com/Joeypc6/Jade-Horizon"
icon = "https://media.forgecdn.net/avatars/thumbnails/207/323/256/256/636965628804677340.png"
+1
View File
@@ -37,6 +37,7 @@ Horizon JEI reads the game's tables, container interface, chat, and its own sett
*Unofficial project. Not affiliated with mezz, Mojang, Microsoft, or the Horizon project.*"""
authors = ["enderliker"]
owners = ["enderliker"]
owner_ids = [166346005]
license = "MIT"
source = "https://github.com/enderliker/horizon-jei"
key = "RWRqzx718YYz/OHRM+pAnFWlDv8Rrgar5C4NszQg3EKJWQZWNel5Sslf"
+1
View File
@@ -18,6 +18,7 @@ Options > Mods: it rests at once, and the next build leaves it out. Its source i
"""
authors = ["Horizon"]
owners = ["ludovic111"]
owner_ids = [136255825]
license = "MIT"
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/keystrokes"
+1
View File
@@ -1,6 +1,7 @@
id = "killc"
kind = "native-mod"
owners = ["Vakarux12"]
owner_ids = [167799729]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "KillC"
summary = "/killc, /killc <player> and /suicide, with an optional random respawn near (0, 0)."
+1
View File
@@ -5,6 +5,7 @@ summary = "1:1 Light Overlay & MiniHUD (F7): visualizes mob-spawnable blocks wit
description = "A 1:1 native Rust port of Light Overlay and MiniHUD's F7 Light Level Overlay for Horizon. Press F7 during gameplay to scan nearby spawnable block surfaces and render depth-tested 3D markers directly on block tops. Red markers indicate blocks where hostile mobs can spawn at any time (caves/indoor darkness), Yellow markers indicate blocks where hostile mobs can spawn at night, and optional Green markers show safe blocks. Supports Crosses (X), 3D Numbers (0-15), or Both, with configurable scan radius (8 to 20 blocks) and Modern (== 0) or Legacy (<= 7) spawn thresholds."
authors = ["CayMac"]
owners = ["CayMac97"]
owner_ids = [197672048]
license = "MIT"
source = "https://github.com/CayMac97/light-overlay"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
+1
View File
@@ -14,6 +14,7 @@ The block or entity under your crosshair at the top of the screen, drawn as a va
**Now a native mod (0.2.1)**: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's permissions, so read the source before you build it. Tested against Horizon `142388e1c136e622652ec7505af15d8ad195e041` with Horizon Mixins `0d6b103c70633d8fd9443d8d0acd3aadaacedb06`; the launcher builds these exact pinned revisions. Settings from 0.1.0 carry over."""
authors = ["d3grando"]
owners = ["d3grando"]
owner_ids = [36463198]
license = "MIT"
source = "https://github.com/d3grando/lookat"
icon = "icons/lookat.png"
+1
View File
@@ -18,6 +18,7 @@ The pack only changes `assets/minecraft/textures/gui/title/background/panorama_0
MIT licensed. Source, the browser generator and the CLI are in the repository."""
authors = ["form1t"]
owners = ["form1t"]
owner_ids = [230844871]
license = "MIT"
source = "https://github.com/form1t/menu-panorama"
icon = "icons/menu_panorama.png"
+1
View File
@@ -18,6 +18,7 @@ Options > Mods: it rests at once, and the next build leaves it out. Its source i
"""
authors = ["Horizon"]
owners = ["ludovic111"]
owner_ids = [136255825]
license = "MIT"
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/minimap"
+9 -1
View File
@@ -10,9 +10,17 @@ Adds a Mods button next to Accessibility on the title screen. It opens a Mod Men
- Open Mods Folder and Done at the bottom, as in the Java mod."""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
owner_ids = [332662152]
license = "MIT"
source = "https://github.com/tariksabanuz/ModMenuHorizon"
icon = "https://raw.githubusercontent.com/tariksabanuz/ModMenuHorizon/main/icon.png"
icon = "https://raw.githubusercontent.com/tariksabanuz/ModMenuHorizon/caec7c4c3a2c6c7746a97d137ccf8f8904801e72/icon.png"
[[versions]]
version = "0.3.3"
published = "2026-11-05"
game = "26.3"
file = "mod_menu-0.3.3.hnmod"
sha256 = "657b0b6ce1f1ac44afa8d7410aa5b16ccf9b6a271024b4e5c1767fa377f98825"
[[versions]]
version = "0.3.0"
+1
View File
@@ -10,6 +10,7 @@ Each frame the mod works out every pixel's motion since the last frame (the worl
Settings are in Options → Mods → Motion Blur: strength for moving things and the camera, shutter angle or a fixed exposure, the longest blur, quality, blurring yourself in third person, and a view that shows each pixel's motion."""
authors = ["levzzz5154"]
owners = ["levzzz5154"]
owner_ids = [86072227]
license = "MIT"
source = "https://github.com/levzzz5154/horizon-motion-blur"
+1
View File
@@ -13,6 +13,7 @@ description = """
Everything goes through the game's own container clicks, so servers accept it. Each trick is a setting (Options → Mods → Mouse Tricks). Inspired by [Mouse Tweaks](https://modrinth.com/mod/mouse-tweaks); not affiliated, no code from it."""
authors = ["d3grando"]
owners = ["d3grando"]
owner_ids = [36463198]
license = "MIT"
source = "https://github.com/d3grando/mousetricks"
icon = "icons/mousetricks.png"
+1
View File
@@ -2,3 +2,4 @@ id = "radiant"
kind = "shader"
repo = "expensiverest/radiant"
owners = ["expensiverest"]
owner_ids = [281485024]
+1
View File
@@ -22,6 +22,7 @@ once, and the next build leaves it out. Its source is
"""
authors = ["Horizon"]
owners = ["ludovic111"]
owner_ids = [136255825]
license = "MIT"
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/recipes"
+23 -8
View File
@@ -1,21 +1,36 @@
id = "resourcify"
kind = "native-mod"
name = "Resourcify"
summary = "Download resource packs in Horizon"
name = "ResourcifyHorizon"
summary = "Resourcify for Horizon"
description = """
Resourcify lets you browse and download resource packs without ever leaving Horizon.
Find and install resource packs from Modrinth without leaving the game. By tariksabanuz.
Features:
- Browse resource packs right inside the game
- One-click download and install
- Search to find the pack you want quickly
- Native Horizon mod, fast and lightweight"""
A **+** button appears in the top right of the Resource Packs screen. It opens a browser with:
- a search box that searches while you type, a sort button (Relevance, Downloads, Newest, Updated) and a switch between "for this game version" and "all versions";
- a list of packs with pictures, authors and download counts, loaded as you scroll;
- a detail pane with the versions of the selected pack and an **Install** button.
Installing downloads the file from Modrinth's own server (`cdn.modrinth.com` only), checks it against the SHA-1 hash Modrinth provides, and saves it in `resourcepacks/`. Nothing is turned on for you.
Source code: https://github.com/tariksabanuz/ResourcifyHorizon"""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
owner_ids = [332662152]
license = "MIT"
source = "https://github.com/tariksabanuz/ResourcifyHorizon"
icon = "https://github.com/tariksabanuz/ResourcifyHorizon/blob/364648324ac0e67489a3f06faa716d96d24668ed/icon.png"
[[versions]]
version = "0.1.1"
published = "2026-11-04"
game = "26.3"
file = "resourcify-0.1.1.hnmod"
sha256 = "a6611819c9401dc7370dece7453cdda2356747f2a34c32940bf82ccd6abdcf53"
changelog = """
- Version 0.1.1.
- Rebuilt and re-checked against the current Horizon revision."""
[[versions]]
version = "0.1.0"
published = "2026-11-03"
+1
View File
@@ -30,6 +30,7 @@ This is a native mod: install only if you trust its source and accept the launch
"""
authors = ["AyanoKaito"]
owners = ["AyanoKaito"]
owner_ids = [82058189]
license = "MIT"
source = "https://github.com/AyanoKaito/horizon-right-click-crops"
icon = "icons/right_click_crops.png"
+1
View File
@@ -5,6 +5,7 @@ summary = "Right-click a ripe crop to pick it and sow it again."
description = "With an empty hand, right-click a ripe wheat, carrot, potato, beetroot or nether wart: the crop is harvested with the game's own drops, sound and statistics, and sown again from a seed in your inventory."
authors = ["Domanffe"]
owners = ["Domanffe"]
owner_ids = [94391234]
license = "MIT"
source = "https://github.com/Domanffe/rightharvest"
icon = "https://horizon-rust.github.io/horizon-mods/icons/rightharvest.png"
+1
View File
@@ -1,6 +1,7 @@
id = "rmotd"
kind = "native-mod"
owners = ["Vakarux12"]
owner_ids = [167799729]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "RMotd"
summary = "Rotating server list MOTDs, managed in game with /randommotd and /motd."
+1
View File
@@ -38,6 +38,7 @@ Install it from the launcher's Mods page, then press Build there, and play that
Native mods are not sandboxed. The source is public and licensed GPL-3.0-only: https://github.com/Louchatfroff/serverify/tree/main/horizon/serverify"""
authors = ["Louchatfroff"]
owners = ["Louchatfroff"]
owner_ids = [87802271]
license = "GPL-3.0-only"
source = "https://github.com/Louchatfroff/serverify"
icon = "icons/serverify.png"
+1
View File
@@ -19,6 +19,7 @@ It never changes items, inventories or worlds. Turn it off any time in Options >
This is a native mod built for Horizon Nightly 2026-10-29 (`5e391eb`). It builds together with Horizon's official native mods (FPS Counter, Zoom, Minimap, Recipes and others)."""
authors = ["vstxx"]
owners = ["vstxx"]
owner_ids = [106024432]
license = "MIT"
source = "https://github.com/vstxx/shulkerpeek"
icon = "icons/shulker_peek.png"
+24
View File
@@ -0,0 +1,24 @@
id = "skins_3d"
kind = "native-mod"
name = "3D Skins"
summary = "The outer layer of every player's skin drawn as real 3D pixels."
description = "The outer layer of every player’s skin drawn as real 3D pixels: hat, jacket, sleeves and pants follow the head, arms and legs exactly as the game moves them. Works for everyone you can see, with downloaded, launcher and default skins, classic and slim arms and HD skins, respects Skin Customization, and switches to the flat layer further away. Client side only."
authors = ["_idom_"]
owners = ["umbx-dot"]
license = "MIT"
source = "https://github.com/umbx-dot/horizon-3d-skins"
icon = "https://raw.githubusercontent.com/umbx-dot/horizon-3d-skins/main/icon.png"
key = "RWT9Ujo/oCprPGHHiSLHFNOJV1ZzoJdAU6EdZyiQcBwT2iMXfiYms4hD"
[[versions]]
version = "1.0.1"
published = "2026-11-04"
game = "26.3"
file = "skins_3d-1.0.1.hnmod"
sha256 = "c9d618c4bedaa92c9373d7363b182eff9f4723889dcb5ba8a877c78ffc0fb6ae"
signature = """
untrusted comment: signature from minisign secret key\r
RUT9Ujo/oCprPNTOokDM8JeIMRDlcc/O1BsND0UNQzPl9RO81svhFlu2yiSfc+uaeR9fARo8pJeE2Mw/+zvPKqb8awVQUHjozAM=\r
trusted comment: timestamp:1793812703\tfile:skins_3d-1.0.1.hnmod\thashed\r
z9cSZKR27HkOB6zuxDSkT9Bz4GHtm3WaQpy9VkZ5aPTs0TZgtgF4qp/ijHPUJP2jRpJl/fWYwAhEl0A2UrK5Aw=="""
changelog = "Initial Release. "
+1
View File
@@ -14,6 +14,7 @@ The F3 debug screen, grouped into 14 modules: FPS, server, coordinates, chunk, f
Settings: Options → Mods → SmartF3. Inspired by [BetterF3](https://modrinth.com/mod/betterf3); not affiliated, no code from it."""
authors = ["d3grando"]
owners = ["d3grando"]
owner_ids = [36463198]
license = "MIT"
source = "https://github.com/d3grando/smartf3"
icon = "icons/smartf3.png"
+1
View File
@@ -4,6 +4,7 @@ name = "Smoothie Capes"
summary = "An Wavey Capes inspired mod for Horizon"
authors = ["Nully"]
owners = ["dumbnully"]
owner_ids = [268078454]
license = "MIT"
source = "https://github.com/dumbnully/smoothie-capes"
key = "RWRJPBl24rTE23dFezewHWl465FAcgTsmeJnDkANtUPT19YfVmQ1gYCu"
+26
View File
@@ -0,0 +1,26 @@
id = "speedrun_timer"
kind = "native-mod"
name = "Speedrun Timer"
summary = "Speedrun Timer Is a mod made to count the time it took you to beat the Game"
description = """
Speedrun Timer Is a mod made to count the time it took you to beat the Game
An RTA timer in a corner: HH:MM:SS.mmm, starts on your first move, stops when you leave the End. Info gets saved locally."""
authors = ["Demerobit"]
owners = ["Demerobit"]
license = "MIT"
source = "https://github.com/Demerobit/Speedrun-Timer-Horizon-Rust-Minecraft"
icon = "https://raw.githubusercontent.com/Demerobit/Speedrun-Timer-Horizon-Rust-Minecraft/main/icon.png"
key = "RWREE8bzxgQZoRDo1MEbx56BjXA/udjM64FBEjMFmXQhhgx1Z6oSrt69"
[[versions]]
version = "1.0.1"
published = "2026-11-04"
game = "26.3"
file = "speedrun_timer-1.0.1.hnmod"
sha256 = "884efd123b6e3a99e783603fd18f94b4fe6cf4b2ce6297e8e58fbb81c699c22e"
signature = """
untrusted comment: signature from minisign secret key
RUREE8bzxgQZoSnNYQKsFGHKDXOabxzO/Svz7gImNoBk9aoGEfRrGHq4Q8FEy4gK6kSdppg4BOj+ppW8+xSMZfFUldfYNXmJmwo=
trusted comment: timestamp:1793811255\tfile:speedrun_timer-1.0.1.hnmod\thashed
c8UAoVvQRjR97rkLv42ZqaAQkHZfjlICWRV+kEdV3ECDL7yrwICmImSwDTaV7KMl/fQcFouC8yvUbr4fpDWZAQ=="""
changelog = "This is he first Release of the mod "
+1
View File
@@ -5,6 +5,7 @@ summary = "1:1 Status Effect Bars & Inventory HUD+ Potion HUD: displays active p
description = "A 1:1 native Rust port of Status Effect Bars and Inventory HUD+ (Potion HUD) for Horizon. Renders all active potion and beacon status effects directly on the HUD during gameplay with their official mob_effect sprites, translated effect names, and Roman numeral potency levels (II, III, IV). Includes color-coded mm:ss countdown timers (White for >30s, Yellow for 10-30s, Flashing Red for <=10s urgent expiration warning, and Aqua infinity symbol for permanent effects) alongside smooth duration progress bars colored in each effect's authentic vanilla potion color."
authors = ["CayMac"]
owners = ["CayMac97"]
owner_ids = [197672048]
license = "MIT"
source = "https://github.com/CayMac97/status-effect-hud"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
+1
View File
@@ -1,6 +1,7 @@
id = "stuck"
kind = "native-mod"
owners = ["Vakarux12"]
owner_ids = [167799729]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "Stuck?"
summary = "/stuck and /escape move you to a safe spot nearby, off the Nether roof too."
+31
View File
@@ -0,0 +1,31 @@
id = "tectonic_horizon"
kind = "native-mod"
name = "Tectonic-Horizon"
summary = "Unofficial native port of Tectonic: mountain ranges, underground rivers and configurable terrain."
description = """
Unofficial native Rust port of Tectonic 3.0.1 by Apollo (Apollounknowndev), revision 0761b561062de01b701d2571401f625c31059a9a under CC0-1.0. Not made or endorsed by Apollo or the Horizon project.
Mountain ranges, terrain density recipes, underground rivers and configurable terrain for Horizon 26.3. Source-only native mod: install in the launcher, then Build; runs with the game's account permissions. No Minecraft code or assets are included.
Enable under Options > Mods before opening a world. The mod's Settings page edits terrain and biome configuration. Config: config/tectonic-horizon.json in the game home. Invalid configuration logs a diagnostic and uses defaults. Restart after editing; new chunks only. Select increased height before world creation.
Pins upstream Horizon 8dbbde43501924d16497d251e6e9d08f812eafcd and Mixins e8cd0aa38cffef49cb2e088ef716baa107148c02. See docs/REVIEW-0.3.4.md for current validation and docs/VALIDATION.md for historical checks with their original revisions.
No block-for-block Java parity claim. Native river-decoration placement differs from Lithostitched. Java config UI and Terralith/CliffTree overlays are omitted. Other biome mods need their own Horizon ports.
![Tectonic terrain](https://raw.githubusercontent.com/eltanschauung/tectonic-horizon/6f69f2b64a41f0cad09f87840cbdb104138c54c3/docs/screenshots/tectonic-landscape.png)
Author-supplied screenshot captured November 1 in our Horizon fork; illustrative, not evidence of current upstream validation. Minimap is a separate mod. Original Tectonic by Apollo; Horizon adaptation by eltanschauung with Codex assistance. Source licenses, notices and provenance hashes retained."""
authors = ["eltanschauung", "Apollo (original Tectonic)"]
owners = ["eltanschauung"]
owner_ids = [158000645]
license = "CC0-1.0"
source = "https://github.com/eltanschauung/tectonic-horizon"
[[versions]]
version = "0.3.4"
published = "2026-11-04"
game = "26.3"
file = "tectonic_horizon-0.3.4.hnmod"
sha256 = "590198d7df43b5c9474c41648db89a15bd4d388be0fe860a2f84a84b59285cec"
changelog = "Revision of PR #160: removes unsupported runtime-toggle.json, falls back to defaults with a diagnostic for invalid/unreadable/oversized configuration, and fixes the GitHub formatting failure. Includes the 0.3.2 in-game settings editor and 0.3.3 upstream height adapter. Pins Horizon 8dbbde43501924d16497d251e6e9d08f812eafcd and Mixins e8cd0aa38cffef49cb2e088ef716baa107148c02. All 13 Rust tests, Clippy, formatting, native-package checking, reproducible packaging, mixin plan/apply, and the patched horizon-world compile check pass. No new interactive game test is claimed. Source: https://github.com/eltanschauung/tectonic-horizon/tree/6f69f2b64a41f0cad09f87840cbdb104138c54c3"
+1
View File
@@ -5,6 +5,7 @@ summary = "1:1 Better Sprinting & Lunar ToggleSprint HUD: toggle sprint and togg
description = "A 1:1 native Rust port of Better Sprinting and the Lunar/Badlion ToggleSprint HUD for Horizon. Allows toggling continuous sprint (V or vanilla Sprint key) and sneak (C) without holding keys down, while automatically pausing toggled sneak inside inventory and chest screens so Shift-clicking items works normally. Renders the classic movement status HUD ([Sprinting (Toggled)], [Sprinting (Key Held)], [Sneaking (Toggled)], [Sneaking (Key Held)], [Flying (2.0x Boost)], [Swimming], [Gliding (Elytra)], [Riding]) and includes a configurable Creative Fly Speed Boost multiplier (1.0x to 4.0x)."
authors = ["CayMac"]
owners = ["CayMac97"]
owner_ids = [197672048]
license = "MIT"
source = "https://github.com/CayMac97/toggle-sprint"
key = "RWS411U2KXdTN4/y5bQhqBYxNlZvCtSZ5lNy46eeSAcgy0cxSJo2+Sm3"
+1
View File
@@ -14,6 +14,7 @@ Cut one log of a natural tree with an axe and the whole tree comes down. Every l
In the spirit of FallingTree; not affiliated, no code from it. Pinned to Horizon 142388e and horizon-mixins 0d6b103."""
authors = ["d3grando"]
owners = ["d3grando"]
owner_ids = [36463198]
license = "MIT"
source = "https://github.com/d3grando/treefall"
icon = "icons/treefall.png"
+1
View File
@@ -26,6 +26,7 @@ Independently written Rust code; no upstream mod code or artwork is bundled. MIT
"""
authors = ["levzzz5154"]
owners = ["levzzz5154"]
owner_ids = [86072227]
license = "MIT"
source = "https://github.com/levzzz5154/horizon-veinminer"
+1
View File
@@ -16,6 +16,7 @@ Pinned to Horizon c9d474d (Nightly 2026-11-01): it hooks private client function
Not made or endorsed by Visuality's author."""
authors = ["BenjiSyS"]
owners = ["BenjiSyS"]
owner_ids = [220019460]
license = "MIT"
source = "https://github.com/BenjiSyS/visuality-horizon"
icon = "icons/visuality_horizon.png"
+1
View File
@@ -30,6 +30,7 @@ The engine's unit tests pass, and the modded client type-checks. Live, against P
"""
authors = ["Nirokx"]
owners = ["Nirokx"]
owner_ids = [95123456]
license = "MIT"
source = "https://github.com/Nirokx/horizon-voice-chat"
+24
View File
@@ -0,0 +1,24 @@
id = "wayfarer_stones"
kind = "mod"
name = "Wayfarer Stones (assets)"
summary = "The textures, text, recipes and villages of Wayfarer Stones. Needs the Wayfarer Stones mod."
description = """
**This is one of the two parts of [Wayfarer Stones](https://playhorizon.dev/mods/wayfarer_stones_native), and does nothing alone.** Install both.
It holds the mod's models and textures, its English and Russian text, its recipes and drops, and the stones villages build. It has no code. The code, and the description of what the mod does, are in Wayfarer Stones.
All the art is original. No Minecraft assets are included. MIT licensed."""
authors = ["bioneisme"]
owners = ["Bioneisme"]
owner_ids = [92920845]
license = "MIT"
source = "https://github.com/Bioneisme/wayfarer-stones"
icon = "https://raw.githubusercontent.com/Bioneisme/wayfarer-stones/main/content/assets/wayfarer_stones/textures/item/wayfarer_stone.png"
[[versions]]
version = "0.6.0"
published = "2026-11-04"
game = "26.3"
file = "wayfarer_stones-0.6.0.hmod"
sha256 = "87bec46b6bbe05d95563a4bf7b8a3b92340ca1dd54c2d5709b6a0a41989d7176"
changelog = "First release."
+34
View File
@@ -0,0 +1,34 @@
id = "wayfarer_stones_native"
kind = "native-mod"
name = "Wayfarer Stones"
summary = "Place stones around your world, activate them, and travel between the ones you know."
description = """
**This mod has two parts, and you need both.** Install this one and [Wayfarer Stones (assets)](https://playhorizon.dev/mods/wayfarer_stones), then press Build.
- **Place** a stone, or find one in a village. Right-click it once to add it to your list.
- **Travel** from any stone you know: pick a destination on the screen, or search for it. You land beside the stone, on solid ground.
- **Pay** in experience levels: 1, plus 1 for each 500 blocks, up to 5. Travel between dimensions costs 3.
- **Carry** a Wayfarer's Scroll to travel from anywhere. Each travel uses one up.
- **Stones are safe:** only the owner or an operator can break one, and pistons won't move it.
Every world has its own settings: cost, cooldown, warmup and more. English and Russian.
**Commands:** `/wayfarer`, `/wayfarer list`, `/wayfarer to <name>`, `/wayfarer name <name>`, `/wayfarer forget <name>`. Operators also have `/wayfarer global <name>` and `/wayfarer remove <name>`.
Native source mod: install it on the launcher's Mods page, press Build there, and confirm the native-code warning. It runs with your account's permissions. It is pinned to one Horizon Nightly and Horizon Mixins revision, named in each version's changelog.
No Minecraft code or assets are included. MIT licensed."""
authors = ["bioneisme"]
owners = ["Bioneisme"]
owner_ids = [92920845]
license = "MIT"
source = "https://github.com/Bioneisme/wayfarer-stones"
icon = "https://raw.githubusercontent.com/Bioneisme/wayfarer-stones/main/content/assets/wayfarer_stones/textures/item/wayfarer_stone.png"
[[versions]]
version = "0.6.0"
published = "2026-11-04"
game = "26.3"
file = "wayfarer_stones_native-0.6.0.hnmod"
sha256 = "0c5929425bea6c570ad455229b519fc411ea2b769ef08be8a91bb8b7793d9dba"
changelog = "First release. Pinned to Nightly 2026-11-04 (67dad0d) and Horizon Mixins e8cd0aa."
+53 -10
View File
@@ -1,23 +1,66 @@
id = "waymap"
kind = "native-mod"
name = "Waymap"
summary = "Native Horizon waypoint mod with per-context storage, minimap dots, world markers, and full command suite."
summary = "Persistent client-side waypoints with 3D world markers, minimap dots, a native editor, and automatic death markers for Horizon."
description = """
Waymap — Native client-side waypoints for Horizon.
Features:
- World markers (3D, camera-projected)
- Minimap dots (rotation-aware)
- Waypoint list panel (bottom-left, 6 nearest)
- Per-context storage (per server server_<host:port>.txt / world world_<folder>.txt)
- Atomic saves (temp + atomic rename), legacy migration
- Commands: /wp add|del|list|rename|color|clear|edit|help
- Keys: N = add, B = toggle list"""
Waymap brings complete waypoint management into the game itself. Instead of relying only on chat commands or external tools, it provides a native Horizon screen for creating, finding and editing waypoints, while keeping the existing /wp commands and minimap behaviour. Waypoints are stored locally per world or server, survive restarts, and never require server-side installation. Deaths are recorded automatically so the player can always find the place where they died.
Features
- World markers — 3D waypoints projected from the player's camera, hidden when outside the view
- Minimap markers — coloured dots at the correct map position, including rotated maps
- Native waypoint screen — list and editor with mouse and keyboard support
- Filters — All, Overworld, Nether, End and Deaths
- Editor — name, X/Y/Z, current position, dimension, 16-colour palette, visibility
- Automatic death waypoints — every death creates Death 1, Death 2, … at the death spot, drawn as a red X
- HUD panel — optional bottom-left list with the nearest waypoints and distances
- Full command suite — /wp commands remain available alongside the GUI
- Per-context storage — separate files for each singleplayer world and multiplayer server
- Atomic persistence — temporary file plus atomic rename; unreadable files are never overwritten
- Legacy migration — old waypoints.txt files are migrated once, with waypoints.txt.v1.bak preserved
- Whole coordinates — all waypoint coordinates are rounded to whole numbers
- Settings and rebindable keys — markers, labels, HUD panel, create key and list key
- Up to 512 waypoints per context
Usage
Keys
- N — create a waypoint at the current position
- B or M — open or close the waypoint list
- Esc — close the list or cancel the editor
- Enter — save the editor or edit the selected waypoint
- Tab / Shift+Tab — next or previous editor field
- ↑ / ↓ — move the list selection or editor field
- PgUp / PgDn / Home / End — navigate the list
- Delete — clear the focused editor field; twice on the list deletes the row
GUI
Click a row to select it. The list offers filter buttons, Hide/Show, Edit, Delete, + New and Close; the mouse wheel scrolls. The editor provides name, X/Y/Z, Use my position, dimension cycling, colour palette, visibility and save/cancel.
Commands
- /wp add [name] — add a waypoint at the current position
- /wp del <name> — delete a waypoint
- /wp list — list waypoints in the current context
- /wp rename <old> <new> — rename a waypoint
- /wp color <name> <color> — set a colour by name or #rrggbb
- /wp clear — delete all waypoints in the current context
- /wp edit <name> — show waypoint information
- /wp hide <name> — hide a waypoint from the world and minimap
- /wp show <name> — show a hidden waypoint
- /wp help — show the command list"""
authors = ["Aron"]
owners = ["Lauch2"]
owner_ids = [339589475]
license = "MIT"
source = "https://github.com/Lauch2/horizon-waymap-mod"
key = "RWS1B1QZPeYaSNKJr9rffnfHDSvB0AZj/Ul1B2pKrJ2XFBgKXL0FC96u"
[[versions]]
version = "0.2.0"
published = "2026-11-04"
game = "26.3"
file = "waymap-0.2.0.hnmod"
sha256 = "096636b4401799f6a48dfa96a5b63ac1e2bb5fd9ad91b34532fd745a1df5af99"
signature = """
untrusted comment: signature from minisign secret key
RUS1B1QZPeYaSFo4MAt57LlqdgtcN1Z2Ujq2Z4+EBdgRbR9DmcVMaYi0oi28JeGYt9INetBYM+Cdr/v/m+0NymnxcKufr1x4mwI=
trusted comment: timestamp:1793833502\tfile:waymap-0.2.0.hnmod\thashed
Bmk8zkQYHb4/9hwW2ZEiv3I2WatOkj6z9sdbVJ16RlvG9b0eiSTZ1pyVLa0p9erPt6xD2Jx36ImUnBcBCnWtBQ=="""
[[versions]]
version = "0.1.4"
published = "2026-11-02"
+1
View File
@@ -15,6 +15,7 @@ Waypoints for Horizon.
Pinned to Horizon `c9d474d`, because it reads the world folder, server address and last frame's camera from the game itself."""
authors = ["Nirokx"]
owners = ["Nirokx"]
owner_ids = [95123456]
license = "MIT"
source = "https://github.com/Nirokx/horizon-waypoints"
+36
View File
@@ -0,0 +1,36 @@
id = "wisdom_horizon"
kind = "shader"
name = "Wisdom Shaders V3 (Horizon port)"
summary = "bobcao3's Wisdom Shaders V3 ported to Horizon's native shader API."
description = """
bobcao3's Wisdom Shaders V3 (Build 3300) ported to Horizon's shader API: its sun, ambient and torch colours, Oren-Nayar and GGX lighting, jittered soft shadows, atmosphere, 2D clouds, fog and AgX tonemap, translated from GLSL to WGSL. Adds bloom, FXAA, mob and chest relighting, held light sources and a horizon haze as Horizon post passes.
Unofficial port by tomasmendes01 (in-game name: iLoong). A lighter version for more FPS is published as `wisdom_horizon_lite`."""
authors = ["bobcao3", "tomasmendes01"]
owners = ["tomasmendes01"]
owner_ids = [68385376]
license = "Apache-2.0"
source = "https://github.com/tomasmendes01/wisdom-horizon"
[[versions]]
version = "1.3.7"
published = "2026-11-04"
game = "26.3"
file = "wisdom_horizon-1.3.7.hshader"
sha256 = "3c1dc775e3eac9e67ced2e346bf7edd5cb83161b35eca7231ca9aff7725a40b4"
changelog = """
- Night: the sky and fog no longer turn black when rain hides the stars, rain streaks stay faint in the dark, and a "Night brightness" option raises the moonlight and night ambient light (3x Wisdom's by default).
- Rain: lighter fog in the rain ("Rain fog", 0.5 by default; 1 is Wisdom's), the water gets the same fog as the land, and the sun's glint on water fades under rain clouds.
- Water: its top stays flat (the waves are in the normals, as in Wisdom), and the mirror Horizon draws with vanilla's light is brought to this pack's light.
- Mobs, items, chests and particles: a post pass relights them to match this pack's light on the terrain in view, eased over time ("Light mobs and chests", with a diagnostic view).
- Held light sources light the terrain when Horizon's Dynamic lights extra is on.
- Glowing blocks show their own colour instead of burning out to white at night ("Glowing blocks"), with extra bloom around them ("Glow bloom").
- The far edge of the loaded terrain blends into the sky ("Horizon haze")."""
[[versions]]
version = "1.1.0"
published = "2026-11-04"
game = "26.3"
file = "wisdom_horizon-1.1.0.hshader"
sha256 = "2b7b4e11e3506b65e8c4162ed56fbf3c33bc51bbc56038a4b1297219d917dd1e"
changelog = "First release: a port of Wisdom Shaders V3 (Build 3300) to Horizon's shader API."
+36
View File
@@ -0,0 +1,36 @@
id = "wisdom_horizon_lite"
kind = "shader"
name = "Wisdom Shaders V3 Lite (Horizon port)"
summary = "A lighter Wisdom Shaders V3 port for Horizon, for more FPS."
description = """
A lighter version of the Wisdom Shaders V3 port (`wisdom_horizon`) for more FPS: the same lighting, atmosphere, fog and AgX tonemap, with a smaller shadow map, no bloom or FXAA, and soft shadows, cloud shadows and water reflections off by default (each can be turned back on in the settings).
Unofficial port of bobcao3's Wisdom Shaders V3 by tomasmendes01 (in-game name: iLoong)."""
authors = ["bobcao3", "tomasmendes01"]
owners = ["tomasmendes01"]
owner_ids = [68385376]
license = "Apache-2.0"
source = "https://github.com/tomasmendes01/wisdom-horizon"
[[versions]]
version = "1.3.7"
published = "2026-11-04"
game = "26.3"
file = "wisdom_horizon_lite-1.3.7.hshader"
sha256 = "ba41f0094a886b2e86a462994206fa184ebabf424dc0736829ddc7024ea9e880"
changelog = """
- Night: the sky and fog no longer turn black when rain hides the stars, rain streaks stay faint in the dark, and a "Night brightness" option raises the moonlight and night ambient light (3x Wisdom's by default).
- Rain: lighter fog in the rain ("Rain fog", 0.5 by default; 1 is Wisdom's), the water gets the same fog as the land, and the sun's glint on water fades under rain clouds.
- Water: its top stays flat (the waves are in the normals, as in Wisdom), and the mirror Horizon draws with vanilla's light is brought to this pack's light.
- Mobs, items, chests and particles: a post pass relights them to match this pack's light on the terrain in view, eased over time ("Light mobs and chests", with a diagnostic view).
- Held light sources light the terrain when Horizon's Dynamic lights extra is on.
- Glowing blocks show their own colour instead of burning out to white at night ("Glowing blocks").
- The far edge of the loaded terrain blends into the sky ("Horizon haze")."""
[[versions]]
version = "1.1.0"
published = "2026-11-04"
game = "26.3"
file = "wisdom_horizon_lite-1.1.0.hshader"
sha256 = "662570653111bd34fec37a7482791b8b1d0bdcee1bcba5351c4137acababb818"
changelog = "First release: the LITE profile of Wisdom Shaders V3 (Build 3300), ported to Horizon's shader API."
+1
View File
@@ -8,6 +8,7 @@ description = """
working schematics."""
authors = ["_cykoo_"]
owners = ["XnotCykoX"]
owner_ids = [215324132]
license = "MIT"
source = "https://github.com/XnotCykoX/WorldEdit-Horizon"
+1
View File
@@ -14,6 +14,7 @@ Instalation:
"""
authors = ["1_st_Cat"]
owners = ["RealCat1st"]
owner_ids = [260922535]
license = "Apache-2.0"
source = "https://github.com/RealCat1st/XToXrayRust"
icon = "icons/xtoxray.png"
+1
View File
@@ -19,6 +19,7 @@ Options > Mods: it rests at once, and the next build leaves it out. Its source i
"""
authors = ["Horizon"]
owners = ["ludovic111"]
owner_ids = [136255825]
license = "MIT"
source = "https://github.com/horizon-rust/horizon-mods/tree/main/native/zoom"
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.31.0"
version = "0.33.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.31.0"
version = "0.33.0"
edition = "2024"
license = "MIT"
publish = false
+35 -19
View File
@@ -27,7 +27,7 @@ header and footer).
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
| `/market [item]` | the moving market as a chest menu: categories, each item's price, its move over 1 h and 24 h and its volume; an item's page buys or sells 1, 16 or 64, sells all you carry, and gives its price chart as a map |
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name), and what you may still sell today |
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name) |
| `/sell hand [count]`, `/sell <item> [count]`, `/sell all` | sells to the market: the stack in your hand, an item, or everything it buys |
| `/buy <item> [count]` | buys from the market, only what fits in your inventory |
| `/trade <player>` | asks a player to trade (60 s to answer) |
@@ -43,6 +43,7 @@ header and footer).
| `/home [name]`, `/sethome [name]`, `/delhome [name]`, `/homes` | your homes (`home` when unnamed): go, set or move one where you stand, delete, list |
| `/warp <name>`, `/warps` | to a warp, the list; `/setwarp <name>`, `/delwarp <name>` for operators of level 3 |
| `/tpa <player>`, `/tpaccept [player]`, `/tpdeny [player]` | asks to go to a player (60 s to answer); answers a request |
| `/rtp`, `/wild` | to a random safe spot 500 to 5,000 blocks from the spawn, in the Overworld only; once every 30 minutes |
| `/rules` | the server's rules |
| `/back` | back to where you died, or where your last teleport took you from (warmup and cooldown as any teleport) |
| `/afk` | marks you away from the keyboard until you move, turn or run a command |
@@ -63,7 +64,7 @@ The market trades only plain items (not worn, named or enchanted), and only with
mode. Items move first, then money, both logged; a sale the log refuses gives the items back, and a purchase checks the
money and the room before anything moves.
**No way to get rich from the market.** The market makes money only from what players gather, and only so much a day:
**No way to get rich from the market.** The market makes money only from what players gather:
- One price per item, to buy and to sell (since 0.29): a config that still has two (`buy` 400, `sell` 100) gets
their geometric mean (200) at load, logged ("one price now"); `buy` or `sell` 0 still closes that side. A trade
of N items is priced along the curve it moves the price on (below), so buying N and selling them back gives back
@@ -76,12 +77,10 @@ money and the room before anything moves.
copper, diamond, emerald, coal, redstone, lapis, wheat, wood and food chains at each start.
- Items a recipe links (an ingot and its block, wheat and hay...) share one moving price, the block moving it as
its 9 ingots do, so crafting never beats the market however prices move; the vanilla-recipe test checks every item
at the worst drift (what makes it at 0.6 times its price, it at 1.6) too.
- Each player sells at most `daily_sell_coins` (2,000 coins) and `daily_sell_items` (640 of each item) a UTC day,
`/worth` and the shop show what is left, and `market.json` keeps the day's count across restarts.
- Money received with `/pay` counts toward the receiver's same `daily_sell_coins`: selling up to the limit on alts
and paying it all to a main is refused once the main's limit is reached ("can only receive N more today").
Trades (`/trade`) and auction house sales are not counted.
at the worst drift (what makes it at 0.2 times its price, it at 1.6) too.
- There are no daily coin or per-item limits on market sales, or daily limits on `/pay` receipts.
The former `daily_sell_coins` and `daily_sell_items` settings and `market.json` counts are ignored,
including in existing server files.
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
- The coinflip and the lottery give the winner all the stakes: they move money between players and never make any.
@@ -140,15 +139,19 @@ names), `reserved` (the words above).
**Moving prices** (`market.dynamic`, on by default since 0.26). Each item has one price: its base price (`items`)
times a factor that supply and demand move as each trade happens. Every item bought raises the log of the factor by
`1% / depth`, every item sold lowers it as much, where the depth follows the item's rarity (400 / its price, between
2 and 400: a diamond moves 50 times faster per item than cobblestone; `depth` overrides it per item). A trade of N is
2 and 50: a diamond moves 25 times faster per item than cobblestone, and any item reaches the floor within 10,000
sold; `depth` overrides it per item). A trade of N is
worth the area under that curve (`price / step × (e^(N × step) - 1)`), rounded up to whole coins when buying and down
when selling: one diamond at 200 costs 201 and sells for 199, 64 cobblestone at 1 cost 65, and a sale walks back the
exact path of the purchase. All trades together move an item at most `max_minute_move_percent` (5%) a minute from
where it started that minute: past that, a purchase is cut short and a sale keeps the rest ("sell them in a
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.6)
and `max_factor` (1.6), where the rest of a trade goes at that price. There is no tax on trades (an older config's
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
sold back to it. (`max_trade_impact_percent` and `max_player_impact_percent` are no longer used: the curve and the
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.2)
and `max_factor` (1.6), where the rest of a trade goes at that price. With no daily sale caps (since 0.33), the floor
is what bounds a farm: selling one item without end takes its price down to a fifth of its base, where it stays while
the selling goes on; a config with the old floor (0.6) gets 0.2 when it loads. There is no tax on trades (an older config's
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Market purchases can be sold back immediately;
the former `round_trip_hours` setting and saved purchase receipts are ignored. (`max_trade_impact_percent` and
`max_player_impact_percent` are no longer used: the curve and the
minute's band do their work.) History: one point a minute for 24 hours and one an hour for 30 days (the mid price, the
items traded and those sold), in `market_prices.json`, written by a thread every 5 minutes and when the world closes.
At the first minute of each UTC day, the day's three biggest risers and fallers (at least 1%) and news from real
@@ -158,7 +161,7 @@ fixed prices of 0.23. For other mods: `Market::spot`, `volume(id, now, hours)`,
**`/market`.** A 6-row chest menu: the shop's categories and the day's movers; a category's items, each with its
price, its change over 1 h and 24 h (green ▲, red ▼) and what was traded in 24 h; an item's page with its
base price and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
base price , buttons to buy or sell 1, 16 and 64 (what they come to
along the curve), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
drawn by the server: the curve green when it rose, red when it fell, the price, the change, the low and high). A
chart map is drawn again each minute for whoever carries it, and once a minute the open menus follow the prices.
@@ -167,8 +170,8 @@ chart map is drawn again each minute for whoever carries it, and once a minute t
**The shop.** `/shop` opens a 6-row chest menu on the market's prices: the categories of `config.json`'s `shop` (an
item in none goes under "Other"), each item named with its prices. A click buys one, a shift-click 64; a click on a
stack in your own inventory, below the menu, sells it; "Sell all" sells everything the market buys. The bottom row
shows your balance and today's selling limit. It goes through the market's own rules (survival or adventure only,
items then money, logged, the daily limits); the items move by the console's `clear` and `give`, measured on the
shows your balance. It goes through the market's own rules (survival or adventure only,
items then money, logged); the items move by the console's `clear` and `give`, measured on the
inventory before and after, so the money follows what really moved.
**Trades.** Once accepted, both players see a 6-row chest menu: their own offer on the left (16 stacks), the other's
@@ -226,6 +229,20 @@ was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20,
lines. `/spawn` lands on a random safe column within `spawn_radius` (250) blocks of the spawn point, like vanilla's `respawn_radius` for a first join or a respawn: after the `tp`, vanilla's `spreadplayers` moves the player off fluid, fire and magma onto solid ground (0: exactly the spawn point; if no column is found the player stays at the spawn point). Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
as any teleport does.
`/rtp` (or `/wild`) draws `rtp_attempts` (6) chunks at random between `rtp_min_distance` (500) and `rtp_max_distance`
(5000) blocks from the spawn (`/setspawn`'s point, 0 0 without one), uniform over the ring's area and at least 16 blocks
inside the world border (as last saved in `world_border.dat`), then holds them loaded one after another with vanilla's
`forceload` while the warmup runs: the game generates them on its worker threads, never on the server tick. Once a
chunk is loaded, the Overworld's tick reads its 256 columns (`native_read_columns`, the top block below the
`MOTION_BLOCKING_NO_LEAVES` height and the two above it) and the player lands, still standing still, on a random one
whose top block is solid ground (no fluid, fire, cactus, magma, powder snow, berry bush or dripstone) with air or grass
above it, outside another player's claim and still in the ring and the border; with none the next chunk is tried. With
no spot after the six, or no land loaded `rtp_search_seconds` (30) after the warmup, it says so and nothing is spent.
A teleport made starts `rtp_cooldown_seconds` (1800) for that player, kept in `places.json` over restarts ("Wait 12 min
before your next /rtp."); a teleport called off (a move, a hit, the game failing it) doesn't. Elsewhere than the
Overworld it refuses. The chunks it forced are let go 10 s after the landing, or at the next start if the server
stopped first; a chunk an operator forced already is used and left forced. `/back` returns from it as from any teleport.
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
click blocks: doors, chests, levers, buttons, placing a block, a bucket or flint on a block. Anyone else's click does
nothing, and the action bar says whose land it is. The chunks a player may hold grow with the time played here
@@ -327,14 +344,13 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| File | Holds |
|---|---|
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `nicks.json` | each player's nickname and when they last changed it |
| `vanish.json` | the vanished operators by UUID, with their name |
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
| `market.json` | what each player sold today (coins and items), for the daily limits |
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), what each player bought in the last day (no selling it back), the day's summary |
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), the day's summary |
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
+109 -42
View File
@@ -2,7 +2,7 @@
// between the game's types and the rules (smp_economy, smp_market, smp_shop,
// smp_trade, smp_games, smp_lottery, smp_places, smp_claims). Added to
// the game as `game::smp_glue`; the helpers (register.rs, command.rs,
// tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs, destroy_block.rs,
// tick.rs, level_tick.rs, lifecycle.rs, menu_clicked.rs, menu_closed.rs, destroy_block.rs,
// right_click_block.rs, block_spread.rs, piston_move.rs,
// explosion_detonate.rs, fill_bucket.rs, attack_entity.rs) call it from the
// hook points.
@@ -41,7 +41,7 @@ use std::{path::PathBuf, sync::Mutex};
/// The mod's id, name and version.
pub const ID: &str = "smp";
pub const NAME: &str = "SMP";
pub const VERSION: &str = "0.31.0";
pub const VERSION: &str = "0.33.0";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time, and again with the keys a newer version adds (values kept).
@@ -143,6 +143,11 @@ static BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(
/// Sleep-count changes decided inside a player's command, for the next
/// second's tick.
static PENDING_SLEEP: Mutex<Vec<smp_presence::Act>> = Mutex::new(Vec::new());
/// Whether an `/rtp` waits for its land: the Overworld's tick reads it then.
static RTP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// What each `/rtp`'s reads cost the Overworld's tick, by player: reads,
/// total and longest (µs), for the line logged when they land.
static RTP_COST: Mutex<Vec<(u128, u32, u64, u64)>> = Mutex::new(Vec::new());
fn open(dir: PathBuf) -> Smp {
let config_path = dir.join("config.json");
@@ -305,9 +310,6 @@ fn save(smp: &mut Smp) {
{
log::error!("[smp] {err}");
}
if let Err(err) = smp.market.save() {
log::error!("[smp] {err}");
}
if let Ok(p) = &mut smp.places
&& let Err(err) = p.save(chrono::Utc::now().timestamp_millis())
{
@@ -392,6 +394,8 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
"tpdeny [player:word]",
"rules",
"back",
"rtp",
"wild",
"playtime [player:word]",
"claim",
"claim info",
@@ -592,18 +596,91 @@ const COMMANDS: [&str; 16] = [
"lottery", "ah", "casino",
];
/// `/pay <player> <amount>`'s receiver (account, name) and amount, as
/// `Economy::pay` reads them; None for another command.
fn pay_target(e: &Economy, line: &str) -> Option<(u128, String, i64)> {
let [cmd, who, amount] = line.split_whitespace().collect::<Vec<_>>()[..] else {
return None;
/// The world border, from the Overworld's `world_border.dat` (the root's in
/// older worlds), as the game last saved it; None without one (the default
/// border, 60 million blocks wide).
fn world_border(world: &std::path::Path) -> Option<smp_places::Bounds> {
let files = [
world.join("dimensions/minecraft/overworld/data/minecraft/world_border.dat"),
world.join("data/minecraft/world_border.dat"),
];
let root = files
.iter()
.find_map(|f| horizon_world::datafix::files::read_saved_dat(f).ok().flatten())?;
let border = horizon_client::worldborder::WorldBorder::unpack(root.get("data")?);
Some(border.bounds())
}
/// After a level's tick: in the Overworld, each `/rtp` that waits reads the
/// chunk it loads (the game reads only the level it ticks), five times a
/// second. Columns in another player's claim are never a spot.
pub(in crate::game) fn level_ticked(game: &mut Game<'_>, dimension: &str) {
use std::sync::atomic::Ordering::Relaxed;
if dimension != smp_places::OVERWORLD
|| !RTP.load(Relaxed)
|| TICKS.load(Relaxed) % 4 != 0
|| !game.native_on(ID)
{
return;
}
let Some(dir) = game.native_world_dir() else {
return;
};
if cmd != "pay" {
let mut guard = state(dir.join(smp_store::DIR));
let Some(smp) = guard.as_mut() else {
return;
};
let (Ok(places), claims) = (&mut smp.places, &smp.claims) else {
return;
};
let game = &*game;
let mut cost = RTP_COST.lock().unwrap_or_else(|e| e.into_inner());
places.rtp_look(&mut |uuid, (cx, cz)| {
let started = std::time::Instant::now();
let ground = read_chunk(game, claims, uuid, (cx, cz));
let us = started.elapsed().as_micros() as u64;
match cost.iter_mut().find(|c| c.0 == uuid) {
Some(c) => *c = (uuid, c.1 + 1, c.2 + us, c.3.max(us)),
None => cost.push((uuid, 1, us, us)),
}
ground
});
RTP.store(places.rtp_searching(), Relaxed);
}
/// Chunk `(cx, cz)` of the Overworld as `/rtp` reads it, None while it isn't
/// loaded; no columns when another player claimed it.
fn read_chunk(
game: &Game<'_>,
claims: &Result<Claims, String>,
uuid: u128,
(cx, cz): (i32, i32),
) -> Option<smp_places::Ground> {
let columns: Vec<(i32, i32)> = (0..256).map(|i| (cx * 16 + i / 16, cz * 16 + i % 16)).collect();
let read = game
.native_read_columns(
smp_places::OVERWORLD,
&columns,
crate::game::native_world_edit::NativeHeightmap::MotionBlockingNoLeaves,
(1, 2),
)
.ok()?;
if read.bases.iter().any(Option::is_none) {
return None;
}
let amount = smp_economy::parse_amount(amount)?;
let (to, account) = e.find(who)?;
Some((to, account.name.clone(), amount))
let mut ground = smp_places::Ground { palette: read.palette, columns: Vec::new() };
let claimed = claims
.as_ref()
.ok()
.and_then(|c| c.owner(smp_places::OVERWORLD, cx * 16, cz * 16))
.is_some_and(|owner| owner != uuid);
if !claimed {
for (i, (&(x, z), base)) in columns.iter().zip(&read.bases).enumerate() {
let b = &read.blocks[i * 3..i * 3 + 3];
ground.columns.push((x, z, base.unwrap_or_default(), [b[0], b[1], b[2]]));
}
}
Some(ground)
}
/// The players on, as the places see them.
@@ -820,6 +897,11 @@ pub(in crate::game) fn perform(
} else if smp_places::COMMANDS.contains(&word) {
match &mut smp.places {
Ok(p) => {
if matches!(word, "rtp" | "wild") {
p.border = world_border(&dir);
RTP_COST.lock().unwrap_or_else(|e| e.into_inner()).retain(|c| c.0 != me.uuid);
BUSY.store(true, std::sync::atomic::Ordering::Relaxed);
}
let seen: Vec<NativeServerPlayer> = players.iter().filter(|p| !hidden.contains(&p.uuid)).cloned().collect();
p.command(&me, &bodies(&seen), now, line).unwrap_or_default()
}
@@ -850,27 +932,7 @@ pub(in crate::game) fn perform(
let report = smp.report.as_ref();
report
.and_then(|r| r.command(e, line))
.or_else(|| {
// A payment counts toward the receiver's daily selling
// limit (alts can't sell for a main past it).
let paid = pay_target(e, line);
if let Some((to, name, amount)) = &paid
&& let Err(left) = smp.market.may_receive(*to, *amount, now)
{
return Some(vec![Out::Error(format!(
"{name} can only receive {} more today: payments count toward the same daily limit as selling to the market.",
e.money(left)
))]);
}
let before = paid.as_ref().map(|(to, ..)| e.balance(*to));
let out = e.command(&me, &on, line);
if let (Some((to, _, amount)), Some(b)) = (paid, before)
&& e.balance(to) == b + amount
{
smp.market.count_received(to, now, amount);
}
out
})
.or_else(|| e.command(&me, &on, line))
.or_else(|| smp.market.command(e, &me, trades, &mut bag, line))
.or_else(|| {
let (out, a) = smp.trades.command(e, &me, trades, &online, now, line)?;
@@ -1509,10 +1571,6 @@ fn shop_items(smp: &Smp, uuid: u128) -> Vec<Option<ItemStack>> {
button("minecraft:gold_ingot", &format!("Balance: {}", money(b)), "gold")
}
C::SellAll => button("minecraft:hopper", "Sell everything the market buys", "green"),
C::Limits => {
let line = economy.and_then(|e| smp.market.limits_line(e, uuid));
button("minecraft:clock", &line.unwrap_or_else(|| "No selling limit".into()), "aqua")
}
C::Help => button("minecraft:book", smp_shop::HELP_TEXT, "white"),
C::Close => button("minecraft:barrier", "Close", "red"),
})
@@ -1693,9 +1751,6 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
let mut lines = price_lines(smp, &id, now);
let base = smp.market.base(&id);
lines.push((format!("Base price: {}", money(base.buy.max(base.sell))), "gray"));
if let Some(left) = smp.market.items_left(uuid, now, &id) {
lines.push((format!("You may still sell {left} today"), "gray"));
}
with_lore(button(&id, &name(&id), "white"), &lines)
}
C::Buy(id, n) => {
@@ -2293,8 +2348,20 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
{
log::warn!("[smp] no safe spot near the spawn: {spread}");
}
if t.what == "a random spot" {
let mut cost = RTP_COST.lock().unwrap_or_else(|e| e.into_inner());
let (_, n, total, max) = cost.iter().find(|c| c.0 == t.uuid).copied().unwrap_or_default();
cost.retain(|c| c.0 != t.uuid);
log::info!(
"[smp] /rtp: {} to {:.1} {:.1} {:.1} ({n} chunk reads on the tick: {total} µs, longest {max} µs)",
t.name, t.to.x, t.to.y, t.to.z
);
}
out.push(Out::Tell(t.uuid, t.done.clone()));
}
// `/rtp`'s chunks: held loaded while it looks, let go after.
p.force(now, &mut |line| game.native_run_command(line));
RTP.store(p.rtp_searching(), Relaxed);
}
if let Ok(e) = &mut smp.economy
&& n % 20 == 0
+11 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.31.0"
version = "0.33.0"
priority = 100
# The commit of horizon it was tested on: it needs native_hide_player (horizon#7003, API 314, stamped at 48c63bc),
@@ -213,6 +213,16 @@ bindings = [
{ name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" },
]
[[injection]]
id = "level_tick"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_level_tick_post"
helper = "level_tick.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "dimension", ty = "&str", value = "dimension" },
]
[[injection]]
id = "lifecycle"
kind = "HEAD"
+5
View File
@@ -0,0 +1,5 @@
// After a level's tick: in the Overworld, the `/rtp`s waiting for their land
// read it (the game reads only the level it ticks).
fn smp_level_tick_post(game: &mut crate::game::Game<'_>, dimension: &str) {
crate::game::smp_glue::level_ticked(game, dimension);
}
+255 -372
View File
@@ -7,16 +7,16 @@
// log refuses gives the items back. Nobody gets rich from it: buying always
// costs more than selling (an item priced otherwise isn't bought), no chain
// of recipes turns bought items into dearer ones (smp_recipes; such an item
// isn't bought either), and what a player sells is capped each UTC day, in
// coins and per item (`market.json` counts it). No game types: the glue
// isn't bought either). No daily sale caps. No game types: the glue
// lends the player's items as a `Bag`. Added to the game as
// `game::smp_market`; also a module of this crate, for its tests.
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
use super::smp_market_prices::{
Book, Dynamic, MINUTE_MS, Point, Quote, Span, arrow, base_mid, one_price, to_get, to_pay,
Book, Dynamic, MIN_FACTOR, MINUTE_MS, OLD_MIN_FACTOR, Point, Quote, Span, arrow, base_mid,
one_price, to_get, to_pay,
};
use super::smp_store::{read_json, uuid_string, write_json};
use super::smp_store::read_json;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
@@ -39,12 +39,6 @@ pub struct Config {
pub enabled: bool,
/// By item id, `minecraft:diamond`. Items that stack to 64.
pub items: BTreeMap<String, Price>,
/// The most a player earns selling to the market in a UTC day (0: no
/// limit).
pub daily_sell_coins: i64,
/// The most of one item a player sells to the market in a UTC day (0:
/// no limit).
pub daily_sell_items: u32,
/// Prices that move with supply and demand (smp_market_prices); the
/// item prices above are then the base ones.
pub dynamic: Dynamic,
@@ -119,8 +113,7 @@ pub const PRICES: &[(&str, i64, i64)] = &[
];
impl Default for Config {
/// `PRICES` (one price where the market trades both ways), and the
/// daily limits.
/// `PRICES` (one price where the market trades both ways).
fn default() -> Self {
Self {
enabled: true,
@@ -137,8 +130,6 @@ impl Default for Config {
)
})
.collect(),
daily_sell_coins: 2000,
daily_sell_items: 640,
dynamic: Dynamic::default(),
}
}
@@ -167,9 +158,20 @@ const OLD_PRICES: [(&str, i64, i64); 18] = [
("torch", 1, 0),
];
/// A config whose prices are still the old defaults, untouched: today's
/// defaults instead. Whether it changed (the file is written again).
/// A config whose prices, or price floor, are still the old defaults,
/// untouched: today's defaults instead. Whether it changed (the file is
/// written again).
pub fn migrate(config: &mut Config) -> bool {
// 0.33 dropped the daily sale caps: the floor (60% of the base before)
// goes down to 20%, so selling without end pays less and less.
let floor = (config.dynamic.min_factor - OLD_MIN_FACTOR).abs() < 1e-9;
if floor {
config.dynamic.min_factor = MIN_FACTOR;
}
migrate_prices(config) || floor
}
fn migrate_prices(config: &mut Config) -> bool {
let old: BTreeMap<String, Price> = OLD_PRICES
.iter()
.map(|(id, buy, sell)| {
@@ -189,29 +191,11 @@ pub fn migrate(config: &mut Config) -> bool {
true
}
/// What one player sold today.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Sold {
/// The UTC day (days since 1970) the counts are for.
pub day: i64,
pub coins: i64,
pub items: BTreeMap<String, u32>,
}
/// `market.json`: what each player sold today, by UUID.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Ledger {
pub sold: BTreeMap<String, Sold>,
}
/// A config's prices made safe: an amount out of range closes that side; an
/// item traded both ways gets one price; an item a chain of recipes makes
/// for less than it sells for is no longer bought. What changed, to log.
pub fn validate(config: &mut Config) -> Vec<String> {
let mut closed = Vec::new();
config.daily_sell_coins = config.daily_sell_coins.clamp(0, MAX_AMOUNT);
config.dynamic.validate();
for (id, p) in config.items.iter_mut() {
if !valid_id(id) {
@@ -315,10 +299,8 @@ pub fn item_name(id: &str) -> String {
pub struct Market {
pub config: Config,
ledger: Ledger,
/// `market.json`; None in tests that keep nothing.
path: Option<PathBuf>,
dirty: bool,
/// The SMP data directory; None in tests that keep nothing.
dir: Option<PathBuf>,
/// The moving prices and their history (`market_prices.json`, written
/// by the glue off the tick thread: `book_to_write`).
pub book: Book,
@@ -368,9 +350,7 @@ const BOOK_EVERY_MS: i64 = 5 * MINUTE_MS;
impl Market {
/// The market of `dir` (`world/horizon-smp/`), its config made safe
/// (`validate`), with what to log. A `market.json` that can't be read
/// starts the day's counts from nothing (they only limit, never hold
/// money).
/// (`validate`), with what to log.
pub fn open(dir: &std::path::Path, mut config: Config) -> (Self, Vec<String>) {
let mids = |c: &Config| -> BTreeMap<String, f64> {
c.items
@@ -383,11 +363,6 @@ impl Market {
.into_iter()
.map(|l| format!("market: {l}"))
.collect();
let path = dir.join("market.json");
let ledger = read_json(&path).unwrap_or_else(|e| {
problems.push(format!("{e}: today's sale limits start again"));
Ledger::default()
});
let mut book: Book = read_json(&dir.join("market_prices.json")).unwrap_or_else(|e| {
problems.push(format!("{e}: prices start again from their base"));
Book::default()
@@ -401,9 +376,7 @@ impl Market {
}
let mut market = Self {
config,
ledger,
path: Some(path),
dirty: false,
dir: Some(dir.to_owned()),
book,
book_written: 0,
book_dirty: !rebased.is_empty(),
@@ -419,9 +392,7 @@ impl Market {
pub fn new(config: Config) -> Self {
let mut m = Self {
config,
ledger: Ledger::default(),
path: None,
dirty: false,
dir: None,
book: Book::default(),
book_written: 0,
book_dirty: false,
@@ -446,18 +417,21 @@ impl Market {
}
/// How far one `id` moves the log of its factor: in a family, by value
/// (a block of 9 moves it as 9 ingots do), so crafting can't beat it.
/// (a block of 9 moves it as 9 ingots do), so crafting can't beat it,
/// from its cheapest member's depth (so that every member, not only
/// the cheapest, reaches the floor within `MAX_DEPTH` items a 1%).
fn unit_move(&self, id: &str) -> f64 {
let d = &self.config.dynamic;
let mid = |id: &str| {
let b = self.base(id);
base_mid(b.buy, b.sell)
};
match self
.family
.get(id)
.and_then(|f| f.iter().chain([&id.to_owned()]).min().cloned())
{
match self.family.get(id).and_then(|f| {
f.iter()
.chain([&id.to_owned()])
.min_by(|a, b| mid(a).total_cmp(&mid(b)).then_with(|| a.cmp(b)))
.cloned()
}) {
Some(first) if mid(&first) > 0.0 => {
Book::unit_move(d, &first, mid(&first)) / mid(&first) * mid(id)
}
@@ -485,82 +459,6 @@ impl Market {
self.spot(id).map_or(0.0, |q| q.mid)
}
pub fn save(&mut self) -> Result<(), String> {
if !self.dirty {
return Ok(());
}
if let Some(path) = &self.path {
write_json(path, &self.ledger)?;
}
self.dirty = false;
Ok(())
}
/// What `uuid` sold today (`now`: milliseconds since 1970).
fn sold(&self, uuid: u128, now: i64) -> Sold {
match self.ledger.sold.get(&uuid_string(uuid)) {
Some(s) if s.day == now.div_euclid(DAY_MS) => s.clone(),
_ => Sold::default(),
}
}
/// The coins `uuid` may still earn selling today (None: no limit).
pub fn coins_left(&self, uuid: u128, now: i64) -> Option<i64> {
let cap = self.config.daily_sell_coins;
(cap > 0).then(|| (cap - self.sold(uuid, now).coins).max(0))
}
/// How many of `id` `uuid` may still sell today (None: no limit).
pub fn items_left(&self, uuid: u128, now: i64, id: &str) -> Option<u32> {
let cap = self.config.daily_sell_items;
(cap > 0)
.then(|| cap.saturating_sub(self.sold(uuid, now).items.get(id).copied().unwrap_or(0)))
}
/// Whether `uuid` may be paid `amount` by another player now (`/pay`):
/// money received counts toward the same daily limit as selling to the
/// market, so selling on alts and paying it all to a main doesn't get
/// past it. Err: what they may still receive today.
pub fn may_receive(&self, uuid: u128, amount: i64, now: i64) -> Result<(), i64> {
match self.coins_left(uuid, now) {
Some(left) if amount > left => Err(left),
_ => Ok(()),
}
}
/// Counts `coins` paid to `uuid` (`/pay`) against today's limit.
pub fn count_received(&mut self, uuid: u128, now: i64, coins: i64) {
let day = now.div_euclid(DAY_MS);
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
if s.day != day {
*s = Sold {
day,
..Sold::default()
};
}
s.coins = s.coins.saturating_add(coins.max(0));
self.ledger.sold.retain(|_, s| s.day == day);
self.dirty = true;
}
/// Counts a sale against today's limits.
fn count_sale(&mut self, uuid: u128, now: i64, id: &str, n: u32, coins: i64) {
let day = now.div_euclid(DAY_MS);
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
if s.day != day {
*s = Sold {
day,
..Sold::default()
};
}
s.coins = s.coins.saturating_add(coins);
let c = s.items.entry(id.to_owned()).or_default();
*c = c.saturating_add(n);
// Yesterday's counts go.
self.ledger.sold.retain(|_, s| s.day == day);
self.dirty = true;
}
/// The price of `id` now (nothing on either side when it isn't
/// traded): the base one moved by supply and demand.
pub fn price(&self, id: &str) -> Price {
@@ -642,7 +540,7 @@ impl Market {
if !self.book_dirty || (!force && now - self.book_written < BOOK_EVERY_MS) {
return None;
}
let path = self.path.as_ref()?.with_file_name("market_prices.json");
let path = self.dir.as_ref()?.join("market_prices.json");
self.book_dirty = false;
self.book_written = now;
Some((path, self.book.clone()))
@@ -696,26 +594,6 @@ impl Market {
}
}
/// Today's limits for `uuid`, as a line (None when there are none).
pub fn limits_line(&self, economy: &Economy, uuid: u128) -> Option<String> {
let now = economy.now;
let coins = self.coins_left(uuid, now).map(|left| {
format!(
"{} left of your {} a day",
economy.money(left),
economy.money(self.config.daily_sell_coins)
)
});
let items = (self.config.daily_sell_items > 0).then(|| {
format!(
"at most {} of each item a day",
self.config.daily_sell_items
)
});
let parts: Vec<String> = coins.into_iter().chain(items).collect();
(!parts.is_empty()).then(|| format!("Selling today: {}.", parts.join(", ")))
}
/// Runs `/worth`, `/sell` or `/buy`; None for another command. `trades`
/// is false for a player in creative or spectator mode.
pub fn command(
@@ -735,7 +613,7 @@ impl Market {
return Some(vec![Out::Error("The market is closed.".into())]);
}
if name == "worth" {
return Some(self.worth(economy, me, bag, args));
return Some(self.worth(economy, bag, args));
}
if !trades {
return Some(vec![Out::Error(
@@ -748,7 +626,7 @@ impl Market {
})
}
fn worth(&self, economy: &Economy, me: &Player, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
fn worth(&self, economy: &Economy, bag: &mut dyn Bag, args: &[&str]) -> Vec<Out> {
let id = match args {
[] => match bag.held() {
Some((id, ..)) => id,
@@ -781,19 +659,10 @@ impl Market {
id.trim_start_matches("minecraft:")
)));
}
if p.sell > 0 {
if let Some(left) = self.items_left(me.uuid, economy.now, &id) {
out.push(Out::Reply(format!(
"You may sell {left} more {name} today."
)));
}
out.extend(self.limits_line(economy, me.uuid).map(Out::Reply));
}
out
}
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`), within
/// today's limits.
/// Sells what `args` names (`hand [n]`, `all`, `<item> [n]`).
pub fn sell(
&mut self,
economy: &mut Economy,
@@ -846,39 +715,15 @@ impl Market {
continue;
}
let price = self.each(&id).max(0.01);
// Never more than the account can be paid for at once, nor
// than today's limits leave.
let now = economy.now;
let mut cap = (MAX_AMOUNT as f64 / price).min(f64::from(u32::MAX)) as u32;
if let Some(left) = self.items_left(me.uuid, now, &id) {
cap = cap.min(left);
}
// What they bought here lately can't come back yet.
let back = self.recently_bought(me.uuid, &id, now);
if let Some(left) = self.coins_left(me.uuid, now) {
cap = cap.min((left as f64 / price).min(f64::from(u32::MAX)) as u32);
}
// Never more than the account can be paid for at once.
let cap = (MAX_AMOUNT as f64 / price).min(f64::from(u32::MAX)) as u32;
let carried = bag.count(&id).min(at_most);
if carried == 0 {
out.push(Out::Error(format!("You have no plain {name}.")));
continue;
}
let limited = carried.min(cap);
if limited == 0 {
out.push(Out::Error(format!(
"You reached today's selling limit for {name}; it starts again at midnight UTC."
)));
continue;
}
let have = limited.min(carried.saturating_sub(back));
if have == 0 {
let hours = self.config.dynamic.round_trip_hours;
out.push(Out::Error(format!(
"You bought {back} {name} from the market in the last {hours} h: those can't be sold back yet."
)));
continue;
}
let taken = bag.take(&id, have);
let taken = bag.take(&id, limited);
if taken == 0 {
continue;
}
@@ -905,7 +750,6 @@ impl Market {
let kind = format!("sell {taken} {id}");
match economy.adjust(&kind, me.uuid, pay, "") {
Ok(_) => {
self.count_sale(me.uuid, now, &id, taken, pay);
self.moved(&id, taken, false);
total = total.saturating_add(pay);
let each = each_money(economy, pay as f64 / f64::from(taken));
@@ -913,19 +757,6 @@ impl Market {
"Sold {taken} {name} at {each} each: {}.",
economy.money(pay)
)));
if taken < limited {
let hours = self.config.dynamic.round_trip_hours;
out.push(Out::Reply(format!(
"{} {name} kept: bought from the market in the last {hours} h.",
limited - taken
)));
}
if limited < carried {
out.push(Out::Reply(format!(
"{} {name} kept: today's selling limit.",
carried - limited
)));
}
}
Err(e) => {
let back = bag.give(&id, taken);
@@ -1008,9 +839,6 @@ impl Market {
};
if given > 0 {
self.moved(&id, given, true);
let now = economy.now;
self.book.bought(&uuid_string(me.uuid), &id, given, now);
self.book_dirty = true;
}
let each = each_money(economy, paid as f64 / f64::from(given.max(1)));
let mut out = vec![Out::Reply(format!(
@@ -1069,15 +897,6 @@ impl Market {
);
self.book_dirty = true;
}
/// How many of `id` `uuid` bought within the round-trip window.
pub fn recently_bought(&self, uuid: u128, id: &str, now: i64) -> u32 {
if !self.config.dynamic.enabled {
return 0;
}
self.book
.recently_bought(&self.config.dynamic, &uuid_string(uuid), id, now)
}
}
/// `minecraft:iron_ingot` as `Iron Ingot`.
@@ -1207,6 +1026,7 @@ impl WebView {
#[cfg(test)]
mod tests {
use super::super::smp_economy::Config as EconomyConfig;
use super::super::smp_store::{uuid_string, write_json};
use super::*;
use std::fs;
@@ -1279,31 +1099,58 @@ mod tests {
}
#[test]
fn money_received_by_pay_counts_toward_the_daily_selling_limit() {
let config = Config {
daily_sell_coins: 250,
..Config::default()
fn legacy_daily_caps_do_not_limit_sales_or_payments() {
// Existing configs and today's saved ledger may still contain the
// removed daily caps and receipts from before the upgrade.
let config: Config = serde_json::from_value(serde_json::json!({
"daily_sell_coins": 2000,
"daily_sell_items": 640,
"dynamic": {"enabled": false}
}))
.unwrap();
let (mut e, mut m, me, dir) = setup_with("no-coin-cap", config);
e.now = 5 * 86_400_000;
fs::write(
dir.join("market.json"),
serde_json::json!({
"sold": { uuid_string(ALEX): {
"day": 5, "coins": 2000, "items": {"minecraft:diamond": 640}
}}
})
.to_string(),
)
.unwrap();
let (loaded, problems) = Market::open(&dir, m.config.clone());
assert!(problems.is_empty(), "{problems:?}");
m = loaded;
let mut b = bag(&[("minecraft:diamond", 700)], 0);
m.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert_eq!(b.count("minecraft:diamond"), 0);
assert_eq!(e.balance(ALEX), 140_000);
let earned = e.balance(ALEX);
e.seen(2, "Steve");
e.adjust("test", 2, 10_000, "").unwrap();
let sender = Player {
uuid: 2,
name: "Steve".into(),
level: 0,
};
let (_e, mut m, _me, _dir) = setup_with("received", config);
let now = 1_793_577_600_000;
// An alt pays the main 200: fine, and it counts.
assert_eq!(m.may_receive(ALEX, 200, now), Ok(()));
m.count_received(ALEX, now, 200);
assert_eq!(m.coins_left(ALEX, now), Some(50));
// A second alt's 100 is refused: 50 left today, for sales or payments.
assert_eq!(m.may_receive(ALEX, 100, now), Err(50));
assert_eq!(m.may_receive(ALEX, 50, now), Ok(()));
// The next day the limit is whole again.
assert_eq!(m.may_receive(ALEX, 250, now + DAY_MS), Ok(()));
// No limit configured: anything goes.
let (_e, m, _me, _dir) = setup_with(
"unlimited",
Config {
daily_sell_coins: 0,
..Config::default()
},
);
assert_eq!(m.may_receive(ALEX, 1_000_000, now), Ok(()));
// Repeated receipts over the former cap, on the same day as sales.
for _ in 0..2 {
let out = e.command(&sender, &[], "pay Alex 3000").unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
}
assert_eq!(e.balance(ALEX), earned + 6000);
assert_eq!(e.balance(2), 4000);
// Reopening on the same day ignores the old full daily ledger too.
let (mut m, problems) = Market::open(&dir, m.config.clone());
assert!(problems.is_empty(), "{problems:?}");
b.items.insert("minecraft:diamond".into(), 700);
m.command(&mut e, &me, true, &mut b, "sell all").unwrap();
assert_eq!(b.count("minecraft:diamond"), 0);
assert_eq!(e.balance(ALEX), 286_000);
fs::remove_dir_all(dir).unwrap();
}
#[test]
@@ -1433,95 +1280,6 @@ mod tests {
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn sales_stop_at_the_daily_limits_and_start_again_the_next_day() {
let config = Config {
daily_sell_coins: 250,
daily_sell_items: 30,
..fixed()
};
let (mut e, mut m, me, dir) = setup_with("limits", config);
e.now = 5 * DAY_MS + 1000;
let mut b = bag(&[("minecraft:diamond", 5), ("minecraft:wheat", 64)], 0);
// 250 coins a day: one diamond (200), then 25 wheat (50 coins), the
// coins' limit before the 30 items of one kind.
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 1 diamond at 200 coins each: 200 coins.".into()),
"{out:?}"
);
assert_eq!(
out[1],
Out::Reply("4 diamond kept: today's selling limit.".into())
);
let out = m
.command(&mut e, &me, true, &mut b, "sell wheat 5")
.unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 5 wheat at 2 coins each: 10 coins.".into()),
"{out:?}"
);
// 40 coins left, but 25 more wheat would pass the 30 items.
e.adjust("test", ALEX, 0, "").ok();
m.config.daily_sell_coins = 1000;
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 25 wheat at 2 coins each: 50 coins.".into()),
"{out:?}"
);
m.config.daily_sell_coins = 250;
let out = m.command(&mut e, &me, true, &mut b, "sell wheat").unwrap();
assert_eq!(
out,
[Out::Error(
"You reached today's selling limit for wheat; it starts again at midnight UTC."
.into()
)]
);
let worth = m
.command(&mut e, &me, true, &mut b, "worth diamond")
.unwrap();
assert!(
worth.contains(&Out::Reply("You may sell 29 more diamond today.".into())),
"{worth:?}"
);
assert!(
worth.contains(&Out::Reply(
"Selling today: 0 coins left of your 250 coins a day, at most 30 of each item a day.".into()
)),
"{worth:?}"
);
assert_eq!((e.balance(ALEX), b.items["minecraft:diamond"]), (260, 4));
// Kept across a restart, in market.json.
m.path = Some(dir.join("market.json"));
m.dirty = true;
m.save().unwrap();
let (mut m, _) = Market::open(&dir, m.config.clone());
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("limit")),
"{out:?}"
);
// A new UTC day.
e.now = 6 * DAY_MS;
let out = m
.command(&mut e, &me, true, &mut b, "sell diamond")
.unwrap();
assert_eq!(
out[0],
Out::Reply("Sold 1 diamond at 200 coins each: 200 coins.".into()),
"{out:?}"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn prices_that_would_make_money_are_closed() {
let mut config = fixed();
@@ -1556,13 +1314,7 @@ mod tests {
);
assert!(config.items.keys().all(|k| valid_id(k)));
// At the largest price: one at a time, without overflow.
let (mut e, mut m, me, dir) = setup_with(
"validate",
Config {
daily_sell_coins: 0,
..config
},
);
let (mut e, mut m, me, dir) = setup_with("validate", Config { ..config });
let mut b = bag(&[("minecraft:rotten_flesh", 64)], 0);
let out = m
.command(&mut e, &me, true, &mut b, "sell rotten_flesh")
@@ -1630,15 +1382,9 @@ mod tests {
assert_eq!(edited, before);
}
/// Moving prices, validated, with no daily caps and no round-trip
/// window: only the prices themselves stand between a trader and money.
/// Moving prices, validated: only the prices stand between a trader and money.
fn moving() -> Config {
let mut c = Config {
daily_sell_coins: 0,
daily_sell_items: 0,
..Config::default()
};
c.dynamic.round_trip_hours = 0;
let mut c = Config::default();
let _ = validate(&mut c);
c
}
@@ -1797,11 +1543,157 @@ mod tests {
}
#[test]
fn moving_prices_pay_the_whole_receipt_and_keep_bought_items_from_coming_back() {
fn the_old_price_floor_becomes_the_new_one_and_an_edited_one_stays() {
let mut c = Config::default();
let _ = validate(&mut c);
c.dynamic.min_factor = 0.6;
assert!(migrate(&mut c));
assert_eq!(c.dynamic.min_factor, 0.2);
assert!(!migrate(&mut c), "once");
c.dynamic.min_factor = 0.5;
assert!(!migrate(&mut c));
assert_eq!(c.dynamic.min_factor, 0.5);
}
#[test]
fn selling_ten_thousand_of_any_item_drives_its_price_to_the_floor() {
// No daily cap: an AFK farm sells 10,000 of one item, a minute at a
// time as the band allows. The price falls to a fifth of its base
// and the farm gets far less than 10,000 at the base price.
let c = moving();
let ids: Vec<String> = c
.items
.iter()
.filter(|(_, p)| p.sell > 0)
.map(|(id, _)| id.clone())
.collect();
assert!(ids.len() > 30, "{ids:?}");
let t0 = 1_800_000_000_000i64;
for id in ids {
let (mut e, _, me, dir) = setup(&format!("farm-{id}"));
let mut m = Market::new(c.clone());
let base = m.base(&id).sell;
// Room for what a minute's band keeps.
let mut b = bag(&[(id.as_str(), 10_000)], u32::MAX);
let mut now = t0;
e.now = now;
m.tick(now);
let mut minutes = 0;
while b.count(&id) > 0 && minutes < 600 {
m.sell(&mut e, &me, &mut b, &[&id]);
minutes += 1;
now += MINUTE_MS;
e.now = now;
m.tick(now);
}
// A few left worth less than a coin at the floor: kept.
assert!(b.count(&id) < 5, "{id}: {} unsold", b.count(&id));
let got = e.balance(ALEX);
let at_base = 10_000 * base;
assert!(
got * 2 < at_base,
"{id}: 10,000 sold for {got}, {at_base} at the base price"
);
let f = m.book.factor(&id);
assert!(f < c.dynamic.min_factor * 1.01, "{id}: factor {f}");
if std::env::var_os("SMP_MARKET_REPORT").is_some() {
eprintln!("{id}: 10,000 sold for {got} ({at_base} at the base) in {minutes} min");
}
// The price comes back once the selling stops: in a day, to
// within 10% of the base.
m.tick(now + 24 * 60 * MINUTE_MS);
assert!(m.book.factor(&id) > 0.9, "{id}: {}", m.book.factor(&id));
fs::remove_dir_all(dir).ok();
}
}
#[test]
fn buying_and_selling_straight_back_never_earns_without_a_resale_delay() {
// Every item the market trades both ways, bought and sold back the
// same moment, from a fresh price, from the floor (after another
// player's dump) and from the top (after another's pumping): the
// trader never ends with more coins than they started with.
let c = moving();
let ids: Vec<String> = c
.items
.iter()
.filter(|(_, p)| p.buy > 0 && p.sell > 0)
.map(|(id, _)| id.clone())
.collect();
assert!(ids.len() > 10, "{ids:?}");
let t0 = 1_800_000_000_000i64;
let mut cases = 0;
let mut worst = (0i64, String::new());
for id in &ids {
for start in ["fresh", "floor", "top"] {
for n in [1u32, 7, 64, 640, 64 * 36] {
let (mut e, _, me, dir) = setup("loop");
let mut m = Market::new(c.clone());
let mut now = t0;
m.tick(now);
// Others push the price to a bound, a minute at a time.
let to_bound = |m: &mut Market, now: &mut i64, buy: bool| {
for _ in 0..200 {
m.moved(id, 100_000, buy);
*now += MINUTE_MS;
m.tick(*now);
}
};
match start {
"floor" => to_bound(&mut m, &mut now, false),
"top" => to_bound(&mut m, &mut now, true),
_ => {}
}
match start {
"floor" => assert!(m.book.factor(id) < c.dynamic.min_factor * 1.01),
"top" => assert!(m.book.factor(id) > c.dynamic.max_factor * 0.99),
_ => {}
}
e.now = now;
e.adjust("test_funds", me.uuid, 100_000_000, "").unwrap();
let start_coins = e.balance(me.uuid);
let mut b = bag(&[], u32::MAX);
m.buy(&mut e, &me, &mut b, &[id, &n.to_string()]);
let bought = b.count(id);
assert!(bought > 0, "{id} {start} {n}: nothing bought");
// At once; what the minute's band keeps, in the next
// minutes (the price only drifts back down meanwhile).
m.sell(&mut e, &me, &mut b, &[id]);
let mut minutes = 0;
while b.count(id) >= 5 && minutes < 2_000 {
now += MINUTE_MS;
e.now = now;
m.tick(now);
m.sell(&mut e, &me, &mut b, &[id]);
minutes += 1;
}
assert!(b.count(id) < 5, "{id} {start} {n}: {} kept", b.count(id));
let end = e.balance(me.uuid);
assert!(
end <= start_coins,
"{id} from {start}: bought {bought} and sold back, {} coins earned",
end - start_coins
);
worst = worst.max((end - start_coins, format!("{id} {start} {n}")));
cases += 1;
fs::remove_dir_all(dir).ok();
}
}
}
assert_eq!(cases, ids.len() * 15);
if std::env::var_os("SMP_MARKET_REPORT").is_some() {
eprintln!("round trips: {cases}, best for the trader {worst:?}");
}
}
#[test]
fn moving_prices_allow_immediate_resale_with_legacy_purchase_history() {
let c: Config = serde_json::from_value(serde_json::json!({
"dynamic": {"round_trip_hours": 24}
}))
.unwrap();
let (mut e, _, me, dir) = setup("dyn");
let mut m = Market::new(c);
let (mut m, problems) = Market::open(&dir, c);
assert!(problems.is_empty(), "{problems:?}");
let t0 = 1_800_000_000_000i64;
e.now = t0;
e.adjust("test_funds", me.uuid, 10_000, "").unwrap();
@@ -1819,31 +1711,22 @@ mod tests {
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into())
);
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151);
// Old files can still contain a purchase within the former 24 h
// window. Preserve the moving price history and ignore that receipt.
let mut old_book = serde_json::to_value(&m.book).unwrap();
old_book["bought"] = serde_json::json!({
uuid_string(me.uuid): [["minecraft:iron_ingot", t0, 10]]
});
write_json(&dir.join("market_prices.json"), &old_book).unwrap();
let (mut m, problems) = Market::open(&dir, m.config.clone());
assert!(problems.is_empty(), "{problems:?}");
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
assert_eq!(
out[0],
Out::Error("You bought 10 iron ingot from the market in the last 24 h: those can't be sold back yet.".into())
);
// Five more found in a mine: those sell, the bought ones stay.
b.items.insert("minecraft:iron_ingot".into(), 15);
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
assert!(
matches!(&out[0], Out::Reply(r) if r.starts_with("Sold 5 iron ingot")),
"{out:?}"
);
assert_eq!(
out[1],
Out::Reply("10 iron ingot kept: bought from the market in the last 24 h.".into())
);
b.items.insert("minecraft:iron_ingot".into(), 10);
// A day later they may.
e.now = t0 + 25 * 3_600_000;
m.tick(e.now);
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
assert!(
matches!(&out[0], Out::Reply(r) if r.starts_with("Sold 10 iron ingot")),
"{out:?}"
Out::Reply("Sold 10 iron ingot at 15 coins each: 150 coins.".into())
);
assert_eq!(b.count("minecraft:iron_ingot"), 0);
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151 + 150);
fs::remove_dir_all(dir).ok();
}
+16 -51
View File
@@ -28,6 +28,17 @@ pub const HOUR_MS: i64 = 3_600_000;
pub const MINUTES: usize = 1440;
pub const HOURS: usize = 720;
/// The least share of its base price the market pays (since 0.33, with no
/// daily sale caps: an item sold without end falls to it and stays there
/// while the selling goes on).
pub const MIN_FACTOR: f64 = 0.2;
/// The default floor before 0.33: a config that still has it gets
/// `MIN_FACTOR` (`smp_market::migrate`).
pub const OLD_MIN_FACTOR: f64 = 0.6;
/// The most items that move a price 1% (unless `depth` says otherwise): the
/// cheap items farms make reach the floor within 10,000 sold.
pub const MAX_DEPTH: f64 = 50.0;
/// `config.json`'s `market.dynamic`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -43,9 +54,6 @@ pub struct Dynamic {
pub max_player_impact_percent: f64,
/// How far the price may move within one minute (either way).
pub max_minute_move_percent: f64,
/// What a player bought from the market can't be sold back to it for
/// this long (that many of that item).
pub round_trip_hours: i64,
/// Items that move the price 1%, by item (else from its price: dear
/// items move faster).
pub depth: BTreeMap<String, f64>,
@@ -58,13 +66,12 @@ impl Default for Dynamic {
fn default() -> Self {
Self {
enabled: true,
min_factor: 0.6,
min_factor: MIN_FACTOR,
max_factor: 1.6,
half_life_hours: 6.0,
max_trade_impact_percent: 2.0,
max_player_impact_percent: 2.0,
max_minute_move_percent: 5.0,
round_trip_hours: 24,
depth: BTreeMap::new(),
news_percent: 30.0,
news_min_volume: 64,
@@ -98,7 +105,6 @@ impl Dynamic {
50.0,
d.max_minute_move_percent,
);
self.round_trip_hours = self.round_trip_hours.clamp(0, 24 * 30);
self.depth.retain(|_, v| v.is_finite() && *v > 0.0);
}
}
@@ -175,9 +181,6 @@ pub struct Book {
pub minute: i64,
/// The UTC day the last summary was for.
pub summary_day: i64,
/// What each player bought (item, when, how many), for the round-trip
/// rule.
pub bought: BTreeMap<String, Vec<(String, i64, u32)>>,
/// The last daily summary's lines (for the web page and Kira).
pub last_summary: Vec<String>,
/// The base price each item's history is quoted in (since 0.29).
@@ -262,7 +265,7 @@ impl Book {
.depth
.get(id)
.copied()
.unwrap_or_else(|| (400.0 / mid.max(0.01)).clamp(2.0, 400.0))
.unwrap_or_else(|| (400.0 / mid.max(0.01)).clamp(2.0, MAX_DEPTH))
}
/// How far one of `id` (based at `mid`) moves the log of its factor.
@@ -448,34 +451,9 @@ impl Book {
// Items no longer traded go.
self.items.retain(|id, _| mids.contains_key(id));
self.minute = minute;
// Purchases past the round-trip window go.
let since = now - config.round_trip_hours * HOUR_MS;
for v in self.bought.values_mut() {
v.retain(|(_, t, _)| *t > since);
}
self.bought.retain(|_, v| !v.is_empty());
true
}
/// `uuid` bought `n` of `id` at `now`.
pub fn bought(&mut self, uuid: &str, id: &str, n: u32, now: i64) {
self.bought
.entry(uuid.to_owned())
.or_default()
.push((id.to_owned(), now, n));
}
/// How many of `id` `uuid` bought within the round-trip window.
pub fn recently_bought(&self, config: &Dynamic, uuid: &str, id: &str, now: i64) -> u32 {
let since = now - config.round_trip_hours * HOUR_MS;
self.bought.get(uuid).map_or(0, |v| {
v.iter()
.filter(|(i, t, _)| i == id && *t > since)
.map(|e| e.2)
.sum()
})
}
pub fn history(&self, id: &str, span: Span) -> &[Point] {
match self.items.get(id) {
Some(t) => match span {
@@ -667,10 +645,10 @@ mod tests {
assert!((b.factor(D) - (-0.005f64).exp()).abs() < 1e-12, "at once");
assert_eq!(deal(&mut b, 1, true), (-250, 1));
assert!((b.factor(D) - 1.0).abs() < 1e-12, "and back");
// Wheat is deep: 64 move it far less than one diamond.
// Wheat is deep: a whole stack moves it under 1.5%.
let w = Book::unit_move(&c, "minecraft:wheat", 2.0);
b.trade(&c, "minecraft:wheat", &[], 2.0, w, 64, false);
assert!(1.0 - b.factor("minecraft:wheat") < 0.004);
assert!(1.0 - b.factor("minecraft:wheat") < 0.015);
// Its family moves with it.
let fam = vec!["minecraft:hay_block".to_string()];
b.trade(&c, "minecraft:wheat", &fam, 2.0, w, 64, true);
@@ -843,20 +821,7 @@ mod tests {
}
#[test]
fn the_round_trip_window_and_one_price() {
let c = Dynamic::default();
let mut b = book();
b.bought("u", "minecraft:diamond", 5, T0);
assert_eq!(
b.recently_bought(&c, "u", "minecraft:diamond", T0 + HOUR_MS),
5
);
b.tick(&c, &mids(), T0 + 25 * HOUR_MS);
assert_eq!(
b.recently_bought(&c, "u", "minecraft:diamond", T0 + 25 * HOUR_MS),
0
);
assert!(b.bought.is_empty());
fn one_price_and_whole_coin_rounding() {
// One price from the old two: their geometric mean, whole.
assert_eq!(one_price(400, 100), 200);
assert_eq!(one_price(30, 8), 15);
+806 -15
View File
@@ -1,8 +1,9 @@
// Places: the server's spawn (`/spawn`, `/setspawn`), each player's homes
// (`/home`, `/sethome`, `/delhome`, `/homes`), warps (`/warp`, `/warps`,
// `/setwarp`, `/delwarp`), teleport requests between players (`/tpa`,
// `/tpaccept`, `/tpdeny`), `/back` (where the player died, or was before
// their last teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
// `/tpaccept`, `/tpdeny`), `/rtp` (`/wild`: a random safe spot far from the
// spawn), `/back` (where the player died, or was before their last
// teleport), `/playtime` and `/rules`. A teleport waits `warmup_seconds`,
// and a player who moves or is hurt meanwhile stays where they are; the glue
// moves them with a vanilla `tp` run as the server. The number of homes grows
// with the time a player has played here, counted by the mod. Everything is
@@ -12,7 +13,7 @@
use super::smp_economy::{Out, Player};
use super::smp_store;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
/// `config.json`'s `places` section.
@@ -34,6 +35,17 @@ pub struct Config {
/// spawn point, like vanilla's `respawn_radius` for a first join or a
/// respawn (0: exactly the spawn point).
pub spawn_radius: i64,
/// `/rtp` lands at least this many blocks from the spawn,
pub rtp_min_distance: i64,
/// and at most this many (uniform over the ring's area, inside the
/// world border; the Overworld only).
pub rtp_max_distance: i64,
/// Between two `/rtp`s of one player, kept over a restart.
pub rtp_cooldown_seconds: i64,
/// Chunks tried, one after another, before `/rtp` gives up.
pub rtp_attempts: i64,
/// How long `/rtp` waits, after the warmup, for the land to load.
pub rtp_search_seconds: i64,
}
impl Default for Config {
@@ -49,6 +61,11 @@ impl Default for Config {
"The currency is earned in game only: never bought or sold for real money.".into(),
],
spawn_radius: 250,
rtp_min_distance: 500,
rtp_max_distance: 5000,
rtp_cooldown_seconds: 1800,
rtp_attempts: 6,
rtp_search_seconds: 30,
}
}
}
@@ -82,6 +99,40 @@ struct Data {
homes: BTreeMap<String, BTreeMap<String, Place>>,
/// Seconds played here, by player UUID.
played: BTreeMap<String, i64>,
/// The last `/rtp` (ms), by player UUID.
rtp: BTreeMap<String, i64>,
/// The Overworld chunks `/rtp` holds loaded (`forceload add`): let go at
/// the next start if the server stopped first.
forced: BTreeSet<(i32, i32)>,
}
/// The world border, `[min_x, min_z, max_x, max_z]`.
pub type Bounds = [f64; 4];
/// The Overworld, the only level `/rtp` goes to.
pub const OVERWORLD: &str = "minecraft:overworld";
/// An `/rtp` looking for its spot.
#[derive(Clone, Debug, PartialEq)]
pub struct Rtp {
/// Where the distances count from.
centre: (f64, f64),
border: Option<Bounds>,
/// The chunks left to try, the one loading first.
chunks: Vec<(i32, i32)>,
/// The spot found, waiting for the warmup's end.
spot: Option<Place>,
/// "Looking for a safe spot" was said.
told: bool,
}
/// A loaded chunk's columns, as the game read them: each column's `x`,
/// `z`, height (the first block above its ground) and its ground, feet and
/// head blocks, as indexes into `palette`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Ground {
pub palette: Vec<String>,
pub columns: Vec<(i32, i32, i32, [u16; 3])>,
}
/// Where a teleport goes.
@@ -93,6 +144,8 @@ enum To {
Spawn(Place),
/// A player, wherever they are when the warmup ends.
Player(u128, String),
/// `/rtp`: a safe spot found in the chunks it loads.
Random(Rtp),
}
/// A teleport waiting for its warmup.
@@ -126,8 +179,17 @@ pub struct Teleport {
/// Land on a safe column within this many blocks of `to` instead of on it
/// (0: on it). Only `/spawn` sets it.
pub spread: i64,
/// The cooldown and `/back` before it: `call_off` puts them back.
undo: (Option<i64>, Option<(Place, bool)>),
/// What it changed: `call_off` puts it back.
undo: Undo,
}
/// The cooldown and `/back` before a teleport, and the `/rtp` cooldown
/// when it is one.
#[derive(Clone, Debug, PartialEq)]
struct Undo {
last: Option<i64>,
back: Option<(Place, bool)>,
rtp: Option<Option<i64>>,
}
impl Teleport {
@@ -146,9 +208,9 @@ impl Teleport {
}
/// The places' commands.
pub const COMMANDS: [&str; 16] = [
pub const COMMANDS: [&str; 18] = [
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime",
"delwarp", "tpa", "tpaccept", "tpdeny", "rules", "back", "playtime", "rtp", "wild",
];
pub struct Places {
@@ -166,7 +228,93 @@ pub struct Places {
/// over a restart.
back: HashMap<u128, (Place, bool)>,
/// The players seen dead (health 0) and not respawned yet.
dead: std::collections::HashSet<u128>,
dead: HashSet<u128>,
/// Chunks an `/rtp` landed in, held loaded until then (ms): the
/// player's own view takes over.
release: Vec<((i32, i32), i64)>,
/// Chunks someone else forced already: wanted, never let go by `/rtp`.
others: HashSet<(i32, i32)>,
/// `/rtp`'s random (never the world's).
pub rng: u64,
/// The world border, as the game last told (None: the default one).
pub border: Option<Bounds>,
}
/// `2 h 05 min`, `12 min`, `40 s` for the time left on a cooldown.
fn wait_text(ms: i64) -> String {
let s = (ms + 999) / 1000;
if s < 60 {
format!("{s} s")
} else {
duration(s + 59)
}
}
/// SplitMix64: the next of `state`'s numbers.
fn next(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A number in `0..1`.
fn unit(state: &mut u64) -> f64 {
(next(state) >> 11) as f64 / (1u64 << 53) as f64
}
/// A point `min..=max` blocks from `centre`, uniform over the ring's area,
/// from two numbers in `0..1`.
pub fn ring_point(centre: (f64, f64), min: f64, max: f64, u: f64, v: f64) -> (f64, f64) {
let r = (min * min + u * (max * max - min * min)).sqrt();
let a = v * std::f64::consts::TAU;
(centre.0 + r * a.cos(), centre.1 + r * a.sin())
}
/// Whether `(x, z)` is at least 16 blocks inside the border.
fn inside(border: Option<Bounds>, x: f64, z: f64) -> bool {
border.is_none_or(|[x0, z0, x1, z1]| {
x >= x0 + 16.0 && x <= x1 - 16.0 && z >= z0 + 16.0 && z <= z1 - 16.0
})
}
/// A block a player may stand on: solid, no fluid, nothing that hurts.
fn ground_ok(name: &str) -> bool {
let n = name.strip_prefix("minecraft:").unwrap_or(name);
!(matches!(
n,
"air"
| "cave_air"
| "void_air"
| "water"
| "lava"
| "fire"
| "soul_fire"
| "cactus"
| "magma_block"
| "powder_snow"
| "campfire"
| "soul_campfire"
| "sweet_berry_bush"
| "pointed_dripstone"
| "wither_rose"
| "bubble_column"
| "kelp"
| "kelp_plant"
| "seagrass"
| "tall_seagrass"
| "bedrock"
) || n.ends_with("_leaves"))
}
/// A block a player's feet or head may be in: air, or a plant they walk through.
fn room_ok(name: &str) -> bool {
let n = name.strip_prefix("minecraft:").unwrap_or(name);
matches!(
n,
"air" | "cave_air" | "short_grass" | "tall_grass" | "fern" | "large_fern" | "snow"
)
}
/// `2 h 05 min`, `12 min`.
@@ -209,6 +357,14 @@ impl Places {
last: HashMap::new(),
back: HashMap::new(),
dead: Default::default(),
release: Vec::new(),
others: HashSet::new(),
rng: {
use std::hash::BuildHasher;
std::collections::hash_map::RandomState::new()
.hash_one(std::time::SystemTime::now())
},
border: None,
})
}
@@ -269,11 +425,19 @@ impl Places {
/// A teleport the game couldn't make: no
/// cooldown, and `/back` goes where it went before.
pub fn call_off(&mut self, t: &Teleport) {
match t.undo.0 {
if let Some(rtp) = t.undo.rtp {
let key = smp_store::uuid_string(t.uuid);
match rtp {
Some(at) => self.data.rtp.insert(key, at),
None => self.data.rtp.remove(&key),
};
self.changed = true;
}
match t.undo.last {
Some(at) => self.last.insert(t.uuid, at),
None => self.last.remove(&t.uuid),
};
match &t.undo.1 {
match &t.undo.back {
Some(b) => self.back.insert(t.uuid, b.clone()),
None => self.back.remove(&t.uuid),
};
@@ -308,6 +472,182 @@ impl Places {
}
}
/// `/rtp`: refused outside the Overworld and during its cooldown;
/// otherwise the chunks to try are drawn and the warmup starts. The
/// distances count from the spawn (`/setspawn`), 0 0 without one.
fn rtp(&mut self, me: &Body, now: i64) -> Vec<Out> {
if me.at.dimension != OVERWORLD {
return vec![Out::Error("/rtp only works in the Overworld.".into())];
}
let key = smp_store::uuid_string(me.uuid);
if let Some(&t) = self.data.rtp.get(&key) {
let wait = t + self.config.rtp_cooldown_seconds * 1000 - now;
if wait > 0 {
return vec![Out::Error(format!(
"Wait {} before your next /rtp.",
wait_text(wait)
))];
}
}
let centre = match &self.data.spawn {
Some(s) if s.dimension == OVERWORLD => (s.x, s.z),
_ => (0.0, 0.0),
};
let chunks = self.rtp_chunks(centre, self.border);
if chunks.is_empty() {
return vec![Out::Error(
"No land for /rtp: the world border is too close to the spawn.".into(),
)];
}
let border = self.border;
self.start(
me,
To::Random(Rtp {
centre,
border,
chunks,
spot: None,
told: false,
}),
now,
)
}
/// Up to `rtp_attempts` distinct chunks around points drawn on the ring,
/// inside the border (none when the border leaves no room).
fn rtp_chunks(&mut self, centre: (f64, f64), border: Option<Bounds>) -> Vec<(i32, i32)> {
let min = self.config.rtp_min_distance.max(0) as f64;
let max = (self.config.rtp_max_distance as f64).max(min);
let mut chunks = Vec::new();
// Drawn until enough fall inside the border: a border that cuts the
// ring takes more draws (a few microseconds each).
for _ in 0..2_000 {
if chunks.len() >= self.config.rtp_attempts.max(1) as usize {
break;
}
let (u, v) = (unit(&mut self.rng), unit(&mut self.rng));
let (x, z) = ring_point(centre, min, max, u, v);
let c = ((x.floor() as i32) >> 4, (z.floor() as i32) >> 4);
if inside(border, x, z) && !chunks.contains(&c) {
chunks.push(c);
}
}
chunks
}
/// The safe columns of a loaded chunk that are within the ring and the
/// border, and one of them at random.
fn pick(&mut self, rtp: &Rtp, ground: &Ground) -> Option<Place> {
let ok: Vec<bool> = ground.palette.iter().map(|n| ground_ok(n)).collect();
let room: Vec<bool> = ground.palette.iter().map(|n| room_ok(n)).collect();
let (min, max) = (
self.config.rtp_min_distance.max(0) as f64,
self.config.rtp_max_distance as f64,
);
let safe: Vec<&(i32, i32, i32, [u16; 3])> = ground
.columns
.iter()
.filter(|(x, z, _, [g, f, h])| {
let (px, pz) = (f64::from(*x) + 0.5, f64::from(*z) + 0.5);
let d = (px - rtp.centre.0).hypot(pz - rtp.centre.1);
let at = |i: &u16, v: &[bool]| v.get(usize::from(*i)).copied().unwrap_or(false);
(min..=max).contains(&d)
&& inside(rtp.border, px, pz)
&& at(g, &ok)
&& at(f, &room)
&& at(h, &room)
})
.collect();
if safe.is_empty() {
return None;
}
let &(x, z, y, _) = safe[(next(&mut self.rng) % safe.len() as u64) as usize];
Some(Place {
dimension: OVERWORLD.into(),
x: f64::from(x) + 0.5,
y: f64::from(y),
z: f64::from(z) + 0.5,
})
}
/// Whether an `/rtp` waits for land: the game reads it then
/// (`rtp_look`).
pub fn rtp_searching(&self) -> bool {
self.pending
.iter()
.any(|p| matches!(&p.to, To::Random(r) if r.spot.is_none() && !r.chunks.is_empty()))
}
/// Each `/rtp` still looking reads the chunk it loads: None while it
/// isn't loaded yet; with no safe column, the next chunk is tried.
pub fn rtp_look(&mut self, look: &mut dyn FnMut(u128, (i32, i32)) -> Option<Ground>) {
let mut pending = std::mem::take(&mut self.pending);
for p in &mut pending {
let To::Random(r) = &mut p.to else { continue };
if r.spot.is_some() {
continue;
}
let Some(&c) = r.chunks.first() else { continue };
let Some(ground) = look(p.uuid, c) else {
continue;
};
match self.pick(r, &ground) {
Some(spot) => r.spot = Some(spot),
None => {
r.chunks.remove(0);
}
}
}
self.pending = pending;
}
/// The chunks `/rtp` wants loaded now: each search's current one, and
/// those just landed in.
pub fn rtp_wanted(&mut self, now: i64) -> BTreeSet<(i32, i32)> {
self.release.retain(|&(_, until)| until > now);
let mut wanted: BTreeSet<(i32, i32)> = self.release.iter().map(|&(c, _)| c).collect();
for p in &self.pending {
if let To::Random(r) = &p.to
&& let Some(&c) = r.chunks.first()
{
wanted.insert(c);
}
}
wanted
}
/// Holds the chunks `/rtp` wants with vanilla's `forceload` (run by
/// `run`, true when the game did it) and lets go of the others it holds.
/// A chunk forced already by someone else is used, never let go.
pub fn force(&mut self, now: i64, run: &mut dyn FnMut(&str) -> bool) {
let wanted = self.rtp_wanted(now);
let line = |verb: &str, (x, z): (i32, i32)| {
format!(
"execute in {OVERWORLD} run forceload {verb} {} {}",
x * 16,
z * 16
)
};
for &c in &wanted {
if self.data.forced.contains(&c) || self.others.contains(&c) {
continue;
}
if run(&line("add", c)) {
self.data.forced.insert(c);
self.changed = true;
} else {
self.others.insert(c);
}
}
let gone: Vec<(i32, i32)> = self.data.forced.difference(&wanted).copied().collect();
for c in gone {
run(&line("remove", c));
self.data.forced.remove(&c);
self.changed = true;
}
self.others.retain(|c| wanted.contains(c));
}
/// The places' commands; None for another command. `bodies` are the
/// players on, `me` among them.
pub fn command(
@@ -353,6 +693,7 @@ impl Places {
))],
None => vec![Out::Error(format!("{name} isn't on."))],
},
("rtp" | "wild", []) => self.rtp(body, now),
("rules", []) => {
let mut out = vec![Out::Reply("Rules of this server:".into())];
for (i, r) in self.config.rules.iter().enumerate() {
@@ -537,6 +878,7 @@ impl Places {
"warp" | "setwarp" | "delwarp" => "Usage: /warp <name>; /warps",
"tpa" => "Usage: /tpa <player>",
"tpaccept" | "tpdeny" => "Usage: /tpaccept [player], /tpdeny [player]",
"rtp" | "wild" => "Usage: /rtp",
_ => "You may not use this command.",
}
.into(),
@@ -581,9 +923,46 @@ impl Places {
));
} else if now < p.due {
self.pending.push(p);
} else if let To::Random(r) = &p.to
&& r.spot.is_none()
{
// `/rtp` after the warmup, its land not found yet.
let search = self.config.rtp_search_seconds.max(0) * 1000;
if r.chunks.is_empty() {
out.push(Out::Tell(
p.uuid,
"No safe spot found for /rtp: try again.".into(),
));
} else if now - p.due > search {
out.push(Out::Tell(
p.uuid,
"/rtp couldn't load the land in time: try again.".into(),
));
} else {
let mut p = p;
if let To::Random(r) = &mut p.to
&& !r.told
{
r.told = true;
out.push(Out::Tell(
p.uuid,
"Looking for a safe spot: don't move.".into(),
));
}
self.pending.push(p);
}
} else {
let mut spread = 0;
let mut rtp = None;
let (to, what) = match &p.to {
To::Random(r) => {
let spot = r.spot.clone();
if let (Some(s), Some(&c)) = (&spot, r.chunks.first()) {
let d = (s.x - r.centre.0).hypot(s.z - r.centre.1).round();
rtp = Some((c, d as i64));
}
(spot, "a random spot".to_owned())
}
To::Place(place, what) => (Some(place.clone()), what.clone()),
To::Spawn(place) => {
spread = self.config.spawn_radius.max(0);
@@ -596,15 +975,31 @@ impl Places {
};
match to {
Some(to) => {
let undo = (
self.last.insert(p.uuid, now),
self.back.insert(p.uuid, (p.from.clone(), false)),
);
let mut done = format!("Teleported to {what}.");
let mut undo_rtp = None;
if let Some((chunk, d)) = rtp {
undo_rtp =
Some(self.data.rtp.insert(smp_store::uuid_string(p.uuid), now));
self.changed = true;
// Held a little longer: the player's own view takes it over.
self.release.push((chunk, now + 10_000));
done = format!(
"Teleported to a random spot {d} blocks from the spawn ({:.0} {:.0} {:.0}).",
to.x.floor(),
to.y,
to.z.floor()
);
}
let undo = Undo {
last: self.last.insert(p.uuid, now),
back: self.back.insert(p.uuid, (p.from.clone(), false)),
rtp: undo_rtp,
};
go.push(Teleport {
uuid: p.uuid,
name: me.name.clone(),
to,
done: format!("Teleported to {what}."),
done,
what,
spread,
undo,
@@ -899,4 +1294,400 @@ mod tests {
assert_eq!(go[0].spread_line(), None);
let _ = std::fs::remove_dir_all(d);
}
/// A chunk of the same column everywhere: `ground`, then `feet` and
/// `head`, the ground's top at y 64.
fn chunk(c: (i32, i32), ground: &str, feet: &str, head: &str) -> Ground {
let palette = vec![ground.to_owned(), feet.to_owned(), head.to_owned()];
let mut columns = Vec::new();
for dx in 0..16 {
for dz in 0..16 {
columns.push((c.0 * 16 + dx, c.1 * 16 + dz, 65, [0, 1, 2]));
}
}
Ground { palette, columns }
}
fn grass(c: (i32, i32)) -> Option<Ground> {
Some(chunk(
c,
"minecraft:grass_block",
"minecraft:air",
"minecraft:short_grass",
))
}
fn distance(t: &Teleport) -> f64 {
t.to.x.hypot(t.to.z)
}
/// An `/rtp` from `here` through its warmup to the teleport, every
/// chunk read as `land`; the time it ended.
fn rtp_to(
p: &mut Places,
here: &[Body],
now: i64,
land: &mut dyn FnMut(u128, (i32, i32)) -> Option<Ground>,
) -> (Vec<Out>, Vec<Teleport>) {
let out = p.command(&player(A, 0), here, now, "rtp").unwrap();
assert_eq!(
out,
vec![Out::Reply("Teleporting in 3 s: don't move.".into())]
);
for _ in 0..8 {
p.rtp_look(land);
}
p.tick(here, now + 3_000)
}
#[test]
fn rtp_points_cover_the_ring_evenly() {
let mut rng = 7;
let n = 20_000;
let mut inner = 0;
for _ in 0..n {
let (x, z) = ring_point(
(100.0, -50.0),
500.0,
5000.0,
unit(&mut rng),
unit(&mut rng),
);
let d = (x - 100.0).hypot(z + 50.0);
assert!((500.0 - 1e-6..=5000.0 + 1e-6).contains(&d), "{d}");
// Half the ring's area is within sqrt((500² + 5000²) / 2) blocks.
inner += usize::from(d < ((500f64.powi(2) + 5000f64.powi(2)) / 2.0).sqrt());
}
let share = inner as f64 / n as f64;
assert!(
(0.48..0.52).contains(&share),
"uniform over the area, not the radius: {share}"
);
// The ring's edges themselves.
assert_eq!(
ring_point((0.0, 0.0), 500.0, 5000.0, 0.0, 0.0),
(500.0, 0.0)
);
assert_eq!(
ring_point((0.0, 0.0), 500.0, 5000.0, 1.0, 0.0),
(5000.0, 0.0)
);
}
#[test]
fn rtp_lands_in_the_ring_inside_the_border() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
p.rng = 1;
// A border 2,000 blocks wide round the spawn: every chunk tried and
// every spot is inside it, and 500 blocks out at least.
p.border = Some([-1000.0, -1000.0, 1000.0, 1000.0]);
for i in 0..50 {
let now = i * 10_000_000;
let here = [body(A, 0.0)];
let mut asked = Vec::new();
let (_, go) = rtp_to(&mut p, &here, now, &mut |_, c| {
asked.push(c);
grass(c)
});
assert_eq!(go.len(), 1);
let t = &go[0];
assert!((500.0..=5000.0).contains(&distance(t)), "{:?}", t.to);
assert!(t.to.x.abs() <= 984.0 && t.to.z.abs() <= 984.0, "{:?}", t.to);
assert_eq!((t.to.dimension.as_str(), t.to.y), (OVERWORLD, 65.0));
assert_eq!(t.spread_line(), None, "the spot is exact");
assert!(
t.done.starts_with("Teleported to a random spot "),
"{}",
t.done
);
for (x, z) in asked {
assert!((x * 16).abs() <= 1000 && (z * 16).abs() <= 1000, "{x} {z}");
}
}
// A border closer than the ring: nothing to try.
p.border = Some([-400.0, -400.0, 400.0, 400.0]);
let out = p
.command(&player(A, 0), &[body(A, 0.0)], 1_000_000_000, "rtp")
.unwrap();
assert!(
matches!(&out[0], Out::Error(s) if s.contains("world border")),
"{out:?}"
);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_has_its_own_cooldown_kept_over_a_restart() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
let (_, go) = rtp_to(&mut p, &here, 0, &mut |_, c| grass(c));
assert_eq!(go.len(), 1);
// Other teleports wait only their own short cooldown.
p.command(&player(A, 3), &here, 20_000, "sethome").unwrap();
let out = p.command(&player(A, 0), &here, 20_000, "home").unwrap();
assert!(
matches!(&out[0], Out::Reply(s) if s.starts_with("Teleporting")),
"{out:?}"
);
p.tick(&[body(A, 1.0)], 20_500);
let out = p
.command(&player(A, 0), &here, 18 * 60_000 + 3_000, "rtp")
.unwrap();
assert_eq!(
out,
vec![Out::Error("Wait 12 min before your next /rtp.".into())]
);
let out = p
.command(&player(A, 0), &here, 1_800_000 + 2_500, "wild")
.unwrap();
assert_eq!(
out,
vec![Out::Error("Wait 1 s before your next /rtp.".into())]
);
// Written, and read back after a restart.
p.save(0).unwrap();
let mut again = Places::open(&d, Config::default()).unwrap();
again.rng = 43;
let out = again.command(&player(A, 0), &here, 60_000, "rtp").unwrap();
assert_eq!(
out,
vec![Out::Error("Wait 30 min before your next /rtp.".into())]
);
let out = again
.command(&player(A, 0), &here, 1_803_000, "rtp")
.unwrap();
assert!(
matches!(&out[0], Out::Reply(_)),
"allowed after it: {out:?}"
);
// Someone else isn't held up by it.
let out = again
.command(&player(B, 0), &[body(B, 0.0)], 60_000, "rtp")
.unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "{out:?}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_without_a_spot_or_called_off_costs_no_cooldown() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
// Ocean everywhere: every chunk tried, one after another, then it says so.
let mut asked = Vec::new();
let (out, go) = rtp_to(&mut p, &here, 0, &mut |_, c| {
asked.push(c);
Some(chunk(
c,
"minecraft:water",
"minecraft:air",
"minecraft:air",
))
});
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(
A,
"No safe spot found for /rtp: try again.".into()
)]
);
assert_eq!(asked.len(), 6, "rtp_attempts chunks: {asked:?}");
// Land that never loads: given up after rtp_search_seconds.
let (out, go) = rtp_to(&mut p, &here, 10_000, &mut |_, _| None);
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(A, "Looking for a safe spot: don't move.".into())]
);
let (out, _) = p.tick(&here, 10_000 + 3_000 + 30_000);
assert!(out.is_empty(), "{out:?}");
let (out, _) = p.tick(&here, 10_000 + 3_000 + 30_001);
assert_eq!(
out,
vec![Out::Tell(
A,
"/rtp couldn't load the land in time: try again.".into()
)]
);
// The game couldn't make the teleport: no cooldown either.
let (_, go) = rtp_to(&mut p, &here, 50_000, &mut |_, c| grass(c));
p.call_off(&go[0]);
let (_, go) = rtp_to(&mut p, &here, 60_000, &mut |_, c| grass(c));
assert_eq!(go.len(), 1, "allowed at once");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_works_only_in_the_overworld() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
for level in ["minecraft:the_nether", "minecraft:the_end"] {
let mut b = body(A, 0.0);
b.at.dimension = level.into();
let out = p.command(&player(A, 0), &[b], 0, "rtp").unwrap();
assert_eq!(
out,
vec![Out::Error("/rtp only works in the Overworld.".into())]
);
}
assert!(p.pending.is_empty() && p.data.rtp.is_empty());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_lands_only_on_safe_ground() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
// Each chunk tried has one kind of ground, the last a safe one.
let bad = [
("minecraft:lava", "minecraft:air", "minecraft:air"),
("minecraft:cactus", "minecraft:air", "minecraft:air"),
("minecraft:magma_block", "minecraft:air", "minecraft:air"),
("minecraft:powder_snow", "minecraft:air", "minecraft:air"),
("minecraft:stone", "minecraft:fire", "minecraft:air"),
("minecraft:stone", "minecraft:air", "minecraft:oak_log"),
];
let mut n = 0;
let (out, go) = rtp_to(&mut p, &here, 0, &mut |_, c| {
n += 1;
Some(match bad.get(n - 1) {
Some((g, f, h)) => chunk(c, g, f, h),
None => chunk(c, "minecraft:sand", "minecraft:air", "minecraft:air"),
})
});
assert_eq!(n, 6);
assert!(
go.is_empty(),
"the six chunks were all unsafe: {go:?} {out:?}"
);
// One safe column among lava: that one.
let (_, go) = rtp_to(&mut p, &here, 100_000, &mut |_, c| {
let mut g = chunk(c, "minecraft:lava", "minecraft:air", "minecraft:air");
g.palette.push("minecraft:dirt".into());
g.columns[37].3[0] = 3;
Some(g)
});
let (x, z) = (go[0].to.x - 0.5, go[0].to.z - 0.5);
assert_eq!(x.rem_euclid(16.0), 2.0);
assert_eq!(z.rem_euclid(16.0), 5.0);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn rtp_holds_its_chunks_loaded_and_lets_them_go() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
p.command(&player(A, 0), &here, 0, "rtp").unwrap();
let mut ran = Vec::new();
p.force(0, &mut |l| {
ran.push(l.to_owned());
true
});
assert_eq!(ran.len(), 1, "one chunk at a time: {ran:?}");
let first = ran[0].clone();
assert!(
first.starts_with("execute in minecraft:overworld run forceload add "),
"{first}"
);
// The chunk isn't loaded yet: nothing more.
p.rtp_look(&mut |_, _| None);
ran.clear();
p.force(100, &mut |l| {
ran.push(l.to_owned());
true
});
assert!(ran.is_empty(), "{ran:?}");
// Ocean: the next one is held, the first let go.
p.rtp_look(&mut |_, c| {
Some(chunk(
c,
"minecraft:water",
"minecraft:air",
"minecraft:air",
))
});
p.force(200, &mut |l| {
ran.push(l.to_owned());
true
});
assert_eq!(ran.len(), 2, "{ran:?}");
assert!(ran[0].contains(" add "), "{ran:?}");
assert_eq!(ran[1], first.replace(" add ", " remove "));
let second = ran[0].replace(" add ", " remove ");
// Held over a restart: let go at the next start.
p.save(0).unwrap();
let mut again = Places::open(&d, Config::default()).unwrap();
again.rng = 43;
ran.clear();
again.force(0, &mut |l| {
ran.push(l.to_owned());
true
});
assert_eq!(ran, vec![second], "the one held when it stopped");
// Landed: held 10 s more, then let go.
p.rtp_look(&mut |_, c| grass(c));
let (_, go) = p.tick(&here, 3_000);
assert_eq!(go.len(), 1);
ran.clear();
p.force(12_000, &mut |l| {
ran.push(l.to_owned());
true
});
assert!(ran.is_empty(), "{ran:?}");
p.force(13_000, &mut |l| {
ran.push(l.to_owned());
true
});
assert_eq!(ran.len(), 1);
assert!(ran[0].contains(" remove "), "{ran:?}");
// A chunk someone forced already: used, never let go.
p.command(&player(A, 0), &here, 4_000_000, "rtp").unwrap();
ran.clear();
p.force(4_000_000, &mut |l| {
ran.push(l.to_owned());
false
});
assert_eq!(ran.len(), 1);
p.rtp_look(&mut |_, c| grass(c));
p.tick(&here, 4_003_000);
ran.clear();
p.force(5_000_000, &mut |l| {
ran.push(l.to_owned());
true
});
assert!(ran.is_empty(), "{ran:?}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_move_calls_an_rtp_off_while_it_looks() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
p.rng = 42;
let here = [body(A, 0.0)];
p.command(&player(A, 0), &here, 0, "rtp").unwrap();
let (out, _) = p.tick(&here, 3_000);
assert_eq!(
out,
vec![Out::Tell(A, "Looking for a safe spot: don't move.".into())]
);
let (out, go) = p.tick(&[body(A, 2.0)], 4_000);
assert!(go.is_empty());
assert_eq!(
out,
vec![Out::Tell(A, "Teleport called off: you moved.".into())]
);
assert!(p.rtp_wanted(4_000).is_empty(), "its chunk is let go");
let out = p.command(&player(A, 0), &here, 5_000, "rtp").unwrap();
assert!(matches!(&out[0], Out::Reply(_)), "no cooldown: {out:?}");
let _ = std::fs::remove_dir_all(d);
}
}
+1 -1
View File
@@ -447,7 +447,7 @@ mod tests {
assert!(recipes.len() > 1000, "{} recipes", recipes.len());
let config = Config::default();
assert_eq!(loops(&config.items, &recipes), []);
// Nor once prices have moved as far as they may (0.6 to 1.6 times
// Nor once prices have moved as far as they may (0.2 to 1.6 times
// their base): an item sold at the top while what makes it is
// bought at the bottom, unless both share one price (a family).
let families = super::super::smp_market::families(&config.items);
+2 -5
View File
@@ -2,8 +2,8 @@
// categories; a category's page its items, with what the market asks and
// pays (in its name). A click buys one, a shift-click a stack of 64; a click on a stack in
// your own inventory sells it; "Sell all" sells everything the market buys.
// Buying and selling go through the market's own rules (its prices, today's
// limits, items first then money, logged). This file only lays the pages out
// Buying and selling go through the market's own rules (its prices,
// items first then money, logged). This file only lays the pages out
// and says what a click means; the glue opens the menu and moves the items.
// No game types. Added to the game as `game::smp_shop`; also a module of this
// crate, for its tests.
@@ -26,7 +26,6 @@ const HELP: usize = 46;
const PREV: usize = 47;
const BALANCE: usize = 48;
const SELL_ALL: usize = 49;
const LIMITS: usize = 50;
const NEXT: usize = 51;
const CLOSE: usize = 53;
/// Where the first page puts the categories: the middle of rows 2 to 4.
@@ -183,7 +182,6 @@ pub enum Cell {
Next,
Balance,
SellAll,
Limits,
Help,
Close,
}
@@ -254,7 +252,6 @@ impl Shop {
cells[HELP] = Cell::Help;
cells[BALANCE] = Cell::Balance;
cells[SELL_ALL] = Cell::SellAll;
cells[LIMITS] = Cell::Limits;
cells[CLOSE] = Cell::Close;
match page {
Page::Main => {
+1 -1
View File
@@ -98,7 +98,7 @@ impl Part {
"invsee" | "endersee" => Part::Invsee,
"spawn" | "setspawn" | "home" | "sethome" | "delhome" | "homes" | "warp" | "warps"
| "setwarp" | "delwarp" | "tpa" | "tpaccept" | "tpdeny" | "rules" | "back"
| "playtime" => Part::Places,
| "playtime" | "rtp" | "wild" => Part::Places,
_ => return None,
})
}
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more