Content patch import dialog & server support

This commit is contained in:
Anuken 2025-10-22 06:42:01 -04:00
parent 9f7817f70e
commit 9cc3105518
16 changed files with 373 additions and 28 deletions

View file

@ -470,6 +470,13 @@ editor.rules = Rules
editor.generation = Generation
editor.objectives = Objectives
editor.locales = Locale Bundles
editor.patches = Content Patches
editor.patch: Patchset: {0}
editor.patches.none = [lightgray]No patchsets loaded.
editor.patches.errors = Patchset Errors
editor.patches.importerror = Failed to import patchset
editor.patches.delete.confirm = Are you sure you want to delete this patchset?
editor.patch.fields = {0} fields
editor.worldprocessors = World Processors
editor.worldprocessors.editname = Edit Name
editor.worldprocessors.none = [lightgray]No world processor blocks found!\nAdd one in the map editor, or use the \ue813 Add button below.

Binary file not shown.

View file

@ -43,12 +43,12 @@ public class GameState{
public Attributes envAttrs = new Attributes();
/** Team data. Gets reset every new game. */
public Teams teams = new Teams();
/** Handles JSON edits of game content. */
public ContentPatcher patcher = new ContentPatcher();
/** Number of enemies in the game; only used clientside in servers. */
public int enemies;
/** Map being playtested (not edited!) */
public @Nullable Map playtestingMap;
/** Null if not content patches have been applied. */
public @Nullable ContentPatcher patcher;
/** Current game state. */
private State state = State.menu;

View file

@ -260,10 +260,7 @@ public class Logic implements ApplicationListener{
public void reset(){
State prev = state.getState();
if(state.patcher != null){
state.patcher.unapply();
state.patcher = null;
}
state.patcher.unapply();
//recreate gamestate - sets state to menu
state = new GameState();
//fire change event, since it was technically changed

View file

@ -20,6 +20,7 @@ public class MapInfoDialog extends BaseDialog{
private MapObjectivesDialog objectives = new MapObjectivesDialog();
private MapLocalesDialog locales = new MapLocalesDialog();
private MapProcessorsDialog processors = new MapProcessorsDialog();
private MapPatchesDialog patches = new MapPatchesDialog();
public MapInfoDialog(){
super("@editor.mapinfo");
@ -33,7 +34,7 @@ public class MapInfoDialog extends BaseDialog{
cont.clear();
ObjectMap<String, String> tags = editor.tags;
cont.pane(t -> {
t.add("@editor.mapname").padRight(8).left();
t.defaults().padTop(15);
@ -113,6 +114,16 @@ public class MapInfoDialog extends BaseDialog{
hide();
processors.show();
}).marginLeft(10f);
r.row();
r.button("@editor.patches", Icon.file, style, () -> {
hide();
patches.show();
}).marginLeft(10f);
//empty space
r.add().marginLeft(10f);
}).colspan(2).center();
name.change();

View file

@ -0,0 +1,156 @@
package mindustry.editor;
import arc.*;
import arc.func.*;
import arc.scene.ui.TextButton.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import arc.util.serialization.*;
import mindustry.*;
import mindustry.gen.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import static mindustry.Vars.*;
public class MapPatchesDialog extends BaseDialog{
private Table list;
public MapPatchesDialog(){
super("@editor.patches");
shown(this::setup);
addCloseButton();
buttons.button("@add", Icon.add, () -> showImport(this::addPatch)).size(210f, 64f);
cont.top();
getCell(cont).grow();
cont.pane(t -> list = t);
}
private void setup(){
list.clearChildren();
var patches = state.patcher.patches;
if(patches.isEmpty()){
list.add("@editor.patches.none");
}else{
Table t = list;
t.defaults().pad(4f);
float h = 50f;
for(var patch : patches){
int fields = countFields(patch.json);
if(patch.warnings.size > 0){
t.button(Icon.warning, Styles.graySquarei, iconMed, () -> {
BaseDialog dialog = new BaseDialog("@editor.patches.errors");
dialog.cont.top().pane(p -> {
p.top();
for(var warning : patch.warnings){
p.table(Styles.grayPanel, in -> {
in.add(warning, Styles.monoLabel).grow().wrap();
}).margin(6f).growX().pad(3f).row();
}
}).grow();
dialog.addCloseButton();
dialog.show();
}).size(h);
}else{
t.add().size(h);
}
t.button((patch.name.isEmpty() ? "<unnamed>\n" : "[accent]" + patch.name + "\n") + "[lightgray][[" + Core.bundle.format("editor.patch.fields", fields) + "]", Styles.grayt, () -> {
BaseDialog dialog = new BaseDialog(Core.bundle.format("editor.patch", patch.name.isEmpty() ? "<unnamed>" : patch.name));
dialog.cont.top().pane(p -> {
p.top();
p.table(Styles.grayPanel, in -> {
in.add(patch.patch.replaceAll("\t", " "), Styles.monoLabel).grow().wrap().left().labelAlign(Align.left);
}).margin(6f).growX().pad(5f).row();
}).grow();
dialog.addCloseButton();
dialog.show();
}).size(mobile ? 390f : 450f, h).margin(10f).with(b -> {
b.getLabel().setAlignment(Align.left, Align.left);
});
t.button(Icon.refresh, Styles.graySquarei, Vars.iconMed, () -> {
showImport(str -> addPatch(str, patches.indexOf(patch)));
}).size(h);
t.button(Icon.trash, Styles.graySquarei, iconMed, () -> {
ui.showConfirm("@editor.patches.delete.confirm", () -> {
patches.remove(patch);
setup();
});
}).size(h);
t.row();
}
}
}
void showImport(Cons<String> handler){
BaseDialog dialog = new BaseDialog("@editor.import");
dialog.cont.pane(p -> {
p.margin(10f);
p.table(Tex.button, t -> {
TextButtonStyle style = Styles.flatt;
t.defaults().size(280f, 60f).left();
t.row();
t.button("@schematic.copy.import", Icon.copy, style, () -> {
dialog.hide();
handler.get(Core.app.getClipboardText());
}).marginLeft(12f).disabled(b -> Core.app.getClipboardText() == null);
t.row();
t.button("@schematic.importfile", Icon.download, style, () -> platform.showMultiFileChooser(file -> {
dialog.hide();
handler.get(file.readString());
}, "json", "hjson", "json5")).marginLeft(12f);
t.row();
});
});
dialog.addCloseButton();
dialog.show();
}
void addPatch(String patch){
addPatch(patch, -1);
}
void addPatch(String patch, int replaceIndex){
var oldPatches = state.patcher.patches.copy();
try{
Jval.read(patch); //validation
Seq<String> patches = state.patcher.patches.map(p -> p.patch);
if(replaceIndex == -1){
patches.add(patch);
}else{
patches.set(replaceIndex, patch);
}
state.patcher.apply(patches);
setup();
}catch(Exception e){
state.patcher.patches.set(oldPatches);
ui.showException("@editor.patches.importerror", e);
}
}
int countFields(JsonValue value){
if(value.isObject() || value.isArray()){
int sum = 0;
for(var child : value){
sum += countFields(child);
}
return Math.max(sum, 1);
}else{
return 1;
}
}
}

View file

@ -1,6 +1,7 @@
package mindustry.game;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.core.GameState.*;
import mindustry.ctype.*;
@ -102,6 +103,15 @@ public class EventType{
/** Called when a game begins and the world tiles are initiated. About to updates tile proximity and sets up physics for the world(Before WorldLoadEvent) */
public static class WorldLoadEndEvent{}
/** Called when a save loads custom patches. {@link #patches} can be modified in the event handler. */
public static class ContentPatchLoadEvent{
public final Seq<String> patches;
public ContentPatchLoadEvent(Seq<String> patches){
this.patches = patches;
}
}
public static class SaveLoadEvent{
public final boolean isMap;

View file

@ -20,7 +20,7 @@ public class SaveIO{
/** Save format header. */
public static final byte[] header = {'M', 'S', 'A', 'V'};
public static final IntMap<SaveVersion> versions = new IntMap<>();
public static final Seq<SaveVersion> versionArray = Seq.with(new Save1(), new Save2(), new Save3(), new Save4(), new Save5(), new Save6(), new Save7(), new Save8(), new Save9(), new Save10());
public static final Seq<SaveVersion> versionArray = Seq.with(new Save1(), new Save2(), new Save3(), new Save4(), new Save5(), new Save6(), new Save7(), new Save8(), new Save9(), new Save10(), new Save11());
static{
for(SaveVersion version : versionArray){

View file

@ -12,6 +12,7 @@ import mindustry.core.*;
import mindustry.ctype.*;
import mindustry.entities.*;
import mindustry.game.*;
import mindustry.game.EventType.*;
import mindustry.game.Teams.*;
import mindustry.gen.*;
import mindustry.maps.Map;
@ -67,6 +68,7 @@ public abstract class SaveVersion extends SaveFileReader{
readRegion("content", stream, counter, this::readContentHeader);
try{
if(version >= 11) readRegion("patches", stream, counter, this::readContentPatches);
readRegion("map", stream, counter, in -> readMap(in, context));
readRegion("entities", stream, counter, this::readEntities);
if(version >= 8) readRegion("markers", stream, counter, this::readMarkers);
@ -79,6 +81,7 @@ public abstract class SaveVersion extends SaveFileReader{
public void write(DataOutputStream stream, StringMap extraTags) throws IOException{
writeRegion("meta", stream, out -> writeMeta(out, extraTags));
writeRegion("content", stream, this::writeContentHeader);
writeRegion("patches", stream, this::writeContentPatches);
writeRegion("map", stream, this::writeMap);
writeRegion("entities", stream, this::writeEntities);
writeRegion("markers", stream, this::writeMarkers);
@ -502,8 +505,46 @@ public abstract class SaveVersion extends SaveFileReader{
readWorldEntities(stream, mapping);
}
public void readContentPatches(DataInput stream) throws IOException{
Seq<String> patches = new Seq<>();
int amount = stream.readUnsignedByte();
if(amount > 0){
for(int i = 0; i < amount; i++){
int len = stream.readInt();
byte[] bytes = new byte[len];
stream.readFully(bytes);
patches.add(new String(bytes, Strings.utf8));
}
}
Events.fire(new ContentPatchLoadEvent(patches));
if(patches.size > 0){
try{
state.patcher.apply(patches);
}catch(Throwable e){
Log.err("Failed to apply patches: " + patches, e);
}
}
}
public void writeContentPatches(DataOutput stream) throws IOException{
if(state.patcher.patches.size > 0){
var patches = state.patcher.patches;
stream.writeByte(patches.size);
for(var patchset : patches){
byte[] bytes = patchset.patch.getBytes(Strings.utf8);
stream.writeInt(bytes.length);
stream.write(bytes);
}
}else{
stream.writeByte(0);
}
}
public void readContentHeader(DataInput stream) throws IOException{
byte mapped = stream.readByte();
int mapped = stream.readUnsignedByte();
MappableContent[][] map = new MappableContent[ContentType.all.length][0];
@ -520,6 +561,21 @@ public abstract class SaveVersion extends SaveFileReader{
}
content.setTemporaryMapper(map);
//HACK: versions below 11 don't read the patch chunk, which means the event for reading patches is never triggered.
//manually fire the event here for older versions.
if(version < 11){
Seq<String> patches = new Seq<>();
Events.fire(new ContentPatchLoadEvent(patches));
if(patches.size > 0){
try{
state.patcher.apply(patches);
}catch(Throwable e){
Log.err("Failed to apply patches: " + patches, e);
}
}
}
}
public void writeContentHeader(DataOutput stream) throws IOException{

View file

@ -0,0 +1,11 @@
package mindustry.io.versions;
import mindustry.io.*;
/** Adds patches in content header. */
public class Save11 extends SaveVersion{
public Save11(){
super(11);
}
}

View file

@ -14,6 +14,7 @@ import java.lang.reflect.*;
import java.util.*;
/** The current implementation is awful. Consider it a proof of concept. */
//TODO block consumer support
@SuppressWarnings("unchecked")
public class ContentPatcher{
private static final Object root = new Object();
@ -25,6 +26,10 @@ public class ContentPatcher{
private ObjectSet<PatchRecord> usedpatches = new ObjectSet<>();
private Seq<Runnable> resetters = new Seq<>();
private Seq<Runnable> afterCallbacks = new Seq<>();
private @Nullable PatchSet currentlyApplying;
/** Currently active patches. Note that apply() should be called after modification. */
public Seq<PatchSet> patches = new Seq<>();
static{
for(var type : ContentType.all){
@ -32,22 +37,37 @@ public class ContentPatcher{
}
}
public void apply(String patch) throws Exception{
/** Applies the specified patches. If patches were already applied, the previous ones are un-applied - they do not stack! */
public void apply(Seq<String> patchArray) throws Exception{
if(applied){
unapply();
applied = false;
}
json = Vars.mods.getContentParser().getJson();
applied = true;
contentLoader = Vars.content.copy();
patches.clear();
try{
JsonValue value = json.fromJson(null, Jval.read(patch).toString(Jformat.plain));
for(var child : value){
assign(root, child.name, child, null, null, null);
for(String patch : patchArray){
try{
JsonValue value = json.fromJson(null, Jval.read(patch).toString(Jformat.plain));
PatchSet set = new PatchSet(patch, value);
patches.add(set);
currentlyApplying = set;
value.remove("name"); //patchsets can have a name, ignore it if present
for(var child : value){
assign(root, child.name, child, null, null, null);
}
currentlyApplying = null;
}catch(Exception e){
Log.err("Failed to apply patch: " + patch, e);
}
afterCallbacks.each(Runnable::run);
}catch(Exception e){
Log.err("Failed to apply patch: " + patch, e);
}
afterCallbacks.each(Runnable::run);
}
public void unapply(){
@ -69,6 +89,7 @@ public class ContentPatcher{
//this should never throw an exception
afterCallbacks.each(Runnable::run);
afterCallbacks.clear();
usedpatches.clear();
}
void assign(Object object, String field, Object value, @Nullable FieldData metadata, @Nullable Object parentObject, @Nullable String parentField) throws Exception{
@ -168,6 +189,10 @@ public class ContentPatcher{
assignValue(object, field, metadata, () -> Array.get(fobj, i), val -> Array.set(fobj, i, val), value, false);
}
}
}else if(object instanceof ObjectSet set && prefix == '+'){
modifiedField(parentObject, parentField, set.copy());
assignValue(object, field, metadata, () -> null, val -> set.add(val), value, false);
}else if(object instanceof ObjectMap map){
if(metadata == null){
warn("ObjectMap cannot be parsed without metadata: @.@", parentObject, parentField);
@ -182,7 +207,13 @@ public class ContentPatcher{
var copy = map.copy();
reset(() -> map.set(copy));
assignValue(object, field, new FieldData(metadata.elementType, null, null), () -> map.get(key), val -> map.put(key, val), value, false);
if(value instanceof JsonValue jval && jval.isString() && (jval.asString().equals("-"))){
//removal syntax:
//"value": "-"
map.remove(key);
}else{
assignValue(object, field, new FieldData(metadata.elementType, null, null), () -> map.get(key), val -> map.put(key, val), value, false);
}
}else{
Class<?> actualType = object.getClass();
if(actualType.isAnonymousClass()) actualType = actualType.getSuperclass();
@ -193,9 +224,15 @@ public class ContentPatcher{
if(checkField(fdata.field)) return;
var fobj = object;
assignValue(object, field, new FieldData(fdata), () -> Reflect.get(fobj, fdata.field), fv -> Reflect.set(fobj, fdata.field, fv), value, true);
assignValue(object, field, new FieldData(fdata), () -> Reflect.get(fobj, fdata.field), fv -> {
if(fv == null && !fdata.field.isAnnotationPresent(Nullable.class)){
warn("Field '@' cannot be null.", fdata.field);
return;
}
Reflect.set(fobj, fdata.field, fv);
}, value, true);
}else{
warn("Unknown field: '@' for '@'", field, actualType.getName());
warn("Unknown field: '@' for class '@'", field, actualType.getSimpleName());
}
}
}
@ -322,9 +359,12 @@ public class ContentPatcher{
return json.fromJson(type, string);
}
//TODO crash?
void warn(String error, Object... fmt){
Log.warn(error, fmt);
String formatted = Strings.format(error, fmt);
if(currentlyApplying != null){
currentlyApplying.warnings.add(formatted);
}
Log.warn("[ContentPatcher] " + formatted);
}
void after(Runnable run){
@ -343,6 +383,19 @@ public class ContentPatcher{
return ((Object[])object).clone();
}
public static class PatchSet{
public String patch;
public JsonValue json;
public String name;
public Seq<String> warnings = new Seq<>();
public PatchSet(String patch, JsonValue json){
this.patch = patch;
this.json = json;
name = json.getString("name", "");
}
}
private static class FieldData{
Class type, elementType, keyType;

View file

@ -51,6 +51,7 @@ public class NetworkIO{
player.write(new Writes(stream));
SaveIO.getSaveWriter().writeContentHeader(stream);
SaveIO.getSaveWriter().writeContentPatches(stream);
SaveIO.getSaveWriter().writeMap(stream);
SaveIO.getSaveWriter().writeTeamBlocks(stream);
SaveIO.getSaveWriter().writeMarkers(stream);
@ -84,6 +85,7 @@ public class NetworkIO{
player.add();
SaveIO.getSaveWriter().readContentHeader(stream);
SaveIO.getSaveWriter().readContentPatches(stream);
SaveIO.getSaveWriter().readMap(stream, world.context);
SaveIO.getSaveWriter().readTeamBlocks(stream);
SaveIO.getSaveWriter().readMarkers(stream);

View file

@ -33,7 +33,7 @@ public class Fonts{
private static ObjectMap<String, String> stringIcons = new ObjectMap<>();
private static ObjectMap<String, TextureRegion> largeIcons = new ObjectMap<>();
public static Font def, outline, icon, iconLarge, tech, logic;
public static Font def, outline, icon, iconLarge, tech, logic, monospace;
public static int getUnicode(String content){
return unicodeIcons.get(content, 0);
@ -66,6 +66,13 @@ public class Fonts{
Core.assets.load("default", Font.class, new FreeTypeFontLoaderParameter(mainFont, param)).loaded = f -> Fonts.def = f;
Core.assets.load("monospace", Font.class, new FreeTypeFontLoaderParameter("fonts/monospace.woff", new FreeTypeFontParameter(){{
size = 16;
incremental = true;
//most people will never see the monospace font, so don't pre-bake anything
characters = "\u0000 ";
}})).loaded = f -> Fonts.monospace = f;
Core.assets.load("icon", Font.class, new FreeTypeFontLoaderParameter("fonts/icon.ttf", new FreeTypeFontParameter(){{
size = 30;
incremental = true;

View file

@ -92,7 +92,7 @@ public class Styles{
public static ScrollPaneStyle defaultPane, horizontalPane, smallPane, noBarPane;
public static SliderStyle defaultSlider;
public static LabelStyle defaultLabel, outlineLabel, techLabel;
public static LabelStyle defaultLabel, outlineLabel, techLabel, monoLabel;
public static TextFieldStyle defaultField, nodeField, areaField, nodeArea;
public static CheckBoxStyle defaultCheck;
public static DialogStyle defaultDialog, fullDialog;
@ -380,6 +380,10 @@ public class Styles{
font = Fonts.tech;
fontColor = Color.white;
}};
monoLabel = new LabelStyle(){{
font = Fonts.monospace;
fontColor = Color.white;
}};
defaultField = new TextFieldStyle(){{
font = Fonts.def;

View file

@ -26,4 +26,4 @@ org.gradle.caching=true
org.gradle.internal.http.socketTimeout=100000
org.gradle.internal.http.connectionTimeout=100000
android.enableR8.fullMode=false
archash=7a3d906e1b
archash=c8f3bd901b

View file

@ -10,6 +10,7 @@ import arc.util.CommandHandler.*;
import arc.util.Timer.*;
import arc.util.serialization.*;
import arc.util.serialization.JsonValue.*;
import arc.util.serialization.Jval.*;
import mindustry.*;
import mindustry.core.GameState.*;
import mindustry.core.*;
@ -72,6 +73,8 @@ public class ServerControl implements ApplicationListener{
private PrintWriter socketOutput;
private String suggested;
private boolean autoPaused = false;
private Fi patchDirectory;
private Seq<String> contentPatches = new Seq<>();
public Cons<GameOverEvent> gameOverListener = event -> {
if(state.rules.waves){
@ -191,13 +194,17 @@ public class ServerControl implements ApplicationListener{
}
});
customMapDirectory.mkdirs();
if(Version.build == -1){
warn("&lyYour server is running a custom build, which means that client checking is disabled.");
warn("&lyIt is highly advised to specify which version you're using by building with gradle args &lb&fb-Pbuildversion=&lr<build>");
}
customMapDirectory.mkdirs();
patchDirectory = dataDirectory.child("patches");
patchDirectory.mkdirs();
loadPatchFiles();
//set up default shuffle mode
try{
maps.setShuffleMode(ShuffleMode.valueOf(Core.settings.getString("shufflemode")));
@ -314,6 +321,30 @@ public class ServerControl implements ApplicationListener{
info("Server loaded. Type @ for help.", "'help'");
});
Events.on(ContentPatchLoadEvent.class, event -> {
//NOTE: if patches change, and an older save is loaded, the patches will be applied twice; the old ones won't be removed.
for(String patch : contentPatches){
event.patches.addUnique(patch);
}
});
}
void loadPatchFiles(){
contentPatches.clear();
Seq<Fi> patches = patchDirectory.findAll(f -> f.extEquals("json") || f.extEquals("hjson") || f.extEquals("json5")).sort();
for(Fi patch : patches){
try{
contentPatches.add(Jval.read(patch.readString()).toString(Jformat.plain));
}catch(Throwable e){
Log.err("Invalid patch file: " + patch.name(), e);
}
}
if(contentPatches.size > 0){
Log.info("Loaded @ content patch files.", contentPatches.size);
}
}
protected void registerCommands(){