|
| 1 | +package com.sc4nam.scripts |
| 2 | + |
| 3 | +import java.nio.file.{Files, Paths} |
| 4 | +import io.github.memo33.metarules.meta.{RotFlip, EquivRule, Rule, IdTile} |
| 5 | +import RotFlip._ |
| 6 | +import com.sc4nam.module._ |
| 7 | +import Rul2Model.{iterateRulFiles, parseRuleWithRestrictedDriveside, Rhd, Lhd, RhdAndLhd, drivesideOfFile} |
| 8 | +import SanityChecker.{fileEndsWithNewline, linePatternIncludingNewlines} |
| 9 | + |
| 10 | +/** Checks for conflicting/duplicate RUL2 code. There are two modes of operation: |
| 11 | + * |
| 12 | + * {{{ |
| 13 | + * SBT_OPTS="-Xmx2G" sbt -no-color conflictingOverridesCheck |
| 14 | + * }}} |
| 15 | + * to check all RUL2 code for new conflicting overrides (that are not tagged yet). |
| 16 | + * The new conflicts are printed to stdout. Exit code will be non-zero if new |
| 17 | + * conflicts are found. |
| 18 | + * |
| 19 | + * {{{ |
| 20 | + * SBT_OPTS="-Xmx2G" sbt "conflictingOverridesCheck --update" |
| 21 | + * }}} |
| 22 | + * to update the files in "Controller/RUL2" in place by tagging the lines with |
| 23 | + * "conflicting-override" where conflicts are found. |
| 24 | + * The tag is removed from lines that are not conflicting anymore. |
| 25 | + * The first rule in a pair of conflicts is never tagged, as it is the one that |
| 26 | + * will have an effect in the game. |
| 27 | + * Make sure to commit your changes before running this command. |
| 28 | + */ |
| 29 | +object ConflictingOverridesChecker { |
| 30 | + |
| 31 | + val tag = "conflicting-override" |
| 32 | + |
| 33 | + /** Scans the Controller/RUL2 folder for conflicting duplicate RUL2 code. |
| 34 | + * |
| 35 | + * Example: |
| 36 | + * |
| 37 | + * A,B=C,D |
| 38 | + * A,B=E,F |
| 39 | + * |
| 40 | + * In this case, the second line conflicts with the first one. |
| 41 | + */ |
| 42 | + def main(args: Array[String]): Unit = { |
| 43 | + if (args.isEmpty) { |
| 44 | + val (numConflicts, _, _) = checkConflictingRul2(updateMode = false) |
| 45 | + val msg = s"Found $numConflicts new conflicting RUL2 overrides." |
| 46 | + if (numConflicts > 0) { |
| 47 | + LOGGER.severe(s"$msg Please avoid introducing new conflicts. Instead verify whether these overrides really have the intended effect and consider rewriting or removing them.") |
| 48 | + System.exit(1) |
| 49 | + } else { |
| 50 | + LOGGER.info(msg) |
| 51 | + } |
| 52 | + } else if (args.sameElements(Seq("--update"))) { |
| 53 | + val (numConflicts, removed, added) = checkConflictingRul2(updateMode = true) |
| 54 | + val msg = s"Found $numConflicts conflicting RUL2 overrides (removed $removed, added $added)." |
| 55 | + if (numConflicts > 0) { |
| 56 | + LOGGER.warning(msg) |
| 57 | + } else { |
| 58 | + LOGGER.info(msg) |
| 59 | + } |
| 60 | + } else { |
| 61 | + LOGGER.severe("wrong arguments") |
| 62 | + System.exit(2) |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + def checkConflictingRul2(updateMode: Boolean): (Int, Int, Int) = { |
| 67 | + var numConflicts = 0 |
| 68 | + var removed = 0 |
| 69 | + var added = 0 |
| 70 | + val rulesRhd = collection.mutable.Map.empty[EquivRule, Rule[IdTile]] |
| 71 | + val rulesLhd = collection.mutable.Map.empty[EquivRule, Rule[IdTile]] |
| 72 | + val rulesShared = collection.mutable.Map.empty[EquivRule, Rule[IdTile]] |
| 73 | + val lookupRuleRhd: PartialFunction[EquivRule, Rule[IdTile]] = rulesShared.orElse(rulesRhd) // the two maps should be disjoint |
| 74 | + val lookupRuleLhd: PartialFunction[EquivRule, Rule[IdTile]] = rulesShared.orElse(rulesLhd) // the two maps should be disjoint |
| 75 | + |
| 76 | + val directory = Paths.get("Controller/RUL2") |
| 77 | + LOGGER.info(s"""Loading all RUL2 code for RHD and LHD from "$directory" and checking for conflicts""") |
| 78 | + iterateRulFiles(directory).foreach { path => |
| 79 | + val drivesideFile = drivesideOfFile(path) |
| 80 | + |
| 81 | + // simultaneously builds the RUL2 cache and looks for conflicts |
| 82 | + def findConflict(line: String): Option[(Rule[IdTile], Rule[IdTile])] = { |
| 83 | + parseRuleWithRestrictedDriveside(line, drivesideFile) match { |
| 84 | + // only the first matching rule is loaded by the game, so we store only the first one read |
| 85 | + case Some((rule, Rhd)) => |
| 86 | + val key = new EquivRule(rule) |
| 87 | + lookupRuleRhd.unapply(key) match { |
| 88 | + case Some(rule2) => if (rulesHaveSameOutput(rule, rule2)) None else Some((rule, rule2)) |
| 89 | + case None => rulesRhd.addOne(key, rule); None |
| 90 | + } |
| 91 | + case Some((rule, Lhd)) => |
| 92 | + val key = new EquivRule(rule) |
| 93 | + lookupRuleLhd.unapply(key) match { |
| 94 | + case Some(rule2) => if (rulesHaveSameOutput(rule, rule2)) None else Some((rule, rule2)) |
| 95 | + case None => rulesLhd.addOne(key, rule); None |
| 96 | + } |
| 97 | + case Some((rule, RhdAndLhd)) => |
| 98 | + val key = new EquivRule(rule) |
| 99 | + rulesShared.get(key) match { |
| 100 | + case Some(rule2) => if (rulesHaveSameOutput(rule, rule2)) None else Some((rule, rule2)) |
| 101 | + case None => |
| 102 | + if (!rulesRhd.contains(key) && !rulesLhd.contains(key)) { |
| 103 | + rulesShared.addOne(key, rule); None |
| 104 | + } else { |
| 105 | + rulesRhd.get(key) |
| 106 | + .flatMap(rule2 => if (rulesHaveSameOutput(rule, rule2)) None else Some((rule, rule2))) |
| 107 | + .orElse( |
| 108 | + rulesLhd.get(key) |
| 109 | + .flatMap(rule2 => if (rulesHaveSameOutput(rule, rule2)) None else Some((rule, rule2))) |
| 110 | + ) |
| 111 | + } |
| 112 | + } |
| 113 | + case None => None |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + var lineNumber = 0 |
| 118 | + var badFile = false |
| 119 | + def logConflict(x: Rule[IdTile], y: Rule[IdTile]): Unit = { |
| 120 | + if (!badFile) { |
| 121 | + LOGGER.info(s"==> $path") |
| 122 | + badFile = true |
| 123 | + } |
| 124 | + LOGGER.warning(s"$lineNumber: ${x(0)},${x(1)}=${x(2)},${x(3)} conflicts with ${y(0)},${y(1)}=${y(2)},${y(3)}") |
| 125 | + } |
| 126 | + |
| 127 | + if (!updateMode) { |
| 128 | + scala.util.Using.resource(new java.util.Scanner(path.toFile(), "UTF-8")) { scanner => |
| 129 | + while(scanner.hasNextLine()) { |
| 130 | + val line = scanner.nextLine() |
| 131 | + lineNumber += 1 |
| 132 | + findConflict(line) match { |
| 133 | + case Some((rule, rule2)) => |
| 134 | + if (!line.contains(tag)) { |
| 135 | + numConflicts += 1 |
| 136 | + logConflict(rule, rule2) |
| 137 | + } |
| 138 | + case None => // ignore |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + } else { // updateMode |
| 143 | + val tmpPath = path.resolveSibling(path.getFileName().toString() + ".tmp") |
| 144 | + val endsWithNewline = fileEndsWithNewline(path) // attempt to preserve missing newlines at end of files to avoid noise |
| 145 | + scala.util.Using.resources( |
| 146 | + new java.util.Scanner(path.toFile(), "UTF-8").useDelimiter(linePatternIncludingNewlines), |
| 147 | + new java.io.PrintWriter(tmpPath.toFile(), "UTF-8") |
| 148 | + ) { (lineScanner, printer) => |
| 149 | + while (lineScanner.hasNext()) { |
| 150 | + val line = lineScanner.next() |
| 151 | + findConflict(line) match { |
| 152 | + case Some(_) => |
| 153 | + numConflicts += 1 |
| 154 | + if (line.contains(tag)) { |
| 155 | + printer.print(line) // preserving original linebreaks |
| 156 | + } else { |
| 157 | + added += 1 |
| 158 | + printer.println(s"${line.stripLineEnd}; $tag") |
| 159 | + } |
| 160 | + case None => |
| 161 | + if (line.contains(tag)) { // no conflict, so remove tag |
| 162 | + removed += 1 |
| 163 | + printer.println(line.stripLineEnd.replaceFirst(s" ?$tag", "").replaceFirst(";\\s*$", "")) |
| 164 | + } else { |
| 165 | + printer.print(line) // preserving original linebreaks |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | + } |
| 170 | + Files.move(tmpPath, path, java.nio.file.StandardCopyOption.REPLACE_EXISTING) |
| 171 | + } |
| 172 | + } |
| 173 | + (numConflicts, removed, added) |
| 174 | + } |
| 175 | + |
| 176 | + /** Check if two rules with equivalent LHS actually lead to the same output on |
| 177 | + * the RHS (and thus are not at conflict with each other). |
| 178 | + */ |
| 179 | + def rulesHaveSameOutput(x: Rule[IdTile], y: Rule[IdTile]): Boolean = ( |
| 180 | + x(0) == y(0) && x(1) == y(1) && x(2) == y(2) && x(3) == y(3) || |
| 181 | + x(0) == y(0) * R2F1 && x(1) == y(1) * R2F1 && x(2) == y(2) * R2F1 && x(3) == y(3) * R2F1 || |
| 182 | + x(0) == y(1) * R2F0 && x(1) == y(0) * R2F0 && x(2) == y(3) * R2F0 && x(3) == y(2) * R2F0 || |
| 183 | + x(0) == y(1) * R0F1 && x(1) == y(0) * R0F1 && x(2) == y(3) * R0F1 && x(3) == y(2) * R0F1 || |
| 184 | + (x(2).id == 0 || x(3).id == 0) && (y(2).id == 0 || y(3).id == 0) |
| 185 | + ) |
| 186 | + |
| 187 | +} |
0 commit comments