Skip to content

Commit 9dd8f28

Browse files
committed
add script for checking for duplicate/conflicting RUL2 code
1 parent 5d37db5 commit 9dd8f28

2 files changed

Lines changed: 191 additions & 1 deletion

File tree

build.sbt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def runMainWithJLogger(main: String) = Def.inputTask {
4646
mainClass = if (main == null) args(0) else main,
4747
classpath = (Compile / fullClasspath).value.files,
4848
log = wrapWithJLogger(streams.value.log),
49-
options = Seq.empty[String])
49+
options = args)
5050
}
5151

5252
// Compile / mainClass := Some("metarules.module.CompileAllMetarules") // execute with `sbt run`
@@ -60,6 +60,9 @@ generateLocales := runMainWithJLogger("com.sc4nam.localization.GenerateLocales")
6060
lazy val regenerateTileOrientationCache = inputKey[scala.util.Try[Unit]]("Regenerates the cache used for translating metarules to RUL2")
6161
regenerateTileOrientationCache := runMainWithJLogger("com.sc4nam.module.RegenerateTileOrientationCache").evaluated
6262

63+
lazy val conflictingOverridesCheck = inputKey[scala.util.Try[Unit]]("Checks all RUL2 code for conflicting overrides, optionally updates the inline `conflicting-override` tags")
64+
conflictingOverridesCheck := runMainWithJLogger("com.sc4nam.scripts.ConflictingOverridesChecker").evaluated
65+
6366

6467
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.15" % "test"
6568

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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

Comments
 (0)