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/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/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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7d4ef2..bd9cafd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,9 +4,14 @@ 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. --> - + Runic Gateway @@ -15,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. @@ -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,41 +104,9 @@ 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. + Sign in with your account on this site. Username Password Authentication code @@ -152,7 +114,7 @@ Sign in Create an account Forgot your password? - + Sign in with SSO Choose a sign-in provider @@ -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,140 +257,19 @@ 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 - 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 @@ -513,22 +284,15 @@ 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 - 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 + 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")) + } +} 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 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: