Make mod categories curated in Json (#9542)

* Dynamic mod categories from online query

* Move Working string to Constants

* Move Mod categories to json

* Move Mod categories to json - UI

* Move Mod categories to json - initial json
This commit is contained in:
SomeTroglodyte 2023-06-14 08:12:24 +02:00 committed by GitHub
parent b0a1eed872
commit 46a84c23b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 251 additions and 66 deletions

View File

@ -0,0 +1,50 @@
[
{
"label": "All mods",
"topic": "unciv-mod",
"createDate": "2020-08-26T11:35:39Z",
"modifyDate": "2023-06-08T05:39:47Z"
},
{
"label": "Rulesets",
"topic": "unciv-mod-rulesets",
"createDate": "2022-07-23T06:41:09Z",
"modifyDate": "2022-11-22T01:58:08Z"
},
{
"label": "Expansions",
"topic": "unciv-mod-expansions",
"createDate": "2022-07-23T03:55:05Z",
"modifyDate": "2023-02-25T19:44:45Z"
},
{
"label": "Graphics",
"topic": "unciv-mod-graphics",
"createDate": "2022-06-26T11:55:31Z",
"modifyDate": "2022-11-06T00:09:20Z"
},
{
"label": "Audio",
"topic": "unciv-mod-audio",
"createDate": "2022-06-26T11:55:15Z",
"modifyDate": "2023-02-19T11:55:56Z"
},
{
"label": "Maps",
"topic": "unciv-mod-maps",
"createDate": "2022-07-28T20:24:37Z",
"modifyDate": "2022-07-28T21:17:57Z"
},
{
"label": "Fun",
"topic": "unciv-mod-fun",
"createDate": "2022-07-24T08:15:47Z",
"modifyDate": "2023-04-07T16:32:11Z"
},
{
"label": "Mods of mods",
"topic": "unciv-mod-modsofmods",
"createDate": "2022-08-05T10:00:15Z",
"modifyDate": "2022-12-13T23:01:19Z"
}
]

View File

@ -794,6 +794,7 @@ Advanced =
Generate translation files =
Translation files are generated successfully. =
Fastlane files are generated successfully. =
Update Mod categories =
Screen orientation =
@ -1732,6 +1733,7 @@ Downloaded! =
[modName] Downloaded! =
Could not download [modName] =
Online query result is incomplete =
Sorting and filtering needs to wait until the online query finishes =
No description provided =
[stargazers]✯ =
Author: [author] =

View File

@ -70,6 +70,7 @@ object Constants {
const val yes = "Yes"
const val no = "No"
const val loading = "Loading..."
const val working = "Working..."
const val barbarians = "Barbarians"
const val spectator = "Spectator"

View File

@ -16,14 +16,13 @@ import java.time.Duration
* [Json] is not thread-safe. Use a new one for each parse.
*/
fun json() = Json(JsonWriter.OutputType.json).apply {
// Gdx default output type is JsonWriter.OutputType.minimal, which generates invalid Json - e.g. most quotes removed.
// The constructor parameter above changes that to valid Json
// Note an instance set to json can read minimal and vice versa
setIgnoreDeprecated(true)
ignoreUnknownFields = true
// Default output type is JsonWriter.OutputType.minimal, which generates invalid Json - e.g. most quotes removed.
// To get better Json, use:
// setOutputType(JsonWriter.OutputType.json)
// Note an instance set to json can read minimal and vice versa
setSerializer(HashMapVector2.getSerializerClass(), HashMapVector2.createSerializer())
setSerializer(Duration::class.java, DurationSerializer())
setSerializer(KeyCharAndCode::class.java, KeyCharAndCode.Serializer())

View File

@ -0,0 +1,85 @@
package com.unciv.models.metadata
import com.badlogic.gdx.Gdx
import com.unciv.json.json
import com.unciv.ui.screens.newgamescreen.TranslatedSelectBox
import com.unciv.ui.screens.pickerscreens.Github
class ModCategories : ArrayList<ModCategories.Category>() {
class Category(
val label: String,
val topic: String,
val hidden: Boolean,
/** copy of github created_at, no function except help evaluate */
@Suppress("unused")
val createDate: String,
/** copy of github updated_at, no function except help evaluate */
var modifyDate: String
) {
constructor() :
this("", "", false, "", "")
constructor(topic: Github.TopicSearchResponse.Topic) :
this(labelSuggestion(topic), topic.name, true, topic.created_at, topic.updated_at)
companion object {
val All = Category("All mods", "unciv-mod", false, "", "")
fun labelSuggestion(topic: Github.TopicSearchResponse.Topic) =
topic.display_name?.takeUnless { it.isBlank() }
?: topic.name.removePrefix("unciv-mod-").replaceFirstChar(Char::titlecase)
}
override fun equals(other: Any?) = this === other || other is Category && topic == other.topic
override fun hashCode() = topic.hashCode()
override fun toString() = label
}
companion object {
private const val fileLocation = "jsons/ModCategories.json"
private val INSTANCE: ModCategories
init {
val file = Gdx.files.internal(fileLocation)
INSTANCE = if (file.exists())
json().fromJson(ModCategories::class.java, Category::class.java, file)
else ModCategories().apply { add(default()) }
}
fun default() = Category.All
fun mergeOnline() = INSTANCE.mergeOnline()
fun fromSelectBox(selectBox: TranslatedSelectBox) = INSTANCE.fromSelectBox(selectBox)
fun asSequence() = INSTANCE.asSequence().filter { !it.hidden }
operator fun iterator() = asSequence().iterator()
}
private fun save() {
val json = json()
val compact = json.toJson(this, ModCategories::class.java, Category::class.java)
val verbose = json.prettyPrint(compact)
Gdx.files.local(fileLocation).writeString(verbose, false, "UTF-8")
}
fun fromSelectBox(selectBox: TranslatedSelectBox): Category {
val selected = selectBox.selected.value
return firstOrNull { it.label == selected } ?: Category.All
}
fun mergeOnline(): String {
val topics = Github.tryGetGithubTopics() ?: return "Failed"
var newCount = 0
for (topic in topics.items.sortedBy { it.name }) {
val existing = firstOrNull { it.topic == topic.name }
if (existing != null) {
existing.modifyDate = topic.updated_at
} else {
add(Category(topic))
newCount++
}
}
save()
return "$newCount new categories"
}
}

View File

@ -13,9 +13,11 @@ import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Array
import com.unciv.Constants
import com.unciv.GUI
import com.unciv.UncivGame
import com.unciv.models.metadata.GameSettings
import com.unciv.models.metadata.ModCategories
import com.unciv.models.metadata.ScreenSize
import com.unciv.models.translations.TranslationFileWriter
import com.unciv.models.translations.tr
@ -234,8 +236,8 @@ private fun addTranslationGeneration(table: Table, optionsPopup: OptionsPopup) {
val generateTranslationsButton = "Generate translation files".toTextButton()
generateTranslationsButton.onActivation {
optionsPopup.tabs.selectPage("Advanced")
generateTranslationsButton.setText("Working...".tr())
optionsPopup.tabs.selectPage("Advanced") // only because key F12 works from any page
generateTranslationsButton.setText(Constants.working.tr())
Concurrency.run("WriteTranslations") {
val result = TranslationFileWriter.writeNewTranslationFiles()
launchOnGLThread {
@ -250,12 +252,24 @@ private fun addTranslationGeneration(table: Table, optionsPopup: OptionsPopup) {
generateTranslationsButton.addTooltip("F12", 18f)
table.add(generateTranslationsButton).colspan(2).row()
val updateModCategoriesButton = "Update Mod categories".toTextButton()
updateModCategoriesButton.onActivation {
updateModCategoriesButton.setText(Constants.working.tr())
Concurrency.run("GithubTopicQuery") {
val result = ModCategories.mergeOnline()
launchOnGLThread {
updateModCategoriesButton.setText(result)
}
}
}
table.add(updateModCategoriesButton).colspan(2).row()
if (!UncivGame.Current.files.getSave("ScreenshotGenerationGame").exists()) return
val generateScreenshotsButton = "Generate screenshots".toTextButton()
generateScreenshotsButton.onActivation {
optionsPopup.tabs.selectPage("Advanced")
generateScreenshotsButton.setText("Working...".tr())
generateScreenshotsButton.setText(Constants.working.tr())
Concurrency.run("GenerateScreenshot") {
val extraImagesLocation = "../../extraImages"
// I'm not sure why we need to advance the y by 2 for every screenshot... but that's the only way it remains centered
@ -267,8 +281,8 @@ private fun addTranslationGeneration(table: Table, optionsPopup: OptionsPopup) {
))
}
}
// table.add(generateScreenshotsButton).colspan(2).row()
table.add(generateScreenshotsButton).colspan(2).row()
}
data class ScreenshotConfig(val width: Int, val height: Int, val screenSize: ScreenSize, var fileLocation:String, var centerTile:Vector2, var attackCity:Boolean=true)

View File

@ -6,6 +6,7 @@ import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.Stack
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.GUI
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
@ -289,7 +290,7 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize {
}
private fun quickstartNewGame() {
ToastPopup("Working...", this)
ToastPopup(Constants.working, this)
val errorText = "Cannot start game with the default new game parameters!"
Concurrency.run("QuickStart") {
val newGame: GameInfo

View File

@ -4,6 +4,7 @@ import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.ui.ButtonGroup
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.Constants
import com.unciv.logic.map.MapGeneratedMainType
import com.unciv.logic.map.MapParameters
import com.unciv.logic.map.MapType
@ -59,9 +60,9 @@ class MapEditorGenerateTab(
private fun setButtonsEnabled(enable: Boolean) {
newTab.generateButton.isEnabled = enable
newTab.generateButton.setText( (if(enable) "Create" else "Working...").tr())
newTab.generateButton.setText( (if(enable) "Create" else Constants.working).tr())
partialTab.generateButton.isEnabled = enable
partialTab.generateButton.setText( (if(enable) "Generate" else "Working...").tr())
partialTab.generateButton.setText( (if(enable) "Generate" else Constants.working).tr())
}
private fun generate(step: MapGeneratorSteps) {

View File

@ -4,6 +4,7 @@ import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.Constants
import com.unciv.logic.files.MapSaver
import com.unciv.logic.map.MapGeneratedMainType
import com.unciv.logic.map.TileMap
@ -81,7 +82,7 @@ class MapEditorSaveTab(
private fun setSaveButton(enabled: Boolean) {
saveButton.isEnabled = enabled
saveButton.setText((if (enabled) "Save map" else "Working...").tr())
saveButton.setText((if (enabled) "Save map" else Constants.working).tr())
}
private fun saveHandler() {

View File

@ -2,6 +2,7 @@ package com.unciv.ui.screens.multiplayerscreens
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.Constants
import com.unciv.logic.IdChecker
import com.unciv.models.translations.tr
import com.unciv.ui.screens.pickerscreens.PickerScreen
@ -53,7 +54,7 @@ class AddMultiplayerGameScreen : PickerScreen() {
}
val popup = Popup(this)
popup.addGoodSizedLabel("Working...")
popup.addGoodSizedLabel(Constants.working)
popup.open()
Concurrency.run("AddMultiplayerGame") {

View File

@ -2,6 +2,7 @@ package com.unciv.ui.screens.multiplayerscreens
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.TextButton.TextButtonStyle
import com.unciv.Constants
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.logic.multiplayer.storage.MultiplayerAuthException
import com.unciv.models.translations.tr
@ -103,7 +104,7 @@ class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame)
private fun resign(multiplayerGame: OnlineMultiplayerGame) {
//Create a popup
val popup = Popup(this)
popup.addGoodSizedLabel("Working...").row()
popup.addGoodSizedLabel(Constants.working).row()
popup.open()
Concurrency.runOnNonDaemonThreadPool("Resign") {

View File

@ -206,7 +206,7 @@ class NewGameScreen(
}
rightSideButton.disable()
rightSideButton.setText("Working...".tr())
rightSideButton.setText(Constants.working.tr())
setSkin()
// Creating a new game can take a while and we don't want ANRs
@ -280,7 +280,7 @@ class NewGameScreen(
private suspend fun startNewGame() = coroutineScope {
val popup = Popup(this@NewGameScreen)
launchOnGLThread {
popup.addGoodSizedLabel("Working...").row()
popup.addGoodSizedLabel(Constants.working).row()
popup.open()
}

View File

@ -395,6 +395,48 @@ object Github {
var avatar_url: String? = null
}
/**
* Query GitHub for topics named "unciv-mod*"
* @return Parsed [TopicSearchResponse] json on success, `null` on failure.
*/
fun tryGetGithubTopics(): TopicSearchResponse? {
// `+repositories:>1` means ignore unused or practically unused topics
val link = "https://api.github.com/search/topics?q=unciv-mod+repositories:%3E1&sort=name&order=asc"
var retries = 2
while (retries > 0) {
retries--
// obey rate limit
if (RateLimit.waitForLimit()) return null
// try download
val inputStream = download(link) {
if (it.responseCode == 403 || it.responseCode == 200 && retries == 1) {
// Pass the response headers to the rate limit handler so it can process the rate limit headers
RateLimit.notifyHttpResponse(it)
retries++ // An extra retry so the 403 is ignored in the retry count
}
} ?: continue
return json().fromJson(TopicSearchResponse::class.java, inputStream.bufferedReader().readText())
}
return null
}
/** Topic search response */
@Suppress("PropertyName")
class TopicSearchResponse {
// Commented out: Github returns them, but we're not interested
// var total_count = 0
// var incomplete_results = false
var items = ArrayList<Topic>()
class Topic {
var name = ""
var display_name: String? = null // Would need to be curated, which is alottawork
// var featured = false
// var curated = false
var created_at = "" // iso datetime with "Z" timezone
var updated_at = "" // iso datetime with "Z" timezone
}
}
/** Rewrite modOptions file for a mod we just installed to include metadata we got from the GitHub api
*
* (called on background thread)

View File

@ -7,6 +7,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.models.metadata.ModCategories
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.translations.tr
import com.unciv.ui.images.ImageGetter
@ -72,27 +73,6 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
}
}
enum class Category(
val label: String,
val topic: String
) {
All("All mods", "unciv-mod"),
Rulesets("Rulesets", "unciv-mod-rulesets"),
Expansions("Expansions", "unciv-mod-expansions"),
Graphics("Graphics", "unciv-mod-graphics"),
Audio("Audio", "unciv-mod-audio"),
Maps("Maps", "unciv-mod-maps"),
Fun("Fun", "unciv-mod-fun"),
ModsOfMods("Mods of mods", "unciv-mod-modsofmods");
companion object {
fun fromSelectBox(selectBox: TranslatedSelectBox): Category {
val selected = selectBox.selected.value
return values().firstOrNull { it.label == selected } ?: All
}
}
}
class Filter(
val text: String,
val topic: String
@ -104,7 +84,8 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
private val textField = UncivTextField.create("Enter search text")
var category = Category.All
var category = ModCategories.default()
var sortInstalled = SortType.Name
var sortOnline = SortType.Stars
@ -140,12 +121,12 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
}
categorySelect = TranslatedSelectBox(
Category.values().map { category -> category.label },
ModCategories.asSequence().map { it.label }.toList(),
category.label,
BaseScreen.skin
)
categorySelect.onChange {
category = Category.fromSelectBox(categorySelect)
category = ModCategories.fromSelectBox(categorySelect)
modManagementScreen.refreshInstalledModTable()
modManagementScreen.refreshOnlineModTable()
}
@ -210,9 +191,9 @@ class ModManagementOptions(private val modManagementScreen: ModManagementScreen)
}
private fun getTextButton(nameString: String, topics: List<String>): TextButton {
val categories = ArrayList<ModManagementOptions.Category>()
for (category in ModManagementOptions.Category.values()) {
if (category== ModManagementOptions.Category.All) continue
val categories = ArrayList<ModCategories.Category>()
for (category in ModCategories) {
if (category == ModCategories.default()) continue
if (topics.contains(category.topic)) categories += category
}
@ -225,8 +206,11 @@ private fun getTextButton(nameString:String, topics: List<String>): TextButton {
return button
}
/** Helper class holds combined mod info for ModManagementScreen, used for both installed and online lists */
class ModUIData(
/** Helper class holds combined mod info for ModManagementScreen, used for both installed and online lists
*
* Note it is guaranteed either ruleset or repo are non-null, never both.
*/
class ModUIData private constructor(
val name: String,
val description: String,
val ruleset: Ruleset?,
@ -270,12 +254,10 @@ class ModUIData(
}
private fun matchesCategory(filter: ModManagementOptions.Filter): Boolean {
val modTopic = repo?.topics ?: ruleset?.modOptions?.topics!!
if (filter.topic == ModManagementOptions.Category.All.topic)
if (filter.topic == ModCategories.default().topic)
return true
if (modTopic.size < 2) return false
if (modTopic[1] == filter.topic) return true
return false
val modTopics = repo?.topics ?: ruleset?.modOptions?.topics!!
return filter.topic in modTopics
}
}

View File

@ -718,7 +718,10 @@ class ModManagementScreen(
}
internal fun refreshOnlineModTable() {
if (runningSearchJob != null) return // cowardice: prevent concurrent modification, avoid a manager layer
if (runningSearchJob != null) {
ToastPopup("Sorting and filtering needs to wait until the online query finishes", this)
return // cowardice: prevent concurrent modification, avoid a manager layer
}
val newHeaderText = optionsManager.getOnlineHeader()
onlineHeaderLabel?.setText(newHeaderText)

View File

@ -141,7 +141,7 @@ class LoadGameScreen : LoadOrSaveScreen() {
private fun getLoadFromClipboardButton(): TextButton {
val pasteButton = loadFromClipboard.toTextButton()
pasteButton.onActivation {
pasteButton.setText("Working...".tr())
pasteButton.setText(Constants.working.tr())
pasteButton.disable()
Concurrency.run(loadFromClipboard) {
try {

View File

@ -192,7 +192,7 @@ class NextTurnButton : IconTextButton("", null, 30) {
class NextTurnAction(val text: String, val color: Color, val icon: String? = null, val action: () -> Unit) {
companion object Prefabs {
val Default = NextTurnAction("", Color.BLACK) {}
val Working = NextTurnAction("Working...", Color.GRAY, "NotificationIcons/Working") {}
val Working = NextTurnAction(Constants.working, Color.GRAY, "NotificationIcons/Working") {}
val Waiting = NextTurnAction("Waiting for other players...",Color.GRAY, "NotificationIcons/Waiting") {}
}
}

View File

@ -84,23 +84,25 @@ In order to do this, all you need to do is:
- Click the gear icon next to the About (top-right part of the page)
- In 'Topics', add "unciv-mod"
Optionally add one of the following topics (make sure this topic is added afer "unciv-mod"):
Optionally add one or more of the following topics to mark your mod as belonging to specific categories:
- unciv-mod-rulesets
- unciv-mod-expansions
- unciv-mod-graphics
- unciv-mod-audio
- unciv-mod-maps
- unciv-mod-fun
- unciv-mod-modsofmods
- unciv-mod-rulesets (for base ruleset mods)
- unciv-mod-expansions (for mods extending vanilla rulesets - please use this, **not** unciv-mod-expansion)
- unciv-mod-graphics (for mods altering graphics - icons, portraits, tilesets)
- unciv-mod-audio (for mods supplying music or modifying sounds)
- unciv-mod-maps (for mods containing maps)
- unciv-mod-fun (for mods mainly tweaking mechanics or other gameplay aspects)
- unciv-mod-modsofmods (for mods extending another mod's ruleset)
When you open your app, it will query Github's [list of repos with that topic](https://github.com/topics/unciv-mod), and now YOUR repo will appear there!
When you open Unciv's Mod Manager, it will query Github's [list of repos with that topic](https://github.com/topics/unciv-mod), and now YOUR repo will appear there!
The categories will appear als annotations on the mod buttons, and the user can filter for them. They are not required for the game to use the content - e.g. you can still load maps from mods lacking the unciv-mod-maps topic.
If you want new categories, github will accept any topic, but you'll have to ask the Unciv team to enable them in the game.
## I have the mod, now what?
The primary use of mods is to add them when starting a new game, or configuring a map. This will mean that both the ruleset of the mod, and the images, will be in use for that specific game/map.
For mods which are primarily visual or audio, there is a second use - through the mod manager, you can enable them as **permanent audiovisual mods**. This means that the images, sounds (or upcoming: music) from the mod will replace the original media everywhere in the game.
For mods which are primarily visual or audio, there is a second use - through the mod manager, you can enable them as **permanent audiovisual mods**. This means that the images and/or sounds from the mod will replace the original media everywhere in the game, and contained music will be available - [see here](Images-and-Audio.md#supply-additional-music).
## Mod location for manual loading of mods