mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-17 14:04:11 +00:00
Compare commits
109
Commits
@@ -1,87 +1,78 @@
|
||||
name: Iris Bug Report
|
||||
description: File a bug report for Iris
|
||||
labels: [ bug ]
|
||||
name: Bug report
|
||||
description: Something in Iris isn't working.
|
||||
labels: [bug]
|
||||
type: Bug
|
||||
assignees: [SwiftSwamp]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill this out!
|
||||
If this does not work for you, feel free to use the [blank](https://github.com/VolmitSoftware/Iris/issues/new) format.
|
||||
Make sure you're on the [latest release](https://github.com/VolmitSoftware/Iris/releases/latest) and that it hasn't [already been reported](https://github.com/VolmitSoftware/Iris/issues?q=is%3Aissue).
|
||||
|
||||
The Iris plugin supports Minecraft 26.1.2 through 26.3 on Paper, Purpur, Leaf, Canvas, Folia, and Spigot. The Fabric, Forge, and NeoForge builds support 26.3. Both need Java 25.
|
||||
- type: textarea
|
||||
id: how
|
||||
id: issue
|
||||
attributes:
|
||||
label: Problem
|
||||
description: Please give a text description of how you reached the problem
|
||||
value: |
|
||||
1. Install Iris...
|
||||
2. Do this...
|
||||
3. Do that...
|
||||
4. Observe the error...
|
||||
label: What issue are you having?
|
||||
description: Include any error message you saw.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
id: steps
|
||||
attributes:
|
||||
label: Solution
|
||||
description: Explain where you think the problem comes from (optional)
|
||||
placeholder: The code to place a is missing b and c...
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Start a server with Iris installed
|
||||
2. Change ... in the config
|
||||
3. Run /iris ...
|
||||
4. ... happens instead of ...
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
- type: input
|
||||
id: diagnostics
|
||||
attributes:
|
||||
label: Debug dump or latest log file
|
||||
description: Run `/iris debug dump upload=true` and paste the link. If Iris won't start, upload `logs/latest.log` to [mclo.gs](https://mclo.gs) and paste that link instead.
|
||||
placeholder: https://mclo.gs/...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: plugin-version
|
||||
attributes:
|
||||
label: "Iris version"
|
||||
description: Run `/iris debug version` (`/iris version` on Fabric, Forge, and NeoForge).
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Which server platform is Iris running on? Pick the loader or server software, not the launcher.
|
||||
label: Server software
|
||||
options:
|
||||
- Paper
|
||||
- Purpur
|
||||
- Leaf
|
||||
- Canvas
|
||||
- Folia
|
||||
- Spigot / CraftBukkit
|
||||
- Fabric
|
||||
- Forge
|
||||
- NeoForge
|
||||
- Other (describe under Problem)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: mcversion
|
||||
attributes:
|
||||
label: Minecraft Version
|
||||
description: What version of Minecraft is the server on? Iris 4.x supports 26.1.2 to 26.3 on Bukkit-family servers and 26.3 on Fabric, Forge and NeoForge; older versions are not supported.
|
||||
options:
|
||||
- '26.3'
|
||||
- '26.2'
|
||||
- '26.1.2'
|
||||
- "Paper"
|
||||
- "Purpur"
|
||||
- "Leaf"
|
||||
- "Canvas"
|
||||
- "Folia"
|
||||
- "Spigot"
|
||||
- "Fabric"
|
||||
- "Forge"
|
||||
- "NeoForge"
|
||||
- Other (unsupported)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: loaderversion
|
||||
- type: dropdown
|
||||
id: minecraft
|
||||
attributes:
|
||||
label: Platform / Loader Version
|
||||
description: Exact server or loader build (see console). For example "Paper 26.3-133", "Fabric Loader 0.19.5", "NeoForge 26.3.0.33-beta".
|
||||
placeholder: DO NOT SAY "LATEST"
|
||||
label: Minecraft version
|
||||
options:
|
||||
- "26.3"
|
||||
- "26.2"
|
||||
- "26.1.2"
|
||||
- Other (unsupported)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: irisversion
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Iris Version
|
||||
description: What version of Iris are you running? (see console)
|
||||
placeholder: DO NOT SAY "LATEST"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: logs
|
||||
attributes:
|
||||
label: Log
|
||||
description: Paste a full log. Always use [mclogs](https://mclo.gs) Or [Pastebin](https://pastebin.com/). Must not be a crash report. Must be a full log. Must not be a screenshot of a log.
|
||||
placeholder: https://mslog.gs/...
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: thanks
|
||||
attributes:
|
||||
value: "Thank you for filling out the form! We will be with you soon. Please do not ask support to review your report."
|
||||
label: Anything else?
|
||||
description: Other plugins involved, screenshots, or videos.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Help and questions
|
||||
url: https://volmitsoftware.com/discord
|
||||
about: Ask setup and configuration questions on the Volmit Discord.
|
||||
- name: "Iris documentation"
|
||||
url: https://docs.volmitsoftware.com/iris
|
||||
about: Installation, commands, permissions, and configuration.
|
||||
@@ -1,46 +1,28 @@
|
||||
name: Iris Feature Request
|
||||
description: File a feature request for Iris. If you want to report a bug this is not the place.
|
||||
labels: [ feature ]
|
||||
name: Suggestion
|
||||
description: Suggest a new feature or a change to Iris.
|
||||
labels: [enhancement]
|
||||
type: Feature
|
||||
assignees: [SwiftSwamp]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill this out!
|
||||
If this does not work for you, feel free to use the [blank](https://github.com/VolmitSoftware/Iris/issues/new) format.
|
||||
- type: dropdown
|
||||
id: arc
|
||||
Check the [existing suggestions](https://github.com/VolmitSoftware/Iris/issues?q=is%3Aissue+label%3Aenhancement) first and add relevant information to the existing issue.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Adding, Removing, or Changing
|
||||
description: What are you doing
|
||||
options:
|
||||
- Adding
|
||||
- Removing
|
||||
- Changing
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: atype
|
||||
attributes:
|
||||
label: Type of Modification
|
||||
description: What is it for?
|
||||
value: I want to ...
|
||||
label: What are your suggestions for new features or changes?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: desc
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What are you trying to modify
|
||||
description: Give as detailed of a description as you can for the modification that you want done (include pictures if applicable)
|
||||
value: The way I would implement this is ...
|
||||
label: How should it work?
|
||||
description: Provide as much detail as possible on what you would like changed or added.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternative
|
||||
id: extra
|
||||
attributes:
|
||||
label: Alternatives
|
||||
description: What alternatives have you considered?
|
||||
value: If this could not be implemented I would ...
|
||||
- type: markdown
|
||||
id: thanks
|
||||
attributes:
|
||||
value: "Thank you for filling out the form! We will be with you soon. Please do not ask support to review your report."
|
||||
label: Anything else?
|
||||
description: Any additional information such as screenshots, examples, etc.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Translation
|
||||
description: A message in Iris is wrong, missing, or unclear.
|
||||
labels: [bug]
|
||||
type: Bug
|
||||
assignees: [SwiftSwamp]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
The language files are in [`core/src/main/resources/languages/`](https://github.com/VolmitSoftware/Iris/tree/HEAD/core/src/main/resources/languages).
|
||||
|
||||
You can create a pull request to modify the files directly.
|
||||
- type: dropdown
|
||||
id: language
|
||||
attributes:
|
||||
label: Language
|
||||
options:
|
||||
- English (en_US)
|
||||
- German (de_DE)
|
||||
- Spanish (es_ES)
|
||||
- Finnish (fi_FI)
|
||||
- French (fr_FR)
|
||||
- Hebrew (he_IL)
|
||||
- Italian (it_IT)
|
||||
- Japanese (ja-JP)
|
||||
- Korean (ko_KR)
|
||||
- Lithuanian (lt_LT)
|
||||
- Dutch (nl_NL)
|
||||
- Polish (pl_PL)
|
||||
- Portuguese (pt_PT)
|
||||
- Russian (ru_RU)
|
||||
- Turkish (tr_TR)
|
||||
- Vietnamese (vi_VI)
|
||||
- Chinese, Simplified (zh_CN)
|
||||
- Chinese, Traditional (zh_TW)
|
||||
- Another language (name it below)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current
|
||||
attributes:
|
||||
label: Current text
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: suggested
|
||||
attributes:
|
||||
label: Suggested text
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: where
|
||||
attributes:
|
||||
label: Where does it appear?
|
||||
description: The command used or message key where it is appearing.
|
||||
+452
-296
@@ -1,160 +1,215 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the
|
||||
works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all
|
||||
versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use
|
||||
the GNU General Public License for most of our software; it applies also to any other work released this way by its
|
||||
authors. You can apply it to your programs, too.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make
|
||||
sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive
|
||||
source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and
|
||||
that you know you can do these things.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights.
|
||||
Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it:
|
||||
responsibilities to respect the freedom of others.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients
|
||||
the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must
|
||||
show them these terms so they know their rights.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute
|
||||
and/or modify it.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software.
|
||||
For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems
|
||||
will not be attributed erroneously to authors of previous versions.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although
|
||||
the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the
|
||||
software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely
|
||||
where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we stand ready to extend this provision to those
|
||||
domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict
|
||||
development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger
|
||||
that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "
|
||||
Licensees" and
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission,
|
||||
other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a
|
||||
work "based on" the earlier work.
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based on the Program.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily
|
||||
liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy.
|
||||
Propagation includes copying, distribution (with or without modification), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction
|
||||
with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright
|
||||
notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the extent that warranties are provided), that
|
||||
licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a
|
||||
list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means
|
||||
any non-source form of a work.
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body,
|
||||
or, in the case of interfaces specified for a particular programming language, one that is widely used among developers
|
||||
working in that language.
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in
|
||||
the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to
|
||||
enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is
|
||||
available to the public in source code form. A
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used
|
||||
to run it.
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (
|
||||
for an executable work) run the object code and to modify the work, including scripts to control those activities.
|
||||
However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs
|
||||
which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding
|
||||
Source includes interface definition files associated with source files for the work, and the source code for shared
|
||||
libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data
|
||||
communication or control flow between those subprograms and other parts of the work.
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the
|
||||
Corresponding Source.
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided
|
||||
the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program.
|
||||
The output from running a covered work is covered by this License only if the output, given its content, constitutes a
|
||||
covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license
|
||||
otherwise remains in force. You may convey covered works to others for the sole purpose of having them make
|
||||
modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do not control copyright. Those thus making or running
|
||||
the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that
|
||||
prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not
|
||||
allowed; section 10 makes it unnecessary.
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling
|
||||
obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or
|
||||
restricting circumvention of such measures.
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the
|
||||
extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you
|
||||
disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users,
|
||||
your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating
|
||||
that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices
|
||||
of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for
|
||||
a fee.
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source
|
||||
code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
@@ -177,17 +232,22 @@ code under the terms of section 4, provided that you also meet all of these cond
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of
|
||||
the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or
|
||||
distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the
|
||||
compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause
|
||||
this License to apply to the other parts of the aggregate.
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
@@ -230,55 +290,77 @@ machine-readable Corresponding Source under the terms of this License, in one of
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library,
|
||||
need not be included in conveying the object code work.
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used
|
||||
for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In
|
||||
determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a
|
||||
particular product received by a particular user, "normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way in which the particular user actually uses, or
|
||||
expects or is expected to use, the product. A product is a consumer product regardless of whether the product has
|
||||
substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of
|
||||
the product.
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information
|
||||
required to install and execute modified versions of a covered work in that User Product from a modified version of its
|
||||
Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code
|
||||
is in no case prevented or interfered with solely because modification has been made.
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the
|
||||
conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to
|
||||
the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding
|
||||
Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not
|
||||
apply if neither you nor any third party retains the ability to install modified object code on the User Product (for
|
||||
example, the work has been installed in ROM).
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support
|
||||
service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product
|
||||
in which it has been modified or installed. Access to a network may be denied when the modification itself materially
|
||||
and adversely affects the operation of the network or violates the rules and protocols for communication across the
|
||||
network.
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format
|
||||
that is publicly documented (and with an implementation available to the public in source code form), and must require
|
||||
no special password or key for unpacking, reading or copying.
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of
|
||||
its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were
|
||||
included in this License, to the extent that they are valid under applicable law. If additional permissions apply only
|
||||
to part of the Program, that part may be used separately under those permissions, but the entire Program remains
|
||||
governed by this License without regard to the additional permissions.
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or
|
||||
from any part of it. (Additional permissions may be written to require their own removal in certain cases when you
|
||||
modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have
|
||||
or can give appropriate copyright permission.
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by
|
||||
the copyright holders of that material) supplement the terms of this License with terms:
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
@@ -303,182 +385,251 @@ the copyright holders of that material) supplement the terms of this License wit
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the
|
||||
Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with
|
||||
a term that is a further restriction, you may remove that term. If a license document contains a further restriction but
|
||||
permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of
|
||||
that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a
|
||||
statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as
|
||||
exceptions; the above requirements apply either way.
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to
|
||||
propagate or modify it is void, and will automatically terminate your rights under this License (including any patent
|
||||
licenses granted under the third paragraph of section 11).
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is
|
||||
reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b)
|
||||
permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days
|
||||
after the cessation.
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you
|
||||
of the violation by some reasonable means, this is the first time you have received notice of violation of this
|
||||
License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the
|
||||
notice.
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or
|
||||
rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not
|
||||
qualify to receive new licenses for the same material under section 10.
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a
|
||||
covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not
|
||||
require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered
|
||||
work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run,
|
||||
modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third
|
||||
parties with this License.
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or
|
||||
subdividing an organization, or merging organizations. If propagation of a covered work results from an entity
|
||||
transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work
|
||||
the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with
|
||||
reasonable efforts.
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For
|
||||
example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License,
|
||||
and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using,
|
||||
selling, offering for sale, or importing the Program or any portion of it.
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the
|
||||
Program is based. The work thus licensed is called the contributor's "contributor version".
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already
|
||||
acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or
|
||||
selling its contributor version, but do not include claims that would be infringed only as a consequence of further
|
||||
modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent
|
||||
sublicenses in a manner consistent with the requirements of this License.
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential
|
||||
patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its
|
||||
contributor version.
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not
|
||||
to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a
|
||||
patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not
|
||||
available for anyone to copy, free of charge and under the terms of this License, through a publicly available network
|
||||
server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available,
|
||||
or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a
|
||||
manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "
|
||||
Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in
|
||||
a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in
|
||||
that country that you have reason to believe are valid.
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring
|
||||
conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing
|
||||
them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is
|
||||
automatically extended to all recipients of the covered work and works based on it.
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of,
|
||||
or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You
|
||||
may not convey a covered work if you are a party to an arrangement with a third party that is in the business of
|
||||
distributing software, under which you make payment to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a
|
||||
discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from
|
||||
those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered
|
||||
work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to
|
||||
infringement that may otherwise be available to you under applicable patent law.
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this
|
||||
License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to
|
||||
satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence
|
||||
you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further
|
||||
conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License
|
||||
would be to refrain entirely from conveying the Program.
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work
|
||||
licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the
|
||||
resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special
|
||||
requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply
|
||||
to the combination as such.
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to
|
||||
time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new
|
||||
problems or concerns.
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the
|
||||
GNU General Public License "or any later version" applies to it, you have the option of following the terms and
|
||||
conditions either of that numbered version or of any later version published by the Free Software Foundation. If the
|
||||
Program does not specify a version number of the GNU General Public License, you may choose any version ever published
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used,
|
||||
that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the
|
||||
Program.
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed
|
||||
on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING
|
||||
THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR
|
||||
IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
|
||||
THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU
|
||||
ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO
|
||||
MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
|
||||
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO
|
||||
LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to
|
||||
their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil
|
||||
liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program
|
||||
in return for a fee.
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve
|
||||
this is to make it free software which everyone can redistribute and change under these terms.
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to
|
||||
most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer
|
||||
to where the full notice is found.
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
@@ -498,21 +649,26 @@ to where the full notice is found.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of
|
||||
course, your program's commands might be different; for a GUI interface, you would use an "about box".
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for
|
||||
the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is
|
||||
a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If
|
||||
this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
@@ -25,7 +25,7 @@ Canonical English is defined in the typed Java catalogs under `core/src/main/jav
|
||||
| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.3 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable |
|
||||
| Fabric | mod jar | 26.3 | Server worldgen + client HUD; requires Fabric Loader 0.19.5+ |
|
||||
| Forge | mod jar | 26.3 | Server worldgen + client HUD; current target is Forge 26.3-66.0.8 |
|
||||
| NeoForge | mod jar | 26.3 | Server worldgen + client HUD; current target is NeoForge 26.3.0.33-beta |
|
||||
| NeoForge | mod jar | 26.3 | Server worldgen + client HUD; requires NeoForge 26.3.0.65-beta+; current target is NeoForge 26.3.0.83 |
|
||||
|
||||
Java 25 is required on every platform.
|
||||
|
||||
@@ -252,7 +252,9 @@ Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everyth
|
||||
./gradlew buildAllToOut
|
||||
```
|
||||
|
||||
builds every platform artifact into the workspace-level `../PluginOuts/` directory:
|
||||
builds every platform artifact into the workspace-level `../PluginOuts/` directory: the Bukkit jar
|
||||
and a Fabric, Forge and NeoForge jar for every Minecraft version in `gradle/mod-targets.json`
|
||||
(1.21.1, 26.1.2, 26.2 and 26.3):
|
||||
|
||||
```
|
||||
Iris v<version> [CraftBukkit] <mc>.jar
|
||||
@@ -261,14 +263,18 @@ Iris v<version> [Forge] <mc>+<loader>.jar
|
||||
Iris v<version> [NeoForge] <mc>+<loader>.jar
|
||||
```
|
||||
|
||||
Per-platform tasks: `./gradlew buildBukkit`, `buildFabric`, `buildForge`, `buildNeoforge`. The
|
||||
Per-platform tasks: `./gradlew buildBukkit`, and `build<Loader><mc>` for one mod jar, for example
|
||||
`buildFabric26_2` or `buildNeoForge1_21_1`. `buildFabric`, `buildForge` and `buildNeoforge` export the
|
||||
26.3 jars. `./gradlew checkAll` runs every check, then builds and verifies all twelve mod jars and runs their
|
||||
unit tests; Forge 1.21.1 only compiles its tests because Forge 52 cannot bootstrap Minecraft outside
|
||||
FML. The
|
||||
SPI jar (the pure-JVM adapter/platform contract, not the stable plugin API) is built to `spi/build/libs/` by
|
||||
`./gradlew :spi:jar`.
|
||||
|
||||
`./gradlew buildAll` also runs `buildAllToOut` and copies the jars into a consumer dropin tree
|
||||
for a local test server. The consumer directory defaults to `../../[Minecraft Server]/consumers/`
|
||||
when it exists, otherwise `build/consumers/` inside the repo. `-Plocation=/path/to/consumers`
|
||||
changes only the consumer directory. Both tasks copy all four verified platform jars into
|
||||
changes only the consumer directory. Both tasks copy every verified platform jar into
|
||||
`../PluginOuts/`.
|
||||
|
||||
VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory
|
||||
|
||||
@@ -223,7 +223,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
private IrisUpdateNotifier updateNotifier;
|
||||
private BukkitDebugDump debugDump;
|
||||
private volatile SettingsHotloadWatch settingsHotloadWatch;
|
||||
private volatile Thread serverLifecycleThread;
|
||||
private volatile ServerShutdownBoundary serverShutdownBoundary;
|
||||
|
||||
public static VolmitSender getSender() {
|
||||
@@ -832,7 +831,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
|
||||
public void addShutdownHook() {
|
||||
removeShutdownHook();
|
||||
serverLifecycleThread = Thread.currentThread();
|
||||
serverShutdownBoundary = INMS.isBound() ? INMS.get().createServerShutdownBoundary() : null;
|
||||
shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook");
|
||||
try {
|
||||
@@ -1164,7 +1162,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
if (generator == null) {
|
||||
continue;
|
||||
}
|
||||
IrisToolbelt.beginWorldMaintenance(world, reason, true);
|
||||
IrisToolbelt.beginWorldMaintenance(world, reason);
|
||||
if (!deferredShutdownGenerators.contains(generator)) {
|
||||
deferredShutdownGenerators.add(generator);
|
||||
}
|
||||
@@ -1176,52 +1174,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
if (!postStopFinisherStarted.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
Thread activeServerThread = serverLifecycleThread;
|
||||
if (activeServerThread == null) {
|
||||
Iris.warn("Iris could not start its post-stop runtime finisher because the server lifecycle thread is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
Thread finisher = new Thread(() -> {
|
||||
if (!awaitServerThreadTermination(activeServerThread)) {
|
||||
return;
|
||||
}
|
||||
finishDeferredRuntimeTeardown("post-server-stop", 30L);
|
||||
}, "Iris-PostStop-Finisher");
|
||||
Thread finisher = new Thread(() -> finishRuntimeAfterServerShutdown("post-server-stop"),
|
||||
"Iris-PostStop-Finisher");
|
||||
finisher.setDaemon(false);
|
||||
finisher.start();
|
||||
}
|
||||
|
||||
static boolean awaitServerThreadTermination(Thread serverThread) {
|
||||
if (serverThread == null || serverThread == Thread.currentThread()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
serverThread.join();
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
Iris.reportError("Iris post-stop runtime finisher was interrupted.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void runShutdownHook() {
|
||||
if (startupBoundaryRestart.get()) {
|
||||
finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L);
|
||||
return;
|
||||
}
|
||||
finishRuntimeAfterServerShutdown("shutdown-hook");
|
||||
}
|
||||
|
||||
private void finishRuntimeAfterServerShutdown(String reason) {
|
||||
if (!awaitServerShutdownBoundary()) {
|
||||
SHUTDOWN_ERRORS.println("[Iris] Paper did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit.");
|
||||
SHUTDOWN_ERRORS.println("[Iris] The server did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit.");
|
||||
return;
|
||||
}
|
||||
finishDeferredRuntimeTeardown("shutdown-hook", 30L);
|
||||
finishDeferredRuntimeTeardown(reason, 30L);
|
||||
}
|
||||
|
||||
private boolean awaitServerShutdownBoundary() {
|
||||
ServerShutdownBoundary boundary = serverShutdownBoundary;
|
||||
if (boundary == null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return boundary.await(
|
||||
@@ -1508,12 +1486,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
public int getIrisVersion() {
|
||||
String input = getDescription().getVersion();
|
||||
int hyphenIndex = input.indexOf('-');
|
||||
if (hyphenIndex != -1) {
|
||||
String result = input.substring(0, hyphenIndex);
|
||||
result = result.replaceAll("\\.", "");
|
||||
return Integer.parseInt(result);
|
||||
String release = hyphenIndex < 0 ? input : input.substring(0, hyphenIndex);
|
||||
if (!release.matches("[0-9]+(?:\\.[0-9]+)*")) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(release.replace(".", ""));
|
||||
} catch (NumberFormatException invalid) {
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public int getMCVersion() {
|
||||
|
||||
@@ -81,7 +81,6 @@ import org.bukkit.FluidCollisionMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
@@ -309,23 +308,33 @@ public class CommandStudio implements DirectorExecutor {
|
||||
O<Integer> ta = new O<>();
|
||||
ta.set(-1);
|
||||
|
||||
var sender = sender();
|
||||
var player = player();
|
||||
var engine = engine();
|
||||
VolmitSender sender = sender();
|
||||
Player player = player();
|
||||
Engine engine = engine();
|
||||
|
||||
ta.set(J.sr(() ->
|
||||
{
|
||||
if (!player.getOpenInventory().getType().equals(InventoryType.CHEST)) {
|
||||
ta.set(J.sr(() -> {
|
||||
if (!J.runEntity(player, () -> {
|
||||
if (player.getOpenInventory().getTopInventory() != inv) {
|
||||
J.csr(ta.get());
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY));
|
||||
return;
|
||||
}
|
||||
if (!add) {
|
||||
inv.clear();
|
||||
}
|
||||
Location location = player.getLocation();
|
||||
try {
|
||||
EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE,
|
||||
location.getWorld(), location.getBlockX(), location.getBlockY(), location.getBlockZ());
|
||||
} catch (RuntimeException | Error failure) {
|
||||
J.csr(ta.get());
|
||||
Iris.reportError(failure);
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CANNOT_ADD_ITEMS_VIRTUAL_INVENTORY_BECAUSE,
|
||||
MessageArgument.untrusted("value", String.valueOf(failure.getMessage()))));
|
||||
}
|
||||
}, 0, () -> J.csr(ta.get()))) {
|
||||
J.csr(ta.get());
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!add) {
|
||||
inv.clear();
|
||||
}
|
||||
|
||||
EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE, player.getWorld(), player.getLocation().getBlockX(), player.getLocation().getBlockY(), player.getLocation().getBlockZ());
|
||||
}, fast ? 5 : 35));
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_INVENTORY_NOW));
|
||||
|
||||
+4
-8
@@ -896,14 +896,10 @@ public final class IrisWorldGeneratorResolver {
|
||||
}
|
||||
|
||||
private static String dimensionTypeKey(IrisData data, IrisDimension dimension) {
|
||||
return IrisPlatforms.get()
|
||||
.registries()
|
||||
.generationRegistry()
|
||||
.dimensionTypeResourceKey(
|
||||
data.getDataFolder().getName(),
|
||||
dimension.getLoadKey(),
|
||||
dimension.getDimensionTypeKey()
|
||||
);
|
||||
return dimension.dimensionTypeResourceKey(
|
||||
IrisPlatforms.get().registries().generationRegistry(),
|
||||
data.getDataFolder().getName()
|
||||
);
|
||||
}
|
||||
|
||||
private record LoadedHistory(GenerationHistory history, File activePack) {
|
||||
|
||||
@@ -2,6 +2,7 @@ name: IrisNativeLifecycleFixture
|
||||
version: 1.0.0
|
||||
main: art.arcane.iris.fixture.NativeLifecycleFixture
|
||||
api-version: '26.1.2'
|
||||
folia-supported: true
|
||||
depend: [Iris]
|
||||
commands:
|
||||
irislifecycle:
|
||||
|
||||
+213
-6
@@ -1,8 +1,12 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.volmlib.nativelib.NativeAdapters;
|
||||
import art.arcane.iris.world.runtime.RuntimeInjection;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.structure.placement.IrisStructureLocator;
|
||||
import art.arcane.iris.world.IrisCreator;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.IrisWorldStorage;
|
||||
import art.arcane.iris.world.WorldCreatorCompat;
|
||||
@@ -13,6 +17,15 @@ import org.bukkit.entity.Player;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.block.Container;
|
||||
import org.bukkit.loot.Lootable;
|
||||
import org.bukkit.loot.LootTable;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.MapMeta;
|
||||
import org.bukkit.map.MapView;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
import org.bukkit.util.StructureSearchResult;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeStructureReader;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime;
|
||||
@@ -38,12 +51,15 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
|
||||
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
|
||||
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
|
||||
private static final NamespacedKey TREASURE_KEY = new NamespacedKey("iris", "treasure_map_probe");
|
||||
private static final long WORLD_SEED = 78264193L;
|
||||
private NativeWorldRuntime runtime;
|
||||
private TreasureLootChecks treasureLootChecks;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
runtime = NativeAdapters.require(NativeWorldRuntime.class);
|
||||
treasureLootChecks = new TreasureLootChecks(this);
|
||||
NativeTerrainAccess terrain = NativeAdapters.require(NativeTerrainAccess.class);
|
||||
getLogger().info("Native lifecycle ready: " + runtime.description()
|
||||
+ "; terrain provider=" + terrain.getClass().getName());
|
||||
@@ -56,12 +72,32 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
if (arguments.length == 0) {
|
||||
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
switch (arguments[0]) {
|
||||
case "treasure-map-create" -> createTreasureMapWorld(sender);
|
||||
case "treasure-map-limit" -> runTreasureRegion(sender, () -> checkTreasureMapLimit(sender));
|
||||
case "treasure-map-chest" -> {
|
||||
if (arguments.length != 3) {
|
||||
throw new IllegalArgumentException("Use treasure-map-chest <destination> <CHEST|COPPER_CHEST>");
|
||||
}
|
||||
runTreasureRegion(sender, () -> prepareTreasureMapChest(sender, arguments[1], arguments[2]));
|
||||
}
|
||||
case "treasure-map-inspect" -> {
|
||||
if (arguments.length != 2) {
|
||||
throw new IllegalArgumentException("Use treasure-map-inspect <destination>");
|
||||
}
|
||||
runTreasureRegion(sender, () -> inspectTreasureMapChest(sender, arguments[1]));
|
||||
}
|
||||
case "treasure-map-check" -> runTreasureRegion(sender, () -> verifyTreasureMapLoot(sender));
|
||||
case "treasure-extra-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verify(sender));
|
||||
case "treasure-region-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyRegion(sender));
|
||||
case "treasure-reload-before" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, false));
|
||||
case "treasure-reload-after" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, true));
|
||||
case "treasure-resources" -> treasureLootChecks.reportResourceReloads(sender);
|
||||
case "create" -> create(sender);
|
||||
case "unload" -> unload(sender);
|
||||
case "structures" -> structures(sender);
|
||||
@@ -80,6 +116,177 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
return true;
|
||||
}
|
||||
|
||||
private void createTreasureMapWorld(CommandSender sender) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
throw new IllegalStateException("Treasure map fixture requires a player");
|
||||
}
|
||||
runTreasureRegion(sender, () -> prepareTreasureMapWorld(player));
|
||||
}
|
||||
|
||||
private void runTreasureRegion(CommandSender sender, Runnable task) {
|
||||
ensureTreasureMapWorld()
|
||||
.thenCompose(ignored -> J.runRegionFuture(Bukkit.getWorld(TREASURE_KEY), 0, 0, task))
|
||||
.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<?> ensureTreasureMapWorld() {
|
||||
if (Bukkit.getWorld(TREASURE_KEY) != null) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
if (WorldCreatorCompat.persistentLevelRoot(TREASURE_KEY).exists()) {
|
||||
return Iris.instance.worldReconciler().loadWorld(ServerProperties.BUKKIT_YML, TREASURE_KEY.toString());
|
||||
}
|
||||
return runAsync(() -> {
|
||||
try {
|
||||
new IrisCreator().name(TREASURE_KEY.toString()).dimension("treasure-map-fixture")
|
||||
.seed(WORLD_SEED).create();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Treasure map fixture creation failed", failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void prepareTreasureMapWorld(Player player) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
if (world == null || !IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED
|
||||
|| !"treasure-map-fixture".equals(IrisToolbelt.access(world).getEngine().getDimension().getLoadKey())) {
|
||||
throw new IllegalStateException("Treasure map fixture world identity is invalid");
|
||||
}
|
||||
for (int x = 0; x <= 8; x++) {
|
||||
for (int z = 0; z <= 8; z++) {
|
||||
world.getBlockAt(x, 160, z).setType(Material.STONE, false);
|
||||
for (int y = 161; y <= 164; y++) {
|
||||
world.getBlockAt(x, y, z).setType(Material.AIR, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 0.5, 161, 0.5)).whenComplete((teleported, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(player, failure);
|
||||
} else if (!Boolean.TRUE.equals(teleported)) {
|
||||
fail(player, new IllegalStateException("Treasure map fixture teleport failed"));
|
||||
} else {
|
||||
player.sendMessage("TREASURE_MAP_READY world=" + TREASURE_KEY
|
||||
+ " version=" + Bukkit.getMinecraftVersion() + " folia=" + J.isFolia());
|
||||
}
|
||||
}))) {
|
||||
throw new IllegalStateException("Could not schedule treasure map fixture teleport");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkTreasureMapLimit(CommandSender sender) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Treasure map fixture world is absent");
|
||||
}
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
IrisStructureLocator.LocateResult located = IrisStructureLocator.locateInPlacementRings(
|
||||
engine, "minecraft:buried_treasure", 2, 2, 50, (x, z) -> true);
|
||||
if (located.status() != IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
|
||||
throw new IllegalStateException("Blocked treasure placement returned " + located.status());
|
||||
}
|
||||
sender.sendMessage("TREASURE_MAP_LIMIT status=" + located.status());
|
||||
}
|
||||
|
||||
private void prepareTreasureMapChest(CommandSender sender, String destination, String material) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
treasureMapChest(world, destination, material);
|
||||
sender.sendMessage("TREASURE_MAP_CHEST destination=" + destination + " material=" + material);
|
||||
}
|
||||
|
||||
private Container treasureMapChest(World world, String destination, String material) {
|
||||
requireTreasureDestination(destination);
|
||||
if (!List.of("CHEST", "COPPER_CHEST").contains(material)) {
|
||||
throw new IllegalArgumentException("Unknown treasure map container: " + material);
|
||||
}
|
||||
LootTable loot = Bukkit.getLootTable(new NamespacedKey("iris_qa", destination));
|
||||
if (loot == null) {
|
||||
throw new IllegalStateException("Treasure map loot table is absent: " + destination);
|
||||
}
|
||||
world.getBlockAt(2, 161, 2).setType(Material.AIR, false);
|
||||
world.getBlockAt(2, 161, 2).setType(Material.valueOf(material), false);
|
||||
Container chest = (Container) world.getBlockAt(2, 161, 2).getState();
|
||||
((Lootable) chest).setLootTable(loot);
|
||||
((Lootable) chest).setSeed(WORLD_SEED);
|
||||
if (!chest.update(true, false)) {
|
||||
throw new IllegalStateException("Treasure map loot chest was not installed");
|
||||
}
|
||||
return (Container) world.getBlockAt(2, 161, 2).getState();
|
||||
}
|
||||
|
||||
private void verifyTreasureMapLoot(CommandSender sender) {
|
||||
checkTreasureMapLimit(sender);
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
for (String destination : List.of("blocked", "disabled", "found", "mixed")) {
|
||||
Inventory inventory = treasureMapChest(world, destination, "CHEST").getInventory();
|
||||
inspectTreasureMapChest(sender, destination);
|
||||
inventory.remove(Material.DIAMOND);
|
||||
Inventory reopened = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory();
|
||||
if (reopened.contains(Material.DIAMOND)
|
||||
|| !(reopened.contains(Material.FILLED_MAP) || reopened.contains(Material.MAP))) {
|
||||
throw new IllegalStateException("Reopening changed " + destination + " chest loot");
|
||||
}
|
||||
}
|
||||
sender.sendMessage("TREASURE_MAP_CHECK destinations=4 retained=true");
|
||||
}
|
||||
|
||||
private void inspectTreasureMapChest(CommandSender sender, String destination) {
|
||||
requireTreasureDestination(destination);
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
Inventory inventory = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory();
|
||||
ItemStack map = null;
|
||||
int diamonds = 0;
|
||||
for (ItemStack item : inventory.getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if ((item.getType() == Material.MAP || item.getType() == Material.FILLED_MAP)
|
||||
&& item.getAmount() == 1 && map == null) {
|
||||
map = item;
|
||||
} else if (item.getType() == Material.DIAMOND) {
|
||||
diamonds += item.getAmount();
|
||||
} else {
|
||||
throw new IllegalStateException("Unexpected chest loot: " + item);
|
||||
}
|
||||
}
|
||||
if (map == null || diamonds != 1) {
|
||||
throw new IllegalStateException("Invalid " + destination + " chest: map=" + map + " diamonds=" + diamonds);
|
||||
}
|
||||
boolean expectedLocated = destination.equals("found") || destination.equals("mixed");
|
||||
MapView view = map.getItemMeta() instanceof MapMeta meta && meta.hasMapView() ? meta.getMapView() : null;
|
||||
if ((view != null) != expectedLocated) {
|
||||
throw new IllegalStateException("Unexpected map data for " + destination + ": " + map);
|
||||
}
|
||||
String result = "TREASURE_MAP_INSPECT destination=" + destination + " diamonds=1 located=" + expectedLocated;
|
||||
if (view != null) {
|
||||
StructureSearchResult target = world.locateNearestStructure(
|
||||
new Location(world, 2, 161, 2), Structure.DESERT_PYRAMID, 50, false);
|
||||
if (target == null || target.getStructure() != Structure.DESERT_PYRAMID) {
|
||||
throw new IllegalStateException("Map has no verified pyramid target");
|
||||
}
|
||||
int targetX = target.getLocation().getBlockX();
|
||||
int targetZ = target.getLocation().getBlockZ();
|
||||
if (view.getWorld() != world || view.getScale() != MapView.Scale.CLOSEST
|
||||
|| view.getCenterX() != Math.floorDiv(targetX + 64, 128) * 128
|
||||
|| view.getCenterZ() != Math.floorDiv(targetZ + 64, 128) * 128) {
|
||||
throw new IllegalStateException("Map data does not cover the verified pyramid at " + target.getLocation());
|
||||
}
|
||||
result += " mapId=" + view.getId() + " targetX=" + targetX + " targetZ=" + targetZ
|
||||
+ " centerX=" + view.getCenterX() + " centerZ=" + view.getCenterZ();
|
||||
}
|
||||
sender.sendMessage(result);
|
||||
}
|
||||
|
||||
private void requireTreasureDestination(String destination) {
|
||||
if (!List.of("blocked", "disabled", "found", "mixed").contains(destination)) {
|
||||
throw new IllegalArgumentException("Unknown treasure map destination: " + destination);
|
||||
}
|
||||
}
|
||||
|
||||
private void lifecycleStatus(CommandSender sender) throws Exception {
|
||||
World world = Bukkit.getWorld(COMMAND_KEY);
|
||||
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
|
||||
@@ -282,11 +489,11 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
}
|
||||
|
||||
public boolean regionized() {
|
||||
return false;
|
||||
return J.isFolia();
|
||||
}
|
||||
|
||||
public boolean primaryThread() {
|
||||
return Bukkit.isPrimaryThread();
|
||||
return J.isFolia() ? Bukkit.isGlobalTickThread() : Bukkit.isPrimaryThread();
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> runGlobal(Runnable task) {
|
||||
@@ -299,10 +506,10 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
result.completeExceptionally(failure);
|
||||
}
|
||||
};
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
if (primaryThread()) {
|
||||
wrapped.run();
|
||||
} else {
|
||||
Bukkit.getScheduler().runTask(this, wrapped);
|
||||
} else if (!J.runGlobal(wrapped)) {
|
||||
result.completeExceptionally(new IllegalStateException("Could not schedule fixture task"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
|
||||
import art.arcane.iris.platform.generation.EngineBukkitOps;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.loot.InventorySlotType;
|
||||
import art.arcane.iris.world.loot.IrisVanillaLootTable;
|
||||
import art.arcane.iris.world.loot.IrisLootTable;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import io.papermc.paper.event.world.StructuresLocateEvent;
|
||||
import io.papermc.paper.event.server.ServerResourcesReloadedEvent;
|
||||
import io.papermc.paper.math.Position;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.Container;
|
||||
import org.bukkit.block.Chest;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.world.LootGenerateEvent;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.util.StructureSearchResult;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public final class TreasureLootChecks implements Listener {
|
||||
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "treasure_map_probe");
|
||||
private static final List<String> CHESTS = List.of(
|
||||
"CHEST", "COPPER_CHEST", "EXPOSED_COPPER_CHEST", "WEATHERED_COPPER_CHEST",
|
||||
"OXIDIZED_COPPER_CHEST", "WAXED_COPPER_CHEST", "WAXED_EXPOSED_COPPER_CHEST",
|
||||
"WAXED_WEATHERED_COPPER_CHEST", "WAXED_OXIDIZED_COPPER_CHEST");
|
||||
private final IrisVanillaLootTable reloadTable = new IrisVanillaLootTable("iris_qa:reload");
|
||||
private final AtomicInteger resourceReloads = new AtomicInteger();
|
||||
private LocateAction locateAction = LocateAction.PASS;
|
||||
private int locateEvents;
|
||||
private int lootEvents;
|
||||
private boolean lootEventsOwned = true;
|
||||
|
||||
public TreasureLootChecks(JavaPlugin plugin) {
|
||||
Bukkit.getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
public void verify(CommandSender sender) {
|
||||
World world = requireWorld();
|
||||
verifyCopperLoot(world);
|
||||
sender.sendMessage("TREASURE_COPPER_LOOT variants=8 normal=true diamonds=1");
|
||||
verifyInventoryRollback(world);
|
||||
sender.sendMessage("TREASURE_LOOT_ROLLBACK contents=true nativeTable=true seed=true retry=true");
|
||||
verifyLocateEvents(world);
|
||||
sender.sendMessage("TREASURE_LOCATE_EVENTS cancelled=true replaced=true calls=2");
|
||||
sender.sendMessage("TREASURE_EXTRA_CHECK variants=9 rollback=true locateEvents=true");
|
||||
}
|
||||
|
||||
public void verifyRegion(CommandSender sender) {
|
||||
World world = requireWorld();
|
||||
if (!J.isOwnedByCurrentRegion(world, 0, 0)) {
|
||||
throw new IllegalStateException("Fixture does not own its loot region");
|
||||
}
|
||||
lootEvents = 0;
|
||||
lootEventsOwned = true;
|
||||
verifyCopperLoot(world);
|
||||
if (lootEvents != CHESTS.size() || !lootEventsOwned) {
|
||||
throw new IllegalStateException("Loot event ownership failed: count=" + lootEvents
|
||||
+ " owned=" + lootEventsOwned);
|
||||
}
|
||||
sender.sendMessage("TREASURE_REGION_LOOT copper=8 normal=1 events=" + lootEvents
|
||||
+ " owned=true folia=" + J.isFolia());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onLoot(LootGenerateEvent event) {
|
||||
if (event.getWorld().getKey().equals(WORLD_KEY) && event.isPlugin()) {
|
||||
Location origin = event.getLootContext().getLocation();
|
||||
lootEvents++;
|
||||
lootEventsOwned &= J.isOwnedByCurrentRegion(event.getWorld(),
|
||||
origin.getBlockX() >> 4, origin.getBlockZ() >> 4);
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyReload(CommandSender sender, boolean reloaded) {
|
||||
World world = requireWorld();
|
||||
Material expected = reloaded ? Material.EMERALD : Material.DIAMOND;
|
||||
Collection<ItemStack> items = reloadTable.getLoot(
|
||||
false, 783L, InventorySlotType.STORAGE, world, 2, 161, 2);
|
||||
if (items.size() != 1 || items.iterator().next().getType() != expected) {
|
||||
throw new IllegalStateException("Reloaded loot did not contain " + expected + ": " + items);
|
||||
}
|
||||
sender.sendMessage("TREASURE_RELOAD material=" + expected);
|
||||
}
|
||||
|
||||
public void reportResourceReloads(CommandSender sender) {
|
||||
sender.sendMessage("TREASURE_RESOURCES revision=" + resourceReloads.get());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onResourcesReloaded(ServerResourcesReloadedEvent event) {
|
||||
resourceReloads.incrementAndGet();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onLocate(StructuresLocateEvent event) {
|
||||
if (!event.getWorld().getKey().equals(WORLD_KEY) || locateAction == LocateAction.PASS) {
|
||||
return;
|
||||
}
|
||||
locateEvents++;
|
||||
if (locateAction == LocateAction.CANCEL) {
|
||||
event.setCancelled(true);
|
||||
} else {
|
||||
event.setResult(new StructuresLocateEvent.Result(Position.block(123, 100, 456), Structure.DESERT_PYRAMID));
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyCopperLoot(World world) {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
Block block = world.getBlockAt(4, 161, 4);
|
||||
MantleChunk<Matter> mantle = engine.getMantle().getMantle().useChunk(block.getChunk());
|
||||
try {
|
||||
for (String name : CHESTS) {
|
||||
block.setType(Material.AIR, false);
|
||||
block.setType(Material.valueOf(name), false);
|
||||
if (!BukkitBlockResolution.isStorageChest(block.getBlockData())) {
|
||||
throw new IllegalStateException("Chest is excluded from Iris loot: " + name);
|
||||
}
|
||||
EngineBukkitOps.update(engine, 4, 161, 4, block.getChunk(), mantle);
|
||||
Container chest = (Container) block.getState();
|
||||
int diamonds = 0;
|
||||
for (ItemStack item : chest.getInventory().getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getType() != Material.DIAMOND) {
|
||||
throw new IllegalStateException("Unexpected " + name + " loot: " + item);
|
||||
}
|
||||
diamonds += item.getAmount();
|
||||
}
|
||||
if (diamonds != 1) {
|
||||
throw new IllegalStateException(name + " contains " + diamonds + " diamonds");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mantle.release();
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyInventoryRollback(World world) {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
Block block = world.getBlockAt(6, 161, 4);
|
||||
block.setType(Material.AIR, false);
|
||||
block.setType(Material.CHEST, false);
|
||||
Chest chest = (Chest) block.getState();
|
||||
chest.getBlockInventory().setItem(0, new ItemStack(Material.EMERALD, 3));
|
||||
chest = (Chest) block.getState();
|
||||
NamespacedKey key = new NamespacedKey("iris_qa", "reload");
|
||||
chest.setLootTable(Bukkit.getLootTable(key));
|
||||
chest.setSeed(913L);
|
||||
if (!chest.update(true, false)) {
|
||||
throw new IllegalStateException("Could not set native loot for rollback check");
|
||||
}
|
||||
Inventory inventory = chest.getBlockInventory();
|
||||
IllegalStateException injected = new IllegalStateException("Fixture insertion interruption");
|
||||
Inventory interrupted = (Inventory) Proxy.newProxyInstance(Inventory.class.getClassLoader(),
|
||||
new Class<?>[]{Inventory.class}, (proxy, method, arguments) -> {
|
||||
Object result;
|
||||
try {
|
||||
result = method.invoke(inventory, arguments);
|
||||
} catch (InvocationTargetException failure) {
|
||||
throw failure.getCause();
|
||||
}
|
||||
if (method.getName().equals("addItem")) {
|
||||
throw injected;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
KList<IrisLootTable> tables = new KList<>();
|
||||
tables.add(new IrisVanillaLootTable("iris_qa:reload"));
|
||||
try {
|
||||
EngineBukkitOps.addItems(engine, false, interrupted, tables, InventorySlotType.STORAGE,
|
||||
world, 6, 161, 4);
|
||||
throw new IllegalStateException("Interrupted insertion did not fail");
|
||||
} catch (IllegalStateException failure) {
|
||||
if (failure != injected) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
Chest restored = (Chest) block.getState();
|
||||
if (restored.getLootTable() == null || !restored.getLootTable().getKey().equals(key)
|
||||
|| restored.getSeed() != 913L) {
|
||||
throw new IllegalStateException("Rollback lost the native loot table or seed");
|
||||
}
|
||||
ItemStack existing = restored.getSnapshotInventory().getItem(0);
|
||||
if (existing == null || existing.getType() != Material.EMERALD || existing.getAmount() != 3) {
|
||||
throw new IllegalStateException("Rollback lost the original contents: " + existing);
|
||||
}
|
||||
EngineBukkitOps.addItems(engine, false, restored.getBlockInventory(), tables, InventorySlotType.STORAGE,
|
||||
world, 6, 161, 4);
|
||||
int diamonds = 0;
|
||||
int emeralds = 0;
|
||||
for (ItemStack item : restored.getBlockInventory().getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getType() == Material.DIAMOND) {
|
||||
diamonds += item.getAmount();
|
||||
} else if (item.getType() == Material.EMERALD) {
|
||||
emeralds += item.getAmount();
|
||||
}
|
||||
}
|
||||
if (diamonds != 2 || emeralds != 3 || ((Chest) block.getState()).getLootTable() != null) {
|
||||
throw new IllegalStateException("Loot retry failed: diamonds=" + diamonds + " emeralds=" + emeralds);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyLocateEvents(World world) {
|
||||
Location origin = new Location(world, 2, 161, 2);
|
||||
locateEvents = 0;
|
||||
try {
|
||||
locateAction = LocateAction.CANCEL;
|
||||
if (world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false) != null) {
|
||||
throw new IllegalStateException("Cancelled structure search returned a target");
|
||||
}
|
||||
locateAction = LocateAction.REPLACE;
|
||||
StructureSearchResult result = world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false);
|
||||
if (result == null || result.getStructure() != Structure.DESERT_PYRAMID
|
||||
|| result.getLocation().getBlockX() != 123 || result.getLocation().getBlockY() != 100
|
||||
|| result.getLocation().getBlockZ() != 456 || locateEvents != 2) {
|
||||
throw new IllegalStateException("Structure search ignored event result: " + result);
|
||||
}
|
||||
} finally {
|
||||
locateAction = LocateAction.PASS;
|
||||
}
|
||||
}
|
||||
|
||||
private World requireWorld() {
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
if (world == null || !IrisToolbelt.isIrisWorld(world)) {
|
||||
throw new IllegalStateException("Treasure map fixture world is absent");
|
||||
}
|
||||
return world;
|
||||
}
|
||||
|
||||
private enum LocateAction {
|
||||
PASS, CANCEL, REPLACE
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:blocked",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:disabled",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:found",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:mixed",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:buried_treasure"
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:mansion"
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:desert_pyramid"
|
||||
]
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:buried_treasure",
|
||||
"minecraft:desert_pyramid"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Treasure map gameplay fixtures",
|
||||
"min_format": [
|
||||
107,
|
||||
1
|
||||
],
|
||||
"max_format": [
|
||||
107,
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:filled_map",
|
||||
"modifier": {
|
||||
"destination": "#iris_qa:blocked",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"type": "minecraft:exploration_map"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:filled_map",
|
||||
"modifier": {
|
||||
"destination": "#iris_qa:disabled",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"type": "minecraft:exploration_map"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:filled_map",
|
||||
"modifier": {
|
||||
"destination": "#iris_qa:found",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"type": "minecraft:exploration_map"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:filled_map",
|
||||
"modifier": {
|
||||
"destination": "#iris_qa:mixed",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"type": "minecraft:exploration_map"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:buried_treasure"
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:mansion"
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:desert_pyramid"
|
||||
]
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:buried_treasure",
|
||||
"minecraft:desert_pyramid"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Treasure map gameplay fixtures",
|
||||
"min_format": [
|
||||
121,
|
||||
0
|
||||
],
|
||||
"max_format": [
|
||||
121,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [
|
||||
{
|
||||
"minHeight": 1,
|
||||
"maxHeight": 1,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:lime_wool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"minHeight": 384,
|
||||
"maxHeight": 384,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
{
|
||||
"generator": "flat",
|
||||
"min": 96,
|
||||
"max": 96
|
||||
}
|
||||
]
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "Treasure Map Fixture",
|
||||
"version": 1,
|
||||
"mode": {
|
||||
"type": "OVERWORLD"
|
||||
},
|
||||
"regions": [
|
||||
"flat"
|
||||
],
|
||||
"focusRegion": "flat",
|
||||
"focus": "flat",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
},
|
||||
"logicalHeight": 384,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:red_concrete"
|
||||
}
|
||||
]
|
||||
},
|
||||
"importedStructures": {
|
||||
"disabled": [
|
||||
"minecraft:"
|
||||
]
|
||||
},
|
||||
"structures": [
|
||||
{
|
||||
"placementId": "blocked-treasure",
|
||||
"nativeStructures": [
|
||||
{
|
||||
"structure": "minecraft:buried_treasure"
|
||||
}
|
||||
],
|
||||
"distribution": "RANDOM_SPREAD",
|
||||
"spacing": 1,
|
||||
"separation": 0,
|
||||
"minHeight": 0,
|
||||
"maxHeight": 0
|
||||
},
|
||||
{
|
||||
"placementId": "reachable-pyramid",
|
||||
"nativeStructures": [
|
||||
{
|
||||
"structure": "minecraft:desert_pyramid"
|
||||
}
|
||||
],
|
||||
"distribution": "RANDOM_SPREAD",
|
||||
"spacing": 16,
|
||||
"separation": 4
|
||||
}
|
||||
],
|
||||
"loot": {
|
||||
"tables": [
|
||||
"fixture-diamond"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"interpolator": {
|
||||
"function": "NONE",
|
||||
"horizontalScale": 1
|
||||
},
|
||||
"seed": 310,
|
||||
"composite": [
|
||||
{
|
||||
"seed": 310,
|
||||
"style": {
|
||||
"style": "FLAT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Fixture Diamond",
|
||||
"rarity": 1,
|
||||
"minPicked": 1,
|
||||
"maxPicked": 1,
|
||||
"maxTries": 1,
|
||||
"loot": [
|
||||
{
|
||||
"type": "diamond",
|
||||
"rarity": 1,
|
||||
"minAmount": 1,
|
||||
"maxAmount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"seaBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"shoreBiomes": [
|
||||
"flat"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const simplifyNbt = value => {
|
||||
if (Array.isArray(value)) return value.map(simplifyNbt)
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if ('type' in value && 'value' in value) return simplifyNbt(value.value)
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, simplifyNbt(entry)]))
|
||||
}
|
||||
|
||||
const completeClientTicks = context => {
|
||||
const client = context.bot._client
|
||||
context.expect(context.bot.supportFeature('sendsClientTickEndPacket'), 'Fixture client supports tick delimiters')
|
||||
let tickTimer
|
||||
const finishTick = () => {
|
||||
clearTimeout(tickTimer)
|
||||
client.write('tick_end', {})
|
||||
tickTimer = setTimeout(finishTick, 50)
|
||||
}
|
||||
context.bot.on('move', finishTick)
|
||||
finishTick()
|
||||
context.signal.addEventListener('abort', () => {
|
||||
clearTimeout(tickTimer)
|
||||
context.bot.removeListener('move', finishTick)
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'iris-treasure-maps',
|
||||
description: 'Verify exploration-map targets, bounded misses, retained loot, and normal/copper storage on Iris.',
|
||||
async run(context) {
|
||||
completeClientTicks(context)
|
||||
context.bot.physicsEnabled = false
|
||||
context.bot.clearControlStates()
|
||||
context.bot.entity.velocity.set(0, 0, 0)
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Treasure map fixtures require an isolated instance')
|
||||
const command = async (operation, expected, timeoutMs = 30000) => {
|
||||
const token = expected.split(' ')[0]
|
||||
const response = await context.command(`/irislifecycle ${operation}`,
|
||||
new RegExp(`${token}|NATIVE_FAILED|TREASURE_.*_FAILED`), timeoutMs)
|
||||
context.expect(response.includes(expected), response)
|
||||
return response
|
||||
}
|
||||
const bounded = (label, action) => context.waitUntil(async () => {
|
||||
await action()
|
||||
return true
|
||||
}, { label, timeoutMs: 10000 })
|
||||
let lastActivation = 0
|
||||
const openChest = async material => {
|
||||
const position = context.bot.entity.position.clone().set(2, 161, 2)
|
||||
await context.waitUntil(() => context.bot.blockAt(position)?.name === material, {
|
||||
label: `${material} reached the client`, timeoutMs: 10000
|
||||
})
|
||||
const delay = 150 - (performance.now() - lastActivation)
|
||||
if (delay > 0) await context.sleep(delay)
|
||||
const opened = context.waitForEvent('windowOpen', undefined, 30000)
|
||||
await bounded(`Activate ${material}`, () => context.bot.activateBlock(context.bot.blockAt(position)))
|
||||
lastActivation = performance.now()
|
||||
const [window] = await opened
|
||||
return window
|
||||
}
|
||||
const chestItems = window => window.slots.slice(0, window.inventoryStart).filter(Boolean)
|
||||
const mapId = item => item.componentMap?.get('map_id')?.data
|
||||
let missItem
|
||||
await context.step('create and enter the Iris treasure map world', async () => {
|
||||
const response = await command('treasure-map-create', 'TREASURE_MAP_READY world=iris:treasure_map_probe', 180000)
|
||||
const version = response.match(/version=(\S+)/)?.[1]
|
||||
context.expect(/^26\.[23](?:[.-]|$)/.test(version ?? ''), 'Fixture requires its matching 26.2 or 26.3 data pack', { version })
|
||||
missItem = version.startsWith('26.2') ? 'map' : 'filled_map'
|
||||
context.report.treasurePlatform = { version, folia: response.includes('folia=true'), missItem }
|
||||
await context.waitUntil(() => context.bot.entity.position.distanceTo(
|
||||
context.bot.entity.position.clone().set(0.5, 161, 0.5)
|
||||
) < 2, { label: 'Fixture teleport reached the client', timeoutMs: 30000 })
|
||||
})
|
||||
await context.step('confirm the missing target exhausts the placement search budget', async () => {
|
||||
await command('treasure-map-limit', 'TREASURE_MAP_LIMIT status=SEARCH_LIMIT_REACHED', 120000)
|
||||
})
|
||||
for (const material of ['chest', 'copper_chest']) {
|
||||
for (const destination of ['blocked', 'disabled', 'found', 'mixed']) {
|
||||
await context.step(`open ${material} with ${destination} map target and retain all loot`, async () => {
|
||||
const evidence = { destination, material, phase: 'prepare' }
|
||||
context.report.treasureMaps ??= []
|
||||
context.report.treasureMaps.push(evidence)
|
||||
const located = destination === 'found' || destination === 'mixed'
|
||||
const mapName = located ? 'filled_map' : missItem
|
||||
await command(`treasure-map-chest ${destination} ${material.toUpperCase()}`,
|
||||
`TREASURE_MAP_CHEST destination=${destination} material=${material.toUpperCase()}`)
|
||||
evidence.phase = 'open'
|
||||
const window = await openChest(material)
|
||||
evidence.windowType = window.type
|
||||
evidence.inventoryStart = window.inventoryStart
|
||||
let targetId
|
||||
let diamondType
|
||||
let diamondsBefore
|
||||
let marker
|
||||
let verified
|
||||
try {
|
||||
await context.waitUntil(() => {
|
||||
const items = chestItems(window)
|
||||
return items.some(item => item.name === mapName) && items.some(item => item.name === 'diamond')
|
||||
}, { label: `${destination} chest contains map and diamond`, timeoutMs: 30000 })
|
||||
const items = chestItems(window)
|
||||
const map = items.find(item => item.name === mapName)
|
||||
const diamond = items.find(item => item.name === 'diamond')
|
||||
targetId = mapId(map)
|
||||
context.expect(items.length === 2 && map.count === 1 && diamond.count === 1,
|
||||
'Loot table must produce exactly one map and one diamond')
|
||||
context.expect((targetId !== undefined) === located, 'Only reachable destinations receive map data', {
|
||||
destination, components: map.components
|
||||
})
|
||||
const response = await command(`treasure-map-inspect ${destination}`,
|
||||
`TREASURE_MAP_INSPECT destination=${destination} diamonds=1 located=${located}`, 120000)
|
||||
verified = Object.fromEntries([...response.matchAll(/(mapId|targetX|targetZ|centerX|centerZ)=(-?\d+)/g)]
|
||||
.map(([, key, value]) => [key, Number(value)]))
|
||||
const decorations = simplifyNbt(map.componentMap?.get('map_decorations')?.data)
|
||||
marker = decorations?.['+']
|
||||
if (located) {
|
||||
context.expect(targetId === verified.mapId, 'Client and saved map IDs agree', { targetId, verified })
|
||||
context.expect(marker?.type === 'minecraft:red_x'
|
||||
&& marker.x === verified.targetX && marker.z === verified.targetZ,
|
||||
'Map marker points to the verified pyramid', { marker, verified, components: map.components })
|
||||
} else {
|
||||
context.expect(marker === undefined, 'Missing destinations must not invent a map marker', { decorations })
|
||||
}
|
||||
diamondType = diamond.type
|
||||
diamondsBefore = context.bot.inventory.count(diamondType)
|
||||
evidence.phase = 'withdraw'
|
||||
evidence.diamondSlot = diamond.slot
|
||||
await bounded(`Withdraw diamond from ${material} ${destination}`,
|
||||
() => context.bot.clickWindow(diamond.slot, 0, 1))
|
||||
evidence.phase = 'withdrawn'
|
||||
await context.waitUntil(() => chestItems(window).every(item => item.name !== 'diamond'), {
|
||||
label: 'Diamond transferred out of the chest', timeoutMs: 10000
|
||||
})
|
||||
} finally {
|
||||
context.bot.closeWindow(window)
|
||||
}
|
||||
evidence.phase = 'reopen'
|
||||
const reopened = await openChest(material)
|
||||
try {
|
||||
await context.waitUntil(() => chestItems(reopened).some(item => item.name === mapName), {
|
||||
label: 'Saved map remains in the reopened chest', timeoutMs: 10000
|
||||
})
|
||||
const retained = chestItems(reopened)
|
||||
context.expect(retained.length === 1 && retained[0].name === mapName && retained[0].count === 1,
|
||||
'Reopening must not regenerate chest loot')
|
||||
context.expect(mapId(retained[0]) === targetId, 'Reopening must retain the same map data')
|
||||
context.expect(JSON.stringify(simplifyNbt(retained[0].componentMap?.get('map_decorations')?.data)?.['+'])
|
||||
=== JSON.stringify(marker), 'Reopening must retain the same destination marker')
|
||||
await context.waitUntil(() => context.bot.inventory.count(diamondType) === diamondsBefore + 1, {
|
||||
label: 'Chest awards exactly one diamond', timeoutMs: 10000
|
||||
})
|
||||
Object.assign(evidence, { phase: 'complete', mapItem: mapName,
|
||||
mapId: targetId ?? null, marker: marker ?? null, verified, reopened: true })
|
||||
} finally {
|
||||
context.bot.closeWindow(reopened)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
await context.step('verify storage variants, insertion rollback, and locate event results', async () => {
|
||||
await command('treasure-extra-check', 'TREASURE_EXTRA_CHECK variants=9 rollback=true locateEvents=true', 120000)
|
||||
})
|
||||
await context.step('verify plugin loot runs on its owning region', async () => {
|
||||
await command('treasure-region-check', 'TREASURE_REGION_LOOT copper=8 normal=1 events=9 owned=true', 120000)
|
||||
})
|
||||
await context.step('verify native loot table reload support', async () => {
|
||||
await command('treasure-reload-before', 'TREASURE_RELOAD material=DIAMOND')
|
||||
if (context.report.treasurePlatform.folia) {
|
||||
const response = await context.command('/minecraft:reload', /Unknown or incomplete command|Reloading/i, 10000)
|
||||
context.expect(response.includes('Unknown or incomplete command'),
|
||||
'Folia fixture expects its unavailable data pack reload command', { response })
|
||||
context.report.treasureReload = { status: 'unavailable', before: 'diamond', evidence: response }
|
||||
return
|
||||
}
|
||||
const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8')
|
||||
const levelName = properties.match(/^level-name=(.+)$/m)?.[1].trim() ?? 'world'
|
||||
const tablePath = path.join(context.server.directory, levelName, 'datapacks', 'iris_treasure',
|
||||
'data', 'iris_qa', 'loot_table', 'reload.json')
|
||||
const original = await readFile(tablePath, 'utf8')
|
||||
const replacement = JSON.parse(original)
|
||||
context.expect(replacement.pools[0].entries[0].name === 'minecraft:diamond',
|
||||
'Reload fixture must start with its diamond table')
|
||||
const revision = async () => {
|
||||
const response = await command('treasure-resources', 'TREASURE_RESOURCES revision=')
|
||||
return Number(response.match(/revision=(\d+)/)?.[1])
|
||||
}
|
||||
const reload = async () => {
|
||||
const before = await revision()
|
||||
await context.command('/minecraft:reload', /Reloading/i, 30000)
|
||||
await context.waitUntil(async () => await revision() > before, {
|
||||
label: 'Server resource reload completed', timeoutMs: 120000, intervalMs: 500
|
||||
})
|
||||
}
|
||||
replacement.pools[0].entries[0].name = 'minecraft:emerald'
|
||||
try {
|
||||
await writeFile(tablePath, `${JSON.stringify(replacement, null, 2)}\n`)
|
||||
await reload()
|
||||
await command('treasure-reload-after', 'TREASURE_RELOAD material=EMERALD')
|
||||
context.report.treasureReload = { status: 'passed', before: 'diamond', after: 'emerald' }
|
||||
} finally {
|
||||
await writeFile(tablePath, original)
|
||||
if (!context.signal.aborted) {
|
||||
await reload()
|
||||
await command('treasure-reload-before', 'TREASURE_RELOAD material=DIAMOND')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionManager;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.platform.bukkit.nms.INMS;
|
||||
import art.arcane.iris.platform.bukkit.nms.INMSBinding;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
||||
import art.arcane.iris.studio.view.PregeneratorJob;
|
||||
import art.arcane.iris.testsupport.PlatformBinding;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.iris.world.WorldMaintenance;
|
||||
import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisShutdownMaintenanceTest {
|
||||
@ClassRule
|
||||
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
|
||||
|
||||
@Test
|
||||
public void finisherRetainsResourcesWhileTheAuthoritativeBoundaryIsPending() throws Exception {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
ServerShutdownBoundary boundary = mock(ServerShutdownBoundary.class);
|
||||
CountDownLatch waiting = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicReference<Thread> finisher = new AtomicReference<>();
|
||||
when(boundary.await(anyLong(), eq(TimeUnit.SECONDS))).thenAnswer(invocation -> {
|
||||
finisher.set(Thread.currentThread());
|
||||
waiting.countDown();
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
return false;
|
||||
});
|
||||
setField(fixture.plugin, "serverShutdownBoundary", boundary);
|
||||
fixture.quiesce();
|
||||
fixture.startFinisher();
|
||||
try {
|
||||
assertTrue(waiting.await(5, TimeUnit.SECONDS));
|
||||
fixture.startFinisher();
|
||||
verify(fixture.generator, never()).closeAsync();
|
||||
verify(fixture.binding, never()).releasePluginClassLoaderClose();
|
||||
} finally {
|
||||
release.countDown();
|
||||
Thread running = finisher.get();
|
||||
if (running != null) {
|
||||
running.join(5_000L);
|
||||
assertFalse(running.isAlive());
|
||||
}
|
||||
}
|
||||
verify(boundary, times(1)).await(anyLong(), eq(TimeUnit.SECONDS));
|
||||
verify(fixture.generator, never()).closeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingShutdownBoundaryDoesNotAuthorizeResourceRelease() throws Exception {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
Method method = Iris.class.getDeclaredMethod("awaitServerShutdownBoundary");
|
||||
method.setAccessible(true);
|
||||
assertEquals(false, method.invoke(fixture.plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serverStopKeepsFoliaGenerationAdmittedUntilTheServerDrains() throws Exception {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
GenerationSessionManager sessions = new GenerationSessionManager();
|
||||
try (GenerationSessionLease active = sessions.acquireForEngine(fixture.engine, "active_chunk")) {
|
||||
fixture.quiesce();
|
||||
|
||||
assertTrue(WorldMaintenance.isWorldMaintenanceActive(fixture.identity));
|
||||
assertFalse(fixture.hooks.shouldRefuseGeneration(fixture.engine));
|
||||
try (GenerationSessionLease queued = sessions.acquireForEngine(fixture.engine, "queued_chunk")) {
|
||||
assertEquals(active.sessionId(), queued.sessionId());
|
||||
assertEquals(2, sessions.activeLeases());
|
||||
}
|
||||
verify(fixture.generator).quiesceForServerShutdown();
|
||||
verify(fixture.generator, never()).closeAsync();
|
||||
fixture.nms.verify(INMS::get);
|
||||
verify(fixture.binding).deferPluginClassLoaderClose();
|
||||
verify(fixture.binding, never()).releasePluginClassLoaderClose();
|
||||
}
|
||||
assertEquals(0, sessions.activeLeases());
|
||||
fixture.drain();
|
||||
verify(fixture.generator).closeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serverStopDoesNotOverrideExistingBlockingMaintenance() throws Exception {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
WorldMaintenance.beginWorldMaintenance(fixture.identity, "world_unload", true);
|
||||
try {
|
||||
fixture.quiesce();
|
||||
assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine));
|
||||
} finally {
|
||||
WorldMaintenance.endWorldMaintenance(fixture.identity, "world_unload", true);
|
||||
}
|
||||
assertFalse(fixture.hooks.shouldRefuseGeneration(fixture.engine));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualDisableStillBlocksGenerationBeforeClosingTheGenerator() throws Exception {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
when(fixture.generator.closeAsync()).thenAnswer(invocation -> {
|
||||
assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine));
|
||||
return CompletableFuture.completedFuture(null);
|
||||
});
|
||||
try {
|
||||
fixture.drain();
|
||||
assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine));
|
||||
verify(fixture.generator).closeAsync();
|
||||
verify(fixture.generator, never()).quiesceForServerShutdown();
|
||||
} finally {
|
||||
WorldMaintenance.endWorldMaintenance(fixture.identity, "manual_disable", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException {
|
||||
Field field = Iris.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, value);
|
||||
}
|
||||
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
private final Iris plugin = mock(Iris.class);
|
||||
private final World world = mock(World.class);
|
||||
private final IrisWorld irisWorld = mock(IrisWorld.class);
|
||||
private final Engine engine = mock(Engine.class);
|
||||
private final PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
|
||||
private final BukkitEnginePlatformHooks hooks = new BukkitEnginePlatformHooks();
|
||||
private final INMSBinding binding = mock(INMSBinding.class);
|
||||
private final String identity = "iris:shutdown_test";
|
||||
private final IrisSettings previousSettings = IrisSettings.settings;
|
||||
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
private final MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class, CALLS_REAL_METHODS);
|
||||
private final MockedStatic<BukkitWorldBinding> worlds = mockStatic(BukkitWorldBinding.class);
|
||||
private final MockedStatic<IrisServices> services = mockStatic(IrisServices.class);
|
||||
private final MockedStatic<INMS> nms = mockStatic(INMS.class);
|
||||
private final MockedStatic<J> tasks = mockStatic(J.class, CALLS_REAL_METHODS);
|
||||
private final MockedStatic<PregeneratorJob> pregenerator = mockStatic(PregeneratorJob.class);
|
||||
private int quiesces;
|
||||
|
||||
private Fixture() throws ReflectiveOperationException {
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
setField(plugin, "serverStopTeardownDeferred", new AtomicBoolean());
|
||||
setField(plugin, "postStopFinisherStarted", new AtomicBoolean());
|
||||
setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList<PlatformChunkGenerator>());
|
||||
when(world.getKey()).thenReturn(NamespacedKey.fromString(identity));
|
||||
when(engine.getWorld()).thenReturn(irisWorld);
|
||||
when(irisWorld.hasPlatformWorld()).thenReturn(true);
|
||||
when(irisWorld.identity()).thenReturn(identity);
|
||||
when(generator.closeAsync()).thenReturn(CompletableFuture.completedFuture(null));
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(world));
|
||||
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
|
||||
worlds.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world);
|
||||
nms.when(INMS::get).thenReturn(binding);
|
||||
tasks.when(J::isFolia).thenReturn(true);
|
||||
}
|
||||
|
||||
private void quiesce() throws ReflectiveOperationException {
|
||||
Method method = Iris.class.getDeclaredMethod("quiesceRuntimeForServerShutdown", String.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(plugin, "server_stop");
|
||||
quiesces++;
|
||||
}
|
||||
|
||||
private void drain() throws ReflectiveOperationException {
|
||||
Method method = Iris.class.getDeclaredMethod("drainWorldGenerators", String.class, long.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(plugin, "manual_disable", 5L);
|
||||
}
|
||||
|
||||
private void startFinisher() throws ReflectiveOperationException {
|
||||
Method method = Iris.class.getDeclaredMethod("startPostStopFinisher");
|
||||
method.setAccessible(true);
|
||||
method.invoke(plugin);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (int index = 0; index < quiesces; index++) {
|
||||
WorldMaintenance.endWorldMaintenance(identity, "server_stop");
|
||||
}
|
||||
pregenerator.close();
|
||||
tasks.close();
|
||||
nms.close();
|
||||
services.close();
|
||||
worlds.close();
|
||||
toolbelt.close();
|
||||
bukkit.close();
|
||||
IrisSettings.settings = previousSettings;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class IrisVersionNumberTest {
|
||||
@Test
|
||||
public void releaseAndQualifiedBuildsUseTheSameVersionNumber() {
|
||||
for (String version : List.of("4.3.3", "4.3.3-SNAPSHOT", "4.3.3-test")) {
|
||||
assertEquals(version, 433, plugin(version).getIrisVersion());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processedPluginMetadataProvidesAValidEngineVersion() throws Exception {
|
||||
try (InputStream stream = getClass().getResourceAsStream("/plugin.yml")) {
|
||||
assertNotNull(stream);
|
||||
PluginDescriptionFile description = new PluginDescriptionFile(stream);
|
||||
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
|
||||
doReturn(description).when(plugin).getDescription();
|
||||
|
||||
assertNotEquals(-1, plugin.getIrisVersion());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedAndOverflowingReleaseNumbersStayInvalid() {
|
||||
for (String version : List.of("development", "4..3", ".4.3.3", "4.3.3.", "4.3a.3-test",
|
||||
"2147483648", "999999999999999999999-SNAPSHOT", "-SNAPSHOT")) {
|
||||
assertEquals(version, -1, plugin(version).getIrisVersion());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void supportedMinecraftVersionsRetainTheirEngineNumbers() {
|
||||
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::getVersion).thenReturn("26.1.2-74-ver/26.1.2@e4e17fc (MC: 26.1.2)");
|
||||
assertEquals(2612, plugin.getMCVersion());
|
||||
bukkit.when(Bukkit::getVersion).thenReturn("26.1.2-8-ver/26.1.x@62dc0f2 (MC: 26.1.2)");
|
||||
assertEquals(2612, plugin.getMCVersion());
|
||||
bukkit.when(Bukkit::getVersion).thenReturn("26.2-133 (MC: 26.2)");
|
||||
assertEquals(262, plugin.getMCVersion());
|
||||
bukkit.when(Bukkit::getVersion).thenReturn("26.3-169 (MC: 26.3)");
|
||||
assertEquals(263, plugin.getMCVersion());
|
||||
}
|
||||
}
|
||||
|
||||
private static Iris plugin(String version) {
|
||||
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
|
||||
doReturn(new PluginDescriptionFile("Iris", version, Iris.class.getName())).when(plugin).getDescription();
|
||||
return plugin;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import org.gradle.api.file.FileCopyDetails
|
||||
import org.gradle.api.file.FileSystemOperations
|
||||
import org.gradle.api.file.FileTree
|
||||
import org.gradle.api.tasks.compile.JavaCompile
|
||||
import org.gradle.api.attributes.Bundling
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
|
||||
import javax.inject.Inject
|
||||
@@ -38,10 +39,14 @@ buildscript {
|
||||
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'net.fabricmc.fabric-loom' version '1.17.14'
|
||||
id 'net.fabricmc.fabric-loom' version '1.17.21' apply false
|
||||
id 'net.fabricmc.fabric-loom-remap' version '1.17.21' apply false
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
apply from: file('../../gradle/mod-target.gradle')
|
||||
|
||||
apply plugin: irisModTarget.fabricRemap ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric-loom'
|
||||
apply plugin: 'art.arcane.volmit-packaging'
|
||||
|
||||
interface IrisFileOperations {
|
||||
@@ -56,9 +61,9 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
|
||||
|
||||
Properties rootProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2'))
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
|
||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
|
||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(irisModTarget.fabricLoader.toString())
|
||||
Closure<String> irisArtifactName = { String platform, String targetVersion ->
|
||||
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
|
||||
}
|
||||
@@ -141,7 +146,11 @@ configurations.create('jij') {
|
||||
|
||||
dependencies {
|
||||
minecraft("com.mojang:minecraft:${minecraftVersion}")
|
||||
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
|
||||
if (irisModTarget.fabricRemap) {
|
||||
mappings(loom.officialMojangMappings())
|
||||
}
|
||||
String modConfiguration = irisModTarget.fabricRemap ? 'modImplementation' : 'implementation'
|
||||
add(modConfiguration, "net.fabricmc:fabric-loader:${fabricLoaderVersion}")
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
testImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testImplementation(testFixtures('art.arcane:core'))
|
||||
@@ -168,11 +177,19 @@ dependencies {
|
||||
libs.fabricApi.keyMapping,
|
||||
libs.fabricApi.permission
|
||||
]
|
||||
if (irisModTarget.fabricModules != null) {
|
||||
fabricApi = ((Map<String, String>) irisModTarget.fabricModules).collect { String module, String moduleVersion ->
|
||||
"net.fabricmc.fabric-api:${module}:${moduleVersion}".toString()
|
||||
}
|
||||
}
|
||||
if (irisModTarget.fabricPermissions != null) {
|
||||
fabricApi.add("me.lucko:fabric-permissions-api:${irisModTarget.fabricPermissions}".toString())
|
||||
}
|
||||
fabricApi.each { Object notation ->
|
||||
add('jij', notation)
|
||||
add('implementation', notation)
|
||||
add(modConfiguration, notation)
|
||||
}
|
||||
compileOnly('org.slf4j:slf4j-api:2.0.17')
|
||||
compileOnly("org.slf4j:slf4j-api:${irisModTarget.slf4j}")
|
||||
compileOnly(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
@@ -201,21 +218,28 @@ dependencies {
|
||||
libs.jaxen
|
||||
]
|
||||
shared.each { Object notation ->
|
||||
add('bundle', notation)
|
||||
add('devBundle', notation)
|
||||
['bundle', 'devBundle'].each { String configuration ->
|
||||
add(configuration, notation) {
|
||||
if (group == 'art.arcane' && name == 'core') {
|
||||
attributes {
|
||||
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('compileJava', JavaCompile).configure {
|
||||
options.encoding = 'UTF-8'
|
||||
options.release.set(25)
|
||||
options.release.set((int) irisModTarget.java)
|
||||
}
|
||||
|
||||
tasks.named('test').configure {
|
||||
}
|
||||
|
||||
loom {
|
||||
accessWidenerPath = art.arcane.volmit.packaging.NativeAccessRules.prepare(nativeSourceImplementation, "fabric", layout.buildDirectory.dir("native-access").get().asFile)
|
||||
accessWidenerPath = art.arcane.volmit.packaging.NativeAccessRules.prepare(irisModTarget.nativeAccess.toString(), "fabric", layout.buildDirectory.dir("native-access").get().asFile)
|
||||
runs {
|
||||
client {
|
||||
runDir(providers.gradleProperty('irisClientRunDir').getOrElse('run'))
|
||||
@@ -270,6 +294,15 @@ Map<String, String> nestedFabricApiJars = [
|
||||
'fabric-permission-api-v1' : 'fabric-permission-api-v1.jar'
|
||||
]
|
||||
|
||||
if (irisModTarget.fabricModules != null) {
|
||||
nestedFabricApiJars = ((Map<String, String>) irisModTarget.fabricModules).collectEntries { String module, String ignored ->
|
||||
[(module): "${module}.jar".toString()]
|
||||
}
|
||||
}
|
||||
if (irisModTarget.fabricPermissions != null) {
|
||||
nestedFabricApiJars.put('fabric-permissions-api', 'fabric-permissions-api.jar')
|
||||
}
|
||||
|
||||
FileTree staleLoaderJars = fileTree(layout.buildDirectory.dir('libs')) {
|
||||
include('Iris v* [Fabric] *.jar')
|
||||
include('iris-fabric-*.jar')
|
||||
@@ -288,6 +321,10 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}"))
|
||||
configurations = [project.configurations.named('bundle').get()]
|
||||
mergeServiceFiles()
|
||||
if ((int) irisModTarget.java == 21) {
|
||||
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class')
|
||||
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
|
||||
}
|
||||
exclude('META-INF/maven/**')
|
||||
exclude('META-INF/proguard/**')
|
||||
exclude('META-INF/*.SF')
|
||||
@@ -346,28 +383,39 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
}
|
||||
}
|
||||
|
||||
// The thin `jar`/`remapJar` outputs carry valid fabric.mod.json metadata but bundle zero
|
||||
// dependencies. Installing one is an instant NoClassDefFoundError, and they sit in build/libs right
|
||||
// next to the real artifact. shadowJar is the only shippable Fabric jar.
|
||||
// The thin jar omits bundled dependencies. Obfuscated targets remap the complete shadow jar.
|
||||
tasks.named('jar').configure {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
tasks.matching { it.name == 'remapJar' }.configureEach {
|
||||
enabled = false
|
||||
enabled = (boolean) irisModTarget.fabricRemap
|
||||
if (irisModTarget.fabricRemap) {
|
||||
dependsOn(tasks.named('shadowJar'))
|
||||
inputFile.set(tasks.named('shadowJar', ShadowJar).flatMap { it.archiveFile })
|
||||
archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}"))
|
||||
addNestedDependencies.set(false)
|
||||
}
|
||||
}
|
||||
if (irisModTarget.fabricRemap) {
|
||||
tasks.named('shadowJar', ShadowJar).configure {
|
||||
destinationDirectory.set(layout.buildDirectory.dir('devlibs'))
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('assemble').configure {
|
||||
dependsOn(tasks.named('shadowJar'))
|
||||
dependsOn(tasks.named(irisModTarget.fabricRemap ? 'remapJar' : 'shadowJar'))
|
||||
}
|
||||
|
||||
pluginPackaging {
|
||||
artifacts {
|
||||
create('distribution') {
|
||||
taskName = 'shadowJar'
|
||||
taskName = irisModTarget.fabricRemap ? 'remapJar' : 'shadowJar'
|
||||
policyName = 'Iris Fabric'
|
||||
prunePrefixes = ['art/arcane/volmlib/']
|
||||
keepPrefixes = ['art/arcane/volmlib/util/matter/slices/']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply from: file('../../gradle/modded-version-sources.gradle')
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "irisworldgen",
|
||||
"version": "${version}",
|
||||
"name": "Iris",
|
||||
"description": "Iris World Generation Engine (Fabric adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)",
|
||||
"authors": [
|
||||
"Arcane Arts (Volmit Software)"
|
||||
],
|
||||
"contributors": [
|
||||
"cyberpwn",
|
||||
"NextdoorPsycho",
|
||||
"Vatuu"
|
||||
],
|
||||
"contact": {
|
||||
"homepage": "https://docs.volmit.com/iris/",
|
||||
"issues": "https://github.com/VolmitSoftware/Iris/issues",
|
||||
"sources": "https://github.com/VolmitSoftware/Iris"
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"icon": "assets/irisworldgen/icon.png",
|
||||
"environment": "*",
|
||||
"accessWidener": "volmlib.accesswidener",
|
||||
"mixins": [
|
||||
"volmlib.fabric.mixins.json",
|
||||
"volmlib.entity.mixins.json",
|
||||
{
|
||||
"config": "volmlib.client.mixins.json",
|
||||
"environment": "client"
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"main": [
|
||||
"art.arcane.iris.fabric.IrisFabricBootstrap"
|
||||
],
|
||||
"client": [
|
||||
"art.arcane.iris.fabric.IrisFabricClient"
|
||||
]
|
||||
},
|
||||
"jars": [
|
||||
{
|
||||
"file": "META-INF/jars/fabric-api-base.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-registry-sync-v0.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-resource-loader-v0.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-lifecycle-events-v1.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-command-api-v2.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-events-interaction-v0.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-networking-api-v1.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-rendering-v1.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-key-binding-api-v1.jar"
|
||||
},
|
||||
{
|
||||
"file": "META-INF/jars/fabric-permissions-api.jar"
|
||||
}
|
||||
],
|
||||
"depends": {
|
||||
"fabricloader": ">=0.19.5",
|
||||
"minecraft": "${minecraftVersion}",
|
||||
"java": ">=21",
|
||||
"fabric-permissions-api-v0": "*"
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.fabric;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import com.mojang.serialization.Lifecycle;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.MappedRegistry;
|
||||
import net.minecraft.core.RegistrationInfo;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeGenerationSettings;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver;
|
||||
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class NativeFabricBiomeDefinitionTest {
|
||||
private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
|
||||
private static final String BIOME_KEY = "iris:biomes/fabric_modified";
|
||||
private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
|
||||
+ "\"effects\":{\"fog_color\":12638463,\"water_color\":4159204,\"water_fog_color\":329011,\"sky_color\":7907327},"
|
||||
+ "\"carvers\":{},\"features\":[],\"spawners\":{},\"spawn_costs\":{}}";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatedBiomeDefinitionExcludesFabricBiomeModifications() throws Exception {
|
||||
MappedRegistry<PlacedFeature> features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
|
||||
Holder.Reference<PlacedFeature> ore = features.register(
|
||||
ResourceKey.create(Registries.PLACED_FEATURE, ResourceLocation.parse("iris:modded_ore")),
|
||||
new PlacedFeature(Holder.direct(mock(ConfiguredFeature.class)), List.of()),
|
||||
RegistrationInfo.BUILT_IN);
|
||||
features.freeze();
|
||||
MappedRegistry<ConfiguredWorldCarver<?>> carvers = new MappedRegistry<>(Registries.CONFIGURED_CARVER, Lifecycle.stable());
|
||||
carvers.freeze();
|
||||
MappedRegistry<Biome> biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
|
||||
RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
|
||||
Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
|
||||
JsonParser.parseString(SOURCE_JSON)).getOrThrow();
|
||||
NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
|
||||
BooleanSupplier::getAsBoolean));
|
||||
loader.recordOriginalBiome(source);
|
||||
Holder.Reference<Biome> biome = biomes.register(
|
||||
ResourceKey.create(Registries.BIOME, ResourceLocation.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
|
||||
biomes.freeze();
|
||||
addFeatureInPlace(biome.value().getGenerationSettings(), ore);
|
||||
warmInPlace(biome.value());
|
||||
NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access, loader::unmodifiedBiome);
|
||||
|
||||
assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F);
|
||||
assertTrue(biome.value().getGenerationSettings().features()
|
||||
.get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()).contains(ore));
|
||||
assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
|
||||
JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
|
||||
}
|
||||
|
||||
private static void addFeatureInPlace(BiomeGenerationSettings settings, Holder<PlacedFeature> feature)
|
||||
throws ReflectiveOperationException {
|
||||
List<HolderSet<PlacedFeature>> steps = new ArrayList<>(settings.features());
|
||||
while (steps.size() <= GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()) {
|
||||
steps.add(HolderSet.direct());
|
||||
}
|
||||
steps.set(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal(), HolderSet.direct(feature));
|
||||
Field features = BiomeGenerationSettings.class.getDeclaredField("features");
|
||||
features.setAccessible(true);
|
||||
features.set(settings, List.copyOf(steps));
|
||||
}
|
||||
|
||||
private static void warmInPlace(Biome biome) throws ReflectiveOperationException {
|
||||
Constructor<Biome.ClimateSettings> climate = Biome.ClimateSettings.class.getDeclaredConstructor(
|
||||
boolean.class, float.class, Biome.TemperatureModifier.class, float.class);
|
||||
climate.setAccessible(true);
|
||||
Field climateSettings = Biome.class.getDeclaredField("climateSettings");
|
||||
climateSettings.setAccessible(true);
|
||||
climateSettings.set(biome, climate.newInstance(true, 0.1F, Biome.TemperatureModifier.NONE, 0.4F));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import org.gradle.api.file.FileCopyDetails
|
||||
import org.gradle.api.file.FileSystemOperations
|
||||
import org.gradle.api.file.FileTree
|
||||
import org.gradle.api.tasks.compile.JavaCompile
|
||||
import org.gradle.api.attributes.Bundling
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
|
||||
import javax.inject.Inject
|
||||
@@ -42,6 +43,8 @@ plugins {
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
apply from: file('../../gradle/mod-target.gradle')
|
||||
|
||||
apply plugin: 'art.arcane.volmit-packaging'
|
||||
|
||||
interface IrisFileOperations {
|
||||
@@ -56,9 +59,9 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
|
||||
|
||||
Properties rootProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2'))
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
|
||||
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8'))
|
||||
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString())
|
||||
Closure<String> loaderDisplayVersion = { String loaderVersion ->
|
||||
String coordinatePrefix = "${minecraftVersion}-"
|
||||
if (loaderVersion.startsWith(coordinatePrefix)) {
|
||||
@@ -165,7 +168,7 @@ dependencies {
|
||||
testImplementation(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
compileOnly('org.slf4j:slf4j-api:2.0.17')
|
||||
compileOnly("org.slf4j:slf4j-api:${irisModTarget.slf4j}")
|
||||
compileOnly(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
@@ -200,14 +203,23 @@ dependencies {
|
||||
libs.jaxen
|
||||
]
|
||||
shared.each { Object notation ->
|
||||
add('bundle', notation)
|
||||
add('devBundle', notation)
|
||||
add('runBundle', notation)
|
||||
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
|
||||
add(configuration, notation) {
|
||||
if (group == 'art.arcane' && name == 'core') {
|
||||
attributes {
|
||||
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
minecraft {
|
||||
accessTransformer.from(art.arcane.volmit.packaging.NativeAccessRules.prepare(nativeSourceImplementation, "forge", layout.buildDirectory.dir("native-access").get().asFile))
|
||||
if (irisModTarget.fabricRemap) {
|
||||
mappings channel: 'official', version: minecraftVersion
|
||||
}
|
||||
accessTransformer.from(art.arcane.volmit.packaging.NativeAccessRules.prepare(irisModTarget.nativeAccess.toString(), "forge", layout.buildDirectory.dir("native-access").get().asFile))
|
||||
runs {
|
||||
register('client') {
|
||||
workingDir = layout.projectDirectory.dir('run')
|
||||
@@ -241,7 +253,8 @@ minecraft {
|
||||
|
||||
tasks.named('compileJava', JavaCompile).configure {
|
||||
options.encoding = 'UTF-8'
|
||||
options.release.set(25)
|
||||
options.compilerArgs.addAll(['-Xmaxerrs', '2000'])
|
||||
options.release.set((int) irisModTarget.java)
|
||||
}
|
||||
|
||||
tasks.named('test').configure {
|
||||
@@ -250,8 +263,9 @@ tasks.named('test').configure {
|
||||
processResources {
|
||||
inputs.property('version', irisVersion)
|
||||
inputs.property('minecraftVersion', minecraftVersion)
|
||||
inputs.property('loaderRange', irisModTarget.forgeRange.toString())
|
||||
filesMatching('META-INF/mods.toml') {
|
||||
expand('version': irisVersion, 'minecraftVersion': minecraftVersion)
|
||||
expand('version': irisVersion, 'minecraftVersion': minecraftVersion, 'loaderRange': irisModTarget.forgeRange.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +294,10 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
configurations = [project.configurations.named('bundle').get()]
|
||||
from(sourceSets.main.output)
|
||||
mergeServiceFiles()
|
||||
if ((int) irisModTarget.java == 21) {
|
||||
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class')
|
||||
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
|
||||
}
|
||||
exclude('META-INF/maven/**')
|
||||
exclude('META-INF/proguard/**')
|
||||
exclude('META-INF/*.SF')
|
||||
@@ -345,3 +363,5 @@ pluginPackaging {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply from: file('../../gradle/modded-version-sources.gradle')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
modLoader = "javafml"
|
||||
loaderVersion = "[66,67)"
|
||||
loaderVersion = "${loaderRange}"
|
||||
license = "GPL-3.0"
|
||||
issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues"
|
||||
|
||||
@@ -16,7 +16,7 @@ logoFile = "assets/irisworldgen/icon.png"
|
||||
[[dependencies.irisworldgen]]
|
||||
modId = "forge"
|
||||
mandatory = true
|
||||
versionRange = "[66,67)"
|
||||
versionRange = "${loaderRange}"
|
||||
ordering = "NONE"
|
||||
side = "BOTH"
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package art.arcane.iris.forge;
|
||||
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedLifecycleCallbacks;
|
||||
import art.arcane.iris.modded.service.ModdedTreeFellerService;
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeChunkGeneratorDefinition;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeLoader;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeBootstrap;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
|
||||
import net.minecraftforge.fml.loading.FMLLoader;
|
||||
|
||||
@Mod("irisworldgen")
|
||||
public final class IrisForgeBootstrap {
|
||||
public IrisForgeBootstrap(FMLJavaModLoadingContext context) {
|
||||
NativeForgeLoader loader = new NativeForgeLoader(new NativeLoaderOptions(
|
||||
"irisworldgen", "treefeller", ModdedTreeFellerService::runBreakProbe));
|
||||
NativeChunkGeneratorDefinition generator = IrisModdedChunkGenerator.DEFINITION;
|
||||
ModdedEngineBootstrap.bootCommon(loader, "Forge " + FMLLoader.versionInfo().forgeVersion(),
|
||||
() -> NativeForgeBootstrap.registerGenerators(context, generator, "block_drops"));
|
||||
ForgeProtocolNetworking.register();
|
||||
if (loader.clientEnvironment()) {
|
||||
IrisForgeClient.init(context);
|
||||
}
|
||||
NativeForgeBootstrap.install(context, loader, ModdedLifecycleCallbacks.create());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package art.arcane.iris.forge;
|
||||
|
||||
import art.arcane.iris.client.IrisClient;
|
||||
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeClientHooks;
|
||||
import art.arcane.iris.client.IrisClientHud;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolChannel;
|
||||
|
||||
public final class IrisForgeClient {
|
||||
private IrisForgeClient() {
|
||||
}
|
||||
|
||||
public static void init(FMLJavaModLoadingContext context) {
|
||||
IrisClient.bindSender(IrisForgeClient::sendToServer);
|
||||
NativeForgeClientHooks.install(context, IrisClientHud.binding(), IrisClient::onWorldJoin, IrisClient::onDisconnect);
|
||||
}
|
||||
|
||||
private static void sendToServer(byte[] frame) {
|
||||
NativeProtocolChannel channel = ForgeProtocolNetworking.channel();
|
||||
if (channel == null) {
|
||||
return;
|
||||
}
|
||||
channel.sendToServer(frame);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.forge;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import com.mojang.serialization.Lifecycle;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.MappedRegistry;
|
||||
import net.minecraft.core.RegistrationInfo;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver;
|
||||
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import net.minecraftforge.common.world.BiomeModifier;
|
||||
import net.minecraftforge.common.world.ModifiableBiomeInfo;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class NativeForgeBiomeDefinitionTest {
|
||||
private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
|
||||
private static final String BIOME_KEY = "iris:biomes/forge_modified";
|
||||
private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
|
||||
+ "\"effects\":{\"fog_color\":12638463,\"water_color\":4159204,\"water_fog_color\":329011,\"sky_color\":7907327},"
|
||||
+ "\"carvers\":{},\"features\":[],\"spawners\":{},\"spawn_costs\":{}}";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatedBiomeDefinitionExcludesForgeBiomeModifierOutput() {
|
||||
MappedRegistry<PlacedFeature> features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
|
||||
Holder.Reference<PlacedFeature> ore = features.register(
|
||||
ResourceKey.create(Registries.PLACED_FEATURE, ResourceLocation.parse("iris:modded_ore")),
|
||||
new PlacedFeature(Holder.direct(mock(ConfiguredFeature.class)), List.of()),
|
||||
RegistrationInfo.BUILT_IN);
|
||||
features.freeze();
|
||||
MappedRegistry<ConfiguredWorldCarver<?>> carvers = new MappedRegistry<>(Registries.CONFIGURED_CARVER, Lifecycle.stable());
|
||||
carvers.freeze();
|
||||
MappedRegistry<Biome> biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
|
||||
RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
|
||||
Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
|
||||
JsonParser.parseString(SOURCE_JSON)).getOrThrow();
|
||||
Holder.Reference<Biome> biome = biomes.register(
|
||||
ResourceKey.create(Registries.BIOME, ResourceLocation.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
|
||||
biomes.freeze();
|
||||
biome.value().modifiableBiomeInfo().applyBiomeModifiers(biome, List.of(new ModdedContent(ore)));
|
||||
NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access,
|
||||
new NativeForgeLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
|
||||
BooleanSupplier::getAsBoolean))::unmodifiedBiome);
|
||||
|
||||
assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F);
|
||||
assertEquals(0x102030, biome.value().getWaterColor() & 0xFFFFFF);
|
||||
assertTrue(biome.value().getGenerationSettings().hasFeature(ore.value()));
|
||||
assertTrue(biome.value().getMobSettings().getMobs(MobCategory.MONSTER).unwrap().stream()
|
||||
.anyMatch(spawn -> spawn.type == EntityType.ZOMBIE));
|
||||
assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
|
||||
JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
|
||||
}
|
||||
|
||||
private record ModdedContent(Holder<PlacedFeature> ore) implements BiomeModifier {
|
||||
@Override
|
||||
public void modify(Holder<Biome> biome, Phase phase, ModifiableBiomeInfo.BiomeInfo.Builder builder) {
|
||||
if (phase != Phase.ADD) {
|
||||
return;
|
||||
}
|
||||
builder.getClimateSettings().setTemperature(0.1F);
|
||||
builder.getSpecialEffects().waterColor(0x102030);
|
||||
builder.getMobSpawnSettings().addSpawn(MobCategory.MONSTER, new MobSpawnSettings.SpawnerData(EntityType.ZOMBIE, 5, 1, 2));
|
||||
builder.getGenerationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MapCodec<? extends BiomeModifier> codec() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.forge;
|
||||
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.MobSpawnType;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.level.ServerLevelAccessor;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.event.entity.living.MobSpawnEvent;
|
||||
import net.minecraftforge.eventbus.api.Event;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeForgeSpawnPositionTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkGenerationSpawnsFireThePositionCheckEventAndHonorItsDenial() {
|
||||
NativeForgeLoader loader = new NativeForgeLoader(
|
||||
new NativeLoaderOptions("irisworldgen", "treefeller", BooleanSupplier::getAsBoolean));
|
||||
ServerLevelAccessor level = mock(ServerLevelAccessor.class);
|
||||
Mob mob = mock(Mob.class);
|
||||
when(mob.checkSpawnRules(level, MobSpawnType.CHUNK_GENERATION)).thenReturn(true);
|
||||
when(mob.checkSpawnObstruction(level)).thenReturn(true);
|
||||
AtomicReference<MobSpawnType> observed = new AtomicReference<>();
|
||||
Consumer<MobSpawnEvent.PositionCheck> listener = event -> {
|
||||
observed.set(event.getSpawnType());
|
||||
event.setResult(Event.Result.DENY);
|
||||
};
|
||||
MinecraftForge.EVENT_BUS.addListener(listener);
|
||||
try {
|
||||
assertFalse(loader.checkSpawnPosition(mob, level, MobSpawnType.CHUNK_GENERATION));
|
||||
} finally {
|
||||
MinecraftForge.EVENT_BUS.unregister(listener);
|
||||
}
|
||||
assertEquals(MobSpawnType.CHUNK_GENERATION, observed.get());
|
||||
assertTrue(loader.checkSpawnPosition(mob, level, MobSpawnType.CHUNK_GENERATION));
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.forge;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
|
||||
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
import com.mojang.serialization.Lifecycle;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.MappedRegistry;
|
||||
import net.minecraft.core.RegistrationInfo;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver;
|
||||
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import net.minecraftforge.common.world.BiomeModifier;
|
||||
import net.minecraftforge.common.world.ModifiableBiomeInfo;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class NativeForgeBiomeDefinitionTest {
|
||||
private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
|
||||
private static final String BIOME_KEY = "iris:biomes/forge_modified";
|
||||
private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
|
||||
+ "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[],"
|
||||
+ "\"spawners\":{},\"spawn_costs\":{}}";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatedBiomeDefinitionExcludesForgeBiomeModifierOutput() {
|
||||
MappedRegistry<PlacedFeature> features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
|
||||
Holder.Reference<PlacedFeature> ore = features.register(
|
||||
ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")),
|
||||
new PlacedFeature(Holder.direct(mock(ConfiguredFeature.class)), List.of()),
|
||||
RegistrationInfo.BUILT_IN);
|
||||
features.freeze();
|
||||
MappedRegistry<ConfiguredWorldCarver<?>> carvers = new MappedRegistry<>(Registries.CONFIGURED_CARVER, Lifecycle.stable());
|
||||
carvers.freeze();
|
||||
MappedRegistry<Biome> biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
|
||||
RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
|
||||
Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
|
||||
JsonParser.parseString(SOURCE_JSON)).getOrThrow();
|
||||
Holder.Reference<Biome> biome = biomes.register(
|
||||
ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
|
||||
biomes.freeze();
|
||||
biome.value().modifiableBiomeInfo().applyBiomeModifiers(biome, List.of(new ModdedContent(ore)));
|
||||
NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access,
|
||||
new NativeForgeLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
|
||||
BooleanSupplier::getAsBoolean))::unmodifiedBiome);
|
||||
|
||||
assertEquals(0.1F, biome.value().modifiableBiomeInfo().get().climateSettings().temperature(), 0.0F);
|
||||
assertEquals(0x102030, biome.value().modifiableBiomeInfo().get().effects().waterColor() & 0xFFFFFF);
|
||||
assertTrue(biome.value().getGenerationSettings().hasFeature(ore.value()));
|
||||
assertTrue(biome.value().getMobSettings().getMobs(MobCategory.MONSTER).unwrap().stream()
|
||||
.anyMatch(spawn -> spawn.value().type() == EntityTypes.ZOMBIE));
|
||||
assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
|
||||
JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
|
||||
}
|
||||
|
||||
private record ModdedContent(Holder<PlacedFeature> ore) implements BiomeModifier {
|
||||
@Override
|
||||
public void modify(Holder<Biome> biome, Phase phase, ModifiableBiomeInfo.BiomeInfo.Builder builder) {
|
||||
if (phase != Phase.ADD) {
|
||||
return;
|
||||
}
|
||||
builder.getClimateSettings().setTemperature(0.1F);
|
||||
builder.getSpecialEffects().waterColor(0x102030);
|
||||
builder.getMobSpawnSettings().addSpawn(MobCategory.MONSTER, 5,
|
||||
new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, 1, 2));
|
||||
builder.getGenerationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MapCodec<? extends BiomeModifier> codec() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-8
@@ -100,6 +100,7 @@ import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -477,6 +478,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
return existing;
|
||||
}
|
||||
try {
|
||||
applyReplacementSeed(level);
|
||||
Engine created = ModdedWorldEngines.get(
|
||||
level,
|
||||
activePack,
|
||||
@@ -511,6 +513,16 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
}
|
||||
}
|
||||
|
||||
private void applyReplacementSeed(NativeWorld level) {
|
||||
if (seedOverride != Long.MIN_VALUE) {
|
||||
return;
|
||||
}
|
||||
OptionalLong replacementSeed = ModdedDimensionReplacements.loadedSeed(level.name());
|
||||
if (replacementSeed.isPresent()) {
|
||||
seedOverride = replacementSeed.getAsLong();
|
||||
}
|
||||
}
|
||||
|
||||
private void recordBindFailure(NativeWorld level, Throwable error) {
|
||||
// A runtime that is stopping says nothing about the world: fail this call, let the next bind try again.
|
||||
if (GenerationFailures.isShutdownFailure(error)) {
|
||||
@@ -743,14 +755,8 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
||||
PackValidationRegistry.requireLoadable(packRoot);
|
||||
packDirectory = packRoot.toFile();
|
||||
}
|
||||
IrisData data = IrisData.get(packDirectory);
|
||||
IrisDimension dimension = data.getDimensionLoader().load(activeDimensionKey);
|
||||
if (dimension == null) {
|
||||
throw new IllegalStateException("Iris dimension '" + activeDimensionKey
|
||||
+ "' missing from pack " + packDirectory.getAbsolutePath());
|
||||
}
|
||||
ModdedDimensionMetadata.ConfiguredPack resolved = new ModdedDimensionMetadata.ConfiguredPack(
|
||||
data, dimension, ModdedDimensionMetadata.dimensionMetadata(dimension));
|
||||
ModdedDimensionMetadata.ConfiguredPack resolved =
|
||||
ModdedDimensionMetadata.ConfiguredPack.load(packDirectory, activeDimensionKey);
|
||||
configuredPack = resolved;
|
||||
heightMetadata = resolved.metadata();
|
||||
return resolved;
|
||||
|
||||
@@ -239,50 +239,21 @@ public final class MainWorldService {
|
||||
|
||||
private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException {
|
||||
Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID());
|
||||
List<Path> moved = new ArrayList<>();
|
||||
List<Path> sources = new ArrayList<>();
|
||||
for (String file : NativeServerWorldLayout.metadataFiles()) {
|
||||
moveToRecovery(worldRoot, worldRoot.resolve(file), recovery, moved);
|
||||
sources.add(worldRoot.resolve(file));
|
||||
}
|
||||
for (String folder : NativeServerWorldLayout.primaryFolders()) {
|
||||
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved);
|
||||
sources.add(worldRoot.resolve(folder));
|
||||
}
|
||||
moveToRecovery(worldRoot, worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME), recovery, moved);
|
||||
sources.add(worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME));
|
||||
for (String folder : NativeServerWorldLayout.dimensionFolders()) {
|
||||
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved);
|
||||
}
|
||||
if (moved.isEmpty()) {
|
||||
Files.deleteIfExists(recovery);
|
||||
sources.add(worldRoot.resolve(folder));
|
||||
}
|
||||
ModdedWorldQuarantine.move(worldRoot, sources, recovery);
|
||||
return recovery;
|
||||
}
|
||||
|
||||
private static void moveToRecovery(Path worldRoot, Path source, Path recovery,
|
||||
List<Path> moved) throws IOException {
|
||||
if (!Files.exists(source)) {
|
||||
return;
|
||||
}
|
||||
Path relative = worldRoot.relativize(source);
|
||||
Path target = recovery.resolve(relative);
|
||||
Files.createDirectories(target.getParent());
|
||||
try {
|
||||
Files.move(source, target);
|
||||
moved.add(relative);
|
||||
} catch (IOException failure) {
|
||||
for (int index = moved.size() - 1; index >= 0; index--) {
|
||||
Path rollbackRelative = moved.get(index);
|
||||
Path rollbackSource = recovery.resolve(rollbackRelative);
|
||||
Path rollbackTarget = worldRoot.resolve(rollbackRelative);
|
||||
try {
|
||||
Files.createDirectories(rollbackTarget.getParent());
|
||||
Files.move(rollbackSource, rollbackTarget);
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value.replace(":", "\\:");
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
public final class MissingPackDimensionException extends IllegalStateException {
|
||||
private final String pack;
|
||||
private final String dimensionKey;
|
||||
|
||||
public MissingPackDimensionException(String pack, String dimensionKey) {
|
||||
super("Pack '" + pack + "' does not contain dimensions/" + dimensionKey + ".json");
|
||||
this.pack = pack;
|
||||
this.dimensionKey = dimensionKey;
|
||||
}
|
||||
|
||||
public String pack() {
|
||||
return pack;
|
||||
}
|
||||
|
||||
public String dimensionKey() {
|
||||
return dimensionKey;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.biome.IrisBiomeCustom;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.context.IrisContext;
|
||||
import art.arcane.iris.generation.locator.BiomeSearchFilter;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -300,10 +301,6 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
if (!isReady(engine)) {
|
||||
throw new IllegalStateException("Iris visible biome search has no active engine runtime");
|
||||
}
|
||||
DimensionStackContext stackContext = engine.getDimensionStackContext();
|
||||
if (stackContext == null) {
|
||||
return query.defaultFallback().get();
|
||||
}
|
||||
Set<H> candidates = new HashSet<>();
|
||||
for (H biome : collectVisibleBiomeHolders()) {
|
||||
if (query.allowed().test(biome)) {
|
||||
@@ -313,16 +310,86 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
|
||||
if (candidates.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
DimensionStackContext stackContext = engine.getDimensionStackContext();
|
||||
if (stackContext != null) {
|
||||
return query.search().search(candidates, (blockX, blockZ) -> {
|
||||
int quartX = blockX >> 2;
|
||||
int quartZ = blockZ >> 2;
|
||||
DimensionStackLayout stackLayout = stackContext.sample(quartX << 2, quartZ << 2);
|
||||
return blockY -> resolveVisibleBiome(engine, quartX, blockY >> 2, quartZ,
|
||||
query.sampler(), stackLayout);
|
||||
});
|
||||
}
|
||||
BiomeSearchFilter filter = BiomeSearchFilter.of(engine, searchBiomeKeys(engine, candidates));
|
||||
int minHeight = engine.getMinHeight();
|
||||
return query.search().search(candidates, (blockX, blockZ) -> {
|
||||
int quartX = blockX >> 2;
|
||||
int quartZ = blockZ >> 2;
|
||||
DimensionStackLayout stackLayout = stackContext.sample(quartX << 2, quartZ << 2);
|
||||
return blockY -> resolveVisibleBiome(engine, quartX, blockY >> 2, quartZ,
|
||||
query.sampler(), stackLayout);
|
||||
BiomeSearchFilter.Column column = filter.column(quartX << 2, quartZ << 2);
|
||||
return blockY -> column.admits(((blockY >> 2) << 2) - minHeight)
|
||||
? locateVisibleBiome(engine, quartX, blockY >> 2, quartZ, query.sampler())
|
||||
: null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private H locateVisibleBiome(Engine engine, int quartX, int quartY, int quartZ, S sampler) {
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = openHistoryCoordinateScope(
|
||||
engine, quartX << 2, quartZ << 2, "modded_locate_visible_biome")) {
|
||||
return resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler, null);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> searchBiomeKeys(Engine engine, Set<H> candidates) {
|
||||
NativeBiomeSourceAccess.RegistryView<H> registry = biomeRegistry();
|
||||
if (registry == null) {
|
||||
throw new IllegalStateException("Iris visible biome search has no biome registry");
|
||||
}
|
||||
H plains = resolveHolder(registry, "minecraft:plains");
|
||||
boolean fallbackCandidate = plains != null && candidates.contains(plains);
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (OwnedBiome ownedBiome : getOwnedBiomes(engine)) {
|
||||
if (emitsCandidate(engine, ownedBiome, registry, candidates, fallbackCandidate)) {
|
||||
keys.add(ownedBiome.biome().getLoadKey());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private boolean emitsCandidate(Engine engine, OwnedBiome ownedBiome, NativeBiomeSourceAccess.RegistryView<H> registry,
|
||||
Set<H> candidates, boolean fallbackCandidate) {
|
||||
IrisBiome irisBiome = ownedBiome.biome();
|
||||
if (irisBiome.isCustom()) {
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
if (customBiome == null) {
|
||||
if (fallbackCandidate) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String key = ownedBiome.dimension() == engine.getDimension() && ownedBiome.data() == engine.getData()
|
||||
? ModdedWorldgenIds.biomeRef(engine, customBiome.getId())
|
||||
: ownedBiome.data().customBiomeResourceKey(ownedBiome.dimension(), customBiome);
|
||||
if (emits(registry, key, candidates, fallbackCandidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (String key : irisBiome.getVisibleBiomeKeys(engine)) {
|
||||
if (emits(registry, key, candidates, fallbackCandidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static <H> boolean emits(NativeBiomeSourceAccess.RegistryView<H> registry, String key,
|
||||
Set<H> candidates, boolean fallbackCandidate) {
|
||||
H holder = key == null ? null : resolveHolder(registry, key);
|
||||
return holder == null ? fallbackCandidate : candidates.contains(holder);
|
||||
}
|
||||
|
||||
private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ,
|
||||
S sampler) {
|
||||
if (isGuaranteedSurfaceBiome(engine, quartY)) {
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
final class ModdedDatapackTagMerge {
|
||||
private static final String REPLACE = "replace";
|
||||
private static final String VALUES = "values";
|
||||
private static final String REMOVE = "remove";
|
||||
|
||||
private ModdedDatapackTagMerge() {
|
||||
}
|
||||
|
||||
static boolean isTag(Path relative) {
|
||||
return relative.getNameCount() >= 4
|
||||
&& "data".equals(relative.getName(0).toString())
|
||||
&& "tags".equals(relative.getName(2).toString())
|
||||
&& relative.getFileName().toString().endsWith(".json");
|
||||
}
|
||||
|
||||
static void merge(Path source, Path destination) throws IOException {
|
||||
JSONObject incoming = read(source);
|
||||
JSONObject existing = read(destination);
|
||||
JSONObject tag = new JSONObject();
|
||||
tag.put(REPLACE, existing.optBoolean(REPLACE, false) || incoming.optBoolean(REPLACE, false));
|
||||
tag.put(VALUES, union(existing, incoming, VALUES, source, destination));
|
||||
if (existing.has(REMOVE) || incoming.has(REMOVE)) {
|
||||
tag.put(REMOVE, union(existing, incoming, REMOVE, source, destination));
|
||||
}
|
||||
Files.writeString(destination, tag.toString(4), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static JSONArray union(JSONObject existing, JSONObject incoming, String field, Path source, Path destination)
|
||||
throws IOException {
|
||||
Map<String, Object> entries = new LinkedHashMap<>();
|
||||
collect(existing, field, entries, destination);
|
||||
collect(incoming, field, entries, source);
|
||||
JSONArray merged = new JSONArray();
|
||||
for (Object entry : entries.values()) {
|
||||
merged.put(entry);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static JSONObject read(Path tag) throws IOException {
|
||||
try {
|
||||
return new JSONObject(Files.readString(tag, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException invalid) {
|
||||
throw new IOException("Iris cannot merge the invalid datapack tag " + tag + ".", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collect(JSONObject tag, String field, Map<String, Object> entries, Path origin) throws IOException {
|
||||
JSONArray values = tag.optJSONArray(field);
|
||||
if (values == null) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < values.length(); index++) {
|
||||
Object entry = values.get(index);
|
||||
String key = entry instanceof JSONObject object ? object.optString("id", "") : entry.toString();
|
||||
if (key.isBlank()) {
|
||||
throw new IOException("Iris cannot merge datapack tag " + origin + ": " + field + " entry " + index + " has no id.");
|
||||
}
|
||||
entries.putIfAbsent(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.modded.command.ModdedPackCommands;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class ModdedDimensionCreations {
|
||||
private static final Map<String, ModdedDimensionInjection.Async<?, ?, ?>> ACTIVE = new ConcurrentHashMap<>();
|
||||
|
||||
private ModdedDimensionCreations() {
|
||||
}
|
||||
|
||||
public static CompletableFuture<Void> create(
|
||||
NativeModdedServer server,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
String packDimensionKey,
|
||||
long seed
|
||||
) {
|
||||
if (ModdedDimensionRegistryStore.get(server, dimensionId) != null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
ModdedDimensionManager.alreadyExists(dimensionId)));
|
||||
}
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension =
|
||||
new ModdedDimensionRegistryStore.PersistentDimension(dimensionId, pack, packDimensionKey, seed);
|
||||
ModdedDimensionInjection.Async<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> creation =
|
||||
ModdedDimensionInjection.async(dimension, new CreationSteps(server), server::execute);
|
||||
if (ACTIVE.putIfAbsent(dimensionId, creation) != null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(beingCreated(dimensionId)));
|
||||
}
|
||||
CompletableFuture<Void> created = creation.result().handle(
|
||||
(ModdedDimensionInjection.Result result, Throwable failure) -> settle(creation, dimension, result));
|
||||
creation.start();
|
||||
return created;
|
||||
}
|
||||
|
||||
public static boolean isCreating(String dimensionId) {
|
||||
return ACTIVE.containsKey(dimensionId);
|
||||
}
|
||||
|
||||
public static void requireNotCreating(String dimensionId) {
|
||||
if (isCreating(dimensionId)) {
|
||||
throw new IllegalStateException(beingCreated(dimensionId));
|
||||
}
|
||||
}
|
||||
|
||||
public static void cancelAll() {
|
||||
List<ModdedDimensionInjection.Async<?, ?, ?>> active = new ArrayList<>(ACTIVE.values());
|
||||
for (ModdedDimensionInjection.Async<?, ?, ?> creation : active) {
|
||||
creation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private static Void settle(
|
||||
ModdedDimensionInjection.Async<?, ?, ?> creation,
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
ModdedDimensionInjection.Result result
|
||||
) {
|
||||
ACTIVE.remove(dimension.id(), creation);
|
||||
Throwable cause = result == null
|
||||
? new IllegalStateException("Iris dimension creation for '" + dimension.id() + "' ended without a result")
|
||||
: result.failure();
|
||||
if (cause instanceof Error fatal) {
|
||||
throw fatal;
|
||||
}
|
||||
if (cause != null) {
|
||||
throw cause instanceof RuntimeException runtimeException ? runtimeException : new IllegalStateException(cause);
|
||||
}
|
||||
ModdedIrisLog.info("Iris created runtime dimension '{}' (pack={} dim={}): prepared {}ms, engine {}ms",
|
||||
dimension.id(), dimension.pack(), dimension.dimension(), result.preparedMillis(), result.builtMillis());
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String beingCreated(String dimensionId) {
|
||||
return "Iris dimension '" + dimensionId + "' is still being created. Try again when it is live.";
|
||||
}
|
||||
|
||||
private record CreationSteps(NativeModdedServer server)
|
||||
implements ModdedDimensionInjection.Steps<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> {
|
||||
@Override
|
||||
public ModdedWorldEngines.PreparedPack prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) {
|
||||
ModdedStartup.requirePackForWorldCreation(dimension.pack());
|
||||
IrisData data = IrisData.get(new File(ModdedPackCommands.packsRoot(), dimension.pack()));
|
||||
if (data.getDimensionLoader().load(dimension.dimension()) == null) {
|
||||
throw new MissingPackDimensionException(dimension.pack(), dimension.dimension());
|
||||
}
|
||||
return ModdedWorldEngines.prepareCreated(server, dimension);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModdedDimensionManager.PendingInjection begin(
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
ModdedWorldEngines.PreparedPack prepared
|
||||
) {
|
||||
return ModdedDimensionManager.beginCreation(server, dimension, prepared.active());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Engine build(ModdedDimensionManager.PendingInjection level, ModdedWorldEngines.PreparedPack prepared) {
|
||||
if (level.present()) {
|
||||
prepared.close();
|
||||
return null;
|
||||
}
|
||||
return ModdedWorldEngines.createPrepared(level.handle().level(), level.handle().seed(), prepared);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete(ModdedDimensionManager.PendingInjection level, Engine engine) {
|
||||
ModdedDimensionManager.completeCreation(server, level, engine);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void abandon(ModdedDimensionManager.PendingInjection level, Engine engine, Throwable failure) {
|
||||
ModdedDimensionManager.abandonCreation(server, level, engine, failure);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void discard(ModdedWorldEngines.PreparedPack prepared) {
|
||||
prepared.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
final class ModdedDimensionInjection<P, L, E> implements AutoCloseable {
|
||||
private static final int MINIMUM_WORKERS = 2;
|
||||
private static final long CANCEL_BUILD_WAIT_SECONDS = 120L;
|
||||
|
||||
private final List<Preparation<P>> preparations;
|
||||
private final Steps<P, L, E> steps;
|
||||
private final ExecutorService workers;
|
||||
private final AtomicBoolean finished = new AtomicBoolean();
|
||||
|
||||
private ModdedDimensionInjection(List<Preparation<P>> preparations, Steps<P, L, E> steps, ExecutorService workers) {
|
||||
this.preparations = preparations;
|
||||
this.steps = steps;
|
||||
this.workers = workers;
|
||||
}
|
||||
|
||||
static <P, L, E> ModdedDimensionInjection<P, L, E> start(
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions,
|
||||
Steps<P, L, E> steps
|
||||
) {
|
||||
Steps<P, L, E> requiredSteps = Objects.requireNonNull(steps, "steps");
|
||||
ExecutorService workers = Executors.newFixedThreadPool(workerCount(dimensions.size()),
|
||||
Thread.ofPlatform().name("Iris Dimension Restore ", 1L).daemon(true).factory());
|
||||
List<Preparation<P>> preparations = new ArrayList<>(dimensions.size());
|
||||
for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) {
|
||||
preparations.add(new Preparation<>(dimension, CompletableFuture.supplyAsync(
|
||||
() -> timed(() -> requiredSteps.prepare(dimension)), workers)));
|
||||
}
|
||||
return new ModdedDimensionInjection<>(List.copyOf(preparations), requiredSteps, workers);
|
||||
}
|
||||
|
||||
static <P, L, E> Async<P, L, E> async(
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
Steps<P, L, E> steps,
|
||||
Executor serverThread
|
||||
) {
|
||||
return new Async<>(dimension, steps, serverThread);
|
||||
}
|
||||
|
||||
void awaitPreparations() {
|
||||
List<CompletableFuture<Timed<P>>> futures = new ArrayList<>(preparations.size());
|
||||
for (Preparation<P> preparation : preparations) {
|
||||
futures.add(preparation.future());
|
||||
}
|
||||
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
|
||||
.handle((Void ignored, Throwable failure) -> null)
|
||||
.join();
|
||||
}
|
||||
|
||||
List<Result> finish() {
|
||||
if (!finished.compareAndSet(false, true)) {
|
||||
throw new IllegalStateException("Iris dimension injection already finished");
|
||||
}
|
||||
try {
|
||||
List<Building<L, E>> building = new ArrayList<>(preparations.size());
|
||||
Result[] results = new Result[preparations.size()];
|
||||
for (int index = 0; index < preparations.size(); index++) {
|
||||
Preparation<P> preparation = preparations.get(index);
|
||||
Timed<P> prepared;
|
||||
try {
|
||||
prepared = join(preparation.future());
|
||||
} catch (Throwable failure) {
|
||||
results[index] = new Result(preparation.dimension(), failure, 0L, 0L);
|
||||
continue;
|
||||
}
|
||||
L level;
|
||||
try {
|
||||
level = steps.begin(preparation.dimension(), prepared.value());
|
||||
} catch (Throwable failure) {
|
||||
discard(steps, prepared.value(), failure);
|
||||
results[index] = new Result(preparation.dimension(), failure, prepared.millis(), 0L);
|
||||
continue;
|
||||
}
|
||||
CompletableFuture<Timed<E>> engine = CompletableFuture.supplyAsync(
|
||||
() -> timed(() -> steps.build(level, prepared.value())), workers);
|
||||
building.add(new Building<>(index, preparation.dimension(), prepared.millis(), level, engine));
|
||||
}
|
||||
for (Building<L, E> pending : building) {
|
||||
results[pending.index()] = complete(pending);
|
||||
}
|
||||
return List.of(results);
|
||||
} finally {
|
||||
workers.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (finished.compareAndSet(false, true)) {
|
||||
for (Preparation<P> preparation : preparations) {
|
||||
preparation.future().whenComplete((Timed<P> prepared, Throwable failure) -> {
|
||||
if (prepared != null) {
|
||||
discard(steps, prepared.value(), null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
workers.shutdown();
|
||||
}
|
||||
|
||||
private Result complete(Building<L, E> pending) {
|
||||
Timed<E> engine;
|
||||
try {
|
||||
engine = join(pending.engine());
|
||||
} catch (Throwable failure) {
|
||||
abandon(steps, pending.level(), null, failure);
|
||||
return new Result(pending.dimension(), failure, pending.preparedMillis(), 0L);
|
||||
}
|
||||
try {
|
||||
steps.complete(pending.level(), engine.value());
|
||||
return new Result(pending.dimension(), null, pending.preparedMillis(), engine.millis());
|
||||
} catch (Throwable failure) {
|
||||
return new Result(pending.dimension(), failure, pending.preparedMillis(), engine.millis());
|
||||
}
|
||||
}
|
||||
|
||||
private static <P, L, E> void abandon(Steps<P, L, E> steps, L level, E engine, Throwable failure) {
|
||||
try {
|
||||
steps.abandon(level, engine, failure);
|
||||
} catch (Throwable cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static <P, L, E> void discard(Steps<P, L, E> steps, P prepared, Throwable failure) {
|
||||
try {
|
||||
steps.discard(prepared);
|
||||
} catch (Throwable cleanupFailure) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
return;
|
||||
}
|
||||
ModdedIrisLog.error("Iris could not release an unused dimension preparation", cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static int workerCount(int dimensions) {
|
||||
int processors = Runtime.getRuntime().availableProcessors();
|
||||
return Math.max(1, Math.min(dimensions, Math.max(MINIMUM_WORKERS, processors / 2)));
|
||||
}
|
||||
|
||||
private static <T> Timed<T> timed(Step<T> step) {
|
||||
long startedAt = System.nanoTime();
|
||||
try {
|
||||
T value = step.run();
|
||||
return new Timed<>(value, (System.nanoTime() - startedAt) / 1_000_000L);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
throw new CompletionException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable unwrap(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current instanceof CompletionException && current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static <T> T join(CompletableFuture<T> future) throws Throwable {
|
||||
try {
|
||||
return future.get();
|
||||
} catch (ExecutionException failure) {
|
||||
Throwable cause = failure.getCause();
|
||||
if (cause instanceof CompletionException wrapped && wrapped.getCause() != null) {
|
||||
throw wrapped.getCause();
|
||||
}
|
||||
throw cause == null ? failure : cause;
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while injecting Iris dimensions", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
interface Steps<P, L, E> {
|
||||
P prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) throws Exception;
|
||||
|
||||
L begin(ModdedDimensionRegistryStore.PersistentDimension dimension, P prepared) throws Exception;
|
||||
|
||||
E build(L level, P prepared) throws Exception;
|
||||
|
||||
void complete(L level, E engine) throws Exception;
|
||||
|
||||
void abandon(L level, E engine, Throwable failure);
|
||||
|
||||
void discard(P prepared);
|
||||
}
|
||||
|
||||
record Result(
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
Throwable failure,
|
||||
long preparedMillis,
|
||||
long builtMillis
|
||||
) {
|
||||
}
|
||||
|
||||
static final class Async<P, L, E> {
|
||||
private final ModdedDimensionRegistryStore.PersistentDimension dimension;
|
||||
private final Steps<P, L, E> steps;
|
||||
private final Executor serverThread;
|
||||
private final ExecutorService worker;
|
||||
private final CompletableFuture<Result> result = new CompletableFuture<>();
|
||||
private State state = State.UNSTARTED;
|
||||
private Timed<P> prepared;
|
||||
private L level;
|
||||
private CompletableFuture<Timed<E>> engine;
|
||||
|
||||
private Async(
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
Steps<P, L, E> steps,
|
||||
Executor serverThread
|
||||
) {
|
||||
this.dimension = Objects.requireNonNull(dimension, "dimension");
|
||||
this.steps = Objects.requireNonNull(steps, "steps");
|
||||
this.serverThread = Objects.requireNonNull(serverThread, "serverThread");
|
||||
this.worker = Executors.newSingleThreadExecutor(
|
||||
Thread.ofPlatform().name("Iris Dimension Create " + dimension.id()).daemon(true).factory());
|
||||
}
|
||||
|
||||
CompletableFuture<Result> result() {
|
||||
return result;
|
||||
}
|
||||
|
||||
synchronized void start() {
|
||||
if (state != State.UNSTARTED) {
|
||||
throw new IllegalStateException("Iris dimension injection for '" + dimension.id() + "' already started");
|
||||
}
|
||||
state = State.PREPARING;
|
||||
CompletableFuture.supplyAsync(() -> timed(() -> steps.prepare(dimension)), worker)
|
||||
.whenComplete(this::prepared);
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
Timed<P> unused = null;
|
||||
CompletableFuture<Timed<E>> building = null;
|
||||
L created = null;
|
||||
synchronized (this) {
|
||||
if (state == State.DONE || state == State.CANCELLED) {
|
||||
return;
|
||||
}
|
||||
if (state == State.PREPARED) {
|
||||
unused = prepared;
|
||||
} else if (state == State.BUILDING) {
|
||||
building = engine;
|
||||
created = level;
|
||||
}
|
||||
state = State.CANCELLED;
|
||||
}
|
||||
CancellationException cancellation = new CancellationException("Iris dimension injection for '"
|
||||
+ dimension.id() + "' was cancelled because the server is stopping");
|
||||
long preparedMillis = 0L;
|
||||
if (unused != null) {
|
||||
discard(steps, unused.value(), cancellation);
|
||||
preparedMillis = unused.millis();
|
||||
}
|
||||
if (building != null) {
|
||||
abandon(steps, created, awaitCancelledEngine(building, cancellation), cancellation);
|
||||
}
|
||||
done(new Result(dimension, cancellation, preparedMillis, 0L));
|
||||
}
|
||||
|
||||
private void prepared(Timed<P> value, Throwable failure) {
|
||||
synchronized (this) {
|
||||
if (state == State.CANCELLED) {
|
||||
if (value != null) {
|
||||
discard(steps, value.value(), null);
|
||||
}
|
||||
worker.shutdown();
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
state = State.DONE;
|
||||
} else {
|
||||
prepared = value;
|
||||
state = State.PREPARED;
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
done(new Result(dimension, unwrap(failure), 0L, 0L));
|
||||
return;
|
||||
}
|
||||
serverThread.execute(this::begin);
|
||||
}
|
||||
|
||||
private void begin() {
|
||||
Timed<P> ready;
|
||||
synchronized (this) {
|
||||
if (state != State.PREPARED) {
|
||||
return;
|
||||
}
|
||||
ready = prepared;
|
||||
state = State.BEGINNING;
|
||||
}
|
||||
L created;
|
||||
try {
|
||||
created = steps.begin(dimension, ready.value());
|
||||
} catch (Throwable failure) {
|
||||
discard(steps, ready.value(), failure);
|
||||
synchronized (this) {
|
||||
state = State.DONE;
|
||||
}
|
||||
done(new Result(dimension, failure, ready.millis(), 0L));
|
||||
return;
|
||||
}
|
||||
CompletableFuture<Timed<E>> building;
|
||||
synchronized (this) {
|
||||
level = created;
|
||||
state = State.BUILDING;
|
||||
building = CompletableFuture.supplyAsync(() -> timed(() -> steps.build(created, ready.value())), worker);
|
||||
engine = building;
|
||||
}
|
||||
building.whenComplete((Timed<E> built, Throwable failure) -> serverThread.execute(this::complete));
|
||||
}
|
||||
|
||||
private void complete() {
|
||||
CompletableFuture<Timed<E>> building;
|
||||
L created;
|
||||
long preparedMillis;
|
||||
synchronized (this) {
|
||||
if (state != State.BUILDING) {
|
||||
return;
|
||||
}
|
||||
state = State.DONE;
|
||||
building = engine;
|
||||
created = level;
|
||||
preparedMillis = prepared.millis();
|
||||
}
|
||||
Timed<E> built;
|
||||
try {
|
||||
built = join(building);
|
||||
} catch (Throwable failure) {
|
||||
abandon(steps, created, null, failure);
|
||||
done(new Result(dimension, failure, preparedMillis, 0L));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
steps.complete(created, built.value());
|
||||
done(new Result(dimension, null, preparedMillis, built.millis()));
|
||||
} catch (Throwable failure) {
|
||||
done(new Result(dimension, failure, preparedMillis, built.millis()));
|
||||
}
|
||||
}
|
||||
|
||||
private E awaitCancelledEngine(CompletableFuture<Timed<E>> building, CancellationException cancellation) {
|
||||
try {
|
||||
return building.get(CANCEL_BUILD_WAIT_SECONDS, TimeUnit.SECONDS).value();
|
||||
} catch (ExecutionException failure) {
|
||||
return null;
|
||||
} catch (TimeoutException failure) {
|
||||
ModdedIrisLog.error("Iris dimension engine for '{}' did not finish within {}s of shutdown; abandoning its level",
|
||||
dimension.id(), CANCEL_BUILD_WAIT_SECONDS);
|
||||
cancellation.addSuppressed(failure);
|
||||
return null;
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
cancellation.addSuppressed(interrupted);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void done(Result outcome) {
|
||||
worker.shutdown();
|
||||
result.complete(outcome);
|
||||
}
|
||||
|
||||
private enum State {
|
||||
UNSTARTED,
|
||||
PREPARING,
|
||||
PREPARED,
|
||||
BEGINNING,
|
||||
BUILDING,
|
||||
DONE,
|
||||
CANCELLED
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Step<T> {
|
||||
T run() throws Exception;
|
||||
}
|
||||
|
||||
private record Timed<T>(T value, long millis) {
|
||||
}
|
||||
|
||||
private record Preparation<P>(
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
CompletableFuture<Timed<P>> future
|
||||
) {
|
||||
}
|
||||
|
||||
private record Building<L, E>(
|
||||
int index,
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
long preparedMillis,
|
||||
L level,
|
||||
CompletableFuture<Timed<E>> engine
|
||||
) {
|
||||
}
|
||||
}
|
||||
+202
-137
@@ -107,150 +107,172 @@ public final class ModdedDimensionManager {
|
||||
) {
|
||||
return create(
|
||||
server,
|
||||
dimensionId,
|
||||
pack,
|
||||
packDimensionKey,
|
||||
seed,
|
||||
ModdedGenerationMode.TRANSIENT_STUDIO
|
||||
new DimensionRequest(dimensionId, pack, packDimensionKey, seed),
|
||||
() -> transientStudioPack(pack, packDimensionKey)
|
||||
);
|
||||
}
|
||||
|
||||
static Handle restorePersistent(
|
||||
static PendingInjection beginInjection(
|
||||
NativeModdedServer server,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
String packDimensionKey,
|
||||
long seed
|
||||
) {
|
||||
return create(
|
||||
server,
|
||||
dimensionId,
|
||||
pack,
|
||||
packDimensionKey,
|
||||
seed,
|
||||
ModdedGenerationMode.PERSISTENT_RESTORE
|
||||
);
|
||||
}
|
||||
|
||||
private static Handle create(
|
||||
NativeModdedServer server,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
String packDimensionKey,
|
||||
long seed,
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
ModdedGenerationHistoryStorage.ActivePack active,
|
||||
ModdedGenerationMode generationMode
|
||||
) {
|
||||
NativeDimensionRuntime serverAccess = requireAccess();
|
||||
DimensionRequest requested = new DimensionRequest(dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed());
|
||||
synchronized (LOCK) {
|
||||
String key = dimensionId;
|
||||
RuntimePack runtimePack = switch (generationMode) {
|
||||
case TRANSIENT_STUDIO -> transientStudioPack(pack, packDimensionKey);
|
||||
case PERSISTENT_CREATE -> persistentRuntimePack(
|
||||
ModdedGenerationHistoryStorage.createOrStage(
|
||||
server,
|
||||
key,
|
||||
pack,
|
||||
packDimensionKey,
|
||||
seed
|
||||
),
|
||||
generationMode
|
||||
);
|
||||
case PERSISTENT_RESTORE -> persistentRuntimePack(
|
||||
ModdedGenerationHistoryStorage.restoreOrAdopt(
|
||||
server,
|
||||
key,
|
||||
pack,
|
||||
packDimensionKey,
|
||||
seed
|
||||
),
|
||||
generationMode
|
||||
);
|
||||
};
|
||||
Handle existing = HANDLES.get(dimensionId);
|
||||
if (existing != null && serverAccess.hasLevel(server, key)) {
|
||||
Handle refreshed = new Handle(dimensionId, pack, packDimensionKey, seed, existing.level(), existing.generator());
|
||||
HANDLES.put(dimensionId, refreshed);
|
||||
return refreshed;
|
||||
Handle present = presentHandle(server, serverAccess, requested);
|
||||
if (present != null) {
|
||||
return new PendingInjection(present, true);
|
||||
}
|
||||
if (serverAccess.hasLevel(server, key)) {
|
||||
NativeWorld present = level(server, dimensionId);
|
||||
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(present, IrisModdedChunkGenerator.class);
|
||||
if (generator == null) {
|
||||
throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded");
|
||||
}
|
||||
ModdedIrisLog.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId);
|
||||
Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, present, generator);
|
||||
HANDLES.put(dimensionId, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
try {
|
||||
Handle handle = inject(
|
||||
server,
|
||||
serverAccess,
|
||||
dimensionId,
|
||||
key,
|
||||
pack,
|
||||
packDimensionKey,
|
||||
seed,
|
||||
runtimePack
|
||||
);
|
||||
HANDLES.put(dimensionId, handle);
|
||||
ModdedIrisLog.info("Iris injected runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed);
|
||||
return handle;
|
||||
RuntimePack runtimePack = persistentRuntimePack(active, generationMode);
|
||||
return new PendingInjection(createLevel(server, serverAccess, requested, runtimePack), false);
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris failed to inject runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed, e);
|
||||
throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e);
|
||||
throw injectionFailure(requested, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Handle createPersistent(NativeModdedServer server, String dimensionId, String pack, String packDimensionKey, long seed) {
|
||||
static Handle completeInjection(NativeModdedServer server, PendingInjection pending, Engine engine) {
|
||||
if (pending.present()) {
|
||||
return pending.handle();
|
||||
}
|
||||
NativeDimensionRuntime serverAccess = requireAccess();
|
||||
synchronized (LOCK) {
|
||||
ModdedDimensionRegistryStore.PersistentDimension previous =
|
||||
ModdedDimensionRegistryStore.get(server, dimensionId);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException("Iris dimension '" + dimensionId
|
||||
+ "' already exists. Use the Iris world update command to stage a pack update.");
|
||||
}
|
||||
ModdedDimensionRegistryStore.put(server, new ModdedDimensionRegistryStore.PersistentDimension(
|
||||
dimensionId,
|
||||
pack,
|
||||
packDimensionKey,
|
||||
seed
|
||||
));
|
||||
try {
|
||||
return create(
|
||||
server,
|
||||
dimensionId,
|
||||
pack,
|
||||
packDimensionKey,
|
||||
seed,
|
||||
ModdedGenerationMode.PERSISTENT_CREATE
|
||||
);
|
||||
return register(publishLevel(server, serverAccess, pending.handle(), engine));
|
||||
} catch (Throwable e) {
|
||||
try {
|
||||
ModdedDimensionRegistryStore.remove(server, dimensionId);
|
||||
} catch (Throwable rollbackFailure) {
|
||||
e.addSuppressed(rollbackFailure);
|
||||
}
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (e instanceof Error fatalError) {
|
||||
throw fatalError;
|
||||
}
|
||||
throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e);
|
||||
throw injectionFailure(DimensionRequest.of(pending.handle()), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void abandonInjection(NativeModdedServer server, PendingInjection pending, Engine engine, Throwable failure) {
|
||||
if (engine != null) {
|
||||
try {
|
||||
ModdedWorldEngines.closeUnregistered(engine);
|
||||
} catch (Throwable cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
ModdedIrisLog.error("Iris failed to close the abandoned engine for '{}'",
|
||||
pending.handle().dimensionId(), cleanupFailure);
|
||||
}
|
||||
}
|
||||
if (pending.present()) {
|
||||
return;
|
||||
}
|
||||
NativeDimensionRuntime serverAccess = requireAccess();
|
||||
Handle handle = pending.handle();
|
||||
synchronized (LOCK) {
|
||||
rollbackInjection(server, serverAccess, handle.dimensionId(), handle.level(), handle.generator(), false, failure);
|
||||
}
|
||||
}
|
||||
|
||||
static PendingInjection beginCreation(
|
||||
NativeModdedServer server,
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
ModdedGenerationHistoryStorage.ActivePack active
|
||||
) {
|
||||
synchronized (LOCK) {
|
||||
if (ModdedDimensionRegistryStore.get(server, dimension.id()) != null) {
|
||||
throw new IllegalStateException(alreadyExists(dimension.id()));
|
||||
}
|
||||
ModdedDimensionRegistryStore.put(server, dimension);
|
||||
try {
|
||||
return beginInjection(server, dimension, active, ModdedGenerationMode.PERSISTENT_CREATE);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
forgetCreation(server, dimension.id(), failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Handle completeCreation(NativeModdedServer server, PendingInjection pending, Engine engine) {
|
||||
try {
|
||||
return completeInjection(server, pending, engine);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
forgetCreation(server, pending.handle().dimensionId(), failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
static void abandonCreation(NativeModdedServer server, PendingInjection pending, Engine engine, Throwable failure) {
|
||||
abandonInjection(server, pending, engine, failure);
|
||||
forgetCreation(server, pending.handle().dimensionId(), failure);
|
||||
}
|
||||
|
||||
static String alreadyExists(String dimensionId) {
|
||||
return "Iris dimension '" + dimensionId
|
||||
+ "' already exists. Use the Iris world update command to stage a pack update.";
|
||||
}
|
||||
|
||||
private static void forgetCreation(NativeModdedServer server, String dimensionId, Throwable failure) {
|
||||
try {
|
||||
ModdedDimensionRegistryStore.remove(server, dimensionId);
|
||||
} catch (Throwable rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Handle create(NativeModdedServer server, DimensionRequest requested, RuntimePackSource source) {
|
||||
NativeDimensionRuntime serverAccess = requireAccess();
|
||||
synchronized (LOCK) {
|
||||
RuntimePack runtimePack = source.open();
|
||||
Handle present = presentHandle(server, serverAccess, requested);
|
||||
if (present != null) {
|
||||
return present;
|
||||
}
|
||||
try {
|
||||
return register(publishLevel(server, serverAccess,
|
||||
createLevel(server, serverAccess, requested, runtimePack), null));
|
||||
} catch (Throwable e) {
|
||||
throw injectionFailure(requested, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Handle presentHandle(NativeModdedServer server, NativeDimensionRuntime serverAccess, DimensionRequest requested) {
|
||||
String dimensionId = requested.dimensionId();
|
||||
Handle existing = HANDLES.get(dimensionId);
|
||||
if (existing != null && serverAccess.hasLevel(server, dimensionId)) {
|
||||
Handle refreshed = requested.bind(existing.level(), existing.generator());
|
||||
HANDLES.put(dimensionId, refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
if (!serverAccess.hasLevel(server, dimensionId)) {
|
||||
return null;
|
||||
}
|
||||
NativeWorld present = level(server, dimensionId);
|
||||
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(present, IrisModdedChunkGenerator.class);
|
||||
if (generator == null) {
|
||||
throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded");
|
||||
}
|
||||
ModdedIrisLog.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId);
|
||||
Handle handle = requested.bind(present, generator);
|
||||
HANDLES.put(dimensionId, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
private static Handle register(Handle handle) {
|
||||
HANDLES.put(handle.dimensionId(), handle);
|
||||
ModdedIrisLog.info("Iris injected runtime dimension '{}' (pack={} dim={} seed={})",
|
||||
handle.dimensionId(), handle.pack(), handle.packDimensionKey(), handle.seed());
|
||||
return handle;
|
||||
}
|
||||
|
||||
private static IllegalStateException injectionFailure(DimensionRequest requested, Throwable failure) {
|
||||
ModdedIrisLog.error("Iris failed to inject runtime dimension '{}' (pack={} dim={} seed={})",
|
||||
requested.dimensionId(), requested.pack(), requested.packDimensionKey(), requested.seed(), failure);
|
||||
return new IllegalStateException("Iris runtime dimension injection failed for " + requested.dimensionId(), failure);
|
||||
}
|
||||
|
||||
public static UpdateResult stagePersistentUpdate(
|
||||
NativeModdedServer server,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
String packDimensionKey
|
||||
) {
|
||||
ModdedDimensionCreations.requireNotCreating(dimensionId);
|
||||
synchronized (LOCK) {
|
||||
ModdedDimensionRegistryStore.PersistentDimension previous =
|
||||
ModdedDimensionRegistryStore.get(server, dimensionId);
|
||||
@@ -313,6 +335,7 @@ public final class ModdedDimensionManager {
|
||||
}
|
||||
|
||||
public static boolean removePersistent(NativeModdedServer server, String dimensionId, boolean wipeStorage) {
|
||||
ModdedDimensionCreations.requireNotCreating(dimensionId);
|
||||
ModdedDimensionRegistryStore.PersistentDimension previous =
|
||||
ModdedDimensionRegistryStore.get(server, dimensionId);
|
||||
ModdedDimensionRegistryStore.remove(server, dimensionId);
|
||||
@@ -392,16 +415,16 @@ public final class ModdedDimensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static Handle inject(
|
||||
private static Handle createLevel(
|
||||
NativeModdedServer server,
|
||||
NativeDimensionRuntime serverAccess,
|
||||
String dimensionId,
|
||||
String key,
|
||||
String pack,
|
||||
String packDimensionKey,
|
||||
long seed,
|
||||
DimensionRequest requested,
|
||||
RuntimePack runtimePack
|
||||
) {
|
||||
String dimensionId = requested.dimensionId();
|
||||
String pack = requested.pack();
|
||||
String packDimensionKey = requested.packDimensionKey();
|
||||
long seed = requested.seed();
|
||||
String typeRef = runtimePack.dimensionTypeKey();
|
||||
ModdedForcedDatapack.requireRegisteredDimensionType(typeRef,
|
||||
serverAccess.hasDimensionType(server, typeRef) ? Optional.of(Boolean.TRUE) : Optional.empty(),
|
||||
@@ -414,35 +437,54 @@ public final class ModdedDimensionManager {
|
||||
runtimePack.generationMode());
|
||||
return generator;
|
||||
}));
|
||||
NativeWorld level = created.world();
|
||||
IrisModdedChunkGenerator generator = created.generator();
|
||||
try {
|
||||
serverAccess.initialize(server, created.world());
|
||||
return requested.bind(created.world(), created.generator());
|
||||
} catch (Throwable error) {
|
||||
rollbackInjection(server, serverAccess, dimensionId, created.world(), created.generator(), false, error);
|
||||
throw rethrow(dimensionId, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static Handle publishLevel(
|
||||
NativeModdedServer server,
|
||||
NativeDimensionRuntime serverAccess,
|
||||
Handle created,
|
||||
Engine prepared
|
||||
) {
|
||||
NativeWorld level = created.level();
|
||||
IrisModdedChunkGenerator generator = created.generator();
|
||||
boolean loadEventStarted = false;
|
||||
try {
|
||||
serverAccess.initialize(server, level);
|
||||
if (prepared != null) {
|
||||
ModdedWorldEngines.installPrepared(level, prepared);
|
||||
}
|
||||
generator.bindLevel(level);
|
||||
Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, level, generator);
|
||||
NativeWorld previous = serverAccess.publish(server, level);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId
|
||||
throw new IllegalStateException("Iris cannot inject dimension '" + created.dimensionId()
|
||||
+ "': the level was registered concurrently");
|
||||
}
|
||||
serverAccess.addWorldBorderListener(server, level);
|
||||
loadEventStarted = true;
|
||||
ModdedEngineBootstrap.loader().fireDynamicLevelLoad(level);
|
||||
return handle;
|
||||
return created;
|
||||
} catch (Throwable error) {
|
||||
rollbackInjection(server, serverAccess, key, level, generator, loadEventStarted, error);
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (error instanceof Error fatalError) {
|
||||
throw fatalError;
|
||||
}
|
||||
throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, error);
|
||||
rollbackInjection(server, serverAccess, created.dimensionId(), level, generator, loadEventStarted, error);
|
||||
throw rethrow(created.dimensionId(), error);
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeException rethrow(String dimensionId, Throwable error) {
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
return runtimeException;
|
||||
}
|
||||
if (error instanceof Error fatalError) {
|
||||
throw fatalError;
|
||||
}
|
||||
return new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, error);
|
||||
}
|
||||
|
||||
private static void rollbackInjection(NativeModdedServer server, NativeDimensionRuntime serverAccess,
|
||||
String key, NativeWorld level,
|
||||
IrisModdedChunkGenerator generator, boolean loadEventStarted,
|
||||
@@ -511,19 +553,21 @@ public final class ModdedDimensionManager {
|
||||
.normalize();
|
||||
PackValidationRegistry.requireLoadable(packRoot);
|
||||
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
|
||||
String dimensionTypeRef;
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
|
||||
if (dimension == null) {
|
||||
throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '"
|
||||
+ dimensionKey + "'.");
|
||||
}
|
||||
dimensionTypeRef = ModdedWorldgenIds.dimensionTypeRef(pack, dimension);
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
return new RuntimePack(
|
||||
packRoot,
|
||||
dimensionKey,
|
||||
ModdedWorldgenIds.dimensionTypeRef(pack, dimensionKey),
|
||||
dimensionTypeRef,
|
||||
ModdedGenerationMode.TRANSIENT_STUDIO
|
||||
);
|
||||
}
|
||||
@@ -542,9 +586,30 @@ public final class ModdedDimensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface RuntimePackSource {
|
||||
RuntimePack open();
|
||||
}
|
||||
|
||||
private record DimensionRequest(String dimensionId, String pack, String packDimensionKey, long seed) {
|
||||
private static DimensionRequest of(Handle handle) {
|
||||
return new DimensionRequest(handle.dimensionId(), handle.pack(), handle.packDimensionKey(), handle.seed());
|
||||
}
|
||||
|
||||
private Handle bind(NativeWorld level, IrisModdedChunkGenerator generator) {
|
||||
return new Handle(dimensionId, pack, packDimensionKey, seed, level, generator);
|
||||
}
|
||||
}
|
||||
|
||||
public record Handle(String dimensionId, String pack, String packDimensionKey, long seed, NativeWorld level, IrisModdedChunkGenerator generator) {
|
||||
}
|
||||
|
||||
record PendingInjection(Handle handle, boolean present) {
|
||||
PendingInjection {
|
||||
handle = Objects.requireNonNull(handle, "handle");
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateResult(
|
||||
boolean pending,
|
||||
boolean restartRequired,
|
||||
|
||||
+15
@@ -28,6 +28,7 @@ import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeResourceKeys;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
@@ -126,6 +127,20 @@ final class ModdedDimensionMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
static void warmConfiguredPack(File packDirectory, String dimensionKey) {
|
||||
ConfiguredPack configured = ConfiguredPack.load(packDirectory, dimensionKey);
|
||||
collectConfiguredBiomeKeys(configured.dimension(), configured.data());
|
||||
}
|
||||
|
||||
record ConfiguredPack(IrisData data, IrisDimension dimension, DimensionMetadata metadata) {
|
||||
static ConfiguredPack load(File packDirectory, String dimensionKey) {
|
||||
IrisData data = IrisData.get(packDirectory);
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey);
|
||||
if (dimension == null) {
|
||||
throw new IllegalStateException("Iris dimension '" + dimensionKey
|
||||
+ "' missing from pack " + packDirectory.getAbsolutePath());
|
||||
}
|
||||
return new ConfiguredPack(data, dimension, dimensionMetadata(dimension));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -194,6 +194,11 @@ public final class ModdedDimensionRegistryStore {
|
||||
write(file, dimensions, List.of());
|
||||
}
|
||||
|
||||
static synchronized void writeWorldRoot(Path worldRoot, List<PersistentDimension> dimensions) {
|
||||
Path file = storeFile(worldRoot);
|
||||
write(file, dimensions, contents(file).unparsed());
|
||||
}
|
||||
|
||||
private static void write(Path file, List<PersistentDimension> dimensions, List<Object> unparsed) {
|
||||
JSONArray entries = new JSONArray();
|
||||
for (PersistentDimension dimension : dimensions) {
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public record ModdedDimensionReplacement(String levelKey, String pack, String packDimension, OptionalLong seed) {
|
||||
private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+");
|
||||
private static final Pattern PATH = Pattern.compile("[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*");
|
||||
private static final Pattern PACK = Pattern.compile("[A-Za-z0-9_.-]+");
|
||||
private static final Pattern PACK_DIMENSION = Pattern.compile("[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*");
|
||||
private static final String TYPE = "type";
|
||||
private static final String SEED = "seed";
|
||||
|
||||
public ModdedDimensionReplacement {
|
||||
Objects.requireNonNull(levelKey, "levelKey");
|
||||
Objects.requireNonNull(pack, "pack");
|
||||
Objects.requireNonNull(packDimension, "packDimension");
|
||||
Objects.requireNonNull(seed, "seed");
|
||||
}
|
||||
|
||||
public static ModdedDimensionReplacement parse(String levelKey, String type, OptionalLong seed) {
|
||||
String level = requireLevelKey(levelKey);
|
||||
String reference = type == null ? "" : type.trim();
|
||||
int packColon = reference.indexOf(':');
|
||||
String pack = packColon >= 0 ? reference.substring(0, packColon) : reference;
|
||||
String packDimension = packColon >= 0 ? reference.substring(packColon + 1) : reference;
|
||||
if (!PACK.matcher(pack).matches()
|
||||
|| !PACK_DIMENSION.matcher(packDimension).matches()
|
||||
|| pack.contains("..")
|
||||
|| packDimension.contains("..")) {
|
||||
throw new IllegalArgumentException("Invalid replacement type '" + type
|
||||
+ "' for " + level + ". Use pack or pack:dimensionKey.");
|
||||
}
|
||||
OptionalLong replacementSeed = Objects.requireNonNull(seed, "seed");
|
||||
if (replacementSeed.isPresent()) {
|
||||
requireUsableSeed(replacementSeed.getAsLong());
|
||||
}
|
||||
return new ModdedDimensionReplacement(level, pack, packDimension, replacementSeed);
|
||||
}
|
||||
|
||||
public static long requireUsableSeed(long seed) {
|
||||
if (seed == Long.MIN_VALUE) {
|
||||
throw new IllegalArgumentException("Seed " + Long.MIN_VALUE
|
||||
+ " is reserved on mod loaders; choose any other signed 64-bit integer");
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
public static ModdedDimensionReplacement fromJson(String levelKey, Object value) {
|
||||
if (!(value instanceof JSONObject entry)) {
|
||||
throw new IllegalArgumentException("the replacement must be an object with a \"type\" and an optional \"seed\"");
|
||||
}
|
||||
Object type = entry.opt(TYPE);
|
||||
if (!(type instanceof String typeRef)) {
|
||||
throw new IllegalArgumentException("the replacement \"type\" must be a pack or pack:dimensionKey string");
|
||||
}
|
||||
if (!entry.has(SEED) || entry.isNull(SEED)) {
|
||||
return parse(levelKey, typeRef, OptionalLong.empty());
|
||||
}
|
||||
Object seed = entry.opt(SEED);
|
||||
if (!(seed instanceof Integer) && !(seed instanceof Long)) {
|
||||
throw new IllegalArgumentException("the replacement \"seed\" must be a signed 64-bit integer");
|
||||
}
|
||||
return parse(levelKey, typeRef, OptionalLong.of(((Number) seed).longValue()));
|
||||
}
|
||||
|
||||
public JSONObject toJson() {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put(TYPE, packRef());
|
||||
if (seed.isPresent()) {
|
||||
json.put(SEED, seed.getAsLong());
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
public String packRef() {
|
||||
return pack.equals(packDimension) ? pack : pack + ":" + packDimension;
|
||||
}
|
||||
|
||||
public ModdedDimensionReplacement withSeed(long replacementSeed) {
|
||||
return new ModdedDimensionReplacement(levelKey, pack, packDimension, OptionalLong.of(replacementSeed));
|
||||
}
|
||||
|
||||
public ModdedDimensionReplacement withPack(String canonicalPack, String canonicalPackDimension) {
|
||||
return new ModdedDimensionReplacement(levelKey, canonicalPack, canonicalPackDimension, seed);
|
||||
}
|
||||
|
||||
public String namespace() {
|
||||
return levelKey.substring(0, levelKey.indexOf(':'));
|
||||
}
|
||||
|
||||
public String path() {
|
||||
return levelKey.substring(levelKey.indexOf(':') + 1);
|
||||
}
|
||||
|
||||
public Path storageFolder(Path worldRoot) {
|
||||
return storageFolder(worldRoot, levelKey);
|
||||
}
|
||||
|
||||
public static Path storageFolder(Path worldRoot, String levelKey) {
|
||||
String level = requireLevelKey(levelKey);
|
||||
int colon = level.indexOf(':');
|
||||
Path folder = worldRoot.resolve("dimensions").resolve(level.substring(0, colon));
|
||||
for (String segment : level.substring(colon + 1).split("/")) {
|
||||
folder = folder.resolve(segment);
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
public static String requireLevelKey(String levelKey) {
|
||||
String level = levelKey == null ? "" : levelKey.trim();
|
||||
int colon = level.indexOf(':');
|
||||
if (colon <= 0
|
||||
|| !NAMESPACE.matcher(level.substring(0, colon)).matches()
|
||||
|| !PATH.matcher(level.substring(colon + 1)).matches()
|
||||
|| level.contains("..")) {
|
||||
throw new IllegalArgumentException("Invalid dimension key '" + levelKey
|
||||
+ "'. Use namespace:path, for example examplemod:skylands.");
|
||||
}
|
||||
return level;
|
||||
}
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogLevelStem;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogStore;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public final class ModdedDimensionReplacements {
|
||||
public static final String IRIS_GENERATOR = "irisworldgen:iris";
|
||||
private static final Set<String> VANILLA_LEVELS = Set.of("minecraft:overworld", "minecraft:the_nether",
|
||||
"minecraft:the_end");
|
||||
private static volatile RecordedCatalog recordedCatalog;
|
||||
private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed";
|
||||
private static final String PLACEHOLDER_BIOME = "minecraft:plains";
|
||||
private static final String DIMENSIONS = "dimensions";
|
||||
private static final String JSON = ".json";
|
||||
|
||||
private ModdedDimensionReplacements() {
|
||||
}
|
||||
|
||||
public static String levelStemJson(String dimensionTypeKey, ModdedDimensionReplacement replacement) {
|
||||
JSONObject biomeSource = new JSONObject();
|
||||
biomeSource.put("type", PLACEHOLDER_BIOME_SOURCE);
|
||||
biomeSource.put("biome", PLACEHOLDER_BIOME);
|
||||
JSONObject generator = new JSONObject();
|
||||
generator.put("type", IRIS_GENERATOR);
|
||||
generator.put("biome_source", biomeSource);
|
||||
generator.put("dimension", replacement.packRef());
|
||||
JSONObject stem = new JSONObject();
|
||||
stem.put("type", Objects.requireNonNull(dimensionTypeKey, "dimensionTypeKey"));
|
||||
stem.put("generator", generator);
|
||||
return stem.toString(2) + "\n";
|
||||
}
|
||||
|
||||
public static Path writeLevelStem(Path datapackRoot, ModdedDimensionReplacement replacement, String dimensionTypeKey)
|
||||
throws IOException {
|
||||
Path output = datapackRoot.resolve("data").resolve(replacement.namespace()).resolve("dimension");
|
||||
String[] segments = replacement.path().split("/");
|
||||
for (int index = 0; index < segments.length - 1; index++) {
|
||||
output = output.resolve(segments[index]);
|
||||
}
|
||||
output = output.resolve(segments[segments.length - 1] + ".json");
|
||||
Files.createDirectories(output.getParent());
|
||||
Files.writeString(output, levelStemJson(dimensionTypeKey, replacement), StandardCharsets.UTF_8);
|
||||
return output;
|
||||
}
|
||||
|
||||
public static void writeLevelStems(Path datapackRoot, Map<String, ModdedDimensionReplacement> configured,
|
||||
StagedTypes staged, Path worldRoot, ReplaceableLevels levels) throws IOException {
|
||||
for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) {
|
||||
if (!levels.replaceable(replacement.levelKey())) {
|
||||
ModdedIrisLog.error(unreplaceableReason(replacement.levelKey(), levels)
|
||||
+ " Iris writes no dimension override for it.");
|
||||
continue;
|
||||
}
|
||||
String typeRef = staged.typeRef(replacement, worldRoot);
|
||||
if (typeRef == null) {
|
||||
skipUnstaged(replacement, staged, worldRoot);
|
||||
continue;
|
||||
}
|
||||
writeLevelStem(datapackRoot, replacement, typeRef);
|
||||
ModdedIrisLog.info("Iris dimension replacement staged: {} generates from {} with dimension type {}",
|
||||
replacement.levelKey(), replacement.packRef(), typeRef);
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, ModdedDimensionReplacement> configured() throws IOException {
|
||||
return canonical(ModdedModConfig.get().dimensionReplacements(), IrisPlatforms.get().packsFolderNoCreate().toPath());
|
||||
}
|
||||
|
||||
public static Map<String, ModdedDimensionReplacement> stageable(Map<String, ModdedDimensionReplacement> configured,
|
||||
ReplaceableLevels levels) {
|
||||
Map<String, ModdedDimensionReplacement> stageable = new TreeMap<>();
|
||||
for (ModdedDimensionReplacement replacement : configured.values()) {
|
||||
if (levels.replaceable(replacement.levelKey())) {
|
||||
stageable.put(replacement.levelKey(), replacement);
|
||||
}
|
||||
}
|
||||
return Map.copyOf(stageable);
|
||||
}
|
||||
|
||||
public static ReplaceableLevels replaceableLevels(Path worldRoot, Map<String, ModdedDimensionReplacement> configured)
|
||||
throws IOException {
|
||||
if (configured.isEmpty()) {
|
||||
return ReplaceableLevels.unknown();
|
||||
}
|
||||
return replaceableLevels(worldRoot);
|
||||
}
|
||||
|
||||
public static ReplaceableLevels replaceableLevels(Path worldRoot) throws IOException {
|
||||
Set<String> applied = worldRoot == null
|
||||
? Set.of()
|
||||
: ModdedReplacementReconciler.readState(worldRoot).applied().keySet();
|
||||
Optional<WorldgenCatalog> catalog = ModdedWorldgenCatalogs.latest();
|
||||
if (catalog.isEmpty()) {
|
||||
catalog = recordedCatalog();
|
||||
}
|
||||
return catalog.isEmpty()
|
||||
? ReplaceableLevels.unknown()
|
||||
: ReplaceableLevels.of(catalog.get().levelStems(), applied);
|
||||
}
|
||||
|
||||
public static String unreplaceableReason(String levelKey, ReplaceableLevels levels) {
|
||||
if (!levels.known()) {
|
||||
return "Iris refuses to replace " + levelKey + ": it has no record of which dimensions this server loads"
|
||||
+ " from level stems yet. Start the server once so Iris can read the dimension registry.";
|
||||
}
|
||||
return "Iris refuses to replace " + levelKey + ": it is not a dimension the server loads from a level stem;"
|
||||
+ " a mod creates it while the server runs, so its data stays untouched.";
|
||||
}
|
||||
|
||||
private static Optional<WorldgenCatalog> recordedCatalog() {
|
||||
Path file = ModdedWorldgenCatalogs.catalogFile();
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
long modified = Files.getLastModifiedTime(file).toMillis();
|
||||
RecordedCatalog cached = recordedCatalog;
|
||||
if (cached != null && cached.file().equals(file) && cached.modified() == modified) {
|
||||
return Optional.of(cached.catalog());
|
||||
}
|
||||
WorldgenCatalog catalog = WorldgenCatalogStore.read(file);
|
||||
recordedCatalog = new RecordedCatalog(file, modified, catalog);
|
||||
return Optional.of(catalog);
|
||||
} catch (IOException | RuntimeException unreadable) {
|
||||
ModdedIrisLog.error("Iris could not read the recorded worldgen catalog at {}; only the vanilla dimensions are replaceable until the next start records it",
|
||||
file, unreadable);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public static OptionalLong loadedSeed(String levelKey) {
|
||||
ModdedModConfig config = ModdedModConfig.loaded();
|
||||
if (config == null) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
ModdedDimensionReplacement replacement = config.dimensionReplacements().get(levelKey);
|
||||
return replacement == null ? OptionalLong.empty() : replacement.seed();
|
||||
}
|
||||
|
||||
static Map<String, ModdedDimensionReplacement> canonical(Map<String, ModdedDimensionReplacement> configured,
|
||||
Path packsRoot) throws IOException {
|
||||
Map<String, ModdedDimensionReplacement> canonical = new TreeMap<>();
|
||||
for (ModdedDimensionReplacement replacement : configured.values()) {
|
||||
canonical.put(replacement.levelKey(), canonical(replacement, packsRoot));
|
||||
}
|
||||
return Map.copyOf(canonical);
|
||||
}
|
||||
|
||||
public static ModdedDimensionReplacement canonical(ModdedDimensionReplacement replacement, Path packsRoot)
|
||||
throws IOException {
|
||||
Path pack = packsRoot.resolve(replacement.pack());
|
||||
if (!Files.isDirectory(pack)) {
|
||||
return replacement;
|
||||
}
|
||||
String packName = sameIgnoringCase(pack.toRealPath().getFileName().toString(), replacement.pack());
|
||||
Path dimensions = pack.resolve(DIMENSIONS);
|
||||
Path dimensionFile = dimensions.resolve(replacement.packDimension() + JSON);
|
||||
String dimension = replacement.packDimension();
|
||||
if (Files.isRegularFile(dimensionFile)) {
|
||||
String relative = dimensions.toRealPath().relativize(dimensionFile.toRealPath()).toString()
|
||||
.replace(File.separatorChar, '/');
|
||||
dimension = sameIgnoringCase(relative.substring(0, relative.length() - JSON.length()), replacement.packDimension());
|
||||
}
|
||||
return replacement.withPack(packName, dimension);
|
||||
}
|
||||
|
||||
public static boolean installed(ModdedDimensionReplacement replacement, Path packsRoot) {
|
||||
return Files.isRegularFile(packsRoot.resolve(replacement.pack()).resolve(DIMENSIONS)
|
||||
.resolve(replacement.packDimension() + JSON));
|
||||
}
|
||||
|
||||
public static boolean matches(ModdedDimensionReplacement applied, ModdedDimensionReplacement configured) {
|
||||
if (applied == null || configured == null) {
|
||||
return applied == configured;
|
||||
}
|
||||
if (!applied.packRef().equals(configured.packRef())) {
|
||||
return false;
|
||||
}
|
||||
return configured.seed().isEmpty() || configured.seed().equals(applied.seed());
|
||||
}
|
||||
|
||||
public static Set<String> changedLevels(Map<String, ModdedDimensionReplacement> applied,
|
||||
Map<String, ModdedDimensionReplacement> configured) {
|
||||
Set<String> levels = new TreeSet<>(applied.keySet());
|
||||
levels.addAll(configured.keySet());
|
||||
Set<String> changed = new TreeSet<>();
|
||||
for (String level : levels) {
|
||||
if (!matches(applied.get(level), configured.get(level))) {
|
||||
changed.add(level);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static void skipUnstaged(ModdedDimensionReplacement replacement, StagedTypes staged, Path worldRoot)
|
||||
throws IOException {
|
||||
String reason = "pack '" + replacement.pack() + "' dimension '" + replacement.packDimension()
|
||||
+ "' is not installed or failed validation" + caseHint(replacement, staged);
|
||||
if (worldRoot != null && ModdedReplacementReconciler.hasReplacementRecord(worldRoot, replacement.levelKey())) {
|
||||
throw new IOException("Iris refused to stage the replacement of " + replacement.levelKey() + ": " + reason
|
||||
+ ", and the level already has an applied replacement or Iris generation history. Restore the pack, or remove the "
|
||||
+ replacement.levelKey() + " entry from dimensionReplacements in modded.json to hand the level back to its own generator.");
|
||||
}
|
||||
ModdedIrisLog.error("Iris skipped the replacement of {}: {}, so the dimension keeps its own generator and the replacement stays unapplied",
|
||||
replacement.levelKey(), reason);
|
||||
}
|
||||
|
||||
private static String caseHint(ModdedDimensionReplacement replacement, StagedTypes staged) {
|
||||
String wanted = replacement.pack() + ":" + replacement.packDimension();
|
||||
for (String installed : staged.installed().keySet()) {
|
||||
if (installed.equalsIgnoreCase(wanted)) {
|
||||
return "; pack names and dimension keys are case-sensitive, the installed dimension is " + installed;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String sameIgnoringCase(String actual, String configured) {
|
||||
return actual.equalsIgnoreCase(configured) ? actual : configured;
|
||||
}
|
||||
|
||||
public static String fingerprint(Map<String, ModdedDimensionReplacement> configured) {
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(configured).entrySet()) {
|
||||
canonical.append(entry.getKey()).append('=').append(entry.getValue().packRef()).append('\n');
|
||||
}
|
||||
return canonical.toString();
|
||||
}
|
||||
|
||||
private record RecordedCatalog(Path file, long modified, WorldgenCatalog catalog) {
|
||||
}
|
||||
|
||||
public record ReplaceableLevels(boolean known, Map<String, String> stemGenerators, Set<String> applied) {
|
||||
public ReplaceableLevels {
|
||||
stemGenerators = Map.copyOf(stemGenerators);
|
||||
applied = Set.copyOf(applied);
|
||||
}
|
||||
|
||||
public static ReplaceableLevels unknown() {
|
||||
return new ReplaceableLevels(false, Map.of(), Set.of());
|
||||
}
|
||||
|
||||
public static ReplaceableLevels of(List<CatalogLevelStem> stems, Set<String> applied) {
|
||||
Map<String, String> generators = new TreeMap<>();
|
||||
for (CatalogLevelStem stem : stems) {
|
||||
generators.put(stem.key(), stem.generatorType());
|
||||
}
|
||||
return new ReplaceableLevels(true, generators, applied);
|
||||
}
|
||||
|
||||
public boolean replaceable(String levelKey) {
|
||||
if (VANILLA_LEVELS.contains(levelKey)) {
|
||||
return true;
|
||||
}
|
||||
String generator = stemGenerators.get(levelKey);
|
||||
if (generator == null) {
|
||||
return false;
|
||||
}
|
||||
return !IRIS_GENERATOR.equals(generator) || applied.contains(levelKey);
|
||||
}
|
||||
}
|
||||
|
||||
public record StagedTypes(Map<String, String> installed, Map<Path, String> retained) {
|
||||
public static StagedTypes create() {
|
||||
return new StagedTypes(new LinkedHashMap<>(), new LinkedHashMap<>());
|
||||
}
|
||||
|
||||
String typeRef(ModdedDimensionReplacement replacement, Path worldRoot) {
|
||||
if (worldRoot != null) {
|
||||
String retainedType = retained.get(replacement.storageFolder(worldRoot).toAbsolutePath().normalize());
|
||||
if (retainedType != null) {
|
||||
return retainedType;
|
||||
}
|
||||
}
|
||||
return installed.get(replacement.pack() + ":" + replacement.packDimension());
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -43,6 +43,10 @@ public final class ModdedDimensionStorage {
|
||||
private ModdedDimensionStorage() {
|
||||
}
|
||||
|
||||
static List<String> chunkDataFolders() {
|
||||
return CHUNK_DATA_FOLDERS;
|
||||
}
|
||||
|
||||
public static File storageFolder(NativeModdedServer server, String dimension) {
|
||||
return server.dimensionFolder(dimension).toFile();
|
||||
}
|
||||
|
||||
+25
-3
@@ -58,6 +58,7 @@ import art.arcane.iris.generation.block.BlockDataMergeSupport;
|
||||
import art.arcane.iris.structure.object.IrisObjectRotation;
|
||||
import art.arcane.iris.generation.block.TileData;
|
||||
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
|
||||
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs;
|
||||
import art.arcane.iris.modded.command.IrisModdedCommands;
|
||||
import art.arcane.iris.modded.command.ModdedGuiHost;
|
||||
import art.arcane.iris.modded.command.ModdedObjectUndo;
|
||||
@@ -144,6 +145,7 @@ public final class ModdedEngineBootstrap {
|
||||
}
|
||||
IrisModdedCommands.openDownloadAdmission();
|
||||
ModdedStartup.prepareForStartup();
|
||||
ModdedStartup.prepareDimensionRestore(server);
|
||||
IrisModdedChunkGenerator.startGenPool();
|
||||
bindWorldGenerators(server);
|
||||
services().enableAll();
|
||||
@@ -163,6 +165,13 @@ public final class ModdedEngineBootstrap {
|
||||
finishPreviousServer("server start");
|
||||
captureInitialSpawn(server);
|
||||
ModdedStartup.prepareForStartup();
|
||||
ModdedStartup.prepareDimensionRestore(server);
|
||||
}
|
||||
|
||||
public static void levelsReady(NativeModdedServer server) {
|
||||
server.refreshWorlds();
|
||||
bindWorldGenerators(server);
|
||||
ModdedStartup.reinjectPersistentDimensions(server);
|
||||
}
|
||||
|
||||
public static void serverStarted(NativeModdedServer server) {
|
||||
@@ -171,8 +180,15 @@ public final class ModdedEngineBootstrap {
|
||||
server.refreshWorlds();
|
||||
bindWorldGenerators(server);
|
||||
ModdedStartup.runOnce(server);
|
||||
reconcileSpawn(server);
|
||||
boolean replacedOverworld = !loader().clientEnvironment() && ModdedReplacementEntry.prepare(server);
|
||||
reconcileSpawn(server, replacedOverworld);
|
||||
if (!loader().clientEnvironment()) {
|
||||
ModdedReplacementReconciler.recordApplied(server);
|
||||
ModdedReplacementReconciler.stopUnpublishedEngines(server);
|
||||
ModdedReplacementEntry.retireIfComplete();
|
||||
}
|
||||
ModdedWorldCheck.serverStarted(server);
|
||||
ModdedWorldgenCatalogs.capture(server);
|
||||
}
|
||||
|
||||
public static void levelLoaded(NativeWorld world) {
|
||||
@@ -203,6 +219,7 @@ public final class ModdedEngineBootstrap {
|
||||
stoppedPending = true;
|
||||
Throwable failure = null;
|
||||
failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads);
|
||||
failure = runStopStage(failure, "dimension creations", ModdedDimensionCreations::cancelAll);
|
||||
failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
|
||||
failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop);
|
||||
failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown);
|
||||
@@ -270,6 +287,7 @@ public final class ModdedEngineBootstrap {
|
||||
Throwable failure = null;
|
||||
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
|
||||
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
|
||||
failure = runStopStage(failure, "replacement entry guard", ModdedReplacementEntry::clear);
|
||||
failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear);
|
||||
ModdedScheduler scheduler = schedulerOrNull();
|
||||
if (scheduler != null) {
|
||||
@@ -325,7 +343,7 @@ public final class ModdedEngineBootstrap {
|
||||
spawnCaptureServer = server;
|
||||
}
|
||||
|
||||
private static void reconcileSpawn(NativeModdedServer server) {
|
||||
private static void reconcileSpawn(NativeModdedServer server, boolean replacedOverworld) {
|
||||
NativeServerSpawn spawn = new NativeServerSpawn(server);
|
||||
NativeServerSpawn.Respawn current = spawn.current();
|
||||
if (current == null || current.world() == null) {
|
||||
@@ -337,7 +355,8 @@ public final class ModdedEngineBootstrap {
|
||||
}
|
||||
String dimensionId = level.name();
|
||||
boolean studio = dimensionId.startsWith("irisworldgen:studio_");
|
||||
if (!shouldReconcileSpawn(initialSpawnWasDefault, studio, current.position().x(), current.position().z())) {
|
||||
if (!shouldReconcileSpawn(initialSpawnWasDefault || replacedOverworld, studio,
|
||||
current.position().x(), current.position().z())) {
|
||||
return;
|
||||
}
|
||||
int surfaceY = spawn.surfaceAtOrigin(level);
|
||||
@@ -367,6 +386,9 @@ public final class ModdedEngineBootstrap {
|
||||
selfTest(moddedLoader.getClass().getClassLoader());
|
||||
bind();
|
||||
IrisLanguage.initialize();
|
||||
if (!moddedLoader.clientEnvironment()) {
|
||||
ModdedReplacementReconciler.reconcileEarly();
|
||||
}
|
||||
ModdedStartup.prefetchStartupDatapack();
|
||||
if (!moddedLoader.clientEnvironment()) {
|
||||
MainWorldService.reconcileEarly();
|
||||
|
||||
+85
-24
@@ -324,6 +324,10 @@ public final class ModdedForcedDatapack {
|
||||
false
|
||||
);
|
||||
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
|
||||
Map<String, ModdedDimensionReplacement> replacements = ModdedDimensionReplacements.configured();
|
||||
digest.update(ModdedDimensionReplacements.fingerprint(ModdedDimensionReplacements.stageable(replacements,
|
||||
ModdedDimensionReplacements.replaceableLevels(worldRootOrNull(), replacements)))
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
@@ -333,15 +337,21 @@ public final class ModdedForcedDatapack {
|
||||
|
||||
int packCount = 0;
|
||||
KList<String> presetIds = new KList<>();
|
||||
ModdedDimensionReplacements.StagedTypes stagedDimensionTypes = ModdedDimensionReplacements.StagedTypes.create();
|
||||
Path worldRoot = worldRootOrNull();
|
||||
Set<PackSelection> legacySelections = unadoptedSelections(worldRoot);
|
||||
List<File> packRoots = compilationPackRoots(worldRoot);
|
||||
for (File pack : packRoots) {
|
||||
PackStageContext context = packStageContext(pack.toPath(), legacySelections);
|
||||
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds)) {
|
||||
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds, stagedDimensionTypes)) {
|
||||
packCount++;
|
||||
}
|
||||
}
|
||||
if (!ModdedEngineBootstrap.loader().clientEnvironment()) {
|
||||
Map<String, ModdedDimensionReplacement> replacements = ModdedDimensionReplacements.configured();
|
||||
ModdedDimensionReplacements.writeLevelStems(stagingDirectory, replacements, stagedDimensionTypes, worldRoot,
|
||||
ModdedDimensionReplacements.replaceableLevels(worldRoot, replacements));
|
||||
}
|
||||
|
||||
IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer);
|
||||
writePackMeta(stagingDirectory);
|
||||
@@ -362,7 +372,8 @@ public final class ModdedForcedDatapack {
|
||||
|
||||
private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory,
|
||||
Map<String, KSet<String>> seenBiomes,
|
||||
KList<String> presetIds) throws IOException {
|
||||
KList<String> presetIds,
|
||||
ModdedDimensionReplacements.StagedTypes stagedDimensionTypes) throws IOException {
|
||||
File sourcePack = context.packRoot().toFile();
|
||||
PackValidationResult validation;
|
||||
try {
|
||||
@@ -392,11 +403,12 @@ public final class ModdedForcedDatapack {
|
||||
stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-");
|
||||
Map<String, KSet<String>> packBiomes = new LinkedHashMap<>();
|
||||
KList<String> packPresetIds = new KList<>();
|
||||
ModdedDimensionReplacements.StagedTypes packDimensionTypes = ModdedDimensionReplacements.StagedTypes.create();
|
||||
KList<File> packFolders = new KList<>();
|
||||
packFolders.add(packStagingDirectory.toFile());
|
||||
boolean installed;
|
||||
try {
|
||||
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds);
|
||||
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds, packDimensionTypes);
|
||||
} catch (Throwable installationFailure) {
|
||||
if (context.required()) {
|
||||
throw requiredPackFailure(context, "datapack serialization", installationFailure);
|
||||
@@ -414,6 +426,8 @@ public final class ModdedForcedDatapack {
|
||||
mergeDirectory(packStagingDirectory, stagingDirectory);
|
||||
mergeBiomes(seenBiomes, packBiomes);
|
||||
presetIds.addAll(packPresetIds);
|
||||
stagedDimensionTypes.installed().putAll(packDimensionTypes.installed());
|
||||
stagedDimensionTypes.retained().putAll(packDimensionTypes.retained());
|
||||
return true;
|
||||
} finally {
|
||||
try {
|
||||
@@ -454,6 +468,8 @@ public final class ModdedForcedDatapack {
|
||||
Files.createDirectories(destination);
|
||||
} else if (!Files.exists(destination)) {
|
||||
Files.copy(source, destination);
|
||||
} else if (Files.isRegularFile(destination) && ModdedDatapackTagMerge.isTag(relative)) {
|
||||
ModdedDatapackTagMerge.merge(source, destination);
|
||||
} else if (!Files.isRegularFile(destination) || Files.mismatch(source, destination) != -1L) {
|
||||
throw new IOException("Conflicting Iris datapack output at " + destination + ".");
|
||||
}
|
||||
@@ -470,7 +486,8 @@ public final class ModdedForcedDatapack {
|
||||
|
||||
private static boolean installPack(PackStageContext context, IDataFixer fixer, KList<File> folders,
|
||||
Map<String, KSet<String>> seenBiomes,
|
||||
KList<String> presetIds) throws IOException {
|
||||
KList<String> presetIds,
|
||||
ModdedDimensionReplacements.StagedTypes dimensionTypes) throws IOException {
|
||||
File packFolder = context.packRoot().toFile();
|
||||
String packName = context.packName();
|
||||
File dimensionsDirectory = new File(packFolder, "dimensions");
|
||||
@@ -504,18 +521,30 @@ public final class ModdedForcedDatapack {
|
||||
biomesForNamespace(seenBiomes, "iris"),
|
||||
context
|
||||
);
|
||||
writeDimensionType(
|
||||
folders,
|
||||
fixer,
|
||||
dimension,
|
||||
context.dimensionTypeRef(dimensionKey),
|
||||
context.emitLegacyDimensionTypeAlias(),
|
||||
context
|
||||
);
|
||||
String typeRef = context.dimensionTypeRef(dimensionKey);
|
||||
if (dimension.hasBorrowedDimensionType()) {
|
||||
typeRef = requireBorrowedType(context, dimension, typeRef);
|
||||
} else {
|
||||
requireScopedType(context, typeRef);
|
||||
writeDimensionType(
|
||||
folders,
|
||||
fixer,
|
||||
dimension,
|
||||
typeRef,
|
||||
context.emitLegacyDimensionTypeAlias(),
|
||||
context
|
||||
);
|
||||
}
|
||||
if (context.retainedDimensionRoot() != null) {
|
||||
dimensionTypes.retained().put(context.retainedDimensionRoot(), typeRef);
|
||||
}
|
||||
if (context.emitPresets()) {
|
||||
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
|
||||
writeWorldPreset(folders, packName, dimensionKey, presetRef);
|
||||
presetIds.add(presetRef);
|
||||
dimensionTypes.installed().put(packName + ":" + dimensionKey, typeRef);
|
||||
if (!dimension.hasBorrowedDimensionType()) {
|
||||
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
|
||||
writeWorldPreset(folders, packName, dimensionKey, presetRef);
|
||||
presetIds.add(presetRef);
|
||||
}
|
||||
}
|
||||
installedDimensions++;
|
||||
}
|
||||
@@ -542,6 +571,22 @@ public final class ModdedForcedDatapack {
|
||||
+ " (datapack source injection failed; see the Iris boot ERROR), so a restart alone will not register it.")));
|
||||
}
|
||||
|
||||
private static void requireScopedType(PackStageContext context, String stagedTypeRef) throws IOException {
|
||||
if (context.required() && ModdedWorldgenIds.scopedDimensionType(stagedTypeRef).isEmpty()) {
|
||||
throw new IOException("Retained Iris dimension type key is not scoped: " + stagedTypeRef);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireBorrowedType(PackStageContext context, IrisDimension dimension, String stagedTypeRef)
|
||||
throws IOException {
|
||||
String borrowed = dimension.getBorrowedDimensionType().trim();
|
||||
if (context.required() && !borrowed.equals(stagedTypeRef)) {
|
||||
throw new IOException("Retained Iris generation pack at " + context.packRoot() + " borrows dimension type "
|
||||
+ borrowed + ", but its generation history records " + stagedTypeRef + ".");
|
||||
}
|
||||
return borrowed;
|
||||
}
|
||||
|
||||
private static void writeWorldPreset(KList<File> folders, String packName, String dimensionKey,
|
||||
String presetRef) throws IOException {
|
||||
String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey;
|
||||
@@ -789,14 +834,16 @@ public final class ModdedForcedDatapack {
|
||||
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
|
||||
RetainedPack retained = retainedPack(normalizedRoot);
|
||||
if (retained != null) {
|
||||
ModdedWorldgenIds.ScopedDimension identity = ModdedWorldgenIds
|
||||
.scopedDimensionType(retained.epoch().dimensionContract().dimensionTypeKey())
|
||||
.orElseThrow(() -> new IOException("Retained Iris dimension type key is not scoped: "
|
||||
+ retained.epoch().dimensionContract().dimensionTypeKey()));
|
||||
if (!identity.dimension().equals(retained.epoch().dimensionContract().dimensionKey())) {
|
||||
GenerationEpoch.DimensionContract contract = retained.epoch().dimensionContract();
|
||||
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
|
||||
if (scoped.isEmpty()) {
|
||||
return PackStageContext.retained(normalizedRoot, retainedPackName(retained.epoch(), normalizedRoot), retained);
|
||||
}
|
||||
ModdedWorldgenIds.ScopedDimension identity = scoped.get();
|
||||
if (!identity.dimension().equals(contract.dimensionKey())) {
|
||||
throw new IOException("Retained Iris dimension type identity does not match its epoch contract.");
|
||||
}
|
||||
return PackStageContext.retained(normalizedRoot, identity.pack(), retained.epoch());
|
||||
return PackStageContext.retained(normalizedRoot, identity.pack(), retained);
|
||||
}
|
||||
|
||||
String packName = normalizedRoot.getFileName().toString();
|
||||
@@ -809,6 +856,16 @@ public final class ModdedForcedDatapack {
|
||||
return PackStageContext.installed(normalizedRoot, packName, legacyDimensions);
|
||||
}
|
||||
|
||||
static String retainedPackName(GenerationEpoch epoch, Path packRoot) throws IOException {
|
||||
String dimensionKey = epoch.dimensionContract().dimensionKey();
|
||||
Set<String> packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), dimensionKey);
|
||||
if (packs.size() > 1) {
|
||||
throw new IOException("Retained Iris generation pack at " + packRoot
|
||||
+ " records scoped biome aliases from several packs " + packs + ".");
|
||||
}
|
||||
return packs.isEmpty() ? dimensionKey : packs.iterator().next();
|
||||
}
|
||||
|
||||
private static RetainedPack retainedPack(Path packRoot) throws IOException {
|
||||
Path epochRoot = packRoot.getParent();
|
||||
Path epochsRoot = epochRoot == null ? null : epochRoot.getParent();
|
||||
@@ -868,7 +925,8 @@ public final class ModdedForcedDatapack {
|
||||
boolean emitLegacyDimensionTypeAlias,
|
||||
boolean required,
|
||||
Set<String> legacyDimensions,
|
||||
GenerationRegistryContract retainedContract
|
||||
GenerationRegistryContract retainedContract,
|
||||
Path retainedDimensionRoot
|
||||
) implements IrisCustomBiomeAliasResolver {
|
||||
private static PackStageContext installed(
|
||||
Path packRoot,
|
||||
@@ -884,6 +942,7 @@ public final class ModdedForcedDatapack {
|
||||
true,
|
||||
false,
|
||||
Set.copyOf(legacyDimensions),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -891,8 +950,9 @@ public final class ModdedForcedDatapack {
|
||||
private static PackStageContext retained(
|
||||
Path packRoot,
|
||||
String packName,
|
||||
GenerationEpoch epoch
|
||||
RetainedPack retained
|
||||
) {
|
||||
GenerationEpoch epoch = retained.epoch();
|
||||
return new PackStageContext(
|
||||
packRoot,
|
||||
packName,
|
||||
@@ -902,7 +962,8 @@ public final class ModdedForcedDatapack {
|
||||
true,
|
||||
true,
|
||||
Set.of(),
|
||||
epoch.registryContract()
|
||||
epoch.registryContract(),
|
||||
retained.history().paths().dimensionRoot().toAbsolutePath().normalize()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+113
-24
@@ -9,6 +9,7 @@ import art.arcane.iris.pack.BrokenPackException;
|
||||
import art.arcane.iris.pack.PackFingerprints;
|
||||
import art.arcane.iris.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.pack.PackValidationResult;
|
||||
import art.arcane.iris.world.history.GenerationAdmission;
|
||||
import art.arcane.iris.world.history.GenerationEpoch;
|
||||
import art.arcane.iris.world.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.world.history.GenerationHistory;
|
||||
@@ -28,6 +29,7 @@ import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class ModdedGenerationHistoryStorage {
|
||||
private ModdedGenerationHistoryStorage() {
|
||||
@@ -52,7 +54,7 @@ final class ModdedGenerationHistoryStorage {
|
||||
ModdedGenerationHistoryStorage::requireRegistryDefinitions,
|
||||
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
|
||||
);
|
||||
return resolveActive(history);
|
||||
return resolveActive(history, source);
|
||||
} catch (IOException failure) {
|
||||
throw historyFailure(levelKey, failure);
|
||||
}
|
||||
@@ -67,25 +69,85 @@ final class ModdedGenerationHistoryStorage {
|
||||
) {
|
||||
Path dimensionRoot = dimensionRoot(server, levelKey);
|
||||
try {
|
||||
GenerationHistory history = restoreOrAdopt(
|
||||
dimensionRoot,
|
||||
worldSeed,
|
||||
() -> {
|
||||
Path source = selectedPackSource(selectedPack, selectedDimensionKey);
|
||||
return new AdoptionSource(source, captureCandidate(
|
||||
source,
|
||||
selectedDimensionKey,
|
||||
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
|
||||
));
|
||||
},
|
||||
ModdedGenerationHistoryStorage::requireRegistryDefinitions
|
||||
);
|
||||
return resolveActive(history);
|
||||
GenerationHistory history = openForRestore(dimensionRoot, selectedPack, selectedDimensionKey, worldSeed);
|
||||
return resolveActive(history, installedPackSource(selectedPack));
|
||||
} catch (IOException failure) {
|
||||
throw historyFailure(levelKey, failure);
|
||||
}
|
||||
}
|
||||
|
||||
static PreparedHistory prepareRestore(
|
||||
NativeModdedServer server,
|
||||
String levelKey,
|
||||
String selectedPack,
|
||||
String selectedDimensionKey,
|
||||
long worldSeed
|
||||
) {
|
||||
Path dimensionRoot = dimensionRoot(server, levelKey);
|
||||
try {
|
||||
GenerationHistory history = openForRestore(dimensionRoot, selectedPack, selectedDimensionKey, worldSeed);
|
||||
return prepare(history, installedPackSource(selectedPack));
|
||||
} catch (IOException failure) {
|
||||
throw historyFailure(levelKey, failure);
|
||||
}
|
||||
}
|
||||
|
||||
static PreparedHistory prepareCreate(
|
||||
NativeModdedServer server,
|
||||
String levelKey,
|
||||
String selectedPack,
|
||||
String selectedDimensionKey,
|
||||
long worldSeed
|
||||
) {
|
||||
Path dimensionRoot = dimensionRoot(server, levelKey);
|
||||
try {
|
||||
Path source = selectedPackSource(selectedPack, selectedDimensionKey);
|
||||
GenerationHistory history = createOrStage(
|
||||
dimensionRoot,
|
||||
source,
|
||||
worldSeed,
|
||||
captureCandidate(source, selectedDimensionKey),
|
||||
ModdedGenerationHistoryStorage::requireRegistryDefinitions,
|
||||
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
|
||||
);
|
||||
return prepare(history, source);
|
||||
} catch (IOException failure) {
|
||||
throw historyFailure(levelKey, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreparedHistory prepare(GenerationHistory history, Path validatedSource) throws IOException {
|
||||
GenerationAdmission.RuntimeLease lease = history.retainRuntime();
|
||||
try {
|
||||
history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks());
|
||||
return new PreparedHistory(resolveActive(history, validatedSource), lease);
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
lease.close();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static GenerationHistory openForRestore(
|
||||
Path dimensionRoot,
|
||||
String selectedPack,
|
||||
String selectedDimensionKey,
|
||||
long worldSeed
|
||||
) throws IOException {
|
||||
return restoreOrAdopt(
|
||||
dimensionRoot,
|
||||
worldSeed,
|
||||
() -> {
|
||||
Path source = selectedPackSource(selectedPack, selectedDimensionKey);
|
||||
return new AdoptionSource(source, captureCandidate(
|
||||
source,
|
||||
selectedDimensionKey,
|
||||
adoptionAliasPolicy(dimensionRoot)
|
||||
));
|
||||
},
|
||||
ModdedGenerationHistoryStorage::requireRegistryDefinitions
|
||||
);
|
||||
}
|
||||
|
||||
static ActivePack openOrAdopt(
|
||||
NativeWorld level,
|
||||
String selectedPack,
|
||||
@@ -170,10 +232,23 @@ final class ModdedGenerationHistoryStorage {
|
||||
);
|
||||
}
|
||||
|
||||
static ActivePack resolveActive(GenerationHistory history) throws IOException {
|
||||
static GenerationRegistryContractFactory.CustomBiomeAliasPolicy adoptionAliasPolicy(Path dimensionRoot)
|
||||
throws IOException {
|
||||
Path regions = dimensionRoot.resolve("region");
|
||||
if (!Files.isDirectory(regions)) {
|
||||
return GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(regions)) {
|
||||
return files.anyMatch((Path file) -> file.getFileName().toString().endsWith(".mca"))
|
||||
? GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
|
||||
: GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY;
|
||||
}
|
||||
}
|
||||
|
||||
static ActivePack resolveActive(GenerationHistory history, Path validatedSource) throws IOException {
|
||||
GenerationEpoch epoch = history.activeEpoch();
|
||||
Path packRoot = normalize(history.activePackRoot());
|
||||
validatePack(packRoot);
|
||||
validatePack(packRoot, validatedSource);
|
||||
validateDimension(packRoot, epoch);
|
||||
return new ActivePack(
|
||||
packRoot,
|
||||
@@ -183,9 +258,12 @@ final class ModdedGenerationHistoryStorage {
|
||||
);
|
||||
}
|
||||
|
||||
static PackValidationResult validatePack(Path packRoot) {
|
||||
PackValidationResult validation = PackValidationRegistry.getMatching(
|
||||
packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile()));
|
||||
static PackValidationResult validatePack(Path packRoot, Path validatedSource) {
|
||||
String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packRoot.toFile());
|
||||
PackValidationResult validation = PackValidationRegistry.getMatching(packRoot, contentFingerprint);
|
||||
if (validation == null && validatedSource != null) {
|
||||
validation = PackValidationRegistry.publishMatchingCopy(validatedSource, packRoot, contentFingerprint);
|
||||
}
|
||||
if (validation == null) {
|
||||
validation = ModdedStartup.validatePack(packRoot.toFile());
|
||||
}
|
||||
@@ -246,10 +324,9 @@ final class ModdedGenerationHistoryStorage {
|
||||
PlatformGenerationRegistry generationRegistry = IrisPlatforms.get()
|
||||
.registries()
|
||||
.generationRegistry();
|
||||
String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey(
|
||||
source.getFileName().toString(),
|
||||
dimension.getLoadKey(),
|
||||
dimension.getDimensionTypeKey()
|
||||
String dimensionTypeKey = dimension.dimensionTypeResourceKey(
|
||||
generationRegistry,
|
||||
source.getFileName().toString()
|
||||
);
|
||||
GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
@@ -273,6 +350,11 @@ final class ModdedGenerationHistoryStorage {
|
||||
return normalize(ModdedWorldEngines.resolvePack(pack, dimensionKey).toPath());
|
||||
}
|
||||
|
||||
private static Path installedPackSource(String pack) {
|
||||
File folder = ModdedWorldEngines.packFolder(pack);
|
||||
return folder.isDirectory() ? normalize(folder.toPath()) : null;
|
||||
}
|
||||
|
||||
private static GenerationRegistryContract requireRegistryDefinitions(
|
||||
Collection<GenerationRegistryContract> contracts
|
||||
)
|
||||
@@ -333,6 +415,13 @@ final class ModdedGenerationHistoryStorage {
|
||||
}
|
||||
}
|
||||
|
||||
record PreparedHistory(ActivePack active, GenerationAdmission.RuntimeLease lease) {
|
||||
PreparedHistory {
|
||||
active = Objects.requireNonNull(active, "active");
|
||||
lease = Objects.requireNonNull(lease, "lease");
|
||||
}
|
||||
}
|
||||
|
||||
record Candidate(
|
||||
String packFingerprint,
|
||||
GenerationEpoch.DimensionContract dimensionContract,
|
||||
|
||||
+5
@@ -46,6 +46,11 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsBorrowedDimensionTypes() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Definition definition(String registryKey, String resourceKey) {
|
||||
return nativeRegistry.definition(registryKey, resourceKey);
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ public final class ModdedLifecycleCallbacks {
|
||||
return new NativeModdedCallbacks(
|
||||
ModdedEngineBootstrap::serverAboutToStart,
|
||||
ModdedEngineBootstrap::start,
|
||||
ModdedEngineBootstrap::levelsReady,
|
||||
ModdedEngineBootstrap::serverStarted,
|
||||
ModdedEngineBootstrap::stop,
|
||||
ModdedEngineBootstrap::stopped,
|
||||
|
||||
@@ -45,6 +45,9 @@ public final class ModdedMixinAudit {
|
||||
private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
|
||||
|
||||
static final List<ExpectedMixin> EXPECTED = List.of(
|
||||
new ExpectedMixin(ENTITY_CONFIG, "ServerLevelsReadyMixin",
|
||||
NativeMixinTarget.SERVER, "volmlib$levelsReady",
|
||||
false, NativeMixinFlags::levelsReadyRan),
|
||||
new ExpectedMixin(ENTITY_CONFIG, "RegistryClientNamesMixin",
|
||||
NativeMixinTarget.REGISTRY_PACKET, "volmlib$clientRegistryNames",
|
||||
false, NativeMixinFlags::registryClientNamesRan),
|
||||
@@ -61,6 +64,10 @@ public final class ModdedMixinAudit {
|
||||
NativeMixinTarget.STRUCTURE_PALETTE,
|
||||
"iris$installConcurrentBlockCache",
|
||||
false, NativeMixinFlags::structureTemplatePaletteRan),
|
||||
new ExpectedMixin(ENTITY_CONFIG, "PackRepositorySelectionMixin",
|
||||
NativeMixinTarget.PACK_REPOSITORY,
|
||||
"volmlib$keepNativeDatapacksLast",
|
||||
false, NativeMixinFlags::packSelectionRan),
|
||||
new ExpectedMixin(CLIENT_CONFIG, "NativeWorldOpenFlowsMixin",
|
||||
NativeMixinTarget.WORLD_OPEN_FLOWS,
|
||||
"volmlib$openWorldCheckWorldStemCompatibility",
|
||||
@@ -111,7 +118,7 @@ public final class ModdedMixinAudit {
|
||||
ModdedIrisLog.error(" missing: {}", entry);
|
||||
}
|
||||
ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute).");
|
||||
ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, readable biome identifiers, or Iris world-type labels are disabled until this is fixed.");
|
||||
ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, Iris datapack priority, readable biome identifiers, or Iris world-type labels are disabled until this is fixed.");
|
||||
ModdedIrisLog.error("===============================================================");
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,15 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public final class ModdedModConfig {
|
||||
private static final Object LOCK = new Object();
|
||||
private static final String DIMENSION_REPLACEMENTS = "dimensionReplacements";
|
||||
private static volatile ModdedModConfig instance;
|
||||
|
||||
private final String defaultPack;
|
||||
@@ -35,15 +41,20 @@ public final class ModdedModConfig {
|
||||
private final String mainWorldPack;
|
||||
private final long mainWorldSeed;
|
||||
private final boolean mainWorldAutoRestart;
|
||||
private final Map<String, ModdedDimensionReplacement> dimensionReplacements;
|
||||
private final List<String> replacementProblems;
|
||||
|
||||
private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld,
|
||||
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart) {
|
||||
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart,
|
||||
Replacements replacements) {
|
||||
this.defaultPack = defaultPack;
|
||||
this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim();
|
||||
this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld;
|
||||
this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim();
|
||||
this.mainWorldSeed = mainWorldSeed;
|
||||
this.mainWorldAutoRestart = mainWorldAutoRestart;
|
||||
this.dimensionReplacements = Map.copyOf(replacements.entries());
|
||||
this.replacementProblems = List.copyOf(replacements.problems());
|
||||
}
|
||||
|
||||
public static ModdedModConfig get() {
|
||||
@@ -60,11 +71,15 @@ public final class ModdedModConfig {
|
||||
}
|
||||
}
|
||||
|
||||
public static ModdedModConfig loaded() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static void setPrimaryWorld(String dimensionId) {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = get();
|
||||
ModdedModConfig current = writable();
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld,
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart);
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.replacements());
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
}
|
||||
@@ -72,14 +87,22 @@ public final class ModdedModConfig {
|
||||
|
||||
public static void setMainWorld(String packRef, long seed) {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = get();
|
||||
ModdedModConfig current = writable();
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
|
||||
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart);
|
||||
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.replacements());
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean setDimensionReplacement(ModdedDimensionReplacement replacement) {
|
||||
return updateDimensionReplacement(replacement.levelKey(), replacement);
|
||||
}
|
||||
|
||||
public static boolean clearDimensionReplacement(String levelKey) {
|
||||
return updateDimensionReplacement(levelKey, null);
|
||||
}
|
||||
|
||||
public String defaultPack() {
|
||||
return defaultPack;
|
||||
}
|
||||
@@ -104,30 +127,108 @@ public final class ModdedModConfig {
|
||||
return mainWorldAutoRestart;
|
||||
}
|
||||
|
||||
public Map<String, ModdedDimensionReplacement> dimensionReplacements() {
|
||||
return dimensionReplacements;
|
||||
}
|
||||
|
||||
public List<String> replacementProblems() {
|
||||
return replacementProblems;
|
||||
}
|
||||
|
||||
static ModdedModConfig read(Path file) {
|
||||
ModdedModConfig defaults = defaults();
|
||||
JSONObject json;
|
||||
try {
|
||||
json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException | IOException unreadable) {
|
||||
ModdedIrisLog.error("Iris modded config at {} is unreadable; using defaults", file, unreadable);
|
||||
return new ModdedModConfig(defaults.defaultPack, defaults.primaryWorld, defaults.routePlayersToPrimaryWorld,
|
||||
defaults.mainWorldPack, defaults.mainWorldSeed, defaults.mainWorldAutoRestart,
|
||||
new Replacements(Map.of(), List.of("Iris could not read " + file + ": " + unreadable.getMessage())));
|
||||
}
|
||||
return new ModdedModConfig(
|
||||
json.optString("defaultPack", defaults.defaultPack),
|
||||
json.optString("primaryWorld", defaults.primaryWorld),
|
||||
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
|
||||
json.optString("mainWorldPack", defaults.mainWorldPack),
|
||||
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
|
||||
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
|
||||
readDimensionReplacements(json, file));
|
||||
}
|
||||
|
||||
private Replacements replacements() {
|
||||
return new Replacements(dimensionReplacements, replacementProblems);
|
||||
}
|
||||
|
||||
private static ModdedModConfig writable() {
|
||||
ModdedModConfig current = get();
|
||||
if (!current.replacementProblems.isEmpty()) {
|
||||
throw new IllegalStateException("Iris will not rewrite " + configFile()
|
||||
+ " while its dimension replacements are unreadable: " + String.join("; ", current.replacementProblems)
|
||||
+ ". Fix the file and restart first.");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static ModdedModConfig defaults() {
|
||||
return new ModdedModConfig("overworld", "", true, "", 0L, false, new Replacements(Map.of(), List.of()));
|
||||
}
|
||||
|
||||
private static boolean updateDimensionReplacement(String levelKey, ModdedDimensionReplacement replacement) {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = writable();
|
||||
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>(current.dimensionReplacements);
|
||||
ModdedDimensionReplacement previous = replacement == null
|
||||
? replacements.remove(levelKey)
|
||||
: replacements.put(levelKey, replacement);
|
||||
if (replacement == null ? previous == null : replacement.equals(previous)) {
|
||||
return false;
|
||||
}
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Replacements(replacements, List.of()));
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static Path configFile() {
|
||||
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("modded.json");
|
||||
}
|
||||
|
||||
private static ModdedModConfig load() {
|
||||
Path file = configFile();
|
||||
ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false);
|
||||
if (!Files.isRegularFile(file)) {
|
||||
ModdedModConfig defaults = defaults();
|
||||
write(file, defaults);
|
||||
return defaults;
|
||||
}
|
||||
try {
|
||||
JSONObject json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
|
||||
return new ModdedModConfig(
|
||||
json.optString("defaultPack", defaults.defaultPack),
|
||||
json.optString("primaryWorld", defaults.primaryWorld),
|
||||
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
|
||||
json.optString("mainWorldPack", defaults.mainWorldPack),
|
||||
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
|
||||
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart));
|
||||
} catch (RuntimeException | IOException e) {
|
||||
ModdedIrisLog.error("Iris modded config at {} is invalid; using defaults", file, e);
|
||||
return defaults;
|
||||
return read(file);
|
||||
}
|
||||
|
||||
private static Replacements readDimensionReplacements(JSONObject json, Path file) {
|
||||
if (!json.has(DIMENSION_REPLACEMENTS) || json.isNull(DIMENSION_REPLACEMENTS)) {
|
||||
return new Replacements(Map.of(), List.of());
|
||||
}
|
||||
JSONObject section = json.optJSONObject(DIMENSION_REPLACEMENTS);
|
||||
if (section == null) {
|
||||
return new Replacements(Map.of(), List.of("Iris could not read " + DIMENSION_REPLACEMENTS + " in " + file
|
||||
+ ": it must be an object mapping dimension keys to replacements"));
|
||||
}
|
||||
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>();
|
||||
List<String> problems = new ArrayList<>();
|
||||
for (String levelKey : new TreeSet<>(section.keySet())) {
|
||||
try {
|
||||
ModdedDimensionReplacement replacement = ModdedDimensionReplacement.fromJson(levelKey, section.opt(levelKey));
|
||||
replacements.put(replacement.levelKey(), replacement);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
problems.add("Iris could not read dimension replacement '" + levelKey + "' in " + file + ": " + invalid.getMessage());
|
||||
}
|
||||
}
|
||||
for (String problem : problems) {
|
||||
ModdedIrisLog.error(problem);
|
||||
}
|
||||
return new Replacements(replacements, problems);
|
||||
}
|
||||
|
||||
private static void write(Path file, ModdedModConfig config) {
|
||||
@@ -138,6 +239,11 @@ public final class ModdedModConfig {
|
||||
json.put("mainWorldPack", config.mainWorldPack);
|
||||
json.put("mainWorldSeed", config.mainWorldSeed);
|
||||
json.put("mainWorldAutoRestart", config.mainWorldAutoRestart);
|
||||
JSONObject replacements = new JSONObject();
|
||||
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(config.dimensionReplacements).entrySet()) {
|
||||
replacements.put(entry.getKey(), entry.getValue().toJson());
|
||||
}
|
||||
json.put(DIMENSION_REPLACEMENTS, replacements);
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, json.toString(4), StandardCharsets.UTF_8);
|
||||
@@ -145,4 +251,7 @@ public final class ModdedModConfig {
|
||||
ModdedIrisLog.error("Iris failed to write modded config at {}", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private record Replacements(Map<String, ModdedDimensionReplacement> entries, List<String> problems) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@ public final class ModdedPlatform implements IrisPlatform {
|
||||
return loader.platformName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsBorrowedDimensionTypes() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String minecraftVersion() {
|
||||
return loader.minecraftVersion();
|
||||
|
||||
@@ -127,6 +127,7 @@ public final class ModdedProtocolHandler {
|
||||
if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) {
|
||||
return;
|
||||
}
|
||||
ModdedReplacementEntry.onPlayerJoin(ModdedEngineBootstrap.currentServer(), player);
|
||||
String sessionId = sessionId(player);
|
||||
ModdedStartup.warnStartupFailuresTo(player);
|
||||
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeServerSpawn;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class ModdedReplacementEntry {
|
||||
private static final Set<UUID> PENDING = ConcurrentHashMap.newKeySet();
|
||||
private static volatile Path guardedWorld;
|
||||
private static volatile UUID transaction;
|
||||
|
||||
private ModdedReplacementEntry() {
|
||||
}
|
||||
|
||||
public static boolean prepare(NativeModdedServer server) {
|
||||
clear();
|
||||
Path overworld = ModdedDimensionReplacement.storageFolder(server.root(), ModdedReplacementReconciler.OVERWORLD);
|
||||
Optional<WorldReplacementEntryGuard.Entry> entry;
|
||||
try {
|
||||
entry = WorldReplacementEntryGuard.load(overworld);
|
||||
} catch (IOException failure) {
|
||||
ModdedIrisLog.error("Iris could not read the Overworld replacement entry marker under {}", overworld, failure);
|
||||
return false;
|
||||
}
|
||||
if (entry.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
PENDING.addAll(entry.get().pendingPlayers());
|
||||
transaction = entry.get().transactionId();
|
||||
guardedWorld = overworld;
|
||||
ModdedIrisLog.info("Iris replaced the Overworld; {} returning player(s) are checked for a safe position on their next join",
|
||||
PENDING.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void retireIfComplete() {
|
||||
Path world = guardedWorld;
|
||||
UUID current = transaction;
|
||||
if (world == null || current == null || !PENDING.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture.runAsync(() -> retire(world, current));
|
||||
}
|
||||
|
||||
public static void onPlayerJoin(NativeModdedServer server, NativeProtocolPlayer player) {
|
||||
if (guardedWorld == null || server == null || player == null || !PENDING.contains(player.id())) {
|
||||
return;
|
||||
}
|
||||
NativeWorld overworld = server.overworld();
|
||||
if (overworld == null || !player.isInWorld(overworld) || standingSafely(overworld, player)) {
|
||||
complete(player.id());
|
||||
return;
|
||||
}
|
||||
NativeServerSpawn.Respawn spawn = new NativeServerSpawn(server).current();
|
||||
boolean overworldSpawn = spawn != null && NativeDimensionRuntime.sameWorld(spawn.world(), overworld);
|
||||
double x = overworldSpawn ? spawn.position().x() + 0.5D : 0.5D;
|
||||
double z = overworldSpawn ? spawn.position().z() + 0.5D : 0.5D;
|
||||
UUID playerId = player.id();
|
||||
NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination(server, overworld, x, Double.MIN_VALUE, z,
|
||||
ModdedTeleportDeadline.fromNow()))
|
||||
.whenComplete((Boolean moved, Throwable failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(moved)) {
|
||||
ModdedIrisLog.error("Iris could not move {} out of the replaced Overworld terrain to the spawn surface",
|
||||
playerId, failure);
|
||||
return;
|
||||
}
|
||||
ModdedIrisLog.info("Iris moved {} from an unsafe position in the replaced Overworld to the spawn surface",
|
||||
playerId);
|
||||
complete(playerId);
|
||||
});
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
PENDING.clear();
|
||||
guardedWorld = null;
|
||||
transaction = null;
|
||||
}
|
||||
|
||||
static boolean standingSafely(NativeWorld world, NativeProtocolPlayer player) {
|
||||
int y = player.blockY();
|
||||
if (y < world.minHeight() || y + 1 >= world.maxHeight()) {
|
||||
return false;
|
||||
}
|
||||
return !world.getBlock(player.blockX(), y, player.blockZ()).isSolid()
|
||||
&& !world.getBlock(player.blockX(), y + 1, player.blockZ()).isSolid();
|
||||
}
|
||||
|
||||
private static void complete(UUID playerId) {
|
||||
Path world = guardedWorld;
|
||||
UUID current = transaction;
|
||||
if (world == null || current == null || !PENDING.remove(playerId)) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture.runAsync(() -> persistCompletion(world, current, playerId));
|
||||
}
|
||||
|
||||
private static synchronized void persistCompletion(Path world, UUID current, UUID playerId) {
|
||||
try {
|
||||
Optional<WorldReplacementEntryGuard.Entry> updated = WorldReplacementEntryGuard.completePlayer(world, current, playerId);
|
||||
if (updated.isPresent() && updated.get().pendingPlayers().isEmpty()) {
|
||||
retire(world, current);
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
ModdedIrisLog.error("Iris could not record the safe Overworld replacement entry of {}", playerId, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static synchronized void retire(Path world, UUID current) {
|
||||
try {
|
||||
if (WorldReplacementEntryGuard.retireIfEmpty(world, current)) {
|
||||
ModdedIrisLog.info("Iris retired the Overworld replacement entry marker: every known player entered safely");
|
||||
if (world.equals(guardedWorld)) {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
ModdedIrisLog.error("Iris could not retire the Overworld replacement entry marker under {}", world, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.world.history.GenerationEpoch;
|
||||
import art.arcane.iris.world.history.GenerationHistory;
|
||||
import art.arcane.iris.world.history.GenerationHistoryPaths;
|
||||
import art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class ModdedReplacementReconciler {
|
||||
public static final String OVERWORLD = "minecraft:overworld";
|
||||
private static final String STATE_FILE = "data/irisworldgen/dimension-replacements.json";
|
||||
private static final String APPLIED = "replacements";
|
||||
private static final String PENDING = "pending";
|
||||
private static final String IRIS_WORLD_NAMESPACE = "irisworldgen:";
|
||||
|
||||
private ModdedReplacementReconciler() {
|
||||
}
|
||||
|
||||
public static void reconcileEarly() {
|
||||
try {
|
||||
ModdedModConfig config = ModdedModConfig.get();
|
||||
Map<String, ModdedDimensionReplacement> configured = ModdedDimensionReplacements.configured();
|
||||
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
|
||||
if (worldRoot == null) {
|
||||
if (!configured.isEmpty()) {
|
||||
ModdedIrisLog.info("Iris dimension replacements {} apply to a new world; nothing to move aside",
|
||||
configured.keySet());
|
||||
}
|
||||
return;
|
||||
}
|
||||
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
|
||||
Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath();
|
||||
Result result = reconcile(new Request(worldRoot, configured, config.replacementProblems(),
|
||||
(ModdedDimensionReplacement replacement) -> ModdedDimensionReplacements.installed(replacement, packsRoot),
|
||||
recoveryParent, ModdedDimensionReplacements.replaceableLevels(worldRoot, configured)));
|
||||
if (!result.adoptedLevels().isEmpty()) {
|
||||
ModdedIrisLog.info("Iris kept the existing terrain of {}: its Iris generation history already uses the configured pack and seed; the replacement is recorded once the level generates from it",
|
||||
result.adoptedLevels());
|
||||
}
|
||||
if (!result.replacedWorlds().isEmpty()) {
|
||||
ModdedIrisLog.warn("Iris replaced the Iris worlds {} from their staged packs", result.replacedWorlds());
|
||||
}
|
||||
if (result.quarantinedLevels().isEmpty() && result.replacedWorlds().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
ModdedIrisLog.warn("Iris dimension replacement changed for {}: {}", movedLevels(result),
|
||||
result.recovery() == null
|
||||
? "no existing chunk data to move aside"
|
||||
: "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery()
|
||||
+ " so they regenerate (player data kept)");
|
||||
} catch (Throwable failure) {
|
||||
ModdedIrisLog.error("Iris dimension replacement reconciliation failed", failure);
|
||||
throw new IllegalStateException("Iris refused startup after dimension replacement reconciliation failed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public static void recordApplied(NativeModdedServer server) {
|
||||
Optional<Path> worldRoot = NativeWorldPaths.root(server);
|
||||
if (worldRoot.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recordApplied(worldRoot.get(), ModdedDimensionReplacements.configured(),
|
||||
(String levelKey) -> liveLevel(server, levelKey));
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
ModdedIrisLog.error("Iris is stopping the server because it could not record the applied dimension replacements under {}; an unrecorded replacement would be moved aside again on the next start",
|
||||
worldRoot.get(), failure);
|
||||
server.halt(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static void stopUnpublishedEngines(NativeModdedServer server) {
|
||||
for (String levelKey : ModdedWorldEngines.evictUnpublished(server,
|
||||
ModdedModConfig.get().dimensionReplacements().keySet())) {
|
||||
ModdedIrisLog.error("Iris stopped the engine it started for {}: a mod replaced that level with its own chunk generator while the server started",
|
||||
levelKey);
|
||||
}
|
||||
}
|
||||
|
||||
static synchronized Map<String, ModdedDimensionReplacement> recordApplied(Path worldRoot,
|
||||
Map<String, ModdedDimensionReplacement> configured,
|
||||
LiveLevels levels) throws IOException {
|
||||
State state = readState(worldRoot);
|
||||
Map<String, ModdedDimensionReplacement> recorded = new TreeMap<>(state.applied());
|
||||
for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) {
|
||||
Optional<LiveLevel> live = levels.level(replacement.levelKey());
|
||||
if (live.isPresent() && live.get().generates(replacement)) {
|
||||
recorded.put(replacement.levelKey(), replacement.withSeed(live.get().seed()));
|
||||
continue;
|
||||
}
|
||||
if (live.isPresent() && !live.get().iris()) {
|
||||
recorded.remove(replacement.levelKey());
|
||||
ModdedIrisLog.error("Iris replacement of {} is configured but the loaded level uses another chunk generator; a mod creates that level while the server runs, so Iris cannot replace it. Remove its dimensionReplacements entry from modded.json",
|
||||
replacement.levelKey());
|
||||
continue;
|
||||
}
|
||||
ModdedIrisLog.warn("Iris replacement of {} is configured but the level does not generate from {}{}; it stays unapplied",
|
||||
replacement.levelKey(), replacement.packRef(),
|
||||
replacement.seed().isPresent() ? " with seed " + replacement.seed().getAsLong() : "");
|
||||
}
|
||||
if (!recorded.equals(state.applied())) {
|
||||
writeState(worldRoot, new State(recorded, state.pending()));
|
||||
}
|
||||
return Map.copyOf(recorded);
|
||||
}
|
||||
|
||||
static synchronized Result reconcile(Request request) throws IOException {
|
||||
Path worldRoot = request.worldRoot();
|
||||
requireReadableConfig(worldRoot, request.configProblems());
|
||||
State state = readState(worldRoot);
|
||||
Map<String, ModdedDimensionReplacement> applied = state.applied();
|
||||
Set<String> refreshed = new TreeSet<>();
|
||||
boolean irisWorldsPending = false;
|
||||
for (String level : state.pending().keySet()) {
|
||||
if (level.startsWith(IRIS_WORLD_NAMESPACE)) {
|
||||
irisWorldsPending = true;
|
||||
} else if (request.configured().containsKey(level)) {
|
||||
refreshed.add(level);
|
||||
}
|
||||
}
|
||||
Set<String> changed = new TreeSet<>(ModdedDimensionReplacements.changedLevels(applied, request.configured()));
|
||||
changed.addAll(refreshed);
|
||||
Map<String, ModdedDimensionReplacement> recorded = new TreeMap<>(applied);
|
||||
Set<String> quarantined = new TreeSet<>();
|
||||
Set<String> adopted = new TreeSet<>();
|
||||
Set<String> unreplaceable = new TreeSet<>();
|
||||
for (String level : changed) {
|
||||
ModdedDimensionReplacement replacement = request.configured().get(level);
|
||||
if (replacement == null) {
|
||||
recorded.remove(level);
|
||||
quarantined.add(level);
|
||||
continue;
|
||||
}
|
||||
if (!request.levels().replaceable(level)) {
|
||||
unreplaceable.add(level);
|
||||
ModdedIrisLog.error(ModdedDimensionReplacements.unreplaceableReason(level, request.levels())
|
||||
+ " Remove its dimensionReplacements entry from modded.json.");
|
||||
continue;
|
||||
}
|
||||
Path storage = replacement.storageFolder(worldRoot);
|
||||
boolean keepHistory = !refreshed.contains(level) && matchingHistorySeed(storage, replacement).isPresent();
|
||||
if (keepHistory) {
|
||||
adopted.add(level);
|
||||
} else if (request.installed().test(replacement)) {
|
||||
quarantined.add(level);
|
||||
} else {
|
||||
refuseOrSkipUninstalled(worldRoot, applied, replacement);
|
||||
}
|
||||
}
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> registry = irisWorldsPending
|
||||
? ModdedDimensionRegistryStore.loadWorldRoot(worldRoot)
|
||||
: List.of();
|
||||
Map<String, ModdedDimensionReplacement> replacedWorlds = new TreeMap<>();
|
||||
for (ModdedDimensionReplacement pending : state.pending().values()) {
|
||||
if (!pending.levelKey().startsWith(IRIS_WORLD_NAMESPACE)) {
|
||||
continue;
|
||||
}
|
||||
if (registered(registry, pending.levelKey())) {
|
||||
replacedWorlds.put(pending.levelKey(), pending);
|
||||
} else {
|
||||
ModdedIrisLog.warn("Iris dropped the staged replacement of {}: the Iris world is no longer registered",
|
||||
pending.levelKey());
|
||||
}
|
||||
}
|
||||
List<Path> sources = new ArrayList<>();
|
||||
for (String level : quarantined) {
|
||||
addChunkData(sources, ModdedDimensionReplacement.storageFolder(worldRoot, level));
|
||||
}
|
||||
for (String level : replacedWorlds.keySet()) {
|
||||
addChunkData(sources, ModdedDimensionReplacement.storageFolder(worldRoot, level));
|
||||
}
|
||||
Path recovery = request.recoveryParent().resolve("replacement-recovery-" + UUID.randomUUID());
|
||||
List<Path> moved = sources.isEmpty() ? List.of() : ModdedWorldQuarantine.move(worldRoot, sources, recovery);
|
||||
if (quarantined.contains(OVERWORLD)) {
|
||||
WorldReplacementEntryGuard.stage(worldRoot, ModdedDimensionReplacement.storageFolder(worldRoot, OVERWORLD),
|
||||
UUID.randomUUID());
|
||||
}
|
||||
if (!replacedWorlds.isEmpty()) {
|
||||
ModdedDimensionRegistryStore.writeWorldRoot(worldRoot, repointed(registry, replacedWorlds));
|
||||
}
|
||||
State reconciled = new State(recorded, Map.of());
|
||||
if (!reconciled.equals(state)) {
|
||||
writeState(worldRoot, reconciled);
|
||||
}
|
||||
return new Result(Set.copyOf(quarantined), Set.copyOf(adopted), Set.copyOf(replacedWorlds.keySet()),
|
||||
Set.copyOf(unreplaceable), moved.isEmpty() ? null : recovery);
|
||||
}
|
||||
|
||||
public static boolean pending(Path worldRoot, String levelKey, Map<String, ModdedDimensionReplacement> configured)
|
||||
throws IOException {
|
||||
State state = readState(worldRoot);
|
||||
if (state.pending().containsKey(levelKey)) {
|
||||
return true;
|
||||
}
|
||||
ModdedDimensionReplacement replacement = configured.get(levelKey);
|
||||
return replacement != null && !ModdedDimensionReplacements.matches(state.applied().get(levelKey), replacement);
|
||||
}
|
||||
|
||||
public static Optional<ModdedDimensionReplacement> applied(Path worldRoot, String levelKey) throws IOException {
|
||||
return Optional.ofNullable(readState(worldRoot).applied().get(levelKey));
|
||||
}
|
||||
|
||||
public static synchronized void stagePending(Path worldRoot, ModdedDimensionReplacement replacement) throws IOException {
|
||||
if (replacement.seed().isEmpty()) {
|
||||
throw new IllegalArgumentException("A staged replacement needs its effective seed: " + replacement.levelKey());
|
||||
}
|
||||
State state = readState(worldRoot);
|
||||
Map<String, ModdedDimensionReplacement> pending = new TreeMap<>(state.pending());
|
||||
pending.put(replacement.levelKey(), replacement);
|
||||
writeState(worldRoot, new State(state.applied(), pending));
|
||||
}
|
||||
|
||||
public static synchronized boolean cancelPending(Path worldRoot, String levelKey) throws IOException {
|
||||
State state = readState(worldRoot);
|
||||
Map<String, ModdedDimensionReplacement> pending = new TreeMap<>(state.pending());
|
||||
if (pending.remove(levelKey) == null) {
|
||||
return false;
|
||||
}
|
||||
writeState(worldRoot, new State(state.applied(), pending));
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean hasReplacementRecord(Path worldRoot, String levelKey) throws IOException {
|
||||
return readState(worldRoot).applied().containsKey(levelKey) || hasGenerationHistory(
|
||||
ModdedDimensionReplacement.storageFolder(worldRoot, levelKey));
|
||||
}
|
||||
|
||||
private static Set<String> movedLevels(Result result) {
|
||||
Set<String> levels = new TreeSet<>(result.quarantinedLevels());
|
||||
levels.addAll(result.replacedWorlds());
|
||||
return levels;
|
||||
}
|
||||
|
||||
private static void addChunkData(List<Path> sources, Path storage) {
|
||||
for (String folder : ModdedDimensionStorage.chunkDataFolders()) {
|
||||
sources.add(storage.resolve(folder));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean registered(List<ModdedDimensionRegistryStore.PersistentDimension> registry, String levelKey) {
|
||||
for (ModdedDimensionRegistryStore.PersistentDimension dimension : registry) {
|
||||
if (dimension.id().equals(levelKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<ModdedDimensionRegistryStore.PersistentDimension> repointed(
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> registry,
|
||||
Map<String, ModdedDimensionReplacement> replacedWorlds) {
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> updated = new ArrayList<>(registry.size());
|
||||
for (ModdedDimensionRegistryStore.PersistentDimension dimension : registry) {
|
||||
ModdedDimensionReplacement replacement = replacedWorlds.get(dimension.id());
|
||||
updated.add(replacement == null ? dimension : new ModdedDimensionRegistryStore.PersistentDimension(
|
||||
dimension.id(), replacement.pack(), replacement.packDimension(), replacement.seed().getAsLong()));
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static void refuseOrSkipUninstalled(Path worldRoot, Map<String, ModdedDimensionReplacement> applied,
|
||||
ModdedDimensionReplacement replacement) throws IOException {
|
||||
if (applied.containsKey(replacement.levelKey()) || hasGenerationHistory(replacement.storageFolder(worldRoot))) {
|
||||
throw new IOException("Iris refused to switch the replacement of " + replacement.levelKey() + " to "
|
||||
+ replacement.packRef() + ": pack '" + replacement.pack() + "' dimension '" + replacement.packDimension()
|
||||
+ "' is not installed, and the level already has an applied replacement or Iris generation history. Install the pack or restore the previous replacement.");
|
||||
}
|
||||
ModdedIrisLog.error("Iris left {} untouched: its replacement pack '{}' dimension '{}' is not installed",
|
||||
replacement.levelKey(), replacement.pack(), replacement.packDimension());
|
||||
}
|
||||
|
||||
private static boolean hasGenerationHistory(Path storage) {
|
||||
return Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
static OptionalLong matchingHistorySeed(Path storage, ModdedDimensionReplacement replacement) throws IOException {
|
||||
if (!hasGenerationHistory(storage)) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch();
|
||||
if (replacement.seed().isPresent() && replacement.seed().getAsLong() != epoch.worldSeed()) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
GenerationEpoch.DimensionContract contract = epoch.dimensionContract();
|
||||
if (!contract.dimensionKey().equals(replacement.packDimension())) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
|
||||
boolean samePack = scoped.isPresent()
|
||||
? scoped.get().equals(new ModdedWorldgenIds.ScopedDimension(replacement.pack(), replacement.packDimension()))
|
||||
: aliasMatches(epoch, contract, replacement);
|
||||
return samePack ? OptionalLong.of(epoch.worldSeed()) : OptionalLong.empty();
|
||||
}
|
||||
|
||||
private static boolean aliasMatches(GenerationEpoch epoch, GenerationEpoch.DimensionContract contract,
|
||||
ModdedDimensionReplacement replacement) {
|
||||
Set<String> packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), contract.dimensionKey());
|
||||
return packs.isEmpty() || packs.contains(replacement.pack());
|
||||
}
|
||||
|
||||
private static Optional<LiveLevel> liveLevel(NativeModdedServer server, String levelKey) {
|
||||
NativeWorld world = server.world(levelKey);
|
||||
if (world == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class);
|
||||
if (generator == null) {
|
||||
return Optional.of(new LiveLevel(false, "", 0L));
|
||||
}
|
||||
return Optional.of(new LiveLevel(true, generator.dimensionKey(), generator.visibleBiomeSeed()));
|
||||
}
|
||||
|
||||
private static void requireReadableConfig(Path worldRoot, List<String> configProblems) throws IOException {
|
||||
if (configProblems.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Path state = worldRoot.resolve(STATE_FILE);
|
||||
if (!Files.exists(state)) {
|
||||
return;
|
||||
}
|
||||
throw new IOException("Iris refused to reconcile dimension replacements because the configured replacements are unreadable while "
|
||||
+ state + " records applied replacements: " + String.join("; ", configProblems)
|
||||
+ ". Fix the modded config so every replacement entry is valid; Iris never treats an unreadable replacement as removed.");
|
||||
}
|
||||
|
||||
static State readState(Path worldRoot) throws IOException {
|
||||
Path state = worldRoot.resolve(STATE_FILE);
|
||||
if (!Files.isRegularFile(state)) {
|
||||
return State.EMPTY;
|
||||
}
|
||||
JSONObject json = new JSONObject(Files.readString(state, StandardCharsets.UTF_8));
|
||||
return new State(readSection(json, APPLIED, state), readSection(json, PENDING, state));
|
||||
}
|
||||
|
||||
static void writeState(Path worldRoot, State replacements) throws IOException {
|
||||
Path state = worldRoot.resolve(STATE_FILE);
|
||||
Files.createDirectories(state.getParent());
|
||||
JSONObject json = new JSONObject();
|
||||
json.put(APPLIED, section(replacements.applied()));
|
||||
json.put(PENDING, section(replacements.pending()));
|
||||
Path temporary = state.resolveSibling(state.getFileName() + ".tmp-" + UUID.randomUUID());
|
||||
Files.writeString(temporary, json.toString(2), StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException atomicUnsupported) {
|
||||
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, ModdedDimensionReplacement> readSection(JSONObject json, String name, Path state)
|
||||
throws IOException {
|
||||
JSONObject section = json.optJSONObject(name);
|
||||
if (section == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>();
|
||||
for (String level : section.keySet()) {
|
||||
try {
|
||||
replacements.put(level, ModdedDimensionReplacement.fromJson(level, section.opt(level)));
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IOException("Iris could not read replacement '" + level + "' in " + state + ": "
|
||||
+ invalid.getMessage(), invalid);
|
||||
}
|
||||
}
|
||||
return Map.copyOf(replacements);
|
||||
}
|
||||
|
||||
private static JSONObject section(Map<String, ModdedDimensionReplacement> replacements) {
|
||||
JSONObject section = new JSONObject();
|
||||
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(replacements).entrySet()) {
|
||||
section.put(entry.getKey(), entry.getValue().toJson());
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
interface LiveLevels {
|
||||
Optional<LiveLevel> level(String levelKey);
|
||||
}
|
||||
|
||||
record LiveLevel(boolean iris, String packRef, long seed) {
|
||||
LiveLevel {
|
||||
Objects.requireNonNull(packRef, "packRef");
|
||||
}
|
||||
|
||||
boolean generates(ModdedDimensionReplacement replacement) {
|
||||
return iris && packRef.equals(replacement.packRef())
|
||||
&& (replacement.seed().isEmpty() || replacement.seed().getAsLong() == seed);
|
||||
}
|
||||
}
|
||||
|
||||
record State(Map<String, ModdedDimensionReplacement> applied, Map<String, ModdedDimensionReplacement> pending) {
|
||||
static final State EMPTY = new State(Map.of(), Map.of());
|
||||
|
||||
State {
|
||||
applied = Map.copyOf(applied);
|
||||
pending = Map.copyOf(pending);
|
||||
}
|
||||
}
|
||||
|
||||
record Request(Path worldRoot, Map<String, ModdedDimensionReplacement> configured, List<String> configProblems,
|
||||
Predicate<ModdedDimensionReplacement> installed, Path recoveryParent,
|
||||
ModdedDimensionReplacements.ReplaceableLevels levels) {
|
||||
}
|
||||
|
||||
record Result(Set<String> quarantinedLevels, Set<String> adoptedLevels, Set<String> replacedWorlds,
|
||||
Set<String> unreplaceableLevels, Path recovery) {
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import art.arcane.iris.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.pack.PackValidationCache;
|
||||
import art.arcane.iris.pack.PackValidationResult;
|
||||
import art.arcane.iris.pack.PackValidator;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.modded.command.ModdedPackCommands;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
@@ -39,19 +40,29 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public final class ModdedStartup {
|
||||
private static final int COMPAT_BOOT_KEY_CAP = 3;
|
||||
private static final AtomicBoolean PREPARED = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean RESTORE_BEGUN = new AtomicBoolean(false);
|
||||
private static final AtomicReference<ModdedDimensionInjection<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine>> RESTORE =
|
||||
new AtomicReference<>();
|
||||
private static final List<String> DIMENSION_FAILURES = new CopyOnWriteArrayList<>();
|
||||
|
||||
private ModdedStartup() {
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
ModdedDimensionInjection<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> restore =
|
||||
RESTORE.getAndSet(null);
|
||||
if (restore != null) {
|
||||
restore.close();
|
||||
}
|
||||
PREPARED.set(false);
|
||||
STARTED.set(false);
|
||||
RESTORE_BEGUN.set(false);
|
||||
DIMENSION_FAILURES.clear();
|
||||
}
|
||||
|
||||
@@ -62,6 +73,24 @@ public final class ModdedStartup {
|
||||
validateAllPacks();
|
||||
}
|
||||
|
||||
public static void prepareDimensionRestore(NativeModdedServer server) {
|
||||
if (server == null || !RESTORE_BEGUN.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
|
||||
ModdedDimensionRegistryStore.loadForStartup(server);
|
||||
if (dimensions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long startedAt = System.currentTimeMillis();
|
||||
ModdedDimensionInjection<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> restore =
|
||||
ModdedDimensionInjection.start(dimensions, new PersistentRestoreSteps(server));
|
||||
RESTORE.set(restore);
|
||||
restore.awaitPreparations();
|
||||
ModdedIrisLog.info("Iris prepared {} persistent dimension(s) during world load in {}ms",
|
||||
dimensions.size(), System.currentTimeMillis() - startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler:
|
||||
* ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
|
||||
@@ -91,7 +120,6 @@ public final class ModdedStartup {
|
||||
}
|
||||
ModdedForcedDatapack.verifyInjected();
|
||||
ModdedMixinAudit.runOnce();
|
||||
reinjectPersistentDimensions(server);
|
||||
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler == null) {
|
||||
@@ -219,38 +247,46 @@ public final class ModdedStartup {
|
||||
}
|
||||
|
||||
static void reinjectPersistentDimensions(NativeModdedServer server) {
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
|
||||
ModdedDimensionRegistryStore.loadForStartup(server);
|
||||
if (dimensions.isEmpty()) {
|
||||
prepareDimensionRestore(server);
|
||||
ModdedDimensionInjection<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> restore =
|
||||
RESTORE.getAndSet(null);
|
||||
if (restore == null) {
|
||||
return;
|
||||
}
|
||||
finishDimensionRestore(restore);
|
||||
}
|
||||
|
||||
static void finishDimensionRestore(ModdedDimensionInjection<?, ?, ?> restore) {
|
||||
long startedAt = System.currentTimeMillis();
|
||||
List<ModdedDimensionInjection.Result> results;
|
||||
try (restore) {
|
||||
results = restore.finish();
|
||||
}
|
||||
long waited = System.currentTimeMillis() - startedAt;
|
||||
int injected = 0;
|
||||
int index = 0;
|
||||
long startedAt = System.currentTimeMillis();
|
||||
for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) {
|
||||
OutOfMemoryError outOfMemory = null;
|
||||
for (ModdedDimensionInjection.Result result : results) {
|
||||
index++;
|
||||
long dimensionStartedAt = System.currentTimeMillis();
|
||||
try {
|
||||
ModdedDimensionManager.restorePersistent(
|
||||
server,
|
||||
dimension.id(),
|
||||
dimension.pack(),
|
||||
dimension.dimension(),
|
||||
dimension.seed()
|
||||
);
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension = result.dimension();
|
||||
Throwable failure = result.failure();
|
||||
if (failure == null) {
|
||||
injected++;
|
||||
ModdedIrisLog.info("Iris re-injected {}/{} '{}' (pack={} dim={}) in {}ms",
|
||||
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
|
||||
System.currentTimeMillis() - dimensionStartedAt);
|
||||
} catch (Throwable e) {
|
||||
reportDimensionFailure(dimension, e);
|
||||
if (e instanceof OutOfMemoryError outOfMemory) {
|
||||
throw outOfMemory;
|
||||
}
|
||||
ModdedIrisLog.info("Iris re-injected {}/{} '{}' (pack={} dim={}): prepared {}ms, engine {}ms",
|
||||
index, results.size(), dimension.id(), dimension.pack(), dimension.dimension(),
|
||||
result.preparedMillis(), result.builtMillis());
|
||||
continue;
|
||||
}
|
||||
reportDimensionFailure(dimension, failure);
|
||||
if (failure instanceof OutOfMemoryError fatal && outOfMemory == null) {
|
||||
outOfMemory = fatal;
|
||||
}
|
||||
}
|
||||
ModdedIrisLog.info("Iris re-injected {}/{} persistent dimension(s) at startup in {}ms",
|
||||
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
|
||||
ModdedIrisLog.info("Iris re-injected {}/{} persistent dimension(s) at startup; server thread waited {}ms",
|
||||
injected, results.size(), waited);
|
||||
if (outOfMemory != null) {
|
||||
throw outOfMemory;
|
||||
}
|
||||
}
|
||||
|
||||
private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) {
|
||||
@@ -304,4 +340,44 @@ public final class ModdedStartup {
|
||||
}
|
||||
}
|
||||
|
||||
private record PersistentRestoreSteps(NativeModdedServer server)
|
||||
implements ModdedDimensionInjection.Steps<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> {
|
||||
@Override
|
||||
public ModdedWorldEngines.PreparedPack prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) {
|
||||
return ModdedWorldEngines.preparePersistent(server, dimension);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModdedDimensionManager.PendingInjection begin(
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension,
|
||||
ModdedWorldEngines.PreparedPack prepared
|
||||
) {
|
||||
return ModdedDimensionManager.beginInjection(
|
||||
server, dimension, prepared.active(), ModdedGenerationMode.PERSISTENT_RESTORE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Engine build(ModdedDimensionManager.PendingInjection level, ModdedWorldEngines.PreparedPack prepared) {
|
||||
if (level.present()) {
|
||||
prepared.close();
|
||||
return null;
|
||||
}
|
||||
return ModdedWorldEngines.createPrepared(level.handle().level(), level.handle().seed(), prepared);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete(ModdedDimensionManager.PendingInjection level, Engine engine) {
|
||||
ModdedDimensionManager.completeInjection(server, level, engine);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void abandon(ModdedDimensionManager.PendingInjection level, Engine engine, Throwable failure) {
|
||||
ModdedDimensionManager.abandonInjection(server, level, engine, failure);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void discard(ModdedWorldEngines.PreparedPack prepared) {
|
||||
prepared.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+200
-42
@@ -20,6 +20,7 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldDimensions;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
|
||||
@@ -31,6 +32,7 @@ import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.EngineTarget;
|
||||
import art.arcane.iris.world.history.GenerationActivation;
|
||||
import art.arcane.iris.world.history.GenerationAdmission;
|
||||
import art.arcane.iris.world.history.GenerationEpoch;
|
||||
import art.arcane.iris.world.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.world.history.GenerationHistory;
|
||||
@@ -48,8 +50,10 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class ModdedWorldEngines {
|
||||
@@ -91,6 +95,19 @@ public final class ModdedWorldEngines {
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> evictUnpublished(NativeModdedServer server, Set<String> levelKeys) {
|
||||
List<String> evicted = new ArrayList<>();
|
||||
for (NativeWorld level : new ArrayList<>(ENGINES.keySet())) {
|
||||
if (!levelKeys.contains(level.name())
|
||||
|| NativeDimensionRuntime.sameWorld(server.world(level.name()), level)) {
|
||||
continue;
|
||||
}
|
||||
evict(level);
|
||||
evicted.add(level.name());
|
||||
}
|
||||
return evicted;
|
||||
}
|
||||
|
||||
static void evictOrThrow(NativeWorld level) {
|
||||
Engine[] removed = new Engine[1];
|
||||
ENGINES.computeIfPresent(level, (NativeWorld ignored, Engine current) -> {
|
||||
@@ -128,6 +145,60 @@ public final class ModdedWorldEngines {
|
||||
});
|
||||
}
|
||||
|
||||
static PreparedPack preparePersistent(
|
||||
NativeModdedServer server,
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension
|
||||
) {
|
||||
ModdedEngineBootstrap.bind();
|
||||
return prepared(ModdedGenerationHistoryStorage.prepareRestore(
|
||||
server,
|
||||
dimension.id(),
|
||||
dimension.pack(),
|
||||
dimension.dimension(),
|
||||
dimension.seed()
|
||||
));
|
||||
}
|
||||
|
||||
static PreparedPack prepareCreated(
|
||||
NativeModdedServer server,
|
||||
ModdedDimensionRegistryStore.PersistentDimension dimension
|
||||
) {
|
||||
ModdedEngineBootstrap.bind();
|
||||
return prepared(ModdedGenerationHistoryStorage.prepareCreate(
|
||||
server,
|
||||
dimension.id(),
|
||||
dimension.pack(),
|
||||
dimension.dimension(),
|
||||
dimension.seed()
|
||||
));
|
||||
}
|
||||
|
||||
private static PreparedPack prepared(ModdedGenerationHistoryStorage.PreparedHistory prepared) {
|
||||
try {
|
||||
ModdedDimensionMetadata.warmConfiguredPack(prepared.active().packRoot().toFile(), prepared.active().dimensionKey());
|
||||
return new PreparedPack(prepared.active(), prepared.lease(), loadPack(prepared.active()));
|
||||
} catch (RuntimeException | Error failure) {
|
||||
prepared.lease().close();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
static Engine createPrepared(NativeWorld level, long seedOverride, PreparedPack prepared) {
|
||||
ModdedEngineBootstrap.bind();
|
||||
long seed = seedOverride == Long.MIN_VALUE ? level.seed() : seedOverride;
|
||||
try (prepared) {
|
||||
return attachHistoryEngine(level, seed, prepared.active(), prepared.take());
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris generation history could not initialize runtime routing for '"
|
||||
+ level.name() + "'.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static void installPrepared(NativeWorld level, Engine engine) {
|
||||
applyWorldBoundary(level, engine);
|
||||
installReplacement(level, engine);
|
||||
}
|
||||
|
||||
static void closeUnregistered(Engine engine) {
|
||||
close(engine);
|
||||
ENGINES.entrySet().removeIf((Map.Entry<NativeWorld, Engine> entry) -> entry.getValue() == engine);
|
||||
@@ -142,9 +213,13 @@ public final class ModdedWorldEngines {
|
||||
ModdedGenerationMode generationMode
|
||||
) {
|
||||
Engine engine = create(level, pack, dimensionKey, seedOverride, generationMode);
|
||||
applyWorldBoundary(level, engine);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static void applyWorldBoundary(NativeWorld level, Engine engine) {
|
||||
try {
|
||||
engine.getPlatformHooks().applyWorldBoundary(engine);
|
||||
return engine;
|
||||
} catch (Throwable failure) {
|
||||
try {
|
||||
close(engine);
|
||||
@@ -197,57 +272,63 @@ public final class ModdedWorldEngines {
|
||||
GenerationHistory history = opened.history();
|
||||
try (GenerationAdmission.RuntimeLease startupAdmission = history.retainRuntime()) {
|
||||
history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks());
|
||||
opened = ModdedGenerationHistoryStorage.resolveActive(history);
|
||||
long openedActivationId = history.activeActivation().activationId();
|
||||
IrisEngine candidate = buildEngine(level, seed, opened);
|
||||
boolean ready = false;
|
||||
try {
|
||||
GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory(
|
||||
ModdedGenerationHistoryStorage.ActivePack active = ModdedGenerationHistoryStorage.resolveActive(history, null);
|
||||
return attachHistoryEngine(level, seed, active, loadPack(active));
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisEngine attachHistoryEngine(
|
||||
NativeWorld level,
|
||||
long seed,
|
||||
ModdedGenerationHistoryStorage.ActivePack active,
|
||||
LoadedPack loaded
|
||||
) throws IOException {
|
||||
GenerationHistory history = active.history();
|
||||
long openedActivationId = history.activeActivation().activationId();
|
||||
IrisEngine candidate = buildEngine(level, seed, active, loaded);
|
||||
boolean ready = false;
|
||||
try {
|
||||
GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory(
|
||||
history,
|
||||
IrisBoundarySignatureSampler.INSTANCE,
|
||||
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
|
||||
);
|
||||
router.preloadActiveRuntimes();
|
||||
if (history.activeActivation().activationId() != openedActivationId) {
|
||||
close(candidate);
|
||||
ModdedGenerationHistoryStorage.ActivePack promoted =
|
||||
ModdedGenerationHistoryStorage.resolveActive(history, null);
|
||||
candidate = buildEngine(level, seed, promoted, loadPack(promoted));
|
||||
router = GenerationHistoryRuntimeRouter.attach(
|
||||
candidate,
|
||||
history,
|
||||
IrisBoundarySignatureSampler.INSTANCE,
|
||||
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
|
||||
IrisBoundarySignatureSampler.INSTANCE
|
||||
);
|
||||
router.preloadActiveRuntimes();
|
||||
if (history.activeActivation().activationId() != openedActivationId) {
|
||||
close(candidate);
|
||||
ModdedGenerationHistoryStorage.ActivePack promoted =
|
||||
ModdedGenerationHistoryStorage.resolveActive(history);
|
||||
candidate = buildEngine(level, seed, promoted);
|
||||
router = GenerationHistoryRuntimeRouter.attach(
|
||||
candidate,
|
||||
history,
|
||||
IrisBoundarySignatureSampler.INSTANCE
|
||||
);
|
||||
router.preloadActiveRuntimes();
|
||||
}
|
||||
requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE);
|
||||
ready = true;
|
||||
ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}",
|
||||
level.name(),
|
||||
candidate.getData().getDataFolder().getAbsolutePath(),
|
||||
candidate.getDimension().getLoadKey(),
|
||||
seed,
|
||||
candidate.getDimension().getMinHeight(),
|
||||
candidate.getDimension().getMaxHeight(),
|
||||
history.activeActivation().activationId());
|
||||
return candidate;
|
||||
} finally {
|
||||
if (!ready) {
|
||||
close(candidate);
|
||||
}
|
||||
}
|
||||
requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE);
|
||||
ready = true;
|
||||
ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}",
|
||||
level.name(),
|
||||
candidate.getData().getDataFolder().getAbsolutePath(),
|
||||
candidate.getDimension().getLoadKey(),
|
||||
seed,
|
||||
candidate.getDimension().getMinHeight(),
|
||||
candidate.getDimension().getMaxHeight(),
|
||||
history.activeActivation().activationId());
|
||||
return candidate;
|
||||
} finally {
|
||||
if (!ready) {
|
||||
close(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisEngine buildEngine(
|
||||
NativeWorld level,
|
||||
long seed,
|
||||
ModdedGenerationHistoryStorage.ActivePack active
|
||||
) throws IOException {
|
||||
private static LoadedPack loadPack(ModdedGenerationHistoryStorage.ActivePack active) {
|
||||
File packDir = active.packRoot().toFile();
|
||||
PackValidationRegistry.requireLoadable(active.packRoot());
|
||||
IrisData data = IrisData.openRuntime(packDir);
|
||||
boolean runtimeOwnsData = false;
|
||||
boolean loaded = false;
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load(active.dimensionKey());
|
||||
if (dimension == null) {
|
||||
@@ -256,7 +337,26 @@ public final class ModdedWorldEngines {
|
||||
throw new IllegalStateException("Iris dimension '" + active.dimensionKey()
|
||||
+ "' missing from immutable pack " + packDir.getAbsolutePath());
|
||||
}
|
||||
data.generationPackFingerprint();
|
||||
loaded = true;
|
||||
return new LoadedPack(data, dimension);
|
||||
} finally {
|
||||
if (!loaded) {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisEngine buildEngine(
|
||||
NativeWorld level,
|
||||
long seed,
|
||||
ModdedGenerationHistoryStorage.ActivePack active,
|
||||
LoadedPack loaded
|
||||
) throws IOException {
|
||||
IrisData data = loaded.data();
|
||||
IrisDimension dimension = loaded.dimension();
|
||||
boolean runtimeOwnsData = false;
|
||||
try {
|
||||
validateDimensionContract(active.dimensionContract(), dimension, level);
|
||||
File worldFolder = NativeModdedServer.forWorld(level).dimensionFolder(level.name()).toFile();
|
||||
IrisWorld world = buildWorld(level, seed, dimension, worldFolder);
|
||||
@@ -391,6 +491,15 @@ public final class ModdedWorldEngines {
|
||||
(actualType.maximumHeight() - actualType.minimumHeight()),
|
||||
actualType.logicalHeight());
|
||||
String runtimeName = "Modded level '" + level.name() + "'";
|
||||
if (dimension.hasBorrowedDimensionType()) {
|
||||
if (!recorded.dimensionTypeKey().equals(dimension.getBorrowedDimensionType().trim())) {
|
||||
throw new IllegalStateException("Immutable Iris dimension contract for '" + level.name()
|
||||
+ "' records dimension type " + recorded.dimensionTypeKey() + ", but its pack borrows "
|
||||
+ dimension.getBorrowedDimensionType().trim() + ".");
|
||||
}
|
||||
expected.requireBorrowedFit(runtimeName, actual);
|
||||
return;
|
||||
}
|
||||
expected.requireExact(runtimeName, actual);
|
||||
expected.requireHeight(runtimeName, level.minHeight(), (level.maxHeight() - level.minHeight()));
|
||||
}
|
||||
@@ -419,6 +528,55 @@ public final class ModdedWorldEngines {
|
||||
}
|
||||
}
|
||||
|
||||
private record LoadedPack(IrisData data, IrisDimension dimension) {
|
||||
private LoadedPack {
|
||||
data = Objects.requireNonNull(data, "data");
|
||||
dimension = Objects.requireNonNull(dimension, "dimension");
|
||||
}
|
||||
}
|
||||
|
||||
static final class PreparedPack implements AutoCloseable {
|
||||
private final ModdedGenerationHistoryStorage.ActivePack active;
|
||||
private final GenerationAdmission.RuntimeLease lease;
|
||||
private LoadedPack loaded;
|
||||
|
||||
private PreparedPack(
|
||||
ModdedGenerationHistoryStorage.ActivePack active,
|
||||
GenerationAdmission.RuntimeLease lease,
|
||||
LoadedPack loaded
|
||||
) {
|
||||
this.active = Objects.requireNonNull(active, "active");
|
||||
this.lease = Objects.requireNonNull(lease, "lease");
|
||||
this.loaded = Objects.requireNonNull(loaded, "loaded");
|
||||
}
|
||||
|
||||
ModdedGenerationHistoryStorage.ActivePack active() {
|
||||
return active;
|
||||
}
|
||||
|
||||
private synchronized LoadedPack take() {
|
||||
LoadedPack taken = loaded;
|
||||
if (taken == null) {
|
||||
throw new IllegalStateException("Iris prepared pack for " + active.packRoot() + " was already used");
|
||||
}
|
||||
loaded = null;
|
||||
return taken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
LoadedPack unused = loaded;
|
||||
loaded = null;
|
||||
try {
|
||||
if (unused != null) {
|
||||
unused.data().close();
|
||||
}
|
||||
} finally {
|
||||
lease.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void shutdown() {
|
||||
Throwable failure = null;
|
||||
for (Map.Entry<NativeWorld, Engine> entry : new ArrayList<>(ENGINES.entrySet())) {
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class ModdedWorldQuarantine {
|
||||
private ModdedWorldQuarantine() {
|
||||
}
|
||||
|
||||
static List<Path> move(Path worldRoot, List<Path> sources, Path recovery) throws IOException {
|
||||
List<Path> moved = new ArrayList<>(sources.size());
|
||||
try {
|
||||
for (Path source : sources) {
|
||||
moveOne(worldRoot, source, recovery, moved);
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
rollback(worldRoot, recovery, moved, failure);
|
||||
throw failure;
|
||||
}
|
||||
if (moved.isEmpty()) {
|
||||
Files.deleteIfExists(recovery);
|
||||
}
|
||||
return List.copyOf(moved);
|
||||
}
|
||||
|
||||
private static void moveOne(Path worldRoot, Path source, Path recovery, List<Path> moved) throws IOException {
|
||||
if (!Files.exists(source)) {
|
||||
return;
|
||||
}
|
||||
Path relative = worldRoot.relativize(source);
|
||||
if (relative.isAbsolute() || relative.startsWith("..")) {
|
||||
throw new IOException("Iris refuses to quarantine " + source + " outside the world " + worldRoot);
|
||||
}
|
||||
Path target = recovery.resolve(relative);
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.move(source, target);
|
||||
moved.add(relative);
|
||||
}
|
||||
|
||||
private static void rollback(Path worldRoot, Path recovery, List<Path> moved, IOException failure) {
|
||||
for (int index = moved.size() - 1; index >= 0; index--) {
|
||||
Path relative = moved.get(index);
|
||||
Path rollbackTarget = worldRoot.resolve(relative);
|
||||
try {
|
||||
Files.createDirectories(rollbackTarget.getParent());
|
||||
Files.move(recovery.resolve(relative), rollbackTarget);
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContract.PhysicalResourceKey;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public final class ModdedWorldgenIds {
|
||||
private static final String NAMESPACE = "irisworldgen";
|
||||
@@ -22,6 +28,12 @@ public final class ModdedWorldgenIds {
|
||||
return NAMESPACE + ":" + scopedPath(pack, dimension) + "/dimension_type";
|
||||
}
|
||||
|
||||
public static String dimensionTypeRef(String pack, IrisDimension dimension) {
|
||||
return dimension.hasBorrowedDimensionType()
|
||||
? dimension.getBorrowedDimensionType().trim()
|
||||
: dimensionTypeRef(pack, dimension.getLoadKey());
|
||||
}
|
||||
|
||||
public static String biomePathPrefix(String pack, String dimension) {
|
||||
return scopedPath(pack, dimension) + "/biomes";
|
||||
}
|
||||
@@ -61,6 +73,41 @@ public final class ModdedWorldgenIds {
|
||||
return Optional.of(new ScopedDimension(pack, dimension));
|
||||
}
|
||||
|
||||
static Set<String> aliasPacks(GenerationRegistryContract contract, String dimension) {
|
||||
Set<String> packs = new TreeSet<>();
|
||||
for (PhysicalResourceKey key : contract.definitions().keySet()) {
|
||||
if (!GenerationRegistryContractFactory.BIOME_REGISTRY.equals(key.registryKey())) {
|
||||
continue;
|
||||
}
|
||||
Optional<ScopedDimension> scoped = scopedBiome(key.resourceKey());
|
||||
if (scoped.isPresent() && scoped.get().dimension().equals(dimension)) {
|
||||
packs.add(scoped.get().pack());
|
||||
}
|
||||
}
|
||||
return packs;
|
||||
}
|
||||
|
||||
static Optional<ScopedDimension> scopedBiome(String biomeRef) {
|
||||
String prefix = NAMESPACE + ":";
|
||||
if (biomeRef == null || !biomeRef.startsWith(prefix)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String[] parts = biomeRef.substring(prefix.length()).split("/", 6);
|
||||
if (parts.length != 6
|
||||
|| !"packs".equals(parts[0])
|
||||
|| !"dimensions".equals(parts[2])
|
||||
|| !"biomes".equals(parts[4])
|
||||
|| parts[5].isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String pack = decode(parts[1]);
|
||||
String dimension = decode(parts[3]);
|
||||
if (pack == null || dimension == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new ScopedDimension(pack, dimension));
|
||||
}
|
||||
|
||||
public static String displayName(String presetPath) {
|
||||
String[] parts = presetPath.split("/");
|
||||
if (parts.length != 5 || !"packs".equals(parts[0])
|
||||
|
||||
+24
-1
@@ -22,6 +22,7 @@ package art.arcane.iris.modded;
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldDimensions;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection.Dimension;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
@@ -34,6 +35,9 @@ final class WorldCheckDimensionContract {
|
||||
static boolean checkDimensionType(NativeWorld level, IrisModdedChunkGenerator generator) {
|
||||
try {
|
||||
IrisDimension dimension = generator.commandEngine().getDimension();
|
||||
if (dimension.hasBorrowedDimensionType()) {
|
||||
return checkBorrowedDimensionType(level, dimension);
|
||||
}
|
||||
Dimension expected = expectedDimensionContract(dimension);
|
||||
Dimension actual = new NativeWorldInspection(level).dimension();
|
||||
boolean pass = matchesDimensionContract(level.minHeight(), (level.maxHeight() - level.minHeight()), expected, actual);
|
||||
@@ -54,6 +58,24 @@ final class WorldCheckDimensionContract {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean checkBorrowedDimensionType(NativeWorld level, IrisDimension dimension) {
|
||||
String borrowed = dimension.getBorrowedDimensionType().trim();
|
||||
String actualKey = NativeWorldDimensions.settings(level).key();
|
||||
boolean pass = borrowed.equals(actualKey)
|
||||
&& dimension.getMinHeight() == level.minHeight()
|
||||
&& dimension.getMaxHeight() <= level.maxHeight();
|
||||
String detail = "borrowed=" + borrowed + ",actual=" + actualKey
|
||||
+ ",packRange=" + dimension.getMinHeight() + ".." + dimension.getMaxHeight()
|
||||
+ ",levelRange=" + level.minHeight() + ".." + level.maxHeight();
|
||||
WorldCheckPredicates.qaEvent("dimension_type", dimension.getLoadKey(), pass, detail);
|
||||
if (!pass) {
|
||||
ModdedIrisLog.error("[worldcheck] borrowed dimension type mismatch for {}: {}", dimension.getLoadKey(), detail);
|
||||
} else {
|
||||
ModdedIrisLog.info("[worldcheck] borrowed dimension type contract: {}", detail);
|
||||
}
|
||||
return pass;
|
||||
}
|
||||
|
||||
static Dimension expectedDimensionContract(IrisDimension dimension) {
|
||||
JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get()));
|
||||
return new Dimension(
|
||||
@@ -64,7 +86,8 @@ final class WorldCheckDimensionContract {
|
||||
(float) json.getDouble("ambient_light"),
|
||||
json.getBoolean("has_skylight"),
|
||||
json.getBoolean("has_ceiling"),
|
||||
json.getBoolean("has_ender_dragon_fight"),
|
||||
json.has("has_ender_dragon_fight") ? json.getBoolean("has_ender_dragon_fight")
|
||||
: "minecraft:the_end".equals(json.optString("effects")),
|
||||
json.getInt("monster_spawn_block_light_limit"));
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.catalog;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModMetadata;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record CatalogCaptureContext(String minecraftVersion, String platform, long capturedAtEpochMillis, List<NativeModMetadata> mods) {
|
||||
public CatalogCaptureContext {
|
||||
Objects.requireNonNull(minecraftVersion, "minecraftVersion");
|
||||
Objects.requireNonNull(platform, "platform");
|
||||
mods = List.copyOf(Objects.requireNonNull(mods, "mods"));
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.catalog;
|
||||
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedIrisLog;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogStore;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenScan;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.EncodeFailures;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public final class ModdedWorldgenCatalogs {
|
||||
private static final AtomicReference<WorldgenCatalog> LATEST = new AtomicReference<>();
|
||||
private static final AtomicReference<CompletableFuture<CaptureResult>> RUNNING = new AtomicReference<>();
|
||||
|
||||
private ModdedWorldgenCatalogs() {
|
||||
}
|
||||
|
||||
public static Optional<WorldgenCatalog> latest() {
|
||||
return Optional.ofNullable(LATEST.get());
|
||||
}
|
||||
|
||||
public static Path catalogFile() {
|
||||
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("catalog")
|
||||
.resolve(WorldgenCatalogStore.FILE_NAME);
|
||||
}
|
||||
|
||||
public static CompletableFuture<CaptureResult> capture(NativeModdedServer server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
return capture(() -> scan(server));
|
||||
}
|
||||
|
||||
static CompletableFuture<CaptureResult> capture(CaptureScan scan) {
|
||||
CompletableFuture<CaptureResult> pending = new CompletableFuture<>();
|
||||
CompletableFuture<CaptureResult> running = RUNNING.compareAndExchange(null, pending);
|
||||
if (running != null) {
|
||||
return running;
|
||||
}
|
||||
CaptureWork work;
|
||||
try {
|
||||
work = scan.scan();
|
||||
} catch (Throwable failure) {
|
||||
ModdedIrisLog.error("Iris could not read the worldgen registries for the catalog", failure);
|
||||
finish(pending, null, failure);
|
||||
rethrowIfVirtualMachineError(failure);
|
||||
return pending;
|
||||
}
|
||||
Thread worker = new Thread(() -> complete(work, pending), "Iris Worldgen Catalog");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
return pending;
|
||||
}
|
||||
|
||||
private static CaptureWork scan(NativeModdedServer server) {
|
||||
long startedAt = System.nanoTime();
|
||||
NativeModdedLoader loader = ModdedEngineBootstrap.loader();
|
||||
NativeWorldgenScan scan = NativeWorldgenScan.scan(server, loader);
|
||||
CatalogCaptureContext context = new CatalogCaptureContext(loader.minecraftVersion(), loader.platformName(),
|
||||
System.currentTimeMillis(), loader.mods());
|
||||
PendingCapture capture = new PendingCapture(scan, context, catalogFile(), startedAt, elapsedMillis(startedAt));
|
||||
return () -> encodeAndWrite(capture);
|
||||
}
|
||||
|
||||
private static void complete(CaptureWork work, CompletableFuture<CaptureResult> pending) {
|
||||
try {
|
||||
finish(pending, work.run(), null);
|
||||
} catch (IOException failure) {
|
||||
ModdedIrisLog.error("Iris could not write the worldgen catalog", failure);
|
||||
finish(pending, null, new UncheckedIOException(failure));
|
||||
} catch (Throwable failure) {
|
||||
ModdedIrisLog.error("Iris could not build the worldgen catalog", failure);
|
||||
finish(pending, null, failure);
|
||||
rethrowIfVirtualMachineError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static CaptureResult encodeAndWrite(PendingCapture capture) throws IOException {
|
||||
NativeWorldgenSnapshot snapshot = capture.scan().encode();
|
||||
WorldgenCatalog catalog = WorldgenCatalogAssembler.assemble(snapshot, capture.context());
|
||||
WorldgenCatalogStore.write(capture.file(), catalog);
|
||||
LATEST.set(catalog);
|
||||
CaptureResult result = new CaptureResult(catalog, capture.file(), elapsedMillis(capture.startedAt()));
|
||||
ModdedIrisLog.info("Iris worldgen catalog: {} namespaces, {} biomes, {} placed features, {} structures, "
|
||||
+ "{} dimension types, {} level stems -> {} ({}ms server thread, {}ms total)",
|
||||
catalog.namespaces().size(), catalog.biomes().size(), catalog.placedFeatures().size(),
|
||||
catalog.structures().size(), catalog.dimensionTypes().size(), catalog.levelStems().size(),
|
||||
capture.file(), capture.scanMillis(), result.millis());
|
||||
reportEncodeFailures(snapshot.failures());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void reportEncodeFailures(EncodeFailures failures) {
|
||||
if (failures.count() == 0) {
|
||||
return;
|
||||
}
|
||||
String first = failures.samples().isEmpty() ? "" : failures.samples().getFirst();
|
||||
if (failures.firstException() != null) {
|
||||
ModdedIrisLog.warn("Iris worldgen catalog left {} entries without JSON; first: {}", failures.count(), first,
|
||||
failures.firstException());
|
||||
return;
|
||||
}
|
||||
ModdedIrisLog.warn("Iris worldgen catalog left {} entries without JSON; first: {}", failures.count(), first);
|
||||
}
|
||||
|
||||
private static void finish(CompletableFuture<CaptureResult> pending, CaptureResult result, Throwable failure) {
|
||||
RUNNING.compareAndSet(pending, null);
|
||||
if (failure != null) {
|
||||
pending.completeExceptionally(failure);
|
||||
return;
|
||||
}
|
||||
pending.complete(result);
|
||||
}
|
||||
|
||||
private static void rethrowIfVirtualMachineError(Throwable failure) {
|
||||
if (failure instanceof VirtualMachineError virtualMachineError) {
|
||||
throw virtualMachineError;
|
||||
}
|
||||
}
|
||||
|
||||
private static long elapsedMillis(long startedAt) {
|
||||
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
|
||||
}
|
||||
|
||||
public record CaptureResult(WorldgenCatalog catalog, Path file, long millis) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface CaptureScan {
|
||||
CaptureWork scan();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface CaptureWork {
|
||||
CaptureResult run() throws IOException;
|
||||
}
|
||||
|
||||
private record PendingCapture(NativeWorldgenScan scan, CatalogCaptureContext context, Path file, long startedAt, long scanMillis) {
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.catalog;
|
||||
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogDimensionType;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogLevelStem;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogNamespace;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogSpawn;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogStructure;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogFingerprint;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModMetadata;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenBiome;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenDimensionType;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenLevelStem;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenPlacedFeature;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenSpawn;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenStructure;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public final class WorldgenCatalogAssembler {
|
||||
private static final String MINECRAFT = "minecraft";
|
||||
|
||||
private WorldgenCatalogAssembler() {
|
||||
}
|
||||
|
||||
public static WorldgenCatalog assemble(NativeWorldgenSnapshot snapshot, CatalogCaptureContext context) {
|
||||
Objects.requireNonNull(snapshot, "snapshot");
|
||||
Objects.requireNonNull(context, "context");
|
||||
List<CatalogBiome> biomes = biomes(snapshot.biomes());
|
||||
List<CatalogPlacedFeature> placedFeatures = placedFeatures(snapshot.placedFeatures());
|
||||
List<CatalogStructure> structures = structures(snapshot.structures());
|
||||
List<CatalogDimensionType> dimensionTypes = dimensionTypes(snapshot.dimensionTypes());
|
||||
List<CatalogLevelStem> levelStems = levelStems(snapshot.levelStems());
|
||||
Map<String, List<String>> blockTags = referencedBlockTags(placedFeatures, snapshot.blockTags());
|
||||
Map<String, List<String>> registryKeys = new LinkedHashMap<>();
|
||||
registryKeys.put("biome", biomes.stream().map(CatalogBiome::key).toList());
|
||||
registryKeys.put("placed_feature", placedFeatures.stream().map(CatalogPlacedFeature::key).toList());
|
||||
registryKeys.put("structure", structures.stream().map(CatalogStructure::key).toList());
|
||||
registryKeys.put("dimension_type", dimensionTypes.stream().map(CatalogDimensionType::key).toList());
|
||||
registryKeys.put("level_stem", levelStems.stream().map(CatalogLevelStem::key).toList());
|
||||
registryKeys.put("block_tag", List.copyOf(blockTags.keySet()));
|
||||
List<CatalogNamespace> namespaces = namespaces(registryKeys, context);
|
||||
return new WorldgenCatalog(WorldgenCatalog.FORMAT, context.minecraftVersion(), context.platform(),
|
||||
context.capturedAtEpochMillis(), WorldgenCatalogFingerprint.of(namespaces, registryKeys), namespaces,
|
||||
biomes, placedFeatures, structures, dimensionTypes, levelStems, blockTags);
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> referencedBlockTags(List<CatalogPlacedFeature> features,
|
||||
Map<String, List<String>> available) {
|
||||
Set<String> referenced = new TreeSet<>();
|
||||
for (CatalogPlacedFeature feature : features) {
|
||||
collectTagMatches(feature.config(), referenced);
|
||||
}
|
||||
Map<String, List<String>> tags = new TreeMap<>();
|
||||
for (String tag : referenced) {
|
||||
List<String> members = available.get(tag);
|
||||
if (members != null) {
|
||||
tags.put(tag, members);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
private static void collectTagMatches(JsonElement element, Set<String> tags) {
|
||||
if (element == null || element.isJsonNull() || element.isJsonPrimitive()) {
|
||||
return;
|
||||
}
|
||||
if (element.isJsonArray()) {
|
||||
for (JsonElement entry : element.getAsJsonArray()) {
|
||||
collectTagMatches(entry, tags);
|
||||
}
|
||||
return;
|
||||
}
|
||||
JsonObject object = element.getAsJsonObject();
|
||||
JsonElement predicate = object.get("predicate_type");
|
||||
JsonElement tag = object.get("tag");
|
||||
if (predicate != null && predicate.isJsonPrimitive() && predicate.getAsString().endsWith("tag_match")
|
||||
&& tag != null && tag.isJsonPrimitive()) {
|
||||
String key = tag.getAsString();
|
||||
tags.add(key.contains(":") ? key : MINECRAFT + ":" + key);
|
||||
}
|
||||
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
|
||||
collectTagMatches(entry.getValue(), tags);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CatalogNamespace> namespaces(Map<String, List<String>> registryKeys, CatalogCaptureContext context) {
|
||||
Map<String, CatalogNamespace> namespaces = new TreeMap<>();
|
||||
for (NativeModMetadata mod : context.mods()) {
|
||||
namespaces.put(mod.id(), new CatalogNamespace(mod.id(), mod.displayName(), mod.version()));
|
||||
}
|
||||
for (List<String> keys : registryKeys.values()) {
|
||||
for (String key : keys) {
|
||||
String namespace = WorldgenCatalogIndex.namespaceOf(key);
|
||||
if (!namespaces.containsKey(namespace)) {
|
||||
namespaces.put(namespace, fallbackNamespace(namespace, context));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!namespaces.containsKey(MINECRAFT)) {
|
||||
namespaces.put(MINECRAFT, fallbackNamespace(MINECRAFT, context));
|
||||
}
|
||||
return List.copyOf(namespaces.values());
|
||||
}
|
||||
|
||||
private static CatalogNamespace fallbackNamespace(String namespace, CatalogCaptureContext context) {
|
||||
if (MINECRAFT.equals(namespace)) {
|
||||
return new CatalogNamespace(MINECRAFT, "Minecraft", context.minecraftVersion());
|
||||
}
|
||||
return new CatalogNamespace(namespace, namespace, "");
|
||||
}
|
||||
|
||||
private static List<CatalogBiome> biomes(List<WorldgenBiome> biomes) {
|
||||
List<CatalogBiome> result = new ArrayList<>(biomes.size());
|
||||
for (WorldgenBiome biome : biomes) {
|
||||
List<CatalogSpawn> spawns = new ArrayList<>(biome.spawns().size());
|
||||
for (WorldgenSpawn spawn : biome.spawns()) {
|
||||
spawns.add(new CatalogSpawn(spawn.category(), spawn.entity(), spawn.weight(), spawn.minCount(), spawn.maxCount()));
|
||||
}
|
||||
result.add(new CatalogBiome(biome.key(), biome.temperature(), biome.downfall(), biome.hasPrecipitation(),
|
||||
biome.tags(), biome.featureSteps(), spawns, biome.definition()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<CatalogPlacedFeature> placedFeatures(List<WorldgenPlacedFeature> features) {
|
||||
List<CatalogPlacedFeature> result = new ArrayList<>(features.size());
|
||||
for (WorldgenPlacedFeature feature : features) {
|
||||
result.add(new CatalogPlacedFeature(feature.key(), feature.feature(), feature.featureType(),
|
||||
feature.config(), feature.placement()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<CatalogStructure> structures(List<WorldgenStructure> structures) {
|
||||
List<CatalogStructure> result = new ArrayList<>(structures.size());
|
||||
for (WorldgenStructure structure : structures) {
|
||||
result.add(new CatalogStructure(structure.key(), structure.type(), structure.step(),
|
||||
structure.terrainAdaptation(), structure.biomeTag(), structure.biomes(), structure.structureSets()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<CatalogDimensionType> dimensionTypes(List<WorldgenDimensionType> types) {
|
||||
List<CatalogDimensionType> result = new ArrayList<>(types.size());
|
||||
for (WorldgenDimensionType type : types) {
|
||||
result.add(new CatalogDimensionType(type.key(), type.minY(), type.height(), type.logicalHeight(), type.definition()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<CatalogLevelStem> levelStems(List<WorldgenLevelStem> stems) {
|
||||
List<CatalogLevelStem> result = new ArrayList<>(stems.size());
|
||||
for (WorldgenLevelStem stem : stems) {
|
||||
result.add(new CatalogLevelStem(stem.key(), stem.dimensionType(), stem.generatorType(),
|
||||
stem.biomeSourceType(), stem.generator()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.catalog;
|
||||
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogDimensionType;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogLevelStem;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogNamespace;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
|
||||
import art.arcane.iris.pack.ingest.catalog.CatalogStructure;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class WorldgenCatalogSummary {
|
||||
private static final String VANILLA_PREFIX = "minecraft:";
|
||||
|
||||
private WorldgenCatalogSummary() {
|
||||
}
|
||||
|
||||
public static List<NamespaceSummary> namespaces(WorldgenCatalog catalog) {
|
||||
Map<String, Builder> builders = new TreeMap<>();
|
||||
for (CatalogNamespace namespace : catalog.namespaces()) {
|
||||
builders.put(namespace.namespace(), new Builder(namespace));
|
||||
}
|
||||
collect(builders, catalog.biomes(), CatalogBiome::key, Builder::biomes);
|
||||
collect(builders, catalog.placedFeatures(), CatalogPlacedFeature::key, Builder::placedFeatures);
|
||||
collect(builders, catalog.structures(), CatalogStructure::key, Builder::structures);
|
||||
collect(builders, catalog.dimensionTypes(), CatalogDimensionType::key, Builder::dimensionTypes);
|
||||
collect(builders, catalog.levelStems(), CatalogLevelStem::key, Builder::levelStems);
|
||||
for (CatalogPlacedFeature feature : catalog.placedFeatures()) {
|
||||
builder(builders, WorldgenCatalogIndex.namespaceOf(feature.key())).featureTypes()
|
||||
.merge(feature.featureType(), 1, Integer::sum);
|
||||
}
|
||||
List<NamespaceSummary> summaries = new ArrayList<>(builders.size());
|
||||
for (Builder builder : builders.values()) {
|
||||
summaries.add(builder.build());
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
public static Optional<NamespaceSummary> namespace(WorldgenCatalog catalog, String namespace) {
|
||||
for (NamespaceSummary summary : namespaces(catalog)) {
|
||||
if (summary.namespace().namespace().equals(namespace)) {
|
||||
return Optional.of(summary);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static String featureTypes(List<TypeCount> types, int limit) {
|
||||
List<String> labels = new ArrayList<>(Math.min(types.size(), limit));
|
||||
for (int index = 0; index < types.size() && index < limit; index++) {
|
||||
TypeCount type = types.get(index);
|
||||
labels.add(shortType(type.type()) + " " + type.count());
|
||||
}
|
||||
return withRemainder(String.join(", ", labels), types.size() - labels.size());
|
||||
}
|
||||
|
||||
public static String limited(List<String> keys, String namespace, int limit) {
|
||||
List<String> labels = new ArrayList<>(Math.min(keys.size(), limit));
|
||||
String ownPrefix = namespace + ":";
|
||||
for (int index = 0; index < keys.size() && index < limit; index++) {
|
||||
String key = keys.get(index);
|
||||
labels.add(key.startsWith(ownPrefix) ? key.substring(ownPrefix.length()) : key);
|
||||
}
|
||||
return withRemainder(String.join(", ", labels), keys.size() - labels.size());
|
||||
}
|
||||
|
||||
private static String shortType(String type) {
|
||||
return type.startsWith(VANILLA_PREFIX) ? type.substring(VANILLA_PREFIX.length()) : type;
|
||||
}
|
||||
|
||||
private static String withRemainder(String joined, int remainder) {
|
||||
return remainder > 0 ? joined + " (+" + remainder + " more)" : joined;
|
||||
}
|
||||
|
||||
private static <T> void collect(Map<String, Builder> builders, List<T> entries, Function<T, String> key,
|
||||
Function<Builder, List<String>> target) {
|
||||
for (T entry : entries) {
|
||||
String entryKey = key.apply(entry);
|
||||
target.apply(builder(builders, WorldgenCatalogIndex.namespaceOf(entryKey))).add(entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static Builder builder(Map<String, Builder> builders, String namespace) {
|
||||
return builders.computeIfAbsent(namespace, (String missing) -> new Builder(new CatalogNamespace(missing, missing, "")));
|
||||
}
|
||||
|
||||
public record TypeCount(String type, int count) {
|
||||
public TypeCount {
|
||||
Objects.requireNonNull(type, "type");
|
||||
}
|
||||
}
|
||||
|
||||
public record NamespaceSummary(
|
||||
CatalogNamespace namespace,
|
||||
List<String> biomes,
|
||||
List<String> placedFeatures,
|
||||
List<TypeCount> featureTypes,
|
||||
List<String> structures,
|
||||
List<String> dimensionTypes,
|
||||
List<String> levelStems) {
|
||||
public NamespaceSummary {
|
||||
Objects.requireNonNull(namespace, "namespace");
|
||||
biomes = List.copyOf(biomes);
|
||||
placedFeatures = List.copyOf(placedFeatures);
|
||||
featureTypes = List.copyOf(featureTypes);
|
||||
structures = List.copyOf(structures);
|
||||
dimensionTypes = List.copyOf(dimensionTypes);
|
||||
levelStems = List.copyOf(levelStems);
|
||||
}
|
||||
|
||||
public boolean hasContent() {
|
||||
return !biomes.isEmpty() || !placedFeatures.isEmpty() || !structures.isEmpty()
|
||||
|| !dimensionTypes.isEmpty() || !levelStems.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private record Builder(
|
||||
CatalogNamespace namespace,
|
||||
List<String> biomes,
|
||||
List<String> placedFeatures,
|
||||
Map<String, Integer> featureTypes,
|
||||
List<String> structures,
|
||||
List<String> dimensionTypes,
|
||||
List<String> levelStems) {
|
||||
private Builder(CatalogNamespace namespace) {
|
||||
this(namespace, new ArrayList<>(), new ArrayList<>(), new TreeMap<>(), new ArrayList<>(), new ArrayList<>(),
|
||||
new ArrayList<>());
|
||||
}
|
||||
|
||||
private NamespaceSummary build() {
|
||||
List<TypeCount> types = new ArrayList<>(featureTypes.size());
|
||||
for (Map.Entry<String, Integer> entry : featureTypes.entrySet()) {
|
||||
types.add(new TypeCount(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
types.sort(Comparator.comparingInt(TypeCount::count).reversed().thenComparing(TypeCount::type));
|
||||
return new NamespaceSummary(namespace, sorted(biomes), sorted(placedFeatures), types, sorted(structures),
|
||||
sorted(dimensionTypes), sorted(levelStems));
|
||||
}
|
||||
|
||||
private static List<String> sorted(List<String> keys) {
|
||||
List<String> copy = new ArrayList<>(keys);
|
||||
copy.sort(null);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -294,16 +294,14 @@ public final class IrisModdedCommands {
|
||||
}
|
||||
Engine engine = irisGenerator.engineIfBound();
|
||||
if (engine == null) {
|
||||
lines.add(irisIdentity + ": pack=" + irisGenerator.dimensionKey()
|
||||
+ " world=" + dimensionId + " (engine not started yet)");
|
||||
lines.add(dimensionId + ": pack=" + irisGenerator.dimensionKey() + " (engine not started yet)");
|
||||
continue;
|
||||
}
|
||||
String featureStatus = irisGenerator.importedFeaturesStatus();
|
||||
lines.add(irisIdentity + ": pack=" + engine.getDimension().getLoadKey()
|
||||
+ " world=" + dimensionId
|
||||
+ (showSeed ? " seed=" + level.seed() : "")
|
||||
lines.add(dimensionId + ": pack=" + engine.getDimension().getLoadKey()
|
||||
+ (showSeed ? " seed=" + engine.getWorld().getRawWorldSeed() : "")
|
||||
+ " height=" + engine.getMinHeight() + ".." + engine.getMaxHeight()
|
||||
+ " generated=" + engine.getGenerated()
|
||||
+ " generated=" + engine.getEngineData().getStatistics().getChunksGenerated()
|
||||
+ (featureStatus == null ? "" : " importedFeatures=" + featureStatus)
|
||||
+ " data=" + engine.getData().getDataFolder().getName());
|
||||
}
|
||||
@@ -329,7 +327,7 @@ public final class IrisModdedCommands {
|
||||
fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_THIS_DIMENSION_IS_NOT_GENERATED_BY_IRIS_14));
|
||||
return 0;
|
||||
}
|
||||
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_WORLD_SEED, MessageArgument.untrusted("value", level.seed())));
|
||||
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_WORLD_SEED, MessageArgument.untrusted("value", engine.getWorld().getRawWorldSeed())));
|
||||
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_SEED_MIXED, MessageArgument.untrusted("value", engine.getSeedManager().getSeed()), MessageArgument.untrusted("value2", engine.getSeedManager().getFullMixedSeed())));
|
||||
return 1;
|
||||
}
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
record ModIngestArguments(String sourcePack, String targetPack, String dimension, Set<String> namespaces,
|
||||
Set<String> excludedRegions, boolean replace) {
|
||||
static final List<String> OPTION_SUGGESTIONS = List.of("dimension=", "namespaces=", "excludeRegions=", "replace=true");
|
||||
private static final Pattern PACK = Pattern.compile("[A-Za-z0-9_.-]+");
|
||||
private static final Pattern TARGET_PACK = Pattern.compile("[A-Za-z0-9_-]+");
|
||||
private static final Pattern DIMENSION = Pattern.compile("[A-Za-z0-9_/.-]+");
|
||||
private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+");
|
||||
private static final Pattern REGION = Pattern.compile("[A-Za-z0-9_/.-]+");
|
||||
private static final String DIMENSION_KEY = "dimension";
|
||||
private static final String NAMESPACES_KEY = "namespaces";
|
||||
private static final String REPLACE_KEY = "replace";
|
||||
private static final String EXCLUDE_REGIONS_KEY = "excludeRegions";
|
||||
|
||||
ModIngestArguments {
|
||||
Objects.requireNonNull(sourcePack, "sourcePack");
|
||||
Objects.requireNonNull(targetPack, "targetPack");
|
||||
Objects.requireNonNull(dimension, "dimension");
|
||||
namespaces = Set.copyOf(Objects.requireNonNull(namespaces, "namespaces"));
|
||||
excludedRegions = Set.copyOf(Objects.requireNonNull(excludedRegions, "excludedRegions"));
|
||||
}
|
||||
|
||||
static ModIngestArguments parse(String raw) {
|
||||
String trimmed = raw == null ? "" : raw.trim();
|
||||
String[] tokens = trimmed.isEmpty() ? new String[0] : trimmed.split("\\s+");
|
||||
String source = null;
|
||||
String target = null;
|
||||
Map<String, String> options = new HashMap<>(4);
|
||||
for (String token : tokens) {
|
||||
int equals = token.indexOf('=');
|
||||
if (equals < 0) {
|
||||
if (source == null) {
|
||||
source = token;
|
||||
} else if (target == null) {
|
||||
target = token;
|
||||
} else {
|
||||
throw new IllegalArgumentException("unexpected argument '" + token + "'");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String key = token.substring(0, equals);
|
||||
if (!key.equals(DIMENSION_KEY) && !key.equals(NAMESPACES_KEY) && !key.equals(REPLACE_KEY)
|
||||
&& !key.equals(EXCLUDE_REGIONS_KEY)) {
|
||||
throw new IllegalArgumentException("unknown option '" + key + "'");
|
||||
}
|
||||
if (options.putIfAbsent(key, token.substring(equals + 1)) != null) {
|
||||
throw new IllegalArgumentException("option '" + key + "' given twice");
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
throw new IllegalArgumentException("missing source pack");
|
||||
}
|
||||
if (target == null) {
|
||||
throw new IllegalArgumentException("missing target pack");
|
||||
}
|
||||
requirePack(source);
|
||||
requirePack(target);
|
||||
if (!TARGET_PACK.matcher(target).matches()) {
|
||||
throw new IllegalArgumentException("invalid target pack name '" + target + "'");
|
||||
}
|
||||
if (source.equals(target)) {
|
||||
throw new IllegalArgumentException("source and target must be different packs");
|
||||
}
|
||||
String dimension = options.getOrDefault(DIMENSION_KEY, source);
|
||||
if (!DIMENSION.matcher(dimension).matches() || dimension.contains("..")) {
|
||||
throw new IllegalArgumentException("invalid dimension key '" + dimension + "'");
|
||||
}
|
||||
return new ModIngestArguments(source, target, dimension, namespaces(options.get(NAMESPACES_KEY)),
|
||||
regions(options.get(EXCLUDE_REGIONS_KEY)), replace(options.get(REPLACE_KEY)));
|
||||
}
|
||||
|
||||
private static void requirePack(String pack) {
|
||||
if (!PACK.matcher(pack).matches() || pack.contains("..")) {
|
||||
throw new IllegalArgumentException("invalid pack name '" + pack + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> namespaces(String raw) {
|
||||
if (raw == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> namespaces = new LinkedHashSet<>();
|
||||
for (String part : raw.split(",")) {
|
||||
if (part.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (!NAMESPACE.matcher(part).matches()) {
|
||||
throw new IllegalArgumentException("invalid namespace '" + part + "'");
|
||||
}
|
||||
namespaces.add(part);
|
||||
}
|
||||
if (namespaces.isEmpty()) {
|
||||
throw new IllegalArgumentException("namespaces must not be empty");
|
||||
}
|
||||
return namespaces;
|
||||
}
|
||||
|
||||
private static Set<String> regions(String raw) {
|
||||
if (raw == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> regions = new LinkedHashSet<>();
|
||||
for (String part : raw.split(",")) {
|
||||
if (part.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (!REGION.matcher(part).matches() || part.contains("..")) {
|
||||
throw new IllegalArgumentException("invalid region '" + part + "'");
|
||||
}
|
||||
regions.add(part);
|
||||
}
|
||||
if (regions.isEmpty()) {
|
||||
throw new IllegalArgumentException("excludeRegions must not be empty");
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
private static boolean replace(String raw) {
|
||||
if (raw == null || raw.equals("false")) {
|
||||
return false;
|
||||
}
|
||||
if (raw.equals("true")) {
|
||||
return true;
|
||||
}
|
||||
throw new IllegalArgumentException("replace must be true or false");
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.modded.ModdedIrisLog;
|
||||
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs;
|
||||
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs.CaptureResult;
|
||||
import art.arcane.iris.modded.catalog.WorldgenCatalogSummary;
|
||||
import art.arcane.iris.modded.catalog.WorldgenCatalogSummary.NamespaceSummary;
|
||||
import art.arcane.iris.modded.localization.ModdedCatalogMessages;
|
||||
import art.arcane.iris.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.pack.ingest.ModIngestReport;
|
||||
import art.arcane.iris.pack.ingest.ModIngestRequest;
|
||||
import art.arcane.iris.pack.ingest.ModIngester;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandRegistration;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.suggestion.SuggestionProvider;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
final class ModdedCatalogCommands {
|
||||
private static final Predicate<NativeCommandSource> GATE = NativeCommandRegistration.GAMEMASTERS;
|
||||
private static final int LIST_FEATURE_TYPES = 3;
|
||||
private static final int DETAIL_LIMIT = 24;
|
||||
private static final String NONE = "-";
|
||||
private static final AtomicReference<NamespaceCache> NAMESPACE_CACHE = new AtomicReference<>();
|
||||
private static final SuggestionProvider<NativeCommandSource> NAMESPACES = (CommandContext<NativeCommandSource> context,
|
||||
SuggestionsBuilder builder) ->
|
||||
NativeCommandRegistration.suggest(contentNamespaces(), builder);
|
||||
private static final SuggestionProvider<NativeCommandSource> INGEST_ARGUMENTS = ModdedCatalogCommands::suggestIngestArguments;
|
||||
|
||||
private ModdedCatalogCommands() {
|
||||
}
|
||||
|
||||
static LiteralArgumentBuilder<NativeCommandSource> catalogTree() {
|
||||
return NativeCommandRegistration.literal("catalog").requires(GATE)
|
||||
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> list(context.getSource())))
|
||||
.then(NativeCommandRegistration.literal("refresh")
|
||||
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> refresh(context.getSource()))))
|
||||
.then(NativeCommandRegistration.argument("namespace", StringArgumentType.word()).suggests(NAMESPACES)
|
||||
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) ->
|
||||
detail(context.getSource(), StringArgumentType.getString(context, "namespace")))));
|
||||
}
|
||||
|
||||
static LiteralArgumentBuilder<NativeCommandSource> ingestTree() {
|
||||
return NativeCommandRegistration.literal("ingest").requires(GATE)
|
||||
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> ModdedCommandHelp.send(context.getSource(), "ingest")))
|
||||
.then(NativeCommandRegistration.literal("mods")
|
||||
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> ingest(context.getSource(), "")))
|
||||
.then(NativeCommandRegistration.argument("arguments", StringArgumentType.greedyString()).suggests(INGEST_ARGUMENTS)
|
||||
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) ->
|
||||
ingest(context.getSource(), StringArgumentType.getString(context, "arguments"))))));
|
||||
}
|
||||
|
||||
private static int list(NativeCommandSource source) {
|
||||
Optional<WorldgenCatalog> latest = ModdedWorldgenCatalogs.latest();
|
||||
if (latest.isEmpty()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.MISSING));
|
||||
return 0;
|
||||
}
|
||||
WorldgenCatalog catalog = latest.get();
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.HEADER,
|
||||
MessageArgument.untrusted("name", catalog.platform()),
|
||||
MessageArgument.untrusted("version", catalog.minecraftVersion()),
|
||||
MessageArgument.untrusted("count", catalog.namespaces().size()),
|
||||
MessageArgument.untrusted("value", catalog.biomes().size()),
|
||||
MessageArgument.untrusted("value2", catalog.placedFeatures().size()),
|
||||
MessageArgument.untrusted("value3", catalog.structures().size()),
|
||||
MessageArgument.untrusted("value4", catalog.dimensionTypes().size()),
|
||||
MessageArgument.untrusted("value5", catalog.levelStems().size())));
|
||||
int quiet = 0;
|
||||
for (NamespaceSummary summary : WorldgenCatalogSummary.namespaces(catalog)) {
|
||||
if (!summary.hasContent()) {
|
||||
quiet++;
|
||||
continue;
|
||||
}
|
||||
IrisModdedCommands.ok(source, namespaceLine(summary));
|
||||
}
|
||||
if (quiet > 0) {
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.QUIET, MessageArgument.untrusted("count", quiet)));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int detail(NativeCommandSource source, String namespace) {
|
||||
Optional<WorldgenCatalog> latest = ModdedWorldgenCatalogs.latest();
|
||||
if (latest.isEmpty()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.MISSING));
|
||||
return 0;
|
||||
}
|
||||
Optional<NamespaceSummary> found = WorldgenCatalogSummary.namespace(latest.get(), namespace);
|
||||
if (found.isEmpty() || !found.get().hasContent()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.UNKNOWN_NAMESPACE,
|
||||
MessageArgument.untrusted("name", namespace)));
|
||||
return 0;
|
||||
}
|
||||
NamespaceSummary summary = found.get();
|
||||
IrisModdedCommands.ok(source, namespaceLine(summary));
|
||||
if (!summary.biomes().isEmpty()) {
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.DETAIL_BIOMES,
|
||||
MessageArgument.untrusted("count", summary.biomes().size()),
|
||||
MessageArgument.untrusted("value", WorldgenCatalogSummary.limited(summary.biomes(), namespace, DETAIL_LIMIT))));
|
||||
}
|
||||
if (!summary.placedFeatures().isEmpty()) {
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.DETAIL_FEATURES,
|
||||
MessageArgument.untrusted("count", summary.placedFeatures().size()),
|
||||
MessageArgument.untrusted("value", WorldgenCatalogSummary.featureTypes(summary.featureTypes(), DETAIL_LIMIT))));
|
||||
}
|
||||
if (!summary.structures().isEmpty()) {
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.DETAIL_STRUCTURES,
|
||||
MessageArgument.untrusted("count", summary.structures().size()),
|
||||
MessageArgument.untrusted("value", WorldgenCatalogSummary.limited(summary.structures(), namespace, DETAIL_LIMIT))));
|
||||
}
|
||||
if (!summary.dimensionTypes().isEmpty() || !summary.levelStems().isEmpty()) {
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.DETAIL_DIMENSIONS,
|
||||
MessageArgument.untrusted("value", orNone(WorldgenCatalogSummary.limited(summary.dimensionTypes(), namespace, DETAIL_LIMIT))),
|
||||
MessageArgument.untrusted("value2", orNone(WorldgenCatalogSummary.limited(summary.levelStems(), namespace, DETAIL_LIMIT)))));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int refresh(NativeCommandSource source) {
|
||||
NativeModdedServer server = source.server();
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.REFRESHING));
|
||||
ModdedWorldgenCatalogs.capture(server).whenComplete((CaptureResult result, Throwable failure) ->
|
||||
source.execute(() -> reportRefresh(source, result, failure)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void reportRefresh(NativeCommandSource source, CaptureResult result, Throwable failure) {
|
||||
if (failure != null) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.REFRESH_FAILED,
|
||||
MessageArgument.untrusted("reason", reason(failure))));
|
||||
return;
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.REFRESHED,
|
||||
MessageArgument.untrusted("duration", result.millis() + " ms"),
|
||||
MessageArgument.untrusted("path", result.file().toString())));
|
||||
}
|
||||
|
||||
private static int ingest(NativeCommandSource source, String raw) {
|
||||
ModIngestArguments arguments;
|
||||
try {
|
||||
arguments = ModIngestArguments.parse(raw);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_INVALID,
|
||||
MessageArgument.untrusted("reason", invalid.getMessage())));
|
||||
return 0;
|
||||
}
|
||||
Optional<WorldgenCatalog> catalog = ModdedWorldgenCatalogs.latest();
|
||||
if (catalog.isEmpty()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.MISSING));
|
||||
return 0;
|
||||
}
|
||||
File packsRoot = ModdedPackCommands.packsRoot();
|
||||
File sourcePack = PackDirectoryResolver.resolveExisting(packsRoot, arguments.sourcePack());
|
||||
if (sourcePack == null) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_PACK_MISSING,
|
||||
MessageArgument.untrusted("pack", arguments.sourcePack()),
|
||||
MessageArgument.untrusted("path", packsRoot.getAbsolutePath())));
|
||||
return 0;
|
||||
}
|
||||
Path targetPack = packsRoot.toPath().toAbsolutePath().normalize().resolve(arguments.targetPack());
|
||||
ModIngestRequest request = new ModIngestRequest(sourcePack.toPath(), targetPack, arguments.dimension(),
|
||||
catalog.get(), arguments.namespaces(), arguments.excludedRegions(), arguments.replace());
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_STARTED,
|
||||
MessageArgument.untrusted("source", arguments.sourcePack()),
|
||||
MessageArgument.untrusted("target", arguments.targetPack()),
|
||||
MessageArgument.untrusted("dimension", arguments.dimension())));
|
||||
Thread worker = new Thread(() -> runIngest(source, request), "Iris Mod Ingest");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void runIngest(NativeCommandSource source, ModIngestRequest request) {
|
||||
try {
|
||||
ModIngestReport report = ModIngester.ingest(request);
|
||||
source.execute(() -> reportIngest(source, report));
|
||||
} catch (FileAlreadyExistsException exists) {
|
||||
source.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_TARGET_EXISTS,
|
||||
MessageArgument.untrusted("target", request.targetPack().getFileName().toString()))));
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
ModdedIrisLog.error("Iris mod ingest into " + request.targetPack() + " failed", failure);
|
||||
String reason = reason(failure);
|
||||
source.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_FAILED,
|
||||
MessageArgument.untrusted("reason", reason))));
|
||||
}
|
||||
}
|
||||
|
||||
private static void reportIngest(NativeCommandSource source, ModIngestReport report) {
|
||||
for (String line : report.summaryLines()) {
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_LINE, MessageArgument.untrusted("summary", line)));
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_COMPLETE,
|
||||
MessageArgument.untrusted("path", report.targetPack().toString())));
|
||||
}
|
||||
|
||||
private static String namespaceLine(NamespaceSummary summary) {
|
||||
String version = summary.namespace().version().isEmpty() ? NONE : summary.namespace().version();
|
||||
return IrisLanguage.plain(ModdedCatalogMessages.NAMESPACE,
|
||||
MessageArgument.untrusted("name", summary.namespace().namespace()),
|
||||
MessageArgument.untrusted("version", version),
|
||||
MessageArgument.untrusted("value", summary.biomes().size()),
|
||||
MessageArgument.untrusted("value2", summary.placedFeatures().size()),
|
||||
MessageArgument.untrusted("detail", orNone(WorldgenCatalogSummary.featureTypes(summary.featureTypes(), LIST_FEATURE_TYPES))),
|
||||
MessageArgument.untrusted("value3", summary.structures().size()),
|
||||
MessageArgument.untrusted("value4", summary.dimensionTypes().size()),
|
||||
MessageArgument.untrusted("value5", summary.levelStems().size()));
|
||||
}
|
||||
|
||||
private static String orNone(String value) {
|
||||
return value.isEmpty() ? NONE : value;
|
||||
}
|
||||
|
||||
private static String reason(Throwable failure) {
|
||||
Throwable cause = failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure;
|
||||
String message = cause.getMessage();
|
||||
return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
private static List<String> contentNamespaces() {
|
||||
Optional<WorldgenCatalog> latest = ModdedWorldgenCatalogs.latest();
|
||||
if (latest.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
NamespaceCache cached = NAMESPACE_CACHE.get();
|
||||
if (cached != null && cached.catalog() == latest.get()) {
|
||||
return cached.namespaces();
|
||||
}
|
||||
List<String> namespaces = new ArrayList<>();
|
||||
for (NamespaceSummary summary : WorldgenCatalogSummary.namespaces(latest.get())) {
|
||||
if (summary.hasContent()) {
|
||||
namespaces.add(summary.namespace().namespace());
|
||||
}
|
||||
}
|
||||
NamespaceCache created = new NamespaceCache(latest.get(), List.copyOf(namespaces));
|
||||
NAMESPACE_CACHE.set(created);
|
||||
return created.namespaces();
|
||||
}
|
||||
|
||||
private static CompletableFuture<Suggestions> suggestIngestArguments(CommandContext<NativeCommandSource> context,
|
||||
SuggestionsBuilder builder) {
|
||||
String remaining = builder.getRemaining();
|
||||
int lastSpace = remaining.lastIndexOf(' ');
|
||||
int bareArguments = 0;
|
||||
List<String> usedOptions = new ArrayList<>(3);
|
||||
for (String token : remaining.substring(0, Math.max(0, lastSpace)).trim().split("\\s+")) {
|
||||
if (token.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int equals = token.indexOf('=');
|
||||
if (equals < 0) {
|
||||
bareArguments++;
|
||||
} else {
|
||||
usedOptions.add(token.substring(0, equals + 1));
|
||||
}
|
||||
}
|
||||
SuggestionsBuilder current = builder.createOffset(builder.getStart() + lastSpace + 1);
|
||||
if (bareArguments < 2) {
|
||||
List<String> packs = new ArrayList<>();
|
||||
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(ModdedPackCommands.packsRoot())) {
|
||||
packs.add(pack.getName());
|
||||
}
|
||||
return NativeCommandRegistration.suggest(packs, current);
|
||||
}
|
||||
List<String> options = new ArrayList<>(ModIngestArguments.OPTION_SUGGESTIONS.size());
|
||||
for (String option : ModIngestArguments.OPTION_SUGGESTIONS) {
|
||||
if (!usedOptions.contains(option.substring(0, option.indexOf('=') + 1))) {
|
||||
options.add(option);
|
||||
}
|
||||
}
|
||||
return NativeCommandRegistration.suggest(options, current);
|
||||
}
|
||||
|
||||
private record NamespaceCache(WorldgenCatalog catalog, List<String> namespaces) {
|
||||
}
|
||||
}
|
||||
+8
@@ -18,8 +18,10 @@
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.localization.DirectorCommandMessages;
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.localization.IrisMessages;
|
||||
import art.arcane.iris.modded.localization.ModdedCatalogMessages;
|
||||
import art.arcane.iris.modded.localization.ModdedHelpMessages;
|
||||
import art.arcane.volmlib.util.director.help.DirectorHelpMessages;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
@@ -68,6 +70,8 @@ final class ModdedCommandHelp {
|
||||
Entry.command("debug", "", ModdedHelpMessages.COMMAND_DEBUG_TOGGLE_IRIS_DEBUG_LOGGING_AND_SAVE_SETTINGS_JSON),
|
||||
Entry.command("reload", "", ModdedHelpMessages.COMMAND_RELOAD_RELOAD_SETTINGS_JSON_ALSO_HOTLOADED_AUTOMATICALLY_EVERY_3S),
|
||||
Entry.command("download", "<pack=overworld|pack=underworld|link=<zip-url>> [overwrite=true]", ModdedHelpMessages.COMMAND_DOWNLOAD_DOWNLOAD_A_PACK_PROJECT, "dl"),
|
||||
Entry.command("catalog", "[namespace|refresh]", ModdedCatalogMessages.HELP_CATALOG),
|
||||
Entry.group("ingest", ModdedCatalogMessages.HELP_INGEST),
|
||||
Entry.command("metrics", "", ModdedHelpMessages.COMMAND_METRICS_PRINT_GENERATION_METRICS_FOR_YOUR_CURRENT_IRIS_DIMENSION, "measure"),
|
||||
Entry.command("regen", "[radius]", ModdedHelpMessages.COMMAND_REGEN_DELETE_AND_REGENERATE_NEARBY_CHUNKS_IN_PLACE, "rg"),
|
||||
Entry.group("pregen", ModdedHelpMessages.GROUP_PREGEN_PREGENERATE_AN_IRIS_DIMENSION, "pregenerate"),
|
||||
@@ -76,6 +80,7 @@ final class ModdedCommandHelp {
|
||||
Entry.group("object", ModdedHelpMessages.GROUP_OBJECT_OBJECT_WAND_SAVE_PASTE_ANALYZE_AND_UNDO_TOOLS, "o"),
|
||||
Entry.group("edit", ModdedHelpMessages.GROUP_EDIT_OPEN_PACK_BIOME_REGION_AND_DIMENSION_JSON_FILES_IN_YOUR_DESKTOP_EDITOR),
|
||||
Entry.command("create", "<name> [pack|pack:dimensionKey] [seed]", ModdedHelpMessages.COMMAND_CREATE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_QUOTE_PACK_DIMENSIONKEY_TO_PICK, "c"),
|
||||
Entry.command("replace", "<target> [type=<pack|pack:dimensionKey|off>] [seed=<seed>]", DirectorCommandMessages.COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART, "override", "overwrite"),
|
||||
Entry.command("height", "", ModdedHelpMessages.COMMAND_HEIGHT_PRINT_WORLD_HEIGHT),
|
||||
Entry.command("worlds", "", ModdedHelpMessages.COMMAND_WORLDS_LIST_WORLD_ACCESS, "accesslist"),
|
||||
Entry.group("studio", ModdedHelpMessages.GROUP_STUDIO_PACK_PROJECT_CREATION_PACKAGING_AND_REPORTS, "std", "s"),
|
||||
@@ -165,6 +170,9 @@ final class ModdedCommandHelp {
|
||||
Entry.command("compat", "[pack]", ModdedHelpMessages.COMMAND_COMPAT_SHOW_CONTENT_THAT_CANNOT_GENERATE_ON_THIS_MINECRAFT_VERSION, "cp")
|
||||
));
|
||||
SECTIONS.put("pk", SECTIONS.get("pack"));
|
||||
SECTIONS.put("ingest", List.of(
|
||||
Entry.command("mods", "<source> <target> [dimension=<key>] [namespaces=<a,b>] [excludeRegions=<a,b>] [replace=true]", ModdedCatalogMessages.HELP_INGEST_MODS)
|
||||
));
|
||||
SECTIONS.put("world", List.of(
|
||||
Entry.command("enable", "<dimension> <pack|pack:dimensionKey> [seed|random]", ModdedHelpMessages.COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK, "create"),
|
||||
Entry.command("replace-overworld", "<pack|pack:dimensionKey> [seed|random]", ModdedHelpMessages.COMMAND_REPLACE_OVERWORLD_INJECT_AN_IRIS_PRIMARY_WORLD_AND_ROUTE_PLAYERS_THERE_INSTEAD_OF_THE),
|
||||
|
||||
+6
@@ -114,6 +114,9 @@ final class ModdedCommandTree {
|
||||
root.then(downloadTree("download"));
|
||||
root.then(downloadTree("dl"));
|
||||
|
||||
root.then(ModdedCatalogCommands.catalogTree());
|
||||
root.then(ModdedCatalogCommands.ingestTree());
|
||||
|
||||
root.then(metricsTree("metrics"));
|
||||
root.then(metricsTree("measure"));
|
||||
|
||||
@@ -135,6 +138,9 @@ final class ModdedCommandTree {
|
||||
|
||||
root.then(createTree("create"));
|
||||
root.then(createTree("c"));
|
||||
root.then(ModdedReplaceCommands.tree("replace"));
|
||||
root.then(ModdedReplaceCommands.tree("override"));
|
||||
root.then(ModdedReplaceCommands.tree("overwrite"));
|
||||
|
||||
root.then(ModdedStudioCommands.tree("studio"));
|
||||
root.then(ModdedStudioCommands.tree("std"));
|
||||
|
||||
+3
@@ -163,6 +163,9 @@ public final class ModdedDatapackCommands {
|
||||
continue;
|
||||
}
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
if (dimension.hasBorrowedDimensionType()) {
|
||||
continue;
|
||||
}
|
||||
String json;
|
||||
try {
|
||||
json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get());
|
||||
|
||||
+64
-31
@@ -114,15 +114,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
if (!sync) {
|
||||
try {
|
||||
semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
ModdedIrisLog.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}",
|
||||
runtime.worldIdentity(), completed.get(), inFlightPeak.get(), adaptiveLimit.get());
|
||||
awaitPendingChunks();
|
||||
ModdedIrisLog.info("Iris modded pregen closing: dim={} completed={} pending={} peakInFlight={} finalLimit={}",
|
||||
runtime.worldIdentity(), completed.get(), inFlight.get(), inFlightPeak.get(), adaptiveLimit.get());
|
||||
try {
|
||||
engine.syncGenerationHistory();
|
||||
} catch (RuntimeException e) {
|
||||
@@ -180,6 +174,39 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
|| queuedFinalSave.get() != null;
|
||||
}
|
||||
|
||||
private void awaitPendingChunks() {
|
||||
if (sync || runtime.isServerThread()) {
|
||||
return;
|
||||
}
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||
+ TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS);
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while (inFlight.get() > 0) {
|
||||
if (finalSaveOnServerThread.get() || !runtime.isRunning()) {
|
||||
return;
|
||||
}
|
||||
if (System.nanoTime() >= deadline) {
|
||||
throw new IllegalStateException("Iris pregen chunk callbacks did not finish for "
|
||||
+ runtime.worldIdentity() + ": " + inFlight.get() + " pending");
|
||||
}
|
||||
try {
|
||||
synchronized (permitMonitor) {
|
||||
if (inFlight.get() > 0) {
|
||||
permitMonitor.wait(FINAL_SAVE_POLL_MILLIS);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void saveLevel(boolean wait) {
|
||||
if (wait) {
|
||||
saveFinalLevel();
|
||||
@@ -237,30 +264,36 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
|
||||
private void awaitFinalSave(FinalSaveRequest request) {
|
||||
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS);
|
||||
while (true) {
|
||||
if (deferFinalSaveIfRequested()) {
|
||||
return;
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while (true) {
|
||||
if (deferFinalSaveIfRequested()) {
|
||||
return;
|
||||
}
|
||||
long remainingNanos = deadline - System.nanoTime();
|
||||
if (remainingNanos <= 0L) {
|
||||
ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity());
|
||||
return;
|
||||
}
|
||||
long waitMillis = Math.max(1L, Math.min(FINAL_SAVE_POLL_MILLIS,
|
||||
TimeUnit.NANOSECONDS.toMillis(remainingNanos)));
|
||||
try {
|
||||
request.completion().get(waitMillis, TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
} catch (InterruptedException ignored) {
|
||||
interrupted = true;
|
||||
} catch (TimeoutException e) {
|
||||
continue;
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause() == null ? e : e.getCause();
|
||||
ModdedIrisLog.error("Iris pregen level save failed for {}", runtime.worldIdentity(), cause);
|
||||
throw new IllegalStateException("Iris pregen level save failed for "
|
||||
+ runtime.worldIdentity(), cause);
|
||||
}
|
||||
}
|
||||
long remainingNanos = deadline - System.nanoTime();
|
||||
if (remainingNanos <= 0L) {
|
||||
ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity());
|
||||
return;
|
||||
}
|
||||
long waitMillis = Math.max(1L, Math.min(FINAL_SAVE_POLL_MILLIS,
|
||||
TimeUnit.NANOSECONDS.toMillis(remainingNanos)));
|
||||
try {
|
||||
request.completion().get(waitMillis, TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
} catch (InterruptedException e) {
|
||||
} finally {
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (TimeoutException e) {
|
||||
continue;
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause() == null ? e : e.getCause();
|
||||
ModdedIrisLog.error("Iris pregen level save failed for {}", runtime.worldIdentity(), cause);
|
||||
throw new IllegalStateException("Iris pregen level save failed for "
|
||||
+ runtime.worldIdentity(), cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.modded.ModdedDimensionReplacement;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
record ModdedReplaceArguments(String target, String type, OptionalLong seed, boolean off) {
|
||||
static final String DEFAULT_TYPE = "default";
|
||||
static final String OFF = "off";
|
||||
static final String PRESERVE_SEED = "preserve";
|
||||
private static final String TARGET = "target";
|
||||
private static final String TYPE = "type";
|
||||
private static final String SEED = "seed";
|
||||
private static final List<String> POSITIONAL = List.of(TARGET, TYPE, SEED);
|
||||
private static final Map<String, String> KEYS = Map.of(
|
||||
TARGET, TARGET,
|
||||
"world-name", TARGET,
|
||||
TYPE, TYPE,
|
||||
"dimension", TYPE,
|
||||
"pack", TYPE,
|
||||
SEED, SEED,
|
||||
"s", SEED
|
||||
);
|
||||
|
||||
static ModdedReplaceArguments parse(String raw) {
|
||||
String input = raw == null ? "" : raw.trim();
|
||||
String[] values = new String[POSITIONAL.size()];
|
||||
String[] tokens = input.isEmpty() ? new String[0] : input.split("\\s+");
|
||||
for (String token : tokens) {
|
||||
int equals = token.indexOf('=');
|
||||
if (equals < 0) {
|
||||
continue;
|
||||
}
|
||||
String key = KEYS.get(token.substring(0, equals).toLowerCase(Locale.ROOT));
|
||||
if (key == null) {
|
||||
throw new IllegalArgumentException("Unknown argument '" + token.substring(0, equals)
|
||||
+ "'. Use target=, type=, or seed=.");
|
||||
}
|
||||
String value = token.substring(equals + 1);
|
||||
if (value.isEmpty()) {
|
||||
throw new IllegalArgumentException("Argument " + key + "= needs a value.");
|
||||
}
|
||||
int index = POSITIONAL.indexOf(key);
|
||||
if (values[index] != null) {
|
||||
throw new IllegalArgumentException("Argument " + key + " was given more than once.");
|
||||
}
|
||||
values[index] = value;
|
||||
}
|
||||
for (String token : tokens) {
|
||||
if (token.indexOf('=') >= 0) {
|
||||
continue;
|
||||
}
|
||||
int index = nextUnset(values);
|
||||
if (index < 0) {
|
||||
throw new IllegalArgumentException("Unexpected argument '" + token
|
||||
+ "'. Usage: /iris replace <target> [type=<pack|pack:dimensionKey|off>] [seed=<seed>]");
|
||||
}
|
||||
values[index] = token;
|
||||
}
|
||||
if (values[0] == null) {
|
||||
throw new IllegalArgumentException("Missing target. Usage: /iris replace <target> [type=<pack|pack:dimensionKey|off>] [seed=<seed>]");
|
||||
}
|
||||
String type = values[1] == null ? DEFAULT_TYPE : values[1];
|
||||
boolean off = OFF.equalsIgnoreCase(type);
|
||||
if (off && values[2] != null) {
|
||||
throw new IllegalArgumentException("Turning a replacement off takes no seed.");
|
||||
}
|
||||
return new ModdedReplaceArguments(values[0], type, parseSeed(values[2]), off);
|
||||
}
|
||||
|
||||
private static int nextUnset(String[] values) {
|
||||
for (int index = 0; index < values.length; index++) {
|
||||
if (values[index] == null) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static OptionalLong parseSeed(String raw) {
|
||||
if (raw == null || PRESERVE_SEED.equalsIgnoreCase(raw)) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
long seed;
|
||||
try {
|
||||
seed = Long.parseLong(raw);
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException("Seed must be a signed 64-bit integer");
|
||||
}
|
||||
return OptionalLong.of(ModdedDimensionReplacement.requireUsableSeed(seed));
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.localization.BukkitCommandMessagesExtended;
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.modded.ModdedDimensionRegistryStore;
|
||||
import art.arcane.iris.modded.ModdedDimensionReplacement;
|
||||
import art.arcane.iris.modded.ModdedDimensionReplacements;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedForcedDatapack;
|
||||
import art.arcane.iris.modded.ModdedIrisLog;
|
||||
import art.arcane.iris.modded.ModdedModConfig;
|
||||
import art.arcane.iris.modded.ModdedReplacementReconciler;
|
||||
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs;
|
||||
import art.arcane.iris.modded.localization.ModdedCommandMessages;
|
||||
import art.arcane.iris.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.pack.PackDownloader;
|
||||
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandRegistration;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
final class ModdedReplaceCommands {
|
||||
private static final Predicate<NativeCommandSource> GATE = NativeCommandRegistration.GAMEMASTERS;
|
||||
private static final String ARGUMENTS = "arguments";
|
||||
private static final String MISSING_TARGET = "/iris replace requires an existing exact world slot; use /iris create for a new world.";
|
||||
private static final String STAGE_FAILURE = "Could not stage the world replacement: ";
|
||||
private static final List<String> TARGET_NAMES = List.of("main", "nether", "end");
|
||||
private static final List<String> KEYS = List.of("target=", "type=", "seed=");
|
||||
|
||||
private ModdedReplaceCommands() {
|
||||
}
|
||||
|
||||
static LiteralArgumentBuilder<NativeCommandSource> tree(String name) {
|
||||
return NativeCommandRegistration.literal(name).requires(GATE)
|
||||
.then(NativeCommandRegistration.argument(ARGUMENTS, StringArgumentType.greedyString())
|
||||
.suggests(ModdedReplaceCommands::suggest)
|
||||
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> replace(
|
||||
context.getSource(),
|
||||
StringArgumentType.getString(context, ARGUMENTS)))));
|
||||
}
|
||||
|
||||
private static int replace(NativeCommandSource source, String raw) {
|
||||
NativeModdedServer server = source.server();
|
||||
ModdedReplaceArguments arguments;
|
||||
ModdedReplaceTarget target;
|
||||
try {
|
||||
arguments = ModdedReplaceArguments.parse(raw);
|
||||
target = ModdedReplaceTarget.resolve(arguments.target(), levelName(server));
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
IrisModdedCommands.fail(source, invalid.getMessage());
|
||||
return 0;
|
||||
}
|
||||
if (ModdedEngineBootstrap.loader().clientEnvironment()) {
|
||||
IrisModdedCommands.fail(source, "Iris replaces dimensions only on dedicated servers.");
|
||||
return 0;
|
||||
}
|
||||
if (arguments.off()) {
|
||||
return turnOff(source, server, target);
|
||||
}
|
||||
String resolvedType = arguments.type().equalsIgnoreCase(ModdedReplaceArguments.DEFAULT_TYPE)
|
||||
? IrisSettings.get().getGenerator().getDefaultWorldType()
|
||||
: arguments.type();
|
||||
String pack = packOf(resolvedType);
|
||||
IrisDimension dimension = installedDimension(pack, packDimensionOf(resolvedType));
|
||||
if (dimension == null) {
|
||||
reportDimensionNotFound(source, resolvedType);
|
||||
return 0;
|
||||
}
|
||||
if (ModdedWorldCommands.blockIfPackBroken(source, target.levelKey(), pack)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
ModdedReplaceTarget.requireCompatibleEnvironment(target.kind(), dimension.getEnvironment());
|
||||
ModdedDimensionReplacement requested = ModdedDimensionReplacements.canonical(
|
||||
ModdedDimensionReplacement.parse(target.levelKey(), resolvedType, arguments.seed()),
|
||||
ModdedPackCommands.packsRoot().toPath());
|
||||
ModdedDimensionReplacement staged = target.kind() == ModdedReplaceTarget.Kind.IRIS_WORLD
|
||||
? stageIrisWorld(server, requested)
|
||||
: stageLevel(server, requested);
|
||||
String seedDetail = arguments.seed().isEmpty()
|
||||
? " preserving seed " + staged.seed().getAsLong()
|
||||
: " using seed " + staged.seed().getAsLong();
|
||||
IrisModdedCommands.ok(source, "Staged Iris replacement for " + staged.levelKey() + seedDetail
|
||||
+ ". Restart once to publish it. On that restart the current terrain of the dimension moves to "
|
||||
+ "a replacement-recovery folder under config/irisworldgen; player data stays in the save.");
|
||||
return 1;
|
||||
} catch (IllegalArgumentException rejected) {
|
||||
IrisModdedCommands.fail(source, STAGE_FAILURE + rejected.getMessage());
|
||||
return 0;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
ModdedIrisLog.error("Iris failed to stage the world replacement for {}", target.levelKey(), failure);
|
||||
IrisModdedCommands.fail(source, STAGE_FAILURE + IrisLanguage.errorDetail(failure));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static ModdedDimensionReplacement stageLevel(NativeModdedServer server, ModdedDimensionReplacement requested)
|
||||
throws IOException {
|
||||
NativeWorld level = server.world(requested.levelKey());
|
||||
if (level == null) {
|
||||
throw new IllegalArgumentException(MISSING_TARGET);
|
||||
}
|
||||
Path worldRoot = server.root();
|
||||
requireReplaceableLevel(worldRoot, requested.levelKey());
|
||||
requireNothingPending(worldRoot, requested.levelKey());
|
||||
long seed = requested.seed().orElse(ModdedReplacementReconciler.applied(worldRoot, requested.levelKey())
|
||||
.map((ModdedDimensionReplacement applied) -> applied.seed().getAsLong())
|
||||
.orElse(level.seed()));
|
||||
ModdedDimensionReplacement staged = requested.withSeed(seed);
|
||||
ModdedReplacementReconciler.stagePending(worldRoot, staged);
|
||||
try {
|
||||
if (ModdedModConfig.setDimensionReplacement(staged)) {
|
||||
ModdedForcedDatapack.scheduleRegeneration("dimension replacement of " + staged.levelKey());
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
ModdedReplacementReconciler.cancelPending(worldRoot, staged.levelKey());
|
||||
throw failure;
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
private static ModdedDimensionReplacement stageIrisWorld(NativeModdedServer server, ModdedDimensionReplacement requested)
|
||||
throws IOException {
|
||||
ModdedDimensionRegistryStore.PersistentDimension registered =
|
||||
ModdedDimensionRegistryStore.get(server, requested.levelKey());
|
||||
if (registered == null) {
|
||||
throw new IllegalArgumentException(MISSING_TARGET);
|
||||
}
|
||||
Path worldRoot = server.root();
|
||||
requireNothingPending(worldRoot, requested.levelKey());
|
||||
ModdedDimensionReplacement staged = requested.withSeed(requested.seed().orElse(registered.seed()));
|
||||
ModdedReplacementReconciler.stagePending(worldRoot, staged);
|
||||
return staged;
|
||||
}
|
||||
|
||||
private static void requireReplaceableLevel(Path worldRoot, String levelKey) throws IOException {
|
||||
Optional<WorldgenCatalog> catalog = ModdedWorldgenCatalogs.latest();
|
||||
Set<String> applied = ModdedReplacementReconciler.applied(worldRoot, levelKey).isPresent()
|
||||
? Set.of(levelKey)
|
||||
: Set.of();
|
||||
ModdedDimensionReplacements.ReplaceableLevels levels = catalog.isEmpty()
|
||||
? ModdedDimensionReplacements.ReplaceableLevels.unknown()
|
||||
: ModdedDimensionReplacements.ReplaceableLevels.of(catalog.get().levelStems(), applied);
|
||||
if (levels.replaceable(levelKey)) {
|
||||
return;
|
||||
}
|
||||
if (!levels.known()) {
|
||||
throw new IllegalArgumentException(IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_WORLD_COMMANDS_REPLACE_REGISTRY_NOT_READY));
|
||||
}
|
||||
throw new IllegalArgumentException(IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_WORLD_COMMANDS_REPLACE_RUNTIME_LEVEL,
|
||||
MessageArgument.untrusted("dimensionId", levelKey)));
|
||||
}
|
||||
|
||||
private static void requireNothingPending(Path worldRoot, String levelKey) throws IOException {
|
||||
Map<String, ModdedDimensionReplacement> configured = ModdedModConfig.get().dimensionReplacements();
|
||||
if (ModdedReplacementReconciler.pending(worldRoot, levelKey, configured)) {
|
||||
throw new IllegalArgumentException("A replacement is already pending for " + levelKey + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static int turnOff(NativeCommandSource source, NativeModdedServer server, ModdedReplaceTarget target) {
|
||||
String levelKey = target.levelKey();
|
||||
try {
|
||||
Path worldRoot = server.root();
|
||||
boolean cancelled = ModdedReplacementReconciler.cancelPending(worldRoot, levelKey);
|
||||
boolean applied = target.kind() != ModdedReplaceTarget.Kind.IRIS_WORLD
|
||||
&& ModdedReplacementReconciler.applied(worldRoot, levelKey).isPresent();
|
||||
boolean cleared = target.kind() != ModdedReplaceTarget.Kind.IRIS_WORLD
|
||||
&& ModdedModConfig.clearDimensionReplacement(levelKey);
|
||||
if (cleared) {
|
||||
ModdedForcedDatapack.scheduleRegeneration("dimension replacement of " + levelKey + " turned off");
|
||||
}
|
||||
if (cleared && applied) {
|
||||
IrisModdedCommands.ok(source, "Turned off the Iris replacement of " + levelKey
|
||||
+ ". Restart once to return the dimension to its own generator; its Iris terrain moves to "
|
||||
+ "a replacement-recovery folder under config/irisworldgen.");
|
||||
return 1;
|
||||
}
|
||||
if (cleared || cancelled) {
|
||||
IrisModdedCommands.ok(source, "Cancelled the staged Iris replacement of " + levelKey + ".");
|
||||
return 1;
|
||||
}
|
||||
if (applied) {
|
||||
IrisModdedCommands.ok(source, "The Iris replacement of " + levelKey
|
||||
+ " is already turned off. Restart once to return the dimension to its own generator.");
|
||||
return 1;
|
||||
}
|
||||
IrisModdedCommands.fail(source, "No Iris replacement is staged or applied for " + levelKey + ".");
|
||||
return 0;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
ModdedIrisLog.error("Iris failed to turn off the replacement of {}", levelKey, failure);
|
||||
IrisModdedCommands.fail(source, "Could not turn off the world replacement: " + IrisLanguage.errorDetail(failure));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static String levelName(NativeModdedServer server) {
|
||||
Path levelFolder = server.root().toAbsolutePath().normalize().getFileName();
|
||||
return levelFolder == null ? "" : levelFolder.toString();
|
||||
}
|
||||
|
||||
private static IrisDimension installedDimension(String pack, String packDimension) {
|
||||
if (!pack.matches("[A-Za-z0-9_.-]+") || pack.contains("..")
|
||||
|| !packDimension.matches("[A-Za-z0-9_/.-]+") || packDimension.contains("..")) {
|
||||
return null;
|
||||
}
|
||||
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
|
||||
if (!packFolder.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
return IrisData.get(packFolder).getDimensionLoader().load(packDimension);
|
||||
}
|
||||
|
||||
private static void reportDimensionNotFound(NativeCommandSource source, String resolvedType) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
BukkitCommandMessagesExtended.COMMAND_IRIS_DIMENSION_NOT_FOUND,
|
||||
MessageArgument.untrusted("dimension", resolvedType)));
|
||||
List<String> installed = installedTypes(ModdedPackCommands.packsRoot());
|
||||
if (!installed.isEmpty()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_WORLD_COMMANDS_REPLACE_INSTALLED_TYPES,
|
||||
MessageArgument.untrusted("value", String.join(", ", installed))));
|
||||
}
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
BukkitCommandMessagesExtended.COMMAND_IRIS_INSTALL_PACK_AND_RESTART,
|
||||
MessageArgument.untrusted("command", PackDownloader.downloadCommandFor(packOf(resolvedType)))));
|
||||
}
|
||||
|
||||
static List<String> installedTypes(File packsRoot) {
|
||||
Set<String> types = new TreeSet<>();
|
||||
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsRoot)) {
|
||||
List<String> dimensionKeys = dimensionKeys(pack.toPath().resolve("dimensions"));
|
||||
if (dimensionKeys.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
types.add(pack.getName());
|
||||
for (String dimensionKey : dimensionKeys) {
|
||||
if (!dimensionKey.equals(pack.getName())) {
|
||||
types.add(pack.getName() + ":" + dimensionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(types);
|
||||
}
|
||||
|
||||
private static List<String> dimensionKeys(Path dimensions) {
|
||||
if (!Files.isDirectory(dimensions)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> keys = new ArrayList<>();
|
||||
try (Stream<Path> files = Files.walk(dimensions)) {
|
||||
for (Path file : files.filter(Files::isRegularFile).toList()) {
|
||||
String relative = dimensions.relativize(file).toString().replace(File.separatorChar, '/');
|
||||
if (relative.endsWith(".json")) {
|
||||
keys.add(relative.substring(0, relative.length() - ".json".length()));
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException unreadable) {
|
||||
ModdedIrisLog.warn("Iris could not list the dimensions of pack folder {}", dimensions.getParent(), unreadable);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static String packOf(String type) {
|
||||
String value = type.trim();
|
||||
int colon = value.indexOf(':');
|
||||
return colon >= 0 ? value.substring(0, colon) : value;
|
||||
}
|
||||
|
||||
private static String packDimensionOf(String type) {
|
||||
String value = type.trim();
|
||||
int colon = value.indexOf(':');
|
||||
return colon >= 0 ? value.substring(colon + 1) : value;
|
||||
}
|
||||
|
||||
private static CompletableFuture<Suggestions> suggest(CommandContext<NativeCommandSource> context,
|
||||
SuggestionsBuilder builder) throws CommandSyntaxException {
|
||||
String remaining = builder.getRemaining();
|
||||
int tokenStart = remaining.lastIndexOf(' ') + 1;
|
||||
String token = remaining.substring(tokenStart);
|
||||
int positional = 0;
|
||||
for (String previous : remaining.substring(0, tokenStart).trim().split("\\s+")) {
|
||||
if (!previous.isEmpty() && previous.indexOf('=') < 0) {
|
||||
positional++;
|
||||
}
|
||||
}
|
||||
int equals = token.indexOf('=');
|
||||
if (equals >= 0) {
|
||||
String key = token.substring(0, equals);
|
||||
SuggestionsBuilder valueBuilder = builder.createOffset(builder.getStart() + tokenStart + equals + 1);
|
||||
return switch (key) {
|
||||
case "target", "world-name" -> NativeCommandRegistration.suggest(targetNames(context.getSource()), valueBuilder);
|
||||
case "type", "dimension", "pack" -> typeSuggestions(context, valueBuilder);
|
||||
default -> builder.buildFuture();
|
||||
};
|
||||
}
|
||||
SuggestionsBuilder tokenBuilder = builder.createOffset(builder.getStart() + tokenStart);
|
||||
if (positional == 0) {
|
||||
List<String> targets = new ArrayList<>(targetNames(context.getSource()));
|
||||
targets.addAll(KEYS);
|
||||
return NativeCommandRegistration.suggest(targets, tokenBuilder);
|
||||
}
|
||||
if (positional == 1) {
|
||||
return typeSuggestions(context, tokenBuilder);
|
||||
}
|
||||
return NativeCommandRegistration.suggest(List.of(ModdedReplaceArguments.PRESERVE_SEED), tokenBuilder);
|
||||
}
|
||||
|
||||
private static CompletableFuture<Suggestions> typeSuggestions(CommandContext<NativeCommandSource> context,
|
||||
SuggestionsBuilder builder)
|
||||
throws CommandSyntaxException {
|
||||
if (ModdedReplaceArguments.OFF.startsWith(builder.getRemainingLowerCase())) {
|
||||
builder.suggest(ModdedReplaceArguments.OFF);
|
||||
}
|
||||
return ModdedCommandSuggestions.PACK_NAMES.getSuggestions(context, builder);
|
||||
}
|
||||
|
||||
private static List<String> targetNames(NativeCommandSource source) {
|
||||
List<String> names = new ArrayList<>(TARGET_NAMES);
|
||||
for (NativeWorld level : source.server().worlds()) {
|
||||
names.add(level.name());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.modded.ModdedDimensionReplacement;
|
||||
import art.arcane.iris.world.IrisEnvironment;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
record ModdedReplaceTarget(String levelKey, Kind kind) {
|
||||
static final String IRIS_WORLD_NAMESPACE = "irisworldgen";
|
||||
private static final String PAPER_IRIS_NAMESPACE = "iris";
|
||||
private static final String OVERWORLD = "minecraft:overworld";
|
||||
private static final String NETHER = "minecraft:the_nether";
|
||||
private static final String END = "minecraft:the_end";
|
||||
|
||||
ModdedReplaceTarget {
|
||||
Objects.requireNonNull(levelKey, "levelKey");
|
||||
Objects.requireNonNull(kind, "kind");
|
||||
}
|
||||
|
||||
static ModdedReplaceTarget resolve(String requestedName, String levelName) {
|
||||
String requested = requestedName == null ? "" : requestedName.trim();
|
||||
if (requested.isEmpty()) {
|
||||
throw new IllegalArgumentException("World name cannot be empty.");
|
||||
}
|
||||
String normalized = requested.toLowerCase(Locale.ENGLISH);
|
||||
if (normalized.contains(":")) {
|
||||
return exact(normalized, requestedName);
|
||||
}
|
||||
if (requested.contains("/") || requested.contains("\\") || requested.contains("..")) {
|
||||
throw new IllegalArgumentException("World name must be a safe single path segment.");
|
||||
}
|
||||
String configuredLevelName = levelName == null ? "" : levelName.trim();
|
||||
if (!configuredLevelName.isEmpty()) {
|
||||
if (requested.equalsIgnoreCase(configuredLevelName)) {
|
||||
return of(OVERWORLD);
|
||||
}
|
||||
if (requested.equalsIgnoreCase(configuredLevelName + "_nether")) {
|
||||
return of(NETHER);
|
||||
}
|
||||
if (requested.equalsIgnoreCase(configuredLevelName + "_the_end")) {
|
||||
return of(END);
|
||||
}
|
||||
}
|
||||
return switch (normalized) {
|
||||
case "main", "overworld" -> of(OVERWORLD);
|
||||
case "nether", "the_nether" -> of(NETHER);
|
||||
case "end", "the_end" -> of(END);
|
||||
default -> of(IRIS_WORLD_NAMESPACE + ":" + normalized.replace(' ', '_'));
|
||||
};
|
||||
}
|
||||
|
||||
static void requireCompatibleEnvironment(Kind kind, IrisEnvironment environment) {
|
||||
IrisEnvironment expected = kind.environment();
|
||||
if (expected != null && environment != expected) {
|
||||
throw new IllegalArgumentException("The " + kind.slotName() + " slot requires a pack environment of "
|
||||
+ expected.name() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static ModdedReplaceTarget exact(String normalized, String requestedName) {
|
||||
int separator = normalized.indexOf(':');
|
||||
if (separator == 0 || separator == normalized.length() - 1 || separator != normalized.lastIndexOf(':')) {
|
||||
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
|
||||
}
|
||||
String namespace = normalized.substring(0, separator);
|
||||
String path = normalized.substring(separator + 1);
|
||||
String levelKey = PAPER_IRIS_NAMESPACE.equals(namespace) ? IRIS_WORLD_NAMESPACE + ":" + path : normalized;
|
||||
return of(levelKey);
|
||||
}
|
||||
|
||||
private static ModdedReplaceTarget of(String levelKey) {
|
||||
String level = ModdedDimensionReplacement.requireLevelKey(levelKey);
|
||||
Kind kind = switch (level) {
|
||||
case OVERWORLD -> Kind.VANILLA_OVERWORLD;
|
||||
case NETHER -> Kind.VANILLA_NETHER;
|
||||
case END -> Kind.VANILLA_END;
|
||||
default -> level.startsWith(IRIS_WORLD_NAMESPACE + ":") ? Kind.IRIS_WORLD : Kind.DIMENSION;
|
||||
};
|
||||
return new ModdedReplaceTarget(level, kind);
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
VANILLA_OVERWORLD(IrisEnvironment.NORMAL),
|
||||
VANILLA_NETHER(IrisEnvironment.NETHER),
|
||||
VANILLA_END(IrisEnvironment.THE_END),
|
||||
IRIS_WORLD(null),
|
||||
DIMENSION(null);
|
||||
|
||||
private final IrisEnvironment environment;
|
||||
|
||||
Kind(IrisEnvironment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
IrisEnvironment environment() {
|
||||
return environment;
|
||||
}
|
||||
|
||||
String slotName() {
|
||||
return name().toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
-37
@@ -26,6 +26,8 @@ import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
|
||||
import art.arcane.iris.modded.MainWorldService;
|
||||
import art.arcane.iris.modded.MissingPackDimensionException;
|
||||
import art.arcane.iris.modded.ModdedDimensionCreations;
|
||||
import art.arcane.iris.modded.ModdedDimensionManager;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedModConfig;
|
||||
@@ -47,12 +49,16 @@ import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.modded.localization.ModdedCommandMessages;
|
||||
import art.arcane.iris.localization.RuntimeUiMessages;
|
||||
import art.arcane.volmlib.util.localization.LanguageAudience;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
public final class ModdedWorldCommands {
|
||||
private static final Predicate<NativeCommandSource> GATE = NativeCommandRegistration.GAMEMASTERS;
|
||||
@@ -193,24 +199,75 @@ public final class ModdedWorldCommands {
|
||||
}
|
||||
|
||||
private static int enableInstalled(NativeCommandSource source, NativeModdedServer server, String dimensionId, String pack, String packDimension, long seed) {
|
||||
if (blockIfPackBroken(source, dimensionId, pack)) {
|
||||
return 0;
|
||||
return create(source, server, new CreationRequest(dimensionId, pack, packDimension, seed), ModdedWorldCommands::reportWorldCreation);
|
||||
}
|
||||
|
||||
private static int create(NativeCommandSource source, NativeModdedServer server, CreationRequest request, CreationReport report) {
|
||||
CompletableFuture<Void> creation = ModdedDimensionCreations.create(
|
||||
server, request.dimensionId(), request.pack(), request.packDimension(), request.seed());
|
||||
if (creation.isDone()) {
|
||||
Throwable failure = creation.isCompletedExceptionally() ? creation.exceptionNow() : null;
|
||||
report.report(source, request, failure);
|
||||
return failure == null ? 1 : 0;
|
||||
}
|
||||
if (!loadPackDimension(source, pack, packDimension)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
ModdedDimensionManager.createPersistent(server, dimensionId, pack, packDimension, seed);
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris world injection failed for {} (pack={} dim={})", dimensionId, pack, packDimension, e);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_WORLD, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e))));
|
||||
return 0;
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_CREATED_IRIS_WORLD_FROM_PACK_DIMENSION_SEED, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("packDimension", packDimension), MessageArgument.untrusted("seed", seed)));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_IT_IS_LIVE_NOW_RE_INJECTED_ON_EVERY_STARTUP_TELEPORT));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_CREATING_IRIS_WORLD_IN_BACKGROUND, MessageArgument.untrusted("dimensionId", request.dimensionId()), MessageArgument.untrusted("pack", request.pack()), MessageArgument.untrusted("packDimension", request.packDimension()), MessageArgument.untrusted("seed", request.seed())));
|
||||
UUID audience = source.playerId();
|
||||
creation.whenComplete((Void ignored, Throwable failure) -> server.execute(
|
||||
() -> LanguageAudience.run(audience, () -> report.report(source, request, failure))));
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void reportWorldCreation(NativeCommandSource source, CreationRequest request, Throwable failure) {
|
||||
Throwable cause = creationCause(failure);
|
||||
if (cause == null) {
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_CREATED_IRIS_WORLD_FROM_PACK_DIMENSION_SEED, MessageArgument.untrusted("dimensionId", request.dimensionId()), MessageArgument.untrusted("pack", request.pack()), MessageArgument.untrusted("packDimension", request.packDimension()), MessageArgument.untrusted("seed", request.seed())));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_IT_IS_LIVE_NOW_RE_INJECTED_ON_EVERY_STARTUP_TELEPORT));
|
||||
return;
|
||||
}
|
||||
if (reportPackRefusal(source, request, cause)) {
|
||||
return;
|
||||
}
|
||||
ModdedIrisLog.error("Iris world injection failed for {} (pack={} dim={})", request.dimensionId(), request.pack(), request.packDimension(), cause);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_WORLD, MessageArgument.untrusted("dimensionId", request.dimensionId()), MessageArgument.untrusted("value", cause.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause))));
|
||||
}
|
||||
|
||||
private static void reportPrimaryCreation(NativeCommandSource source, CreationRequest request, Throwable failure) {
|
||||
Throwable cause = creationCause(failure);
|
||||
if (cause == null) {
|
||||
ModdedModConfig.setPrimaryWorld(request.dimensionId());
|
||||
ModdedPrimaryWorldRouter.clear();
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_IRIS_PRIMARY_WORLD_SET_PACK_DIMENSION_SEED, MessageArgument.untrusted("dimensionId", request.dimensionId()), MessageArgument.untrusted("pack", request.pack()), MessageArgument.untrusted("packDimension", request.packDimension()), MessageArgument.untrusted("seed", request.seed())));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_VANILLA_OVERWORLD_GENERATOR_CANNOT_BE_HOT_SWAPPED_SO_THIS_DOES));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_INSTEAD_IS_NOW_CONFIGURED_PRIMARY_WORLD_PLAYERS_VANILLA_OVERWORLD_ARE, MessageArgument.untrusted("dimensionId", request.dimensionId())));
|
||||
return;
|
||||
}
|
||||
if (reportPackRefusal(source, request, cause)) {
|
||||
return;
|
||||
}
|
||||
ModdedIrisLog.error("Iris primary world injection failed for {} (pack={} dim={})", request.dimensionId(), request.pack(), request.packDimension(), cause);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_PRIMARY_WORLD, MessageArgument.untrusted("value", cause.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause))));
|
||||
}
|
||||
|
||||
private static boolean reportPackRefusal(NativeCommandSource source, CreationRequest request, Throwable cause) {
|
||||
if (cause instanceof BrokenPackException broken) {
|
||||
reportBrokenPack(source, request.dimensionId(), request.pack(), broken);
|
||||
return true;
|
||||
}
|
||||
if (cause instanceof MissingPackDimensionException missing) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_DOES_NOT_CONTAIN_DIMENSIONS_JSON, MessageArgument.untrusted("pack", missing.pack()), MessageArgument.untrusted("packDimension", missing.dimensionKey())));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Throwable creationCause(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while (cause instanceof CompletionException && cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause;
|
||||
}
|
||||
|
||||
private static int update(NativeCommandSource source, String targetDimension, String packRaw) {
|
||||
String dimensionId;
|
||||
try {
|
||||
@@ -265,25 +322,12 @@ public final class ModdedWorldCommands {
|
||||
return 0;
|
||||
}
|
||||
String dimensionId = DEFAULT_NAMESPACE + ":primary";
|
||||
if (blockIfPackBroken(source, dimensionId, pack)) {
|
||||
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
|
||||
if (!packFolder.isDirectory()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_WAS_NOT_FOUND_UNDER, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("value", ModdedPackCommands.packsRoot().getAbsolutePath())));
|
||||
return 0;
|
||||
}
|
||||
if (!loadPackDimension(source, pack, packDimension)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
ModdedDimensionManager.createPersistent(server, dimensionId, pack, packDimension, seed);
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris primary world injection failed for {} (pack={} dim={})", dimensionId, pack, packDimension, e);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_PRIMARY_WORLD, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e))));
|
||||
return 0;
|
||||
}
|
||||
ModdedModConfig.setPrimaryWorld(dimensionId);
|
||||
ModdedPrimaryWorldRouter.clear();
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_IRIS_PRIMARY_WORLD_SET_PACK_DIMENSION_SEED, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("packDimension", packDimension), MessageArgument.untrusted("seed", seed)));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_VANILLA_OVERWORLD_GENERATOR_CANNOT_BE_HOT_SWAPPED_SO_THIS_DOES));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_INSTEAD_IS_NOW_CONFIGURED_PRIMARY_WORLD_PLAYERS_VANILLA_OVERWORLD_ARE, MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
return 1;
|
||||
return create(source, server, new CreationRequest(dimensionId, pack, packDimension, seed), ModdedWorldCommands::reportPrimaryCreation);
|
||||
}
|
||||
|
||||
private static int clearMainWorld(NativeCommandSource source) {
|
||||
@@ -363,20 +407,24 @@ public final class ModdedWorldCommands {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static boolean blockIfPackBroken(NativeCommandSource source, String dimensionId, String pack) {
|
||||
static boolean blockIfPackBroken(NativeCommandSource source, String dimensionId, String pack) {
|
||||
try {
|
||||
ModdedStartup.requirePackForWorldCreation(pack);
|
||||
return false;
|
||||
} catch (BrokenPackException e) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_REFUSING_CREATE_WORLD_USING_PACK_BECAUSE_REQUIRED_VALIDATION_FAILED, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack)));
|
||||
for (String reason : e.getReasons()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_MESSAGE, MessageArgument.untrusted("reason", reason)));
|
||||
}
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("pack", pack)));
|
||||
reportBrokenPack(source, dimensionId, pack, e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void reportBrokenPack(NativeCommandSource source, String dimensionId, String pack, BrokenPackException broken) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_REFUSING_CREATE_WORLD_USING_PACK_BECAUSE_REQUIRED_VALIDATION_FAILED, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack)));
|
||||
for (String reason : broken.getReasons()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_MESSAGE, MessageArgument.untrusted("reason", reason)));
|
||||
}
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("pack", pack)));
|
||||
}
|
||||
|
||||
private static boolean loadPackDimension(NativeCommandSource source, String pack, String packDimension) {
|
||||
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
|
||||
if (!packFolder.isDirectory()) {
|
||||
@@ -532,4 +580,12 @@ public final class ModdedWorldCommands {
|
||||
}
|
||||
return namespace + ":" + path;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface CreationReport {
|
||||
void report(NativeCommandSource source, CreationRequest request, Throwable failure);
|
||||
}
|
||||
|
||||
private record CreationRequest(String dimensionId, String pack, String packDimension, long seed) {
|
||||
}
|
||||
}
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.localization;
|
||||
|
||||
import art.arcane.volmlib.util.localization.MessageKey;
|
||||
import art.arcane.volmlib.util.localization.TextKey;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class ModdedCatalogMessages {
|
||||
public static final TextKey MISSING = TextKey.of(
|
||||
"iris.modded.catalog.missing",
|
||||
"No worldgen catalog has been captured yet. Run /iris catalog refresh."
|
||||
);
|
||||
public static final TextKey HEADER = TextKey.of(
|
||||
"iris.modded.catalog.header",
|
||||
"Worldgen catalog ({name} {version}): {count} namespaces, {value} biomes, {value2} placed features, "
|
||||
+ "{value3} structures, {value4} dimension types, {value5} level stems"
|
||||
);
|
||||
public static final TextKey NAMESPACE = TextKey.of(
|
||||
"iris.modded.catalog.namespace",
|
||||
"{name} {version}: {value} biomes, {value2} placed features ({detail}), {value3} structures, "
|
||||
+ "{value4} dimension types, {value5} level stems"
|
||||
);
|
||||
public static final TextKey QUIET = TextKey.of(
|
||||
"iris.modded.catalog.quiet",
|
||||
"{count} more loaded mods have no worldgen content."
|
||||
);
|
||||
public static final TextKey UNKNOWN_NAMESPACE = TextKey.of(
|
||||
"iris.modded.catalog.unknown_namespace",
|
||||
"The worldgen catalog has no content from namespace {name}."
|
||||
);
|
||||
public static final TextKey DETAIL_BIOMES = TextKey.of(
|
||||
"iris.modded.catalog.detail_biomes",
|
||||
"Biomes ({count}): {value}"
|
||||
);
|
||||
public static final TextKey DETAIL_FEATURES = TextKey.of(
|
||||
"iris.modded.catalog.detail_features",
|
||||
"Placed features by type ({count}): {value}"
|
||||
);
|
||||
public static final TextKey DETAIL_STRUCTURES = TextKey.of(
|
||||
"iris.modded.catalog.detail_structures",
|
||||
"Structures ({count}): {value}"
|
||||
);
|
||||
public static final TextKey DETAIL_DIMENSIONS = TextKey.of(
|
||||
"iris.modded.catalog.detail_dimensions",
|
||||
"Dimension types: {value}; level stems: {value2}"
|
||||
);
|
||||
public static final TextKey REFRESHING = TextKey.of(
|
||||
"iris.modded.catalog.refreshing",
|
||||
"Capturing the worldgen catalog..."
|
||||
);
|
||||
public static final TextKey REFRESHED = TextKey.of(
|
||||
"iris.modded.catalog.refreshed",
|
||||
"Worldgen catalog captured in {duration}: {path}"
|
||||
);
|
||||
public static final TextKey REFRESH_FAILED = TextKey.of(
|
||||
"iris.modded.catalog.refresh_failed",
|
||||
"Worldgen catalog capture failed: {reason}"
|
||||
);
|
||||
public static final TextKey INGEST_INVALID = TextKey.of(
|
||||
"iris.modded.catalog.ingest_invalid",
|
||||
"Invalid arguments: {reason}. Usage: /iris ingest mods <source> <target> [dimension=<key>] "
|
||||
+ "[namespaces=<a,b>] [excludeRegions=<a,b>] [replace=true]"
|
||||
);
|
||||
public static final TextKey INGEST_PACK_MISSING = TextKey.of(
|
||||
"iris.modded.catalog.ingest_pack_missing",
|
||||
"Pack '{pack}' was not found under {path}."
|
||||
);
|
||||
public static final TextKey INGEST_STARTED = TextKey.of(
|
||||
"iris.modded.catalog.ingest_started",
|
||||
"Ingesting mod worldgen from '{source}' into '{target}' (dimension {dimension})..."
|
||||
);
|
||||
public static final TextKey INGEST_FAILED = TextKey.of(
|
||||
"iris.modded.catalog.ingest_failed",
|
||||
"Mod ingest failed: {reason}"
|
||||
);
|
||||
public static final TextKey INGEST_TARGET_EXISTS = TextKey.of(
|
||||
"iris.modded.catalog.ingest_target_exists",
|
||||
"Target pack '{target}' already exists. Add replace=true to overwrite it."
|
||||
);
|
||||
public static final TextKey INGEST_LINE = TextKey.of(
|
||||
"iris.modded.catalog.ingest_line",
|
||||
" {summary}"
|
||||
);
|
||||
public static final TextKey INGEST_COMPLETE = TextKey.of(
|
||||
"iris.modded.catalog.ingest_complete",
|
||||
"Mod ingest finished: {path}"
|
||||
);
|
||||
public static final TextKey HELP_CATALOG = TextKey.of(
|
||||
"iris.modded.help.entry.command.catalog",
|
||||
"List the worldgen content of loaded mods, show one namespace, or capture it again"
|
||||
);
|
||||
public static final TextKey HELP_INGEST = TextKey.of(
|
||||
"iris.modded.help.entry.group.ingest",
|
||||
"Build Iris packs from the worldgen content of loaded mods"
|
||||
);
|
||||
public static final TextKey HELP_INGEST_MODS = TextKey.of(
|
||||
"iris.modded.help.entry.command.ingest_mods",
|
||||
"Copy a source pack into a new target pack and adopt the worldgen of loaded mods"
|
||||
);
|
||||
|
||||
private static final List<MessageKey> KEYS = List.of(
|
||||
MISSING,
|
||||
HEADER,
|
||||
NAMESPACE,
|
||||
QUIET,
|
||||
UNKNOWN_NAMESPACE,
|
||||
DETAIL_BIOMES,
|
||||
DETAIL_FEATURES,
|
||||
DETAIL_STRUCTURES,
|
||||
DETAIL_DIMENSIONS,
|
||||
REFRESHING,
|
||||
REFRESHED,
|
||||
REFRESH_FAILED,
|
||||
INGEST_INVALID,
|
||||
INGEST_PACK_MISSING,
|
||||
INGEST_STARTED,
|
||||
INGEST_FAILED,
|
||||
INGEST_TARGET_EXISTS,
|
||||
INGEST_LINE,
|
||||
INGEST_COMPLETE,
|
||||
HELP_CATALOG,
|
||||
HELP_INGEST,
|
||||
HELP_INGEST_MODS
|
||||
);
|
||||
|
||||
private ModdedCatalogMessages() {
|
||||
}
|
||||
|
||||
public static List<MessageKey> keys() {
|
||||
return KEYS;
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -1124,6 +1124,10 @@ public final class ModdedCommandMessages {
|
||||
"iris.modded.moddedworldcommands.failed_inject_iris_world",
|
||||
"Failed to inject Iris world '" + "{dimensionId}" + "': " + "{value}" + "{errorMessage}"
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_CREATING_IRIS_WORLD_IN_BACKGROUND = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.creating_iris_world_in_background",
|
||||
"Creating Iris world " + "{dimensionId}" + " from pack '" + "{pack}" + "' dimension '" + "{packDimension}" + "' (seed " + "{seed}" + ") in the background. You will be told when it is live."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_CREATED_IRIS_WORLD_FROM_PACK_DIMENSION_SEED = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.created_iris_world_from_pack_dimension_seed",
|
||||
"Created Iris world " + "{dimensionId}" + " from pack '" + "{pack}" + "' dimension '" + "{packDimension}" + "' (seed " + "{seed}" + ")."
|
||||
@@ -1260,6 +1264,18 @@ public final class ModdedCommandMessages {
|
||||
"iris.modded.moddedworldcommands.use_iris_world_create_name_pack_inject_one_without_restarting",
|
||||
"Use /iris world create <name> <pack> to inject one without restarting."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_REPLACE_RUNTIME_LEVEL = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.replace_runtime_level",
|
||||
"{dimensionId}" + " is created by a mod while the server runs and is not defined as a dimension, so Iris cannot replace it."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_REPLACE_REGISTRY_NOT_READY = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.replace_registry_not_ready",
|
||||
"Iris is still reading this server's dimensions. Try again in a moment."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_REPLACE_INSTALLED_TYPES = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.replace_installed_types",
|
||||
"Installed packs: " + "{value}"
|
||||
);
|
||||
|
||||
private static final List<MessageKey> KEYS = List.of(
|
||||
IRIS_MODDED_COMMANDS_THIS_DIMENSION_IS_NOT_GENERATED_BY_IRIS,
|
||||
@@ -1541,6 +1557,7 @@ public final class ModdedCommandMessages {
|
||||
MODDED_STUDIO_COMMANDS_REGION_SAMPLING_FAILED,
|
||||
MODDED_WORLD_COMMANDS_REQUIRED_PACK_IS_NOT_INSTALLED_INSTALL_THEN_RESTART,
|
||||
MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_WORLD,
|
||||
MODDED_WORLD_COMMANDS_CREATING_IRIS_WORLD_IN_BACKGROUND,
|
||||
MODDED_WORLD_COMMANDS_CREATED_IRIS_WORLD_FROM_PACK_DIMENSION_SEED,
|
||||
MODDED_WORLD_COMMANDS_IT_IS_LIVE_NOW_RE_INJECTED_ON_EVERY_STARTUP_TELEPORT,
|
||||
MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_PRIMARY_WORLD,
|
||||
@@ -1574,7 +1591,10 @@ public final class ModdedCommandMessages {
|
||||
MODDED_WORLD_COMMANDS_NO_IRIS_DIMENSIONS_ARE_CURRENTLY_LOADED_CREATE_ONE_WITH_IRIS,
|
||||
MODDED_WORLD_COMMANDS_LOADED_IRIS_DIMENSIONS,
|
||||
MODDED_WORLD_COMMANDS_MESSAGE_2,
|
||||
MODDED_WORLD_COMMANDS_USE_IRIS_WORLD_CREATE_NAME_PACK_INJECT_ONE_WITHOUT_RESTARTING
|
||||
MODDED_WORLD_COMMANDS_USE_IRIS_WORLD_CREATE_NAME_PACK_INJECT_ONE_WITHOUT_RESTARTING,
|
||||
MODDED_WORLD_COMMANDS_REPLACE_RUNTIME_LEVEL,
|
||||
MODDED_WORLD_COMMANDS_REPLACE_REGISTRY_NOT_READY,
|
||||
MODDED_WORLD_COMMANDS_REPLACE_INSTALLED_TYPES
|
||||
);
|
||||
|
||||
private ModdedCommandMessages() {
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ public final class ModdedMessageContributor implements IrisMessageContributor {
|
||||
List<MessageKey> keys = new ArrayList<>();
|
||||
keys.addAll(ModdedCommandMessages.keys());
|
||||
keys.addAll(ModdedHelpMessages.keys());
|
||||
keys.addAll(ModdedCatalogMessages.keys());
|
||||
keys.addAll(ClientUiMessages.keys());
|
||||
return List.copyOf(keys);
|
||||
}
|
||||
|
||||
+1
-11
@@ -30,7 +30,6 @@ import art.arcane.iris.studio.view.PregeneratorJob;
|
||||
import art.arcane.iris.integration.Identifier;
|
||||
import art.arcane.iris.generation.cache.Cache;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.GenerationFailures;
|
||||
import art.arcane.iris.generation.block.TileData;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedBlockResolution;
|
||||
@@ -382,16 +381,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
if (!state.isStorageChest()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ModdedLootApplier.apply(engine, level, pos, state, chunk);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
GenerationFailures.rethrowEngineFailure(unavailable);
|
||||
IrisLogging.warnOnce("chunk-update-loot:" + level.name() + ":" + unavailable.getMessage(),
|
||||
"Iris left the container at " + pos + " in " + level.name() + " without loot: " + unavailable.getMessage());
|
||||
} catch (Throwable e) {
|
||||
GenerationFailures.rethrowEngineFailure(e);
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
ModdedLootApplier.apply(engine, level, pos, state, chunk);
|
||||
} else {
|
||||
NativeWorldMaintenance.refresh(level, pos);
|
||||
}
|
||||
|
||||
+2
-1
@@ -74,7 +74,8 @@ public class IrisModLanguageAssetsTest {
|
||||
private Set<String> resourceFiles() throws Exception {
|
||||
URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT + "en_us.json");
|
||||
assertNotNull("Missing English mod language resource", resource);
|
||||
assertEquals("Mod language resources must resolve to a directory on disk", "file", resource.getProtocol());
|
||||
assertTrue("Mod language resources must resolve to a directory, got " + resource,
|
||||
resource.getProtocol().equals("file") || resource.getProtocol().equals("union"));
|
||||
Path directory = Path.of(resource.toURI()).getParent();
|
||||
try (Stream<Path> paths = Files.list(directory)) {
|
||||
return paths
|
||||
|
||||
+98
@@ -6,11 +6,17 @@ import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.subterrain.IrisSubterrainFeature;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.pack.loading.ResourceLoader;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionException;
|
||||
import art.arcane.iris.generation.runtime.GenerationSessionLease;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourceAccess;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourcePolicy;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeModdedBiomePolicy;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import org.junit.Test;
|
||||
@@ -20,7 +26,9 @@ import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -29,8 +37,12 @@ import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -140,6 +152,92 @@ public class ModdedBiomePolicyTest {
|
||||
assertThrows(IllegalStateException.class, () -> boundTo(unready).getVisibleSurfaceBiome(8, 8));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla /locate biome samples a 6400 block radius every 32 blocks. Each Iris surface sample needs the column's
|
||||
* terrain height, so the search may only resolve columns whose region and biome streams can hold the target, and
|
||||
* it must answer with the visible biome a chunk is filled with.
|
||||
*/
|
||||
@Test
|
||||
public void locateResolvesVisibleBiomesOnlyInColumnsThatCanHoldTheTarget() throws Exception {
|
||||
BiomeToken mirage = new BiomeToken("terralith:mirage_isles");
|
||||
BiomeToken plains = new BiomeToken("minecraft:plains");
|
||||
IrisBiome isles = new IrisBiome().setDerivative("terralith:mirage_isles");
|
||||
isles.setLoadKey("isles");
|
||||
IrisBiome meadow = new IrisBiome().setDerivative("minecraft:plains");
|
||||
meadow.setLoadKey("meadow");
|
||||
IrisRegion main = new IrisRegion().setLandBiomes(new KList<>("meadow"));
|
||||
main.setLoadKey("main");
|
||||
IrisRegion archipelago = new IrisRegion().setLandBiomes(new KList<>("isles"));
|
||||
archipelago.setLoadKey("archipelago");
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
when(dimension.getReachableBiomes(any())).thenReturn(new KList<>(meadow, isles));
|
||||
when(dimension.getAllRegions(any())).thenReturn(new KList<>(main, archipelago));
|
||||
when(dimension.getCarving()).thenReturn(new KList<>());
|
||||
when(dimension.getSubterrainFeatures()).thenReturn(new KList<>());
|
||||
@SuppressWarnings("unchecked")
|
||||
ResourceLoader<IrisBiome> biomes = (ResourceLoader<IrisBiome>) mock(ResourceLoader.class);
|
||||
when(biomes.load("meadow")).thenReturn(meadow);
|
||||
when(biomes.load("isles")).thenReturn(isles);
|
||||
IrisData data = mock(IrisData.class);
|
||||
when(data.getDataFolder()).thenReturn(new File("synthetic-pack"));
|
||||
when(data.getBiomeLoader()).thenReturn(biomes);
|
||||
when(data.physicalBiomeResourceKey(any(), anyString())).thenAnswer(invocation -> invocation.getArgument(1));
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(complex.getRegionStream()).thenReturn(ProceduralStream.of(
|
||||
(x, z) -> x >= 4096D ? archipelago : main, Interpolated.of(value -> 0D, value -> main)));
|
||||
when(complex.getLandBiomeStream()).thenReturn(ProceduralStream.of(
|
||||
(x, z) -> x >= 4096D ? isles : meadow, Interpolated.of(value -> 0D, value -> meadow)));
|
||||
when(complex.getSeaBiomeStream()).thenReturn(ProceduralStream.of(
|
||||
(x, z) -> meadow, Interpolated.of(value -> 0D, value -> meadow)));
|
||||
when(complex.getShoreBiomeStream()).thenReturn(ProceduralStream.of(
|
||||
(x, z) -> meadow, Interpolated.of(value -> 0D, value -> meadow)));
|
||||
when(complex.historicalPhysicalBiomeKeyAt(anyInt(), anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
GenerationHistoryRuntimeRouter.CoordinateScope scope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class);
|
||||
when(router.openCoordinateScope(anyInt(), anyInt())).thenReturn(scope);
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getData()).thenReturn(data);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.getCacheID()).thenReturn(1);
|
||||
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
||||
when(engine.answersFromNaturalTerrain(anyInt(), anyInt())).thenReturn(true);
|
||||
AtomicInteger heightSamples = new AtomicInteger();
|
||||
when(engine.getHostSurfaceBiome(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
heightSamples.incrementAndGet();
|
||||
return invocation.<Integer>getArgument(0) >= 4096 ? isles : meadow;
|
||||
});
|
||||
ModdedBiomePolicy<BiomeToken, Object> policy = new ModdedBiomePolicy<>(
|
||||
new Access(new ArrayList<>(List.of(plains, mirage))));
|
||||
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> false,
|
||||
() -> Set.of(plains.key(), mirage.key()), bound -> Set.of(), () -> 0L));
|
||||
int[] columns = {0, 1024, 2048, 3072, 4096, 5120};
|
||||
|
||||
NativeBiomeSourcePolicy.BiomeLocation<BiomeToken> found = policy.findClosestBiome3d(
|
||||
new NativeModdedBiomePolicy.ClosestQuery<>(mirage::equals, null,
|
||||
() -> {
|
||||
throw new AssertionError("A bound engine must not use the unbound search");
|
||||
},
|
||||
(candidates, resolver) -> {
|
||||
for (int x : columns) {
|
||||
NativeBiomeSourcePolicy.Column<BiomeToken> column = resolver.resolve(x, 0);
|
||||
BiomeToken biome = column.sample(100);
|
||||
if (candidates.contains(biome)) {
|
||||
return new NativeBiomeSourcePolicy.BiomeLocation<>(x, 100, 0, biome);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
|
||||
assertEquals(new NativeBiomeSourcePolicy.BiomeLocation<>(4096, 100, 0, mirage), found);
|
||||
assertEquals(1, heightSamples.get());
|
||||
verify(router).openCoordinateScope(4096, 0);
|
||||
verify(router, times(1)).openCoordinateScope(anyInt(), anyInt());
|
||||
verify(scope).close();
|
||||
}
|
||||
|
||||
private static ModdedBiomePolicy<BiomeToken, Object> boundTo(Engine engine) {
|
||||
ModdedBiomePolicy<BiomeToken, Object> policy = new ModdedBiomePolicy<>(new Access(new ArrayList<>()));
|
||||
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user