From ff8b66ace1312df7e40f5b76d0198fcb4e601b00 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 01:32:37 -0500 Subject: [PATCH 1/5] refactor(strings): split strings by owner into core, UO and Rust files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md M20 step 1. strings.xml keeps the core strings; strings_uo.xml and strings_rust.xml take the strings only that game's screens use. Android merges the three into one R.string, so no key changes and no Kotlin changes. Each string was classified by the screens that reference it, traced in code, not by its key: a string any deployment's screen can show stays core. error_shard_offline and error_feature_unavailable are the shared error-view texts, so they stay core. All seven legacy push streams, server.status included, are declared only by module-uo, so their titles are UO. No text changes: all 578 elements are moved byte for byte (checked by comparing name → element before and after). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- app/src/main/res/values/strings.xml | 394 +---------------------- app/src/main/res/values/strings_rust.xml | 157 +++++++++ app/src/main/res/values/strings_uo.xml | 256 +++++++++++++++ 3 files changed, 419 insertions(+), 388 deletions(-) create mode 100644 app/src/main/res/values/strings_rust.xml create mode 100644 app/src/main/res/values/strings_uo.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7d4ef2..ebc842d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,11 @@ All user-facing strings are externalized from day one (PLAN.md §2): English is the only bundled locale, but the structure invites community translations. No hardcoded UI strings in Kotlin. + + The strings are split by owner (PLAN.md M20), and Android merges the three files + into one R.string: this file is CORE — every screen any deployment can show — and + uses core's words only. A game's words live in strings_uo.xml and strings_rust.xml. + StringVocabularyTest fails the build when a game's word lands in the wrong file. --> @@ -43,23 +48,12 @@ News Events Wiki - Shard - Rust servers - Rules - Atlas - Leaderboards - Market About Contact My account My events - My characters - My vendors - My houses Dashboard Content - Moderation - Support queue Sign in Sign out Change server @@ -110,38 +104,6 @@ A title is required. Slug and title are required. - - Account action - Account - Serial (0x…) - Reason (ban) - Ban duration (seconds; blank = indefinite) - Kick - Ban - Unban - Broadcast - Message to everyone online - Broadcast - Account kicked. - Account banned. - Ban cleared. - Message broadcast. - Enter an account or serial. - Enter a message to broadcast. - The shard is offline — the action couldn\'t be delivered. - - - No open help pages. - Reply - Close - Send - Reply message - Close the page after replying - Reply sent. - Page closed. - Enter a reply message. - That page is no longer in the queue. - Sign in Sign in with your shard account. @@ -256,77 +218,7 @@ Share Done - - Link your game account - In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses. - Link code - Link account - Account linked. - That code is unknown or has expired. Run [link in game for a new one. - Couldn\'t link that code. Please try again. - - Create a game account - Account name - Password - Create account - Game account created and linked. - That account name is already taken. - The account limit for your network has been reached. - Game-account signup isn\'t available right now. - That account name or password wasn\'t accepted. - Couldn\'t create that account. Please try again. - - - Link a game account above to see your characters. - Loading… - No characters on this account. - Unknown - Online - Offline - - - Governor of %1$s - Guildmaster, %1$s - Attributes - Vitals - STR - DEX - INT - Hits - Mana - Stamina - Resistances - Physical - Fire - Cold - Poison - Energy - Skills - Loyalty & Points - - %1$s · #%2$d - - %1$d / %2$d - Equipment - Item - id %1$d - hue %1$d - - - Recent sales - No recent sales. - %1$s gp - Link a game account to see your vendors. - No vendors on this account. - Player vendor - Holding %1$s gp - No items listed. - Item %1$d ×%2$d - - - You don\'t have any houses on your linked accounts. - A house - Status: %1$s + Player Moderator Editor @@ -365,127 +257,6 @@ Please fill in every field. Couldn\'t send your message. Please try again. - - Live - Offline - Shard online - Shard offline - %1$d players online - Gold supply: %1$s - Staff online - Live activity - No recent activity. - Champion spawns - Guilds - Governors - Falling houses - - - No champion spawns are being tracked right now. - Champion spawn - Level %1$d - Restarting - - - No guilds are being tracked right now. - Guild - %1$d members - Led by %1$s - Alliance: %1$s - - - - This shard hasn\'t published its ruleset yet. - Shard - Systems - Skill & stat caps - Accounts & housing - Vendors - Saves & restarts - Name - Expansion - Connect - Individual skill cap - Total skill cap - Total stat cap - Strength cap - Dexterity cap - Intelligence cap - Character slots - Accounts per IP - Houses per account - Vendor restock delay - Max sell quantity - Auto-save every - Auto-restart at - %1$d min - - - This shard isn\'t publishing any leaderboards yet. - Nobody has scored here yet. - - — - %1$d players - - Cap: %1$d - #%1$d %2$s - - Someone - - - Search every shop - No listings match that search. - This shop has nothing for sale. - - Prices are refreshed in rotation and may be out of date. - %1$d gp - Item %1$d - A shop - Kept by %1$s - - This shard doesn\'t publish shop locations. - Showing %1$d of %2$d — this shop holds more than the shard publishes. - - - Search creatures - No creatures match that search. - - - %1$d spawner - %1$d spawners - - Up to %1$d alive at once - %1$s (%2$d) - - Where it spawns - up to %1$d at once - Spawn points - Also spawns here - More spawn points than shown. - Up to %1$d - - Respawn %1$s - - - No governors — this shard may not run the City Loyalty system. - Governed by %1$s - No governor - Election: %1$s - Term history - Loading… - No past terms recorded. - - - No houses are in danger right now. - A house - IDOC - Notifications Notifications @@ -522,13 +293,6 @@ New post - Shard status changed - A house is falling (IDOC) - Champion spawn started - New governor elected - Your vendor made a sale - Your house entered IDOC - Login to your account New notification @@ -571,150 +335,4 @@ Score %1$s Show more Loading… - - - No Rust servers are configured on this site yet. - Offline - %1$d / %2$d online - has never reported - last reported %1$s - last reported %1$s — out of date, so it is shown as offline - size %1$d - seed %1$d - wiped %1$s - Next wipe %1$s - Next wipe %1$s (rescheduled) - Could not refresh just now. This is the last thing the site heard. - No such server - This address does not name a server this site follows. - Back to the server list - Feed - Leaderboard - Online - Wipes - Map - - No map yet. This server has not told the site which map it is on. The map will appear once the server is up. - This server has no picture of its map, so the layers are drawn on a plain background. - The picture of the map could not be loaded, so the layers are drawn on a plain background. - Map of the server - No moving layers are shown to you on this server. - Asking the server where things are… - The server did not say where things are just now. The site asks again every ten seconds. - The server is offline, so nothing is moving on its map. - Positions could not be loaded. - Could not refresh just now. These are the last positions the site heard. - Positions as of %1$s. They refresh every ten seconds while this tab is open. - Grid - Monuments & world events - Site events - Players - Bases - You and your clan - Sign in to see this layer. - Shown to everyone. - Shown to staff only. - %1$s Limited by who may see who is online. - Not every sleeper is shown. - Not every base is shown. - Your own position, and clan mates who are online. - Link your Steam account to see yourself and your clan here. - Cargo ship - Patrol helicopter - Chinook - Bradley APC - Supply drop - Locked crate - %1$s left on the hack - Hacked - Event zone - Event crate - Event NPC - Site event - Site event · %1$s - %1$s (boss) - Boss - %1$s (a boss\'s add) - Open event - Tool cupboard - Vending machine - Sleeping - Asleep, offline - You - Clan mate - Grid %1$s - Close - All time - %1$s (this wipe) - Current - Showing - Nothing has happened on this server yet — or not during the wipe you are looking at. - Nobody has scored here yet. - This server has not reported a wipe yet. - - - %1$d player online - %1$d players online - - Only this site’s staff can see who they are. - Sign in to see who they are. - This site is not showing who they are right now. - Joins, deaths and chat are not shown. Only this site’s staff can see what players did. - Joins, deaths and chat are not shown. Sign in to see what players did. - The server is up and the island is empty. Somebody has to be first. - Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is. - On the server right now. - The last board this server sent. It is offline, so this is who was on then — not who is on now. - Sleeping - Player - Kills - Deaths - NPC - Built - Played - %1$d players online - - - My Rust account - Link your Steam account - Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once. - Link code - Link account - Linked. Your play on our servers now appears under your name here. - That account was already linked to you. - That code is unknown or has expired. Type /link in game for a new one. - That Steam account is linked to another website account. Run /unlink in game to release it. - Too many attempts just now. Try again in a few minutes. - A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute. - Could not link that code. - Unlink - Could not unlink that account. - linked %1$s - No Steam account is linked to this profile yet. - A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game. - What you can do in game - Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover. - None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync. - A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back. - No servers are configured yet. - Coins - Link a Steam account to see your balance. - Economics is not installed on this server. - %1$s · %2$s coins - Last known — the server did not answer just now. - Backpack - Link a Steam account to see your backpack. - Open backpack - Read again - Close - Backpacks is not installed on this server. - This site cannot show backpacks yet. - No backpack here yet — it has never been opened. - %1$d stacks in %2$d slots - %1$d past your size — they move to your inventory, or drop at your feet if it is full, the next time you open it. - Page %1$d - %1$s · has it - %1$s · waiting diff --git a/app/src/main/res/values/strings_rust.xml b/app/src/main/res/values/strings_rust.xml new file mode 100644 index 0000000..6f92576 --- /dev/null +++ b/app/src/main/res/values/strings_rust.xml @@ -0,0 +1,157 @@ + + + + + + Rust servers + + + No Rust servers are configured on this site yet. + Offline + %1$d / %2$d online + has never reported + last reported %1$s + last reported %1$s — out of date, so it is shown as offline + size %1$d + seed %1$d + wiped %1$s + Next wipe %1$s + Next wipe %1$s (rescheduled) + Could not refresh just now. This is the last thing the site heard. + No such server + This address does not name a server this site follows. + Back to the server list + Feed + Leaderboard + Online + Wipes + Map + + No map yet. This server has not told the site which map it is on. The map will appear once the server is up. + This server has no picture of its map, so the layers are drawn on a plain background. + The picture of the map could not be loaded, so the layers are drawn on a plain background. + Map of the server + No moving layers are shown to you on this server. + Asking the server where things are… + The server did not say where things are just now. The site asks again every ten seconds. + The server is offline, so nothing is moving on its map. + Positions could not be loaded. + Could not refresh just now. These are the last positions the site heard. + Positions as of %1$s. They refresh every ten seconds while this tab is open. + Grid + Monuments & world events + Site events + Players + Bases + You and your clan + Sign in to see this layer. + Shown to everyone. + Shown to staff only. + %1$s Limited by who may see who is online. + Not every sleeper is shown. + Not every base is shown. + Your own position, and clan mates who are online. + Link your Steam account to see yourself and your clan here. + Cargo ship + Patrol helicopter + Chinook + Bradley APC + Supply drop + Locked crate + %1$s left on the hack + Hacked + Event zone + Event crate + Event NPC + Site event + Site event · %1$s + %1$s (boss) + Boss + %1$s (a boss\'s add) + Open event + Tool cupboard + Vending machine + Sleeping + Asleep, offline + You + Clan mate + Grid %1$s + Close + All time + %1$s (this wipe) + Current + Showing + Nothing has happened on this server yet — or not during the wipe you are looking at. + Nobody has scored here yet. + This server has not reported a wipe yet. + + + %1$d player online + %1$d players online + + Only this site’s staff can see who they are. + Sign in to see who they are. + This site is not showing who they are right now. + Joins, deaths and chat are not shown. Only this site’s staff can see what players did. + Joins, deaths and chat are not shown. Sign in to see what players did. + The server is up and the island is empty. Somebody has to be first. + Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is. + On the server right now. + The last board this server sent. It is offline, so this is who was on then — not who is on now. + Sleeping + Player + Kills + Deaths + NPC + Built + Played + %1$d players online + + + My Rust account + Link your Steam account + Join any of our Rust servers and type /link in chat. The server replies with a six-character code only you can see, good for five minutes. Enter it here — it works once. + Link code + Link account + Linked. Your play on our servers now appears under your name here. + That account was already linked to you. + That code is unknown or has expired. Type /link in game for a new one. + That Steam account is linked to another website account. Run /unlink in game to release it. + Too many attempts just now. Try again in a few minutes. + A server could not be reached, so that code could not be checked. Your code is still good — try again in a minute. + Could not link that code. + Unlink + Could not unlink that account. + linked %1$s + No Steam account is linked to this profile yet. + A link covers every server this community runs — a Steam account is one person wherever they play. You can also type /unlink in game. + What you can do in game + Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover. + None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync. + A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back. + No servers are configured yet. + Coins + Link a Steam account to see your balance. + Economics is not installed on this server. + %1$s · %2$s coins + Last known — the server did not answer just now. + Backpack + Link a Steam account to see your backpack. + Open backpack + Read again + Close + Backpacks is not installed on this server. + This site cannot show backpacks yet. + No backpack here yet — it has never been opened. + %1$d stacks in %2$d slots + %1$d past your size — they move to your inventory, or drop at your feet if it is full, the next time you open it. + Page %1$d + %1$s · has it + %1$s · waiting + diff --git a/app/src/main/res/values/strings_uo.xml b/app/src/main/res/values/strings_uo.xml new file mode 100644 index 0000000..deaa7b0 --- /dev/null +++ b/app/src/main/res/values/strings_uo.xml @@ -0,0 +1,256 @@ + + + + + + Shard + Rules + Atlas + Leaderboards + Market + My characters + My vendors + My houses + Moderation + Support queue + + + + Account action + Account + Serial (0x…) + Reason (ban) + Ban duration (seconds; blank = indefinite) + Kick + Ban + Unban + Broadcast + Message to everyone online + Broadcast + Account kicked. + Account banned. + Ban cleared. + Message broadcast. + Enter an account or serial. + Enter a message to broadcast. + The shard is offline — the action couldn\'t be delivered. + + + No open help pages. + Reply + Close + Send + Reply message + Close the page after replying + Reply sent. + Page closed. + Enter a reply message. + That page is no longer in the queue. + + + Link your game account + In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses. + Link code + Link account + Account linked. + That code is unknown or has expired. Run [link in game for a new one. + Couldn\'t link that code. Please try again. + + Create a game account + Account name + Password + Create account + Game account created and linked. + That account name is already taken. + The account limit for your network has been reached. + Game-account signup isn\'t available right now. + That account name or password wasn\'t accepted. + Couldn\'t create that account. Please try again. + + + Link a game account above to see your characters. + Loading… + No characters on this account. + Unknown + Online + Offline + + + Governor of %1$s + Guildmaster, %1$s + Attributes + Vitals + STR + DEX + INT + Hits + Mana + Stamina + Resistances + Physical + Fire + Cold + Poison + Energy + Skills + Loyalty & Points + + %1$s · #%2$d + + %1$d / %2$d + Equipment + Item + id %1$d + hue %1$d + + + Recent sales + No recent sales. + %1$s gp + Link a game account to see your vendors. + No vendors on this account. + Player vendor + Holding %1$s gp + No items listed. + Item %1$d ×%2$d + + + You don\'t have any houses on your linked accounts. + A house + Status: %1$s + + + Live + Offline + Shard online + Shard offline + %1$d players online + Gold supply: %1$s + Staff online + Live activity + No recent activity. + Champion spawns + Guilds + Governors + Falling houses + + + No champion spawns are being tracked right now. + Champion spawn + Level %1$d + Restarting + + + No guilds are being tracked right now. + Guild + %1$d members + Led by %1$s + Alliance: %1$s + + + + This shard hasn\'t published its ruleset yet. + Shard + Systems + Skill & stat caps + Accounts & housing + Vendors + Saves & restarts + Name + Expansion + Connect + Individual skill cap + Total skill cap + Total stat cap + Strength cap + Dexterity cap + Intelligence cap + Character slots + Accounts per IP + Houses per account + Vendor restock delay + Max sell quantity + Auto-save every + Auto-restart at + %1$d min + + + This shard isn\'t publishing any leaderboards yet. + Nobody has scored here yet. + + — + %1$d players + + Cap: %1$d + #%1$d %2$s + + Someone + + + Search every shop + No listings match that search. + This shop has nothing for sale. + + Prices are refreshed in rotation and may be out of date. + %1$d gp + Item %1$d + A shop + Kept by %1$s + + This shard doesn\'t publish shop locations. + Showing %1$d of %2$d — this shop holds more than the shard publishes. + + + Search creatures + No creatures match that search. + + + %1$d spawner + %1$d spawners + + Up to %1$d alive at once + %1$s (%2$d) + + Where it spawns + up to %1$d at once + Spawn points + Also spawns here + More spawn points than shown. + Up to %1$d + + Respawn %1$s + + + No governors — this shard may not run the City Loyalty system. + Governed by %1$s + No governor + Election: %1$s + Term history + Loading… + No past terms recorded. + + + No houses are in danger right now. + A house + IDOC + + + Shard status changed + A house is falling (IDOC) + Champion spawn started + New governor elected + Your vendor made a sale + Your house entered IDOC + Login to your account + From 5e28989adca33e5780a62aef70a7dae3b2d5286c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 01:33:10 -0500 Subject: [PATCH 2/5] feat(strings): use core's words on the screens every deployment shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md M20 step 2. The first screen a Rust community's player sees said "Connect to your shard", and the next said "Sign in with your shard account". The core strings now use core's words, as the website and runicgateway.com do: - connect_*: "your community", "a Runic Gateway site" - login_subtitle: "Sign in with your account on this site." - error_shard_offline: "The game server is offline right now." It is the shared 503 text that both games' screens show. - error_feature_unavailable and notifications_*: "This site …" - push_channel_* and push_service_text: "Notifications from your community" Two comments in the file said "shard" too, and now say "site". Keys are unchanged. The UO screens keep their words. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- app/src/main/res/values/strings.xml | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ebc842d..bd9cafd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ StringVocabularyTest fails the build when a game's word lands in the wrong file. --> - + Runic Gateway @@ -20,22 +20,22 @@ Can\'t reach the site. Check your connection and try again. This content couldn\'t be found. Too many requests. Please try again in a moment. - The shard is offline right now. - This shard doesn\'t publish this here. + The game server is offline right now. + This site doesn\'t publish this here. Something went wrong on the server. Please try again. - Connect to your shard - Enter your shard\'s website address to get started. + Connect to your community + Enter your community\'s website address to get started. Website address - https://your-shard.example.com + https://your-community.example.com Connect Connecting… Enter a website address. That doesn\'t look like a valid web address. Only http and https addresses are supported. A secure https address is required. - That site isn\'t a Runic Gateway shard. + That site isn\'t a Runic Gateway site. This app is out of date for that site (it speaks API %1$s). Update the app and try again. Couldn\'t reach that site. Check the address and your connection. The site responded with an error (%1$d). Try again shortly. @@ -106,7 +106,7 @@ Sign in - Sign in with your shard account. + Sign in with your account on this site. Username Password Authentication code @@ -114,7 +114,7 @@ Sign in Create an account Forgot your password? - + Sign in with SSO Choose a sign-in provider @@ -260,16 +260,16 @@ Notifications Notifications - Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on. + Choose what this site notifies you about, and how it reaches you. Nothing is sent unless you turn it on. General Your game account Link a game account to get this as a push notification. - This shard offers no notification streams yet. - This shard hasn\'t set up push notifications yet. + This site offers no notification streams yet. + This site hasn\'t set up push notifications yet. Notification settings saved. Notifications turned off. Couldn\'t save your notification settings. Try again. - This shard\'s push relay isn\'t reachable right now. + This site\'s push relay isn\'t reachable right now. Off @@ -284,12 +284,12 @@ Offline — showing what was saved on this device. - Shard notifications - Alerts you opted into from this shard. + Notifications from your community + Alerts you opted into from this site. Background connection Keeps the connection open to deliver notifications. Notifications active - Listening for shard notifications. + Listening for notifications from your community. New post From 3048582ce0daa59a329d41f307143bb7d03fd385 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 01:35:59 -0500 Subject: [PATCH 3/5] test(strings): guard each strings file against another game's words PLAN.md M20 step 3. StringVocabularyTest reads strings.xml, strings_uo.xml and strings_rust.xml in every values* directory, so translations are checked too. It fails, naming the key and the word, when the core file holds either game's word, the UO file holds a Rust word, or the Rust file holds a UO word. The word list is runicgateway.com's checkTerms.mjs list, word for word. The app never shows Discord's "guild ID", so that allowlist entry isn't carried over. Three more tests keep the guard honest: - a fourth strings file with no owner is refused, because Android would read it and the guard would skip it; - the three default files must exist and hold strings, so a wrong path can't pass on nothing; - the matcher is checked against known hits and misses. Run against the core file as it was before the rewording, it names exactly the 14 strings that commit changed. build.gradle.kts declares src/main/res as an input of the Test tasks. A reworded string changes no class, so without that input Gradle left the test task UP-TO-DATE and the guard did not run again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- app/build.gradle.kts | 7 + .../runicgateway/app/StringVocabularyTest.kt | 168 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0df18df..4594d20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,13 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) } +// StringVocabularyTest (PLAN.md M20) reads the string resources from disk. A reworded +// string changes no class, so without this input the test task would stay UP-TO-DATE +// and the guard would not run again. +tasks.withType().configureEach { + inputs.dir("src/main/res").withPathSensitivity(PathSensitivity.RELATIVE) +} + // JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated, // DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number; // pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because diff --git a/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt b/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt new file mode 100644 index 0000000..dc60bd3 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/StringVocabularyTest.kt @@ -0,0 +1,168 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** + * The guard on the app's words (PLAN.md M20). A game's words stay on that game's + * screens, and the screens every deployment shows use core's words. This is the + * app's half of the rule the website docs and runicgateway.com already follow + * (runicgateway.com PLAN.md §16, D63/D64). + * + * The strings are split by owner into three resource files that Android merges as + * one `R.string`: `strings.xml` (core), `strings_uo.xml` and `strings_rust.xml`. + * A string's owner is the screens that use it, not its key: a string any screen a + * Rust deployment can reach is core, even when its key says `shard_`. This test + * reads the three files and fails, naming the key and the word, when + * - the core file holds either game's word; + * - the UO file holds a Rust word; + * - the Rust file holds a UO word. + * + * It checks every `values*` directory, so a translation is held to the same rule. + * Comments are not read, because nobody using the app sees them. + */ +class StringVocabularyTest { + + private enum class Owner(val file: String, val label: String) { + CORE("strings.xml", "core"), + UO("strings_uo.xml", "Ultima Online"), + RUST("strings_rust.xml", "Rust"), + } + + private class Term(val game: Owner, val word: String, pattern: String) { + val regex = Regex(pattern, RegexOption.IGNORE_CASE) + } + + /** + * runicgateway.com's `scripts/checkTerms.mjs` list, word for word. Each word is a game's + * name for something core names differently (shard → game server, guild and clan → + * Team), or the name of one game's component. Game NAMES (Ultima Online, Rust) are not + * on it, and neither is Steam, which is a core sign-in provider. Matching is on word + * boundaries, so "wipe" does not match "wiped" and "shard" does not match `SHARD_X`. + */ + private val terms = listOf( + Term(Owner.UO, "shard", """\bshards?\b"""), + Term(Owner.UO, "ServUO", """\bservuo\b"""), + Term(Owner.UO, "uo-link", """\buo-?link\b"""), + Term(Owner.UO, "Module-uo", """\bmodule-uo\b"""), + Term(Owner.UO, "Bridge.cfg", """\bbridge\.cfg\b"""), + Term(Owner.UO, "cliloc", """\bclilocs?\b"""), + Term(Owner.UO, "guild", """\bguilds?\b"""), + Term(Owner.UO, "IDOC", """\bidocs?\b"""), + Term(Owner.RUST, "rust-link", """\brust-?link\b"""), + Term(Owner.RUST, "Rust-Plugins", """\brust-plugins\b"""), + Term(Owner.RUST, "Module-Rust", """\bmodule-rust\b"""), + Term(Owner.RUST, "Oxide", """\boxide\b"""), + Term(Owner.RUST, "Carbon", """\bcarbon\b"""), + Term(Owner.RUST, "uMod", """\bumod\b"""), + Term(Owner.RUST, "clan", """\bclans?\b"""), + Term(Owner.RUST, "wipe", """\bwipes?\b"""), + ) + + /** The words [owner]'s file may not use. */ + private fun forbiddenIn(owner: Owner): List = when (owner) { + Owner.CORE -> terms + Owner.UO -> terms.filter { it.game == Owner.RUST } + Owner.RUST -> terms.filter { it.game == Owner.UO } + } + + /** The words in [text] that [owner]'s file may not use. */ + private fun hits(owner: Owner, text: String): List> = + forbiddenIn(owner).flatMap { term -> term.regex.findAll(text).map { term to it.value }.toList() } + + /** + * The module's `res`, wherever the test runs from: Gradle runs unit tests in the + * module directory, and an IDE may run them from the repository root. + */ + private val res: File = listOf(File("src/main/res"), File("app/src/main/res")) + .firstOrNull { it.isDirectory } + ?: error("Can't find src/main/res from ${File(".").absolutePath}") + + private val valuesDirs: List + get() = res.listFiles { f -> f.isDirectory && f.name.startsWith("values") }!!.sortedBy { it.name } + + /** Every resource in [file] that the app shows: key → its visible text. */ + private fun visibleText(file: File): List> { + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val children = doc.documentElement.childNodes + return (0 until children.length) + .map { children.item(it) } + .filterIsInstance() + // A plural's items arrive as one text, which is all a word scan needs. + .map { it.getAttribute("name") to it.textContent } + } + + @Test fun eachFileUsesOnlyItsOwnGamesWords() { + val failures = mutableListOf() + for (dir in valuesDirs) { + for (owner in Owner.entries) { + val file = File(dir, owner.file) + if (!file.exists()) continue + for ((key, text) in visibleText(file)) { + for ((term, found) in hits(owner, text)) { + failures += "${dir.name}/${owner.file}: $key uses \"$found\", " + + "${term.game.label} vocabulary, in the ${owner.label} file" + } + } + } + } + if (failures.isNotEmpty()) { + fail( + failures.joinToString( + separator = "\n ", + prefix = "${failures.size} game word(s) outside their game's file:\n ", + postfix = "\n\nPLAN.md M20: a string any deployment's screen shows is core, and uses " + + "core's words (site, community, game server, Team). Reword it, or, if only one " + + "game's screens use it, move it to that game's file.", + ), + ) + } + } + + /** + * A fourth strings file would be read by Android and skipped by this guard, so it + * is refused. A new game gets its own file and its own row in [Owner]. + */ + @Test fun everyStringsFileHasAnOwner() { + val known = Owner.entries.map { it.file }.toSet() + val strays = valuesDirs.flatMap { dir -> + dir.listFiles { f -> f.name.startsWith("strings") && f.name !in known }!!.map { "${dir.name}/${it.name}" } + } + assertEquals("Strings files with no owner in StringVocabularyTest", emptyList(), strays) + } + + /** Without this a wrong path would let the guard pass on nothing. */ + @Test fun theDefaultFilesAreAllThere() { + for (owner in Owner.entries) { + val file = File(res, "values/${owner.file}") + assertTrue("${file.path} is missing", file.exists()) + assertTrue("${file.path} holds no strings", visibleText(file).isNotEmpty()) + } + } + + /** The matcher itself, so a broken pattern can't make every file look clean. */ + @Test fun theWordListCatchesWhatItShould() { + fun words(owner: Owner, text: String) = hits(owner, text).map { it.first.word } + + assertEquals(listOf("shard"), words(Owner.CORE, "Connect to your shard")) + assertEquals(listOf("guild"), words(Owner.CORE, "Guilds")) + assertEquals(listOf("clan"), words(Owner.CORE, "You and your clan")) + assertEquals(listOf("wipe"), words(Owner.UO, "Next wipe %1\$s")) + assertEquals(listOf("IDOC"), words(Owner.RUST, "Your house entered IDOC")) + // Each game's own words are fine in its own file. + assertEquals(emptyList(), words(Owner.UO, "Shard online")) + assertEquals(emptyList(), words(Owner.RUST, "Clan mate")) + // Word boundaries: not a prefix, not an identifier. + assertEquals(emptyList(), words(Owner.CORE, "wiped %1\$s")) + assertEquals(emptyList(), words(Owner.CORE, "Guildmaster")) + assertEquals(emptyList(), words(Owner.CORE, "SHARD_VISIBILITY")) + } +} From bb9cd9008d0e5a58b242b8ad170308020cfd8080 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 01:36:53 -0500 Subject: [PATCH 4/5] fix(nav): gate Moderation and Support queue on the shard module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the M20 trace, and fixed in this PR by the org lead's decision. Both rows call /admin/shard/*, module-uo's admin mount, but they carried no capability. So a moderator on a Rust-only site saw "Moderation" in the drawer and opened a UO form ("Serial (0x…)", kick or ban by UO account) where every action failed. Both rows now declare Capability.SHARD, as the player rows do, so they are UO screens and their strings belong in strings_uo.xml, where step 1 put them. A new MenuCapabilityGatingTest case covers it: a moderator on a Rust-only backend sees neither row but keeps the Dashboard, and on a UO backend sees both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../runicgateway/app/ui/navigation/Menu.kt | 17 ++++++++++++++-- .../ui/navigation/MenuCapabilityGatingTest.kt | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index a8f7c8d..a3bb18e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -196,8 +196,21 @@ val APP_MENU: List = listOf( // Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call. MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF), MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF), - MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR), - MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR), + // Moderation and the support queue act on the shard (`/admin/shard/*`, module-uo's + // admin mount), so they need the module installed like the player rows do. On a + // Rust-only site they once showed a UO form whose every action failed (M20). + MenuEntry( + Routes.ADMIN_MODERATION, + R.string.menu_admin_moderation, + MenuAccess.MODERATOR, + capability = Capability.SHARD, + ), + MenuEntry( + Routes.ADMIN_SUPPORT, + R.string.menu_admin_support, + MenuAccess.MODERATOR, + capability = Capability.SHARD, + ), ) /** diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt index 99df01a..6e42726 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt @@ -168,6 +168,26 @@ class MenuCapabilityGatingTest { } } + @Test fun moderationAndSupportNeedTheShardModule() { + // Both call `/admin/shard/*`, module-uo's admin mount. Before M20 they + // carried no capability, so a moderator on a Rust-only site saw a UO form + // whose every action failed. + val staffRows = APP_MENU.filter { it.route == Routes.ADMIN_MODERATION || it.route == Routes.ADMIN_SUPPORT } + assertEquals(2, staffRows.size) + assertTrue(staffRows.all { it.capability == Capability.SHARD }) + + val moderator = signedIn(Role.MODERATOR) + val rustOnly = visibleEntries(APP_MENU, moderator, null, serving(Capability.RUST)).map { it.route } + assertFalse(Routes.ADMIN_MODERATION in rustOnly) + assertFalse(Routes.ADMIN_SUPPORT in rustOnly) + // …while the core staff rows stay. + assertTrue(Routes.ADMIN_DASHBOARD in rustOnly) + + val uo = visibleEntries(APP_MENU, moderator, null, serving(Capability.SHARD)).map { it.route } + assertTrue(Routes.ADMIN_MODERATION in uo) + assertTrue(Routes.ADMIN_SUPPORT in uo) + } + @Test fun bothEventRowsDeclareCoresCapabilityAndNoFeature() { // Events are core's. A `feature` on one of them would gate a core screen on // a module's visibility config, which is the coupling this separation exists From c41aff9b2e518be53575387a71ef807fe45e5662 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 01:38:01 -0500 Subject: [PATCH 5/5] feat(rust): print the weapon name the module sends in the feed PLAN.md M20 step 4. Module-Rust#60 gives every event frame a weaponName: the game's own name for a ranged weapon, or the prefab name as words. The feed printed the prefab name as words itself, which read "with ak47u entity". weaponName() in RustFormat.kt is the Kotlin half of module-rust's format.js weapon(). It prints frame.weaponName. Against a module too old to send it, it drops the .entity / .weapon / .deployed ending before prefabName(), as the website does, so an older module's kill reads "with ak47u". Tests: a kill with weaponName, the same kill from an older module, and the fallback's edges (blank name, each ending, an ending only at the end, no weapon). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../com/runicgateway/app/ui/rust/RustFeed.kt | 2 +- .../runicgateway/app/ui/rust/RustFormat.kt | 17 +++++++++++ .../runicgateway/app/ui/rust/RustFeedTest.kt | 29 +++++++++++++++++++ .../app/ui/rust/RustFormatTest.kt | 15 ++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt index fdf2c63..354c093 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt @@ -161,7 +161,7 @@ fun describe(row: RustEventDto): FeedLine { */ private fun death(row: RustEventDto, name: String?): FeedLine { val where = listOfNotNull( - row.str("weapon")?.let { "with ${prefabName(it)}" }, + row.str("weapon")?.let { "with ${weaponName(it, row.str("weaponName"))}" }, row.num("distance")?.let { "${Math.round(it)}m" }, row.str("grid"), if (row.flag("sleeping")) "while sleeping" else null, diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt index bd6b8ae..8afe24f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt @@ -159,6 +159,23 @@ fun prefabName(name: String?): String { return name.replace(Regex("[_.]+"), " ").trim() } +/** The endings a held entity's prefab name carries that no player says. */ +private val WEAPON_ENDING = Regex("[._](entity|weapon|deployed)$") + +/** + * The weapon in a kill, the Kotlin half of `module-rust`'s `format.js weapon()`. + * The module sends `weaponName` (Module-Rust#60), which is the game's own name for a + * gun or the prefab name as words (`server/model/titles/weaponNames.js`), and the + * app prints that. A module too old to send it gets the same fallback here: the + * `.entity` / `.weapon` / `.deployed` ending is dropped, then [prefabName] turns the + * rest into words. So the app never prints "ak47u entity". + */ +fun weaponName(weapon: String?, weaponName: String?): String { + if (weapon.isNullOrBlank()) return "" + if (!weaponName.isNullOrBlank()) return weaponName + return prefabName(weapon.trim().replace(WEAPON_ENDING, "")) +} + /** * An Economics balance (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2.2): grouped, * at most two places, and a dash for one the server never reported — not "0", diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt index 35e79ed..be172c8 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt @@ -41,6 +41,35 @@ class RustFeedTest { assertTrue(line.detail.contains("42m")) } + @Test + fun `a kill prints the weapon name the module sends`() { + // Module-Rust#60: the module reads the game's own name off its table and + // sends it beside the prefab, which stays the counting key. + val line = describe( + row( + "player.death", + """{"name":"Bob","attackerType":"player","attackerName":"Alice", + "weapon":"ak47u.entity","weaponName":"Assault Rifle"}""", + ), + ) + + assertEquals("with Assault Rifle", line.detail) + } + + @Test + fun `an older module's kill drops the prefab ending rather than printing it`() { + // A module from before #60 sends no `weaponName`. The feed once said + // "with ak47u entity"; the ending is dropped the way the website does it. + val line = describe( + row( + "player.death", + """{"name":"Bob","attackerType":"player","attackerName":"Alice","weapon":"ak47u.entity"}""", + ), + ) + + assertEquals("with ak47u", line.detail) + } + @Test fun `a fall is a death by nobody, not a kill`() { // The failure this distinction exists to avoid: `HitInfo` is legitimately diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt index 06e8650..0f2dc9e 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt @@ -117,6 +117,21 @@ class RustFormatTest { assertEquals("", prefabName(null)) } + @Test + fun `a weapon prints the module's name, or the prefab without its ending`() { + assertEquals("Assault Rifle", weaponName("ak47u.entity", "Assault Rifle")) + // An older module sends no name; neither does one that sends a blank. + assertEquals("ak47u", weaponName("ak47u.entity", null)) + assertEquals("ak47u", weaponName("ak47u.entity", "")) + assertEquals("salvaged sword", weaponName("salvaged_sword.weapon", null)) + assertEquals("autoturret", weaponName("autoturret.deployed", null)) + assertEquals("rifle ak", weaponName("rifle.ak", null)) + // The ending is dropped only at the end. + assertEquals("entity thing", weaponName("entity.thing", null)) + // No weapon, no name, even when a name came along. + assertEquals("", weaponName(null, "Assault Rifle")) + } + @Test fun `a nameless player shows a shortened id, not the word unknown`() { // A steam id is not a name and does not look like one, which is the point: