Compare commits

...
Author SHA1 Message Date
koka c8531aac4b docs: organiser la production TikTok 2026-09-01 03:50:29 +02:00
koka 3ffa9b889f docs: ajouter les 100 premiers scripts TikTok 2026-09-01 02:35:12 +02:00
koka a16156b005 Merge pull request 'feat: ajouter une cape craftable décorée par bannière' (#126) from feature/craftable-banner-cape into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #126
2026-08-31 11:44:49 +00:00
koka 2518c20071 feat(sanctuary): add craftable banner cape
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 13:25:27 +02:00
koka b7497c81d1 Merge pull request 'release: publier Sanctuary 26.2.0-alpha.225' (#125) from release/26.2.0-alpha.225 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #125
2026-08-31 09:50:25 +00:00
koka 97638e0fa2 release: prepare Sanctuary 26.2.0-alpha.225
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 11:46:39 +02:00
koka a071a0b746 fix(magic-carpet): restore the Notch Totem texture 2026-08-31 11:46:24 +02:00
koka 6fcc56552c fix(canaplia): preserve crop variants through growth 2026-08-31 11:46:17 +02:00
koka 66944ec492 fix(companions): suppress loot and quest progress 2026-08-31 11:46:02 +02:00
koka b513ab1262 fix(anotherworld): require a vault before raids 2026-08-31 11:45:46 +02:00
koka b2222c5d33 Merge pull request 'release: publier Sanctuary 26.2.0-alpha.224' (#122) from release/26.2.0-alpha.224 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #122
2026-08-31 05:17:01 +00:00
koka 2d0be21c5b release: prepare Sanctuary 26.2.0-alpha.224
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 07:13:29 +02:00
koka a0b0788869 Merge pull request 'feat: livrer et migrer le Magic Carpet après Notch' (#121) from integration/magic-carpet-reward into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #121
2026-08-31 04:59:18 +00:00
koka 106d730dd8 feat: deliver and migrate the Magic Carpet
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 06:45:28 +02:00
koka 21dd089486 Merge pull request 'feat: ajouter Notch et son combat dans Alpha' (#119) from integration/notch-encounter into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #119
2026-08-31 04:15:54 +00:00
koka e3b6c6ed01 Merge pull request 'fix(sanctuary): trier coffres et barrels et garder la hotbar en bas' (#120) from fix/issue-118-container-sort into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #120
2026-08-31 04:15:42 +00:00
ChrisM-PekandCursor 7387096d98 chore(sanctuary): aligner la verif alpha.223 sur le tri des conteneurs
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 05:54:43 +02:00
ChrisM-PekandCursor 457f91b92e fix(sanctuary): trier coffres et barrels et garder la hotbar en bas
Le clic molette range aussi les conteneurs de stockage, et la hotbar reste sur la derniere ligne a la connexion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 05:52:17 +02:00
koka f283c1705b feat: add Notch Alpha encounter
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 05:42:19 +02:00
koka 6ee5cdc2fb Merge pull request 'release: tester les dernières PR dans Packwiz alpha.223' (#117) from release/26.2.0-alpha.223 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #117
2026-08-31 03:15:43 +00:00
koka 87428c5f0d release: test latest PRs in Packwiz alpha.223
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 05:13:46 +02:00
koka 0599bf6885 Merge pull request 'feat(sanctuary): ajouter les modes du suivi de quêtes' (#107) from feature/quest-tracker-display-modes into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #107
2026-08-31 03:02:03 +00:00
koka 624f441578 Merge pull request 'feat(sanctuary): trier le stockage sans deplacer la hotbar' (#112) from feature/inventory-sorter into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #112
2026-08-31 03:01:13 +00:00
ChrisM-PekandCursor d6ca1140b5 feat(sanctuary): trier le stockage sans deplacer la hotbar
Verification 26.2 / check (pull_request) Canceled after 0s
Le clic molette range les rangées débloquées depuis le haut, fusionne les piles identiques et laisse la hotbar, l'armure et les slots spéciaux intacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 04:20:33 +02:00
koka 53fa32bbc5 feat(sanctuary): ajouter les modes du suivi de quetes
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 03:27:36 +02:00
koka 7a2f697024 Merge pull request 'release: publier les dernières PR dans Packwiz alpha.222' (#100) from release/26.2.0-alpha.222 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #100
2026-08-30 13:33:43 +00:00
koka 4d0750e993 release: update Packwiz to alpha.222
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 15:29:24 +02:00
koka c49673801f Merge pull request 'fix(sanctuary): simplifier le suivi de quetes' (#99) from fix/quest-tracker-transparent into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #99
2026-08-30 13:15:18 +00:00
koka 5386c7ac8a fix(sanctuary): simplifier le suivi de quetes
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 15:09:01 +02:00
koka eb25625a45 Merge pull request 'feat(sanctuary): ajouter un suivi d'avancement des quetes' (#86) from feature/quest-tracker-hud into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #86
2026-08-30 13:00:21 +00:00
ChrisM-PekandCursor 05c445eaeb Merge origin/main into feature/quest-tracker-hud
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:50:24 +02:00
koka 788928f2d9 Merge pull request 'feat(redstoner): rechercher et vider sans glissement dans le terminal' (#97) from feature/storage-terminal-search into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #97
2026-08-30 08:06:20 +00:00
koka 5d3db9bb42 chore(redstoner): defer packwiz refresh
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 10:01:50 +02:00
koka fd528abb9d feat(redstoner): add storage terminal keyboard search 2026-08-30 10:00:31 +02:00
koka 40acccc818 feat(redstoner): add storage terminal keyboard search
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 08:43:17 +02:00
koka 62c8c87eb0 Merge pull request 'fix(ambiance): restaurer la vitesse native du disque blanc (alpha.221)' (#96) from fix/white-disc-native-rate into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #96
2026-08-30 06:03:00 +00:00
koka b5dab3b5f6 chore(release): prepare alpha.221 native audio
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 07:07:36 +02:00
koka 90790161db Merge remote-tracking branch 'origin/feature/storage-terminal-menu' into fix/white-disc-native-rate 2026-08-30 06:58:50 +02:00
koka e8dee9b7f8 feat(redstoner): integrate storage terminal menu
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 06:55:18 +02:00
koka 6fc93ca732 fix(ambiance): preserve white disc native audio rate 2026-08-30 06:51:22 +02:00
koka 8787969347 Merge pull request 'feat(alpha): intégrer l’île Indev, son portail et ses textures locales' (#93) from integration/alpha-indev into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #93
2026-08-30 03:10:56 +00:00
koka 01a82c3c93 merge: align Alpha Indev release with alpha.219
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 04:17:04 +02:00
koka f23f741a9d Merge pull request 'release: alpha.217 audio et alpha.218 terminal de stockage' (#94) from release/26.2.0-alpha.218 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #94
2026-08-30 02:08:32 +00:00
koka 4714c69255 chore(release): prepare Alpha Indev integration
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 03:59:19 +02:00
koka c03e5d8555 feat(ambiance): scope Golden Days to Alpha (#46) 2026-08-30 03:59:10 +02:00
koka 9abf6bbea7 feat(iliketomoveit): activate Alpha portal (#45) 2026-08-30 03:59:05 +02:00
koka 9c09664778 feat(sanctuary): port finite Indev island (#44) 2026-08-30 03:59:00 +02:00
koka 87f11f6127 release: sequence audio alpha.217 and terminal alpha.218
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 03:55:28 +02:00
koka c04cd78ae9 test(ambiance): verify white disc playback cadence 2026-08-30 03:48:15 +02:00
koka 7ae99a12a8 Merge pull request 'feat(redstoner): ajouter le terminal de stockage' (#91) from feature/storage-terminal into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #91
2026-08-30 01:27:15 +00:00
koka 1b1f3a791d feat(redstoner): add storage terminal
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 03:13:48 +02:00
koka 10b1cd9c5e Merge pull request 'Fix white disc audio cadence and release alpha.216' (#89) from fix/white-disc-audio-quality into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #89
2026-08-29 23:26:10 +00:00
koka ef18e0ae32 Fix white disc audio cadence and release alpha.216
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 01:19:02 +02:00
koka 1c2e000da3 Merge pull request 'release: Sanctuary 26.2.0-alpha.215 — disque blanc stabilisé' (#88) from release/26.2.0-alpha.215 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #88
2026-08-29 22:54:40 +00:00
koka 9e35c244ec release: prepare 26.2.0-alpha.215
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 00:52:59 +02:00
koka e82cb33407 Merge pull request 'fix(ambiance): stabiliser la lecture du disque blanc' (#87) from fix/white-disc-playback-crash into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #87
2026-08-29 22:46:44 +00:00
ChrisM-PekandCursor 6e58d44c71 feat(sanctuary): assister les IDs de quete et limiter le tracker aux quetes acceptees
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 00:44:38 +02:00
ChrisM-PekandCursor d45afc7767 feat(sanctuary): ajouter un suivi d'avancement des quetes
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 23:45:40 +02:00
koka e126ec9eea fix(ambiance): stabilize white disc playback
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 21:36:04 +02:00
koka 13a74ddbd2 Merge pull request 'release: Sanctuary 26.2.0-alpha.214 — disque blanc solo' (#85) from release/26.2.0-alpha.214 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #85
2026-08-29 18:44:13 +00:00
koka b221ca1b52 release: prepare 26.2.0-alpha.214
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 20:40:54 +02:00
koka 05505279f8 Merge pull request 'fix(ambiance): attendre la synchronisation du disque blanc' (#84) from fix/white-disc-solo-sync into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #84
2026-08-29 18:35:33 +00:00
koka 61e66d3a11 fix(ambiance): attendre la synchronisation du disque blanc
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 20:30:41 +02:00
koka 3aa36ed129 Merge pull request 'release: Sanctuary 26.2.0-alpha.213 — correctif Ambiance' (#83) from release/26.2.0-alpha.213 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #83
2026-08-29 18:03:43 +00:00
koka 90b9eee27e release: prepare 26.2.0-alpha.213
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 20:02:22 +02:00
koka 7d800d8932 Merge pull request 'fix(ambiance): corriger le crash des événements de niveau' (#82) from fix/ambiance-level-event-crash into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #82
2026-08-29 17:57:58 +00:00
koka 913a754f20 fix(ambiance): corriger le crash des événements de niveau
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 19:54:46 +02:00
koka 73d32999e5 Merge pull request 'release: Sanctuary 26.2.0-alpha.212 — correctif de démarrage' (#81) from release/26.2.0-alpha.212 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #81
2026-08-29 17:43:40 +00:00
koka 86034ca71f release: prepare 26.2.0-alpha.212
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 19:41:04 +02:00
koka db91698c09 Merge pull request 'fix(sanctuary): corriger le crash du clic molette' (#80) from fix/black-market-middle-click-crash into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #80
2026-08-29 17:35:20 +00:00
koka 7f74114d31 fix(sanctuary): corriger le crash du clic molette
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 19:32:54 +02:00
koka 53db634ec2 Merge pull request 'release: Sanctuary 26.2.0-alpha.211 — Abjection et correctifs' (#79) from release/26.2.0-alpha.211 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #79
2026-08-29 17:04:04 +00:00
koka 400f1f6289 release: prepare 26.2.0-alpha.211
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 18:57:52 +02:00
koka 596432f3f7 Merge pull request 'feat(onlyfun): ajouter la touche Abjection' (#78) from feature/abjection-keybind into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #78
2026-08-29 16:51:17 +00:00
koka 14980c993f Merge branch 'main' into feature/abjection-keybind
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:49:57 +02:00
koka e17433f28f Merge pull request 'feat(ouch): restaurer le projectile Abjection' (#77) from feature/abjection-projectile into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #77
2026-08-29 16:41:13 +00:00
koka 0833d4e44f Merge branch 'main' into feature/abjection-projectile
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:40:16 +02:00
koka 07b2eba5c5 Merge pull request 'feat(onlyfun): intégrer les actions Abjection' (#76) from feature/abjection-actions into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #76
2026-08-29 16:36:59 +00:00
koka 0f95a5cfc7 Merge branch 'main' into feature/abjection-actions
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:35:00 +02:00
koka 028d63b64a Merge pull request 'feat(ambiance): ajouter le disque blanc YouTube (#62)' (#75) from feature/white-disc-youtube into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #75
2026-08-29 16:30:44 +00:00
koka 61ba842206 Merge branch 'main' into feature/white-disc-youtube
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:29:42 +02:00
koka 0476a74d43 Merge pull request 'feat(sanctuary): rétablir le Black Market au clic molette (#68)' (#74) from feature/black-market-middle-click into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #74
2026-08-29 16:17:00 +00:00
koka d5fe55ddcc Merge branch 'main' into feature/black-market-middle-click
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:12:40 +02:00
koka 4a9f50e488 Merge pull request 'fix(canaplia): restaurer les pages vanilla du Livre interdit (#72)' (#73) from fix/canaplia-normal-pages into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #73
2026-08-29 16:08:49 +00:00
koka b9aabae914 fix(canaplia): restaurer les pages vanilla
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 18:07:31 +02:00
koka b121606b4e Merge pull request 'release: Sanctuary 26.2.0-alpha.210 — Livre interdit et correctifs' (#71) from release/26.2.0-alpha.210 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #71
2026-08-29 15:44:14 +00:00
koka fa40548db7 release: prepare 26.2.0-alpha.210
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 17:39:01 +02:00
koka 7ee1e5f094 fix(ambiance): reprendre la texture vanilla du disque blanc 2026-08-29 17:09:48 +02:00
koka 91b4def490 feat(onlyfun): ajouter la touche Abjection 2026-08-29 15:21:38 +02:00
koka 122ff939aa feat(ouch): restaurer le projectile Abjection 2026-08-29 15:17:37 +02:00
koka 3fef2d5437 feat(onlyfun): importer les actions Abjection 2026-08-29 15:10:14 +02:00
koka 75ddc5b682 feat(ambiance): ajouter le disque blanc YouTube 2026-08-29 14:59:38 +02:00
koka bf09bdfa78 feat(sanctuary): ouvrir le Black Market au clic molette 2026-08-29 14:31:20 +02:00
koka 2df4f35e48 Merge pull request 'feat(itsalive): transformer le Codex en Livre interdit (#65)' (#70) from feature/canaplia-forbidden-book into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #70
2026-08-29 12:19:25 +00:00
koka 4b4881d922 feat(itsalive): transformer le Codex en Livre interdit
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 14:16:34 +02:00
koka 66cd185983 Merge pull request 'feat(iliketomoveit): accélérer le Happy Ghast monté (#61)' (#69) from feature/happy-ghast-cruise-speed into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #69
2026-08-29 12:02:52 +00:00
koka bd93dc1cd6 feat(iliketomoveit): accélérer le Happy Ghast monté
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 13:17:50 +02:00
koka 2838bf0767 Merge pull request 'fix(sanctuary): réserver les requêtes aux panneaux dédiés (#25)' (#67) from fix/request-sign-block-only into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #67
2026-08-29 11:11:41 +00:00
koka e6740ff891 fix(sanctuary): réserver les requêtes aux panneaux dédiés
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:59:08 +02:00
koka 054c8e4ad4 Merge pull request 'fix(sanctuary): resynchroniser les capes à la reconnexion (#13)' (#66) from fix/cape-reconnect-sync into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #66
2026-08-29 10:54:41 +00:00
koka 4b11d217c2 fix(sanctuary): resynchroniser les capes à la reconnexion
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:43:32 +02:00
koka 3923b8547c Merge pull request 'feat(sanctuary): nommer tous les zombies façon Xbox 360 (#33)' (#64) from feature/zombie-xbox-gamertags into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #64
2026-08-29 10:37:23 +00:00
koka 76915e2f44 feat(sanctuary): nommer les zombies façon Xbox 360
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:30:11 +02:00
koka 5aaaf3831c Merge pull request 'fix(onlyfun): retirer les raccourcis de gameplay du Shop (#34)' (#63) from fix/shop-gameplay-bypasses into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #63
2026-08-29 10:27:03 +00:00
koka 37beda56ec fix(onlyfun): retirer les raccourcis du Shop
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:13:15 +02:00
koka 5d48917cf4 Merge pull request 'fix(sanctuary): séparer les statuts du Shop (#32)' (#60) from fix/shop-timer-overlap into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #60
2026-08-29 10:09:04 +00:00
koka 18cca8808b fix(sanctuary): séparer les statuts du Shop
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 11:08:47 +02:00
koka cbba8dc2d3 Merge pull request 'fix(canaplia): restaurer le gameplay et les textures historiques' (#56) from fix/canaplia-gameplay-visuals into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #56
2026-08-29 07:51:38 +00:00
koka 59239b1721 fix(canaplia): restore gameplay and authored visuals
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 09:24:09 +02:00
koka 680b6d647a Merge pull request 'release: Sanctuary 26.2.0-alpha.208 — Canaplia' (#55) from release/26.2.0-alpha.208 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #55
2026-08-29 06:42:27 +00:00
koka dd993d7064 release: prepare 26.2.0-alpha.208 with Canaplia
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 08:26:35 +02:00
koka 1ce299f88a Merge pull request 'feat(canaplia): implémenter le gameplay complet (#36 à #43)' (#54) from integration/canaplia-complete into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #54
2026-08-29 06:09:24 +00:00
koka b708bf5ffa feat(canaplia): implement complete gameplay integration
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 03:17:26 +02:00
koka 2e1585f12f Merge pull request 'feat(canaplia): porter les fondations en 26.2 (CANA-01)' (#53) from feature/canaplia-foundation into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #53
2026-08-29 00:12:58 +00:00
koka 1a8a983506 feat(canaplia): add 26.2 foundation
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 02:04:28 +02:00
koka 7db698881e Merge pull request 'release: Sanctuary 26.2.0-alpha.207' (#31) from release/26.2.0-alpha.207 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #31
2026-08-28 04:16:16 +00:00
koka 2d286ef54c release: prepare 26.2.0-alpha.207
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-28 06:03:36 +02:00
koka e599727d26 Merge pull request 'feat(anotherworld): rendre le coffre-fort illimité' (#30) from feature/safe-vault-unlimited-balances into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #30
2026-08-28 03:50:11 +00:00
koka 236d28854e feat(anotherworld): add unlimited safe balances
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-28 04:53:22 +02:00
koka 1377678e30 Merge pull request 'release: Sanctuary 26.2.0-alpha.206' (#28) from release/26.2.0-alpha.206 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #28
2026-08-27 23:56:40 +00:00
koka 91525a211d release: prepare 26.2.0-alpha.206
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-28 01:02:21 +02:00
koka 702a1d70c3 Merge pull request 'fix(anotherworld): corriger les colonnes CTM du Safe Block' (#27) from fix/safe-vault-ctm-columns into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #27
2026-08-27 22:55:52 +00:00
koka ee5bc3d109 fix(anotherworld): correct safe vault CTM columns
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-27 23:26:56 +02:00
koka cbf09dfb0e Merge pull request 'release: Sanctuary 26.2.0-alpha.205' (#24) from release/26.2.0-alpha.205 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #24
2026-08-27 16:11:04 +00:00
koka 4fc59802d3 release: prepare 26.2.0-alpha.205
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-27 18:03:03 +02:00
koka 64a5eca235 Merge pull request 'fix(anotherworld): coffre-fort creux 3×3×3' (#23) from fix/safe-vault-3x3x3 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #23
2026-08-27 15:50:21 +00:00
koka b5a30d294d fix(anotherworld): form hollow 3x3x3 safe vault
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-27 17:41:25 +02:00
koka 394b163e6b Merge pull request 'release: Sanctuary 26.2.0-alpha.204' (#22) from release/26.2.0-alpha.204 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #22
2026-08-27 14:51:00 +00:00
koka fd3c8bc8d7 release: prepare 26.2.0-alpha.204
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-27 16:39:58 +02:00
koka b215128e08 Merge pull request 'fix(anotherworld): finaliser le Safe Block 3 × 3' (#21) from feature/safe-vault-interface into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #21
2026-08-27 14:25:12 +00:00
587 changed files with 21339 additions and 686 deletions
+60
View File
@@ -0,0 +1,60 @@
# Migration de Canaplia
Canaplia est désormais implémenté dans Sanctuary 26.2 par les tickets [CANA-01 à CANA-09](https://git.botsu.net/koka/sanctuary/issues?state=open). Il reste un namespace de compatibilité réparti entre les propriétaires fonctionnels existants, et non un onzième sous-projet Gradle.
## Architecture
| Responsabilité | Propriétaire | Contrat public |
|---|---|---|
| cultures, variantes, récolte, compostage, résine, effets serveur et Codex | It's Alive | classes itsalive.canaplia, registres historiques canaplia:* |
| calendrier, météo, rendu visuel et accessibilité | Ambiance | AmbianceLunarHarvestApi, AmbianceVisualEffectApi et payloads bornés |
| événements de première découverte | Sanctuary | SanctuaryProgressionService.recordExternal |
| climat et biomes | Another World / It's Alive | ClimateCropForaging |
| exclusions Shop et récompenses aléatoires | Only Fun | OnlyFunItemPolicy |
It's Alive fournit l'alias Fabric canaplia et dépend explicitement d'Another World, d'Ambiance et de Sanctuary. Ambiance dépend de Sanctuary mais jamais d'It's Alive ; aucun cycle n'est introduit.
## Contenu et survie
Les seize clés stables sont common, tender, olive, dark, ashen, moss, gold, amber, bluish, pink, coral, purple, indigo, varieg, lunar et ember.
Les trois blocs historiques, le type de block entity et les 64 items historiques sont enregistrés une seule fois. Les cultures partagent une croissance à huit âges et conservent leur variante dans le blockstate. Dix têtes identiques dans un composteur produisent le bloc de résine correspondant ; une autre variante est refusée. La résine est posée éteinte, s'allume au briquet, à la charge de feu ou avec un projectile enflammé, émet une fumée visible et applique son aura serveur pendant 90 secondes avant de disparaître.
Les sources sauvages sont chargées depuis data/canaplia/canaplia_discovery/sources.json. Elles fonctionnent lors de la casse de flore dans les chunks existants. Ember est limitée à la flore native du Nether, Indigo aux chorus de l'End, et toutes les variantes Canaplia sont exclues du Shop et des récompenses ordinaires.
Les hybridations utilisent uniquement des parents matures dans un rayon de un bloc. Le serveur revalide climat, humidité, lumière, eau, horaire, nuit et ciel visible au moment de la récolte et n'effectue jamais plus d'un tirage. La règle Lunar passe par l'instantané public d'Ambiance.
## Effets, rendu et accessibilité
La consommation démarre une session autoritaire serveur de 140 à 180 secondes. Un bad trip éventuel est tiré une seule fois, commence après un délai borné et dure de 25 à 40 secondes. Les rafraîchissements restent bornés ; mort, expiration et déconnexion nettoient la session. L'aura d'une résine ne relance jamais le tirage.
Ambiance possède les trois payloads historiques canaplia:hemp_visual_sync, canaplia:hemp_bad_trip et canaplia:hemp_visual_clear. Il compose un profil normal et un profil de bad trip avec sa météo, sa lightmap et son contrat Iris/Canon, sans shader privé concurrent. Les réglages locaux d'intensité, de réduction des mouvements, de wobble et de ghosting sont disponibles dans les contrôles via « Réglages d'accessibilité visuelle » et n'altèrent jamais le gameplay serveur.
## Codex
Les monstres tués par un joueur peuvent laisser une `canaplia:codex_page` et, très rarement, le `canaplia:codex` complet. Quatre pages récupérées permettent aussi d'assembler le Livre interdit autour d'un livre vanilla. Un clic droit sur une page découvre au hasard une entrée encore inconnue et ne consomme la page qu'en cas de nouvelle découverte.
L'ID historique `canaplia:codex` est conservé, mais l'objet s'affiche désormais comme le « Livre interdit » : il utilise la couleur du livre enchanté, ouvre directement les seize entrées et les présente sur les pages claires du livre vanilla. En main, son contenu est strictement personnel. Posé sur un pupitre, il est temporairement inscrit au nom du joueur qui le pose et partage ses découvertes avec tous les lecteurs ; le récupérer en s'accroupissant ou casser le pupitre efface cette inscription.
Les données `canaplia:codex` restent en version 1. Les clés futures syntaxiquement valides et l'ancien champ partagé restent conservés lors d'un aller-retour afin qu'une migration ne perde aucune découverte, mais les nouvelles découvertes ne sont plus publiées globalement. Aucun payload de découverte n'est accepté du client vers le serveur et le protocole It's Alive reste en version 4.
## Tickets livrés
| Ticket | Fonction |
|---|---|
| [#36 CANA-02](https://git.botsu.net/koka/sanctuary/issues/36) | seize familles, ressources et compostage |
| [#37 CANA-03](https://git.botsu.net/koka/sanctuary/issues/37) | découvertes climatiques data-driven |
| [#38 CANA-04](https://git.botsu.net/koka/sanctuary/issues/38) | hybridations serveur |
| [#39 CANA-05](https://git.botsu.net/koka/sanctuary/issues/39) | Ember Nether et Indigo End |
| [#40 CANA-06](https://git.botsu.net/koka/sanctuary/issues/40) | récolte Lunar liée à Ambiance |
| [#41 CANA-07](https://git.botsu.net/koka/sanctuary/issues/41) | effets serveur et bad trips |
| [#42 CANA-08](https://git.botsu.net/koka/sanctuary/issues/42) | composition visuelle accessible |
| [#43 CANA-09](https://git.botsu.net/koka/sanctuary/issues/43) | Codex persistant personnel et partagé |
## Compatibilité, licence et migrations
Les IDs historiques, les clés et le namespace sont inchangés. L'ancien format non versionné de canaplia:hemp_bud_composters reste lisible et est réécrit sous une enveloppe v1. Le block entity de résine conserve `remaining_burn_ticks` et migre sa donnée v1 déjà active vers l'état allumé v2. Le Codex utilise sa propre donnée v1. Les protocoles restent It's Alive v4 et Ambiance v5.
La source historique 2.0.0 demeure **All Rights Reserved**, copyright KOKA99CAB. À la demande explicite du détenteur des droits, ses 224 textures 16×16 ont été restaurées dans l'alpha.209 ; leur provenance est documentée avec les ressources. Aucun fichier Java, modèle, son ou JSON historique n'est copié. Le code d'intégration reste sous GPL-3.0-or-later.
Les inventaires machine sont dans `pack/migrations/canaplia-foundation-alpha32.json` et `pack/migrations/canaplia-gameplay-alpha33.json`. La release `26.2.0-alpha.209` décrit la correction de gameplay et l'import autorisé dans `pack/migrations/26.2.0-alpha.208-canaplia-fixes-to-alpha.209.json`; lalpha.210 décrit le Livre interdit et les autres changements distribués dans `pack/migrations/26.2.0-alpha.209-forbidden-book-and-polish-to-alpha.210.json`.
+270 -2
View File
@@ -6,13 +6,281 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
- Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46.
- Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées.
## En développement — correction finale du Safe Block
## `26.2.0-alpha.226`
Module modifié : **Another World `0.0.0-alpha.52`**. Le pack distribué reste en **`26.2.0-alpha.203`**.
Module modifié : **Sanctuary `0.0.0-alpha.130`**. Les neuf autres modules conservent leur version issue de l'alpha.225.
- ajoute `sanctuary:white_cape`, une cape blanche craftable avec six laines blanches disposées comme une porte, en deux colonnes de trois ;
- permet d'associer simplement la cape et n'importe quelle bannière dans l'établi afin d'en conserver la couleur de fond et jusqu'à six motifs ;
- affiche les motifs dans l'inventaire et sur la cape équipée, avec la physique native des capes, y compris en multijoueur ;
- conserve les sauvegardes Sanctuary en data v13 ; le payload d'apparence transporte désormais la couleur et les motifs bornés, donc le protocole Sanctuary passe de 23 à 24 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.225`
Modules modifiés : **Another World `0.0.0-alpha.56`**, **It's Alive `0.0.0-alpha.38`**, **I Like To Move It `0.0.0-alpha.18`**, **Only Fun `0.0.0-alpha.27`** et **Sanctuary `0.0.0-alpha.129`**. Les cinq autres modules conservent leur version issue de l'alpha.224.
- exige que lattaquant possède son propre coffre-fort actif avant de commencer un braquage, revalide cette condition pendant le forage et refuse défensivement tout transfert si le coffre disparaît entre-temps ;
- empêche les familiers temporaires Sanctuary de produire du butin vanilla, de lexpérience ou une lootbox Only Fun, et les exclut des contrats de panneau comme des demandes communautaires ;
- conserve la variante Canaplia des seize graines historiques pendant le placement, la croissance naturelle, la poudre dos, la création de la partie haute, le rechargement et la récolte, ce qui réactive notamment lhybridation Tendre + Olive vers Dorée ;
- remplace la laine violette du Magic Carpet par la texture historique 16 × 16 du Totem de Notch, utilisée par lobjet canonique et son alias hérité sans modifier la plateforme 3 × 3 ;
- conserve tous les IDs, sauvegardes et formats de données ; les protocoles réseau restent Another World 2, It's Alive 4, I Like To Move It 5, Only Fun 3 et Sanctuary 23, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.224`
Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **I Like To Move It `0.0.0-alpha.17`**, **Only Fun `0.0.0-alpha.26`**, **Sanctuary `0.0.0-alpha.128`** et **MasterKey `0.0.0-alpha.9`**.
- étend le clic molette aux coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, tout en excluant les fours, le Shop et le terminal Red-Stoner ;
- trie l'inventaire du joueur lorsque le curseur survole l'une de ses cases dans un conteneur, sans toucher à la hotbar, et replace la hotbar en bas après connexion ou achat d'une rangée ;
- ajoute Notch comme résident unique et persistant de la maison Indev, avec restauration automatique, déduplication et lecture des anciennes entités `portals:notch` ;
- transforme Notch en Titan au premier coup volontaire, avec santé bornée selon les participants, poursuite, attaques télégraphiées, boules de feu renvoyables et réinitialisation si le combat est abandonné ;
- limite la destruction de blocs à l'explosion initiale de transformation ; les attaques suivantes utilisent uniquement dégâts, poussée, particules et explosions non destructrices ;
- enregistre la première victoire comme jalon mondial monotone, crédite les participants, inscrit un marqueur de boss dans leur Atlas déjà découvert et réserve une récompense physique unique ;
- place le Magic Carpet de Notch dans un coffre déterministe signalé par feu d'artifice, avec réservation et livraison persistées séparément pour récupérer une interruption sans duplication ;
- conserve `portals:notch_totem` comme alias caché et convertit inventaires, conteneurs et items au sol vers `iliketomoveit:magic_carpet` en gardant quantité, composants et nom personnalisé ;
- maintient la plateforme temporaire 3 × 3 sous autorité serveur, sans écraser le terrain et avec un suivi par propriétaire qui protège les tapis superposés pendant les nettoyages ;
- rend l'offre Magic Carpet visible et achetable uniquement après `sanctuary:notch_defeated`, au prix exceptionnel existant, avec paiement normal, livraison Mailbox, provenance et journal de remplacement ;
- ajoute les diagnostics `/masterkey notch inspect` et `/masterkey notch restore`, les traductions FR/EN/RU et les contrats de test déterministes associés ;
- migre les données It's Alive de v3 à v4, I Like To Move It de v3 à v4 et Sanctuary de v11 à v13 ; le payload de tri ayant gagné un booléen borné, le protocole Sanctuary passe de 22 à 23 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.223`
Module modifié : **Sanctuary `0.0.0-alpha.125`**. Les neuf autres modules conservent leur version issue de l'alpha.222.
- ajoute un tri natif de linventaire par clic molette configurable, uniquement sur les rangées de stockage débloquées ;
- fusionne et range les piles depuis le haut sans déplacer la hotbar, larmure, la main secondaire, le craft ni les emplacements spéciaux ;
- garde le tri sous autorité serveur, avec un délai de huit ticks, et lignore en créatif ou pendant la saisie dans un champ texte ;
- ajoute un style configurable au suivi des quêtes (`N` par défaut), entre le texte transparent et le panneau original de Chris ;
- place automatiquement le style texte sous la hauteur réelle de la colonne gauche du F3 permanent afin que les deux affichages ne se recouvrent pas ;
- nomme et ordonne les contrats suivis par niveau de récompense, rubis puis saphir puis émeraude/autres, au lieu du libellé générique « Contrat du jour » ;
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.198` et synchronise ses empreintes entre Packwiz et Prism ;
- conserve les données Sanctuary v11, le protocole réseau 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation nest nécessaire.
## `26.2.0-alpha.222`
Modules modifiés : **Red-Stoner `0.0.0-alpha.13`** et **Sanctuary `0.0.0-alpha.124`**. Les huit autres modules conservent leur version issue de l'alpha.221.
- ajoute au terminal de stockage une recherche clavier focalisée à l'ouverture, compatible avec les noms affichés, les IDs, les traductions, les accents et Unicode ;
- affiche les grandes quantités du terminal sous forme compacte (`1K`, `1.2K`, `12K`, `1.2M`) tout en conservant la valeur exacte dans l'infobulle ;
- stabilise les emplacements du terminal lorsqu'une pile est vidée afin que les ressources suivantes ne glissent plus sous le curseur, puis recompacte volontairement lors d'une nouvelle recherche ou à la réouverture ;
- ajoute un suivi d'avancement des quêtes, affichable ou masquable par une touche configurable (`J` par défaut) ;
- affiche chaque quête active comme un simple texte ombré en haut à gauche, sans panneau, bordure, titre, message vide ni barre de progression ;
- synchronise un snapshot borné des quêtes acceptées et des contrats réservés, sans les demandes que le joueur a lui-même créées, ni changer les données Sanctuary v11 ni le protocole réseau 22 ;
- propose une saisie assistée des IDs d'item et de mob dans l'éditeur de demande, avec icône et noms proches.
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.196` et synchronise Packwiz avec l'index Prism/Modrinth ;
- conserve Red-Stoner data v3/protocole 7, Sanctuary data v11/protocole 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
## `26.2.0-alpha.221`
Module modifié : **Ambiance `0.0.0-alpha.22`**. Les neuf autres modules conservent leur version issue de l'alpha.220.
- corrige la cause réelle du disque blanc ralenti et trop grave : la piste AAC YouTube reste décodée en stéréo native à 48 kHz, puis chaque paire gauche/droite est explicitement moyennée en un échantillon mono positionnel ;
- supprime la compensation OpenAL approximative à `4/3` et restitue la musique à sa hauteur et sa vitesse originales avec une hauteur `1.0` ;
- conserve un flux PCM mono signé 16 bits little-endian pour l'audio positionnel du jukebox, sans silence ajouté ni changement de durée ;
- ajoute un test déterministe du downmix stéréo et valide le lien signalé sur quatre secondes : 4 000 ms de source donnent 4 000 ms de PCM à 48 kHz ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
## `26.2.0-alpha.220`
Module modifié : **Red-Stoner `0.0.0-alpha.12`**. Les neuf autres modules conservent leur version issue de l'alpha.219.
- remplace l’écran propriétaire du terminal par un véritable menu de conteneur inspiré des coffres vanilla et composé avec le panneau dinventaire Sanctuary ;
- supprime le bouton de fermeture dédié : Échap et la touche dinventaire suivent désormais le comportement normal du jeu ;
- affiche et synchronise toutes les rangées dinventaire débloquées par Sanctuary, dune à six, y compris les deux rangées persistantes supplémentaires ;
- permet le retrait par clic gauche, le retrait unitaire par clic droit et le retrait vers linventaire par shift-clic ; un clic avec une pile portée ou un shift-clic depuis linventaire dépose dans le réseau ;
- conserve les 54 cases comme une vue agrégée non réorganisable et affiche les quantités exactes sans créer de stockage virtuel ;
- dépose dans le premier coffre ou baril disponible selon lordre déterministe de découverte, remplit dabord ses piles compatibles puis ses cases vides et ne passe au stockage suivant quen cas de besoin ;
- supprime les anciens payloads spécifiques au terminal au profit de la synchronisation native des menus, tout en gardant la distance, la topologie et les transferts sous autorité serveur ;
- conserve les données Red-Stoner v3 et les sauvegardes existantes ; le protocole réseau Red-Stoner passe de 6 à 7, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.219`
Modules modifiés : **Sanctuary `0.0.0-alpha.122`**, **I Like To Move It `0.0.0-alpha.16`** et **Ambiance `0.0.0-alpha.21`**. Les sept autres modules conservent leur version issue de l'alpha.218.
- ajoute `sanctuary:alpha`, une île Minecraft Indev finie de 128 × 128 × 64 blocs, déterministe par seed, avec plages, cavernes, minerais, eau, lave, végétation et maison de départ historique ;
- borne le monde par un fond en bedrock et un anneau de barrières, sans placement au-delà de X/Z 64…63, et expose les positions stables de la résidence et de l'arrivée sûre via l'API publique de Sanctuary ;
- ajoute la dimension, son biome, son type et ses réglages aux profils Sanctuary et Overworld, tout en conservant la migration `portals:alpha` vers `sanctuary:alpha` ;
- active la route de portail v2 en cobblestone moussue, cadre 4 × 5 et intérieur 2 × 3, avec validation serveur, refus si la dimension manque, paire persistante, déliaison à la casse et arrivée distante hors de la maison ;
- compose localement les packs officiels Golden Days Base puis Alpha uniquement dans `sanctuary:alpha`, restaure exactement la pile précédente à la sortie ou déconnexion et laisse les textures vanilla fonctionner si les ZIP manquent ;
- ne distribue aucun asset Golden Days dans les JAR, Packwiz ou `.mrpack`, documente les liens et la licence officiels, et n'impose aucun fichier au serveur dédié ;
- ajoute les traductions FR/EN/RU et des tests déterministes pour le générateur, le portail et la composition des packs ;
- conserve Sanctuary data v11/protocole 22, I Like To Move It data v3/protocole 5 et Ambiance data v2/protocole 6, sans réinitialisation de sauvegarde.
## `26.2.0-alpha.218`
Modules modifiés : **Red-Stoner `0.0.0-alpha.11`**, **Only Fun `0.0.0-alpha.25`** et **Sanctuary `0.0.0-alpha.121`**. Les sept autres modules conservent leur version issue de l'alpha.217.
- ajoute `redstoner:storage_terminal`, une demi-dalle verticale de huit pixels qui se pose uniquement sur la face horizontale dun coffre, coffre piégé ou baril ;
- découvre sans charger de chunk les conteneurs adjacents par face, verticalement compris, et refuse clairement tout réseau qui dépasse 128 blocs physiques ;
- agrège uniquement les piles dont litem et tous les composants sont strictement identiques, puis les affiche sur des pages défilantes de 54 entrées ;
- valide côté serveur la distance, le terminal, la topologie, la page et lidentité de chaque pile avant un retrait par pile, un retrait unitaire ou un dépôt par shift-clic ;
- fabrique le terminal avec quatre plaques dargent, trois verres teintés, un Controller et un lingot de titane, sans entrée Shop, Black Market ni récompense de lootbox ;
- ajoute les traductions FR/EN/RU, les ressources bloc/item/loot/recette et des tests déterministes de topologie, pagination, agrégation et transferts ;
- conserve les données Red-Stoner v3, Only Fun v2 et Sanctuary v11 ; seul le protocole réseau Red-Stoner passe de 5 à 6, sans nouveau monde ni réinitialisation des sauvegardes.
## `26.2.0-alpha.217`
Module modifié : **Ambiance `0.0.0-alpha.20`**. Les neuf autres modules conservent leur version issue de l'alpha.216.
- corrige la hauteur trop grave et la vitesse trop lente du disque blanc en compensant la cadence du flux AAC YouTube par un facteur OpenAL exact de `4/3` ;
- réapplique cette hauteur pendant toute la lecture afin quaucune mise à jour du canal audio ne la ramène à la valeur ralentie ;
- valide la correction sur le lien signalé en comparant vingt secondes décodées par Lavaplayer au flux AAC direct, puis conserve les tests PCM 48 kHz et de niveau sonore ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
- distribue Ambiance `0.0.0-alpha.20` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.216`
Module modifié : **Ambiance `0.0.0-alpha.19`**. Les neuf autres modules conservent leur version issue de l'alpha.215.
- corrige le disque blanc ralenti et dégradé en remplissant continûment les tampons PCM dune seconde demandés par Minecraft au lieu de les compléter prématurément avec du silence ;
- conserve une fréquence réelle de 48 kHz, un flux mono 16 bits little-endian et force la vitesse OpenAL normale à `1.0` ;
- borne chaque attente de trame à 250 ms après le démarrage afin de garder un flux continu sans bloquer indéfiniment le thread audio ;
- ajoute un test déterministe des tampons Minecraft et renforce le test YouTube réel sur quatre secondes complètes ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
- distribue Ambiance `0.0.0-alpha.19` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.215`
Module modifié : **Ambiance `0.0.0-alpha.18`**. Les neuf autres modules conservent leur version issue de l'alpha.214.
- corrige le crash natif déclenché par linsertion ou larrêt du disque blanc en laissant Minecraft libérer le canal OpenAL sur son thread audio ;
- rétablit le décodage audible du lien YouTube signalé grâce au profil iOS corrigé et à une révision épinglée de `youtube-source` ;
- borne uniquement lattente initiale du flux puis poursuit la lecture sans blocage, avec silence de compensation lorsque les données arrivent en retard ;
- transforme les erreurs et blocages de lecture en arrêt propre accompagné dun message au joueur ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
- distribue Ambiance `0.0.0-alpha.18` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.214`
Module modifié : **Ambiance `0.0.0-alpha.17`**. Les neuf autres modules conservent leur version issue de l'alpha.213.
- attend de façon bornée la synchronisation client du jukebox avant de charger un disque blanc, ce qui rétablit sa lecture en solo ;
- affiche immédiatement le chargement puis un échec explicite si le jukebox n'est toujours pas synchronisé après cinq secondes ;
- accepte toujours les liens de partage YouTube avec paramètres, notamment `https://youtu.be/Ue5ZBe-GzSM?si=…` ;
- conserve les données Ambiance v2 et le protocole réseau 6, sans migration de sauvegarde ;
- distribue Ambiance `0.0.0-alpha.17` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.213`
Module modifié : **Ambiance `0.0.0-alpha.16`**. Les neuf autres modules conservent leur version issue de l'alpha.212.
- corrige le crash client au démarrage d'Ambiance sous Minecraft 26.2 en ciblant le nouveau gestionnaire vanilla `LevelEventHandler` pour l'arrêt du disque blanc ;
- conserve les données Ambiance v2 et le protocole réseau 6, sans migration de sauvegarde ;
- remplace le JAR Ambiance cassé dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.212`
Module modifié : **Sanctuary `0.0.0-alpha.120`**. Les neuf autres modules conservent leur version issue de l'alpha.211.
- corrige le crash client au démarrage provoqué par le mixin du clic molette du Black Market sous Minecraft 26.2 ;
- cible désormais la méthode vanilla `pickBlockOrEntity`, sans modifier les données Sanctuary v11 ni le protocole réseau 22.
- remplace le JAR Sanctuary cassé dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.211`
Modules modifiés : **Ambiance `0.0.0-alpha.15`**, **It's Alive `0.0.0-alpha.36`**, **Only Fun `0.0.0-alpha.24`**, **Ouch `0.0.0-alpha.10`** et **Sanctuary `0.0.0-alpha.119`**. Les cinq autres modules conservent leur version issue de lalpha.210.
- remplace les pages noires du Livre interdit par laffichage clair du livre vanilla ;
- conserve les seize entrées, les découvertes personnelles, le partage sur pupitre, les IDs, les données v3 et le protocole réseau 4.
- rétablit l'accès au Black Market par clic molette sur un panneau, avec une intention vide et une nouvelle validation complète de la cible côté serveur ;
- conserve les données Sanctuary v11 et porte son protocole réseau de 21 à 22, sans nouvel ID persistant.
- ajoute le disque blanc, à fabriquer puis renommer avec une URL YouTube HTTPS avant de linsérer dans un jukebox ;
- valide strictement lhôte et lidentifiant vidéo côté serveur, ne transmet quun identifiant borné de onze caractères et limite chaque client à quatre lectures de trente minutes maximum ;
- diffuse le son en mono positionnel dans le canal des disques, sans nouvelle donnée persistante ; Ambiance conserve ses données v2 et passe son protocole réseau de 5 à 6.
- importe le cœur GPL historique dAbjection dans Only Fun, sans onzième JAR : `/piss`, `/vomit`, `/shit` et `/smoke` retrouvent leurs durées, jets, impacts et messages ;
- restaure `abjection:shit_ball` ainsi que le bloc boueux `abjection:shit_cube`, sa recette, son butin et ses ressources originales en FR/EN/RU ;
- expose une API serveur unique pour les commandes et le futur raccourci, nettoie les actions temporaires à la mort, en spectateur ou à la déconnexion, et conserve les données v2 ainsi que le protocole réseau 2 dOnly Fun.
- rend `abjection:shit_ball` lançable à la main et par dispenser grâce à une fabrique publique installée par Ouch, sans réenregistrer lobjet possédé par Only Fun ;
- restaure le projectile historique de 0,25 bloc, sa consommation unique hors créatif, son rendu dobjet, son impact boueux et ses sept secondes de nausée ajoutées à la durée existante ;
- déclare la dépendance sans cycle `ouch -> onlyfun` ; les données et protocoles réseau dOuch et dOnly Fun restent inchangés.
- ajoute la touche configurable « Chier » dans les contrôles Only Fun, assignée à `J` par défaut et sans répétition lorsque la touche reste maintenue ;
- transmet une demande C2S vide, déduplique le spam dun même tick et revalide côté serveur la connexion, la vie et le mode spectateur avant de réutiliser exactement laction `/shit` ;
- conserve les données Only Fun v2 et porte son protocole réseau de 2 à 3, sans nouveau cooldown, besoin ni donnée persistante.
- distribue les cinq JAR mis à jour dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.210`
Modules modifiés : **Sanctuary `0.0.0-alpha.118`**, **Only Fun `0.0.0-alpha.21`**, **I Like To Move It `0.0.0-alpha.15`** et **It's Alive `0.0.0-alpha.35`**. Les six autres modules conservent leur version issue de lalpha.209.
- sépare le compteur quotidien et le statut de déblocage du Shop pour empêcher leur chevauchement ;
- retire du Shop Only Fun les raccourcis qui contournaient la progression normale ;
- attribue à tous les Zombies vanilla des gamertags déterministes inspirés de la Xbox 360 ;
- resynchronise les capes après une reconnexion avec des tentatives retardées et bornées ;
- réserve les requêtes communautaires aux panneaux dédiés au lieu dintercepter les panneaux ordinaires ;
- double la vitesse de croisière du Happy Ghast lorsquun joueur le pilote, sans accélérer ses déplacements autonomes ;
- conserve les données v3 et le protocole réseau 5, sans migration de sauvegarde ni nouvel ID.
- transforme le Codex Canaplia en « Livre interdit » aux pages noires, sans page d'accueil, et fait découvrir une entrée aléatoire encore inconnue à chaque page consommée ;
- garde les découvertes du Livre interdit personnelles en main et partage temporairement celles du propriétaire uniquement lorsque le livre est posé sur un pupitre ;
- conserve les IDs et les anciennes données partagées, ainsi que les données v3 et le protocole réseau 4 d'It's Alive, sans migration de sauvegarde.
- actualise JEI de `30.28.0.191` à la bêta compatible `30.28.0.193` dans Packwiz et le manifeste Prism/Modrinth ;
- distribue les quatre JAR mis à jour dans Packwiz, Prism et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde.
## `26.2.0-alpha.209`
Module modifié : **It's Alive `0.0.0-alpha.34`**. Les neuf autres modules conservent leur version issue de lalpha.208.
- restaure les 224 textures 16×16 authored du Canaplia historique, avec provenance et autorisation explicite du détenteur des droits, à la place des palettes procédurales illisibles ;
- rétablit la consommation des seize joints avec animation, son, particules de fumée, découverte et effets serveur ;
- pose les blocs de résine éteints, permet de les allumer au briquet, à la charge de feu ou avec un projectile enflammé, puis affiche une fumée de feu de camp pendant leur aura de 90 secondes ;
- migre sans perte les résines déjà posées par lalpha.208 vers l’état allumé et porte les données It's Alive en v3 sans changer le protocole réseau 4 ;
- remplace lancien panneau vert du Codex par l’écran et les pages du livre Minecraft ;
- ajoute les pages de Canaplia aux butins des monstres tués par un joueur, un rare butin direct du Codex et une recette livre + quatre pages ;
- distribue It's Alive `0.0.0-alpha.34` dans les manifestes Packwiz, Prism et Modrinth du pack alpha.209, sans exiger de nouveau monde.
## `26.2.0-alpha.208`
Modules modifiés : **It's Alive `0.0.0-alpha.33`**, **Ambiance `0.0.0-alpha.14`** et **Only Fun `0.0.0-alpha.20`**. Les sept autres modules conservent leur version issue de lalpha.207.
- enregistre sous les IDs historiques les seize cultures Canaplia, leurs graines, têtes, résines et joints, avec ressources originales 16×16 et traductions FR/EN/RU ;
- transforme de façon déterministe dix têtes identiques en résine correspondante, migre l'ancien suivi de composteur et fait persister l'aura de résine pendant 90 secondes ;
- charge les sources de découverte depuis un catalogue data-pack borné, avec variantes sauvages climatiques, Ember limitée au Nether et Indigo limitée à l'End ;
- revalide côté serveur les parents matures, l'humidité, la lumière, l'eau, le climat et l'heure pour six hybridations et la récolte Lunar sous ciel froid et visible ;
- applique seize profils serveur de 140 à 180 secondes avec bad trip unique et borné, nettoyage à l'expiration, à la mort et à la déconnexion ;
- fait composer par Ambiance un seul profil normal et un seul bad trip, sans shader concurrent, avec intensité, réduction des mouvements, wobble et ghosting configurables depuis les contrôles ;
- ajoute un Codex partageable qui conserve les découvertes personnelles et communautaires, révèle ses indices progressivement et préserve les futures clés inconnues ;
- exclut tout le namespace Canaplia du Shop et des lootboxes ordinaires, conserve les IDs historiques et ne copie aucun fichier de la source 2.0.0 All Rights Reserved.
- distribue les trois JAR mis à jour dans Packwiz, Prism et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde.
- actualise JEI de `30.27.0.189` à la bêta compatible `30.28.0.191` dans les manifestes Packwiz et Modrinth.
## `26.2.0-alpha.207`
Module modifié : **Another World `0.0.0-alpha.55`**. Les neuf autres modules conservent leur version issue de lalpha.206.
- remplace les 27 piles du coffre-fort par trois soldes 64 bits séparés pour les rubis, saphirs et émeraudes ;
- migre automatiquement chaque pile existante vers son solde correspondant dans `safe_vaults` v4, sans changer les IDs ni exiger de nouveau monde ;
- synchronise les soldes 64 bits avec linterface et les affiche sous forme compacte au-delà de 999 ;
- conserve laccès du Shop aux monnaies stockées et borne son API historique 32 bits sans limiter le solde réel ;
- crédite directement les 20 % pillés dans le coffre-fort de lattaquant afin d’éviter de générer un nombre non borné dobjets ;
- porte Another World en donnée v5 et protocole réseau 2, puis régénère Packwiz avec le JAR `0.0.0-alpha.55`.
## `26.2.0-alpha.206`
Module modifié : **Another World `0.0.0-alpha.54`**. Les neuf autres modules conservent leur version issue de lalpha.205.
- remet les colonnes A et C dans le bon ordre sur la façade et le dos du coffre-fort 3 × 3 × 3 ;
- conserve la colonne B, les côtés, le dessus, le dessous et les quatre orientations du coffre-fort ;
- régénère Packwiz avec le JAR Another World `0.0.0-alpha.54` et synchronise les métadonnées Prism/Modrinth sur lalpha.206 ;
- ne modifie ni les blockstates formés, ni les données v4, ni `safe_vaults` v3, ni le protocole 1.
## `26.2.0-alpha.205`
Module modifié : **Another World `0.0.0-alpha.53`**. Les neuf autres modules conservent leur version issue de lalpha.204.
- remplace le mur de neuf Safe Blocks par une coque creuse de 26 blocs disposés en 3 × 3 × 3 ;
- laisse le bloc central entièrement libre afin de pouvoir y cacher nimporte quel bloc ;
- refuse les murs 3 × 3, les cubes pleins et toute structure à laquelle un Safe Block supplémentaire est attaché ;
- répartit les textures 48×48 sur chacune des faces extérieures du cube, avec la façade, le dessus, le dessous et les côtés/arrière ;
- conserve les anciennes valeurs de blockstate pour permettre le chargement et le démontage des murs existants, qui doivent être reconstruits en cube ;
- actualise JEI de `30.27.0.188` à `30.27.0.189` dans Packwiz et le manifeste Prism/Modrinth ;
- préserve les comptes, les 27 emplacements de stockage, les soldes et le protocole réseau ; Another World passe à la donnée v4 et `safe_vaults` à la donnée v3.
## `26.2.0-alpha.204`
Module modifié : **Another World `0.0.0-alpha.52`**. Les neuf autres modules conservent leur version issue de lalpha.203.
- charge la texture 16×16 du Safe Block depuis latlas de blocs afin de supprimer la texture manquante avant formation ;
- refuse explicitement les lignes horizontales ou verticales de 3 × 1 et nactive le CTM 48×48 quaprès validation des neuf blocs du mur 3 × 3 ;
- décale le panneau dinventaire étendu sous les trois lignes monétaires afin de rendre l’émeraude visible et laisse Sanctuary afficher un seul libellé dinventaire ;
- actualise JEI de `30.27.0.187` à `30.27.0.188` dans Packwiz et le manifeste Prism/Modrinth ;
- conserve les données v3, le protocole 1, les IDs et les soldes existants sans migration de sauvegarde.
## `26.2.0-alpha.203`
+36 -6
View File
@@ -1,6 +1,6 @@
# Sanctuary 26.2
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.155.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.158.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Auteur : **KOKA99CAB**
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
@@ -10,9 +10,9 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
| Fabric ID | Nom public | Responsabilité | État |
|---|---|---|---|
| `anotherworld` | Another World | choses matérielles | matière, photographie, instruments météo et 108 biomes natifs |
| `itsalive` | It's Alive ! | vivant | Creepers, créatures et agriculture (20 cultures, vergers, café et vanille) |
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, temps réel et calendrier minecraftien |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, Drawer et Particuleur |
| `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia |
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, Drawer et Particuleur |
| `iliketomoveit` | I Like To Move It | déplacement | portails, véhicules, grappin, poulies et waystones locaux |
| `ouch` | Ouch | combat et destruction | arsenal actif (6 armes) et TNT météo |
| `onlyfun` | Only Fun | règles ludiques absurdes | lootboxes, Lucky Block, raretés et règles absurdes |
@@ -22,13 +22,41 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
Les descriptions FR/EN/RU sont déclarées dans chaque `fabric.mod.json` et dans les fichiers de langue. Les images fournies servent d'icônes Fabric de leur module.
Version courante du pack distribué : **`26.2.0-alpha.203`**. Dans le workspace, le correctif du Safe Block porte Another World en développement à **`0.0.0-alpha.52`** sans produire une nouvelle version du pack.
Version courante du pack distribué : **`26.2.0-alpha.226`**. Cette release distribue Sanctuary **`0.0.0-alpha.130`** ; les neuf autres modules conservent leur version issue de l'alpha.225.
Lalpha.225 corrige quatre tickets : posséder un coffre-fort devient obligatoire pour en braquer un autre, les familiers temporaires ne donnent plus de butin, dexpérience, de lootbox ni de progression de quête, les seize variantes Canaplia survivent à tout leur cycle de culture, et le Magic Carpet retrouve la texture historique du Totem de Notch à la place de la laine violette. Les IDs, sauvegardes, formats de données et protocoles restent compatibles.
La migration [CANA-01 à CANA-09](CANAPLIA.md) porte Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.35`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Livre interdit personnel, partageable temporairement sur un pupitre. Ambiance **`0.0.0-alpha.14`** compose les profils visuels bornés avec ses réglages d'accessibilité, et Only Fun **`0.0.0-alpha.21`** exclut explicitement Canaplia et les raccourcis de progression du Shop.
Lalpha.224 ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables. La première victoire réserve maintenant un unique `iliketomoveit:magic_carpet` dans un coffre signalé par feu d'artifice. L'ancien `portals:notch_totem` est migré sans perte ; après Notch, Only Fun ouvre l'offre de remplacement au Shop avec paiement serveur et livraison par Mailbox. Le clic molette trie aussi les coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, ou l'inventaire du joueur lorsqu'il est survolé, sans déplacer la hotbar. Le protocole Sanctuary passe à 23 : client et serveur doivent utiliser l'alpha.224.
Lalpha.223 ajoute le tri natif des rangées de stockage par clic molette sans déplacer la hotbar ni les emplacements spéciaux. Le suivi des quêtes propose désormais le texte transparent aligné sous la colonne gauche du F3 ou le panneau original de Chris, avec bascule configurable sur `N` et contrats ordonnés par récompense. JEI passe à la bêta Fabric 26.2 `30.29.0.198`.
Lalpha.222 améliore le terminal de stockage avec une recherche Unicode, des quantités compactes et des emplacements qui ne glissent plus lorsqu'une pile est vidée. Elle ajoute aussi le suivi textuel des quêtes actives en haut à gauche, masquable avec une touche configurable, ainsi que des suggestions dIDs ditems et de mobs dans l’éditeur de demandes. JEI passe à la bêta Fabric 26.2 `30.29.0.196`.
Lalpha.221 corrige la cause réelle du disque blanc ralenti et trop grave : Ambiance décode désormais la source AAC en stéréo native à 48 kHz, puis moyenne explicitement chaque paire gauche/droite vers le flux mono positionnel de Minecraft. OpenAL reste à sa hauteur normale `1.0`, sans compensation artificielle ni changement de durée.
Lalpha.220 transforme le terminal de stockage en véritable menu de conteneur, sans bouton de fermeture propriétaire. Les clics gauche/droit et shift-clic permettent maintenant de retirer ou déposer des piles depuis toutes les rangées dinventaire débloquées par Sanctuary, jusqu’à six ; la grille agrégée reste non réorganisable et remplit chaque coffre ou baril dans lordre de découverte avant de passer au suivant.
Lalpha.219 porte l'île finie Minecraft Indev dans `sanctuary:alpha` : 128 × 128 × 64 blocs déterministes, plages, cavernes, minerais, fluides, végétation et maison historique, le tout borné sans chunks parasites. Le cadre 4 × 5 en cobblestone moussue est maintenant actif et crée une paire persistante à une arrivée sûre hors de la maison. Ambiance peut enfin composer localement Golden Days Base puis Alpha uniquement dans cette dimension, sans redistribuer leurs textures et en restaurant exactement la pile précédente. Voir le [contrat Alpha](sanctuary/ALPHA.md), le [contrat des portails](iliketomoveit/PORTALS.md) et le [guide Golden Days](ambiance/GOLDEN_DAYS.md).
Lalpha.218 ajoute le terminal de stockage Red-Stoner : cette demi-dalle verticale se fixe sur un coffre, un coffre piégé ou un baril et donne accès, par pages défilantes de 54 types, à un réseau contigu borné à 128 conteneurs chargés. Les retraits et dépôts sont validés par le serveur, les composants ditems restent strictement séparés et le terminal endgame est exclu du Shop, du Black Market et des récompenses Only Fun.
Lalpha.217 tentait de compenser la hauteur trop grave du disque blanc par un facteur OpenAL de `4/3`. Cette compensation est retirée par lalpha.221, qui corrige directement la conversion stéréo vers mono.
Lalpha.216 corrige la lecture lente et dégradée du disque blanc : les quatre tampons PCM initiaux de Minecraft sont maintenant remplis continûment à 48 kHz, avec une vitesse OpenAL normale, au lieu d’être majoritairement complétés par du silence. Les packs Modrinth, Prism et Packwiz sont mis à jour sans exiger de nouveau monde ni réinitialiser les sauvegardes.
Lalpha.207 rend le stockage du coffre-fort pratiquement illimité : rubis, saphirs et émeraudes utilisent chacun un solde 64 bits. Les 27 anciennes piles sont additionnées automatiquement lors du chargement, le Shop continue de consommer directement ces soldes et les 20 % pillés sont crédités dans le coffre-fort de lattaquant sans créer des millions dentités ou de piles dobjets.
Lalpha.206 exige une coque creuse de **3 × 3 × 3**, soit 26 Safe Blocks appartenant au même joueur, et conserve JEI en **`30.27.0.189`**. Le centre est libre et peut contenir un bloc caché. Les quatre images 48×48 se répartissent sur les faces extérieures du cube : façade, dessus, dessous et côtés/arrière. Les colonnes extrêmes de la façade et du dos sont désormais orientées correctement. Un ancien mur 3 × 3 ne sactive plus et doit être démonté puis reconstruit ; les comptes et soldes existants restent conservés.
Lalpha.204 finalise le Safe Block vertical et actualise JEI en **`30.27.0.188`** : sa texture individuelle 16×16 est chargée avant formation, les structures incomplètes 3 × 1 sont refusées et le CTM 48×48 exige les neuf blocs. Linterface monétaire affiche désormais correctement l’émeraude et place linventaire étendu sous les trois lignes de monnaie.
Lalpha.203 porte Another World en **`0.0.0-alpha.51`** et JEI en **`30.27.0.187`**. Le Safe Block forme désormais un mur vertical exact de 3 × 3 : ses blocs restent des cubes individuels avant validation, puis les neuf segments composent les textures connectées 48×48. Son interface affiche trois soldes centrés — rubis, saphirs et émeraudes — avec les transferts serveur ` ×64`, ` ×1`, `+ ×1` et `+ ×64`.
Lalpha.202 permet de descendre par un ascenseur de Waystones vers l’étage immédiatement inférieur encore inconnu, sans ouvrir la montée ni les trajets ordinaires aux destinations non découvertes. Le Shop et le marché noir comptent et consomment désormais les rubis et saphirs de toutes les rangées débloquées, y compris loverflow. Le livre culinaire peut enfin être posé sur un pupitre vanilla pour partager le catalogue de recettes de son propriétaire ; retiré du pupitre, il redevient personnel.
Le coffre-fort joueur utilise neuf blocs Another World disposés en mur vertical de 3 × 3. Il conserve les rubis, saphirs et émeraudes dans un compte lié au joueur et les rend disponibles aux transactions du Shop même lorsque la structure est déchargée. Une foreuse en titane maintenue pendant 60 secondes permet de piller 20 % du contenu dun propriétaire connecté, qui reçoit immédiatement une alerte ; une réussite consomme la foreuse et protège ensuite le coffre pendant 24 heures.
Le coffre-fort joueur utilise 26 blocs Another World disposés en coque creuse de 3 × 3 × 3 autour dun centre libre. Il conserve les rubis, saphirs et émeraudes dans un compte lié au joueur et les rend disponibles aux transactions du Shop même lorsque la structure est déchargée. Une foreuse en titane maintenue pendant 60 secondes permet de piller 20 % du contenu dun propriétaire connecté, qui reçoit immédiatement une alerte ; une réussite consomme la foreuse et protège ensuite le coffre pendant 24 heures.
Lalpha.201 rend les demandes communautaires réellement terminables. Le demandeur dépose la récompense lors de la publication, le preneur confirme sans commande après avoir lu les détails, KILL progresse automatiquement, BRING livre les objets au demandeur et PAY est confirmé par le joueur désigné. La récompense séquestrée est ensuite expédiée une seule fois dans la Mailbox du preneur. Le pseudonyme conserve la couleur du joueur et le panneau rose adopte une palette plus claire et poudrée.
@@ -88,6 +116,8 @@ Lalpha.173 centralise laudio du Miner : une seule boucle de frappe sur le
Lalpha.171 rassemble les **46 capes** dans un onglet créatif dédié « Capes ». Elles ne sont plus mélangées à longlet vanilla Tools & Utilities et ne sont donc affichées quune seule fois.
Lalpha.226 ajoute une 47e cape, `sanctuary:white_cape`, distincte de la collection Only Fun. Elle se fabrique avec six laines blanches en deux colonnes de trois, puis accepte une bannière dans une recette simple pour en conserver la couleur et jusqu’à six motifs ; les motifs saffichent dans linventaire comme une fois la cape équipée.
Lalpha.170 ajoute les pictogrammes fournis aux emplacements vides de la cape et du familier. Ils disparaissent dès quune cape ou un œuf est équipé, sans modifier les objets, la persistance ni le protocole.
Lalpha.169 agrandit la collection de 13 à **46 capes équipables**. Les 33 nouvelles textures 64×32 fournies sont conservées telles quelles, avec une icône dinventaire, un modèle et des traductions FR/EN/RU pour chaque cape. Elles utilisent le slot, la persistance et le rendu multijoueur déjà existants, restent sans recette et rejoignent toutes la lootbox de capes Only Fun. Les caméras Canon restent accessibles dans la lootbox normale et peuvent désormais aussi apparaître directement, très rarement, dans les coffres dexploration et du donjon de cobblestone.
+36
View File
@@ -0,0 +1,36 @@
# Textures historiques Golden Days dans Alpha
Ambiance peut charger les packs officiels Golden Days uniquement pendant que
le client se trouve dans `sanctuary:alpha`. Aucun fichier, texture ou dérivé de
Golden Days n'est distribué dans les JAR ou dans le `.mrpack` Sanctuary.
## Installation cliente facultative
1. Télécharger [Golden Days Base](https://www.curseforge.com/minecraft/texture-packs/golden-days)
depuis sa page officielle.
2. Télécharger [Golden Days Alpha](https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha)
depuis sa page officielle.
3. Placer les deux ZIP sans les extraire dans le dossier `resourcepacks/` du
client. Les noms doivent conserver les marqueurs `golden-days-base` et
`golden-days-alpha` ; les espaces et `_` sont aussi reconnus.
4. Laisser les deux packs désactivés dans l'écran vanilla. Ambiance les compose
temporairement dans l'ordre **Base**, puis **Alpha**.
À l'entrée dans Alpha, Ambiance capture la pile sélectionnée exacte, ajoute les
deux packs en mémoire et recharge les ressources. À la sortie ou à la
déconnexion, la pile capturée est restaurée exactement. Le fichier d'options
vanilla n'est jamais modifié : après une interruption ou un crash pendant un
rechargement, le prochain démarrage retrouve donc la pile enregistrée.
Si un des deux packs manque ou si un rechargement échoue, le client reste
jouable avec les textures vanilla et affiche un message lisible. Un serveur
dédié n'a besoin d'aucun ZIP.
## Licence et redistribution
Golden Days est publié sous **All Rights Reserved**. Sa
[licence officielle](https://poeticrainbow.github.io/golden-days/license/)
interdit notamment de redistribuer ou de reconditionner les ressources sans
autorisation. Sanctuary ne fournit que l'intégration locale ci-dessus et les
liens officiels. Les archives de développement restent hors du dépôt et de
tous les artefacts distribués.
+13
View File
@@ -0,0 +1,13 @@
# Third-party notices
## Golden Days Base and Golden Days Alpha
Optional client resource packs by PoeticRainbow. No Golden Days asset is
included in Ambiance or Sanctuary.
- Base project: https://www.curseforge.com/minecraft/texture-packs/golden-days
- Alpha project: https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha
- License: https://poeticrainbow.github.io/golden-days/license/
Golden Days is All Rights Reserved. Players obtain the two archives separately
from the official project pages for local use.
+37
View File
@@ -0,0 +1,37 @@
# Disque blanc YouTube
Le disque blanc appartient à **Ambiance**, propriétaire de laudio positionnel. Il conserve lID
`ambiance:white_disc` et se fabrique avec un disque 13 entouré de huit colorants blancs. Pour le
lire, il faut le renommer dans une enclume avec une URL YouTube HTTPS puis linsérer dans un
jukebox vanilla.
## Contrat réseau et sécurité
- le serveur reste lautorité de linsertion et refuse les noms qui ne correspondent pas à une URL
HTTPS exacte de `youtube.com`, `www.youtube.com`, `m.youtube.com`, `music.youtube.com` ou
`youtu.be` ;
- les formats `watch`, `shorts`, `embed`, `live` et le lien court sont acceptés, mais lidentifiant
doit contenir exactement onze caractères ASCII autorisés ;
- le payload S2C ne contient que la position du jukebox et cet identifiant borné, jamais une URL
libre ; il est envoyé uniquement aux clients qui suivent le chunk et annoncent le canal ;
- chaque client ouvre lui-même la source YouTube, refuse les directs et les vidéos de plus de
trente minutes, et limite les lectures simultanées à quatre ;
- la source AAC stéréo est décodée à 48 kHz (48 000 Hz) puis convertie explicitement en PCM mono 16 bits
little-endian, échantillon gauche/droite par échantillon gauche/droite, afin de conserver la
vitesse et la hauteur originales tout en restant positionnelle ;
- OpenAL lit ce PCM à sa hauteur native `1.0` sur le canal `RECORDS`, avec une atténuation à
64 blocs. La lecture sarrête lorsque le disque est éjecté, le chunk disparaît ou la session se
ferme.
Le disque ne crée aucune donnée persistante et ne demande aucune migration de sauvegarde.
Ambiance conserve ses données v2 ; son protocole passe de 5 à 6.
## Dépendances clientes embarquées
Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et sur le commit youtube-source
`f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3` (MIT), épinglé pour intégrer les correctifs de lecture
YouTube publiés après `1.18.2`. Le profil iOS corrigé est utilisé sans compte, OAuth, jeton privé ou
service de chiffrement tiers. Les dépendances sont placées en JAR imbriqués par Loom et leurs avis
de licence restent dans les archives dorigine. Laccès YouTube est effectué uniquement par le
client et peut cesser de fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou
URL arbitraire nest acceptée par le serveur.
+203 -3
View File
@@ -1,8 +1,59 @@
import groovy.json.JsonSlurper
def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"
configurations {
whiteDiscLibraries
implementation.extendsFrom whiteDiscLibraries
include.extendsFrom whiteDiscLibraries
}
dependencies {
implementation project(':sanctuary')
compileOnly "maven.modrinth:YL57xq9U:oaD6KQls"
whiteDiscLibraries("dev.arbjerg:lavaplayer:2.2.7") {
exclude group: "org.slf4j", module: "slf4j-api"
exclude group: "org.jetbrains", module: "annotations"
}
whiteDiscLibraries("dev.lavalink.youtube:v2:${youtubeSourceVersion}") {
exclude group: "org.slf4j", module: "slf4j-api"
exclude group: "org.jetbrains", module: "annotations"
}
[
"dev.arbjerg:lavaplayer-natives:2.2.7",
"dev.arbjerg:lava-common:2.2.7",
"dev.lavalink.youtube:common:${youtubeSourceVersion}",
"org.mozilla:rhino-engine:1.7.15",
"org.mozilla:rhino:1.7.15",
"commons-io:commons-io:2.13.0",
"org.jsoup:jsoup:1.16.1",
"net.iharder:base64:2.3.9",
"org.json:json:20240303",
"org.apache.httpcomponents:httpclient:4.5.14",
"org.apache.httpcomponents:httpcore:4.4.16",
"commons-logging:commons-logging:1.2",
"commons-codec:commons-codec:1.11",
"com.fasterxml.jackson.core:jackson-core:2.15.2",
"com.fasterxml.jackson.core:jackson-databind:2.15.2",
"com.fasterxml.jackson.core:jackson-annotations:2.15.2",
"com.grack:nanojson:1.7"
].each { coordinates ->
whiteDiscLibraries(coordinates) { transitive = false }
}
}
tasks.named("jar") {
from("THIRD_PARTY_NOTICES.md") {
rename { "THIRD_PARTY_NOTICES_ambiance.md" }
}
}
tasks.register("ambianceVisualApiSmoke", JavaExec) {
group = "verification"
description = "Checks bounded visual-effect requests exposed to gameplay modules."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualApiSmoke"
}
tasks.register("ambianceScheduleSmoke", JavaExec) {
@@ -13,15 +64,49 @@ tasks.register("ambianceScheduleSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.ambiance.environment.AmbianceScheduleSmoke"
}
tasks.register("whiteDiscUrlSmoke", JavaExec) {
group = "verification"
description = "Checks strict and bounded YouTube URL parsing for the white disc."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrlSmoke"
}
tasks.register("whiteDiscPlaybackSmoke", JavaExec) {
group = "verification"
description = "Manually checks live YouTube loading and PCM decoding for the white disc."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscPlaybackSmoke"
}
tasks.register("whiteDiscStreamSmoke", JavaExec) {
group = "verification"
description = "Checks continuous 48 kHz PCM buffering for Minecraft's white-disc channel."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.LavaplayerAudioStreamSmoke"
}
tasks.register("alphaTexturePackManagerSmoke", JavaExec) {
group = "verification"
description = "Checks local Golden Days detection, ordering and exact stack preservation."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.AlphaTexturePackManagerSmoke"
}
tasks.named("test") {
failOnNoDiscoveredTests = false
}
tasks.register("verifyAmbiance") {
group = "verification"
description = "Checks Ambiance alpha.12, including timed weather overrides, playable night fog and weather/lunar controls."
description = "Checks Ambiance alpha.22, including native-rate white-disc audio and Alpha textures."
inputs.files(fileTree("src/main/java"))
inputs.files(fileTree("src/main/resources"))
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java"))
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStreamSmoke.java"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.125-realtime-calendar-to-alpha.126.json"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.127-daily-ambiance-sodium-iris-to-alpha.128.json"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.128-ambiance-visuals-canon-iris-to-alpha.129.json"))
@@ -60,8 +145,23 @@ tasks.register("verifyAmbiance") {
def languages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language ->
[(language): new JsonSlurper().parse(file("src/main/resources/assets/realtime/lang/${language}.json"))]
}
def ambianceLanguages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language ->
[(language): new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${language}.json"))]
}
def whiteDiscTexture = javax.imageio.ImageIO.read(
file("src/main/resources/assets/ambiance/textures/item/white_disc.png"))
def whiteDiscRecipe = new JsonSlurper().parse(
file("src/main/resources/data/ambiance/recipe/white_disc.json"))
def whiteDiscSong = new JsonSlurper().parse(
file("src/main/resources/data/ambiance/jukebox_song/white_disc.json"))
def levelEventMixin = file(
"src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelEventHandlerMixin.java").text
def whiteDiscAudioPlayer = file(
"src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java").text
def whiteDiscPlaybackSmoke = file(
"src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java").text
if (project.version.toString() != "0.0.0-alpha.12"
if (project.version.toString() != "0.0.0-alpha.22"
|| migration.source?.pack_version != "26.2.0-alpha.125"
|| migration.target?.pack_version != "26.2.0-alpha.126"
|| migration.target?.modules?.ambiance != "0.0.0-alpha.1"
@@ -76,7 +176,7 @@ tasks.register("verifyAmbiance") {
|| migration.compatibility?.ambiance_data_version != 1
|| migration.compatibility?.ambiance_network_protocol != 2
|| manifest.custom?.sanctuary26?.data_version != 2
|| manifest.custom?.sanctuary26?.network_protocol != 4
|| manifest.custom?.sanctuary26?.network_protocol != 6
|| dailyMigration.target?.pack_version != "26.2.0-alpha.128"
|| dailyMigration.target?.modules?.ambiance != "0.0.0-alpha.2"
|| dailyMigration.lunar_schedule?.cycle != "minecraft_native_eight_days"
@@ -254,6 +354,60 @@ tasks.register("verifyAmbiance") {
|| !javaText.contains("Mth.lerp(night, 1.0F, 0.98F)")) {
throw new GradleException("Ambiance alpha.11 playable-night fog contract is incomplete")
}
if (!javaText.contains('id("white_disc_playback")')
|| !javaText.contains('readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH)')
|| !javaText.contains('PlayerLookup.tracking(level, pos)')
|| !javaText.contains('ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)')
|| !javaText.contains('MAXIMUM_SIMULTANEOUS_DISCS = 4')
|| !javaText.contains('PENDING_STARTS.size() + REQUESTS.size() + PLAYBACKS.size()')
|| !javaText.contains('MAXIMUM_START_WAIT_TICKS = 100')
|| !javaText.contains('tickPendingStarts(client)')
|| !javaText.contains('client.level.dimension().equals(playback.dimension())')
|| !javaText.contains('MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L')
|| !javaText.contains('new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,')
|| !javaText.contains('new YoutubeAudioSourceManager(false, new Ios())')
|| !javaText.contains('player.addListener(new AudioEventAdapter()')
|| !javaText.contains('failPlayback(client, key, failedPlayer)')
|| !javaText.contains('initialWaitAttempted')
|| !javaText.contains('CONTINUATION_FRAME_WAIT_MILLIS = 250L')
|| !javaText.contains('player.provide(waitMillis, TimeUnit.MILLISECONDS)')
|| !whiteDiscAudioPlayer.contains('PLAYBACK_PITCH = 1.0F')
|| whiteDiscAudioPlayer.count('channel.setPitch(PLAYBACK_PITCH)') != 2
|| !javaText.contains('SOURCE_CHANNELS = 2')
|| !javaText.contains('downmixStereoFrame(frame.getData())')
|| !javaText.contains('short mixed = (short) (((int) left + right) / SOURCE_CHANNELS)')
|| !whiteDiscAudioPlayer.contains('handle.execute(channel -> channel.stop())')
|| whiteDiscAudioPlayer.contains('.release()')
|| !whiteDiscPlaybackSmoke.contains('https://www.youtube.com/watch?v=Ue5ZBe-GzSM')
|| !whiteDiscPlaybackSmoke.contains('PCM cadence: 100%% speed')
|| !whiteDiscPlaybackSmoke.contains('stream.decodedTimelineMillis() != 4_000L')
|| !whiteDiscPlaybackSmoke.contains('stream.paddedSilenceBytes() != 0L')
|| !javaText.contains('SoundSource.RECORDS')
|| !javaText.contains('channel.linearAttenuation(ATTENUATION_DISTANCE)')
|| !javaText.contains('LONG_HOSTS = Set.of(')
|| !javaText.contains('"https".equalsIgnoreCase(uri.getScheme())')
|| !file("src/main/resources/assets/ambiance/items/white_disc.json").isFile()
|| !file("src/main/resources/assets/ambiance/models/item/white_disc.json").isFile()
|| whiteDiscTexture == null || whiteDiscTexture.width != 16 || whiteDiscTexture.height != 16
|| whiteDiscRecipe.result?.id != "ambiance:white_disc"
|| whiteDiscRecipe.key?.W != "minecraft:white_dye"
|| whiteDiscSong.sound_event != "minecraft:intentionally_empty"
|| whiteDiscSong.length_in_seconds != 1800
|| !levelEventMixin.contains('@Mixin(LevelEventHandler.class)')
|| !levelEventMixin.contains('@Inject(method = "levelEvent"')
|| !levelEventMixin.contains('ambiance$stopWhiteDisc(int type, BlockPos pos, int data,')
|| ambianceLanguages.any { locale, translations ->
!["item.ambiance.white_disc", "tooltip.ambiance.white_disc",
"message.ambiance.white_disc.invalid_url", "message.ambiance.white_disc.failed"].every {
translations.containsKey(it)
}
}
|| !file("build.gradle").text.contains('dev.arbjerg:lavaplayer:2.2.7')
|| !file("build.gradle").text.contains(
'def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"')
|| !file("build.gradle").text.contains('dev.lavalink.youtube:v2:${youtubeSourceVersion}')) {
throw new GradleException("Ambiance white-disc playback contract is incomplete")
}
[
'class RealtimeMod', 'class RealtimeConfig', 'class MinecraftChronology',
@@ -301,7 +455,53 @@ tasks.register("verifyAmbiance") {
}
}
tasks.register("verifyAlphaTextures") {
group = "verification"
description = "Checks the alpha.21 local-only Golden Days overlay and exact restore contract."
inputs.file(file("GOLDEN_DAYS.md"))
inputs.file(file("THIRD_PARTY_NOTICES.md"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AlphaTexturePackManager.java"))
inputs.files(fileTree("src/main/resources/assets/ambiance/lang") { include "*.json" })
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
doLast {
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
def manager = file(
"src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AlphaTexturePackManager.java").text
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${it}.json"))
}
def forbiddenArchives = rootProject.fileTree(rootProject.projectDir) {
include "**/*golden*days*.zip", "**/*golden*days*.jar"
exclude "**/build/**", "**/.gradle/**", "**/.git/**"
}.files
if (project.version.toString() != "0.0.0-alpha.22"
|| migration.alpha_textures?.destination != "sanctuary:alpha"
|| migration.alpha_textures?.client_only != true
|| migration.alpha_textures?.bundled_assets != false
|| migration.alpha_textures?.overlay_order != ["base", "alpha"]
|| migration.alpha_textures?.previous_selection_restored_exactly != true
|| migration.alpha_textures?.saved_options_modified != false
|| migration.alpha_textures?.dedicated_server_files_required != false
|| !manager.contains("SanctuaryDimensions.ALPHA")
|| !manager.contains("previousSelection = List.copyOf")
|| !manager.contains("selectionWithOverlay(previousSelection")
|| manager.contains("updateResourcePacks")
|| locales.any { !it.containsKey("message.ambiance.alpha_textures_missing")
|| !it.containsKey("message.ambiance.alpha_textures_restore_failed") }
|| !forbiddenArchives.isEmpty()
|| !file("THIRD_PARTY_NOTICES.md").text.contains("No Golden Days asset")) {
throw new GradleException("Ambiance alpha.21 Golden Days overlay contract is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("verifyAmbiance")
dependsOn tasks.named("ambianceScheduleSmoke")
dependsOn tasks.named("ambianceVisualApiSmoke")
dependsOn tasks.named("whiteDiscUrlSmoke")
dependsOn tasks.named("whiteDiscStreamSmoke")
dependsOn tasks.named("alphaTexturePackManagerSmoke")
dependsOn tasks.named("verifyAlphaTextures")
}
@@ -7,6 +7,7 @@ import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceBloodNightAssault;
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceWeatherCommands;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceNetworking;
import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries;
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscService;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
import org.slf4j.Logger;
@@ -29,6 +30,7 @@ public final class AmbianceMod implements ModInitializer {
public void onInitialize() {
AmbianceRegistries.initialize();
AmbianceNetworking.initialize();
WhiteDiscService.initialize();
RealtimeMod.initialize();
AmbianceEnvironmentController.initialize();
AmbianceFirstSpawnVision.initialize();
@@ -0,0 +1,32 @@
package fr.koka99cab.sanctuary26.ambiance.api.environment;
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceEnvironmentController;
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceEnvironmentSnapshot;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
/** Server-owned sky/calendar snapshot for dependent harvest rules. */
public final class AmbianceLunarHarvestApi {
public static final int CONTRACT_VERSION = 1;
public record Conditions(
boolean night,
boolean skyVisible,
boolean skyDimension,
AmbianceEnvironmentSnapshot environment
) {
public boolean compatible() {
return night && skyVisible && skyDimension;
}
}
private AmbianceLunarHarvestApi() {}
public static Conditions evaluate(ServerLevel level, BlockPos pos) {
long clock = Math.floorMod(level.getDefaultClockTime(), 24_000L);
boolean night = clock >= 13_000L && clock <= 23_000L;
boolean skyDimension = level.dimensionType().hasSkyLight() && !level.dimensionType().hasFixedTime();
return new Conditions(night, skyDimension && level.canSeeSky(pos.above()), skyDimension,
AmbianceEnvironmentController.snapshot(level.getServer()));
}
}
@@ -0,0 +1,38 @@
package fr.koka99cab.sanctuary26.ambiance.api.visual;
import java.util.Objects;
import net.minecraft.resources.Identifier;
/**
* Public boundary used by gameplay modules that ask Ambiance to compose a
* temporary visual treatment. Networking and rendering remain Ambiance-owned.
*/
public final class AmbianceVisualEffectApi {
public static final int CONTRACT_VERSION = 1;
public static final int MAX_DURATION_TICKS = 10 * 60 * 20;
private AmbianceVisualEffectApi() {
}
public static Request request(Identifier source, AmbianceVisualProfile profile, int remainingTicks) {
return new Request(source, profile, remainingTicks);
}
public record Request(Identifier source, AmbianceVisualProfile profile, int remainingTicks) {
public Request {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(profile, "profile");
remainingTicks = Math.max(0, Math.min(MAX_DURATION_TICKS, remainingTicks));
}
public boolean expired() {
return this.remainingTicks == 0;
}
public Request withReducedMotion(boolean reduceMotion) {
return reduceMotion
? new Request(this.source, this.profile.withoutMotion(), this.remainingTicks)
: this;
}
}
}
@@ -0,0 +1,81 @@
package fr.koka99cab.sanctuary26.ambiance.api.visual;
/**
* Renderer-independent color and motion parameters composed by Ambiance.
*
* <p>Every value is finite and bounded at construction time so gameplay
* modules cannot send an invasive or unbounded profile to the client.</p>
*/
public record AmbianceVisualProfile(
float intensity,
float tintRed,
float tintGreen,
float tintBlue,
float tintStrength,
float saturationDelta,
float contrastDelta,
float vignetteStrength,
float ghostingStrength,
float wobbleStrength,
float pulseStrength
) {
public static final AmbianceVisualProfile NONE = new AmbianceVisualProfile(
0.0F,
1.0F,
1.0F,
1.0F,
0.0F,
0.0F,
0.0F,
0.0F,
0.0F,
0.0F,
0.0F
);
public AmbianceVisualProfile {
intensity = unit("intensity", intensity);
tintRed = unit("tintRed", tintRed);
tintGreen = unit("tintGreen", tintGreen);
tintBlue = unit("tintBlue", tintBlue);
tintStrength = unit("tintStrength", tintStrength);
saturationDelta = signedUnit("saturationDelta", saturationDelta);
contrastDelta = signedUnit("contrastDelta", contrastDelta);
vignetteStrength = unit("vignetteStrength", vignetteStrength);
ghostingStrength = unit("ghostingStrength", ghostingStrength);
wobbleStrength = unit("wobbleStrength", wobbleStrength);
pulseStrength = unit("pulseStrength", pulseStrength);
}
/** Removes image motion while preserving the bounded color treatment. */
public AmbianceVisualProfile withoutMotion() {
return new AmbianceVisualProfile(
this.intensity,
this.tintRed,
this.tintGreen,
this.tintBlue,
this.tintStrength,
this.saturationDelta,
this.contrastDelta,
this.vignetteStrength,
0.0F,
0.0F,
0.0F
);
}
private static float unit(String field, float value) {
return bounded(field, value, 0.0F, 1.0F);
}
private static float signedUnit(String field, float value) {
return bounded(field, value, -1.0F, 1.0F);
}
private static float bounded(String field, float value, float minimum, float maximum) {
if (!Float.isFinite(value)) {
throw new IllegalArgumentException(field + " must be finite");
}
return Math.max(minimum, Math.min(maximum, value));
}
}
@@ -0,0 +1,192 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
import net.minecraft.server.packs.repository.PackRepository;
/**
* Applies locally installed Golden Days packs only while the player is in
* {@code sanctuary:alpha}. The selected stack is changed in memory only; the
* player's saved resource-pack options remain untouched.
*/
public final class AlphaTexturePackManager {
static final String BASE_MARKER = "golden-days-base";
static final String ALPHA_MARKER = "golden-days-alpha";
private static final int MAX_RESTORE_RETRIES = 1;
private static boolean requestKnown;
private static boolean alphaRequested;
private static boolean reloadInProgress;
private static boolean failedForCurrentRequest;
private static boolean missingMessageShown;
private static boolean reconcilePending = true;
private static int restoreFailures;
private static List<String> previousSelection = List.of();
private static boolean previousSelectionCaptured;
private AlphaTexturePackManager() {
}
public static void register() {
ClientTickEvents.END_CLIENT_TICK.register(AlphaTexturePackManager::tick);
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> request(client, false));
}
private static void tick(Minecraft client) {
boolean shouldUseAlphaTextures = client.level != null
&& client.level.dimension().equals(SanctuaryDimensions.ALPHA);
request(client, shouldUseAlphaTextures);
if (!reloadInProgress && reconcilePending) {
reconcilePending = false;
reconcile(client);
}
}
private static void request(Minecraft client, boolean requested) {
if (requestKnown && requested == alphaRequested) return;
alphaRequested = requested;
requestKnown = true;
failedForCurrentRequest = false;
missingMessageShown = false;
restoreFailures = 0;
if (requested && !previousSelectionCaptured) {
PackRepository repository = client.getResourcePackRepository();
repository.reload();
previousSelection = List.copyOf(repository.getSelectedIds());
previousSelectionCaptured = true;
}
reconcilePending = true;
}
private static void reconcile(Minecraft client) {
PackRepository repository = client.getResourcePackRepository();
repository.reload();
List<String> selected = List.copyOf(repository.getSelectedIds());
if (alphaRequested && !failedForCurrentRequest) {
Optional<PackPair> packs = findPacks(repository.getAvailableIds());
if (packs.isEmpty()) {
failedForCurrentRequest = true;
showMissingPackMessage(client);
applyIfDifferent(client, selected, previousSelection, false);
return;
}
List<String> desired = selectionWithOverlay(previousSelection, packs.orElseThrow());
applyIfDifferent(client, selected, desired, true);
return;
}
if (!previousSelectionCaptured) return;
applyIfDifferent(client, selected, previousSelection, false);
}
private static void applyIfDifferent(Minecraft client, List<String> selected,
List<String> desired, boolean enablingAlpha) {
if (selected.equals(desired)) {
if (!enablingAlpha && !alphaRequested) clearSnapshot();
return;
}
client.getResourcePackRepository().setSelected(desired);
reloadInProgress = true;
AmbianceMod.LOGGER.info("[{}] Reloading client resources for Alpha texture mode: {}.",
AmbianceMod.DISPLAY_NAME, enablingAlpha ? "enabled" : "disabled");
client.reloadResourcePacks().whenComplete((ignored, error) -> client.execute(() -> {
reloadInProgress = false;
if (error != null) {
AmbianceMod.LOGGER.error("[{}] Could not {} the Golden Days Alpha overlay.",
AmbianceMod.DISPLAY_NAME, enablingAlpha ? "enable" : "restore", error);
if (enablingAlpha) {
failedForCurrentRequest = true;
reconcilePending = true;
} else if (++restoreFailures <= MAX_RESTORE_RETRIES) {
reconcilePending = true;
} else {
client.getResourcePackRepository().setSelected(previousSelection);
showRestoreFailureMessage(client);
}
return;
}
if (!enablingAlpha && !alphaRequested) clearSnapshot();
if (enablingAlpha != alphaRequested) reconcilePending = true;
}));
}
static Optional<PackPair> findPacks(Collection<String> availableIds) {
Optional<String> base = findPack(availableIds, BASE_MARKER);
Optional<String> alpha = findPack(availableIds, ALPHA_MARKER);
return base.isPresent() && alpha.isPresent()
? Optional.of(new PackPair(base.orElseThrow(), alpha.orElseThrow()))
: Optional.empty();
}
static List<String> selectionWithOverlay(Collection<String> previous, PackPair packs) {
List<String> desired = selectedWithoutGoldenDays(previous);
// Pack order is bottom-to-top: Base first, then its Alpha overlay.
desired.add(packs.base());
desired.add(packs.alpha());
return List.copyOf(desired);
}
private static List<String> selectedWithoutGoldenDays(Collection<String> selectedIds) {
List<String> result = new ArrayList<>(selectedIds.size());
for (String id : selectedIds) {
if (!isGoldenDaysPack(id)) result.add(id);
}
return result;
}
private static Optional<String> findPack(Collection<String> availableIds, String marker) {
return availableIds.stream()
.filter(id -> normalized(id).contains(marker))
.sorted(Comparator.naturalOrder())
.findFirst();
}
static boolean isGoldenDaysPack(String id) {
String normalized = normalized(id);
return normalized.contains(BASE_MARKER) || normalized.contains(ALPHA_MARKER);
}
private static String normalized(String id) {
return id.toLowerCase(Locale.ROOT).replace('_', '-').replace(' ', '-');
}
private static void clearSnapshot() {
previousSelection = List.of();
previousSelectionCaptured = false;
restoreFailures = 0;
}
private static void showMissingPackMessage(Minecraft client) {
if (!missingMessageShown && client.player != null) {
client.gui.hud.getChat().addClientSystemMessage(
Component.translatable("message.ambiance.alpha_textures_missing"));
missingMessageShown = true;
}
}
private static void showRestoreFailureMessage(Minecraft client) {
if (client.player != null) {
client.gui.hud.getChat().addClientSystemMessage(
Component.translatable("message.ambiance.alpha_textures_restore_failed"));
}
}
record PackPair(String base, String alpha) {
PackPair {
if (base == null || alpha == null || base.equals(alpha)) {
throw new IllegalArgumentException("Golden Days Base and Alpha packs must be distinct");
}
}
}
}
@@ -1,34 +1,71 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import fr.koka99cab.realtime.client.RealtimeClient;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceEnvironmentPayload;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload;
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.fabricmc.fabric.api.client.render.fluid.v1.FluidRenderingRegistry;
import net.fabricmc.fabric.api.client.rendering.v1.BlockColorRegistry;
import net.minecraft.client.renderer.block.FluidModel;
import net.minecraft.client.KeyMapping;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.resources.model.sprite.Material;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.material.Fluids;
import java.util.List;
import org.lwjgl.glfw.GLFW;
public final class AmbianceClient implements ClientModInitializer {
private static final KeyMapping.Category AMBIANCE_CATEGORY =
KeyMapping.Category.register(AmbianceMod.id("ambiance"));
private static KeyMapping visualSettingsKey;
@Override
public void onInitializeClient() {
RealtimeClient.initialize();
AlphaTexturePackManager.register();
CelestialSkyRenderer.register();
ClientTickEvents.END_CLIENT_TICK.register(DynamicTorchLights::tick);
ClientPlayNetworking.registerGlobalReceiver(AmbianceEnvironmentPayload.TYPE, (payload, context) ->
context.client().execute(() -> AmbianceClientEnvironment.apply(payload.snapshot()))
);
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.Normal.ID, (payload, context) ->
context.client().execute(() -> AmbianceClientVisualEffects.apply(payload)));
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.BadTrip.ID, (payload, context) ->
context.client().execute(() -> AmbianceClientVisualEffects.apply(payload)));
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.Clear.ID, (payload, context) ->
context.client().execute(AmbianceClientVisualEffects::clear));
ClientPlayNetworking.registerGlobalReceiver(WhiteDiscPlaybackPayload.ID, (payload, context) ->
context.client().execute(() -> WhiteDiscAudioPlayer.play(
context.client(), payload.pos(), payload.videoId())));
visualSettingsKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.ambiance.visual_settings", InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_UNKNOWN, AMBIANCE_CATEGORY));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
AmbianceClientVisualEffects.tick();
WhiteDiscAudioPlayer.tick(client);
while (visualSettingsKey.consumeClick()) {
if (client.gui.screen() == null) client.gui.setScreen(new AmbianceVisualSettingsScreen(null));
}
});
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> {
AmbianceClientEnvironment.reset();
DynamicTorchLights.reset();
AmbianceClientVisualEffects.clear();
WhiteDiscAudioPlayer.clear();
});
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> WhiteDiscAudioPlayer.shutdown());
AmbianceVisualOverlay.register();
AmbianceBiomeTints.registerColorResolvers();
registerWaterFluidTint();
BlockColorRegistry.register(List.of(AmbianceBiomeTints.GRASS),
@@ -0,0 +1,74 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
/** Single normal plus single bad-trip layer, composed and expired by Ambiance. */
public final class AmbianceClientVisualEffects {
public enum Mode { NONE, NORMAL, BAD_TRIP }
private static AmbianceVisualAccessibility accessibility = AmbianceVisualAccessibility.load();
private static AmbianceVisualProfile normal = AmbianceVisualProfile.NONE;
private static AmbianceVisualProfile badTrip = AmbianceVisualProfile.NONE;
private static int normalTicks;
private static int badTripTicks;
private AmbianceClientVisualEffects() {}
public static void apply(AmbianceCanapliaVisualPayloads.Normal payload) {
if (payload.protocol() != AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION) return;
normal = payload.profile();
normalTicks = payload.remainingTicks();
}
public static void apply(AmbianceCanapliaVisualPayloads.BadTrip payload) {
if (payload.protocol() != AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION) return;
badTrip = payload.profile();
badTripTicks = payload.remainingTicks();
}
public static void clear() {
normal = AmbianceVisualProfile.NONE;
badTrip = AmbianceVisualProfile.NONE;
normalTicks = 0;
badTripTicks = 0;
}
public static void tick() {
if (normalTicks > 0 && --normalTicks == 0) normal = AmbianceVisualProfile.NONE;
if (badTripTicks > 0 && --badTripTicks == 0) badTrip = AmbianceVisualProfile.NONE;
}
public static Mode mode() {
return badTripTicks > 0 ? Mode.BAD_TRIP : normalTicks > 0 ? Mode.NORMAL : Mode.NONE;
}
public static AmbianceVisualProfile current() {
return accessible(mode() == Mode.BAD_TRIP ? badTrip
: mode() == Mode.NORMAL ? normal : AmbianceVisualProfile.NONE);
}
public static AmbianceVisualAccessibility accessibility() {
return accessibility;
}
public static void updateAccessibility(AmbianceVisualAccessibility updated) {
accessibility = updated == null ? AmbianceVisualAccessibility.defaults() : updated;
accessibility.save();
}
private static AmbianceVisualProfile accessible(AmbianceVisualProfile profile) {
float scale = accessibility.intensity();
boolean noMotion = accessibility.reduceMotion();
return new AmbianceVisualProfile(
profile.intensity() * scale,
profile.tintRed(), profile.tintGreen(), profile.tintBlue(),
profile.tintStrength() * scale,
profile.saturationDelta() * scale,
profile.contrastDelta() * scale,
profile.vignetteStrength() * scale,
noMotion || !accessibility.ghostingEnabled() ? 0.0F : profile.ghostingStrength() * scale,
noMotion || !accessibility.wobbleEnabled() ? 0.0F : profile.wobbleStrength() * scale,
noMotion ? 0.0F : profile.pulseStrength() * scale
);
}
}
@@ -54,6 +54,7 @@ public final class AmbianceEffects {
Vector4f color = fogData.color;
// Keep the deeper beta-like night fog without turning the horizon into a black wall.
setAdjustedColor(color, 1.18F, Mth.lerp(night, 1.0F, 0.98F));
applyTemporaryProfile(color);
}
public static void adjustLightmap(LightmapRenderState state, float tickDelta) {
@@ -75,6 +76,8 @@ public final class AmbianceEffects {
state.blockLightTint = warmBlockLight(state.blockLightTint, night);
state.blockFactor *= Mth.lerp(night, 1.0F, 1.08F);
state.brightness = Math.min(state.brightness, Mth.lerp(night, 0.58F, 0.16F));
state.ambientColor = applyTemporaryProfile(state.ambientColor);
state.skyLightColor = applyTemporaryProfile(state.skyLightColor);
}
private static void applyWhiteNightLight(LightmapRenderState state, float night) {
@@ -128,4 +131,23 @@ public final class AmbianceEffects {
Mth.lerp(warmth, saturated.z(), 0.56F)
);
}
private static void applyTemporaryProfile(Vector4f color) {
Vector3f adjusted = applyTemporaryProfile(new Vector3f(color.x(), color.y(), color.z()));
color.set(adjusted.x(), adjusted.y(), adjusted.z(), color.w());
}
private static Vector3f applyTemporaryProfile(Vector3fc source) {
var profile = AmbianceClientVisualEffects.current();
float intensity = profile.intensity();
if (intensity <= 0.0F) return new Vector3f(source);
Vector3f graded = adjustedColor(source, 1.0F + profile.saturationDelta() * intensity,
1.0F + profile.contrastDelta() * intensity * 0.35F);
float tint = profile.tintStrength() * intensity * 0.28F;
return new Vector3f(
Mth.lerp(tint, graded.x(), profile.tintRed()),
Mth.lerp(tint, graded.y(), profile.tintGreen()),
Mth.lerp(tint, graded.z(), profile.tintBlue())
);
}
}
@@ -0,0 +1,58 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import net.fabricmc.loader.api.FabricLoader;
/** Local presentation-only settings; they can never affect server gameplay state. */
public record AmbianceVisualAccessibility(float intensity, boolean reduceMotion,
boolean wobbleEnabled, boolean ghostingEnabled) {
private static final Gson GSON = new Gson();
public AmbianceVisualAccessibility {
intensity = Float.isFinite(intensity) ? Math.clamp(intensity, 0.0F, 1.0F) : 1.0F;
}
public static AmbianceVisualAccessibility load() {
try {
Path file = configPath();
if (!Files.isRegularFile(file)) return defaults();
JsonObject json = GSON.fromJson(Files.readString(file), JsonObject.class);
if (json == null) return defaults();
return new AmbianceVisualAccessibility(
json.has("intensity") ? json.get("intensity").getAsFloat() : 1.0F,
json.has("reduce_motion") && json.get("reduce_motion").getAsBoolean(),
!json.has("wobble") || json.get("wobble").getAsBoolean(),
!json.has("ghosting") || json.get("ghosting").getAsBoolean());
} catch (IOException | RuntimeException ignored) {
return defaults();
}
}
public void save() {
JsonObject json = new JsonObject();
json.addProperty("intensity", intensity);
json.addProperty("reduce_motion", reduceMotion);
json.addProperty("wobble", wobbleEnabled);
json.addProperty("ghosting", ghostingEnabled);
try {
Path file = configPath();
Files.createDirectories(file.getParent());
Files.writeString(file, GSON.toJson(json) + System.lineSeparator());
} catch (IOException | RuntimeException exception) {
AmbianceMod.LOGGER.warn("[Ambiance] Could not save visual accessibility settings.", exception);
}
}
public static AmbianceVisualAccessibility defaults() {
return new AmbianceVisualAccessibility(1.0F, false, true, true);
}
private static Path configPath() {
return FabricLoader.getInstance().getConfigDir().resolve("ambiance-visual.json");
}
}
@@ -0,0 +1,44 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.gui.GuiGraphicsExtractor;
/** Lightweight final-frame tint/vignette; no competing post-processing owner is introduced. */
public final class AmbianceVisualOverlay {
private AmbianceVisualOverlay() {}
public static void register() {
HudElementRegistry.addLast(AmbianceMod.id("temporary_visual_effects"), AmbianceVisualOverlay::render);
}
private static void render(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker) {
AmbianceVisualProfile profile = AmbianceClientVisualEffects.current();
if (profile.intensity() <= 0.0F) return;
int width = graphics.guiWidth();
int height = graphics.guiHeight();
int alpha = Math.clamp(Math.round(255.0F * profile.intensity() * profile.tintStrength() * 0.32F), 0, 80);
int rgb = (Math.round(profile.tintRed() * 255.0F) << 16)
| (Math.round(profile.tintGreen() * 255.0F) << 8)
| Math.round(profile.tintBlue() * 255.0F);
graphics.fill(0, 0, width, height, (alpha << 24) | rgb);
int depth = Math.max(8, Math.min(width, height) / 12);
int edgeAlpha = Math.clamp(Math.round(190.0F * profile.vignetteStrength() * profile.intensity()), 0, 150);
for (int layer = 0; layer < 4; layer++) {
int outer = layer * depth / 4;
int inner = (layer + 1) * depth / 4;
int color = ((edgeAlpha * (4 - layer) / 4) << 24) | (rgb & 0x7F7F7F);
graphics.fill(outer, outer, width - outer, inner, color);
graphics.fill(outer, height - inner, width - outer, height - outer, color);
graphics.fill(outer, inner, inner, height - inner, color);
graphics.fill(width - inner, inner, width - outer, height - inner, color);
}
if (profile.ghostingStrength() > 0.0F) {
int ghostAlpha = Math.clamp(Math.round(profile.ghostingStrength() * 45.0F), 0, 45);
graphics.fill(2, 0, 5, height, (ghostAlpha << 24) | rgb);
graphics.fill(width - 7, 0, width - 3, height, (ghostAlpha << 24) | rgb);
}
}
}
@@ -0,0 +1,72 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/** Small in-game editor for the local presentation-only Canaplia visual settings. */
public final class AmbianceVisualSettingsScreen extends Screen {
private final Screen parent;
public AmbianceVisualSettingsScreen(Screen parent) {
super(Component.translatable("options.ambiance.visual.title"));
this.parent = parent;
}
@Override
protected void init() {
AmbianceVisualAccessibility settings = AmbianceClientVisualEffects.accessibility();
int left = width / 2 - 120;
int y = height / 2 - 62;
addRenderableWidget(Button.builder(value("options.ambiance.visual.intensity",
Math.round(settings.intensity() * 100.0F) + "%"), button -> {
float next = settings.intensity() <= 0.0F ? 1.0F : settings.intensity() - 0.25F;
update(new AmbianceVisualAccessibility(next, settings.reduceMotion(),
settings.wobbleEnabled(), settings.ghostingEnabled()));
}).bounds(left, y, 240, 20).build());
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.reduce_motion", settings.reduceMotion()),
button -> update(new AmbianceVisualAccessibility(settings.intensity(), !settings.reduceMotion(),
settings.wobbleEnabled(), settings.ghostingEnabled())))
.bounds(left, y + 24, 240, 20).build());
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.wobble", settings.wobbleEnabled()),
button -> update(new AmbianceVisualAccessibility(settings.intensity(), settings.reduceMotion(),
!settings.wobbleEnabled(), settings.ghostingEnabled())))
.bounds(left, y + 48, 240, 20).build());
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.ghosting", settings.ghostingEnabled()),
button -> update(new AmbianceVisualAccessibility(settings.intensity(), settings.reduceMotion(),
settings.wobbleEnabled(), !settings.ghostingEnabled())))
.bounds(left, y + 72, 240, 20).build());
addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> onClose())
.bounds(left + 44, y + 108, 152, 20).build());
}
private void update(AmbianceVisualAccessibility settings) {
AmbianceClientVisualEffects.updateAccessibility(settings);
rebuildWidgets();
}
private static Component toggle(String key, boolean enabled) {
return value(key, Component.translatable(enabled ? "options.on" : "options.off"));
}
private static Component value(String key, String value) {
return value(key, Component.literal(value));
}
private static Component value(String key, Component value) {
return Component.translatable(key).append(": ").append(value);
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
graphics.fill(width / 2 - 142, height / 2 - 92, width / 2 + 142, height / 2 + 92, 0xE0161C19);
graphics.centeredText(font, title, width / 2, height / 2 - 82, 0xFFE4EAD9);
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
}
@Override
public void onClose() {
if (minecraft != null) minecraft.gui.setScreen(parent);
}
}
@@ -0,0 +1,145 @@
package fr.koka99cab.sanctuary26.ambiance.client.audio;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.sound.sampled.AudioFormat;
import net.minecraft.client.sounds.AudioStream;
/** Downmixes bounded stereo PCM frames from Lavaplayer for Minecraft's mono OpenAL stream. */
final class LavaplayerAudioStream implements AudioStream {
static final int SAMPLE_RATE = 48_000;
static final int SOURCE_CHANNELS = 2;
static final int FRAME_SAMPLES = 960;
private static final int BYTES_PER_SAMPLE = Short.BYTES;
private static final int SOURCE_FRAME_SIZE = SOURCE_CHANNELS * BYTES_PER_SAMPLE;
private static final long INITIAL_FRAME_WAIT_MILLIS = 2_000L;
private static final long CONTINUATION_FRAME_WAIT_MILLIS = 250L;
private static final AudioFormat FORMAT =
new AudioFormat(SAMPLE_RATE, 16, 1, true, false);
private final AudioPlayer player;
private byte[] pending = new byte[0];
private int pendingOffset;
private long decodedBytes;
private long paddedSilenceBytes;
private long firstFrameTimecode = -1L;
private long lastFrameTimecode = -1L;
private int decodedFrames;
private boolean initialWaitAttempted;
private volatile boolean closed;
private volatile boolean ended;
LavaplayerAudioStream(AudioPlayer player) {
this.player = player;
}
@Override
public AudioFormat getFormat() {
return FORMAT;
}
@Override
public ByteBuffer read(int maximumBytes) throws IOException {
if (closed || maximumBytes <= 0) return null;
ByteBuffer output = ByteBuffer.allocateDirect(maximumBytes);
while (output.hasRemaining() && !closed) {
if (pendingOffset < pending.length) {
int length = Math.min(output.remaining(), pending.length - pendingOffset);
output.put(pending, pendingOffset, length);
pendingOffset += length;
decodedBytes += length;
continue;
}
AudioFrame frame = nextFrame();
if (frame == null) {
if (ended) break;
while (output.hasRemaining()) {
output.put((byte) 0);
paddedSilenceBytes++;
}
break;
}
if (frame.isTerminator()) {
ended = true;
break;
}
if (firstFrameTimecode < 0L) firstFrameTimecode = frame.getTimecode();
lastFrameTimecode = frame.getTimecode();
decodedFrames++;
pending = downmixStereoFrame(frame.getData());
pendingOffset = 0;
}
if (output.position() == 0 && ended) return null;
output.flip();
return output;
}
private static byte[] downmixStereoFrame(byte[] stereo) throws IOException {
if (stereo.length == 0 || stereo.length % SOURCE_FRAME_SIZE != 0) {
throw new IOException("Invalid stereo PCM frame length: " + stereo.length);
}
byte[] mono = new byte[stereo.length / SOURCE_CHANNELS];
for (int sourceOffset = 0, targetOffset = 0;
sourceOffset < stereo.length;
sourceOffset += SOURCE_FRAME_SIZE, targetOffset += BYTES_PER_SAMPLE) {
short left = littleEndianSample(stereo, sourceOffset);
short right = littleEndianSample(stereo, sourceOffset + BYTES_PER_SAMPLE);
short mixed = (short) (((int) left + right) / SOURCE_CHANNELS);
mono[targetOffset] = (byte) mixed;
mono[targetOffset + 1] = (byte) (mixed >>> 8);
}
return mono;
}
private static short littleEndianSample(byte[] pcm, int offset) {
return (short) (Byte.toUnsignedInt(pcm[offset]) | pcm[offset + 1] << 8);
}
private AudioFrame nextFrame() throws IOException {
try {
long waitMillis = initialWaitAttempted
? CONTINUATION_FRAME_WAIT_MILLIS
: INITIAL_FRAME_WAIT_MILLIS;
initialWaitAttempted = true;
AudioFrame frame = player.provide(waitMillis, TimeUnit.MILLISECONDS);
if (frame == null && player.getPlayingTrack() == null) ended = true;
return frame;
} catch (TimeoutException ignored) {
return null;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while buffering the white disc", interrupted);
}
}
boolean ended() {
return ended;
}
boolean uses(AudioPlayer expectedPlayer) {
return player == expectedPlayer;
}
long decodedBytes() {
return decodedBytes;
}
long paddedSilenceBytes() {
return paddedSilenceBytes;
}
long decodedTimelineMillis() {
if (decodedFrames == 0) return 0L;
return lastFrameTimecode - firstFrameTimecode + 20L;
}
@Override
public void close() {
if (closed) return;
closed = true;
player.destroy();
}
}
@@ -0,0 +1,305 @@
package fr.koka99cab.sanctuary26.ambiance.client.audio;
import com.mojang.blaze3d.audio.Library;
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.clients.Ios;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundEngineAccessor;
import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundManagerAccessor;
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import net.minecraft.client.Minecraft;
import net.minecraft.client.sounds.ChannelAccess;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.JukeboxBlock;
import net.minecraft.world.phys.Vec3;
/** Client-only, positional and resource-bounded YouTube playback for white discs. */
public final class WhiteDiscAudioPlayer {
private static final int MAXIMUM_SIMULTANEOUS_DISCS = 4;
private static final int MAXIMUM_START_WAIT_TICKS = 100;
private static final long MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L;
private static final long FINISHED_GRACE_NANOS = 5_000_000_000L;
private static final float ATTENUATION_DISTANCE = 64.0F;
/** OpenAL must preserve the decoded music's native speed and pitch. */
static final float PLAYBACK_PITCH = 1.0F;
private static final DefaultAudioPlayerManager MANAGER = createManager();
private static final Map<Long, PendingStart> PENDING_STARTS = new HashMap<>();
private static final Map<Long, Long> REQUESTS = new HashMap<>();
private static final LinkedHashMap<Long, Playback> PLAYBACKS = new LinkedHashMap<>();
private static long requestSequence;
private static boolean shutdown;
private WhiteDiscAudioPlayer() {
}
public static void play(Minecraft client, BlockPos pos, String videoId) {
if (shutdown || !WhiteDiscUrl.validVideoId(videoId) || !validJukeboxTarget(client, pos)) return;
long key = pos.asLong();
if (PENDING_STARTS.containsKey(key) || REQUESTS.containsKey(key)) return;
boolean replacingPlayback = PLAYBACKS.containsKey(key);
if (!replacingPlayback
&& PENDING_STARTS.size() + REQUESTS.size() + PLAYBACKS.size()
>= MAXIMUM_SIMULTANEOUS_DISCS) return;
stop(key);
client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading"));
if (!validTarget(client, pos)) {
PENDING_STARTS.put(key, new PendingStart(pos.immutable(), client.level.dimension(), videoId,
MAXIMUM_START_WAIT_TICKS));
return;
}
startLoading(client, pos, videoId);
}
private static void startLoading(Minecraft client, BlockPos pos, String videoId) {
long key = pos.asLong();
long request = ++requestSequence;
REQUESTS.put(key, request);
MANAGER.loadItemOrdered(key, WhiteDiscUrl.canonicalUrl(videoId), new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
client.execute(() -> acceptTrack(client, pos, request, track));
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
AudioTrack selected = playlist.getSelectedTrack();
if (selected == null && !playlist.getTracks().isEmpty()) selected = playlist.getTracks().getFirst();
if (selected == null) noMatches();
else trackLoaded(selected);
}
@Override
public void noMatches() {
client.execute(() -> fail(client, key, request));
}
@Override
public void loadFailed(FriendlyException exception) {
AmbianceMod.LOGGER.warn("Unable to load white-disc YouTube video {}: {}", videoId,
exception.getMessage());
client.execute(() -> fail(client, key, request));
}
});
}
private static void acceptTrack(Minecraft client, BlockPos pos, long request, AudioTrack track) {
long key = pos.asLong();
if (REQUESTS.getOrDefault(key, -1L) != request) return;
if (!validTarget(client, pos)) {
REQUESTS.remove(key);
return;
}
if (track.getInfo().isStream || track.getDuration() <= 0L
|| track.getDuration() > MAXIMUM_TRACK_MILLIS) {
REQUESTS.remove(key);
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.too_long"));
return;
}
while (PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) {
stop(PLAYBACKS.keySet().iterator().next());
}
ChannelAccess channels = ((SoundEngineAccessor) ((SoundManagerAccessor) client.getSoundManager())
.ambiance$getSoundEngine()).ambiance$getChannelAccess();
channels.createHandle(Library.Pool.STREAMING).thenAccept(handle -> client.execute(() -> {
if (handle == null || REQUESTS.getOrDefault(key, -1L) != request
|| !validTarget(client, pos)) {
if (handle != null) handle.execute(channel -> channel.stop());
if (REQUESTS.getOrDefault(key, -1L) == request) REQUESTS.remove(key);
return;
}
AudioPlayer player = MANAGER.createPlayer();
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
Playback playback = new Playback(pos.immutable(), client.level.dimension(), handle, stream);
PLAYBACKS.put(key, playback);
REQUESTS.remove(key);
player.addListener(new AudioEventAdapter() {
@Override
public void onTrackException(AudioPlayer failedPlayer, AudioTrack failedTrack,
FriendlyException exception) {
AmbianceMod.LOGGER.warn("White-disc playback failed for YouTube video {}: {}",
failedTrack.getIdentifier(), exception.getMessage());
client.execute(() -> failPlayback(client, key, failedPlayer));
}
@Override
public void onTrackStuck(AudioPlayer stuckPlayer, AudioTrack stuckTrack,
long thresholdMs) {
AmbianceMod.LOGGER.warn("White-disc playback stalled for YouTube video {} after {} ms",
stuckTrack.getIdentifier(), thresholdMs);
client.execute(() -> failPlayback(client, key, stuckPlayer));
}
});
player.playTrack(track);
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
handle.execute(channel -> {
try {
channel.setSelfPosition(Vec3.atCenterOf(pos));
channel.setRelative(false);
channel.setPitch(PLAYBACK_PITCH);
channel.linearAttenuation(ATTENUATION_DISTANCE);
channel.setVolume(volume);
channel.attachBufferStream(stream);
channel.play();
} catch (RuntimeException | LinkageError exception) {
AmbianceMod.LOGGER.error("Unable to attach white-disc audio to Minecraft's sound engine",
exception);
client.execute(() -> failPlayback(client, key, player));
}
});
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.playing", track.getInfo().title));
}));
}
private static void fail(Minecraft client, long key, long request) {
if (REQUESTS.getOrDefault(key, -1L) != request) return;
REQUESTS.remove(key);
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.failed"));
}
private static void failPlayback(Minecraft client, long key, AudioPlayer player) {
Playback playback = PLAYBACKS.get(key);
if (playback == null || !playback.stream().uses(player)) return;
stop(key);
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.failed"));
}
public static void stop(BlockPos pos) {
stop(pos.asLong());
}
private static void stop(long key) {
PENDING_STARTS.remove(key);
REQUESTS.remove(key);
Playback playback = PLAYBACKS.remove(key);
if (playback == null) return;
playback.stream().close();
playback.handle().execute(channel -> channel.stop());
}
public static void tick(Minecraft client) {
if (client.level == null) {
clear();
return;
}
tickPendingStarts(client);
long now = System.nanoTime();
for (Map.Entry<Long, Playback> entry : Map.copyOf(PLAYBACKS).entrySet()) {
Playback playback = entry.getValue();
boolean missing = !client.level.dimension().equals(playback.dimension())
|| !client.level.hasChunkAt(playback.pos())
|| !client.level.getBlockState(playback.pos()).is(Blocks.JUKEBOX)
|| !client.level.getBlockState(playback.pos()).getValue(JukeboxBlock.HAS_RECORD);
if (missing || playback.finishedFor(now) >= FINISHED_GRACE_NANOS) {
stop(entry.getKey());
continue;
}
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
playback.handle().execute(channel -> {
channel.setPitch(PLAYBACK_PITCH);
channel.setVolume(volume);
});
}
}
public static void clear() {
PENDING_STARTS.clear();
REQUESTS.clear();
for (long key : PLAYBACKS.keySet().stream().mapToLong(Long::longValue).toArray()) stop(key);
}
public static void shutdown() {
if (shutdown) return;
shutdown = true;
clear();
MANAGER.shutdown();
}
private static DefaultAudioPlayerManager createManager() {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
manager.setFrameBufferDuration(5_000);
manager.setItemLoaderThreadPoolSize(2);
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
return manager;
}
private static boolean validTarget(Minecraft client, BlockPos pos) {
return validJukeboxTarget(client, pos)
&& client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD);
}
private static boolean validJukeboxTarget(Minecraft client, BlockPos pos) {
return client.player != null && client.level != null
&& client.player.distanceToSqr(Vec3.atCenterOf(pos))
<= ATTENUATION_DISTANCE * ATTENUATION_DISTANCE * 4.0D
&& client.level.getBlockState(pos).is(Blocks.JUKEBOX);
}
private static void tickPendingStarts(Minecraft client) {
for (Map.Entry<Long, PendingStart> entry : Map.copyOf(PENDING_STARTS).entrySet()) {
long key = entry.getKey();
PendingStart pending = entry.getValue();
if (!client.level.dimension().equals(pending.dimension())
|| !validJukeboxTarget(client, pending.pos())) {
PENDING_STARTS.remove(key);
continue;
}
if (validTarget(client, pending.pos())) {
PENDING_STARTS.remove(key);
startLoading(client, pending.pos(), pending.videoId());
continue;
}
if (pending.ticksRemaining() <= 1) {
PENDING_STARTS.remove(key);
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.failed"));
continue;
}
PENDING_STARTS.put(key, pending.waitOneTick());
}
}
private record PendingStart(BlockPos pos, ResourceKey<Level> dimension, String videoId,
int ticksRemaining) {
private PendingStart waitOneTick() {
return new PendingStart(pos, dimension, videoId, ticksRemaining - 1);
}
}
private record Playback(BlockPos pos, ResourceKey<Level> dimension,
ChannelAccess.ChannelHandle handle,
LavaplayerAudioStream stream, long[] finishedAt) {
private Playback(BlockPos pos, ResourceKey<Level> dimension,
ChannelAccess.ChannelHandle handle, LavaplayerAudioStream stream) {
this(pos, dimension, handle, stream, new long[1]);
}
private long finishedFor(long now) {
if (!stream.ended()) return 0L;
if (finishedAt[0] == 0L) finishedAt[0] = now;
return now - finishedAt[0];
}
}
}
@@ -0,0 +1,23 @@
package fr.koka99cab.sanctuary26.ambiance.item;
import java.util.function.Consumer;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** Blank disc whose custom name may hold a validated YouTube URL. */
public final class WhiteDiscItem extends Item {
public WhiteDiscItem(Properties properties) {
super(properties);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("tooltip.ambiance.white_disc")
.withStyle(ChatFormatting.GRAY));
}
}
@@ -0,0 +1,30 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceClientVisualEffects;
import net.minecraft.client.Camera;
import net.minecraft.client.DeltaTracker;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Bounded motion is applied on the central Ambiance camera path and disappears with the profile. */
@Mixin(Camera.class)
public abstract class CameraMixin {
@Shadow public abstract float yaw();
@Shadow public abstract float xRot();
@Shadow protected abstract void setRotation(float yaw, float pitch);
@Inject(method = "update", at = @At("TAIL"))
private void ambiance$applyTemporaryMotion(DeltaTracker deltaTracker, CallbackInfo ci) {
AmbianceVisualProfile profile = AmbianceClientVisualEffects.current();
if (profile.wobbleStrength() <= 0.0F && profile.pulseStrength() <= 0.0F) return;
double time = System.nanoTime() / 1_000_000_000.0D;
float yawOffset = (float) Math.sin(time * 2.1D) * profile.wobbleStrength() * 1.8F;
float pitchOffset = (float) Math.cos(time * 1.7D) *
(profile.wobbleStrength() + profile.pulseStrength() * 0.5F) * 1.2F;
setRotation(yaw() + yawOffset, xRot() + pitchOffset);
}
}
@@ -0,0 +1,17 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
import net.minecraft.client.renderer.LevelEventHandler;
import net.minecraft.core.BlockPos;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(LevelEventHandler.class)
public abstract class LevelEventHandlerMixin {
@Inject(method = "levelEvent", at = @At("HEAD"))
private void ambiance$stopWhiteDisc(int type, BlockPos pos, int data, CallbackInfo callback) {
if (type == 1011) WhiteDiscAudioPlayer.stop(pos);
}
}
@@ -0,0 +1,12 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import net.minecraft.client.sounds.ChannelAccess;
import net.minecraft.client.sounds.SoundEngine;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SoundEngine.class)
public interface SoundEngineAccessor {
@Accessor("channelAccess")
ChannelAccess ambiance$getChannelAccess();
}
@@ -0,0 +1,12 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import net.minecraft.client.sounds.SoundEngine;
import net.minecraft.client.sounds.SoundManager;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SoundManager.class)
public interface SoundManagerAccessor {
@Accessor("soundEngine")
SoundEngine ambiance$getSoundEngine();
}
@@ -0,0 +1,108 @@
package fr.koka99cab.sanctuary26.ambiance.network;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualEffectApi;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Ambiance-owned, versioned implementation of the three historical Canaplia payload IDs. */
public final class AmbianceCanapliaVisualPayloads {
public static final int PROTOCOL_VERSION = 1;
private static final Identifier NORMAL_ID = id("hemp_visual_sync");
private static final Identifier BAD_TRIP_ID = id("hemp_bad_trip");
private static final Identifier CLEAR_ID = id("hemp_visual_clear");
private AmbianceCanapliaVisualPayloads() {}
public record Normal(int protocol, AmbianceVisualProfile profile, int remainingTicks)
implements CustomPacketPayload {
public static final Type<Normal> ID = new Type<>(NORMAL_ID);
public static final StreamCodec<RegistryFriendlyByteBuf, Normal> CODEC =
CustomPacketPayload.codec(Normal::write, Normal::new);
public Normal {
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(NORMAL_ID, profile, remainingTicks);
profile = request.profile();
remainingTicks = request.remainingTicks();
}
private Normal(RegistryFriendlyByteBuf buffer) {
this(buffer.readVarInt(), readProfile(buffer), buffer.readVarInt());
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeVarInt(protocol);
writeProfile(buffer, profile);
buffer.writeVarInt(remainingTicks);
}
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
}
public record BadTrip(int protocol, AmbianceVisualProfile profile, int remainingTicks)
implements CustomPacketPayload {
public static final Type<BadTrip> ID = new Type<>(BAD_TRIP_ID);
public static final StreamCodec<RegistryFriendlyByteBuf, BadTrip> CODEC =
CustomPacketPayload.codec(BadTrip::write, BadTrip::new);
public BadTrip {
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(BAD_TRIP_ID, profile, remainingTicks);
profile = request.profile();
remainingTicks = request.remainingTicks();
}
private BadTrip(RegistryFriendlyByteBuf buffer) {
this(buffer.readVarInt(), readProfile(buffer), buffer.readVarInt());
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeVarInt(protocol);
writeProfile(buffer, profile);
buffer.writeVarInt(remainingTicks);
}
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
}
public record Clear(int protocol) implements CustomPacketPayload {
public static final Type<Clear> ID = new Type<>(CLEAR_ID);
public static final StreamCodec<RegistryFriendlyByteBuf, Clear> CODEC =
CustomPacketPayload.codec(Clear::write, Clear::new);
public Clear {
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
}
private Clear(RegistryFriendlyByteBuf buffer) { this(buffer.readVarInt()); }
private void write(RegistryFriendlyByteBuf buffer) { buffer.writeVarInt(protocol); }
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
}
private static AmbianceVisualProfile readProfile(RegistryFriendlyByteBuf buffer) {
return new AmbianceVisualProfile(buffer.readFloat(), buffer.readFloat(), buffer.readFloat(),
buffer.readFloat(), buffer.readFloat(), buffer.readFloat(), buffer.readFloat(), buffer.readFloat(),
buffer.readFloat(), buffer.readFloat(), buffer.readFloat());
}
private static void writeProfile(RegistryFriendlyByteBuf buffer, AmbianceVisualProfile profile) {
buffer.writeFloat(profile.intensity());
buffer.writeFloat(profile.tintRed());
buffer.writeFloat(profile.tintGreen());
buffer.writeFloat(profile.tintBlue());
buffer.writeFloat(profile.tintStrength());
buffer.writeFloat(profile.saturationDelta());
buffer.writeFloat(profile.contrastDelta());
buffer.writeFloat(profile.vignetteStrength());
buffer.writeFloat(profile.ghostingStrength());
buffer.writeFloat(profile.wobbleStrength());
buffer.writeFloat(profile.pulseStrength());
}
private static Identifier id(String path) {
return Identifier.fromNamespaceAndPath("canaplia", path);
}
}
@@ -13,7 +13,7 @@ import net.minecraft.server.level.ServerPlayer;
* Networking contract for Ambiance. Payload IDs must use the module namespace.
*/
public final class AmbianceNetworking {
public static final int PROTOCOL_VERSION = 4;
public static final int PROTOCOL_VERSION = 6;
private static boolean initialized;
private AmbianceNetworking() {
@@ -28,6 +28,14 @@ public final class AmbianceNetworking {
AmbianceEnvironmentPayload.TYPE,
AmbianceEnvironmentPayload.CODEC
);
PayloadTypeRegistry.clientboundPlay().register(
AmbianceCanapliaVisualPayloads.Normal.ID, AmbianceCanapliaVisualPayloads.Normal.CODEC);
PayloadTypeRegistry.clientboundPlay().register(
AmbianceCanapliaVisualPayloads.BadTrip.ID, AmbianceCanapliaVisualPayloads.BadTrip.CODEC);
PayloadTypeRegistry.clientboundPlay().register(
AmbianceCanapliaVisualPayloads.Clear.ID, AmbianceCanapliaVisualPayloads.Clear.CODEC);
PayloadTypeRegistry.clientboundPlay().register(
WhiteDiscPlaybackPayload.ID, WhiteDiscPlaybackPayload.CODEC);
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) ->
send(handler.player, AmbianceEnvironmentController.snapshot(server))
);
@@ -0,0 +1,34 @@
package fr.koka99cab.sanctuary26.ambiance.network;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
/** Bounded clientbound intent: a server-resolved jukebox position and an 11-character video ID. */
public record WhiteDiscPlaybackPayload(BlockPos pos, String videoId) implements CustomPacketPayload {
public static final Type<WhiteDiscPlaybackPayload> ID =
new Type<>(AmbianceMod.id("white_disc_playback"));
public static final StreamCodec<RegistryFriendlyByteBuf, WhiteDiscPlaybackPayload> CODEC =
CustomPacketPayload.codec(WhiteDiscPlaybackPayload::write, WhiteDiscPlaybackPayload::new);
public WhiteDiscPlaybackPayload {
if (!WhiteDiscUrl.validVideoId(videoId)) videoId = "";
}
private WhiteDiscPlaybackPayload(RegistryFriendlyByteBuf buffer) {
this(buffer.readBlockPos(), buffer.readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH));
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeBlockPos(pos);
buffer.writeUtf(videoId, WhiteDiscUrl.VIDEO_ID_LENGTH);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return ID;
}
}
@@ -1,12 +1,32 @@
package fr.koka99cab.sanctuary26.ambiance.registry;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.item.WhiteDiscItem;
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.JukeboxSong;
import net.minecraft.world.item.Rarity;
/**
* Single registration entry point for the Ambiance namespace.
* Feature registries must be called from here to keep initialization ordering explicit.
*/
public final class AmbianceRegistries {
public static final ResourceKey<JukeboxSong> WHITE_DISC_SONG = ResourceKey.create(
Registries.JUKEBOX_SONG, AmbianceMod.id("white_disc"));
private static final ResourceKey<Item> WHITE_DISC_KEY = ResourceKey.create(
Registries.ITEM, AmbianceMod.id("white_disc"));
private static final ResourceKey<CreativeModeTab> TOOLS_TAB = ResourceKey.create(
Registries.CREATIVE_MODE_TAB, Identifier.withDefaultNamespace("tools_and_utilities"));
public static final Item WHITE_DISC = Registry.register(BuiltInRegistries.ITEM, WHITE_DISC_KEY,
new WhiteDiscItem(new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON)
.jukeboxPlayable(WHITE_DISC_SONG).setId(WHITE_DISC_KEY)));
private static boolean initialized;
private AmbianceRegistries() {
@@ -17,7 +37,8 @@ public final class AmbianceRegistries {
return;
}
initialized = true;
AmbianceMod.LOGGER.debug("[{}] Empty registry hub initialized.", AmbianceMod.DISPLAY_NAME);
CreativeModeTabEvents.modifyOutputEvent(TOOLS_TAB).register(entries -> entries.accept(WHITE_DISC));
AmbianceMod.LOGGER.debug("[{}] Registry hub initialized with the white disc.",
AmbianceMod.DISPLAY_NAME);
}
}
@@ -0,0 +1,70 @@
package fr.koka99cab.sanctuary26.ambiance.white_disc;
import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload;
import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Set;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.JukeboxPlayable;
import net.minecraft.world.level.block.JukeboxBlock;
import net.minecraft.world.level.block.entity.JukeboxBlockEntity;
/** Server-authoritative insertion and bounded playback dispatch for the white disc. */
public final class WhiteDiscService {
private static boolean initialized;
private WhiteDiscService() {
}
public static void initialize() {
if (initialized) return;
initialized = true;
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
BlockPos pos = hit.getBlockPos();
if (!(level.getBlockEntity(pos) instanceof JukeboxBlockEntity jukebox)
|| jukebox.getBlockState().getValue(JukeboxBlock.HAS_RECORD)) return InteractionResult.PASS;
ItemStack held = player.getItemInHand(hand);
if (!held.is(AmbianceRegistries.WHITE_DISC)) return InteractionResult.PASS;
Optional<String> videoId = videoId(held);
if (videoId.isEmpty()) {
if (!level.isClientSide()) player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.invalid_url"));
return InteractionResult.FAIL;
}
if (level.isClientSide()) return InteractionResult.SUCCESS;
InteractionResult result = JukeboxPlayable.tryInsertIntoJukebox(level, pos, held, player);
if (result.consumesAction() && level instanceof ServerLevel serverLevel) {
broadcast(serverLevel, pos, videoId.get(), player instanceof ServerPlayer serverPlayer
? serverPlayer : null);
}
return result;
});
}
public static Optional<String> videoId(ItemStack stack) {
Component name = stack.get(DataComponents.CUSTOM_NAME);
return name == null ? Optional.empty() : WhiteDiscUrl.videoId(name.getString());
}
private static void broadcast(ServerLevel level, BlockPos pos, String videoId, ServerPlayer inserter) {
WhiteDiscPlaybackPayload payload = new WhiteDiscPlaybackPayload(pos, videoId);
Set<ServerPlayer> receivers = new LinkedHashSet<>(PlayerLookup.tracking(level, pos));
if (inserter != null) receivers.add(inserter);
for (ServerPlayer receiver : receivers) {
if (ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)) {
ServerPlayNetworking.send(receiver, payload);
}
}
}
}
@@ -0,0 +1,74 @@
package fr.koka99cab.sanctuary26.ambiance.white_disc;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
/** Strict, side-effect-free YouTube URL parser used before any client network request. */
public final class WhiteDiscUrl {
public static final int MAXIMUM_URL_LENGTH = 256;
public static final int VIDEO_ID_LENGTH = 11;
private static final Pattern VIDEO_ID = Pattern.compile("[A-Za-z0-9_-]{11}");
private static final Set<String> LONG_HOSTS = Set.of(
"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com");
private WhiteDiscUrl() {
}
public static Optional<String> videoId(String value) {
if (value == null || value.isBlank() || value.length() > MAXIMUM_URL_LENGTH) return Optional.empty();
try {
URI uri = new URI(value.strip());
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null
|| (uri.getPort() != -1 && uri.getPort() != 443)) return Optional.empty();
String host = uri.getHost();
if (host == null) return Optional.empty();
host = host.toLowerCase(Locale.ROOT);
if ("youtu.be".equals(host)) return valid(singlePathSegment(uri.getRawPath()));
if (!LONG_HOSTS.contains(host)) return Optional.empty();
String path = uri.getRawPath();
if ("/watch".equals(path)) return valid(queryParameter(uri.getRawQuery(), "v"));
for (String prefix : new String[] {"/shorts/", "/embed/", "/live/"}) {
if (path != null && path.startsWith(prefix)) return valid(singlePathSegment(path.substring(prefix.length())));
}
} catch (URISyntaxException ignored) {
// Invalid user text is rejected without network access.
}
return Optional.empty();
}
public static boolean validVideoId(String value) {
return value != null && VIDEO_ID.matcher(value).matches();
}
public static String canonicalUrl(String videoId) {
if (!validVideoId(videoId)) throw new IllegalArgumentException("Invalid YouTube video ID");
return "https://www.youtube.com/watch?v=" + videoId;
}
private static String singlePathSegment(String path) {
if (path == null || path.isEmpty()) return "";
String segment = path.startsWith("/") ? path.substring(1) : path;
return segment.contains("/") ? "" : segment;
}
private static String queryParameter(String query, String name) {
if (query == null) return "";
String found = null;
for (String parameter : query.split("&", -1)) {
int equals = parameter.indexOf('=');
if (equals <= 0 || !name.equals(parameter.substring(0, equals))) continue;
if (found != null) return "";
found = parameter.substring(equals + 1);
}
return found == null ? "" : found;
}
private static Optional<String> valid(String candidate) {
return validVideoId(candidate) ? Optional.of(candidate) : Optional.empty();
}
}
@@ -4,12 +4,16 @@
"package": "fr.koka99cab.sanctuary26.ambiance.mixin.client",
"compatibilityLevel": "JAVA_25",
"client": [
"CameraMixin",
"DynamicEntityRendererMixin",
"DynamicItemInHandRendererMixin",
"FogRendererMixin",
"GameRendererMixin",
"LightCoordsUtilMixin",
"LightmapRenderStateExtractorMixin",
"LevelEventHandlerMixin",
"SoundEngineAccessor",
"SoundManagerAccessor",
"SkyRendererMixin"
],
"injectors": {
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "ambiance:item/white_disc"
}
}
@@ -17,5 +17,22 @@
"command.ambiance.weather.set_day": "Weather set to %s until the next day.",
"command.ambiance.weather.set_duration": "Weather set to %1$s for %2$s ticks.",
"command.ambiance.weather.status": "Current weather: %s.",
"command.ambiance.weather.auto": "Automatic daily weather restored: %s."
"command.ambiance.weather.auto": "Automatic daily weather restored: %s.",
"options.ambiance.visual.title": "Temporary visual effects",
"options.ambiance.visual.intensity": "Effect intensity",
"options.ambiance.visual.reduce_motion": "Reduce motion",
"options.ambiance.visual.wobble": "Camera wobble",
"options.ambiance.visual.ghosting": "Edge ghosting",
"key.categories.ambiance.ambiance": "Ambiance",
"key.ambiance.visual_settings": "Visual accessibility settings",
"item.ambiance.white_disc": "White Disc",
"jukebox_song.ambiance.white_disc": "White Disc",
"tooltip.ambiance.white_disc": "Rename it to an HTTPS YouTube URL, then insert it into a jukebox.",
"message.ambiance.white_disc.invalid_url": "Rename the white disc to a valid HTTPS YouTube URL.",
"message.ambiance.white_disc.loading": "Loading YouTube video…",
"message.ambiance.white_disc.playing": "Now playing: %s",
"message.ambiance.white_disc.failed": "This YouTube video could not be played.",
"message.ambiance.white_disc.too_long": "This video is live or longer than 30 minutes.",
"message.ambiance.alpha_textures_missing": "Alpha textures are unavailable: install Golden Days Base then Golden Days Alpha in resourcepacks and leave them disabled.",
"message.ambiance.alpha_textures_restore_failed": "The previous resource-pack stack could not be reloaded. It will be restored on the next start."
}
@@ -17,5 +17,22 @@
"command.ambiance.weather.set_day": "Météo réglée sur %s jusqu'au prochain jour.",
"command.ambiance.weather.set_duration": "Météo réglée sur %1$s pendant %2$s ticks.",
"command.ambiance.weather.status": "Météo actuelle : %s.",
"command.ambiance.weather.auto": "Météo quotidienne automatique restaurée : %s."
"command.ambiance.weather.auto": "Météo quotidienne automatique restaurée : %s.",
"options.ambiance.visual.title": "Effets visuels temporaires",
"options.ambiance.visual.intensity": "Intensité des effets",
"options.ambiance.visual.reduce_motion": "Réduire les mouvements",
"options.ambiance.visual.wobble": "Oscillation de la caméra",
"options.ambiance.visual.ghosting": "Rémanence sur les bords",
"key.categories.ambiance.ambiance": "Ambiance",
"key.ambiance.visual_settings": "Réglages daccessibilité visuelle",
"item.ambiance.white_disc": "Disque blanc",
"jukebox_song.ambiance.white_disc": "Disque blanc",
"tooltip.ambiance.white_disc": "Renommez-le avec une URL YouTube HTTPS, puis insérez-le dans un jukebox.",
"message.ambiance.white_disc.invalid_url": "Renommez le disque blanc avec une URL YouTube HTTPS valide.",
"message.ambiance.white_disc.loading": "Chargement de la vidéo YouTube…",
"message.ambiance.white_disc.playing": "Lecture : %s",
"message.ambiance.white_disc.failed": "Impossible de lire cette vidéo YouTube.",
"message.ambiance.white_disc.too_long": "Cette vidéo est en direct ou dépasse 30 minutes.",
"message.ambiance.alpha_textures_missing": "Textures Alpha indisponibles : installez Golden Days Base puis Golden Days Alpha dans resourcepacks en les laissant désactivés.",
"message.ambiance.alpha_textures_restore_failed": "La pile de packs précédente na pas pu être rechargée. Elle sera restaurée au prochain démarrage."
}
@@ -17,5 +17,22 @@
"command.ambiance.weather.set_day": "Погода установлена: %s до следующего дня.",
"command.ambiance.weather.set_duration": "Погода установлена: %1$s на %2$s тиков.",
"command.ambiance.weather.status": "Текущая погода: %s.",
"command.ambiance.weather.auto": "Автоматическая погода дня восстановлена: %s."
"command.ambiance.weather.auto": "Автоматическая погода дня восстановлена: %s.",
"options.ambiance.visual.title": "Временные визуальные эффекты",
"options.ambiance.visual.intensity": "Интенсивность эффектов",
"options.ambiance.visual.reduce_motion": "Уменьшить движение",
"options.ambiance.visual.wobble": "Покачивание камеры",
"options.ambiance.visual.ghosting": "Шлейф по краям",
"key.categories.ambiance.ambiance": "Ambiance",
"key.ambiance.visual_settings": "Настройки визуальной доступности",
"item.ambiance.white_disc": "Белая пластинка",
"jukebox_song.ambiance.white_disc": "Белая пластинка",
"tooltip.ambiance.white_disc": "Переименуйте её в HTTPS-ссылку YouTube и вставьте в проигрыватель.",
"message.ambiance.white_disc.invalid_url": "Переименуйте белую пластинку в допустимую HTTPS-ссылку YouTube.",
"message.ambiance.white_disc.loading": "Загрузка видео YouTube…",
"message.ambiance.white_disc.playing": "Сейчас играет: %s",
"message.ambiance.white_disc.failed": "Не удалось воспроизвести это видео YouTube.",
"message.ambiance.white_disc.too_long": "Это прямая трансляция или видео длиннее 30 минут.",
"message.ambiance.alpha_textures_missing": "Текстуры Alpha недоступны: установите Golden Days Base, затем Golden Days Alpha в resourcepacks и оставьте их выключенными.",
"message.ambiance.alpha_textures_restore_failed": "Не удалось перезагрузить прежний набор ресурсов. Он будет восстановлен при следующем запуске."
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/template_music_disc",
"textures": {
"layer0": "ambiance:item/white_disc"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,8 @@
{
"comparator_output": 15,
"description": {
"translate": "jukebox_song.ambiance.white_disc"
},
"length_in_seconds": 1800.0,
"sound_event": "minecraft:intentionally_empty"
}
@@ -0,0 +1,16 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": [
"WWW",
"WDW",
"WWW"
],
"key": {
"D": "minecraft:music_disc_13",
"W": "minecraft:white_dye"
},
"result": {
"id": "ambiance:white_disc"
}
}
+5 -5
View File
@@ -3,7 +3,7 @@
"id": "ambiance",
"version": "${version}",
"name": "Ambiance",
"description": "Owns world atmosphere, dynamic held lights, real time and the Minecraft calendar.",
"description": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.",
"authors": [
"KOKA99CAB"
],
@@ -36,13 +36,13 @@
"system_type": "gameplay-system",
"namespace": "ambiance",
"data_version": 2,
"network_protocol": 4,
"network_protocol": 6,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"descriptions": {
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, le temps réel et le calendrier minecraftien.",
"en_us": "Owns world atmosphere, dynamic lights, real time and the Minecraft calendar.",
"ru_ru": "Управляет атмосферой, динамическим освещением, реальным временем и календарём Minecraft."
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, l'audio positionnel, le calendrier et les effets visuels accessibles et composables, dont Canaplia.",
"en_us": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.",
"ru_ru": "Управляет атмосферой, динамическим освещением, позиционным звуком, календарём и доступными составными визуальными эффектами, включая Canaplia."
}
}
}
@@ -0,0 +1,89 @@
package fr.koka99cab.sanctuary26.ambiance.api.visual;
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceVisualAccessibility;
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceClientVisualEffects;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
import net.minecraft.resources.Identifier;
public final class AmbianceVisualApiSmoke {
private AmbianceVisualApiSmoke() {
}
public static void main(String[] arguments) {
AmbianceVisualProfile bounded = new AmbianceVisualProfile(
2.0F,
-1.0F,
0.5F,
4.0F,
2.0F,
-3.0F,
3.0F,
2.0F,
0.4F,
0.3F,
0.2F
);
assertEquals(1.0F, bounded.intensity(), "intensity upper bound");
assertEquals(0.0F, bounded.tintRed(), "red lower bound");
assertEquals(1.0F, bounded.tintBlue(), "blue upper bound");
assertEquals(-1.0F, bounded.saturationDelta(), "saturation lower bound");
assertEquals(1.0F, bounded.contrastDelta(), "contrast upper bound");
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(
Identifier.fromNamespaceAndPath("canaplia", "hemp_visual_sync"),
bounded,
Integer.MAX_VALUE
);
assertEquals(AmbianceVisualEffectApi.MAX_DURATION_TICKS, request.remainingTicks(), "duration bound");
AmbianceVisualProfile reduced = request.withReducedMotion(true).profile();
assertEquals(0.0F, reduced.ghostingStrength(), "reduced ghosting");
assertEquals(0.0F, reduced.wobbleStrength(), "reduced wobble");
assertEquals(0.0F, reduced.pulseStrength(), "reduced pulse");
AmbianceCanapliaVisualPayloads.Normal normal = new AmbianceCanapliaVisualPayloads.Normal(
Integer.MAX_VALUE, bounded, Integer.MAX_VALUE);
assertEquals(AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION, normal.protocol(), "payload protocol bound");
assertEquals(AmbianceVisualEffectApi.MAX_DURATION_TICKS, normal.remainingTicks(), "payload duration bound");
AmbianceCanapliaVisualPayloads.BadTrip badTrip = new AmbianceCanapliaVisualPayloads.BadTrip(
Integer.MAX_VALUE, bounded, -4);
assertEquals(0, badTrip.remainingTicks(), "negative payload duration bound");
AmbianceClientVisualEffects.apply(normal);
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.NORMAL,
"normal profile active");
AmbianceClientVisualEffects.apply(new AmbianceCanapliaVisualPayloads.BadTrip(
AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION, bounded, 20));
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.BAD_TRIP,
"bad-trip profile precedence");
AmbianceClientVisualEffects.clear();
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.NONE,
"profile cleanup");
AmbianceVisualAccessibility accessibility = new AmbianceVisualAccessibility(
2.0F, true, true, true);
assertEquals(1.0F, accessibility.intensity(), "accessibility intensity bound");
try {
new AmbianceVisualProfile(
Float.NaN, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F
);
throw new AssertionError("non-finite profile value accepted");
} catch (IllegalArgumentException expected) {
// Expected.
}
}
private static void assertEquals(float expected, float actual, String label) {
if (Float.compare(expected, actual) != 0) {
throw new AssertionError(label + ": expected " + expected + ", got " + actual);
}
}
private static void assertTrue(boolean condition, String label) {
if (!condition) throw new AssertionError(label);
}
private static void assertEquals(int expected, int actual, String label) {
if (expected != actual) {
throw new AssertionError(label + ": expected " + expected + ", got " + actual);
}
}
}
@@ -0,0 +1,36 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import java.util.List;
/** Executable tests for local Golden Days discovery and exact stack composition. */
public final class AlphaTexturePackManagerSmoke {
private AlphaTexturePackManagerSmoke() {
}
public static void main(String[] args) {
var packs = AlphaTexturePackManager.findPacks(List.of(
"vanilla",
"file/Golden Days Base 16.2 [1.20 to 26.2].zip",
"file/golden_days_alpha_16.2.zip"
)).orElseThrow(() -> new AssertionError("Golden Days packs were not detected"));
List<String> previous = List.of(
"vanilla", "file/custom.zip", "file/golden-days-alpha-old.zip");
List<String> desired = AlphaTexturePackManager.selectionWithOverlay(previous, packs);
require(desired.equals(List.of("vanilla", "file/custom.zip", packs.base(), packs.alpha())),
"Base and Alpha must be appended in bottom-to-top order without disturbing custom packs");
require(previous.equals(List.of(
"vanilla", "file/custom.zip", "file/golden-days-alpha-old.zip")),
"Composing the overlay must not mutate the captured stack");
require(AlphaTexturePackManager.findPacks(List.of("file/golden-days-base.zip")).isEmpty(),
"A partial installation must keep the vanilla stack");
require(AlphaTexturePackManager.isGoldenDaysPack("FILE/GOLDEN_DAYS_ALPHA.ZIP"),
"Pack matching must be case- and separator-insensitive");
require(!AlphaTexturePackManager.isGoldenDaysPack("file/custom-alpha.zip"),
"Unrelated packs must never be removed from the captured stack");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,97 @@
package fr.koka99cab.sanctuary26.ambiance.client.audio;
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
import com.sedmelluq.discord.lavaplayer.track.playback.ImmutableAudioFrame;
import java.lang.reflect.Proxy;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/** Deterministic check for Minecraft's four one-second streaming buffers. */
public final class LavaplayerAudioStreamSmoke {
private static final int FRAME_SAMPLES = 960;
private static final int SOURCE_FRAME_BYTES = FRAME_SAMPLES
* LavaplayerAudioStream.SOURCE_CHANNELS * Short.BYTES;
private static final int FRAMES_PER_SECOND = LavaplayerAudioStream.SAMPLE_RATE / FRAME_SAMPLES;
private LavaplayerAudioStreamSmoke() {
}
public static void main(String[] args) throws Exception {
if (Math.abs(WhiteDiscAudioPlayer.PLAYBACK_PITCH - 1.0F) > 0.0001F) {
throw new IllegalStateException("The white-disc playback pitch is not native");
}
Queue<AudioFrame> frames = new ArrayDeque<>();
Pcm16AudioDataFormat format = new Pcm16AudioDataFormat(
LavaplayerAudioStream.SOURCE_CHANNELS, LavaplayerAudioStream.SAMPLE_RATE,
FRAME_SAMPLES, false);
for (int index = 0; index < FRAMES_PER_SECOND; index++) {
byte[] data = new byte[SOURCE_FRAME_BYTES];
for (int offset = 0; offset < data.length; offset += 4) {
putLittleEndian(data, offset, (short) (1_000 + index));
putLittleEndian(data, offset + 2, (short) (3_000 + index));
}
frames.add(new ImmutableAudioFrame(index * 20L, data, 100, format));
}
AtomicInteger timedProvides = new AtomicInteger();
AtomicInteger nonBlockingProvides = new AtomicInteger();
AtomicBoolean destroyed = new AtomicBoolean();
AudioPlayer player = (AudioPlayer) Proxy.newProxyInstance(
AudioPlayer.class.getClassLoader(), new Class<?>[] {AudioPlayer.class},
(proxy, method, arguments) -> switch (method.getName()) {
case "provide" -> {
if (method.getParameterCount() == 2) {
timedProvides.incrementAndGet();
yield frames.poll();
}
nonBlockingProvides.incrementAndGet();
yield null;
}
case "destroy" -> {
destroyed.set(true);
yield null;
}
case "getPlayingTrack" -> null;
case "getVolume" -> 100;
case "isPaused", "startTrack" -> false;
default -> null;
});
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2);
if (pcm == null || pcm.remaining() != LavaplayerAudioStream.SAMPLE_RATE * 2) {
throw new IllegalStateException("The stream did not fill Minecraft's one-second buffer");
}
for (int frame = 0; frame < FRAMES_PER_SECOND; frame++) {
short expected = (short) (2_000 + frame);
for (int sample = 0; sample < FRAME_SAMPLES; sample++) {
int low = Byte.toUnsignedInt(pcm.get());
short actual = (short) (low | pcm.get() << 8);
if (actual != expected) {
throw new IllegalStateException("Stereo PCM was not downmixed in sample order");
}
}
}
if (timedProvides.get() != FRAMES_PER_SECOND || nonBlockingProvides.get() != 0) {
throw new IllegalStateException("PCM frames were not read with bounded waits");
}
if (stream.getFormat().getSampleRate() != LavaplayerAudioStream.SAMPLE_RATE
|| stream.getFormat().isBigEndian()) {
throw new IllegalStateException("The OpenAL PCM format is not 48 kHz little-endian");
}
stream.close();
if (!destroyed.get()) {
throw new IllegalStateException("Closing the stream did not destroy its player");
}
}
private static void putLittleEndian(byte[] data, int offset, short sample) {
data[offset] = (byte) sample;
data[offset + 1] = (byte) (sample >>> 8);
}
}
@@ -0,0 +1,110 @@
package fr.koka99cab.sanctuary26.ambiance.client.audio;
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.clients.Ios;
import java.nio.ByteBuffer;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/** Manual network smoke test for the exact YouTube playback path used by white discs. */
public final class WhiteDiscPlaybackSmoke {
private static final String VIDEO_URL = "https://www.youtube.com/watch?v=Ue5ZBe-GzSM";
private WhiteDiscPlaybackSmoke() {
}
public static void main(String[] args) throws Exception {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
try {
AudioTrack track = load(manager).get(30, TimeUnit.SECONDS);
AudioPlayer player = manager.createPlayer();
player.playTrack(track);
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
int totalBytes = 0;
long squaredSampleSum = 0L;
int sampleCount = 0;
int peakSample = 0;
for (int index = 0; index < 4; index++) {
ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2);
if (pcm == null) break;
totalBytes += pcm.remaining();
while (pcm.remaining() >= 2) {
int low = Byte.toUnsignedInt(pcm.get());
int high = pcm.get();
short sample = (short) ((high << 8) | low);
int magnitude = Math.abs((int) sample);
peakSample = Math.max(peakSample, magnitude);
squaredSampleSum += (long) sample * sample;
sampleCount++;
}
}
int expectedBytes = LavaplayerAudioStream.SAMPLE_RATE * 2 * 4;
if (totalBytes != expectedBytes) {
throw new IllegalStateException("YouTube playback did not fill four PCM seconds");
}
if (stream.decodedBytes() != expectedBytes || stream.paddedSilenceBytes() != 0L) {
throw new IllegalStateException("YouTube playback was not 100% decoded PCM: "
+ stream.decodedBytes() + " decoded bytes and "
+ stream.paddedSilenceBytes() + " padded bytes");
}
if (stream.decodedTimelineMillis() != 4_000L) {
throw new IllegalStateException("PCM cadence mismatch: four seconds of samples cover "
+ stream.decodedTimelineMillis() + " ms of the YouTube timeline");
}
double rmsSample = Math.sqrt((double) squaredSampleSum / sampleCount);
if (rmsSample < 256.0 || peakSample < 2_048) {
throw new IllegalStateException("YouTube PCM signal is too quiet: RMS "
+ Math.round(rmsSample) + ", peak " + peakSample);
}
System.out.printf(
"PCM cadence: 100%% speed, 4000 ms decoded into 4000 ms at %.0f Hz; RMS %.0f, peak %d from %s%n",
stream.getFormat().getSampleRate(), rmsSample, peakSample, track.getInfo().title);
stream.close();
} finally {
manager.shutdown();
}
}
private static CompletableFuture<AudioTrack> load(DefaultAudioPlayerManager manager) {
CompletableFuture<AudioTrack> result = new CompletableFuture<>();
manager.loadItem(VIDEO_URL, new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
result.complete(track);
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
AudioTrack selected = playlist.getSelectedTrack();
if (selected == null && !playlist.getTracks().isEmpty()) {
selected = playlist.getTracks().getFirst();
}
if (selected == null) result.completeExceptionally(
new IllegalStateException("YouTube playlist contained no track"));
else result.complete(selected);
}
@Override
public void noMatches() {
result.completeExceptionally(new IllegalStateException("YouTube returned no match"));
}
@Override
public void loadFailed(FriendlyException exception) {
result.completeExceptionally(exception);
}
});
return result;
}
}
@@ -0,0 +1,42 @@
package fr.koka99cab.sanctuary26.ambiance.white_disc;
/** Executable URL-policy regression test, intentionally independent from a running game. */
public final class WhiteDiscUrlSmoke {
private static final String VIDEO_ID = "dQw4w9WgXcQ";
private WhiteDiscUrlSmoke() {
}
public static void main(String[] args) {
require(VIDEO_ID.equals(id("https://www.youtube.com/watch?v=" + VIDEO_ID)), "watch URL rejected");
require(VIDEO_ID.equals(id("https://youtu.be/" + VIDEO_ID + "?si=abc")), "short URL rejected");
require("Ue5ZBe-GzSM".equals(id("https://youtu.be/Ue5ZBe-GzSM?si=78LSbtBQzUYzhA2Y")),
"reported white-disc URL rejected");
require(VIDEO_ID.equals(id("https://music.youtube.com/watch?v=" + VIDEO_ID)), "music URL rejected");
require(VIDEO_ID.equals(id("https://www.youtube.com/shorts/" + VIDEO_ID)), "shorts URL rejected");
require(VIDEO_ID.equals(id("https://www.youtube.com/embed/" + VIDEO_ID)), "embed URL rejected");
reject("http://www.youtube.com/watch?v=" + VIDEO_ID);
reject("https://youtube.example/watch?v=" + VIDEO_ID);
reject("https://youtube.com.evil.example/watch?v=" + VIDEO_ID);
reject("https://user@www.youtube.com/watch?v=" + VIDEO_ID);
reject("https://www.youtube.com:8443/watch?v=" + VIDEO_ID);
reject("https://www.youtube.com/watch?v=too_short");
reject("https://www.youtube.com/watch?v=" + VIDEO_ID + "&v=" + VIDEO_ID);
reject("https://youtu.be/" + VIDEO_ID + "/extra");
reject("https://www.youtube.com/watch?v=dQw4w9WgX%63Q");
reject("x".repeat(WhiteDiscUrl.MAXIMUM_URL_LENGTH + 1));
}
private static String id(String url) {
return WhiteDiscUrl.videoId(url).orElseThrow();
}
private static void reject(String url) {
require(WhiteDiscUrl.videoId(url).isEmpty(), "Unsafe URL accepted: " + url);
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
+41 -23
View File
@@ -9,7 +9,7 @@ sourceSets.main.resources.srcDir(generatedResources)
tasks.register("vaultRulesSmoke", JavaExec) {
group = "verification"
description = "Checks the 3x3 vault, one-minute raid, 20% loot and 24-hour protection rules."
description = "Checks the hollow 3x3x3 vault, one-minute raid, 20% loot and 24-hour protection rules."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.anotherworld.vault.VaultRulesSmoke"
@@ -177,13 +177,24 @@ tasks.register("generateAnotherWorldResources") {
// Mirror, creative-only rainbow blocks, gems and first-pass metal art.
simpleBlock("mirror", "minecraft:block/cube_all", "anotherworld:block/mirror_glass")
def vaultParts = [
def vaultFaceParts = [
[id: "bottom_left", column: 0, row: 0], [id: "bottom_center", column: 1, row: 0],
[id: "bottom_right", column: 2, row: 0], [id: "middle_left", column: 0, row: 1],
[id: "center", column: 1, row: 1], [id: "middle_right", column: 2, row: 1],
[id: "top_left", column: 0, row: 2], [id: "top_center", column: 1, row: 2],
[id: "top_right", column: 2, row: 2]
]
def vaultParts = []
[[prefix: "back", depth: 0], [prefix: null, depth: 1], [prefix: "front", depth: 2]].each { layer ->
vaultFaceParts.each { facePart ->
vaultParts.add([
id: layer.prefix == null ? facePart.id : "${layer.prefix}_${facePart.id}",
column: facePart.column,
row: facePart.row,
depth: layer.depth
])
}
}
def safeMultipart = [[when: [formed: "false"], apply: [model: "anotherworld:block/safe_block_unformed"]]]
[north: 0, east: 90, south: 180, west: 270].each { facing, rotation ->
vaultParts.each { part ->
@@ -201,10 +212,14 @@ tasks.register("generateAnotherWorldResources") {
[column * third, (2 - row) * third, (column + 1) * third, (3 - row) * third]
}
vaultParts.each { part ->
def frontUv = segmentUv(part.column as int, part.row as int)
def backUv = segmentUv(2 - (part.column as int), part.row as int)
def sideUv = segmentUv(1, part.row as int)
def topUv = segmentUv(part.column as int, 1)
// Structure columns run screen-right on the front face. Mirror them there,
// while the same axis is already screen-left when viewed from the back.
def frontUv = segmentUv(2 - (part.column as int), part.row as int)
def backUv = segmentUv(part.column as int, part.row as int)
def westUv = segmentUv(2 - (part.depth as int), part.row as int)
def eastUv = segmentUv(part.depth as int, part.row as int)
def topUv = segmentUv(part.column as int, part.depth as int)
def bottomUv = segmentUv(part.column as int, 2 - (part.depth as int))
writeJson("assets/anotherworld/models/block/safe_block/${part.id}.json", [
textures: [
particle: "anotherworld:block/safe_vault/front",
@@ -214,12 +229,12 @@ tasks.register("generateAnotherWorldResources") {
side: "anotherworld:block/safe_vault/side"
],
elements: [[from: [0, 0, 0], to: [16, 16, 16], faces: [
down: [uv: topUv, texture: "#bottom", cullface: "down"],
down: [uv: bottomUv, texture: "#bottom", cullface: "down"],
up: [uv: topUv, texture: "#top", cullface: "up"],
north: [uv: frontUv, texture: "#front", cullface: "north"],
south: [uv: backUv, texture: "#side", cullface: "south"],
west: [uv: sideUv, texture: "#side", cullface: "west"],
east: [uv: sideUv, texture: "#side", cullface: "east"]
west: [uv: westUv, texture: "#side", cullface: "west"],
east: [uv: eastUv, texture: "#side", cullface: "east"]
]]]
])
}
@@ -1622,16 +1637,17 @@ tasks.register("generateAnotherWorldResources") {
"container.anotherworld.safe_vault": "Coffre-fort",
"tooltip.anotherworld.titanium_drill.use": "Maintenir sur un coffre-fort ennemi pour le forer.",
"tooltip.anotherworld.titanium_drill.consumed": "Consommée après un pillage réussi.",
"message.anotherworld.safe_vault.incomplete": "Le coffre-fort exige exactement 9 blocs appartenant au même joueur, posés en mur vertical de 3 × 3.",
"message.anotherworld.safe_vault.incomplete": "Le coffre-fort exige exactement 26 blocs appartenant au même joueur, disposés en coque creuse de 3 × 3 × 3 avec un centre libre.",
"message.anotherworld.safe_vault.locked": "Ce coffre-fort appartient à un autre joueur.",
"message.anotherworld.safe_vault.formed": "Coffre-fort vertical 3 × 3 lié à vous en %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Impossible de déposer %s : inventaire vide ou coffre-fort plein.",
"message.anotherworld.safe_vault.formed": "Coffre-fort creux 3 × 3 × 3 lié à vous en %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Impossible de déposer %s : aucun exemplaire disponible dans votre inventaire.",
"message.anotherworld.safe_vault.withdraw_failed": "Impossible de retirer %s : solde vide ou inventaire plein.",
"message.anotherworld.safe_vault.already_owned": "Vous possédez déjà un coffre-fort actif.",
"message.anotherworld.safe_vault.break_not_owner": "Seul le propriétaire peut démonter ce bloc de coffre-fort.",
"message.anotherworld.safe_vault.empty_before_break": "Videz le coffre-fort et attendez la fin du forage avant de le démonter.",
"message.anotherworld.safe_vault.drill_own": "Vous ne pouvez pas forer votre propre coffre-fort.",
"message.anotherworld.safe_vault.inactive": "Ce coffre-fort nest pas actif.",
"message.anotherworld.safe_vault.raid_requires_vault": "Vous devez posséder un coffre-fort actif avant den braquer un autre.",
"message.anotherworld.safe_vault.empty": "Ce coffre-fort est vide.",
"message.anotherworld.safe_vault.owner_offline": "Le propriétaire doit être connecté pour que le coffre-fort soit attaquable.",
"message.anotherworld.safe_vault.protected": "Ce coffre-fort reste protégé pendant environ %s minute(s).",
@@ -1641,21 +1657,22 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.drilling_progress": "Forage : %s %%",
"message.anotherworld.safe_vault.drilling_cancelled": "Forage interrompu.",
"message.anotherworld.safe_vault.raided_owner": "%s a pillé %s gemme(s) dans votre coffre-fort.",
"message.anotherworld.safe_vault.raided": "Pillage réussi : %s gemme(s) récupérée(s)."
"message.anotherworld.safe_vault.raided": "Pillage réussi : %s gemme(s) créditée(s) dans votre coffre-fort."
] : locale == "ru_ru" ? [
"container.anotherworld.safe_vault": "Сейф",
"tooltip.anotherworld.titanium_drill.use": "Удерживайте на чужом сейфе, чтобы просверлить его.",
"tooltip.anotherworld.titanium_drill.consumed": "Расходуется после успешного ограбления.",
"message.anotherworld.safe_vault.incomplete": "Сейф требует вертикальную стену 3 × 3 из 9 блоков одного владельца.",
"message.anotherworld.safe_vault.incomplete": "Сейф требует полую оболочку 3 × 3 × 3 из 26 блоков одного владельца со свободным центром.",
"message.anotherworld.safe_vault.locked": "Этот сейф принадлежит другому игроку.",
"message.anotherworld.safe_vault.formed": "Вертикальный сейф 3 × 3 привязан к вам в точке %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Не удалось внести %s: предметов нет или сейф заполнен.",
"message.anotherworld.safe_vault.formed": "Полый сейф 3 × 3 × 3 привязан к вам в точке %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Не удалось внести %s: в инвентаре нет доступных предметов.",
"message.anotherworld.safe_vault.withdraw_failed": "Не удалось забрать %s: баланс пуст или инвентарь заполнен.",
"message.anotherworld.safe_vault.already_owned": "У вас уже есть активный сейф.",
"message.anotherworld.safe_vault.break_not_owner": "Разобрать этот блок может только владелец.",
"message.anotherworld.safe_vault.empty_before_break": "Опустошите сейф и дождитесь окончания сверления перед разборкой.",
"message.anotherworld.safe_vault.drill_own": "Нельзя сверлить собственный сейф.",
"message.anotherworld.safe_vault.inactive": "Этот сейф не активен.",
"message.anotherworld.safe_vault.raid_requires_vault": "Для ограбления чужого сейфа нужен собственный активный сейф.",
"message.anotherworld.safe_vault.empty": "Этот сейф пуст.",
"message.anotherworld.safe_vault.owner_offline": "Владелец должен быть в сети, чтобы атаковать сейф.",
"message.anotherworld.safe_vault.protected": "Сейф защищён ещё примерно %s минут(ы).",
@@ -1665,21 +1682,22 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.drilling_progress": "Сверление: %s %%",
"message.anotherworld.safe_vault.drilling_cancelled": "Сверление прервано.",
"message.anotherworld.safe_vault.raided_owner": "%s похитил(а) %s драгоценностей из вашего сейфа.",
"message.anotherworld.safe_vault.raided": "Ограбление удалось: получено %s драгоценностей."
"message.anotherworld.safe_vault.raided": "Ограбление удалось: %s драгоценностей зачислено в ваш сейф."
] : [
"container.anotherworld.safe_vault": "Safe",
"tooltip.anotherworld.titanium_drill.use": "Hold on an enemy safe to drill it.",
"tooltip.anotherworld.titanium_drill.consumed": "Consumed after a successful raid.",
"message.anotherworld.safe_vault.incomplete": "A safe requires an upright 3 × 3 wall of exactly 9 blocks owned by one player.",
"message.anotherworld.safe_vault.incomplete": "A safe requires a hollow 3 × 3 × 3 shell of exactly 26 blocks owned by one player, with a free center.",
"message.anotherworld.safe_vault.locked": "This safe belongs to another player.",
"message.anotherworld.safe_vault.formed": "Upright 3 × 3 safe linked to you at %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Could not deposit %s: none available or the safe is full.",
"message.anotherworld.safe_vault.formed": "Hollow 3 × 3 × 3 safe linked to you at %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Could not deposit %s: none are available in your inventory.",
"message.anotherworld.safe_vault.withdraw_failed": "Could not withdraw %s: the balance is empty or inventory is full.",
"message.anotherworld.safe_vault.already_owned": "You already own an active safe.",
"message.anotherworld.safe_vault.break_not_owner": "Only the owner may dismantle this safe block.",
"message.anotherworld.safe_vault.empty_before_break": "Empty the safe and wait for drilling to end before dismantling it.",
"message.anotherworld.safe_vault.drill_own": "You cannot drill your own safe.",
"message.anotherworld.safe_vault.inactive": "This safe is not active.",
"message.anotherworld.safe_vault.raid_requires_vault": "You need an active safe of your own before raiding another one.",
"message.anotherworld.safe_vault.empty": "This safe is empty.",
"message.anotherworld.safe_vault.owner_offline": "The owner must be online before the safe can be raided.",
"message.anotherworld.safe_vault.protected": "This safe remains protected for about %s minute(s).",
@@ -1689,7 +1707,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.drilling_progress": "Drilling: %s %%",
"message.anotherworld.safe_vault.drilling_cancelled": "Drilling interrupted.",
"message.anotherworld.safe_vault.raided_owner": "%s stole %s gem item(s) from your safe.",
"message.anotherworld.safe_vault.raided": "Raid successful: recovered %s gem item(s)."
"message.anotherworld.safe_vault.raided": "Raid successful: %s gem item(s) credited to your safe."
]
lang.putAll(vaultText)
def tagTranslations = locale == "fr_fr" ? [
@@ -1938,7 +1956,7 @@ tasks.register("verifyAnotherWorldCatalog") {
"src/main/resources/assets/redstoner/textures/item/weather_gauge.png",
"src/main/resources/data/redstoner/recipe/weather_gauge.json"
]
if (project.version.toString() != "0.0.0-alpha.52"
if (project.version.toString() != "0.0.0-alpha.56"
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
@@ -3255,7 +3273,7 @@ tasks.register("verifyClimateBiomeMixer") {
"pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json"))
def mixer = file(
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text
if (project.version.toString() != "0.0.0-alpha.52"
if (project.version.toString() != "0.0.0-alpha.56"
|| migration.source?.pack_version != "26.2.0-alpha.188"
|| migration.target?.pack_version != "26.2.0-alpha.189"
|| migration.target?.modules?.anotherworld != "0.0.0-alpha.48"
@@ -108,7 +108,7 @@ public final class SafeVaultBlock extends BaseEntityBlock {
player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.locked"));
return InteractionResult.CONSUME;
}
if (level.getBlockEntity(formed.center()) instanceof SafeVaultBlockEntity controller) {
if (level.getBlockEntity(formed.controller()) instanceof SafeVaultBlockEntity controller) {
player.openMenu(controller);
return InteractionResult.SUCCESS_SERVER;
}
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.anotherworld.block.entity;
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlockEntities;
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultData;
import fr.koka99cab.sanctuary26.anotherworld.vault.SafeVaultMenu;
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultStructure;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.UUIDUtil;
@@ -54,7 +55,10 @@ public final class SafeVaultBlockEntity extends BlockEntity implements MenuProvi
@Override
public AbstractContainerMenu createMenu(int containerId, Inventory inventory, Player player) {
if (owner == null || !owner.equals(player.getUUID()) || !(level instanceof ServerLevel serverLevel)) return null;
VaultStructure.Formed formed = VaultStructure.find(serverLevel, getBlockPos())
.filter(candidate -> candidate.owner().equals(owner)).orElse(null);
if (formed == null || !formed.controller().equals(getBlockPos())) return null;
return new SafeVaultMenu(containerId, inventory, VaultData.get(serverLevel.getServer()),
serverLevel, getBlockPos(), owner);
serverLevel, formed.center(), owner);
}
}
@@ -53,7 +53,7 @@ public final class SafeVaultScreen extends AbstractContainerScreen<SafeVaultMenu
graphics.outline(x, y, 18, 18, 0xFF373737);
graphics.fill(x + 1, y + 1, x + 17, y + 17, 0xFF8B8B8B);
graphics.item(new ItemStack(SafeVaultMenu.currency(currency)), x + 1, y + 1);
Component balance = Component.literal(Integer.toString(menu.balance(currency)));
Component balance = Component.literal(VaultRules.formatBalance(menu.balance(currency)));
graphics.text(font, balance, x + 17 - font.width(balance), y + 9, 0xFFFFFFFF, true);
}
}
@@ -34,7 +34,7 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
private final BlockPos center;
public SafeVaultMenu(int containerId, Inventory inventory) {
this(containerId, inventory, new SimpleContainerData(VaultRules.CURRENCY_SLOTS),
this(containerId, inventory, new SimpleContainerData(VaultRules.BALANCE_DATA_SLOTS),
null, null, null, null);
}
@@ -47,7 +47,7 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
private SafeVaultMenu(int containerId, Inventory inventory, ContainerData balances,
VaultData vaultData, ServerLevel serverLevel, BlockPos center, UUID owner) {
super(AnotherWorldMenus.SAFE_VAULT, containerId);
checkContainerDataCount(balances, VaultRules.CURRENCY_SLOTS);
checkContainerDataCount(balances, VaultRules.BALANCE_DATA_SLOTS);
this.balances = balances;
this.vaultData = vaultData;
this.serverLevel = serverLevel;
@@ -58,8 +58,10 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
addDataSlots(balances);
}
public int balance(int currency) {
return currency >= 0 && currency < VaultRules.CURRENCY_SLOTS ? balances.get(currency) : 0;
public long balance(int currency) {
if (currency < 0 || currency >= VaultRules.CURRENCY_SLOTS) return 0L;
int base = currency * VaultRules.BALANCE_WORDS_PER_CURRENCY;
return VaultRules.balanceFromWords(balances.get(base), balances.get(base + 1));
}
public static Item currency(int currency) {
@@ -104,22 +106,22 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
return false;
}
return vaultData.isRegistered(serverLevel, center, owner)
&& VaultStructure.find(serverLevel, center)
&& VaultStructure.findAtCenter(serverLevel, center)
.filter(formed -> formed.center().equals(center) && formed.owner().equals(owner)).isPresent();
}
private int deposit(ServerPlayer player, Item item, int requested) {
int available = countPlayerItem(player, item);
int amount = Math.min(requested, Math.min(available, vaultData.capacity(owner, item)));
int amount = Math.min(requested, available);
if (amount <= 0) return 0;
int removed = removePlayerItem(player, item, amount);
int stored = vaultData.add(owner, item, removed);
int stored = (int) vaultData.add(owner, item, removed);
if (stored < removed) insertPlayerItem(player, item, removed - stored);
return stored;
}
private int withdraw(ServerPlayer player, Item item, int requested) {
int amount = Math.min(requested,
int amount = (int) Math.min(requested,
Math.min(vaultData.count(owner, item), playerCapacity(player, item)));
if (amount <= 0) return 0;
int removed = vaultData.remove(owner, item, amount);
@@ -184,12 +186,15 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
private record VaultBalanceData(VaultData data, UUID owner) implements ContainerData {
@Override public int get(int index) {
return index >= 0 && index < CURRENCIES.size() ? data.count(owner, CURRENCIES.get(index)) : 0;
if (index < 0 || index >= VaultRules.BALANCE_DATA_SLOTS) return 0;
int currency = index / VaultRules.BALANCE_WORDS_PER_CURRENCY;
int word = index % VaultRules.BALANCE_WORDS_PER_CURRENCY;
return VaultRules.balanceWord(data.count(owner, CURRENCIES.get(currency)), word);
}
@Override public void set(int index, int value) {
}
@Override public int getCount() {
return VaultRules.CURRENCY_SLOTS;
return VaultRules.BALANCE_DATA_SLOTS;
}
}
}
@@ -9,28 +9,27 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Global player-linked vault inventory, available to the Shop even when its structure is unloaded. */
/** Global player-linked unlimited currency balances, available even when the vault is unloaded. */
public final class VaultData extends SavedData {
public static final String SAVE_KEY = "safe_vaults";
public static final int DATA_VERSION = 2;
private static final Identifier RUBY = Identifier.fromNamespaceAndPath("anotherworld", "ruby");
private static final Identifier SAPPHIRE = Identifier.fromNamespaceAndPath("anotherworld", "sapphire");
private static final Identifier EMERALD = Identifier.withDefaultNamespace("emerald");
public static final int DATA_VERSION = 4;
private static final List<Identifier> CURRENCIES = List.of(
Identifier.fromNamespaceAndPath("anotherworld", "ruby"),
Identifier.fromNamespaceAndPath("anotherworld", "sapphire"),
Identifier.withDefaultNamespace("emerald")
);
/** Legacy v3 slot format, retained only so existing worlds migrate monotonically. */
private static final Codec<StoredSlot> SLOT_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.fieldOf("slot").forGetter(StoredSlot::slot),
ItemStack.CODEC.fieldOf("item").forGetter(StoredSlot::item)
@@ -38,6 +37,9 @@ public final class VaultData extends SavedData {
private static final Codec<StoredAccount> ACCOUNT_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("owner").forGetter(StoredAccount::owner),
SLOT_CODEC.listOf().optionalFieldOf("items", List.of()).forGetter(StoredAccount::items),
Codec.LONG.optionalFieldOf("rubies", 0L).forGetter(StoredAccount::rubies),
Codec.LONG.optionalFieldOf("sapphires", 0L).forGetter(StoredAccount::sapphires),
Codec.LONG.optionalFieldOf("emeralds", 0L).forGetter(StoredAccount::emeralds),
Codec.LONG.optionalFieldOf("protected_until", 0L).forGetter(StoredAccount::protectedUntil)
).apply(instance, StoredAccount::new));
private static final Codec<VaultAddress> ADDRESS_CODEC = RecordCodecBuilder.create(instance -> instance.group(
@@ -66,13 +68,13 @@ public final class VaultData extends SavedData {
for (StoredAccount stored : accounts == null ? List.<StoredAccount>of() : accounts) {
try {
UUID owner = UUID.fromString(stored.owner());
NonNullList<ItemStack> items = NonNullList.withSize(VaultRules.STORAGE_SLOTS, ItemStack.EMPTY);
Account account = new Account(stored.rubies(), stored.sapphires(), stored.emeralds(),
stored.protectedUntil());
for (StoredSlot slot : stored.items()) {
if (slot.slot() >= 0 && slot.slot() < items.size() && isCurrency(slot.item())) {
items.set(slot.slot(), slot.item().copy());
}
int currency = currencyIndex(slot.item());
if (currency >= 0) account.add(currency, slot.item().getCount());
}
this.accounts.put(owner, new Account(items, Math.max(0L, stored.protectedUntil())));
this.accounts.put(owner, account);
} catch (IllegalArgumentException ignored) {
AnotherWorldMod.LOGGER.warn("[Vault] Ignored account with invalid owner {}.", stored.owner());
}
@@ -111,6 +113,12 @@ public final class VaultData extends SavedData {
&& address.pos() == center.asLong() && address.owner().equals(ownerId));
}
public boolean hasRegisteredVault(UUID owner) {
if (owner == null) return false;
String ownerId = owner.toString();
return vaults.stream().anyMatch(address -> address.owner().equals(ownerId));
}
public void unregisterNear(ServerLevel level, BlockPos member) {
String dimension = dimension(level);
boolean changed = vaults.removeIf(address -> {
@@ -123,86 +131,63 @@ public final class VaultData extends SavedData {
if (changed) setDirty();
}
public Container container(UUID owner, ServerLevel level, BlockPos center) {
accounts.computeIfAbsent(owner, ignored -> new Account());
return new VaultContainer(this, owner, level, center.immutable());
public long count(UUID owner, Item item) {
int currency = currencyIndex(item);
Account account = accounts.get(owner);
return currency < 0 || account == null ? 0L : account.balance(currency);
}
public int count(UUID owner, Item item) {
if (!isCurrency(item)) return 0;
Account account = accounts.get(owner);
if (account == null) return 0;
long count = 0L;
for (ItemStack stack : account.items) if (stack.is(item)) count += stack.getCount();
return (int) Math.min(Integer.MAX_VALUE, count);
public int countCapped(UUID owner, Item item) {
return (int) Math.min(Integer.MAX_VALUE, count(owner, item));
}
public int remove(UUID owner, Item item, int amount) {
if (!isCurrency(item) || amount <= 0) return 0;
return (int) remove(owner, item, (long) amount);
}
public long remove(UUID owner, Item item, long amount) {
int currency = currencyIndex(item);
Account account = accounts.get(owner);
if (account == null) return 0;
int remaining = amount;
for (ItemStack stack : account.items) {
if (remaining <= 0) break;
if (!stack.is(item)) continue;
int removed = Math.min(remaining, stack.getCount());
stack.shrink(removed);
remaining -= removed;
}
if (remaining != amount) setDirty();
return amount - remaining;
if (currency < 0 || account == null || amount <= 0L) return 0L;
long removed = account.remove(currency, amount);
if (removed > 0L) setDirty();
return removed;
}
public int capacity(UUID owner, Item item) {
if (!isCurrency(item)) return 0;
long capacity = 0L;
for (ItemStack stack : items(owner)) {
if (stack.isEmpty()) capacity += item.getDefaultMaxStackSize();
else if (stack.is(item)) capacity += Math.max(0, stack.getMaxStackSize() - stack.getCount());
}
return (int) Math.min(Integer.MAX_VALUE, capacity);
}
public int add(UUID owner, Item item, int amount) {
if (!isCurrency(item) || amount <= 0) return 0;
NonNullList<ItemStack> stored = items(owner);
int remaining = amount;
for (ItemStack stack : stored) {
if (remaining <= 0) break;
if (!stack.is(item) || stack.getCount() >= stack.getMaxStackSize()) continue;
int inserted = Math.min(remaining, stack.getMaxStackSize() - stack.getCount());
stack.grow(inserted);
remaining -= inserted;
}
for (int slot = 0; slot < stored.size() && remaining > 0; slot++) {
if (!stored.get(slot).isEmpty()) continue;
int inserted = Math.min(remaining, item.getDefaultMaxStackSize());
stored.set(slot, new ItemStack(item, inserted));
remaining -= inserted;
}
if (remaining != amount) setDirty();
return amount - remaining;
public long add(UUID owner, Item item, long amount) {
int currency = currencyIndex(item);
if (currency < 0 || amount <= 0L) return 0L;
long added = accounts.computeIfAbsent(owner, ignored -> new Account()).add(currency, amount);
if (added > 0L) setDirty();
return added;
}
public boolean hasContents(UUID owner) {
Account account = accounts.get(owner);
return account != null && account.items.stream().anyMatch(stack -> !stack.isEmpty());
return account != null && account.hasContents();
}
public List<ItemStack> raidLoot(UUID owner) {
List<ItemStack> loot = new ArrayList<>();
for (Identifier id : List.of(RUBY, SAPPHIRE, EMERALD)) {
Item item = BuiltInRegistries.ITEM.getOptional(id).orElse(null);
if (item == null) continue;
int amount = VaultRules.raidLoot(count(owner, item));
int removed = remove(owner, item, amount);
while (removed > 0) {
int stackSize = Math.min(removed, item.getDefaultMaxStackSize());
loot.add(new ItemStack(item, stackSize));
removed -= stackSize;
}
/** Transfers the exact 20% raid share into the attacker's unlimited balances without item spam. */
public long transferRaid(UUID owner, UUID attacker) {
if (!canReceiveRaidLoot(owner, attacker, hasRegisteredVault(attacker))) return 0L;
Account source = accounts.get(owner);
if (source == null) return 0L;
Account target = accounts.computeIfAbsent(attacker, ignored -> new Account());
long total = 0L;
for (int currency = 0; currency < VaultRules.CURRENCY_SLOTS; currency++) {
long requested = VaultRules.raidLoot(source.balance(currency));
long accepted = Math.min(requested, VaultRules.MAX_BALANCE - target.balance(currency));
long removed = source.remove(currency, accepted);
long added = target.add(currency, removed);
if (added < removed) source.add(currency, removed - added);
total = VaultRules.saturatingAdd(total, added);
}
return List.copyOf(loot);
if (total > 0L) setDirty();
return total;
}
static boolean canReceiveRaidLoot(UUID owner, UUID attacker, boolean attackerHasRegisteredVault) {
return owner != null && attacker != null && !owner.equals(attacker) && attackerHasRegisteredVault;
}
public long protectionRemaining(UUID owner, long gameTime) {
@@ -217,90 +202,74 @@ public final class VaultData extends SavedData {
}
public static boolean isCurrency(ItemStack stack) {
return stack != null && !stack.isEmpty() && isCurrency(stack.getItem());
return stack != null && !stack.isEmpty() && currencyIndex(stack) >= 0;
}
public static boolean isCurrency(Item item) {
if (item == null) return false;
Identifier id = BuiltInRegistries.ITEM.getKey(item);
return RUBY.equals(id) || SAPPHIRE.equals(id) || EMERALD.equals(id);
}
private NonNullList<ItemStack> items(UUID owner) {
return accounts.computeIfAbsent(owner, ignored -> new Account()).items;
return currencyIndex(item) >= 0;
}
private List<StoredAccount> storedAccounts() {
List<StoredAccount> stored = new ArrayList<>();
accounts.forEach((owner, account) -> {
List<StoredSlot> slots = new ArrayList<>();
for (int index = 0; index < account.items.size(); index++) {
ItemStack stack = account.items.get(index);
if (!stack.isEmpty()) slots.add(new StoredSlot(index, stack));
}
stored.add(new StoredAccount(owner.toString(), List.copyOf(slots), account.protectedUntil));
});
accounts.forEach((owner, account) -> stored.add(new StoredAccount(owner.toString(), List.of(),
account.balance(0), account.balance(1), account.balance(2), account.protectedUntil)));
return List.copyOf(stored);
}
private static int currencyIndex(ItemStack stack) {
return stack == null || stack.isEmpty() ? -1 : currencyIndex(stack.getItem());
}
private static int currencyIndex(Item item) {
if (item == null) return -1;
return CURRENCIES.indexOf(BuiltInRegistries.ITEM.getKey(item));
}
private static String dimension(ServerLevel level) {
return level.dimension().identifier().toString();
}
private static final class Account {
private final NonNullList<ItemStack> items;
private final long[] balances = new long[VaultRules.CURRENCY_SLOTS];
private long protectedUntil;
private Account() { this(NonNullList.withSize(VaultRules.STORAGE_SLOTS, ItemStack.EMPTY), 0L); }
private Account(NonNullList<ItemStack> items, long protectedUntil) {
this.items = items;
this.protectedUntil = protectedUntil;
}
}
private static final class VaultContainer implements Container {
private final VaultData data;
private final UUID owner;
private final ServerLevel level;
private final BlockPos center;
private VaultContainer(VaultData data, UUID owner, ServerLevel level, BlockPos center) {
this.data = data;
this.owner = owner;
this.level = level;
this.center = center;
private Account() {
this(0L, 0L, 0L, 0L);
}
@Override public int getContainerSize() { return VaultRules.STORAGE_SLOTS; }
@Override public boolean isEmpty() { return data.items(owner).stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return data.items(owner).get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(data.items(owner), slot, amount);
if (!removed.isEmpty()) setChanged();
private Account(long rubies, long sapphires, long emeralds, long protectedUntil) {
balances[0] = Math.max(0L, rubies);
balances[1] = Math.max(0L, sapphires);
balances[2] = Math.max(0L, emeralds);
this.protectedUntil = Math.max(0L, protectedUntil);
}
private long balance(int currency) {
return balances[currency];
}
private long add(int currency, long amount) {
if (amount <= 0L) return 0L;
long previous = balances[currency];
balances[currency] = VaultRules.saturatingAdd(previous, amount);
return balances[currency] - previous;
}
private long remove(int currency, long amount) {
if (amount <= 0L) return 0L;
long removed = Math.min(amount, balances[currency]);
balances[currency] -= removed;
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(data.items(owner), slot);
if (!removed.isEmpty()) setChanged();
return removed;
private boolean hasContents() {
for (long balance : balances) if (balance > 0L) return true;
return false;
}
@Override public void setItem(int slot, ItemStack stack) {
if (stack != null && !stack.isEmpty() && !isCurrency(stack)) return;
data.items(owner).set(slot, stack == null ? ItemStack.EMPTY : stack);
setChanged();
}
@Override public void setChanged() { data.setDirty(); }
@Override public boolean stillValid(Player player) {
if (player == null || !player.getUUID().equals(owner)
|| player.distanceToSqr(center.getX() + 0.5D, center.getY() + 0.5D, center.getZ() + 0.5D) > 64.0D)
return false;
return data.isRegistered(level, center, owner)
&& VaultStructure.find(level, center).filter(formed -> formed.center().equals(center)).isPresent();
}
@Override public void clearContent() { data.items(owner).clear(); setChanged(); }
@Override public boolean canPlaceItem(int slot, ItemStack stack) { return isCurrency(stack); }
}
private record StoredSlot(int slot, ItemStack item) {}
private record StoredAccount(String owner, List<StoredSlot> items, long protectedUntil) {}
private record StoredAccount(String owner, List<StoredSlot> items, long rubies, long sapphires,
long emeralds, long protectedUntil) {}
private record VaultAddress(String dimension, long pos, String owner) {}
}
@@ -3,7 +3,6 @@ package fr.koka99cab.sanctuary26.anotherworld.vault;
import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod;
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldItems;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
@@ -49,6 +48,10 @@ public final class VaultDrillingService {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.inactive"));
return false;
}
if (!data.hasRegisteredVault(attacker.getUUID())) {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.raid_requires_vault"));
return false;
}
if (!data.hasContents(formed.owner())) {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty"));
return false;
@@ -105,6 +108,13 @@ public final class VaultDrillingService {
attacker.stopUsingItem();
return;
}
if (!VaultData.get(level.getServer()).hasRegisteredVault(attacker.getUUID())) {
cancel(attacker, false);
attacker.sendOverlayMessage(Component.translatable(
"message.anotherworld.safe_vault.raid_requires_vault"));
attacker.stopUsingItem();
return;
}
int elapsed = session.elapsedTicks() + 1;
Session progressed = session.withElapsed(elapsed);
@@ -125,14 +135,12 @@ public final class VaultDrillingService {
private static void complete(ServerPlayer attacker, ServerLevel level, Session session,
VaultStructure.Formed formed) {
VaultData data = VaultData.get(level.getServer());
List<ItemStack> loot = data.raidLoot(session.owner());
if (loot.isEmpty()) {
long total = data.transferRaid(session.owner(), attacker.getUUID());
if (total <= 0L) {
cancel(attacker, true);
attacker.stopUsingItem();
return;
}
int total = loot.stream().mapToInt(ItemStack::getCount).sum();
loot.forEach(stack -> attacker.getInventory().placeItemBackInInventory(stack));
data.protectAfterRaid(session.owner(), level.getServer().overworld().getGameTime());
ItemStack drill = attacker.getItemInHand(session.hand());
if (!attacker.hasInfiniteMaterials()) drill.shrink(1);
@@ -2,26 +2,46 @@ package fr.koka99cab.sanctuary26.anotherworld.vault;
import net.minecraft.util.StringRepresentable;
/** Stable position of one segment in the front-facing 3x3 vault wall. */
/** Stable position of one segment in the front-facing 3x3x3 vault cube. */
public enum VaultPart implements StringRepresentable {
BOTTOM_LEFT("bottom_left", 0, 0),
BOTTOM_CENTER("bottom_center", 1, 0),
BOTTOM_RIGHT("bottom_right", 2, 0),
MIDDLE_LEFT("middle_left", 0, 1),
CENTER("center", 1, 1),
MIDDLE_RIGHT("middle_right", 2, 1),
TOP_LEFT("top_left", 0, 2),
TOP_CENTER("top_center", 1, 2),
TOP_RIGHT("top_right", 2, 2);
BACK_BOTTOM_LEFT("back_bottom_left", 0, 0, 0),
BACK_BOTTOM_CENTER("back_bottom_center", 1, 0, 0),
BACK_BOTTOM_RIGHT("back_bottom_right", 2, 0, 0),
BACK_MIDDLE_LEFT("back_middle_left", 0, 1, 0),
BACK_CENTER("back_center", 1, 1, 0),
BACK_MIDDLE_RIGHT("back_middle_right", 2, 1, 0),
BACK_TOP_LEFT("back_top_left", 0, 2, 0),
BACK_TOP_CENTER("back_top_center", 1, 2, 0),
BACK_TOP_RIGHT("back_top_right", 2, 2, 0),
BOTTOM_LEFT("bottom_left", 0, 0, 1),
BOTTOM_CENTER("bottom_center", 1, 0, 1),
BOTTOM_RIGHT("bottom_right", 2, 0, 1),
MIDDLE_LEFT("middle_left", 0, 1, 1),
CENTER("center", 1, 1, 1),
MIDDLE_RIGHT("middle_right", 2, 1, 1),
TOP_LEFT("top_left", 0, 2, 1),
TOP_CENTER("top_center", 1, 2, 1),
TOP_RIGHT("top_right", 2, 2, 1),
FRONT_BOTTOM_LEFT("front_bottom_left", 0, 0, 2),
FRONT_BOTTOM_CENTER("front_bottom_center", 1, 0, 2),
FRONT_BOTTOM_RIGHT("front_bottom_right", 2, 0, 2),
FRONT_MIDDLE_LEFT("front_middle_left", 0, 1, 2),
FRONT_CENTER("front_center", 1, 1, 2),
FRONT_MIDDLE_RIGHT("front_middle_right", 2, 1, 2),
FRONT_TOP_LEFT("front_top_left", 0, 2, 2),
FRONT_TOP_CENTER("front_top_center", 1, 2, 2),
FRONT_TOP_RIGHT("front_top_right", 2, 2, 2);
private final String serializedName;
private final int column;
private final int row;
private final int depth;
VaultPart(String serializedName, int column, int row) {
VaultPart(String serializedName, int column, int row, int depth) {
this.serializedName = serializedName;
this.column = column;
this.row = row;
this.depth = depth;
}
public int column() {
@@ -32,11 +52,15 @@ public enum VaultPart implements StringRepresentable {
return row;
}
public static VaultPart at(int column, int row) {
public int depth() {
return depth;
}
public static VaultPart at(int column, int row, int depth) {
for (VaultPart part : values()) {
if (part.column == column && part.row == row) return part;
if (part.column == column && part.row == row && part.depth == depth) return part;
}
throw new IllegalArgumentException("Invalid vault part: " + column + ", " + row);
throw new IllegalArgumentException("Invalid vault part: " + column + ", " + row + ", " + depth);
}
@Override
@@ -3,12 +3,19 @@ package fr.koka99cab.sanctuary26.anotherworld.vault;
/** Pure balancing rules shared by runtime code and smoke tests. */
public final class VaultRules {
public static final int STRUCTURE_WIDTH = 3;
public static final int STRUCTURE_DEPTH = 3;
public static final int STRUCTURE_RADIUS = 1;
public static final int STRUCTURE_BLOCKS = STRUCTURE_WIDTH * STRUCTURE_WIDTH;
public static final int COMPLETE_STRUCTURE_MASK = (1 << STRUCTURE_BLOCKS) - 1;
public static final int STORAGE_SLOTS = 27;
public static final int STRUCTURE_POSITIONS = STRUCTURE_WIDTH * STRUCTURE_WIDTH * STRUCTURE_DEPTH;
public static final int STRUCTURE_BLOCKS = STRUCTURE_POSITIONS - 1;
public static final int CENTER_PART_INDEX = STRUCTURE_RADIUS * STRUCTURE_WIDTH * STRUCTURE_WIDTH
+ STRUCTURE_RADIUS * STRUCTURE_WIDTH + STRUCTURE_RADIUS;
public static final int FULL_STRUCTURE_MASK = (1 << STRUCTURE_POSITIONS) - 1;
public static final int COMPLETE_STRUCTURE_MASK = FULL_STRUCTURE_MASK & ~(1 << CENTER_PART_INDEX);
public static final int CURRENCY_SLOTS = 3;
public static final int BALANCE_WORDS_PER_CURRENCY = 2;
public static final int BALANCE_DATA_SLOTS = CURRENCY_SLOTS * BALANCE_WORDS_PER_CURRENCY;
public static final int BUTTONS_PER_CURRENCY = 4;
public static final long MAX_BALANCE = Long.MAX_VALUE;
public static final int RAID_DURATION_TICKS = 60 * 20;
public static final int RAID_LOOT_PERCENT = 20;
public static final long RAID_PROTECTION_TICKS = 24L * 60L * 60L * 20L;
@@ -16,10 +23,47 @@ public final class VaultRules {
private VaultRules() {
}
public static int raidLoot(int stored) {
public static long raidLoot(long stored) {
if (stored <= 0) return 0;
long rounded = ((long) stored * RAID_LOOT_PERCENT + 99L) / 100L;
return (int) Math.min(stored, Math.max(1L, rounded));
long rounded = stored / 100L * RAID_LOOT_PERCENT
+ (stored % 100L * RAID_LOOT_PERCENT + 99L) / 100L;
return Math.min(stored, Math.max(1L, rounded));
}
public static long saturatingAdd(long current, long added) {
if (current < 0L || added <= 0L) return Math.max(0L, current);
return added > MAX_BALANCE - current ? MAX_BALANCE : current + added;
}
public static int balanceWord(long balance, int word) {
long safe = Math.clamp(balance, 0L, MAX_BALANCE);
return switch (word) {
case 0 -> (int) safe;
case 1 -> (int) (safe >>> 32);
default -> 0;
};
}
public static long balanceFromWords(int low, int high) {
return Math.max(0L, ((long) high << 32) | Integer.toUnsignedLong(low));
}
public static String formatBalance(long balance) {
long safe = Math.max(0L, balance);
if (safe < 1_000L) return Long.toString(safe);
long[] units = {1_000L, 1_000_000L, 1_000_000_000L, 1_000_000_000_000L,
1_000_000_000_000_000L};
String[] suffixes = {"K", "M", "B", "T", "Q"};
int unitIndex = units.length - 1;
while (unitIndex > 0 && safe < units[unitIndex]) unitIndex--;
long unit = units[unitIndex];
long whole = safe / unit;
if (whole >= 1_000L) return safe / 1_000_000_000_000_000_000L + "E18";
if (whole < 10L) {
long tenth = safe % unit * 10L / unit;
if (tenth > 0L) return whole + "." + tenth + suffixes[unitIndex];
}
return whole + suffixes[unitIndex];
}
public static int progressPercent(int elapsedTicks) {
@@ -30,6 +74,14 @@ public final class VaultRules {
return partMask == COMPLETE_STRUCTURE_MASK;
}
public static int partIndex(int column, int row, int depth) {
if (column < 0 || column >= STRUCTURE_WIDTH || row < 0 || row >= STRUCTURE_WIDTH
|| depth < 0 || depth >= STRUCTURE_DEPTH) {
throw new IllegalArgumentException("Invalid vault coordinates: " + column + ", " + row + ", " + depth);
}
return depth * STRUCTURE_WIDTH * STRUCTURE_WIDTH + row * STRUCTURE_WIDTH + column;
}
public static int buttonCurrency(int buttonId) {
return validButton(buttonId) ? buttonId / BUTTONS_PER_CURRENCY : -1;
}
@@ -26,7 +26,7 @@ public final class VaultService {
initialized = true;
SanctuaryCurrencyStorage.register(AnotherWorldMod.id("safe_vault"), new SanctuaryCurrencyStorage.Provider() {
@Override public int count(ServerPlayer player, net.minecraft.world.item.Item item) {
return VaultData.get(player.level().getServer()).count(player.getUUID(), item);
return VaultData.get(player.level().getServer()).countCapped(player.getUUID(), item);
}
@Override public int remove(ServerPlayer player, net.minecraft.world.item.Item item, int amount) {
return VaultData.get(player.level().getServer()).remove(player.getUUID(), item, amount);
@@ -61,15 +61,18 @@ public final class VaultService {
net.minecraft.core.BlockPos center = VaultStructure.centerFor(pos, state);
var facing = state.getValue(SafeVaultBlock.FACING);
var width = facing.getClockWise();
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
var member = center.relative(width, column - VaultRules.STRUCTURE_RADIUS)
.above(row - VaultRules.STRUCTURE_RADIUS);
BlockState memberState = level.getBlockState(member);
if (!memberState.is(AnotherWorldBlocks.SAFE_BLOCK)) continue;
level.setBlock(member, memberState
.setValue(SafeVaultBlock.FORMED, false)
.setValue(SafeVaultBlock.PART, VaultPart.CENTER), Block.UPDATE_CLIENTS);
for (int depth = 0; depth < VaultRules.STRUCTURE_DEPTH; depth++) {
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
var member = center.relative(width, column - VaultRules.STRUCTURE_RADIUS)
.above(row - VaultRules.STRUCTURE_RADIUS)
.relative(facing, depth - VaultRules.STRUCTURE_RADIUS);
BlockState memberState = level.getBlockState(member);
if (!memberState.is(AnotherWorldBlocks.SAFE_BLOCK)) continue;
level.setBlock(member, memberState
.setValue(SafeVaultBlock.FORMED, false)
.setValue(SafeVaultBlock.PART, VaultPart.CENTER), Block.UPDATE_CLIENTS);
}
}
}
}
@@ -12,7 +12,7 @@ import net.minecraft.core.Direction;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.Level;
/** Detects one isolated, upright 3x3 vault wall owned by a single player. */
/** Detects one isolated, hollow 3x3x3 vault cube owned by a single player. */
public final class VaultStructure {
private VaultStructure() {
}
@@ -29,41 +29,66 @@ public final class VaultStructure {
offsetWidth <= VaultRules.STRUCTURE_RADIUS; offsetWidth++) {
for (int offsetY = -VaultRules.STRUCTURE_RADIUS;
offsetY <= VaultRules.STRUCTURE_RADIUS; offsetY++) {
BlockPos center = member.relative(width, -offsetWidth).below(offsetY);
Formed formed = validate(level, center, facing);
if (formed != null && formed.contains(member)) return Optional.of(formed);
for (int offsetDepth = -VaultRules.STRUCTURE_RADIUS;
offsetDepth <= VaultRules.STRUCTURE_RADIUS; offsetDepth++) {
BlockPos center = member.relative(width, -offsetWidth).below(offsetY)
.relative(facing, -offsetDepth);
Formed formed = validate(level, center, facing);
if (formed != null && formed.contains(member)) return Optional.of(formed);
}
}
}
}
return Optional.empty();
}
public static Optional<Formed> findAtCenter(Level level, BlockPos center) {
if (level == null || center == null) return Optional.empty();
for (Direction direction : Direction.Plane.HORIZONTAL) {
Optional<Formed> formed = find(level, center.relative(direction))
.filter(candidate -> candidate.center().equals(center));
if (formed.isPresent()) return formed;
}
return Optional.empty();
}
private static Formed validate(Level level, BlockPos center, Direction facing) {
UUID owner = null;
List<BlockPos> members = new ArrayList<>(VaultRules.STRUCTURE_BLOCKS);
int partMask = 0;
Direction width = facing.getClockWise();
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
BlockPos pos = center.relative(width, column - VaultRules.STRUCTURE_RADIUS)
.above(row - VaultRules.STRUCTURE_RADIUS);
if (!level.getBlockState(pos).is(AnotherWorldBlocks.SAFE_BLOCK)
|| !(level.getBlockEntity(pos) instanceof SafeVaultBlockEntity blockEntity)
|| blockEntity.owner() == null) return null;
if (owner == null) owner = blockEntity.owner();
else if (!owner.equals(blockEntity.owner())) return null;
members.add(pos.immutable());
partMask |= 1 << (row * VaultRules.STRUCTURE_WIDTH + column);
for (int depth = 0; depth < VaultRules.STRUCTURE_DEPTH; depth++) {
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
BlockPos pos = center.relative(width, column - VaultRules.STRUCTURE_RADIUS)
.above(row - VaultRules.STRUCTURE_RADIUS)
.relative(facing, depth - VaultRules.STRUCTURE_RADIUS);
if (column == VaultRules.STRUCTURE_RADIUS && row == VaultRules.STRUCTURE_RADIUS
&& depth == VaultRules.STRUCTURE_RADIUS) {
if (level.getBlockState(pos).is(AnotherWorldBlocks.SAFE_BLOCK)) return null;
continue;
}
if (!level.getBlockState(pos).is(AnotherWorldBlocks.SAFE_BLOCK)
|| !(level.getBlockEntity(pos) instanceof SafeVaultBlockEntity blockEntity)
|| blockEntity.owner() == null) return null;
if (owner == null) owner = blockEntity.owner();
else if (!owner.equals(blockEntity.owner())) return null;
members.add(pos.immutable());
partMask |= 1 << VaultRules.partIndex(column, row, depth);
}
}
}
if (!VaultRules.isCompleteStructureMask(partMask)) return null;
// Reject ambiguous 3x4/4x3 walls: a vault is one discrete upright 3x3 structure.
// Reject attached Safe Blocks: a vault is one discrete 3x3x3 cube.
for (int widthOffset = -2; widthOffset <= 2; widthOffset++) {
for (int yOffset = -2; yOffset <= 2; yOffset++) {
if (Math.abs(widthOffset) <= 1 && Math.abs(yOffset) <= 1) continue;
if (level.getBlockState(center.relative(width, widthOffset).above(yOffset))
.is(AnotherWorldBlocks.SAFE_BLOCK)) return null;
for (int depthOffset = -2; depthOffset <= 2; depthOffset++) {
if (Math.abs(widthOffset) <= 1 && Math.abs(yOffset) <= 1
&& Math.abs(depthOffset) <= 1) continue;
if (level.getBlockState(center.relative(width, widthOffset).above(yOffset)
.relative(facing, depthOffset)).is(AnotherWorldBlocks.SAFE_BLOCK)) return null;
}
}
}
return new Formed(center.immutable(), owner, List.copyOf(members), facing);
@@ -73,7 +98,9 @@ public final class VaultStructure {
VaultPart part = state.getValue(SafeVaultBlock.PART);
Direction width = state.getValue(SafeVaultBlock.FACING).getClockWise();
return member.relative(width, VaultRules.STRUCTURE_RADIUS - part.column())
.below(part.row() - VaultRules.STRUCTURE_RADIUS).immutable();
.below(part.row() - VaultRules.STRUCTURE_RADIUS)
.relative(state.getValue(SafeVaultBlock.FACING), VaultRules.STRUCTURE_RADIUS - part.depth())
.immutable();
}
public record Formed(BlockPos center, UUID owner, List<BlockPos> members, Direction facing) {
@@ -86,7 +113,13 @@ public final class VaultStructure {
int column = (pos.getX() - center.getX()) * width.getStepX()
+ (pos.getZ() - center.getZ()) * width.getStepZ() + VaultRules.STRUCTURE_RADIUS;
int row = pos.getY() - center.getY() + VaultRules.STRUCTURE_RADIUS;
return VaultPart.at(column, row);
int depth = (pos.getX() - center.getX()) * facing.getStepX()
+ (pos.getZ() - center.getZ()) * facing.getStepZ() + VaultRules.STRUCTURE_RADIUS;
return VaultPart.at(column, row, depth);
}
public BlockPos controller() {
return center.relative(facing, VaultRules.STRUCTURE_RADIUS).immutable();
}
}
}
@@ -37,8 +37,8 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "anotherworld",
"data_version": 3,
"network_protocol": 1,
"data_version": 5,
"network_protocol": 2,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"descriptions": {
@@ -1,23 +1,44 @@
package fr.koka99cab.sanctuary26.anotherworld.vault;
import java.util.UUID;
public final class VaultRulesSmoke {
private VaultRulesSmoke() {
}
public static void main(String[] args) {
require(VaultRules.STRUCTURE_WIDTH == 3, "vault width");
require(VaultRules.STRUCTURE_BLOCKS == 9, "upright vault wall");
require(!VaultRules.isCompleteStructureMask(0b000_000_111),
"horizontal 3x1 must stay unformed");
require(!VaultRules.isCompleteStructureMask(0b001_001_001),
"vertical 3x1 must stay unformed");
require(VaultRules.isCompleteStructureMask(0b111_111_111),
"only all nine parts form the vault");
require(VaultRules.STORAGE_SLOTS == 27, "vault capacity");
require(VaultRules.STRUCTURE_DEPTH == 3, "vault depth");
require(VaultRules.STRUCTURE_POSITIONS == 27, "vault cube positions");
require(VaultRules.STRUCTURE_BLOCKS == 26, "hollow vault shell");
require(VaultRules.CENTER_PART_INDEX == VaultRules.partIndex(1, 1, 1), "free center index");
require(Integer.bitCount(VaultRules.COMPLETE_STRUCTURE_MASK) == VaultRules.STRUCTURE_BLOCKS,
"26 required shell positions");
require(!VaultRules.isCompleteStructureMask((1 << 9) - 1),
"3x3 wall must stay unformed");
require(!VaultRules.isCompleteStructureMask(VaultRules.FULL_STRUCTURE_MASK),
"solid cube must stay unformed");
require(VaultRules.isCompleteStructureMask(VaultRules.COMPLETE_STRUCTURE_MASK),
"only the complete 26-block shell forms the vault");
require(VaultRules.CURRENCY_SLOTS == 3, "currency controls");
require(VaultPart.at(0, 0) == VaultPart.BOTTOM_LEFT, "bottom-left CTM part");
require(VaultPart.at(1, 1) == VaultPart.CENTER, "center CTM part");
require(VaultPart.at(2, 2) == VaultPart.TOP_RIGHT, "top-right CTM part");
require(VaultRules.BALANCE_DATA_SLOTS == 6, "64-bit balance synchronization");
require(VaultRules.MAX_BALANCE == Long.MAX_VALUE, "unlimited practical balance");
require(VaultPart.at(0, 0, 0) == VaultPart.BACK_BOTTOM_LEFT, "back-bottom-left CTM part");
require(VaultPart.at(1, 1, 1) == VaultPart.CENTER, "center CTM part");
require(VaultPart.at(2, 2, 2) == VaultPart.FRONT_TOP_RIGHT, "front-top-right CTM part");
boolean[] seenParts = new boolean[VaultRules.STRUCTURE_POSITIONS];
for (int depth = 0; depth < VaultRules.STRUCTURE_DEPTH; depth++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
int index = VaultRules.partIndex(column, row, depth);
require(!seenParts[index], "unique cube part index");
seenParts[index] = true;
VaultPart part = VaultPart.at(column, row, depth);
require(part.column() == column && part.row() == row && part.depth() == depth,
"cube part coordinates");
}
}
}
require(VaultRules.buttonCurrency(0) == 0 && VaultRules.buttonCurrency(11) == 2,
"button currency routing");
require(VaultRules.buttonAmount(0) == 64 && VaultRules.buttonAmount(1) == 1
@@ -33,7 +54,26 @@ public final class VaultRulesSmoke {
require(VaultRules.raidLoot(1) == 1, "minimum loot");
require(VaultRules.raidLoot(5) == 1, "exact 20 percent");
require(VaultRules.raidLoot(64) == 13, "rounded 20 percent");
require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730, "bounded large balance");
require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730L, "32-bit balance raid");
require(VaultRules.raidLoot(Long.MAX_VALUE) == 1_844_674_407_370_955_162L,
"64-bit balance raid without overflow");
UUID owner = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID attacker = UUID.fromString("00000000-0000-0000-0000-000000000002");
require(!VaultData.canReceiveRaidLoot(owner, attacker, false),
"an attacker without a registered vault cannot receive raid loot");
require(VaultData.canReceiveRaidLoot(owner, attacker, true),
"an attacker with a registered vault can receive raid loot");
require(!VaultData.canReceiveRaidLoot(owner, owner, true),
"a vault owner cannot receive loot from their own vault");
require(VaultRules.saturatingAdd(Long.MAX_VALUE - 2L, 10L) == Long.MAX_VALUE,
"balance saturation without overflow");
for (long balance : new long[] {0L, Integer.MAX_VALUE, (long) Integer.MAX_VALUE + 1L, Long.MAX_VALUE}) {
require(VaultRules.balanceFromWords(VaultRules.balanceWord(balance, 0),
VaultRules.balanceWord(balance, 1)) == balance, "64-bit balance synchronization round trip");
}
require(VaultRules.formatBalance(999L).equals("999"), "small balance display");
require(VaultRules.formatBalance(1_500L).equals("1.5K"), "compact balance display");
require(VaultRules.formatBalance(1_000_000L).equals("1M"), "million balance display");
require(VaultRules.progressPercent(0) == 0, "initial progress");
require(VaultRules.progressPercent(600) == 50, "half progress");
require(VaultRules.progressPercent(1_200) == 100, "complete progress");
+3018 -119
View File
File diff suppressed because it is too large Load Diff
+404
View File
@@ -0,0 +1,404 @@
# Sanctuary TikTok — organisation de réalisation
Ce document transforme les [100 premiers scripts](VIDEOS_001_100.md) en chaîne de production solo, reproductible et extensible. L'objectif est de produire vite sans perdre la sobriété, la précision technique ni la cohérence de Sanctuary.
## Objectif de la saison 001
- Produire les 100 vidéos en **10 à 12 semaines**.
- Publier **5 vidéos par semaine**, soit 20 semaines de diffusion.
- Maintenir en permanence **au moins 15 vidéos terminées d'avance**.
- Valider le langage visuel sur 12 pilotes avant d'industrialiser les 88 suivantes.
- Utiliser les performances pour choisir les prochains sujets, sans réécrire l'identité de la série à chaque publication.
Charge solo réaliste : **14 à 18 heures par semaine**, soit environ **140 à 180 heures** pour la saison complète. Les vidéos d'interface peuvent demander moins d'une heure ; les scènes multijoueur, worldgen ou avant/après authentiques peuvent en demander deux à quatre.
## Principe directeur
La publication suit les numéros. La réalisation suit les décors.
Tourner dix vidéos partageant la même ferme, le même laboratoire ou la même lumière est plus efficace que de réaliser les épisodes 001 à 010 dans l'ordre. Le numéro reste l'identité publique de la vidéo ; le lot reste son identité de production.
## Les neuf plateaux de tournage
| Code | Plateau | Épisodes principaux | Préparation indispensable |
|---|---|---|---|
| `UI` | Laboratoire interface | 003019, 083086, 097 | profils de progression, inventaires remplis, terminal et Shop |
| `WRLD` | Archipel et worldgen | 002, 020027, 053, 072079 | seed verrouillée, points de vue, météo et heures reproductibles |
| `ALPH` | Alpha / maison Indev | 028031 | portail, maison intacte, état de Notch et récompense réinitialisable |
| `MOVE` | Terrain de déplacement | 032039 | tours, deux niveaux de Waystones, ligne de poulies et trajectoire de grappin |
| `PASS` | Bestiaire calme | 040049, 053 | enclos invisibles ou hors champ, variantes préparées, fleurs et reproduction |
| `HOST` | Bestiaire hostile | 050059 | arène sombre, protections opérateur, cibles mobiles et second joueur si nécessaire |
| `FARM` | Ferme et cuisine | 060071 | parcelles par stade, six postes, pupitre, stocks d'ingrédients et deux joueurs |
| `TECH` | Atelier technologique | 080090 | Canon, terminal, ordinateurs, réseau de coffres et deux coffres-forts appartenant à des joueurs distincts |
| `OUCH` | Stand de tir / place serveur | 091100 | cibles, munitions, protections, Shop, panneaux, Mailboxes et second joueur |
Les commandes MasterKey servent à préparer les acteurs et les états, jamais à apparaître dans l'image finale. Les scènes doivent ensuite montrer le comportement de jeu normal.
## Les six vagues de production
### P — Pilote, 12 vidéos
`001, 003, 006, 014, 020, 031, 041, 053, 062, 077, 086, 099`
But : éprouver le manifeste, l'interface, le monde, une mécanique spectaculaire, une créature, l'agriculture, l'ambiance et le multijoueur.
Livrable : 12 exports finalisés, mais seulement les quatre premiers sont programmés immédiatement. Les huit autres restent disponibles pour corriger le système avant une diffusion complète.
### A — Progression et interfaces, 20 vidéos
`002, 004, 005, 007013, 015019, 082085, 097`
But : rentabiliser le laboratoire UI et verrouiller la manière de filmer les menus verticaux.
### B — Monde et Ambiance, 19 vidéos
`021030, 072076, 078081`
But : tourner toutes les variations de jour, nuit, météo, shader et dimension pendant que la seed et les cadrages sont verrouillés.
### C — Déplacement et bestiaire, 20 vidéos
`032040, 042052`
But : mutualiser les acteurs, les enclos, les caméras latérales et les scènes nécessitant du mouvement.
### D — Infectés, agriculture et Canaplia, 17 vidéos
`054061, 063071`
But : partager l'arène hostile, la ferme à stades contrôlés, les ingrédients et le deuxième joueur.
### E — Technologie, combat et systèmes sociaux, 12 vidéos
`087096, 098, 100`
But : terminer avec les scènes demandant le plus de préparation matérielle et de règles serveur.
Ces six listes couvrent les 100 épisodes une seule fois. Une vidéo pilote ne doit pas être retournée pendant sa vague thématique sauf si le test révèle un défaut concret.
## Calendrier conseillé
### Semaine 0 — Installer l'usine
- Verrouiller la seed, la version du pack et les réglages vidéo.
- Créer les neuf plateaux ou sauvegardes de référence.
- Choisir la typographie, la taille du texte et l'unique animation de carton final.
- Créer un projet de montage modèle avec trois pistes vidéo, voix, sons du jeu, ambiance et sous-titres.
- Exporter un test de dix secondes et le regarder sur un téléphone réel.
- Préparer les douze fiches du pilote et leurs commandes de laboratoire.
### Semaine 1 — Produire le pilote
- Jour 1 : préparer les scènes `003, 006, 014, 086`.
- Jour 2 : capturer `001, 020, 053, 077` dans le monde.
- Jour 3 : capturer `031, 041, 062, 099` et les scènes multijoueur.
- Jour 4 : monter les douze versions brutes.
- Jour 5 : voix, texte, son, exports et contrôle qualité.
- Jour 6 : corrections et programmation des quatre premières publications.
### Semaine 2 — Publier et corriger le système
- Publier quatre vidéos espacées.
- Relever les performances à J+1 sans changer les vidéos déjà programmées au premier commentaire négatif.
- Identifier les problèmes répétés de lisibilité, de rythme ou de compréhension.
- Corriger uniquement le modèle de production concerné : accroche, taille de texte, cadrage, son ou durée.
- Commencer la vague A pendant la diffusion.
### Semaines 3 à 11 — Régime de croisière
- Produire environ dix nouvelles vidéos par semaine.
- Publier cinq vidéos par semaine.
- Consacrer une semaine complète aux grandes vagues A, B et C lorsqu'elles exigent davantage de scènes.
- Garder la semaine 11 pour les reprises, les scènes multijoueur manquantes et les exports finaux.
### Semaines 12 à 20 — Diffuser avec une saison d'avance
- Continuer la publication des 100 vidéos.
- Concevoir la saison 101200 à partir des formats qui fonctionnent, pas seulement des sujets qui ont fait le plus de vues.
- Maintenir un tampon minimum de quinze exports prêts.
## Semaine type en régime de croisière
| Jour | Travail | Durée cible | Livrable |
|---|---|---:|---|
| Lundi | verrouillage de dix scripts, vérification des sources, liste des accessoires | 2 h | dix fiches `LOCKED` |
| Mardi | préparation des scènes, acteurs, inventaires, heure et météo | 3 h | dix scènes `READY` |
| Mercredi | capture groupée du lot | 4 h | rushes nommés et vérifiés |
| Jeudi | montage image des dix vidéos | 4 h | dix montages `V1` |
| Vendredi | voix, texte, mixage, export et QC | 3 à 4 h | dix masters `APPROVED` |
| Samedi | programmation et revue des publications précédentes | 1 h | calendrier et décisions J+7 |
Ne pas imposer cette répartition lorsque la scène dicte autre chose. Une nuit de sang, un combat multijoueur ou une météo rare doivent être capturés en série pendant que leur état est disponible.
## Pipeline d'une vidéo
Chaque vidéo passe par un seul état à la fois :
```text
BACKLOG
→ LOCKED
→ READY
→ CAPTURED
→ V1
→ TEXT_AUDIO
→ QC
→ APPROVED
→ SCHEDULED
→ PUBLISHED
→ REVIEWED
```
- `BACKLOG` : sujet présent dans le catalogue.
- `LOCKED` : accroche, plans, texte et source validés pour la version distribuée du pack.
- `READY` : décor, acteurs, objets et état du monde préparés.
- `CAPTURED` : tous les rushes existent, sont lisibles et sauvegardés.
- `V1` : montage image complet sans finition.
- `TEXT_AUDIO` : texte exact, voix et sons finalisés.
- `QC` : contrôle sur ordinateur puis téléphone.
- `APPROVED` : master immuable prêt à publier.
- `SCHEDULED` : date, légende et couverture enregistrées.
- `PUBLISHED` : URL et date réelles ajoutées au suivi.
- `REVIEWED` : performances J+1 et J+7 enregistrées, décision de suite prise.
Une vidéo qui échoue au contrôle revient à l'état concerné. Elle ne saute jamais directement de `CAPTURED` à `SCHEDULED`.
## Arborescence locale des médias
Les rushes et exports sont trop lourds pour Git. Les conserver hors du dépôt, par exemple sous `/Users/koka/Movies/Sanctuary-TikTok/Season-001/` :
```text
Season-001/
├── 00-shared/
│ ├── brand/
│ ├── music-and-sfx/
│ ├── project-template/
│ └── export-presets/
├── 01-pilot/
├── 02-wave-a-ui/
├── 03-wave-b-world/
├── 04-wave-c-movement-living/
├── 05-wave-d-food-canaplia/
├── 06-wave-e-tech-combat-social/
└── episodes/
└── SNC-TT-001/
├── brief/
├── capture/
├── audio/
├── project/
├── exports/
└── cover/
```
Le dépôt Git conserve uniquement les scripts, le plan, les éventuels manifests texte et les décisions éditoriales. Il ne doit recevoir ni rushes, ni projets de montage lourds, ni exports vidéo.
## Convention de fichiers
Identifiant canonique : `SNC-TT-<numéro sur trois chiffres>`.
Exemples :
```text
SNC-TT-041__capture-01__wide.mov
SNC-TT-041__capture-02__flower.mov
SNC-TT-041__voice-fr__v02.wav
SNC-TT-041__master-fr__v03.mp4
SNC-TT-041__cover-fr__v01.png
SNC-TT-041__metadata.yml
```
Règles :
- ne jamais employer `final-final`, `nouveau` ou `ok2` ;
- incrémenter `v01`, `v02`, `v03` ;
- ne jamais remplacer un master `APPROVED` ; créer une nouvelle version ;
- conserver la langue dans le nom du master pour préparer EN et RU ;
- inscrire la version exacte du pack capturé dans `metadata.yml` ;
- conserver les musiques, sons et images avec leur provenance et leurs droits.
## Fiche de préparation obligatoire
Avant la capture, chaque épisode reçoit ces champs :
```yaml
id: SNC-TT-041
status: LOCKED
pack_version: 26.2.0-alpha.226
capture_batch: PASS
format: interaction
target_duration_seconds: 11
source_verified: true
actors: 1
multiplayer_required: false
world_snapshot: passive-mobs-v1
shots:
- dangerous-approach
- flower-given
- happy-follow
rights_checked: true
```
Le numéro de version sert à empêcher la publication tardive d'une règle devenue fausse après une mise à jour. Avant programmation, comparer le comportement capturé à la version actuellement distribuée.
## Préparation des scènes
### Décors
- Utiliser des volumes simples, de grandes surfaces lisibles et un fond sans détails concurrents.
- Réserver la pièce blanche ou gris très clair aux vidéos de laboratoire.
- Réserver le décor naturel aux découvertes et chroniques.
- Conserver les coordonnées et l'orientation de chaque caméra dans la fiche de scène.
- Pour un avant/après, réutiliser exactement la même caméra, la même heure et la même action.
### Caméras
N'utiliser que quatre familles de plans :
1. **preuve FPS** : interaction telle que le joueur la vit ;
2. **caméra latérale fixe** : mouvement, créature ou mécanisme ;
3. **plan large lent** : monde, météo ou révélation ;
4. **gros plan d'interface** : valeur, état ou changement de rangée.
Un cinquième type de plan doit justifier son existence. Aucun mouvement de caméra ne doit concurrencer la mécanique montrée.
### Portrait
- Capturer directement en portrait lorsque l'interface est la feature.
- Si la source est horizontale, cadrer toute l'action dans la zone verticale centrale dès le tournage.
- Tester la taille du GUI et les sous-titres sur téléphone avant de tourner tout un lot.
- Garder le texte loin des zones extrêmes supérieure et inférieure de l'écran.
### Multijoueur
Réserver une session acteur de deux à trois heures toutes les deux semaines. Y regrouper au minimum : `031, 037, 039, 048, 057, 065, 071, 090, 098 et 099`.
Préparer avant l'arrivée du second joueur : inventaires, permissions, Mailboxes, objectifs, position de caméra et état de sauvegarde. Le temps acteur doit servir à jouer la scène, pas à construire son décor.
## Montage minimaliste
### Structure par défaut
```text
0,01,5 s preuve ou anomalie visible
1,55,0 s action principale
5,010,0 s conséquence
10,014,0 s règle ou précision
dernier 0,51,0 s carton Sanctuary
```
Ce rythme est un point de départ. Une chronique contemplative peut respirer davantage ; une interaction évidente peut s'arrêter après huit secondes.
### Texte
- Une phrase d'accroche.
- Une phrase de compréhension ou de conséquence.
- Deux lignes maximum à la fois.
- Aucun paragraphe explicatif.
- Les nombres et noms techniques doivent être composés dans le logiciel de montage, jamais générés dans une image.
- Les sous-titres reprennent la voix sans ajouter une deuxième narration concurrente.
### Son
- Conserver le son de l'action : clic, déplacement, impact, ouverture, souffle ou ambiance.
- Utiliser le silence comme contraste avant une révélation.
- Éviter une musique différente à chaque vidéo ; une petite famille sonore suffit à créer la série.
- Vérifier les droits de tout son extérieur au jeu.
- Pour le disque blanc, n'utiliser qu'un extrait dont la publication est autorisée.
## Contrôle qualité avant publication
Une vidéo est `APPROVED` seulement si toutes les réponses sont oui :
- La feature est-elle compréhensible sans lire la légende TikTok ?
- La première preuve apparaît-elle dans les deux premières secondes ?
- Une seule idée principale est-elle défendue ?
- Les valeurs et comportements correspondent-ils encore à la version distribuée ?
- Le HUD est-il présent uniquement lorsqu'il sert de preuve ?
- Les textes restent-ils lisibles sur téléphone et limités à deux lignes ?
- L'action reste-t-elle visible sous les textes et les surfaces de l'application ?
- Le montage contient-il trois plans ou moins, sauf justification écrite ?
- Les sons, musiques et images peuvent-ils être publiés légalement ?
- Les commandes de laboratoire, messages opérateur et éléments privés sont-ils absents ?
- Le fichier porte-t-il le bon ID, la langue et la bonne version ?
- Le master a-t-il été regardé intégralement après export ?
## Publication
Cadence initiale : cinq vidéos par semaine.
| Jour | Type conseillé |
|---|---|
| Lundi | règle simple ou interface |
| Mardi | créature ou interaction |
| Mercredi | problème / correction |
| Vendredi | feature spectaculaire |
| Dimanche | chronique calme, monde ou détail absurde |
Ne pas publier dix épisodes du même module à la suite même s'ils ont été capturés ensemble. Alterner les plateaux donne l'impression d'un monde cohérent plutôt que de neuf mini-campagnes séparées.
La légende doit compléter la vidéo, pas répéter son texte. Structure recommandée :
```text
Une phrase de contexte.
La règle exacte en une phrase.
Sanctuary — épisode 041.
```
Limiter les appels à l'action. Une question réelle peut être utilisée lorsqu'elle ouvre le sujet suivant ; éviter les demandes génériques de like ou d'abonnement sur chaque publication.
## Revue des performances
Enregistrer à J+1 puis J+7 :
- vues ;
- rétention dans les deux premières secondes ;
- durée moyenne regardée ;
- taux de complétion ;
- relectures ;
- partages et enregistrements ;
- visites de profil ;
- questions ou incompréhensions récurrentes.
Comparer chaque vidéo à la médiane de son propre format : une chronique de 25 secondes ne doit pas être jugée comme une interaction de huit secondes.
Décisions :
- accroche forte et bonne complétion : produire une suite dans les sept jours ;
- accroche faible mais bonne complétion chez les spectateurs restants : recouper les deux premières secondes pour un futur sujet proche ;
- chute au moment d'une explication : raccourcir ou déplacer la phrase en légende ;
- question répétée : créer une vidéo-réponse, sans alourdir la vidéo originale ;
- bon sujet mais preuve peu visible : retourner la scène, pas seulement changer le texte ;
- résultat faible isolé : ne pas abandonner immédiatement tout le format.
## Tableau de suivi minimal
Le suivi peut rester dans un tableur ou un outil Kanban, avec une ligne par ID et ces colonnes :
| Champ | Usage |
|---|---|
| `id` | `SNC-TT-001` à `SNC-TT-100` |
| `title` | titre canonique du script |
| `batch` | `UI`, `WRLD`, `ALPH`, `MOVE`, `PASS`, `HOST`, `FARM`, `TECH` ou `OUCH` |
| `status` | état unique du pipeline |
| `pack_version` | version réellement capturée |
| `source_verified` | oui/non et date |
| `scene_ready` | oui/non |
| `multiplayer` | oui/non |
| `capture_date` | date réelle |
| `master_path` | chemin du master approuvé |
| `rights_checked` | oui/non |
| `scheduled_at` | date de programmation |
| `published_url` | URL réelle |
| `review_j1` | métriques et note courte |
| `review_j7` | métriques et décision |
| `follow_up` | nouvel ID ou action décidée |
## Prochaine action concrète
Commencer par la semaine 0, puis ne préparer que les douze pilotes. Le système n'est considéré comme verrouillé qu'après :
1. un export regardé sur téléphone ;
2. quatre publications réelles ;
3. une revue J+1 de ces quatre vidéos ;
4. une décision explicite sur la taille du texte, le rythme, le son et le carton final.
Après cette validation, les 88 vidéos restantes peuvent entrer en production industrielle sans devoir être remontées à cause d'un défaut de format découvert trop tard.
+950
View File
@@ -0,0 +1,950 @@
# Sanctuary TikTok — vidéos 001 à 100
Première saison éditoriale de TikTok sobres et minimalistes présentant Sanctuary comme une réinterprétation personnelle de Minecraft, une règle à la fois. La chaîne de production complète est décrite dans le [plan de réalisation](PRODUCTION_PLAN.md).
## Contrat de réalisation
- Format vertical 9:16, 1080 × 1920, 30 images par seconde.
- Une seule idée par vidéo, généralement en 10 à 20 secondes.
- Trois plans maximum, coupes franches, caméra fixe ou mouvement très lent.
- HUD masqué sauf lorsque l'interface constitue la preuve.
- Sons du jeu prioritaires ; musique et voix off facultatives.
- Deux lignes de texte maximum simultanément.
- Carton final de 0,5 à 1 seconde : `SANCTUARY / <SÉRIE> <NUMÉRO>`.
- Les valeurs, limites et comportements annoncés doivent rester identiques aux sources liées.
- Pour les correctifs, montrer le symptôme puis la correction ; ne jamais fabriquer un faux « avant ».
Les textes proposés sont une base de tournage. Ils peuvent être lus en voix off ou affichés à l'écran, mais ne doivent pas être doublés intégralement sur les deux canaux.
## Vue d'ensemble
| Épisodes | Axe principal |
|---|---|
| 001 | Manifeste de la série |
| 002019 | Progression, inventaire et Atlas |
| 020031 | Monde flottant, biomes et Alpha |
| 032039 | Waystones, grappin et poulies |
| 040059 | Creepers, animaux, monstres et familiers |
| 060071 | Agriculture, cuisine et Canaplia |
| 072081 | Temps, météo, lumière, image et audio |
| 082090 | Red-Stoner, stockage et coffre-fort |
| 091096 | Arsenal Ouch |
| 097100 | Shop, marché, quêtes et Only Fun |
## 001 — Je réinterprète Minecraft, un détail à la fois
- **Format / durée :** manifeste, 15 s.
- **Accroche :** « Je ne voulais pas seulement ajouter des mods à Minecraft. »
- **Plans :** îles flottantes au loin ; Creeper portant une fleur ; inventaire qui gagne une nouvelle rangée ; coupe noire.
- **Texte / voix :** « Je voulais réinterpréter ses règles, son monde et ses créatures. Un détail à la fois. »
- **Sortie :** `SANCTUARY / MANIFESTE 001`.
- **Source :** [présentation des dix modules](../../README.md#modules).
## 002 — Ici, tu commences réellement faible
- **Format / durée :** une règle, 12 s.
- **Accroche :** « Trois cœurs. Trois morceaux de faim. »
- **Plans :** apparition sur l'île ; gros plan propre sur le HUD ; tentative de minage volontairement lente.
- **Texte / voix :** « Dans Sanctuary, le personnage vanilla complet est quelque chose que tu reconstruis. »
- **Sortie :** `SANCTUARY / PROGRESSION 002`.
- **Source :** [départ de partie et statistiques initiales](../../sanctuary/PROGRESSION.md#2-départ-de-partie).
## 003 — Tu ne commences qu'avec ta hotbar
- **Format / durée :** une règle, 10 s.
- **Accroche :** « Au début, ceci est tout ton inventaire. »
- **Plans :** ramasser plusieurs objets ; ouvrir l'inventaire ; révéler uniquement les neuf cases disponibles.
- **Texte / voix :** « Les autres rangées ne sont pas cachées. Elles ne sont pas encore à toi. »
- **Sortie :** `SANCTUARY / INVENTAIRE 003`.
- **Source :** [inventaire extensible](../../README.md#progression-sanctuary-alpha).
## 004 — La place se gagne
- **Format / durée :** interaction, 14 s.
- **Accroche :** « Ton XP peut agrandir ton inventaire. »
- **Plans :** inventaire à une rangée ; achat du rang suivant dans Progression ; réouverture avec deux rangées.
- **Texte / voix :** « Cinq achats permettent de passer d'une seule rangée à six. Les déblocages restent acquis après la mort. »
- **Sortie :** `SANCTUARY / INVENTAIRE 004`.
- **Source :** [inventaire extensible et coûts](../../sanctuary/PROGRESSION.md#44-inventaire-extensible-alpha74).
## 005 — Tab change réellement de hotbar
- **Format / durée :** interaction cachée, 10 s.
- **Accroche :** « Ta deuxième rangée peut devenir ta hotbar. »
- **Plans :** objet distinct dans deux rangées ; pression sur `Tab` ; échange visible de la rangée active sans ouvrir de menu.
- **Texte / voix :** « `Tab` avance. `Maj + Tab` revient. Le serveur échange la vraie hotbar vanilla. »
- **Sortie :** `SANCTUARY / INVENTAIRE 005`.
- **Source :** [rangées de hotbar](../../README.md#progression-sanctuary-alpha).
## 006 — Miner 256 blocs sans tricher
- **Format / durée :** démonstration, 15 s.
- **Accroche :** « Le minage groupé grandit avec ton personnage. »
- **Plans :** cible composée de blocs identiques ; maintien de `Mouse 4` et sélection à la molette ; destruction bornée de la veine.
- **Texte / voix :** « Les rangs passent de 4 à 256 blocs. Portée, permissions, outils, usure et drops restent vérifiés par le serveur. »
- **Sortie :** `SANCTUARY / MINAGE 006`.
- **Source :** [rangs de minage groupé](../../sanctuary/PROGRESSION.md#minage--sept-améliorations).
## 007 — Construire jusqu'à 15 × 15
- **Format / durée :** démonstration, 15 s.
- **Accroche :** « Un clic peut poser un plan entier. »
- **Plans :** prévisualisation 3 × 3 ; sélection d'un rang supérieur à la molette ; placement final d'un plan 15 × 15.
- **Texte / voix :** « `B` et clic droit construisent de 3 × 3 à 15 × 15. Chaque bloc reste placé par une action Minecraft normale. »
- **Sortie :** `SANCTUARY / CONSTRUCTION 007`.
- **Source :** [construction groupée](../../sanctuary/PROGRESSION.md#construction--sept-améliorations).
## 008 — Ta portée devient une statistique
- **Format / durée :** avant / après, 12 s.
- **Accroche :** « Au départ, ce bloc est trop loin. »
- **Plans :** interaction refusée à trois blocs dépassés ; achat de Construction ; même caméra, interaction réussie jusqu'au plafond de 5,5 blocs.
- **Texte / voix :** « La portée de construction progresse. La portée d'attaque, elle, ne change pas. »
- **Sortie :** `SANCTUARY / PROGRESSION 008`.
- **Source :** [rangs de Construction](../../sanctuary/PROGRESSION.md#construction--sept-améliorations).
## 009 — Les bons repas donnent de vrais cœurs temporaires
- **Format / durée :** une règle, 13 s.
- **Accroche :** « Manger peut te protéger sans augmenter ta vie permanente. »
- **Plans :** HUD avant le repas ; consommation ; apparition des cœurs orange ; disparition d'une portion après sa durée.
- **Texte / voix :** « Nutrition, saturation et effets déterminent jusqu'à trois cœurs de repas pendant 15 à 60 secondes. »
- **Sortie :** `SANCTUARY / REPAS 009`.
- **Source :** [cœurs de repas](../../README.md#progression-sanctuary-alpha).
## 010 — Une barre pleine ne suffit pas pour sprinter
- **Format / durée :** problème / règle, 12 s.
- **Accroche :** « Six points de faim. Barre pleine. Sprint impossible. »
- **Plans :** tentative de sprint au rang initial ; achat du premier rang de Faim ; sprint réussi avec plus de six points.
- **Texte / voix :** « Sanctuary garde le seuil absolu de Minecraft. Ton premier rang de Faim débloque réellement la course. »
- **Sortie :** `SANCTUARY / FAIM 010`.
- **Source :** [faim et seuil de sprint](../../sanctuary/PROGRESSION.md#faim--sept-améliorations).
## 011 — Le souffle n'encombre plus l'écran
- **Format / durée :** avant / après, 10 s.
- **Accroche :** « Pourquoi afficher ton air quand tu es sur terre ? »
- **Plans :** HUD terrestre sans bulles ; entrée dans l'eau ; apparition de trois à dix bulles selon le rang.
- **Texte / voix :** « La réserve de souffle n'apparaît que lorsqu'elle devient utile. »
- **Sortie :** `SANCTUARY / HUD 011`.
- **Source :** [HUD de souffle](../../README.md#progression-sanctuary-alpha).
## 012 — L'expérience achète des capacités permanentes
- **Format / durée :** une règle, 14 s.
- **Accroche :** « Dans Sanctuary, l'XP ne sert pas qu'aux enchantements. »
- **Plans :** niveaux disponibles ; écran Progression ; achat du rang suivant ; mort rapide puis retour du rang.
- **Texte / voix :** « Vie, faim, minage, souffle, construction et inventaire progressent avec les niveaux vanilla. Les achats survivent à la mort. »
- **Sortie :** `SANCTUARY / PROGRESSION 012`.
- **Source :** [améliorations et récompenses](../../sanctuary/PROGRESSION.md#4-améliorations-et-récompenses).
## 013 — La progression appartient au serveur
- **Format / durée :** note de laboratoire, 16 s.
- **Accroche :** « Le client demande. Le serveur décide. »
- **Plans :** achat valide ; tentative sans assez d'XP ; action refusée sans désynchronisation.
- **Texte / voix :** « Le prix n'est jamais choisi par le client. Le serveur vérifie le rang, le solde et applique chaque capacité. »
- **Sortie :** `SANCTUARY / LABO 013`.
- **Source :** [contrat serveur de progression](../../README.md#progression-sanctuary-alpha).
## 014 — Une carte composée de 160 000 points
- **Format / durée :** révélation, 15 s.
- **Accroche :** « L'Atlas n'est pas une image vue du ciel. »
- **Plans :** ouverture avec `M` ; rotation lente du nuage de points ; zoom sur le relief d'un chunk.
- **Texte / voix :** « C'est une vue GPU orientable, construite depuis la hauteur et la couleur du terrain exploré. »
- **Sortie :** `SANCTUARY / ATLAS 014`.
- **Source :** [Atlas en nuage de points](../../README.md#progression-sanctuary-alpha).
## 015 — Le monde calculé n'est pas le monde découvert
- **Format / durée :** une règle, 14 s.
- **Accroche :** « L'Atlas connaît les biomes. Pas tes souvenirs. »
- **Plans :** fond de biomes discret ; marche vers une zone inconnue ; apparition du profil détaillé après chargement du chunk.
- **Texte / voix :** « Le fog of war sépare le monde prévisible des lieux que tu as réellement visités. »
- **Sortie :** `SANCTUARY / ATLAS 015`.
- **Source :** [fog of war persistant](../../README.md#progression-sanctuary-alpha).
## 016 — La carte peut exister dans le monde
- **Format / durée :** interaction, 12 s.
- **Accroche :** « Maintiens `Mouse 5`. »
- **Plans :** vue FPS normale ; apparition de la projection Atlas dans le monde ; regard vers l'horizon et disparition progressive.
- **Texte / voix :** « Son opacité suit ton regard pour ne pas masquer le paysage. »
- **Sortie :** `SANCTUARY / ATLAS 016`.
- **Source :** [projection temps réel de l'Atlas](../../README.md#progression-sanctuary-alpha).
## 017 — Une mini-carte à neuf positions
- **Format / durée :** interaction cachée, 13 s.
- **Accroche :** « La même carte peut devenir une mini-carte. »
- **Plans :** activation avec `Mouse 5` ; déplacement entre trois des neuf ancrages ; réglage du zoom à la molette.
- **Texte / voix :** « Permanente, maintenue ou désactivée. Placée là où ton interface en a besoin. »
- **Sortie :** `SANCTUARY / ATLAS 017`.
- **Source :** [modes et placements de mini-carte](../../README.md#progression-sanctuary-alpha).
## 018 — L'Atlas observe les traces du monde
- **Format / durée :** découverte, 15 s.
- **Accroche :** « Certains lieux se cartographient sans marqueur manuel. »
- **Plans :** passage près d'un coffre, d'un portail et d'un générateur ; ouverture de l'Atlas ; trois pictogrammes fixes visibles.
- **Texte / voix :** « Coffres, portails, générateurs et rails souterrains deviennent des points d'intérêt locaux. »
- **Sortie :** `SANCTUARY / ATLAS 018`.
- **Source :** [détection des lieux Atlas](../../README.md#progression-sanctuary-alpha).
## 019 — Maison et Arbre ne sont jamais devinés
- **Format / durée :** une règle, 14 s.
- **Accroche :** « Sanctuary refuse de décider où se trouve ta maison. »
- **Plans :** Personal Computer posé ; clic sur « Maison ici » ; apparition du pictogramme et de son libellé dans l'Atlas.
- **Texte / voix :** « Ces lieux sont personnels. Tu les déclares toi-même depuis ton ordinateur. »
- **Sortie :** `SANCTUARY / ATLAS 019`.
- **Source :** [Personal Computer et lieux personnels](../../README.md#red-stoner-et-photographie).
## 020 — Le monde principal est un archipel flottant
- **Format / durée :** chronique du monde, 18 s.
- **Accroche :** aucun texte pendant les deux premières secondes.
- **Plans :** départ sur l'île centrale ; travelling très lent au-dessus de la couronne de vide ; révélation des îles procédurales extérieures.
- **Texte / voix :** « Sanctuary remplace le monde par défaut : un refuge central, du vide, puis un archipel sans fin. »
- **Sortie :** `SANCTUARY / MONDE 020`.
- **Source :** [génération du monde principal](../../README.md#génération-du-monde-principal).
## 021 — Le vide protège le début de partie
- **Format / durée :** une règle du monde, 15 s.
- **Accroche :** « Cette absence de terrain est volontaire. »
- **Plans :** bord de l'île ; longue focale sur le vide ; îles lointaines reprenant au-delà de la couronne.
- **Texte / voix :** « Le refuge initial est séparé du monde procédural. S'en échapper devient une étape de progression. »
- **Sortie :** `SANCTUARY / MONDE 021`.
- **Source :** [départ de partie](../../sanctuary/PROGRESSION.md#2-départ-de-partie).
## 022 — 108 biomes, sans charger Terralith
- **Format / durée :** preuve visuelle, 18 s.
- **Accroche :** « Sanctuary distribue 108 biomes natifs. »
- **Plans :** trois biomes très contrastés en coupes franches ; survol d'une transition ; écran final sobre avec `108`.
- **Texte / voix :** « Huit biomes historiques et cent créations Terramix, générés sous les règles climatiques natives du pack. »
- **Sortie :** `SANCTUARY / BIOMES 022`.
- **Source :** [biomes natifs d'Another World](../../anotherworld/README.md#biomes-natifs).
## 023 — Le climat dessine des provinces
- **Format / durée :** note de conception, 17 s.
- **Accroche :** « Les biomes ne sont pas tirés au hasard chunk par chunk. »
- **Plans :** travelling continu traversant une zone chaude et sèche ; transition vers une zone humide ; carte large montrant les grands contours.
- **Texte / voix :** « Température et humidité forment de grandes provinces. Une variation locale casse ensuite leurs frontières. »
- **Sortie :** `SANCTUARY / CLIMAT 023`.
- **Source :** [prototype climatique de l'alpha.189](../../CHANGELOG.md#2620-alpha189).
## 024 — Les Sulfur Caves s'annoncent dans la pierre
- **Format / durée :** découverte, 15 s.
- **Accroche :** « Avant la caverne, la roche te prévient. »
- **Plans :** tunnel de deepslate ; première amorce de soufre ; révélation des piscines et pointes de la Sulfur Cave.
- **Texte / voix :** « Une trace visible précède la formation principale. L'exploration peut se lire dans les parois. »
- **Sortie :** `SANCTUARY / CAVERNES 024`.
- **Source :** [Sulfur Caves de l'alpha.179](../../CHANGELOG.md#2620-alpha179).
## 025 — Un donjon peut être suspendu sous une île
- **Format / durée :** chronique du monde, 17 s.
- **Accroche :** « Certains donjons n'ont pas de sol. »
- **Plans :** île flottante vue de côté ; descente verticale ; révélation d'un donjon de cobblestone accroché par des chaînes.
- **Texte / voix :** « S'il trouve de la matière au-dessus, le donjon s'y suspend. Sans ancrage, il n'apparaît pas. »
- **Sortie :** `SANCTUARY / DONJONS 025`.
- **Source :** [donjons suspendus de l'alpha.188](../../CHANGELOG.md#2620-alpha188).
## 026 — Une ravine tapissée de seize couleurs
- **Format / durée :** révélation, 14 s.
- **Accroche :** « Ce biome est volontairement presque introuvable. »
- **Plans :** marche dans un paysage sombre ; arrivée au bord ; plongée lente dans une Rainbow Ravine verticale.
- **Texte / voix :** « Les seize blocs arc-en-ciel suivent les entailles irrégulières du relief, du sommet jusqu'au fond. »
- **Sortie :** `SANCTUARY / BIOMES 026`.
- **Source :** [Rainbow Ravine de l'alpha.114](../../CHANGELOG.md#2620-alpha114).
## 027 — Un monde Minecraft fini en 128 × 128 × 64
- **Format / durée :** chronique historique, 18 s.
- **Accroche :** « Ce monde possède une vraie fin. »
- **Plans :** panorama de l'île Indev ; plage, caverne et maison en coupes courtes ; approche de la limite extérieure.
- **Texte / voix :** « Alpha reconstruit un volume déterministe de 128 × 128 × 64 blocs. Rien ne se génère au-delà. »
- **Sortie :** `SANCTUARY / ALPHA 027`.
- **Source :** [contrat de génération Alpha](../../sanctuary/ALPHA.md#contrat-de-génération).
## 028 — Le portail d'Alpha se construit en cobblestone moussue
- **Format / durée :** interaction, 15 s.
- **Accroche :** « Quatre sur cinq. Deux sur trois à l'intérieur. »
- **Plans :** construction du cadre extérieur 4 × 5 ; allumage au briquet ; arrivée sûre hors de la maison Indev.
- **Texte / voix :** « Le serveur valide le cadre, la dimension et l'arrivée. La première traversée crée une paire persistante. »
- **Sortie :** `SANCTUARY / PORTAIL 028`.
- **Source :** [accès à Alpha](../../sanctuary/ALPHA.md#accès).
## 029 — Les textures Alpha n'existent que dans Alpha
- **Format / durée :** avant / après, 16 s.
- **Accroche :** « Un resource pack peut appartenir à une dimension. »
- **Plans :** monde normal avec la pile habituelle ; traversée vers Alpha et rechargement ; retour et restauration exacte de l'apparence précédente.
- **Texte / voix :** « Ambiance compose Golden Days Base puis Alpha uniquement pendant la visite, sans redistribuer leurs fichiers. »
- **Sortie :** `SANCTUARY / AMBIANCE 029`.
- **Source :** [composition Golden Days](../../ambiance/GOLDEN_DAYS.md).
## 030 — Notch habite encore dans la maison Indev
- **Format / durée :** chronique, 16 s.
- **Accroche :** « Dans ce monde, Notch est un résident. Pas un spawn aléatoire. »
- **Plans :** approche silencieuse de la maison ; Notch à l'intérieur ; même maison après un redémarrage de la scène.
- **Texte / voix :** « Il existe en un seul exemplaire persistant. S'il disparaît, Sanctuary sait le ramener chez lui. »
- **Sortie :** `SANCTUARY / ALPHA 030`.
- **Source :** [résident d'Alpha](../../sanctuary/ALPHA.md#résident-et-jalon-mondial).
## 031 — La première victoire crée un seul Magic Carpet
- **Format / durée :** récompense, 18 s.
- **Accroche :** « Ce coffre n'existe qu'après la première victoire mondiale. »
- **Plans :** fin du combat de Notch ; feu d'artifice près de la maison ; ouverture du coffre avec le tapis placé au centre.
- **Texte / voix :** « La réservation et la livraison sont sauvegardées séparément : même un redémarrage ne duplique pas la récompense. »
- **Sortie :** `SANCTUARY / RÉCOMPENSE 031`.
- **Source :** [première récompense du Magic Carpet](../../iliketomoveit/MAGIC_CARPET.md#première-récompense).
## 032 — Une Waystone doit être découverte physiquement
- **Format / durée :** une règle, 13 s.
- **Accroche :** « Poser une pierre ne suffit pas pour la connaître. »
- **Plans :** menu sans destination ; arrivée à pied et contact avec la pierre ; destination maintenant visible.
- **Texte / voix :** « Chaque joueur possède son propre réseau de lieux réellement visités. »
- **Sortie :** `SANCTUARY / WAYSTONE 032`.
- **Source :** [Waystones locales](../../README.md#waystones-locaux).
## 033 — Le voyage coûte la distance parcourue
- **Format / durée :** une règle, 12 s.
- **Accroche :** « Une téléportation lointaine ne coûte pas comme un saut de voisinage. »
- **Plans :** deux destinations à des distances différentes ; affichage des coûts ; voyage vers la plus lointaine.
- **Texte / voix :** « Le serveur facture un niveau d'XP par tranche commencée de 512 blocs et recherche une arrivée sûre. »
- **Sortie :** `SANCTUARY / WAYSTONE 033`.
- **Source :** [coût des Waystones](../../README.md#waystones-locaux).
## 034 — Une pierre peut être renommée sans commande
- **Format / durée :** interaction cachée, 10 s.
- **Accroche :** « Ouvre une Waystone. Appuie sur `R`. »
- **Plans :** ouverture du réseau ; saisie d'un nom court ; validation avec `Entrée` puis affichage immédiat.
- **Texte / voix :** « `Échap` annule. Le nouveau nom reste attaché à la pierre. »
- **Sortie :** `SANCTUARY / WAYSTONE 034`.
- **Source :** [renommage des Waystones](../../README.md#waystones-locaux).
## 035 — Un ascenseur peut descendre vers l'inconnu
- **Format / durée :** interaction cachée, 13 s.
- **Accroche :** « Tu peux descendre vers un étage que tu n'as jamais visité. »
- **Plans :** deux Waystones alignées en X/Z ; accroupissement sur celle du haut ; arrivée en bas et découverte de la destination.
- **Texte / voix :** « La montée reste interdite tant que l'étage supérieur n'a pas été découvert. »
- **Sortie :** `SANCTUARY / ASCENSEUR 035`.
- **Source :** [ascenseur vertical Waystone](../../README.md#waystones-locaux).
## 036 — Le grappin conserve ton élan
- **Format / durée :** démonstration, 14 s.
- **Accroche :** « Ce grappin ne te téléporte pas vers son crochet. »
- **Plans :** lancement ; balancement en arc lisible ; lâcher au point bas et conservation de la vitesse.
- **Texte / voix :** « Charge, longueur de corde, rappel, descente et restitution de l'élan composent le mouvement. »
- **Sortie :** `SANCTUARY / GRAPPIN 036`.
- **Source :** [grappin et poulies](../../README.md#grappin-et-poulies).
## 037 — Un grappin fluide, même quand le serveur décide
- **Format / durée :** problème / Sanctuary, 16 s.
- **Accroche :** « En multijoueur, un grappin peut devenir une suite de corrections. »
- **Plans :** capture authentique d'un ancien mouvement saccadé si disponible ; version actuelle fluide ; très bref arrêt sur la position réconciliée.
- **Texte / voix :** « Le client prédit le même pendule. Le serveur reste l'autorité et ne corrige que dans une limite bornée. »
- **Sortie :** `SANCTUARY / LABO 037`.
- **Source :** [prédiction du grappin, alpha.196](../../CHANGELOG.md#2620-alpha196).
## 038 — Deux poulies créent une tyrolienne
- **Format / durée :** démonstration, 12 s.
- **Accroche :** « Deux points. Jusqu'à 128 blocs. »
- **Plans :** liaison des deux têtes ; vue latérale de la ligne ; traversée complète à vitesse constante.
- **Texte / voix :** « Les collisions sont vérifiées pendant tout le trajet. »
- **Sortie :** `SANCTUARY / POULIE 038`.
- **Source :** [poulies et tyrolienne](../../README.md#grappin-et-poulies).
## 039 — La tyrolienne peut piloter une machine
- **Format / durée :** interaction entre systèmes, 12 s.
- **Accroche :** « Le passage d'un joueur devient un signal. »
- **Plans :** lampe redstone éteinte reliée à la poulie ; joueur traversant ; signal 15 et lampe allumée pendant le passage.
- **Texte / voix :** « Le déplacement peut devenir une entrée d'automatisation. »
- **Sortie :** `SANCTUARY / SYSTÈMES 039`.
- **Source :** [signal redstone des poulies](../../README.md#grappin-et-poulies).
## 040 — Un Creeper peut naître avec sept apparences
- **Format / durée :** collection, 15 s.
- **Accroche :** « C'est toujours le Creeper vanilla. »
- **Plans :** même cadre, coupes rapides entre classic, simple, pig, business, better, shiny et starry night ; arrêt sur le dernier.
- **Texte / voix :** « L'entité, la charge et l'œuf restent vanilla. L'apparence et le tempérament deviennent persistants. »
- **Sortie :** `SANCTUARY / CREEPER 040`.
- **Source :** [apparences de Creepers](../../itsalive/README.md#creepers).
## 041 — Une fleur peut rendre un Creeper heureux
- **Format / durée :** interaction, 11 s.
- **Accroche :** « Essaie de lui donner une fleur. »
- **Plans :** approche dangereuse ; remise de la fleur ; fleur visible sur la tête et Creeper suivant le joueur.
- **Texte / voix :** « Le changement n'est pas cosmétique : son tempérament devient heureux. »
- **Sortie :** `SANCTUARY / CREEPER 041`.
- **Source :** [tempérament heureux](../../itsalive/README.md#creepers).
## 042 — Le bonheur d'un Creeper est réversible
- **Format / durée :** interaction cachée, 10 s.
- **Accroche :** « Tu peux reprendre la fleur. »
- **Plans :** Creeper heureux ; tonte ; fleur rendue au joueur et retour immédiat du tempérament dangereux.
- **Texte / voix :** « Rien n'est supprimé silencieusement. Même la fleur revient. »
- **Sortie :** `SANCTUARY / CREEPER 042`.
- **Source :** [tonte du Creeper](../../itsalive/README.md#creepers).
## 043 — Une poule sur 256 peut pondre ceci
- **Format / durée :** découverte rare, 12 s.
- **Accroche :** plan fixe sur l'œuf doré, sans texte pendant une seconde.
- **Plans :** poule vanilla ; ponte d'un œuf doré mise en scène avec une capture authentique ou une reproduction de laboratoire clairement identifiée ; lancer de l'œuf.
- **Texte / voix :** « Une poule vanilla possède une chance sur 256 de pondre l'entrée vers une nouvelle lignée. »
- **Sortie :** `SANCTUARY / ŒUF DORÉ 043`.
- **Source :** [œuf doré et poules à gemmes](../../itsalive/README.md#poules-à-gemmes).
## 044 — Les poules peuvent pondre des gemmes
- **Format / durée :** collection, 15 s.
- **Accroche :** « Verte, rouge ou bleue. Trois cadences différentes. »
- **Plans :** bébé issu de l'œuf doré ; trois variantes adultes ; émeraude, rubis et saphir pondus en coupes distinctes.
- **Texte / voix :** « L'émeraude est la plus rapide. Le saphir, la plus lente. »
- **Sortie :** `SANCTUARY / POULLES 044`.
- **Source :** [variantes et cadences des poules à gemmes](../../itsalive/README.md#poules-à-gemmes).
## 045 — Une vache sur 64 se tient debout
- **Format / durée :** découverte absurde, 11 s.
- **Accroche :** plan fixe d'une vache normale qui sort du cadre, puis révélation de la vache debout.
- **Plans :** contraste des deux silhouettes ; marche de la variante ; plan final frontal.
- **Texte / voix :** « La mutation est rare, persistante et peut se transmettre par l'élevage. »
- **Sortie :** `SANCTUARY / VACHE 045`.
- **Source :** [vaches debout rares](../../itsalive/README.md#vaches-debout-rares).
## 046 — Chaque villageois reçoit un nom stable
- **Format / durée :** une règle, 12 s.
- **Accroche :** « Ce villageois ne changera pas de nom au redémarrage. »
- **Plans :** trois villageois avec noms différents ; sortie puis retour dans le monde ; mêmes noms visibles.
- **Texte / voix :** « Le nom vient de son UUID. Un nom donné manuellement n'est jamais remplacé. »
- **Sortie :** `SANCTUARY / VILLAGE 046`.
- **Source :** [villageois nommés](../../itsalive/README.md#villageois-nommés).
## 047 — Il existe seize Mooblooms
- **Format / durée :** collection, 16 s.
- **Accroche :** « Une seule créature. Seize fleurs. »
- **Plans :** travelling devant une ligne de variantes ; gros plan sur les fleurs du dos ; gros plan sur celle qui suit la tête.
- **Texte / voix :** « Chaque variante est synchronisée, sauvegardée et possède son propre œuf de test. »
- **Sortie :** `SANCTUARY / MOOBLOOM 047`.
- **Source :** [seize variantes de Moobloom](../../itsalive/README.md#mooblooms).
## 048 — Un veau Moobloom hérite d'un de ses parents
- **Format / durée :** expérience, 13 s.
- **Accroche :** « Deux fleurs différentes. Une seule pour le veau. »
- **Plans :** deux parents de variantes distinctes ; reproduction ; révélation de la variante héritée.
- **Texte / voix :** « Le veau choisit aléatoirement la fleur de l'un de ses deux parents. »
- **Sortie :** `SANCTUARY / HÉRITAGE 048`.
- **Source :** [élevage des Mooblooms](../../itsalive/README.md#mooblooms).
## 049 — Les papillons se nourrissent de fleurs
- **Format / durée :** chronique calme, 14 s.
- **Accroche :** aucun texte pendant le premier vol.
- **Plans :** groupe de papillons autour d'un biome fleuri ; joueur tenant une fleur ; reproduction puis réaction agressive après une attaque.
- **Texte / voix :** « Ils suivent les fleurs, se reproduisent avec elles et se défendent lorsqu'on les attaque. »
- **Sortie :** `SANCTUARY / PAPILLONS 049`.
- **Source :** [papillons](../../itsalive/README.md#papillons).
## 050 — Le Ghost ne reste jamais jusqu'au matin
- **Format / durée :** horreur minimale, 15 s.
- **Accroche :** murmure hors champ, sans texte.
- **Plans :** Ghost traversant un mur ; charge courte vers le joueur ; lever du jour et disparition.
- **Texte / voix :** « La nuit, il hante, murmure, frappe, puis disparaît. Le jour le supprime. »
- **Sortie :** `SANCTUARY / GHOST 050`.
- **Source :** [Real Ghost](../../itsalive/README.md#real-ghost).
## 051 — Bracken recule quand tu le regardes
- **Format / durée :** horreur minimale, 15 s.
- **Accroche :** « Ne détourne pas les yeux. Mais ne le fixe pas trop longtemps. »
- **Plans :** Bracken approchant dans le dos ; regard du joueur et recul ; rage après un regard prolongé.
- **Texte / voix :** « Il préfère attaquer par derrière. Les haches et le feu lui font davantage de dégâts. »
- **Sortie :** `SANCTUARY / BRACKEN 051`.
- **Source :** [comportement de Bracken](../../itsalive/README.md#real-ghost).
## 052 — Sanctuary supprime les Phantoms
- **Format / durée :** une règle, 9 s.
- **Accroche :** « Les Phantoms n'existent pas ici. »
- **Plans :** nuit après plusieurs jours sans dormir ; ciel vide ; tentative de chargement d'un Phantom immédiatement supprimé en laboratoire.
- **Texte / voix :** « C'est la politique héritée de Real Ghost et No Phantoms. »
- **Sortie :** `SANCTUARY / RÈGLE 052`.
- **Source :** [suppression des Phantoms](../../itsalive/README.md#real-ghost).
## 053 — Une baleine de 36 blocs traverse le ciel
- **Format / durée :** chronique du monde, 18 s.
- **Accroche :** garder les deux premières secondes entièrement silencieuses.
- **Plans :** ombre passant sur le terrain ; révélation latérale de la baleine ; plan large avec son sillage nuageux.
- **Texte / voix :** « Elle est géante, intangible, invulnérable et ne donne aucun butin. Elle existe seulement pour traverser le monde. »
- **Sortie :** `SANCTUARY / BALEINE 053`.
- **Source :** [baleine volante géante](../../itsalive/README.md#baleine-volante-géante).
## 054 — Cinq Infectés, une seule entité persistante
- **Format / durée :** collection, 15 s.
- **Accroche :** « Rouge, jaune, orange, violet, vert. »
- **Plans :** ligne des cinq variantes ; gros plans sur les t-shirts ; attaque simultanée puis coupe noire.
- **Texte / voix :** « Infecté, vomiteur, cracheur, hunter et chargeur partagent la même base, mais pas le même comportement. »
- **Sortie :** `SANCTUARY / INFECTÉS 054`.
- **Source :** [cinq variantes d'Infectés](../../itsalive/README.md#infectés).
## 055 — Le Vomiteur transforme la vision en danger
- **Format / durée :** démonstration, 13 s.
- **Accroche :** attaque reçue avant l'apparition du texte.
- **Plans :** Vomiteur ; overlay de projections ; désorientation et monstres proches attirés vers le joueur.
- **Texte / voix :** « Neuf secondes de vision masquée. Douze secondes de désorientation. Et le bruit attire les monstres. »
- **Sortie :** `SANCTUARY / INFECTÉS 055`.
- **Source :** [attaque du Vomiteur](../../itsalive/README.md#infectés).
## 056 — Le Cracheur combat à distance
- **Format / durée :** démonstration, 11 s.
- **Accroche :** « Celui-ci n'a pas besoin de te toucher. »
- **Plans :** projectile acide en trajectoire ; impact ; effets Poison et Lenteur visibles.
- **Texte / voix :** « Une boule d'acide blesse, empoisonne et ralentit. »
- **Sortie :** `SANCTUARY / INFECTÉS 056`.
- **Source :** [attaque du Cracheur](../../itsalive/README.md#infectés).
## 057 — Quatre pressions pour repousser le Hunter
- **Format / durée :** règle de survie, 16 s.
- **Accroche :** écran noir au moment de la capture.
- **Plans :** bond corrigé vers une cible mobile ; Hunter assis sur la tête ; quatre pressions distinctes d'accroupissement puis libération.
- **Texte / voix :** « Il verrouille sa victime pendant six secondes. Résister quatre fois le repousse. »
- **Sortie :** `SANCTUARY / INFECTÉS 057`.
- **Source :** [capture du Hunter](../../itsalive/README.md#infectés).
## 058 — Le Chargeur devient vulnérable après l'impact
- **Format / durée :** règle de combat, 12 s.
- **Accroche :** « Esquiver est aussi une attaque. »
- **Plans :** préparation lisible ; charge et esquive latérale ; courte récupération pendant laquelle le joueur riposte.
- **Texte / voix :** « Il percute et repousse. Puis il doit récupérer. »
- **Sortie :** `SANCTUARY / INFECTÉS 058`.
- **Source :** [comportement du Chargeur](../../itsalive/README.md#infectés).
## 059 — Un œuf équipé devient un pouvoir
- **Format / durée :** manifeste de système, 16 s.
- **Accroche :** « Les œufs d'apparition ne servent plus seulement à faire apparaître des créatures. »
- **Plans :** œuf équipé dans le slot de familier ; apparition du familier visuel ; activation avec `G` de trois pouvoirs contrastés en coupes rapides.
- **Texte / voix :** « Une essence à la fois. Un passif. Un actif choisi et validé par le serveur. »
- **Sortie :** `SANCTUARY / FAMILIERS 059`.
- **Source :** [contrat des pouvoirs de familiers](../../sanctuary/COMPANION_POWERS.md#contrat-de-jeu).
## 060 — Certaines cultures poussent sur deux blocs
- **Format / durée :** time-lapse, 12 s.
- **Accroche :** « Le maïs ne reste pas une texture plate. »
- **Plans :** plant bas ; accéléré des huit stades ; maïs, tomate ou concombre devenant réellement haut de deux blocs.
- **Texte / voix :** « La moitié supérieure reste synchronisée avec le plant inférieur. »
- **Sortie :** `SANCTUARY / AGRICULTURE 060`.
- **Source :** [agriculture It's Alive](../../itsalive/README.md#agriculture).
## 061 — Récolter sans arracher la plante
- **Format / durée :** interaction, 11 s.
- **Accroche :** « Certaines récoltes sont pérennes. »
- **Plans :** plant mûr de tomate ; clic de récolte donnant les fruits ; retour visuel du plant à un stade plus jeune.
- **Texte / voix :** « Fraise, tomate, concombre, poivron, aubergine et raisin repoussent après la récolte. »
- **Sortie :** `SANCTUARY / AGRICULTURE 061`.
- **Source :** [cultures pérennes](../../itsalive/README.md#agriculture).
## 062 — Le riz exige une vraie rizière
- **Format / durée :** une règle, 12 s.
- **Accroche :** « Pas de riz sur une simple terre labourée. »
- **Plans :** plantation refusée sur une parcelle ordinaire ; source d'eau pleine posée sur de la boue ; croissance complète du riz immergé.
- **Texte / voix :** « Le décor et la règle agricole racontent la même chose. »
- **Sortie :** `SANCTUARY / CUISINE 062`.
- **Source :** [riz immergé](../../itsalive/FOOD_WIKI.md#riz--une-vraie-rizière).
## 063 — Le haricot change selon le moment de récolte
- **Format / durée :** comparaison, 12 s.
- **Accroche :** « Récolté jeune ou récolté sec, ce n'est pas le même ingrédient. »
- **Plans :** récolte d'un plant jeune ; même cadrage sur le plant mûr ; comparaison des deux objets.
- **Texte / voix :** « Le temps de récolte devient un choix culinaire. »
- **Sortie :** `SANCTUARY / CUISINE 063`.
- **Source :** [haricot vert ou sec](../../itsalive/FOOD_WIKI.md#haricot--vert-ou-sec).
## 064 — Six machines, six gestes culinaires
- **Format / durée :** collection, 18 s.
- **Accroche :** « Presser. Moudre. Poêler. Bouillir. Cuire. Fermenter. »
- **Plans :** une action lisible par poste, toujours avec la même caméra et la même table de travail ; résultat final aligné.
- **Texte / voix :** « La cuisine de Sanctuary n'est pas un unique menu de recettes. »
- **Sortie :** `SANCTUARY / CUISINE 064`.
- **Source :** [postes de cuisine](../../itsalive/FOOD_WIKI.md#postes-de-cuisine).
## 065 — Le livre culinaire possède 64 pages à découvrir
- **Format / durée :** découverte, 15 s.
- **Accroche :** « Les recettes ne sont pas toutes connues au départ. »
- **Plans :** livre presque vide ; obtention d'une page ; nouvelle recette révélée ; livre posé sur un pupitre et lu par un autre joueur.
- **Texte / voix :** « Le catalogue est personnel dans l'inventaire et temporairement partageable sur un pupitre. »
- **Sortie :** `SANCTUARY / LIVRE 065`.
- **Source :** [64 pages et partage du livre](../../itsalive/FOOD_WIKI.md#les-64-pages-de-recettes).
## 066 — Canaplia possède seize familles
- **Format / durée :** collection, 16 s.
- **Accroche :** « Seize plantes. Seize découvertes. Seize effets. »
- **Plans :** grille ou travelling de cultures matures ; trois têtes et résines en gros plan ; Livre interdit refermé.
- **Texte / voix :** « Chaque famille possède sa culture, sa progression et son profil serveur. »
- **Sortie :** `SANCTUARY / CANAPLIA 066`.
- **Source :** [migration Canaplia](../../CANAPLIA.md).
## 067 — Le climat décide où découvrir Canaplia
- **Format / durée :** exploration, 15 s.
- **Accroche :** « Toutes les plantes ne peuvent pas apparaître partout. »
- **Plans :** recherche dans un biome incompatible ; voyage vers le bon climat ; découverte de la variante sauvage.
- **Texte / voix :** « Les sources sont distribuées par climat. Ember reste au Nether. Indigo reste dans l'End. »
- **Sortie :** `SANCTUARY / CANAPLIA 067`.
- **Source :** [découvertes climatiques de l'alpha.208](../../CHANGELOG.md#2620-alpha208).
## 068 — Une hybridation vérifie réellement ses parents
- **Format / durée :** expérience, 15 s.
- **Accroche :** « Deux plantes côte à côte ne suffisent pas. »
- **Plans :** hybridation refusée dans de mauvaises conditions ; correction de l'humidité, de la lumière ou de l'heure ; résultat obtenu.
- **Texte / voix :** « Le serveur revalide les parents matures, l'eau, le climat, la lumière et le moment. »
- **Sortie :** `SANCTUARY / CANAPLIA 068`.
- **Source :** [six hybridations validées côté serveur](../../CHANGELOG.md#2620-alpha208).
## 069 — Dix têtes deviennent une résine
- **Format / durée :** transformation, 12 s.
- **Accroche :** « Dix têtes identiques. Une seule résine. »
- **Plans :** dix objets réunis ; transformation ; application et aura persistante visualisée sobrement.
- **Texte / voix :** « L'aura dure 90 secondes et reste attachée à son état serveur. »
- **Sortie :** `SANCTUARY / CANAPLIA 069`.
- **Source :** [résines Canaplia de l'alpha.208](../../CHANGELOG.md#2620-alpha208).
## 070 — Un bad trip doit rester accessible
- **Format / durée :** note de conception, 16 s.
- **Accroche :** « Un effet psychédélique ne doit pas rendre le jeu injouable. »
- **Plans :** profil normal ; profil bad trip à intensité modérée ; écran des réglages montrant réduction des mouvements, wobble et ghosting.
- **Texte / voix :** « Ambiance compose un seul effet à la fois et laisse réduire ses mouvements visuels. »
- **Sortie :** `SANCTUARY / ACCESSIBILITÉ 070`.
- **Source :** [profils Canaplia et réglages Ambiance](../../CHANGELOG.md#2620-alpha208).
## 071 — Le Livre interdit est personnel, puis partageable
- **Format / durée :** interaction, 14 s.
- **Accroche :** « Ton livre connaît tes découvertes. Pas celles de tout le serveur. »
- **Plans :** deux joueurs avec catalogues différents ; pose sur pupitre ; consultation temporaire par le second joueur ; retrait du livre.
- **Texte / voix :** « Sur un pupitre, le savoir se partage. Dans l'inventaire, il redevient personnel. »
- **Sortie :** `SANCTUARY / CANAPLIA 071`.
- **Source :** [Livre interdit de Canaplia](../../CANAPLIA.md).
## 072 — Le ciel suit l'heure réelle du serveur
- **Format / durée :** time-lapse, 15 s.
- **Accroche :** « Ici, midi dans le jeu peut être midi dehors. »
- **Plans :** horloge système ou carton d'heure sobre ; ciel correspondant ; accéléré contrôlé montrant la continuité du cycle.
- **Texte / voix :** « Ambiance peut synchroniser le cycle du ciel sur le fuseau du serveur ou sur un décalage UTC fixe. »
- **Sortie :** `SANCTUARY / TEMPS 072`.
- **Source :** [runtime Realtime de l'alpha.126](../../CHANGELOG.md#2620-alpha126).
## 073 — Le calendrier commence le 17 mai 2009
- **Format / durée :** détail d'auteur, 11 s.
- **Accroche :** « Le premier jour de ce monde a une date. »
- **Plans :** joueur tenant une horloge ; apparition du HUD calendrier ; gros plan sur la date et le mois.
- **Texte / voix :** « L'époque minecraftienne de Sanctuary commence le 17 mai 2009 et possède douze mois. »
- **Sortie :** `SANCTUARY / CALENDRIER 073`.
- **Source :** [calendrier Realtime](../../CHANGELOG.md#2620-alpha126).
## 074 — La météo de la journée est déterministe
- **Format / durée :** une règle du monde, 15 s.
- **Accroche :** « La météo n'est choisie qu'une fois par jour. »
- **Plans :** matin clair ; transition vers brouillard ou pluie légère ; consultation d'un appareil de prévision.
- **Texte / voix :** « Éclaircies, ciel couvert, brouillard, pluie légère ou pluie normale composent le profil quotidien. »
- **Sortie :** `SANCTUARY / MÉTÉO 074`.
- **Source :** [profils météo de l'alpha.128](../../CHANGELOG.md#2620-alpha128) ; [appareils de prévision de l'alpha.131](../../CHANGELOG.md#2620-alpha131).
## 075 — Une pleine lune sur quatre devient rouge
- **Format / durée :** chronique nocturne, 16 s.
- **Accroche :** lune blanche pendant deux secondes, puis coupe vers la lune de sang.
- **Plans :** pleine lune et nuit blanche ; autre cycle en rouge ; plan du paysage sous la nouvelle ambiance.
- **Texte / voix :** « Le cycle vanilla reste sur huit jours. La première nuit de sang arrive au jour 24. »
- **Sortie :** `SANCTUARY / LUNE 075`.
- **Source :** [cycle lunaire de l'alpha.128](../../CHANGELOG.md#2620-alpha128).
## 076 — Le premier jour refuse la mauvaise météo
- **Format / durée :** une règle du monde, 10 s.
- **Accroche :** « Le premier jour doit rester lisible. »
- **Plans :** première apparition ; ciel clair ; accéléré jusqu'au coucher sans pluie ni brouillard.
- **Texte / voix :** « Le jour zéro est toujours clair. L'orage est rare et interdit pendant cette première journée. »
- **Sortie :** `SANCTUARY / MÉTÉO 076`.
- **Source :** [premier jour de l'alpha.128](../../CHANGELOG.md#2620-alpha128).
## 077 — Tout bloc lumineux tenu éclaire réellement
- **Format / durée :** avant / après, 12 s.
- **Accroche :** « La torche n'a pas besoin d'être posée. »
- **Plans :** grotte noire ; torche prise en main principale ; deuxième lumière tenue en main secondaire avec le terrain et les entités éclairés.
- **Texte / voix :** « La lumière locale reprend le niveau vanilla du bloc et décroît d'un niveau par bloc. »
- **Sortie :** `SANCTUARY / LUMIÈRE 077`.
- **Source :** [Dynamic Torches de l'alpha.130](../../CHANGELOG.md#2620-alpha130).
## 078 — La pluie ne doit pas simplement assombrir l'image
- **Format / durée :** avant / après conceptuel, 14 s.
- **Accroche :** « Une météo différente n'a pas besoin d'un filtre gris. »
- **Plans :** paysage normal ; même cadrage sous pluie légère ; même cadrage sous orage avec seulement la portée du brouillard transformée.
- **Texte / voix :** « Les profils météo modifient la visibilité sans écraser la colorimétrie du monde. »
- **Sortie :** `SANCTUARY / AMBIANCE 078`.
- **Source :** [signature visuelle de l'alpha.129](../../CHANGELOG.md#2620-alpha129).
## 079 — Un shader remplace proprement l'étalonnage final
- **Format / durée :** note de laboratoire, 14 s.
- **Accroche :** « Deux traitements d'image superposés donnent rarement une meilleure image. »
- **Plans :** Ambiance sans shader ; activation d'Iris ; rendu final sans double étalonnage.
- **Texte / voix :** « Quand Iris est actif, son traitement final remplace celui d'Ambiance au lieu de s'y empiler. »
- **Sortie :** `SANCTUARY / LABO 079`.
- **Source :** [compatibilité Iris de l'alpha.129](../../CHANGELOG.md#2620-alpha129).
## 080 — L'appareil photo capture l'image après le shader
- **Format / durée :** problème / Sanctuary, 14 s.
- **Accroche :** « Une photo doit ressembler à ce que le joueur voit. »
- **Plans :** scène avec shader ; visée Canon ; carte photo finale comparée au cadre avant le HUD.
- **Texte / voix :** « Canon capture après la composition finale d'Iris et avant l'interface. »
- **Sortie :** `SANCTUARY / PHOTO 080`.
- **Source :** [capture Canon compatible shaders](../../CHANGELOG.md#2620-alpha129).
## 081 — Le disque n'était pas trop grave à cause du pitch
- **Format / durée :** bug / correction, 18 s.
- **Accroche :** « Ce disque jouait trop lentement et trop grave. »
- **Plans :** extrait de test autorisé du symptôme historique ; carton très bref `stéréo 48 kHz → mono` ; lecture corrigée au même endroit.
- **Texte / voix :** « La source stéréo était mal convertie. Sanctuary moyenne maintenant chaque paire gauche-droite, avec une vitesse OpenAL normale. »
- **Sortie :** `SANCTUARY / LABO 081`.
- **Source :** [correction audio de l'alpha.221](../../CHANGELOG.md#2620-alpha221).
## 082 — Une demi-dalle peut ouvrir 128 conteneurs
- **Format / durée :** révélation fonctionnelle, 15 s.
- **Accroche :** « Ce terminal ne contient aucun objet. »
- **Plans :** terminal fixé sur un coffre ; recul révélant le réseau contigu ; ouverture de la grille agrégée.
- **Texte / voix :** « Il donne accès à un réseau borné de 128 coffres, coffres piégés ou barils chargés. »
- **Sortie :** `SANCTUARY / STOCKAGE 082`.
- **Source :** [terminal Red-Stoner de l'alpha.218](../../CHANGELOG.md#2620-alpha218).
## 083 — Le terminal affiche 54 types par page
- **Format / durée :** interaction, 12 s.
- **Accroche :** « Une page. Cinquante-quatre types. »
- **Plans :** première page ; défilement ; changement de page en conservant l'ordre des emplacements.
- **Texte / voix :** « La grille agrège les types d'objets, pas les cases physiques des coffres. »
- **Sortie :** `SANCTUARY / STOCKAGE 083`.
- **Source :** [pagination du terminal, alpha.218](../../CHANGELOG.md#2620-alpha218).
## 084 — La recherche du terminal comprend Unicode
- **Format / durée :** démonstration d'interface, 13 s.
- **Accroche :** « Accents et alphabets Unicode restent recherchables. »
- **Plans :** saisie d'un nom Unicode réellement présent dans le catalogue ; filtrage instantané ; retrait d'une pile sans déplacement des résultats restants.
- **Texte / voix :** « Les quantités deviennent compactes et les emplacements ne glissent plus lorsqu'une pile est vidée. »
- **Sortie :** `SANCTUARY / STOCKAGE 084`.
- **Source :** [recherche et stabilité du terminal, alpha.222](../../CHANGELOG.md#2620-alpha222).
## 085 — Le terminal se comporte comme un vrai conteneur
- **Format / durée :** problème / Sanctuary, 15 s.
- **Accroche :** « Pas de bouton propriétaire pour fermer. Pas de clics réinventés. »
- **Plans :** clic gauche ; clic droit ; shift-clic depuis une sixième rangée d'inventaire ; fermeture vanilla.
- **Texte / voix :** « Les gestes de Minecraft fonctionnent sur toutes les rangées débloquées. Le serveur décide où déposer chaque pile. »
- **Sortie :** `SANCTUARY / STOCKAGE 085`.
- **Source :** [menu de conteneur du terminal, alpha.220](../../CHANGELOG.md#2620-alpha220).
## 086 — Un clic molette trie sans toucher à la hotbar
- **Format / durée :** interaction cachée, 10 s.
- **Accroche :** « Ouvre un coffre. Clique sur la molette. »
- **Plans :** coffre volontairement désordonné ; clic molette ; rangement immédiat, puis gros plan prouvant que la hotbar n'a pas bougé.
- **Texte / voix :** « Coffres, barils, Shulkers, entonnoirs, distributeurs et inventaire joueur utilisent la même règle. »
- **Sortie :** `SANCTUARY / RANGEMENT 086`.
- **Source :** [tri natif de l'alpha.224](../../CHANGELOG.md#2620-alpha224).
## 087 — Un ordinateur Minecraft avec six ports redstone
- **Format / durée :** démonstration technique, 17 s.
- **Accroche :** « Ce bloc exécute réellement un programme assembleur. »
- **Plans :** saisie d'un programme court ; impulsion sur un port directionnel ; résultat sur lampe et comparateur.
- **Texte / voix :** « Programme borné, RAM, inventaire, six ports, impulsions et sortie comparateur : la machine virtuelle historique reste jouable. »
- **Sortie :** `SANCTUARY / RED-STONER 087`.
- **Source :** [ordinateur 8 bits](../../README.md#red-stoner-et-photographie).
## 088 — Seize pellicules, une photo sur carte
- **Format / durée :** collection, 16 s.
- **Accroche :** « L'appareil ne crée pas un fichier hors du jeu. »
- **Plans :** choix de trois pellicules contrastées parmi les seize ; visée ; trois cartes photo exposées côte à côte.
- **Texte / voix :** « Canon transforme la scène en objet Minecraft que l'on peut conserver et montrer. »
- **Sortie :** `SANCTUARY / CANON 088`.
- **Source :** [Canon et styles de pellicule](../../README.md#red-stoner-et-photographie).
## 089 — Un coffre-fort est une coque creuse de 26 blocs
- **Format / durée :** construction, 15 s.
- **Accroche :** « Ni un mur. Ni un cube plein. »
- **Plans :** structure incorrecte refusée ; coque 3 × 3 × 3 avec centre libre ; activation des textures sur les six faces.
- **Texte / voix :** « Les 26 Safe Blocks doivent appartenir au même joueur. Le centre peut cacher n'importe quel bloc. »
- **Sortie :** `SANCTUARY / COFFRE-FORT 089`.
- **Source :** [coque du coffre-fort, alpha.205](../../CHANGELOG.md#2620-alpha205).
## 090 — Pour braquer un coffre, il faut déjà en posséder un
- **Format / durée :** règle de braquage, 18 s.
- **Accroche :** « Un voleur doit mettre sa propre banque en jeu. »
- **Plans :** forage refusé sans coffre personnel ; construction ou preuve du coffre ; forage continu de 60 secondes et transfert final.
- **Texte / voix :** « Le propriétaire doit être connecté. Une réussite vole 20 %, consomme la foreuse, alerte la victime et protège le coffre pendant 24 heures. »
- **Sortie :** `SANCTUARY / BRAQUAGE 090`.
- **Source :** [forage et transfert de l'alpha.202](../../CHANGELOG.md#2620-alpha202) ; [condition de propriété de l'alpha.225](../../CHANGELOG.md#2620-alpha225).
## 091 — Le Creeperlock explose sans casser le monde
- **Format / durée :** arsenal, 12 s.
- **Accroche :** « Une explosion de combat, pas une explosion de terrain. »
- **Plans :** tir sur plusieurs cibles ; souffle et recul ; même décor intact après l'impact.
- **Texte / voix :** « Le Creeperlock consomme une poudre à canon et inflige des dégâts selon la distance, sans détruire de bloc. »
- **Sortie :** `SANCTUARY / OUCH 091`.
- **Source :** [Creeperlock](../../ouch/README.md#arsenal).
## 092 — La Mitraillette tire les métaux de ton inventaire
- **Format / durée :** arsenal, 14 s.
- **Accroche :** « La munition décide des dégâts. »
- **Plans :** pépites cuivre, fer, or, argent et titane alignées ; tir continu ; pépite tenue en main secondaire choisie en priorité.
- **Texte / voix :** « Cinq métaux, cinq puissances. Un tir toutes les cinq ticks jusqu'à 18 blocs. »
- **Sortie :** `SANCTUARY / OUCH 092`.
- **Source :** [Mitraillette](../../ouch/README.md#arsenal).
## 093 — L'Expulseur transforme ton inventaire en projectiles
- **Format / durée :** arsenal absurde, 13 s.
- **Accroche :** « Ce qui se trouve dans ton sac peut devenir une arme. »
- **Plans :** inventaire principal contenant les munitions improvisées ; jusqu'à huit objets projetés ; hotbar montrée intacte.
- **Texte / voix :** « Les objets restent physiques et récupérables. Les neuf cases de la barre d'action ne sont jamais consommées. »
- **Sortie :** `SANCTUARY / OUCH 093`.
- **Source :** [Expulseur](../../ouch/README.md#arsenal).
## 094 — L'Aspirateur retire aussi le bloc visé
- **Format / durée :** arsenal utilitaire, 13 s.
- **Accroche :** « Attirer les objets n'était que la moitié de la fonction. »
- **Plans :** objets au sol attirés dans un rayon de dix blocs ; visée d'un bloc cassable ; extraction et récupération.
- **Texte / voix :** « Il refuse les fluides, les block entities et toute interaction sans permission. »
- **Sortie :** `SANCTUARY / OUCH 094`.
- **Source :** [Aspirateur](../../ouch/README.md#arsenal).
## 095 — Chaque minerai brut change le Mining Rifle
- **Format / durée :** arsenal, 17 s.
- **Accroche :** « Cuivre, fer, or, argent ou titane : le fusil ne tire pas la même chose. »
- **Plans :** cinq munitions ; rechargement plus rapide avec l'or ; bonus argent sur un mort-vivant ; titane traversant une seconde cible.
- **Texte / voix :** « L'argent ajoute des dégâts aux morts-vivants. Le titane conserve 75 % des dégâts sur une seconde cible. »
- **Sortie :** `SANCTUARY / OUCH 095`.
- **Source :** [Mining Rifle](../../ouch/README.md#arsenal).
## 096 — L'Arc bêta tire quatre flèches par seconde
- **Format / durée :** arsenal historique, 13 s.
- **Accroche :** « Avant l'animation de charge, l'arc tirait immédiatement. »
- **Plans :** premier clic instantané ; maintien produisant quatre tirs par seconde ; relâchement sans projectile supplémentaire.
- **Texte / voix :** « Chaque tir consomme une vraie flèche vanilla et conserve ses effets compatibles. »
- **Sortie :** `SANCTUARY / OUCH 096`.
- **Source :** [Arc bêta](../../ouch/README.md#arsenal).
## 097 — Le Shop commence avec trois offres
- **Format / durée :** progression économique, 14 s.
- **Accroche :** « Le magasin n'est pas entièrement ouvert au début. »
- **Plans :** trois cartes actives et six verrouillées ; achat d'un emplacement ; grille progressivement étendue jusqu'à neuf offres.
- **Texte / voix :** « Les six emplacements supplémentaires coûtent 2, 4, 8, 12, 18 puis 25 niveaux. »
- **Sortie :** `SANCTUARY / SHOP 097`.
- **Source :** [progression du Shop, alpha.193](../../CHANGELOG.md#2620-alpha193).
## 098 — Le marché noir est construit avec de vrais panneaux
- **Format / durée :** interaction sociale, 18 s.
- **Accroche :** « Ici, une annonce existe parce qu'un panneau existe dans le monde. »
- **Plans :** vendeur tenant un objet et cliquant sur un panneau ; prix et pseudonyme visibles ; achat par un second joueur puis livraison Mailbox.
- **Texte / voix :** « L'objet, le paiement et les composants sont séquestrés et livrés par le serveur. Casser le panneau obéit à son propriétaire. »
- **Sortie :** `SANCTUARY / MARCHÉ 098`.
- **Source :** [Black Market physique](../../sanctuary/BLACK_MARKET.md#utilisation).
## 099 — Les joueurs peuvent écrire leurs propres quêtes
- **Format / durée :** interaction sociale, 18 s.
- **Accroche :** « KILL. BRING. PAY. Une quête écrite par un autre joueur. »
- **Plans :** création sur le panneau rose ; lecture et confirmation par un second joueur ; complétion puis arrivée de la récompense dans la Mailbox.
- **Texte / voix :** « La récompense est déposée dès la publication. Le serveur suit l'objectif et ne la livre qu'une fois. »
- **Sortie :** `SANCTUARY / QUÊTES 099`.
- **Source :** [demandes communautaires, alpha.201](../../CHANGELOG.md#2620-alpha201).
## 100 — Appuie sur J
- **Format / durée :** absurdité sèche, 9 s.
- **Accroche :** plan parfaitement sérieux du joueur, immobile au centre d'une pièce blanche.
- **Plans :** pression sur `J` ; action de six secondes ; boule créée, puis transformation de neuf boules en cube dans une coupe séparée.
- **Texte / voix :** « Sanctuary réinterprète aussi les mauvaises idées. »
- **Sortie :** `SANCTUARY / ONLY FUN 100`.
- **Source :** [action et cube](../../onlyfun/ABJECTION.md#abj-01--actions-serveur) ; [raccourci configurable](../../onlyfun/ABJECTION.md#abj-03--raccourci-configurable).
## Ordre de production recommandé
Ne pas tourner les vidéos dans leur ordre de publication. Les regrouper par décor réduit fortement le temps de capture :
1. **Laboratoire interface :** 003 à 019, 083 à 086 et 097.
2. **Archipel et worldgen :** 002, 020 à 027, 053 et 072 à 079.
3. **Alpha / maison Indev :** 028 à 031.
4. **Terrain de déplacement :** 032 à 039.
5. **Bestiaire contrôlé :** 040 à 059.
6. **Ferme et cuisine :** 060 à 071.
7. **Atelier Red-Stoner / Another World :** 080 à 090.
8. **Stand de tir Ouch :** 091 à 096.
9. **Place de serveur :** 098 à 100.
## Premier lot public conseillé
Avant de produire les cent vidéos, valider la grammaire avec douze épisodes très différents : **001, 003, 006, 014, 020, 031, 041, 053, 062, 077, 086 et 099**. Ils couvrent le manifeste, l'interface, une mécanique spectaculaire, l'exploration, une créature, l'agriculture, l'ambiance et le multijoueur sans nécessiter cent tournages pour apprendre ce qui fonctionne.
+10 -10
View File
@@ -12,23 +12,23 @@ fabric_api_version=0.158.0+26.2
# Standby modules stay at zero until their migration really begins.
mod_version=0.0.0-alpha.0
module_lifecycle=standby
ambiance_version=0.0.0-alpha.12
ambiance_version=0.0.0-alpha.22
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.10
redstoner_version=0.0.0-alpha.13
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.52
anotherworld_version=0.0.0-alpha.56
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.31
itsalive_version=0.0.0-alpha.38
itsalive_lifecycle=active
ouch_version=0.0.0-alpha.9
ouch_version=0.0.0-alpha.11
ouch_lifecycle=active
masterkey_version=0.0.0-alpha.8
masterkey_version=0.0.0-alpha.9
masterkey_lifecycle=active
iliketomoveit_version=0.0.0-alpha.14
iliketomoveit_version=0.0.0-alpha.18
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.19
onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.117
sanctuary_version=0.0.0-alpha.130
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.203
pack_version=26.2.0-alpha.226
maven_group=fr.koka99cab.sanctuary26
+66
View File
@@ -0,0 +1,66 @@
# Magic Carpet et Totem de Notch
I Like To Move It possède l'objet, la plateforme temporaire, la remise physique
de la récompense et la compatibilité de l'ancien Totem. Sanctuary reste
propriétaire du jalon mondial et de la réservation unique ; Only Fun possède le
catalogue et applique le jalon avant d'exposer l'offre.
## Identités et provenance
| Rôle | ID | Statut |
|---|---|---|
| objet canonique | `iliketomoveit:magic_carpet` | distribué après Notch puis achetable au Shop |
| Totem historique | `portals:notch_totem` | alias caché, lisible et converti vers l'objet canonique |
| jalon mondial | `sanctuary:notch_defeated` | autorité serveur Sanctuary |
La conversion utilise `ItemStack.transmuteCopy` : quantité, composants et nom
personnalisé sont conservés. Les inventaires joueurs sont traités à la connexion,
les conteneurs vanilla lors de leur chargement et les entités d'item dès leur
tick serveur. Un Totem produit exactement un tapis et les nouveaux mondes ne
distribuent jamais l'ancien ID. La donnée I Like To Move It passe en v4 ; une
pile déjà convertie ne correspond plus à l'alias et ne peut donc pas être
convertie une seconde fois.
Chaque tapis porte une provenance Sanctuary sans autorité de gameplay :
`notch_reward`, `shop_purchase` ou `legacy_notch_totem`. Le serveur ne se fie
jamais à cette étiquette pour autoriser un achat ; elle sert à l'historique, au
diagnostic et à l'infobulle.
## Première récompense
Après la première victoire mondiale, Sanctuary réserve atomiquement la
récompense puis I Like To Move It crée un coffre déterministe près de la maison
de Notch. Le tapis unique est placé au centre et un feu d'artifice signale le
coffre. Sanctuary ne marque la livraison terminée qu'après avoir retrouvé ou
créé ce coffre.
Les états `initial_reward_claimed` et `initial_reward_delivered` sont distincts.
Une reprise entre les deux relance la création de façon idempotente ; un coffre
déjà créé et marqué est reconnu sans recevoir de second tapis. Les anciennes
sauvegardes v1 dont la récompense était déjà déclarée remise sont migrées vers
l'état livré afin de ne rien dupliquer.
## Plateforme temporaire
Tenir le tapis en main principale ou secondaire maintient neuf blocs de verre
sous le joueur. Seul l'air est remplacé : terrain, liquides, plantes et blocs
construits restent intacts. Le joueur conserve ses capacités normales ; seul le
dégât de chute est réinitialisé tant que la plateforme est active.
Chaque position possède un ensemble implicite de propriétaires. Deux tapis
peuvent se chevaucher et le verre ne disparaît qu'après le départ du dernier.
Le nettoyage est effectué côté serveur au relâchement, à la déconnexion, au
changement de dimension et pendant l'arrêt normal du serveur.
## Remplacement au Shop
Only Fun associe le Magic Carpet au resolver public
`SanctuaryWorldMilestones`. Avant `sanctuary:notch_defeated`, l'offre est
absente des snapshots et toute tentative réseau est rejetée par Sanctuary.
Après la victoire, elle rejoint la rotation normale au prix configuré de
1 536 rubis et 128 saphirs : les saphirs débloquent le catalogue, les rubis
paient chaque remplacement et la Mailbox effectue la livraison différée.
Un achat porte la provenance `shop_purchase`, est journalisé comme remplacement
et ne touche jamais aux états de réservation ou de coffre de la récompense
initiale.
+10 -2
View File
@@ -29,7 +29,7 @@ Une route est un JSON sous `data/<namespace>/sanctuary_portal/<id>.json` :
- `arrival.type` vaut `surface`, `matched_y` ou `fixed_y`; ce dernier exige `arrival.y`.
- `priority` départage deux routes acceptant le même cadre.
Les neuf routes historiques restent déclarées pour la compatibilité. Pour l'alpha.150, seules deux routes sont actives : terre vers la paire Sanctuary/Overworld et pierre vers `sanctuary:caverns`. Alpha, Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservées mais ne sont pas chargeables.
Les neuf routes historiques restent déclarées pour la compatibilité. Depuis l'alpha.219, trois routes sont actives : terre vers la paire Sanctuary/Overworld, pierre vers `sanctuary:caverns` et cobblestone moussue vers `sanctuary:alpha`. Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservées mais ne sont pas chargeables.
La route en terre est la seule route v2 dynamique. Dans le profil Sanctuary, elle relie `minecraft:overworld` à `sanctuary:overworld`. Dans le profil Overworld, elle relie `minecraft:overworld` à `sanctuary:main`. Le choix appartient à la sauvegarde, jamais à une préférence cliente.
@@ -43,8 +43,16 @@ La route en terre est la seule route v2 dynamique. Dans le profil Sanctuary, ell
4. Reconstruire le même cadre réactive son endpoint; la traversée suivante régénère un lien.
5. Une destination absente refuse la traversée proprement. Elle ne crée aucune dimension et n'endommage pas la sauvegarde.
La route Alpha exige un cadre extérieur de 4 × 5 avec un intérieur vide de
2 × 3, entièrement en cobblestone moussue, puis un briquet. Le serveur valide
le cadre, l'activateur et la présence de `sanctuary:alpha` avant de l'allumer.
Sa première moitié distante utilise l'arrivée sûre calculée par l'API publique
de Sanctuary, hors de la maison Indev ; le client ne choisit ni la destination
ni les coordonnées. L'Atlas découvre le bloc de portail actif comme les autres
points d'intérêt locaux.
Le bloc intérieur actif est `iliketomoveit:portal`. Les neuf anciens blocs `portals:*_portal` restent enregistrés comme compatibilité de lecture; une première utilisation les rattache au nouvel index.
## État des générateurs
Le générateur principal Sanctuary et sa paire Overworld sont actifs depuis l'alpha.71. Caverns, Alpha, Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservés mais non générés. Leurs cadres peuvent être allumés et persistés ; une traversée vers une destination encore absente est refusée proprement.
Le générateur principal Sanctuary et sa paire Overworld sont actifs depuis l'alpha.71. Caverns et Alpha sont également générés ; Alpha est le monde Indev fini 128 × 128 × 64 possédé par Sanctuary. Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservés mais non générés. Une traversée vers une destination absente est refusée proprement dès l'allumage.
+80 -12
View File
@@ -16,6 +16,30 @@ tasks.register("waystoneElevatorRulesSmoke", JavaExec) {
dependsOn tasks.named("testClasses")
}
tasks.register("happyGhastCruiseSpeedSmoke", JavaExec) {
group = "verification"
description = "Checks the rider-only Happy Ghast cruise-speed multiplier."
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.HappyGhastCruiseSpeedSmoke"
dependsOn tasks.named("testClasses")
}
tasks.register("alphaPortalContractSmoke", JavaExec) {
group = "verification"
description = "Checks the active Alpha route and its finite-world safe-arrival contract."
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.portal.AlphaPortalContractSmoke"
dependsOn tasks.named("testClasses")
}
tasks.register("magicCarpetLedgerSmoke", JavaExec) {
group = "verification"
description = "Checks Magic Carpet overlap, dimension-change and shutdown cleanup ownership."
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetLedgerSmoke"
dependsOn tasks.named("testClasses")
}
tasks.named("test") {
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
failOnNoDiscoveredTests = false
@@ -84,11 +108,11 @@ tasks.register("verifyWaystones") {
if (!missingResources.isEmpty()) throw new GradleException("Missing Waystone resources: ${missingResources}")
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 3
if (manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 5
|| manifest.depends?.anotherworld != ">=0.0.0-alpha.46"
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.21") {
throw new GradleException("I Like To Move It must expose data v3, protocol 5 and its Another World alpha.46/Sanctuary alpha.21 dependencies")
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.127") {
throw new GradleException("I Like To Move It must expose data v4, protocol 5 and its Another World alpha.46/Sanctuary alpha.127 dependencies")
}
["fr_fr", "en_us", "ru_ru"].each { language ->
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
@@ -168,6 +192,7 @@ tasks.register("verifyGrappling") {
"data/iliketomoveit/loot_table/blocks/pulley_head.json",
"data/spiderman/loot_table/blocks/pulley_head.json",
"data/minecraft/tags/block/needs_stone_tool.json",
"iliketomoveit.mixins.json",
"iliketomoveit.client.mixins.json"
]
def missingResources = requiredResources.findAll { !file("src/main/resources/${it}").isFile() }
@@ -184,10 +209,11 @@ tasks.register("verifyGrappling") {
}
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (!manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
|| manifest.custom?.sanctuary26?.data_version != 3
if (!manifest.mixins?.any { it.config == "iliketomoveit.mixins.json" }
|| !manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
|| manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 5) {
throw new GradleException("Grappling must keep I Like To Move It aggregate data v3/protocol 5 and register its client mixin")
throw new GradleException("I Like To Move It must expose aggregate data v4/protocol 5 and register its common/client mixins")
}
["fr_fr", "en_us", "ru_ru"].each { language ->
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
@@ -258,7 +284,7 @@ tasks.register("verifyPortals") {
def routeFile = file("src/main/resources/data/iliketomoveit/sanctuary_portal/${route}.json")
if (!routeFile.isFile()) throw new GradleException("Missing portal route ${route}")
def json = new JsonSlurper().parse(routeFile)
def expectedSchema = route == 'overworld' ? 2 : 1
def expectedSchema = route in ['overworld', 'alpha'] ? 2 : 1
if (json.schema_version != expectedSchema || json.destination_dimension != destination
|| json.source_dimension != "minecraft:overworld"
|| (route == 'overworld' && json.destination_resolver != "overworld_pair")
@@ -295,7 +321,7 @@ tasks.register("verifyPortals") {
}
def atlasCavernsMigration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.149-atlas-caverns-to-alpha.150.json"))
if (project.version.toString() != "0.0.0-alpha.14"
if (project.version.toString() != "0.0.0-alpha.18"
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|| atlasCavernsMigration.portals?.active_routes != [
@@ -303,7 +329,7 @@ tasks.register("verifyPortals") {
|| atlasCavernsMigration.portals?.inactive_routes?.size() != 7
|| atlasCavernsMigration.portals?.reserved_ids_retained != true
|| atlasCavernsMigration.portals?.inactive_routes_loadable != false
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS)")
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA)")
|| !javaText.contains("REQUIRED_ROUTES = ACTIVE_ROUTES")
|| atlasCavernsMigration.compatibility?.iliketomoveit_data_version != 3
|| atlasCavernsMigration.compatibility?.iliketomoveit_network_protocol != 3) {
@@ -331,7 +357,7 @@ tasks.register("verifySilverAircraft") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
if (project.version.toString() != "0.0.0-alpha.14"
if (project.version.toString() != "0.0.0-alpha.18"
|| recipe.pattern != ["PGP", "GCG", "PBP"]
|| recipe.key != [B: "anotherworld:silver_block", C: "anotherworld:aircraft_cockpit",
G: "anotherworld:silver_gear", P: "anotherworld:silver_plate"]
@@ -360,7 +386,7 @@ tasks.register("verifyGrapplePrediction") {
def payload = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/GrappleStatePayload.java").text
def controller = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/grapple/GrappleController.java").text
def prediction = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/client/grapple/ClientGrapplePrediction.java").text
if (project.version.toString() != "0.0.0-alpha.14"
if (project.version.toString() != "0.0.0-alpha.18"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.13"
|| migration.grappling?.owner_prediction_payload != "iliketomoveit:s2c/grapple_state"
|| migration.compatibility?.iliketomoveit_source_network_protocol != 4
@@ -392,7 +418,7 @@ tasks.register("verifyWaystoneDescendingRelease") {
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRules.java").text
def teleportation = file(
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/WaystoneTeleportation.java").text
if (project.version.toString() != "0.0.0-alpha.14"
if (project.version.toString() != "0.0.0-alpha.18"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.14"
|| migration.waystone_elevator?.undiscovered_downward_destination_allowed != true
|| migration.waystone_elevator?.destination_discovered_after_arrival != true
@@ -408,13 +434,55 @@ tasks.register("verifyWaystoneDescendingRelease") {
}
}
tasks.register("verifyAlphaPortal") {
group = "verification"
description = "Checks the alpha.16 mossy Alpha portal lifecycle and server authority."
inputs.file(file("PORTALS.md"))
inputs.file(file("src/main/resources/data/iliketomoveit/sanctuary_portal/alpha.json"))
inputs.files(fileTree("src/main/resources/assets/iliketomoveit/lang") { include "*.json" })
inputs.files(fileTree("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/portal") { include "*.java" })
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
doLast {
def route = new JsonSlurper().parse(file(
"src/main/resources/data/iliketomoveit/sanctuary_portal/alpha.json"))
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
def javaText = fileTree("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/portal")
.matching { include "*.java" }.files*.text.join("\n")
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.18"
|| route.schema_version != 2 || route.source_dimension != "minecraft:overworld"
|| route.destination_dimension != "sanctuary:alpha"
|| route.destination_resolver != "fixed"
|| route.frame_blocks != ["minecraft:mossy_cobblestone"]
|| route.activation_items != ["minecraft:flint_and_steel"]
|| migration.alpha_portal?.outer_width != 4 || migration.alpha_portal?.outer_height != 5
|| migration.alpha_portal?.inner_width != 2 || migration.alpha_portal?.inner_height != 3
|| migration.alpha_portal?.inactive_when_destination_absent != true
|| migration.alpha_portal?.persistent_pair_reused != true
|| migration.alpha_portal?.break_unlinks_pair != true
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA)")
|| !javaText.contains("SanctuaryAlphaWorld.portalArrival(targetLevel.getSeed())")
|| !javaText.contains("getServer().getLevel(")
|| locales.any { !it.containsKey("message.iliketomoveit.portal.destination_unavailable") }) {
throw new GradleException("I Like To Move It alpha.16 Alpha portal contract is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("grapplePhysicsSmoke")
dependsOn tasks.named("magicCarpetLedgerSmoke")
dependsOn tasks.named("waystoneElevatorRulesSmoke")
dependsOn tasks.named("happyGhastCruiseSpeedSmoke")
dependsOn tasks.named("verifyWaystones")
dependsOn tasks.named("verifyGrappling")
dependsOn tasks.named("verifyGrapplePrediction")
dependsOn tasks.named("verifyWaystoneDescendingRelease")
dependsOn tasks.named("verifyPortals")
dependsOn tasks.named("verifySilverAircraft")
dependsOn tasks.named("alphaPortalContractSmoke")
dependsOn tasks.named("verifyAlphaPortal")
}
@@ -8,6 +8,8 @@ import fr.koka99cab.sanctuary26.iliketomoveit.pulley.PulleyHeadController;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.GrapplingLootTables;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItRegistries;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetController;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigrationService;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.NotchCarpetRewardService;
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneElevatorController;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
@@ -17,7 +19,7 @@ import org.slf4j.LoggerFactory;
public final class ILikeToMoveItMod implements ModInitializer {
public static final String MOD_ID = "iliketomoveit";
public static final String DISPLAY_NAME = "I Like To Move It";
public static final int CURRENT_DATA_VERSION = 3;
public static final int CURRENT_DATA_VERSION = 4;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ILikeToMoveItMod() {
@@ -37,6 +39,8 @@ public final class ILikeToMoveItMod implements ModInitializer {
GrappleController.register();
PulleyHeadController.register();
MagicCarpetController.register();
MagicCarpetMigrationService.register();
NotchCarpetRewardService.register();
WaystoneElevatorController.initialize();
LOGGER.info("[{}] Portals, vehicles, grappling, pulleys, Magic Carpet and dimension-local Waystones ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, ILikeToMoveItNetworking.PROTOCOL_VERSION);
@@ -0,0 +1,41 @@
package fr.koka99cab.sanctuary26.iliketomoveit.item;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import java.util.function.Consumer;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** Epic carpet with explicit behaviour and provenance in its tooltip. */
public final class MagicCarpetItem extends Item {
public MagicCarpetItem(Properties properties) {
super(properties);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.description")
.withStyle(ChatFormatting.GRAY));
var origin = SanctuaryItemProvenance.origin(stack);
if (SanctuaryItemProvenance.NOTCH_REWARD.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.notch")
.withStyle(ChatFormatting.GOLD));
} else if (SanctuaryItemProvenance.SHOP_PURCHASE.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.shop")
.withStyle(ChatFormatting.AQUA));
} else if (SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.legacy")
.withStyle(ChatFormatting.DARK_PURPLE));
}
}
@Override
public boolean isFoil(ItemStack stack) {
return SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)
|| super.isFoil(stack);
}
}
@@ -0,0 +1,19 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.HappyGhastCruiseSpeed;
import net.minecraft.world.entity.animal.happyghast.HappyGhast;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(HappyGhast.class)
public abstract class HappyGhastMixin {
@Inject(method = "getRiddenInput", at = @At("RETURN"), cancellable = true)
private void iliketomoveit$boostCruiseSpeed(Player rider, Vec3 input,
CallbackInfoReturnable<Vec3> callback) {
callback.setReturnValue(HappyGhastCruiseSpeed.boost(callback.getReturnValue()));
}
}
@@ -0,0 +1,19 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
import net.minecraft.world.Container;
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
import net.minecraft.world.level.storage.ValueInput;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Converts historical Totems as loaded chests and other vanilla containers are decoded. */
@Mixin(BaseContainerBlockEntity.class)
abstract class MagicCarpetContainerMixin {
@Inject(method = "loadAdditional", at = @At("TAIL"))
private void sanctuary26$migrateNotchTotem(ValueInput input, CallbackInfo ci) {
MagicCarpetMigration.migrateContainer((Container) (Object) this);
}
}
@@ -0,0 +1,21 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Converts historical Totems already present as loaded item entities. */
@Mixin(ItemEntity.class)
abstract class MagicCarpetItemEntityMixin {
@Inject(method = "tick", at = @At("HEAD"))
private void sanctuary26$migrateNotchTotem(CallbackInfo ci) {
ItemEntity self = (ItemEntity) (Object) this;
ItemStack current = self.getItem();
ItemStack replacement = MagicCarpetMigration.migrateStack(current);
if (replacement != current) self.setItem(replacement);
}
}
@@ -26,7 +26,7 @@ public final class PortalDefinitions {
public static final Identifier LAPUTA = ILikeToMoveItMod.id("laputa");
/** Routes enabled for the current gameplay milestone. Other stable IDs stay reserved. */
public static final List<Identifier> ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS);
public static final List<Identifier> ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA);
public static final List<Identifier> REQUIRED_ROUTES = ACTIVE_ROUTES;
private static volatile Map<Identifier, PortalDefinition> definitions = Map.of();
@@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
import java.util.Optional;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
@@ -37,6 +38,12 @@ public final class PortalIgnitionHandler {
}
ServerLevel serverLevel = (ServerLevel) level;
if (serverLevel.getServer().getLevel(
selected.targetDimension(serverLevel.getServer(), serverLevel.dimension())) == null) {
player.sendOverlayMessage(Component.translatable(
"message.iliketomoveit.portal.destination_unavailable"));
return InteractionResult.FAIL;
}
PortalEndpoint endpoint = PortalSavedData.get(serverLevel.getServer())
.register(serverLevel, selectedFrame, selected.id());
selectedFrame.fill(serverLevel, selected, endpoint, ILikeToMoveItBlocks.PORTAL);
@@ -3,6 +3,8 @@ package fr.koka99cab.sanctuary26.iliketomoveit.portal;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.block.PortalBlock;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import java.util.Optional;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
@@ -92,6 +94,9 @@ public final class PortalTeleporter {
private static BlockPos targetCenter(ServerLevel sourceLevel, ServerLevel targetLevel,
PortalFrame sourceFrame, PortalDefinition definition) {
if (targetLevel.dimension().equals(SanctuaryDimensions.ALPHA)) {
return SanctuaryAlphaWorld.portalArrival(targetLevel.getSeed());
}
double scale = definition.scaleFrom(sourceLevel.dimension());
int x = Mth.floor(sourceFrame.center().getX() * scale);
int z = Mth.floor(sourceFrame.center().getZ() * scale);
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.iliketomoveit.registry;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.item.AircraftItem;
import fr.koka99cab.sanctuary26.iliketomoveit.item.GrapplingRodItem;
import fr.koka99cab.sanctuary26.iliketomoveit.item.MagicCarpetItem;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
@@ -25,7 +26,11 @@ public final class ILikeToMoveItItems {
key -> new BoatItem(ILikeToMoveItEntityTypes.MAGIC_CLOUD,
new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON).setId(key)));
public static final Item MAGIC_CARPET = register("magic_carpet",
key -> new Item(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
/** Hidden read-preserving alias for inventories saved by the historical MagicCarpet mod. */
public static final Item LEGACY_NOTCH_TOTEM = register(
Identifier.fromNamespaceAndPath("portals", "notch_totem"),
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
public static final Item GRAPPLING_HAND = register("grappling_hand",
key -> new Item(new Item.Properties().setId(key)));
public static final Item GRAPPLING_ROD = register("grappling_rod",
@@ -0,0 +1,15 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import net.minecraft.world.phys.Vec3;
/** Keeps the Happy Ghast ride practical without changing its autonomous movement. */
public final class HappyGhastCruiseSpeed {
public static final double RIDER_INPUT_MULTIPLIER = 2.0D;
private HappyGhastCruiseSpeed() {
}
public static Vec3 boost(Vec3 vanillaInput) {
return vanillaInput.scale(RIDER_INPUT_MULTIPLIER);
}
}
@@ -1,10 +1,7 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -27,7 +24,7 @@ public final class MagicCarpetController {
private static final int RADIUS = 1;
private static final Block CARPET_BLOCK = Blocks.GLASS;
private static final BlockState CARPET_STATE = CARPET_BLOCK.defaultBlockState();
private static final Map<UUID, CarpetState> ACTIVE_CARPETS = new HashMap<>();
private static final MagicCarpetLedger ACTIVE_CARPETS = new MagicCarpetLedger();
private MagicCarpetController() {
}
@@ -45,8 +42,8 @@ public final class MagicCarpetController {
connected.add(id);
boolean active = !player.isSpectator() && (isMagicCarpet(player.getMainHandItem()) || isMagicCarpet(player.getOffhandItem()));
if (active) {
if (!ACTIVE_CARPETS.containsKey(id)) {
ACTIVE_CARPETS.put(id, CarpetState.empty(player.level().dimension()));
if (!ACTIVE_CARPETS.contains(id)) {
ACTIVE_CARPETS.track(id, player.level().dimension(), Set.of());
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.enabled"));
}
updateCarpet(id, player);
@@ -54,27 +51,26 @@ public final class MagicCarpetController {
deactivate(server, id, true);
}
}
Iterator<Map.Entry<UUID, CarpetState>> iterator = ACTIVE_CARPETS.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<UUID, CarpetState> entry = iterator.next();
if (!connected.contains(entry.getKey())) {
removePositions(server, entry.getKey(), entry.getValue(), entry.getValue().positions());
iterator.remove();
for (UUID owner : ACTIVE_CARPETS.owners()) {
if (!connected.contains(owner)) {
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
if (state != null) removePositions(server, owner, state, state.positions());
}
}
}
private static boolean isMagicCarpet(ItemStack stack) {
return !stack.isEmpty() && stack.is(ILikeToMoveItItems.MAGIC_CARPET);
return MagicCarpetMigration.isAnyCarpet(stack);
}
private static void updateCarpet(UUID owner, ServerPlayer player) {
ServerLevel level = player.level();
CarpetState previous = ACTIVE_CARPETS.getOrDefault(owner, CarpetState.empty(level.dimension()));
MagicCarpetLedger.State previous = ACTIVE_CARPETS.state(owner, level.dimension());
Set<BlockPos> target = carpetPositions(player.blockPosition().below(player.isCrouching() ? 2 : 1));
if (!previous.dimension().equals(level.dimension())) {
removePositions(level.getServer(), owner, previous, previous.positions());
previous = CarpetState.empty(level.dimension());
ACTIVE_CARPETS.remove(owner);
previous = ACTIVE_CARPETS.state(owner, level.dimension());
} else {
Set<BlockPos> stale = new HashSet<>(previous.positions());
stale.removeAll(target);
@@ -91,7 +87,7 @@ public final class MagicCarpetController {
placed.add(pos);
}
}
ACTIVE_CARPETS.put(owner, new CarpetState(level.dimension(), Set.copyOf(placed)));
ACTIVE_CARPETS.track(owner, level.dimension(), placed);
player.resetFallDistance();
}
@@ -104,14 +100,14 @@ public final class MagicCarpetController {
}
private static void deactivate(MinecraftServer server, UUID owner, boolean notify) {
CarpetState state = ACTIVE_CARPETS.remove(owner);
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
if (state == null) return;
removePositions(server, owner, state, state.positions());
ServerPlayer player = server.getPlayerList().getPlayer(owner);
if (notify && player != null) player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.disabled"));
}
private static void removePositions(MinecraftServer server, UUID owner, CarpetState state, Set<BlockPos> positions) {
private static void removePositions(MinecraftServer server, UUID owner, MagicCarpetLedger.State state, Set<BlockPos> positions) {
ServerLevel level = server.getLevel(state.dimension());
if (level == null) return;
for (BlockPos pos : positions) {
@@ -122,18 +118,13 @@ public final class MagicCarpetController {
}
private static boolean isTrackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
return ACTIVE_CARPETS.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
&& entry.getValue().dimension().equals(dimension) && entry.getValue().positions().contains(pos));
return ACTIVE_CARPETS.trackedByAnother(owner, dimension, pos);
}
private static void reset(MinecraftServer server) {
Map<UUID, CarpetState> copy = new HashMap<>(ACTIVE_CARPETS);
ACTIVE_CARPETS.clear();
Map<UUID, MagicCarpetLedger.State> copy = ACTIVE_CARPETS.drain();
copy.forEach((owner, state) -> removePositions(server, owner, state, state.positions()));
ILikeToMoveItMod.LOGGER.debug("Magic Carpet temporary platforms cleaned.");
}
private record CarpetState(ResourceKey<Level> dimension, Set<BlockPos> positions) {
private static CarpetState empty(ResourceKey<Level> dimension) { return new CarpetState(dimension, Set.of()); }
}
}
@@ -0,0 +1,56 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Pure ownership ledger preventing overlapping temporary platforms from deleting each other. */
public final class MagicCarpetLedger {
private final Map<UUID, State> carpets = new HashMap<>();
public boolean contains(UUID owner) {
return carpets.containsKey(owner);
}
public State state(UUID owner, ResourceKey<Level> dimension) {
return carpets.getOrDefault(owner, State.empty(dimension));
}
public void track(UUID owner, ResourceKey<Level> dimension, Set<BlockPos> positions) {
carpets.put(owner, new State(dimension, Set.copyOf(positions)));
}
public State remove(UUID owner) {
return carpets.remove(owner);
}
public Set<UUID> owners() {
return Set.copyOf(carpets.keySet());
}
public boolean trackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
return carpets.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
&& entry.getValue().dimension().equals(dimension)
&& entry.getValue().positions().contains(pos));
}
public Map<UUID, State> drain() {
Map<UUID, State> copy = Map.copyOf(carpets);
carpets.clear();
return copy;
}
public record State(ResourceKey<Level> dimension, Set<BlockPos> positions) {
public State {
positions = Set.copyOf(positions);
}
private static State empty(ResourceKey<Level> dimension) {
return new State(dimension, Set.of());
}
}
}
@@ -0,0 +1,39 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
/** Read-preserving conversion from the historical Totem identity to the canonical carpet. */
public final class MagicCarpetMigration {
private MagicCarpetMigration() {
}
public static boolean isAnyCarpet(ItemStack stack) {
return stack != null && !stack.isEmpty()
&& (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
|| stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM));
}
public static ItemStack migrateStack(ItemStack stack) {
if (stack == null || stack.isEmpty() || !stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM)) return stack;
ItemStack migrated = stack.transmuteCopy(ILikeToMoveItItems.MAGIC_CARPET, stack.getCount());
SanctuaryItemProvenance.stampIfAbsent(migrated, SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM);
return migrated;
}
public static int migrateContainer(Container container) {
if (container == null) return 0;
int migrated = 0;
for (int slot = 0; slot < container.getContainerSize(); slot++) {
ItemStack current = container.getItem(slot);
ItemStack replacement = migrateStack(current);
if (replacement == current) continue;
container.setItem(slot, replacement);
migrated++;
}
if (migrated > 0) container.setChanged();
return migrated;
}
}
@@ -0,0 +1,34 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.data.ILikeToMoveItData;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Converts online inventories once their historical alias has been decoded. */
public final class MagicCarpetMigrationService {
private MagicCarpetMigrationService() {
}
public static void register() {
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> migrate(handler.player));
ServerTickEvents.END_SERVER_TICK.register(MagicCarpetMigrationService::tick);
}
private static void tick(MinecraftServer server) {
ILikeToMoveItData.get(server);
if (server.overworld().getGameTime() % 20L != 0L) return;
for (ServerPlayer player : server.getPlayerList().getPlayers()) migrate(player);
}
private static void migrate(ServerPlayer player) {
int migrated = MagicCarpetMigration.migrateContainer(player.getInventory());
migrated += MagicCarpetMigration.migrateContainer(player.getEnderChestInventory());
if (migrated > 0) {
ILikeToMoveItMod.LOGGER.info("[Magic Carpet] Migrated {} historical Totem stack(s) for {}.",
migrated, player.getGameProfile().name());
}
}
}
@@ -0,0 +1,124 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList;
import java.util.List;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.FireworkExplosion;
import net.minecraft.world.item.component.Fireworks;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
/** Idempotently materializes the globally reserved Magic Carpet in Alpha. */
public final class NotchCarpetRewardService {
private static final int REWARD_SLOT = 13;
private NotchCarpetRewardService() {
}
public static void register() {
ServerTickEvents.END_SERVER_TICK.register(NotchCarpetRewardService::tick);
}
private static void tick(MinecraftServer server) {
if (server.overworld().getGameTime() % 20L != 0L) return;
var state = SanctuaryNotchMilestone.snapshot(server);
if (!state.defeated() || state.initialRewardDelivered()) return;
if (!state.initialRewardClaimed() && !SanctuaryNotchMilestone.claimInitialReward(server)) return;
ServerLevel alpha = server.getLevel(SanctuaryDimensions.ALPHA);
if (alpha == null) return;
BlockPos home = SanctuaryAlphaWorld.residence(alpha.getSeed());
if (!alpha.isLoaded(home)) return;
RewardChest result = ensureRewardChest(alpha, home);
if (result == null || !SanctuaryNotchMilestone.completeInitialReward(server)) return;
if (result.created()) {
launchFirework(alpha, result.pos());
server.getPlayerList().broadcastSystemMessage(
Component.translatable("message.iliketomoveit.magic_carpet.reward_created"), false);
}
ILikeToMoveItMod.LOGGER.info(
"[Magic Carpet] Completed unique Notch reward at {} (origin=notch_reward, recovered={}).",
result.pos().toShortString(), !result.created());
}
private static RewardChest ensureRewardChest(ServerLevel level, BlockPos home) {
List<BlockPos> candidates = candidates(home);
for (BlockPos pos : candidates) {
if (containsInitialReward(level, pos)) return new RewardChest(pos, false);
}
for (BlockPos pos : candidates) {
if (!level.getBlockState(pos).isAir() || !level.getBlockState(pos.above()).isAir()
|| level.getBlockState(pos.below()).isAir()) continue;
if (!level.setBlock(pos, Blocks.CHEST.defaultBlockState(), Block.UPDATE_ALL)) continue;
if (level.getBlockEntity(pos) instanceof ChestBlockEntity chest) {
chest.setItem(REWARD_SLOT, rewardStack());
chest.setChanged();
level.sendBlockUpdated(pos, level.getBlockState(pos), level.getBlockState(pos), Block.UPDATE_ALL);
return new RewardChest(pos, true);
}
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
return null;
}
private static boolean containsInitialReward(ServerLevel level, BlockPos pos) {
if (level.getBlockState(pos).getBlock() != Blocks.CHEST
|| !(level.getBlockEntity(pos) instanceof Container container)) return false;
for (ItemStack stack : container) {
if (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
&& SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)) return true;
}
return false;
}
private static ItemStack rewardStack() {
ItemStack reward = new ItemStack(ILikeToMoveItItems.MAGIC_CARPET);
reward.set(DataComponents.CUSTOM_NAME,
Component.translatable("item.iliketomoveit.magic_carpet.notch_reward_name"));
SanctuaryItemProvenance.stamp(reward, SanctuaryItemProvenance.NOTCH_REWARD);
return reward;
}
private static List<BlockPos> candidates(BlockPos home) {
ArrayList<BlockPos> positions = new ArrayList<>();
for (int y : new int[] {0, 1, -1, 2}) {
for (int radius = 2; radius <= 7; radius++) {
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
BlockPos candidate = home.offset(x, y, z).immutable();
if (SanctuaryAlphaWorld.containsHorizontal(candidate)) positions.add(candidate);
}
}
}
return List.copyOf(positions);
}
private static void launchFirework(ServerLevel level, BlockPos pos) {
ItemStack rocket = new ItemStack(Items.FIREWORK_ROCKET);
FireworkExplosion explosion = new FireworkExplosion(FireworkExplosion.Shape.STAR,
new IntArrayList(new int[] {0xE8B923, 0xF1F1F1}),
new IntArrayList(new int[] {0x9B59B6}), true, true);
rocket.set(DataComponents.FIREWORKS, new Fireworks(1, List.of(explosion)));
level.addFreshEntity(new FireworkRocketEntity(level, pos.getX() + 0.5D,
pos.getY() + 1.2D, pos.getZ() + 0.5D, rocket));
}
private record RewardChest(BlockPos pos, boolean created) {
}
}
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Aircraft",
"item.iliketomoveit.magic_cloud": "Magic Cloud",
"item.iliketomoveit.magic_carpet": "Magic Carpet (laboratory)",
"item.iliketomoveit.magic_carpet": "Magic Carpet",
"item.portals.notch_totem": "Notch Totem (legacy)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Notch's Magic Carpet",
"item.iliketomoveit.magic_carpet.description": "Hold it to maintain a temporary 3 × 3 platform beneath your feet.",
"item.iliketomoveit.magic_carpet.origin.notch": "Unique world reward from Notch",
"item.iliketomoveit.magic_carpet.origin.shop": "Replacement purchased from the Shop",
"item.iliketomoveit.magic_carpet.origin.legacy": "Converted from the historical Notch Totem",
"block.iliketomoveit.waystone": "Waystone",
"block.waystone.waystone": "Waystone (legacy)",
"entity.iliketomoveit.aircraft": "Aircraft",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Aircraft Engine",
"message.iliketomoveit.magic_carpet.enabled": "Magic Carpet enabled",
"message.iliketomoveit.magic_carpet.disabled": "Magic Carpet disabled",
"message.iliketomoveit.magic_carpet.reward_created": "Notch's unique Magic Carpet appeared in a chest near his house.",
"subtitles.iliketomoveit.magic_cloud.fly": "Magic Cloud flies",
"screen.iliketomoveit.waystone.title": "Waystones",
"screen.iliketomoveit.waystone.empty": "No other waystone in this dimension",
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Avion",
"item.iliketomoveit.magic_cloud": "Nuage magique",
"item.iliketomoveit.magic_carpet": "Tapis magique (laboratoire)",
"item.iliketomoveit.magic_carpet": "Tapis magique",
"item.portals.notch_totem": "Totem de Notch (hérité)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Tapis magique de Notch",
"item.iliketomoveit.magic_carpet.description": "Tenu en main, il maintient une plateforme temporaire 3 × 3 sous vos pieds.",
"item.iliketomoveit.magic_carpet.origin.notch": "Récompense mondiale unique de Notch",
"item.iliketomoveit.magic_carpet.origin.shop": "Remplacement acheté au Shop",
"item.iliketomoveit.magic_carpet.origin.legacy": "Converti depuis le Totem de Notch historique",
"block.iliketomoveit.waystone": "Pierre de voyage",
"block.waystone.waystone": "Pierre de voyage (héritée)",
"entity.iliketomoveit.aircraft": "Avion",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Moteur de l'avion",
"message.iliketomoveit.magic_carpet.enabled": "Tapis magique activé",
"message.iliketomoveit.magic_carpet.disabled": "Tapis magique désactivé",
"message.iliketomoveit.magic_carpet.reward_created": "Le Tapis magique unique de Notch est apparu dans un coffre près de sa maison.",
"subtitles.iliketomoveit.magic_cloud.fly": "Le nuage magique vole",
"screen.iliketomoveit.waystone.title": "Pierres de voyage",
"screen.iliketomoveit.waystone.empty": "Aucune autre pierre dans cette dimension",
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Самолёт",
"item.iliketomoveit.magic_cloud": "Волшебное облако",
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт (лаборатория)",
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт",
"item.portals.notch_totem": "Тотем Нотча (старый)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Ковёр-самолёт Нотча",
"item.iliketomoveit.magic_carpet.description": "В руке поддерживает временную платформу 3 × 3 под ногами.",
"item.iliketomoveit.magic_carpet.origin.notch": "Уникальная мировая награда за Нотча",
"item.iliketomoveit.magic_carpet.origin.shop": "Замена, купленная в Магазине",
"item.iliketomoveit.magic_carpet.origin.legacy": "Преобразован из исторического Тотема Нотча",
"block.iliketomoveit.waystone": "Камень пути",
"block.waystone.waystone": "Камень пути (старый)",
"entity.iliketomoveit.aircraft": "Самолёт",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Двигатель самолёта",
"message.iliketomoveit.magic_carpet.enabled": "Ковёр-самолёт включён",
"message.iliketomoveit.magic_carpet.disabled": "Ковёр-самолёт выключен",
"message.iliketomoveit.magic_carpet.reward_created": "Уникальный ковёр-самолёт Нотча появился в сундуке возле его дома.",
"subtitles.iliketomoveit.magic_cloud.fly": "Волшебное облако летит",
"screen.iliketomoveit.waystone.title": "Камни пути",
"screen.iliketomoveit.waystone.empty": "В этом измерении нет других камней",
@@ -1,6 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "minecraft:block/purple_wool"
"layer0": "iliketomoveit:item/magic_carpet"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "iliketomoveit:item/magic_carpet"
}
}
@@ -1,7 +1,8 @@
{
"schema_version": 1,
"schema_version": 2,
"source_dimension": "minecraft:overworld",
"destination_dimension": "sanctuary:alpha",
"destination_resolver": "fixed",
"frame_blocks": ["minecraft:mossy_cobblestone"],
"preferred_frame_block": "minecraft:mossy_cobblestone",
"activation_items": ["minecraft:flint_and_steel"],
@@ -19,6 +19,9 @@
]
},
"mixins": [
{
"config": "iliketomoveit.mixins.json"
},
{
"config": "iliketomoveit.client.mixins.json",
"environment": "client"
@@ -30,13 +33,13 @@
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"anotherworld": ">=0.0.0-alpha.46",
"sanctuary": ">=0.0.0-alpha.21"
"sanctuary": ">=0.0.0-alpha.127"
},
"custom": {
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "iliketomoveit",
"data_version": 3,
"data_version": 4,
"network_protocol": 5,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -0,0 +1,13 @@
{
"required": true,
"package": "fr.koka99cab.sanctuary26.iliketomoveit.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"HappyGhastMixin",
"MagicCarpetContainerMixin",
"MagicCarpetItemEntityMixin"
],
"injectors": {
"defaultRequire": 1
}
}
@@ -0,0 +1,37 @@
package fr.koka99cab.sanctuary26.iliketomoveit.portal;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import java.util.Set;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.server.Bootstrap;
/** Executable cross-module contract for the active mossy Alpha portal. */
public final class AlphaPortalContractSmoke {
private AlphaPortalContractSmoke() {
}
public static void main(String[] args) {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
require(PortalDefinitions.ACTIVE_ROUTES.equals(PortalDefinitions.REQUIRED_ROUTES),
"Every active portal definition must be required at reload");
require(Set.copyOf(PortalDefinitions.ACTIVE_ROUTES).equals(Set.of(
PortalDefinitions.OVERWORLD, PortalDefinitions.CAVERNS, PortalDefinitions.ALPHA)),
"Exactly the Overworld, Caverns and Alpha routes must be active");
for (long seed : new long[] { 0L, 1L, 46L }) {
BlockPos arrival = SanctuaryAlphaWorld.portalArrival(seed);
BlockPos residence = SanctuaryAlphaWorld.residence(seed);
require(SanctuaryAlphaWorld.containsHorizontal(arrival),
"Server-selected Alpha arrival must stay inside the finite island");
require(Math.max(Math.abs(arrival.getX() - residence.getX()),
Math.abs(arrival.getZ() - residence.getZ())) >= 10,
"The remote portal must not damage the Indev residence");
}
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,27 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import net.minecraft.world.phys.Vec3;
/** Regression checks for the rider-only Happy Ghast cruise multiplier. */
public final class HappyGhastCruiseSpeedSmoke {
private HappyGhastCruiseSpeedSmoke() {
}
public static void main(String[] args) {
Vec3 vanillaInput = new Vec3(-0.195D, 0.0975D, 0.195D);
Vec3 boosted = HappyGhastCruiseSpeed.boost(vanillaInput);
assertNear(boosted.x, -0.39D, "sideways input");
assertNear(boosted.y, 0.195D, "vertical input");
assertNear(boosted.z, 0.39D, "forward input");
if (!HappyGhastCruiseSpeed.boost(Vec3.ZERO).equals(Vec3.ZERO)) {
throw new IllegalStateException("Idle Happy Ghasts must remain idle");
}
}
private static void assertNear(double actual, double expected, String label) {
if (Math.abs(actual - expected) > 1.0E-12D) {
throw new IllegalStateException(label + ": expected " + expected + ", got " + actual);
}
}
}
@@ -0,0 +1,45 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Regression checks for overlap, world changes and shutdown cleanup ownership. */
public final class MagicCarpetLedgerSmoke {
private MagicCarpetLedgerSmoke() {
}
public static void main(String[] args) {
MagicCarpetLedger ledger = new MagicCarpetLedger();
UUID first = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID second = UUID.fromString("00000000-0000-0000-0000-000000000002");
ResourceKey<Level> firstWorld = ResourceKey.create(Registries.DIMENSION,
Identifier.fromNamespaceAndPath("test", "first"));
ResourceKey<Level> secondWorld = ResourceKey.create(Registries.DIMENSION,
Identifier.fromNamespaceAndPath("test", "second"));
BlockPos shared = new BlockPos(4, 70, 4);
ledger.track(first, firstWorld, Set.of(shared));
ledger.track(second, firstWorld, Set.of(shared));
if (!ledger.trackedByAnother(first, firstWorld, shared)) {
throw new AssertionError("Overlapping carpets lost their second owner");
}
ledger.remove(first);
if (ledger.trackedByAnother(second, firstWorld, shared)) {
throw new AssertionError("Removed owner still controls the shared glass");
}
ledger.track(second, secondWorld, Set.of(new BlockPos(0, 64, 0)));
if (!ledger.state(second, secondWorld).dimension().equals(secondWorld)
|| !ledger.state(second, firstWorld).dimension().equals(secondWorld)) {
throw new AssertionError("Dimension change did not replace the tracked world");
}
Map<UUID, MagicCarpetLedger.State> shutdown = ledger.drain();
if (shutdown.size() != 1 || !ledger.owners().isEmpty()) {
throw new AssertionError("Server shutdown did not atomically drain temporary platforms");
}
}
}
+9 -1
View File
@@ -2,7 +2,15 @@
It's Alive possède le vivant de Sanctuary. Il enrichit directement le Creeper vanilla et accueille désormais les papillons de Pipallon, les Mooblooms fleuries et les créatures de Real Ghost — baleine volante comprise — sous un référencement unifié `itsalive:*`.
## Version courante — `0.0.0-alpha.20`
## Version courante — `0.0.0-alpha.37`
## Résident Notch
- `itsalive:notch` est l'unique résident persistant de la maison Indev dans `sanctuary:alpha` ; il n'apparaît jamais naturellement et ne peut pas être invoqué par les commandes génériques.
- Son UUID canonique est sauvegardé, les doublons sont supprimés et le service le recrée au domicile s'il manque. L'alias caché `portals:notch` charge les anciennes entités du prototype sans dupliquer les règles.
- Le premier coup volontaire d'un joueur transforme l'humain en Titan. Le nombre de participants est borné à huit, la santé va de 360 à 600 et un combat abandonné se réinitialise après quinze secondes sans participant actif.
- It's Alive possède l'entité et son cycle de vie ; les attaques passent exclusivement par l'API publique `NotchCombatApi`, implémentée par Ouch.
- Diagnostics : `/masterkey notch inspect` et `/masterkey notch restore`.
## Creepers
+227 -8
View File
@@ -3,6 +3,22 @@ import groovy.json.JsonSlurper
import javax.imageio.ImageIO
import java.security.MessageDigest
tasks.register("canapliaFoundationSmoke", JavaExec) {
group = "verification"
description = "Checks the stable Canaplia namespace, variant keys and Ambiance visual boundary."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaFoundationSmoke"
}
tasks.register("canapliaGameplaySmoke", JavaExec) {
group = "verification"
description = "Checks Canaplia discovery, hybrids, effects and bounded Codex payloads."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaGameplaySmoke"
}
tasks.register("culinaryBatchingSmoke", JavaExec) {
group = "verification"
description = "Checks one-stack-per-ingredient cauldron batch quantities."
@@ -19,14 +35,146 @@ tasks.register("culinaryPartialMatchingSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.itsalive.culinary.recipe.CulinaryPartialMatchingSmoke"
}
tasks.register("notchFightRulesSmoke", JavaExec) {
group = "verification"
description = "Checks bounded Notch Titan scaling, reset timing and reflected-fireball damage."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchFightRulesSmoke"
}
tasks.named("test") {
// Quantity contracts are executable smoke tasks, not JUnit engine tests.
failOnNoDiscoveredTests = false
}
tasks.named("check") {
dependsOn tasks.named("canapliaFoundationSmoke")
dependsOn tasks.named("canapliaGameplaySmoke")
dependsOn tasks.named("notchFightRulesSmoke")
}
def generatedResources = layout.buildDirectory.dir("generated/resources")
def generatedCanapliaResources = layout.buildDirectory.dir("generated/canapliaResources")
sourceSets.main.resources.srcDir(generatedResources)
sourceSets.main.resources.srcDir(generatedCanapliaResources)
def canapliaVariants = [
[key: "common"], [key: "tender"], [key: "olive"], [key: "dark"],
[key: "ashen"], [key: "moss"], [key: "gold"], [key: "amber"],
[key: "bluish"], [key: "pink"], [key: "coral"], [key: "purple"],
[key: "indigo"], [key: "varieg"], [key: "lunar"], [key: "ember"]
]
tasks.register("generateCanapliaResources") {
group = "build"
description = "Generates models, loot and recipes around the restored authored Canaplia sprites."
outputs.dir(generatedCanapliaResources)
doLast {
def root = generatedCanapliaResources.get().asFile
delete(root)
def writeJson = { String relativePath, Object value ->
def target = new File(root, relativePath)
target.parentFile.mkdirs()
target.text = JsonOutput.prettyPrint(JsonOutput.toJson(value)) + "\n"
}
def path = { String key, String family -> key == "common" ? family : "${key}_${family}" }
def cropTexture = { String key, int age ->
if (age == 7) return key == "common" ? "hemp_tall_bottom" : "hemp_tall_bottom_${key}"
key == "common" ? "hemp_stage_${age}" : "hemp_stage_${age}_${key}"
}
def topTexture = { String key -> key == "common" ? "hemp_tall_top" : "hemp_tall_top_${key}" }
def itemDefinition = { String itemPath, String modelPath ->
writeJson("assets/canaplia/items/${itemPath}.json", [model: [type: "minecraft:model", model: modelPath]])
}
def generatedItem = { String itemPath ->
writeJson("assets/canaplia/models/item/${itemPath}.json", [
parent: "minecraft:item/generated", textures: [layer0: "canaplia:item/${itemPath}"]])
itemDefinition(itemPath, "canaplia:item/${itemPath}")
}
def cropVariants = [:]
def topVariants = [:]
def resinVariants = [:]
canapliaVariants.each { variant ->
String key = variant.key
(0..7).each { age ->
cropVariants["age=${age},variant=${key}"] = [model: "canaplia:block/hemp_crop_${key}_stage${age}"]
topVariants["age=${age},variant=${key}"] = [model: "canaplia:block/hemp_top_${key}_stage${Math.max(5, age)}"]
writeJson("assets/canaplia/models/block/hemp_crop_${key}_stage${age}.json", [
parent: "minecraft:block/crop", textures: [crop: "canaplia:block/${cropTexture(key, age)}"]])
if (age >= 5) writeJson("assets/canaplia/models/block/hemp_top_${key}_stage${age}.json", [
parent: "minecraft:block/crop", textures: [crop: "canaplia:block/${topTexture(key)}"]])
}
String seed = path(key, "hemp_seeds")
String bud = path(key, "hemp_bud")
String resin = path(key, "resin_block")
String joint = path(key, "rolled_joint")
[seed, bud, joint].each { generatedItem(it) }
writeJson("assets/canaplia/models/item/${resin}.json", [parent: "canaplia:block/resin_block_${key}"])
itemDefinition(resin, "canaplia:item/${resin}")
writeJson("assets/canaplia/models/block/resin_block_${key}.json", [
parent: "minecraft:block/cube_all", textures: [all: "canaplia:block/${resin}"]])
resinVariants["variant=${key}"] = [model: "canaplia:block/resin_block_${key}"]
writeJson("data/canaplia/recipe/${joint}.json", [
type: "minecraft:crafting_shapeless", category: "misc",
ingredients: ["minecraft:paper", "canaplia:${bud}"], result: [id: "canaplia:${joint}"]])
}
writeJson("assets/canaplia/blockstates/hemp_crop.json", [variants: cropVariants])
writeJson("assets/canaplia/blockstates/hemp_top.json", [variants: topVariants])
writeJson("assets/canaplia/blockstates/resin_block.json", [variants: resinVariants])
def cropSeedChildren = canapliaVariants.collect { variant ->
String seed = path(variant.key, "hemp_seeds")
[type: "minecraft:item", conditions: [[condition: "minecraft:block_state_property",
block: "canaplia:hemp_crop", properties: [variant: variant.key]]], name: "canaplia:${seed}"]
}
def cropBudChildren = canapliaVariants.collect { variant ->
String bud = path(variant.key, "hemp_bud")
[type: "minecraft:item", conditions: [[condition: "minecraft:block_state_property",
block: "canaplia:hemp_crop", properties: [age: "7", variant: variant.key]]],
functions: [[function: "minecraft:set_count", count: [type: "minecraft:uniform", min: 1.0, max: 3.0]]],
name: "canaplia:${bud}"]
}
writeJson("data/canaplia/loot_table/blocks/hemp_crop.json", [type: "minecraft:block",
functions: [[function: "minecraft:explosion_decay"]], pools: [
[rolls: 1.0, entries: [[type: "minecraft:alternatives", children: cropSeedChildren]]],
[rolls: 1.0, entries: [[type: "minecraft:alternatives", children: cropBudChildren]]]
], random_sequence: "canaplia:blocks/hemp_crop"])
writeJson("data/canaplia/loot_table/blocks/hemp_top.json", [type: "minecraft:block", pools: []])
def resinChildren = canapliaVariants.collect { variant ->
String resin = path(variant.key, "resin_block")
[type: "minecraft:item", conditions: [[condition: "minecraft:block_state_property",
block: "canaplia:resin_block", properties: [variant: variant.key]]], name: "canaplia:${resin}"]
}
writeJson("data/canaplia/loot_table/blocks/resin_block.json", [type: "minecraft:block",
functions: [[function: "minecraft:explosion_decay"]],
pools: [[rolls: 1.0, entries: [[type: "minecraft:alternatives", children: resinChildren]]]],
random_sequence: "canaplia:blocks/resin_block"])
def seedIds = canapliaVariants.collect { "canaplia:${path(it.key, 'hemp_seeds')}" }
def budIds = canapliaVariants.collect { "canaplia:${path(it.key, 'hemp_bud')}" }
writeJson("data/c/tags/item/seeds.json", [replace: false, values: seedIds])
writeJson("data/canaplia/tags/item/hemp_buds.json", [replace: false, values: budIds])
itemDefinition("codex", "minecraft:item/enchanted_book")
itemDefinition("codex_page", "minecraft:item/paper")
writeJson("data/canaplia/recipe/codex.json", [
type: "minecraft:crafting_shaped", category: "misc",
pattern: [" P ", "PBP", " P "],
key: [P: "canaplia:codex_page", B: "minecraft:book"],
result: [id: "canaplia:codex"]])
}
}
tasks.named("processResources") {
dependsOn tasks.named("generateCanapliaResources")
}
def agricultureCrops = [
[name: "strawberry", crop: "strawberry_crop", seed: "strawberry_seeds", produce: "strawberry"],
@@ -318,7 +466,7 @@ tasks.register("generateAgricultureResources") {
smokingRecipe("roasted_coffee_beans", "coffee_beans", "roasted_coffee_beans")
smokingRecipe("dried_vanilla_pod", "green_vanilla_pod", "vanilla_pod")
def cropBlocks = agricultureCrops.collect { "itsalive:${it.crop}" }
def cropBlocks = agricultureCrops.collect { "itsalive:${it.crop}" } + ["canaplia:hemp_crop"]
def plantingItems = agricultureCrops.collect { "itsalive:${it.seed}" }.unique()
tag("block", "minecraft", "crops", cropBlocks)
tag("block", "minecraft", "maintains_farmland", cropBlocks.findAll { it != "itsalive:rice_crop" })
@@ -417,7 +565,7 @@ tasks.register("verifyItsAlive") {
def climateSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ClimateCropForaging.java").text
def pineappleSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/PineappleItem.java").text
def worldgenSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ItsAliveAgricultureWorldGeneration.java").text
if (project.version.toString() != "0.0.0-alpha.31"
if (project.version.toString() != "0.0.0-alpha.38"
|| climateMigration.target?.pack_version != "26.2.0-alpha.157"
|| climateMigration.target?.modules?.itsalive != "0.0.0-alpha.24"
|| !climateSource.contains("PlayerBlockBreakEvents.AFTER.register")
@@ -936,8 +1084,8 @@ tasks.register("verifyItsAlive") {
}
}
def cropTag = new JsonSlurper().parse(new File(generatedRoot, "data/minecraft/tags/block/crops.json"))
if (cropTag.values.toSet() != agricultureCrops.collect { "itsalive:${it.crop}" }.toSet()) {
throw new GradleException("The minecraft:crops tag must contain the twenty It's Alive crops exactly once")
if (cropTag.values.toSet() != (agricultureCrops.collect { "itsalive:${it.crop}" } + ["canaplia:hemp_crop"]).toSet()) {
throw new GradleException("The minecraft:crops tag must contain the twenty It's Alive crops and Canaplia exactly once")
}
def herbsTag = new JsonSlurper().parse(new File(generatedRoot, "data/itsalive/tags/item/herbs.json"))
if (herbsTag.values.toSet() != ["itsalive:basil", "itsalive:mint", "itsalive:coriander"].toSet()) {
@@ -1174,7 +1322,7 @@ tasks.register("verifyCulinaryReliability") {
}
}
}
if (project.version.toString() != "0.0.0-alpha.31"
if (project.version.toString() != "0.0.0-alpha.38"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.28"
|| migration.culinary_fixes?.food_press_contents_destroyed_on_break != true
|| migration.culinary_fixes?.comparator_contract?.blocks?.size() != 6
@@ -1212,7 +1360,7 @@ tasks.register("verifyCauldronBatching") {
def recipe = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryProcessRecipe.java").text
def cauldron = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/CookingCauldronBlockEntity.java").text
def timed = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/AbstractTimedCulinaryBlockEntity.java").text
if (project.version.toString() != "0.0.0-alpha.31"
if (project.version.toString() != "0.0.0-alpha.38"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.29"
|| migration.cauldron_batches?.logical_stack_limit != 64
|| migration.cauldron_batches?.identity != "same_item_same_components"
@@ -1250,7 +1398,7 @@ tasks.register("verifyFermentationBarrelAutomation") {
def partialMatching = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryPartialMatching.java").text
def blockstate = new JsonSlurper().parse(
file("src/main/resources/assets/itsalive/blockstates/fermentation_barrel.json"))
if (project.version.toString() != "0.0.0-alpha.31"
if (project.version.toString() != "0.0.0-alpha.38"
|| migration.source?.modules?.itsalive != "0.0.0-alpha.29"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.30"
|| migration.fermentation_barrel?.maximum_units_per_input_slot != 1
@@ -1298,7 +1446,7 @@ tasks.register("verifyCulinaryLecternRelease") {
[(locale): new JsonSlurper().parse(file(
"src/main/resources/assets/itsalive/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.31"
if (project.version.toString() != "0.0.0-alpha.38"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.31"
|| migration.culinary_lectern?.book_tag != "minecraft:lectern_books"
|| migration.culinary_lectern?.owner_catalog_shared_while_placed != true
@@ -1322,8 +1470,79 @@ tasks.register("verifyCulinaryLecternRelease") {
}
}
tasks.register("verifyCanapliaForbiddenBook") {
group = "verification"
description = "Checks personal Forbidden Books, random page learning and lectern sharing."
dependsOn tasks.named("generateCanapliaResources")
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledge.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledgeData.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaLectern.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/item/CanapliaCodexPageItem.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/client/CanapliaCodexScreen.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java"))
inputs.file(file("src/main/resources/data/minecraft/tags/item/lectern_books.json"))
inputs.files(fileTree("src/main/resources/assets/canaplia/lang") { include "*.json" })
inputs.file(rootProject.file("CANAPLIA.md"))
doLast {
def knowledge = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledge.java").text
def knowledgeData = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledgeData.java").text
def lectern = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaLectern.java").text
def pageItem = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/item/CanapliaCodexPageItem.java").text
def screen = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/client/CanapliaCodexScreen.java").text
def components = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java").text
def lecternBooks = new JsonSlurper().parse(file(
"src/main/resources/data/minecraft/tags/item/lectern_books.json"))
def generatedCodex = new JsonSlurper().parse(new File(
generatedCanapliaResources.get().asFile, "assets/canaplia/items/codex.json"))
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file(
"src/main/resources/assets/canaplia/lang/${locale}.json"))]
}
def expectedNames = [
fr_fr: "Livre interdit",
en_us: "Forbidden Book",
ru_ru: "Запретная книга"]
if (project.version.toString() != "0.0.0-alpha.38"
|| !pageItem.contains("CanapliaKnowledge.learnRandomUnknown(serverPlayer)")
|| !knowledge.contains("unknownVariants(data.personal(player.getUUID()))")
|| !knowledge.contains("data.personal(inscription.ownerId())")
|| knowledge.contains("data.shared()")
|| !knowledgeData.contains("the legacy shared field is preserved")
|| !lectern.contains("UseBlockCallback.EVENT.register")
|| !lectern.contains("PlayerBlockBreakEvents.BEFORE.register")
|| !lectern.contains("CanapliaKnowledge.open(serverPlayer, book)")
|| !lectern.contains("CanapliaKnowledge.forgetInscription(taken)")
|| screen.contains("PAGE_BACKGROUND")
|| screen.contains("extractBackground(GuiGraphicsExtractor")
|| screen.contains("extractRenderState(GuiGraphicsExtractor")
|| !screen.contains("ChatFormatting.DARK_GRAY")
|| !screen.contains("ChatFormatting.DARK_GREEN")
|| screen.contains("screen.canaplia.codex.introduction")
|| !components.contains('ItsAliveMod.id("canaplia_codex")')
|| lecternBooks["values"] != ["itsalive:culinary_recipe_book", "canaplia:codex"]
|| generatedCodex.model?.model != "minecraft:item/enchanted_book"
|| locales.any { locale, values ->
values["item.canaplia.codex"] != expectedNames[locale]
|| values["screen.canaplia.codex.title"] != expectedNames[locale]
|| !values.containsKey("message.canaplia.codex_page.complete")
|| !values.containsKey("tooltip.canaplia.codex.personal")
}) {
throw new GradleException("The Canaplia Forbidden Book contract is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("verifyItsAlive")
dependsOn tasks.named("canapliaFoundationSmoke")
dependsOn tasks.named("verifyCanapliaForbiddenBook")
dependsOn tasks.named("verifyCulinaryReliability")
dependsOn tasks.named("verifyCauldronBatching")
dependsOn tasks.named("culinaryBatchingSmoke")
@@ -1,5 +1,7 @@
package fr.koka99cab.sanctuary26.itsalive;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaFoundation;
import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchResidentService;
import fr.koka99cab.sanctuary26.itsalive.network.ItsAliveNetworking;
import fr.koka99cab.sanctuary26.itsalive.culinary.knowledge.CulinaryRecipeKnowledge;
import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveRegistries;
@@ -12,7 +14,7 @@ import org.slf4j.LoggerFactory;
public final class ItsAliveMod implements ModInitializer {
public static final String MOD_ID = "itsalive";
public static final String DISPLAY_NAME = "It's Alive !";
public static final int CURRENT_DATA_VERSION = 1;
public static final int CURRENT_DATA_VERSION = 4;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ItsAliveMod() {
@@ -24,7 +26,9 @@ public final class ItsAliveMod implements ModInitializer {
@Override
public void onInitialize() {
CanapliaFoundation.initialize();
ItsAliveRegistries.initialize();
NotchResidentService.initialize();
ItsAliveNetworking.initialize();
CulinaryRecipeKnowledge.initialize();
ProceduralVillagerNames.initialize();
@@ -0,0 +1,46 @@
package fr.koka99cab.sanctuary26.itsalive.api.notch;
import java.util.Objects;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.LivingEntity;
/** Public bridge allowing the combat-owning Ouch module to drive Notch attacks. */
public final class NotchCombatApi {
private static Engine engine = Engine.NOOP;
private static boolean registered;
private NotchCombatApi() {
}
public static synchronized void register(Engine implementation) {
Objects.requireNonNull(implementation, "implementation");
if (registered) throw new IllegalStateException("A Notch combat engine is already registered");
engine = implementation;
registered = true;
}
public static void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) {
engine.telegraph(level, boss, target, attack);
}
public static void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) {
engine.execute(level, boss, target, attack);
}
public enum Attack {
MELEE,
FIREBALL,
LIGHTNING
}
public interface Engine {
Engine NOOP = new Engine() {
@Override public void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) { }
@Override public void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) { }
};
void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack);
void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack);
}
}
@@ -0,0 +1,41 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.monster.Enemy;
import net.minecraft.world.item.ItemStack;
/** Bounded player-kill drops that make the Codex and its loose pages survival rewards. */
public final class CanapliaCodexDrops {
public static final int PAGE_DROP_DENOMINATOR = 12;
public static final int CODEX_DROP_DENOMINATOR = 256;
private static boolean initialized;
private CanapliaCodexDrops() {}
public static void initialize() {
if (initialized) return;
initialized = true;
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (!(entity instanceof Enemy)
|| !(entity.level() instanceof ServerLevel level)
|| !(damageSource.getEntity() instanceof ServerPlayer)) return;
if (dropsPage(level.getRandom().nextInt(PAGE_DROP_DENOMINATOR))) {
entity.spawnAtLocation(level, new ItemStack(CanapliaContent.CODEX_PAGE));
}
if (dropsCodex(level.getRandom().nextInt(CODEX_DROP_DENOMINATOR))) {
entity.spawnAtLocation(level, new ItemStack(CanapliaContent.CODEX));
}
});
}
static boolean dropsPage(int roll) {
return roll == 0;
}
static boolean dropsCodex(int roll) {
return roll == 0;
}
}
@@ -0,0 +1,43 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.UUID;
import net.minecraft.core.UUIDUtil;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Binds a placed Forbidden Book to the player whose knowledge it shares. */
public record CanapliaCodexInscription(UUID ownerId, String ownerName) {
private static final int MAX_NAME_LENGTH = 32;
public static final Codec<CanapliaCodexInscription> CODEC = RecordCodecBuilder.create(instance ->
instance.group(
UUIDUtil.CODEC.fieldOf("owner_id").forGetter(CanapliaCodexInscription::ownerId),
Codec.STRING.fieldOf("owner_name").forGetter(CanapliaCodexInscription::ownerName)
).apply(instance, CanapliaCodexInscription::new)
);
public static final StreamCodec<RegistryFriendlyByteBuf, CanapliaCodexInscription> STREAM_CODEC =
StreamCodec.composite(
UUIDUtil.STREAM_CODEC,
CanapliaCodexInscription::ownerId,
ByteBufCodecs.STRING_UTF8,
CanapliaCodexInscription::ownerName,
CanapliaCodexInscription::new
);
public CanapliaCodexInscription {
if (ownerId == null) throw new IllegalArgumentException("Forbidden Book owner is required");
ownerName = sanitizeName(ownerName);
}
public static CanapliaCodexInscription of(UUID ownerId, String ownerName) {
return new CanapliaCodexInscription(ownerId, ownerName);
}
private static String sanitizeName(String ownerName) {
String cleaned = ownerName == null ? "" : ownerName.replaceAll("\\p{Cntrl}", "").trim();
if (cleaned.length() > MAX_NAME_LENGTH) cleaned = cleaned.substring(0, MAX_NAME_LENGTH);
return cleaned.isEmpty() ? "?" : cleaned;
}
}
@@ -0,0 +1,92 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import com.mojang.datafixers.util.Either;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Per-dimension composter state that reads the unversioned 2.0 list and writes a monotone wrapper. */
public final class CanapliaComposterData extends SavedData {
public static final int DATA_VERSION = 1;
private static final int MAX_ENTRIES = 16_384;
public record Entry(long pos, int budCount, String sourceVariant) {
private static final Codec<Entry> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("pos").forGetter(Entry::pos),
Codec.INT.fieldOf("bud_count").forGetter(Entry::budCount),
Codec.STRING.optionalFieldOf("source_variant", "common").forGetter(Entry::sourceVariant)
).apply(instance, Entry::new));
public Entry {
budCount = Math.clamp(budCount, 1, CanapliaComposting.REQUIRED_BUDS);
sourceVariant = CanapliaVariantKey.byKey(sourceVariant).map(CanapliaVariantKey::key).orElse("common");
}
}
private static final Codec<CanapliaComposterData> VERSIONED_CODEC = RecordCodecBuilder.create(instance ->
instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
Entry.CODEC.listOf().optionalFieldOf("entries", List.of()).forGetter(CanapliaComposterData::entries)
).apply(instance, CanapliaComposterData::new));
private static final Codec<CanapliaComposterData> LEGACY_CODEC = Entry.CODEC.listOf()
.xmap(entries -> new CanapliaComposterData(0, entries), CanapliaComposterData::entries);
private static final Codec<CanapliaComposterData> CODEC = Codec.either(VERSIONED_CODEC, LEGACY_CODEC)
.xmap(value -> value.map(data -> data, data -> data), Either::left);
private static final SavedDataType<CanapliaComposterData> TYPE = new SavedDataType<>(
CanapliaLegacyCatalog.SAVED_DATA_IDS.getFirst(), CanapliaComposterData::new, CODEC, null);
private int dataVersion;
private final Map<Long, Entry> tracked = new HashMap<>();
public CanapliaComposterData() {
this(DATA_VERSION, List.of());
}
private CanapliaComposterData(int dataVersion, List<Entry> entries) {
this.dataVersion = Math.max(0, dataVersion);
entries.stream().limit(MAX_ENTRIES).forEach(entry -> tracked.put(entry.pos(), entry));
}
public static CanapliaComposterData get(ServerLevel level) {
CanapliaComposterData data = level.getDataStorage().computeIfAbsent(TYPE);
if (data.dataVersion < DATA_VERSION) {
data.dataVersion = DATA_VERSION;
data.setDirty();
}
return data;
}
public Entry entry(BlockPos pos) {
return tracked.get(pos.asLong());
}
public Entry record(BlockPos pos, CanapliaVariantKey variant) {
Entry previous = tracked.get(pos.asLong());
if (previous != null && !previous.sourceVariant().equals(variant.key())) {
throw new IllegalArgumentException("Tracked Canaplia composter already contains another variant");
}
int count = Math.min(CanapliaComposting.REQUIRED_BUDS, previous == null ? 1 : previous.budCount() + 1);
Entry current = new Entry(pos.asLong(), count, variant.key());
tracked.put(pos.asLong(), current);
setDirty();
return current;
}
public void clear(BlockPos pos) {
if (tracked.remove(pos.asLong()) != null) setDirty();
}
private List<Entry> entries() {
return tracked.values().stream().limit(MAX_ENTRIES).toList();
}
static Codec<CanapliaComposterData> codecForTests() {
return CODEC;
}
}

Some files were not shown because too many files have changed in this diff Show More