Directory conventions
Where a file lives should tell you what it is responsible for. When you are unsure where a new file goes, ask who edits it first.
| Directory | Who edits it | Rule |
|---|---|---|
locale/ |
translators | Translated output and fonts. No code |
src/ |
developers | C# that runs inside the game’s Mono runtime |
tools/ |
developers | things a human runs by hand |
tests/ |
developers | runs without the game |
generated/ |
nobody | produced by tools/. Edit it and the next extraction erases you |
docs/ |
developers | documentation, and the GitHub Pages source |
Most of generated/ is in .gitignore. Anything extracted from the game does not
go in the repository. generated/fingerprint.json is the exception and the reason
is written next to it.
One project, three places code runs
Unlike a CDP mod, everything here is C# — which makes it easy to forget that these three fail in completely different ways.
src/ inside the game a thrown exception in a Harmony patch can
silently disable the patch and nothing else
tools/ run by hand some need the game installed, some don't
tests/ CI never sees the game; anchors read metadata only
A patch that throws does not crash the game. It leaves the feature quietly dead,
which is worse. Every patch body catches and logs; the logging goes through Log.cs
so there is one format to grep.
One responsibility per file
If you cannot say what a file does in one sentence, split it. If the sentence needs an “and”, it is already two.
Plugin (src/)
Plugin.cs the entry point: MelonMod lifecycle and module wiring. Nothing else
Config.cs MelonPreferences binding
Log.cs log formatting, and Guard() so a dead patch says so once
Reflect.cs member lookup that survives `new` shadowing. Pure, and tested
Alive.cs whether something from the game still exists. Unity's == lies
Anchors.cs every game type reached by name, and what to say when one is gone
Singletons.cs resolving the game's MonoSingleton<T> instances, in one place
Json.cs a flat {string: string} reader that refuses what it cannot parse
i18n/Catalog.cs locale/<lang>/strings.json → Key lookup
i18n/Patches.cs Harmony patches on LocalizedStringAsset and _BaseData
i18n/Font.cs TMP font asset creation and fallback registration
i18n/Dump.cs Resources.LoadAll extraction (dev only)
cheat/Widget.cs the panel: layout and input. No game writes
cheat/Economy.cs money, score, rerolls, removes, dismisses, rewinds
cheat/Run.cs weeks, target score, milestone
cheat/Build.cs placement restriction bypass, spawning
cheat/Unlocks.cs guild unlock and level
autoplay/Snapshot.cs a board as plain data. No Unity types, no game types
autoplay/Board.cs the only code that touches MapController/BuildingController
autoplay/State.cs InteractionState gating — when it is safe to act
autoplay/Rules.cs the placement rules we must evaluate ourselves. Pure
autoplay/Offers.cs describing a building from the choice bar, with no instance
autoplay/Value.cs what one placement on one tile is worth. Pure
autoplay/Plan.cs the ranked shortlist. Pure
autoplay/Overlay.cs drawing the shortlist. Reads, never writes
autoplay/Pace.cs how much hurry the milestone is in. Pure
autoplay/Screens.cs the screens a run stops on, and how to get past them
autoplay/Shop.cs whether to go in, what to buy, when to leave
autoplay/Quests.cs which council request to take, and collecting its reward
autoplay/sim/Rules.cs what the game knows about a building, with no game
autoplay/sim/Map.cs a board placement can be tried on and taken back off. Pure
autoplay/sim/Preview.cs GetScorePreview, reproduced. Pure
autoplay/sim/Evaluate.cs what a placement is worth, in points. Pure
autoplay/sim/Policy.cs the weights the trainer fits. Pure
autoplay/sim/Milestone.cs a whole milestone played out - the training episode. Pure
autoplay/sim/Dump.cs reading the rules out of the game, once
autoplay/sim/Live.cs the simulator attached to a running game
autoplay/Items.cs spending what the run has picked up
autoplay/Offers.cs describing an offered building, with no instance to ask
autoplay/Supervisor.cs the loop: WHEN to act, never what
autoplay/Exec.cs the only file in autoplay/ that WRITES to the game
Board.cs being the only file that touches the game’s controllers is the point.
Reading game state and deciding what to do with it are different jobs; entangled,
neither is testable. Split, Value.cs takes a plain board snapshot and CI can run it.
Overlay.cs never writing is what makes stage 1 of the play helper safe to ship
before the valuation is any good.
Everything under sim/ except Dump.cs and Live.cs is pure as well, and for a
second reason: tools/train links those files and runs tens of thousands of
simulated milestones on a machine that has never seen the game. Dump.cs reads the
rules out of a running game and Live.cs attaches the simulator to one; those two
are the boundary, and nothing else in sim/ may touch Unity.
Snapshot.cs, Rules.cs, Value.cs and Plan.cs carry no Unity and no game
types, which is what lets tests/ link them as source and run them where neither
Unity nor the game exists. That constraint is the reason Board.cs exists at all.
Adding a UnityEngine using to any of those four takes the tests with it.
Translations (locale/)
locale/ko/strings.json Key → Korean. Nothing else
locale/fonts/Galmuri11.ttf
locale/fonts/OFL-Galmuri.txt
strings.json is keyed by the game’s own LocalizedStringAsset.Key, so a game
update that rewords English does not move our translations — it only reveals which
keys are new. The English is never stored here; it lives in generated/, which is
ignored, because it is the game’s text.
Testing what only the real game can show
Two suites, answering two different questions.
dotnet test tests/Combolands.Mod.Tests # is the reasoning right? no game, no Unity
dotnet test tests/Combolands.Anchors # is the game still there? metadata only
The first runs against hand-written board fixtures and hand-written type hierarchies. That is the only way to state a case precisely — and also why none of them would notice a patch target drifting in the real game.
The second closes exactly that gap. It reads an installed Assembly-CSharp.dll as
metadata — nothing is loaded, constructed or executed — and checks every bound row
of ANCHORS.md in one pass, naming the row that broke. With no game installed it
skips rather than passes: a green run that checked nothing would be worse than
no test at all, so CI sees yellow and says so. The checks that hold ANCHORS.md and
the catalogue to each other still run there, since those need no game.
Both link the plugin’s own files as source rather than referencing the built
assembly, for the same reason: the plugin targets net472 and binds Unity and
MelonLoader, neither of which exists on a runner. The anchor suite links
Reflect.cs in particular because binding a member the way the mod binds it is
the whole point — a check written with GetProperty would pass on overloads the mod
cannot actually resolve.
One gap is still open, and it is worth naming rather than implying away.
Nothing watches the valuation’s judgement. Whether a suggestion is good is not a property any fixture can assert — every correction to it so far came from playing a run and disagreeing with what was on screen. The tests pin the mechanics the judgement is built from; they cannot pin the judgement.
Commit and branch
main is releasable. Tags are vX.Y.Z and a tag push is what publishes — see
release.yml. Anything extracted from the game stays out of history; it cannot be
removed later without a rewrite.