diff --git a/api/AbstractListWindow.cs b/api/AbstractListWindow.cs index 10eb8fae..417cfeee 100644 --- a/api/AbstractListWindow.cs +++ b/api/AbstractListWindow.cs @@ -25,7 +25,7 @@ public abstract class AbstractListWindowItem : MonoBehaviour /// You should create a subclass of this class, and call CreateAndInit to create a window. /// In addition, you need to create a subclass of for setup each item of the list /// -/// +/// /// The type of the class which inherits this class /// The type of object passed into AbstractListWindowItem.Setup as parameter public abstract class AbstractListWindow : AbstractWindow diff --git a/resources/commit b/resources/commit index 630218e6..ae7ea583 100644 --- a/resources/commit +++ b/resources/commit @@ -1 +1 @@ -98377654d461586e413f2e9bc28ed3fa24514e27 +4773c590604d5af711ff15ddc21b18f092234163 \ No newline at end of file diff --git a/resources/locales/ch.json b/resources/locales/ch.json index 36457e54..9ef32552 100644 --- a/resources/locales/ch.json +++ b/resources/locales/ch.json @@ -1,8 +1,6 @@ { "nml_mods_list": "模組列表", "nml_mods_list_description": "這裡是你的模組列表。\n你可以在這裡啟用,禁用和配置模組。", - "NeoModList Title": "模組列表", - "WorkshopMods Title": "創意工坊已訂閱模組", "NeoModLoader Update Title": "NeoModLoader 更新", "NeoModLoader Update Complete": "NeoModLoader 更新至{0},\n 重啟遊戲以應用更新。", "ModUpload Title": "上傳模組", @@ -85,25 +83,141 @@ "SkipAuth Title": "跳過驗證", "SkipAuth Description": "跳過驗證\n將上傳至Unverified Mod\n鑒於目前其它驗證方式未完成\n該選項能夠直接上傳至Mod列表", "NeoModLoader Updated": "NeoModLoader 已更新, 重啟遊戲以應用更新", - "TypeMod Title": "模組", - "NMLMenu Title": "模組列表", - "TypeResource Title": "資源包", "ModConfigure Title": "模組設定", - "ModCommunity Title": "模組社群", - "OpenFolder Title": "打開模組資料夾", - "UploadMod Title": "上傳模組", - "ToggleMod Title": "啟用/禁用模組", - "ReloadMod Title": "重載模組\n非開發者慎用", - "ModReload Title": "重載模組\n非開發者慎用", "NeoModLoader Report": "點擊反饋Bug或建議", "mod_load_failed_title": "模組載入失敗", "mod_load_failed_description": "模組載入失敗, 請檢查模組是否完好且適配當前版本\n點擊禁用\n下面是錯誤信息", "toggle_mod_title": "啟用/禁用模組", - "mod_enabled_description": "當前狀態\n已啟用", - "mod_disabled_description": "當前狀態\n已禁用", - "mod_state_enabled": "當前已啟用", - "mod_state_disabled": "當前已禁用", - "mod_state_failed": "當前載入失敗", - "mod_next_state_enabled": "下次將啟用", - "mod_next_state_disabled": "下次將禁用" + "ModManager Title": "管理模組", + "ModManager Description": "啟用、禁用和設定已安裝的模組。", + "ModInfo Title": "模組資訊", + "NMLConfirm Title": "確定嗎?", + "mod_hub_confirm": "確定", + "mod_hub_cancel": "取消", + "mod_hub_filter": "顯示", + "mod_hub_filter_all": "全部模組", + "mod_hub_filter_enabled": "已啟用的模組", + "mod_hub_filter_disabled": "已禁用的模組", + "mod_hub_filter_problems": "載入失敗或缺少前置模組的模組", + "mod_hub_sort": "排序", + "mod_hub_sort_nameascending": "名稱,A 到 Z", + "mod_hub_sort_namedescending": "名稱,Z 到 A", + "mod_hub_sort_recentlychanged": "最近變更:最近安裝、更新或編輯的排在前面", + "mod_hub_search_mode": "搜尋依據", + "mod_hub_search_mode_name": "名稱", + "mod_hub_search_mode_author": "作者", + "mod_hub_clear_search": "清除搜尋", + "mod_hub_enable_all": "全部啟用", + "mod_hub_disable_all": "全部禁用", + "mod_hub_open_mods_folder": "模組資料夾", + "mod_hub_enable_all_confirm": "啟用 {0} 個已禁用的模組?\n\n它們將在下次啟動遊戲時編譯並載入。", + "mod_hub_disable_all_confirm": "禁用全部 {0} 個已啟用的模組?\n\n目前已載入的模組在重新啟動遊戲前仍會生效。", + "mod_hub_status_enabled": "已啟用", + "mod_hub_status_disabled": "已禁用", + "mod_hub_status_failed": "載入失敗", + "mod_hub_status_missingdependency": "缺少前置", + "mod_hub_status_restartrequired": "需要重新啟動", + "mod_hub_status_enabled_tip": "已載入並生效。", + "mod_hub_status_disabled_tip": "未載入。", + "mod_hub_status_failed_tip": "已啟用,但載入失敗。", + "mod_hub_status_missingdependency_tip": "已啟用,但缺少所需的模組:", + "mod_hub_status_restartrequired_tip": "你的變更或其檔案的更新將在重新啟動遊戲後生效。", + "mod_hub_source_workshop": "Steam 創意工坊", + "mod_hub_source_modsfolder": "Mods 資料夾", + "mod_hub_source_nativemodsfolder": "遊戲的 StreamingAssets/mods 資料夾", + "mod_hub_source_bepinex": "BepInEx 外掛", + "mod_hub_source_other": "其他資料夾", + "mod_hub_loaded_from": "載入自:{0}", + "mod_hub_info": "更多資訊", + "mod_hub_info_no_description": "此模組沒有描述。", + "mod_hub_workshop_page": "創意工坊頁面", + "mod_hub_open_folder": "開啟資料夾", + "mod_hub_configure": "設定", + "mod_hub_reload": "重新載入模組(開發者工具:重新編譯並修補程式碼)", + "mod_hub_no_match": "沒有符合篩選或搜尋條件的模組。", + "ModBrowser Title": "下載模組", + "ModBrowser Description": "瀏覽並下載 Steam 創意工坊中的模組。", + "mod_hub_click_to_enable": "點擊啟用:NML 會立即編譯並載入它。", + "mod_hub_click_to_disable": "點擊禁用:已載入的模組在重新啟動遊戲前仍保持載入。", + "mod_hub_status_updateavailable": "有更新", + "mod_hub_status_updateavailable_tip": "Steam 上有新版本。可在「下載模組」中用「全部更新」取得。", + "mod_hub_filter_updates": "Steam 上有更新的模組", + "mod_hub_retry": "重試", + "mod_hub_refresh": "從 Steam 重新載入清單", + "mod_hub_security_notice": "創意工坊模組是其他玩家編寫的程式。它們以你的權限執行,NeoModLoader 不會審查或隔離它們。\n\n下載會讓你的 Steam 帳戶訂閱該模組。新下載的模組會儲存為禁用狀態:在你信任它們後,可在「管理模組」中啟用。", + "mod_hub_steam_unavailable": "Steam 無法使用。請啟動 Steam,然後重新啟動遊戲以瀏覽創意工坊。", + "mod_hub_steam_offline": "Steam 處於離線狀態。「下載模組」需要連線到 Steam。", + "mod_hub_query_failed": "Steam 創意工坊沒有回應。請稍後再試。", + "mod_hub_loading": "正在載入模組... {0} / {1}", + "mod_hub_loading_stopped": "已停止載入:已載入 {0} 個模組", + "mod_hub_shown_count": "顯示 {0} / {1} 個模組", + "mod_hub_downloads_waiting": "剩餘下載:{0}", + "mod_hub_page_cap": "只載入了前 2000 個模組。", + "mod_hub_update_all": "全部更新", + "mod_hub_cancel_loading": "取消載入", + "mod_hub_all_up_to_date": "你下載的所有創意工坊模組都是最新的。", + "mod_hub_sort_recentlyupdated": "最近更新的排在前面", + "mod_hub_sort_mostsubscribed": "訂閱最多的排在前面", + "mod_hub_sort_trending": "熱門:本週最受歡迎", + "mod_hub_sort_toprated": "評分最高的排在前面", + "mod_hub_sort_newest": "最新發佈的排在前面", + "mod_hub_sort_name": "名稱,A 到 Z", + "mod_hub_updated_within": "更新時間", + "mod_hub_updated_within_anytime": "任何時間", + "mod_hub_updated_within_week": "最近一週", + "mod_hub_updated_within_month": "最近一個月", + "mod_hub_updated_within_threemonths": "最近三個月", + "mod_hub_updated_within_year": "最近一年", + "mod_hub_install": "顯示", + "mod_hub_install_all": "全部模組", + "mod_hub_install_notinstalled": "未下載", + "mod_hub_install_installed": "已下載", + "mod_hub_install_updateavailable": "有更新", + "mod_hub_category": "分類", + "mod_hub_category_any": "任意分類", + "mod_hub_ai": "AI 內容", + "mod_hub_ai_filter_any": "顯示全部模組", + "mod_hub_ai_filter_hideaimade": "隱藏標記為 AI-Made 的模組", + "mod_hub_ai_filter_hideai": "隱藏標記為 AI-Made 或 AI-Assisted 的模組", + "mod_hub_ai_filter_onlyhumanauthored": "僅顯示標記為 Human Authored 的模組(舊上傳沒有 AI 標籤)", + "mod_hub_browser_search_mode_name": "標題", + "mod_hub_browser_search_mode_author": "作者的 Steam 名稱(名稱在清單之後載入)", + "mod_hub_subscribers": "{0} 人訂閱", + "mod_hub_listing_queued": "排隊中", + "mod_hub_listing_downloading": "下載中 {0}%", + "mod_hub_listing_installed": "已下載", + "mod_hub_listing_updateavailable": "有更新", + "mod_hub_download": "下載", + "mod_hub_cancel_download": "取消下載", + "mod_hub_no_rating": "尚無評分", + "mod_hub_rating": "評分:{0}%({1} 票)", + "mod_hub_by_author": "作者:{0}", + "mod_hub_dates": "更新於 {0},建立於 {1}", + "mod_hub_categories": "分類:{0}", + "mod_hub_categories_none": "無", + "mod_hub_ai_content": "AI 內容:{0}", + "mod_hub_ai_notdeclared": "未聲明", + "mod_hub_ai_humanauthored": "Human Authored(人工製作)", + "mod_hub_ai_aiassisted": "AI-Assisted(AI 輔助)", + "mod_hub_ai_aimade": "AI-Made(AI 製作)", + "mod_hub_age_minute": "{0} 分鐘前", + "mod_hub_age_minutes": "{0} 分鐘前", + "mod_hub_age_hour": "{0} 小時前", + "mod_hub_age_hours": "{0} 小時前", + "mod_hub_age_day": "{0} 天前", + "mod_hub_age_days": "{0} 天前", + "mod_hub_age_month": "{0} 個月前", + "mod_hub_age_months": "{0} 個月前", + "mod_hub_age_year": "{0} 年前", + "mod_hub_age_years": "{0} 年前", + "mod_hub_download_error_subscribe": "Steam 拒絕了訂閱。", + "mod_hub_download_error_no_progress": "Steam 沒有任何進度。是否處於離線狀態?", + "mod_hub_download_error_unexpected": "失敗。詳情請查看日誌。", + "mod_hub_download_error_not_mod": "不是 NeoModLoader 模組(沒有 mod.json)。", + "mod_hub_unsubscribe": "取消訂閱", + "mod_hub_unsubscribe_confirm": "取消訂閱 {0}?\n\nSteam 會刪除它的檔案。重新啟動遊戲後它會從此清單中消失。", + "mod_hub_unsubscribe_failed": "Steam 拒絕取消訂閱。你可以在該模組的創意工坊頁面取消訂閱。", + "mod_hub_delete": "刪除模組", + "mod_hub_delete_confirm": "刪除 {0}?\n\n它的資料夾會從 Mods 資料夾中刪除。此操作無法復原。", + "mod_hub_delete_failed": "無法刪除該模組。請關閉正在使用其檔案的程式後重試。" } \ No newline at end of file diff --git a/resources/locales/cz.json b/resources/locales/cz.json index e1b2f8b2..626b45dc 100644 --- a/resources/locales/cz.json +++ b/resources/locales/cz.json @@ -1,8 +1,6 @@ { "nml_mods_list": "模组列表", "nml_mods_list_description": "这里是你的模组列表。\n你可以在这里启用,禁用和配置模组。", - "NeoModList Title": "模组列表", - "WorkshopMods Title": "创意工坊模组列表", "NeoModLoader Update Title": "NeoModLoader 更新", "NeoModLoader Update Complete": "NeoModLoader 更新至{0},\n 重启游戏以应用更新。", "ModUpload Title": "上传模组", @@ -85,25 +83,141 @@ "SkipAuth Title": "跳过验证", "SkipAuth Description": "跳过验证\n将上传至Unverified Mod", "NeoModLoader Updated": "NeoModLoader 已更新, 重启游戏以应用更新", - "TypeMod Title": "模组", - "NMLMenu Title": "模组列表", - "TypeResource Title": "资源包", "ModConfigure Title": "模组设置", - "ModCommunity Title": "模组社区", - "OpenFolder Title": "打开模组文件夹", - "UploadMod Title": "上传模组", - "ToggleMod Title": "启用/禁用模组", - "ReloadMod Title": "重载模组\n非开发者慎用", - "ModReload Title": "重载模组\n非开发者慎用", "NeoModLoader Report": "点击反馈Bug或建议", "mod_load_failed_title": "模组加载失败", "mod_load_failed_description": "模组加载失败, 请检查模组是否完好且适配当前版本\n点击禁用\n下面是错误信息", "toggle_mod_title": "启用/禁用模组", - "mod_enabled_description": "当前状态\n已启用", - "mod_disabled_description": "当前状态\n已禁用", - "mod_state_enabled": "当前已启用", - "mod_state_disabled": "当前已禁用", - "mod_state_failed": "当前加载失败", - "mod_next_state_enabled": "下次将启用", - "mod_next_state_disabled": "下次将禁用" + "ModManager Title": "管理模组", + "ModManager Description": "启用、禁用和设置已安装的模组。", + "ModInfo Title": "模组信息", + "NMLConfirm Title": "确定吗?", + "mod_hub_confirm": "确定", + "mod_hub_cancel": "取消", + "mod_hub_filter": "显示", + "mod_hub_filter_all": "全部模组", + "mod_hub_filter_enabled": "已启用的模组", + "mod_hub_filter_disabled": "已禁用的模组", + "mod_hub_filter_problems": "加载失败或缺少前置模组的模组", + "mod_hub_sort": "排序", + "mod_hub_sort_nameascending": "名称,A 到 Z", + "mod_hub_sort_namedescending": "名称,Z 到 A", + "mod_hub_sort_recentlychanged": "最近更改:最近安装、更新或编辑的排在前面", + "mod_hub_search_mode": "搜索依据", + "mod_hub_search_mode_name": "名称", + "mod_hub_search_mode_author": "作者", + "mod_hub_clear_search": "清除搜索", + "mod_hub_enable_all": "全部启用", + "mod_hub_disable_all": "全部禁用", + "mod_hub_open_mods_folder": "模组文件夹", + "mod_hub_enable_all_confirm": "启用 {0} 个已禁用的模组?\n\n它们将在下次启动游戏时编译并加载。", + "mod_hub_disable_all_confirm": "禁用全部 {0} 个已启用的模组?\n\n当前已加载的模组在重启游戏前仍会生效。", + "mod_hub_status_enabled": "已启用", + "mod_hub_status_disabled": "已禁用", + "mod_hub_status_failed": "加载失败", + "mod_hub_status_missingdependency": "缺少前置", + "mod_hub_status_restartrequired": "需要重启", + "mod_hub_status_enabled_tip": "已加载并生效。", + "mod_hub_status_disabled_tip": "未加载。", + "mod_hub_status_failed_tip": "已启用,但加载失败。", + "mod_hub_status_missingdependency_tip": "已启用,但缺少所需的模组:", + "mod_hub_status_restartrequired_tip": "你的更改或其文件的更新将在重启游戏后生效。", + "mod_hub_source_workshop": "Steam 创意工坊", + "mod_hub_source_modsfolder": "Mods 文件夹", + "mod_hub_source_nativemodsfolder": "游戏的 StreamingAssets/mods 文件夹", + "mod_hub_source_bepinex": "BepInEx 插件", + "mod_hub_source_other": "其他文件夹", + "mod_hub_loaded_from": "加载自:{0}", + "mod_hub_info": "更多信息", + "mod_hub_info_no_description": "此模组没有描述。", + "mod_hub_workshop_page": "创意工坊页面", + "mod_hub_open_folder": "打开文件夹", + "mod_hub_configure": "设置", + "mod_hub_reload": "重载模组(开发者工具:重新编译并修补代码)", + "mod_hub_no_match": "没有符合筛选或搜索条件的模组。", + "ModBrowser Title": "下载模组", + "ModBrowser Description": "浏览并下载 Steam 创意工坊中的模组。", + "mod_hub_click_to_enable": "点击启用:NML 会立即编译并加载它。", + "mod_hub_click_to_disable": "点击禁用:已加载的模组在重启游戏前仍保持加载。", + "mod_hub_status_updateavailable": "有更新", + "mod_hub_status_updateavailable_tip": "Steam 上有新版本。可在“下载模组”中用“全部更新”获取。", + "mod_hub_filter_updates": "Steam 上有更新的模组", + "mod_hub_retry": "重试", + "mod_hub_refresh": "从 Steam 重新加载列表", + "mod_hub_security_notice": "创意工坊模组是其他玩家编写的程序。它们以你的权限运行,NeoModLoader 不会审查或隔离它们。\n\n下载会让你的 Steam 账户订阅该模组。新下载的模组会保存为禁用状态:在你信任它们后,可在“管理模组”中启用。", + "mod_hub_steam_unavailable": "Steam 不可用。请启动 Steam,然后重启游戏以浏览创意工坊。", + "mod_hub_steam_offline": "Steam 处于离线状态。“下载模组”需要连接 Steam。", + "mod_hub_query_failed": "Steam 创意工坊没有响应。请稍后重试。", + "mod_hub_loading": "正在加载模组... {0} / {1}", + "mod_hub_loading_stopped": "已停止加载:已加载 {0} 个模组", + "mod_hub_shown_count": "显示 {0} / {1} 个模组", + "mod_hub_downloads_waiting": "剩余下载:{0}", + "mod_hub_page_cap": "只加载了前 2000 个模组。", + "mod_hub_update_all": "全部更新", + "mod_hub_cancel_loading": "取消加载", + "mod_hub_all_up_to_date": "你下载的所有创意工坊模组都是最新的。", + "mod_hub_sort_recentlyupdated": "最近更新的排在前面", + "mod_hub_sort_mostsubscribed": "订阅最多的排在前面", + "mod_hub_sort_trending": "热门:本周最受欢迎", + "mod_hub_sort_toprated": "评分最高的排在前面", + "mod_hub_sort_newest": "最新发布的排在前面", + "mod_hub_sort_name": "名称,A 到 Z", + "mod_hub_updated_within": "更新时间", + "mod_hub_updated_within_anytime": "任何时间", + "mod_hub_updated_within_week": "最近一周", + "mod_hub_updated_within_month": "最近一个月", + "mod_hub_updated_within_threemonths": "最近三个月", + "mod_hub_updated_within_year": "最近一年", + "mod_hub_install": "显示", + "mod_hub_install_all": "全部模组", + "mod_hub_install_notinstalled": "未下载", + "mod_hub_install_installed": "已下载", + "mod_hub_install_updateavailable": "有更新", + "mod_hub_category": "分类", + "mod_hub_category_any": "任意分类", + "mod_hub_ai": "AI 内容", + "mod_hub_ai_filter_any": "显示全部模组", + "mod_hub_ai_filter_hideaimade": "隐藏标记为 AI-Made 的模组", + "mod_hub_ai_filter_hideai": "隐藏标记为 AI-Made 或 AI-Assisted 的模组", + "mod_hub_ai_filter_onlyhumanauthored": "仅显示标记为 Human Authored 的模组(旧上传没有 AI 标签)", + "mod_hub_browser_search_mode_name": "标题", + "mod_hub_browser_search_mode_author": "作者的 Steam 名称(名称在列表之后加载)", + "mod_hub_subscribers": "{0} 人订阅", + "mod_hub_listing_queued": "排队中", + "mod_hub_listing_downloading": "下载中 {0}%", + "mod_hub_listing_installed": "已下载", + "mod_hub_listing_updateavailable": "有更新", + "mod_hub_download": "下载", + "mod_hub_cancel_download": "取消下载", + "mod_hub_no_rating": "暂无评分", + "mod_hub_rating": "评分:{0}%({1} 票)", + "mod_hub_by_author": "作者:{0}", + "mod_hub_dates": "更新于 {0},创建于 {1}", + "mod_hub_categories": "分类:{0}", + "mod_hub_categories_none": "无", + "mod_hub_ai_content": "AI 内容:{0}", + "mod_hub_ai_notdeclared": "未声明", + "mod_hub_ai_humanauthored": "Human Authored(人工制作)", + "mod_hub_ai_aiassisted": "AI-Assisted(AI 辅助)", + "mod_hub_ai_aimade": "AI-Made(AI 制作)", + "mod_hub_age_minute": "{0} 分钟前", + "mod_hub_age_minutes": "{0} 分钟前", + "mod_hub_age_hour": "{0} 小时前", + "mod_hub_age_hours": "{0} 小时前", + "mod_hub_age_day": "{0} 天前", + "mod_hub_age_days": "{0} 天前", + "mod_hub_age_month": "{0} 个月前", + "mod_hub_age_months": "{0} 个月前", + "mod_hub_age_year": "{0} 年前", + "mod_hub_age_years": "{0} 年前", + "mod_hub_download_error_subscribe": "Steam 拒绝了订阅。", + "mod_hub_download_error_no_progress": "Steam 没有任何进度。是否处于离线状态?", + "mod_hub_download_error_unexpected": "失败。详情请查看日志。", + "mod_hub_download_error_not_mod": "不是 NeoModLoader 模组(没有 mod.json)。", + "mod_hub_unsubscribe": "取消订阅", + "mod_hub_unsubscribe_confirm": "取消订阅 {0}?\n\nSteam 会删除它的文件。重启游戏后它会从此列表中消失。", + "mod_hub_unsubscribe_failed": "Steam 拒绝取消订阅。你可以在该模组的创意工坊页面取消订阅。", + "mod_hub_delete": "删除模组", + "mod_hub_delete_confirm": "删除 {0}?\n\n它的文件夹会从 Mods 文件夹中删除。此操作无法撤销。", + "mod_hub_delete_failed": "无法删除该模组。请关闭正在使用其文件的程序后重试。" } \ No newline at end of file diff --git a/resources/locales/en.json b/resources/locales/en.json index 3df9c00d..3f01ce5b 100644 --- a/resources/locales/en.json +++ b/resources/locales/en.json @@ -1,8 +1,6 @@ { "nml_mods_list": "Mods", "nml_mods_list_description": "Here are your mods.\nYou can enable, disable or configure your mods.", - "NeoModList Title": "Mods", - "WorkshopMods Title": "Workshop Subscribed Mods", "NeoModLoader Update Title": "NeoModLoader Update", "NeoModLoader Update Complete": "NeoModLoader has been updated to version {0}.\nPlease restart the game to apply the update.", "ModUpload Title": "Upload Mod", @@ -85,25 +83,141 @@ "SkipAuth Title": "Skip Authentication", "SkipAuth Description": "Skip authentication\nUpload mod to 'Unverified Mod'", "NeoModLoader Updated": "NeoModLoader Updated, please restart the game.", - "TypeMod Title": "Mod", - "NMLMenu Title": "Mods", - "TypeResource Title": "Resource Pack", "ModConfigure Title": "Mod Configure", - "ModCommunity Title": "Mod Community", - "OpenFolder Title": "Open Mod Folder", - "UploadMod Title": "Upload Mod", - "ToggleMod Title": "Toggle Mod", - "ReloadMod Title": "Reload Mod\nDO NOT USE IT IF YOU ARE NOT DEVELOPER", - "ModReload Title": "Reload Mod\nDO NOT USE IT IF YOU ARE NOT DEVELOPER", "NeoModLoader Report": "Click to report bug or suggest new feature", "mod_load_failed_title": "Failed to load mod", "mod_load_failed_description": "Failed to load mod\nClick to disable it\nPlease check the mod is valid", "toggle_mod_title": "Toggle Mod", - "mod_enabled_description": "Current State\nEnabled", - "mod_disabled_description": "Current State\nDisabled", - "mod_state_enabled": "Current Enabled", - "mod_state_disabled": "Current Disabled", - "mod_state_failed": "Current Failed", - "mod_next_state_enabled": "will load", - "mod_next_state_disabled": "won't load" + "ModManager Title": "Manage Mods", + "ModManager Description": "Enable, disable and configure your installed mods.", + "ModInfo Title": "Mod Info", + "NMLConfirm Title": "Are you sure?", + "mod_hub_confirm": "Confirm", + "mod_hub_cancel": "Cancel", + "mod_hub_filter": "Show", + "mod_hub_filter_all": "All mods", + "mod_hub_filter_enabled": "Enabled mods", + "mod_hub_filter_disabled": "Disabled mods", + "mod_hub_filter_problems": "Mods that failed to load or miss a required mod", + "mod_hub_sort": "Sort", + "mod_hub_sort_nameascending": "Name, A to Z", + "mod_hub_sort_namedescending": "Name, Z to A", + "mod_hub_sort_recentlychanged": "Recently changed: last installed, updated or edited first", + "mod_hub_search_mode": "Search by", + "mod_hub_search_mode_name": "Name", + "mod_hub_search_mode_author": "Author", + "mod_hub_clear_search": "Clear search", + "mod_hub_enable_all": "Enable all", + "mod_hub_disable_all": "Disable all", + "mod_hub_open_mods_folder": "Mods folder", + "mod_hub_enable_all_confirm": "Enable {0} disabled mods?\n\nThey will be compiled and loaded the next time you start the game.", + "mod_hub_disable_all_confirm": "Disable all {0} enabled mods?\n\nMods that are loaded now stay active until you restart the game.", + "mod_hub_status_enabled": "Enabled", + "mod_hub_status_disabled": "Disabled", + "mod_hub_status_failed": "Failed", + "mod_hub_status_missingdependency": "Missing dependency", + "mod_hub_status_restartrequired": "Restart required", + "mod_hub_status_enabled_tip": "Loaded and active.", + "mod_hub_status_disabled_tip": "Not loaded.", + "mod_hub_status_failed_tip": "Enabled, but it failed to load.", + "mod_hub_status_missingdependency_tip": "Enabled, but a mod it requires is not installed:", + "mod_hub_status_restartrequired_tip": "Your change, or an update of its files, applies when you restart the game.", + "mod_hub_source_workshop": "Steam Workshop", + "mod_hub_source_modsfolder": "Mods folder", + "mod_hub_source_nativemodsfolder": "The game's StreamingAssets/mods folder", + "mod_hub_source_bepinex": "BepInEx plugin", + "mod_hub_source_other": "Other folder", + "mod_hub_loaded_from": "Loaded from: {0}", + "mod_hub_info": "More info", + "mod_hub_info_no_description": "This mod has no description.", + "mod_hub_workshop_page": "Workshop page", + "mod_hub_open_folder": "Open folder", + "mod_hub_configure": "Configure", + "mod_hub_reload": "Reload mod (developer tool: recompiles and patches its code)", + "mod_hub_no_match": "No mods match the filter or search.", + "ModBrowser Title": "Download Mods", + "ModBrowser Description": "Browse and download mods from the Steam Workshop.", + "mod_hub_click_to_enable": "Click to enable it. NML compiles and loads it right away.", + "mod_hub_click_to_disable": "Click to disable it. A loaded mod stays loaded until you restart the game.", + "mod_hub_status_updateavailable": "Update available", + "mod_hub_status_updateavailable_tip": "Steam has a newer version. Get it with Update all in Download Mods.", + "mod_hub_filter_updates": "Mods with an update on Steam", + "mod_hub_retry": "Retry", + "mod_hub_refresh": "Reload the list from Steam", + "mod_hub_security_notice": "Workshop mods are programs made by other players. They run with your permissions, and NeoModLoader does not review or sandbox them.\n\nDownloading subscribes your Steam account to the mod. New downloads are saved as disabled: enable them in Manage Mods when you trust them.", + "mod_hub_steam_unavailable": "Steam is not available. Start Steam, then restart the game to browse the Workshop.", + "mod_hub_steam_offline": "Steam is offline. Download Mods needs a connection to Steam.", + "mod_hub_query_failed": "The Steam Workshop did not answer. Try again in a moment.", + "mod_hub_loading": "Loading mods... {0} of {1}", + "mod_hub_loading_stopped": "Loading stopped: {0} mods loaded", + "mod_hub_shown_count": "{0} of {1} mods shown", + "mod_hub_downloads_waiting": "Downloads left: {0}", + "mod_hub_page_cap": "Only the first 2,000 mods were loaded.", + "mod_hub_update_all": "Update all", + "mod_hub_cancel_loading": "Cancel loading", + "mod_hub_all_up_to_date": "All your downloaded Workshop mods are up to date.", + "mod_hub_sort_recentlyupdated": "Recently updated first", + "mod_hub_sort_mostsubscribed": "Most subscribed first", + "mod_hub_sort_trending": "Trending: most popular this week", + "mod_hub_sort_toprated": "Top rated first", + "mod_hub_sort_newest": "Newest first", + "mod_hub_sort_name": "Name, A to Z", + "mod_hub_updated_within": "Updated within", + "mod_hub_updated_within_anytime": "Any time", + "mod_hub_updated_within_week": "The last week", + "mod_hub_updated_within_month": "The last month", + "mod_hub_updated_within_threemonths": "The last 3 months", + "mod_hub_updated_within_year": "The last year", + "mod_hub_install": "Show", + "mod_hub_install_all": "All mods", + "mod_hub_install_notinstalled": "Not downloaded", + "mod_hub_install_installed": "Downloaded", + "mod_hub_install_updateavailable": "Update available", + "mod_hub_category": "Categories", + "mod_hub_category_any": "Any category", + "mod_hub_ai": "AI content", + "mod_hub_ai_filter_any": "Show all mods", + "mod_hub_ai_filter_hideaimade": "Hide mods tagged AI-Made", + "mod_hub_ai_filter_hideai": "Hide mods tagged AI-Made or AI-Assisted", + "mod_hub_ai_filter_onlyhumanauthored": "Only mods tagged Human Authored (older uploads have no AI tag)", + "mod_hub_browser_search_mode_name": "Title", + "mod_hub_browser_search_mode_author": "Author's Steam name (names load after the list)", + "mod_hub_subscribers": "{0} subscribers", + "mod_hub_listing_queued": "Queued", + "mod_hub_listing_downloading": "Downloading {0}%", + "mod_hub_listing_installed": "Downloaded", + "mod_hub_listing_updateavailable": "Update available", + "mod_hub_download": "Download", + "mod_hub_cancel_download": "Cancel download", + "mod_hub_no_rating": "No rating yet", + "mod_hub_rating": "Rating: {0}% of {1} votes", + "mod_hub_by_author": "By: {0}", + "mod_hub_dates": "Updated {0}, created {1}", + "mod_hub_categories": "Categories: {0}", + "mod_hub_categories_none": "none", + "mod_hub_ai_content": "AI content: {0}", + "mod_hub_ai_notdeclared": "not declared", + "mod_hub_ai_humanauthored": "Human Authored", + "mod_hub_ai_aiassisted": "AI-Assisted", + "mod_hub_ai_aimade": "AI-Made", + "mod_hub_age_minute": "{0} minute ago", + "mod_hub_age_minutes": "{0} minutes ago", + "mod_hub_age_hour": "{0} hour ago", + "mod_hub_age_hours": "{0} hours ago", + "mod_hub_age_day": "{0} day ago", + "mod_hub_age_days": "{0} days ago", + "mod_hub_age_month": "{0} month ago", + "mod_hub_age_months": "{0} months ago", + "mod_hub_age_year": "{0} year ago", + "mod_hub_age_years": "{0} years ago", + "mod_hub_download_error_subscribe": "Steam refused the subscription.", + "mod_hub_download_error_no_progress": "Steam made no progress. Is it offline?", + "mod_hub_download_error_unexpected": "Failed. See the log for details.", + "mod_hub_download_error_not_mod": "Not a NeoModLoader mod (no mod.json).", + "mod_hub_unsubscribe": "Unsubscribe", + "mod_hub_unsubscribe_confirm": "Unsubscribe from {0}?\n\nSteam deletes its files. It leaves this list when you restart the game.", + "mod_hub_unsubscribe_failed": "Steam refused to unsubscribe. You can unsubscribe on the mod's Workshop page.", + "mod_hub_delete": "Delete mod", + "mod_hub_delete_confirm": "Delete {0}?\n\nIts folder is removed from the Mods folder. This cannot be undone.", + "mod_hub_delete_failed": "The mod could not be deleted. Close any program using its files and try again." } \ No newline at end of file diff --git a/services/IPlatformSpecificModWorkshopService.cs b/services/IPlatformSpecificModWorkshopService.cs index 54186f00..a58cb16a 100644 --- a/services/IPlatformSpecificModWorkshopService.cs +++ b/services/IPlatformSpecificModWorkshopService.cs @@ -3,7 +3,7 @@ namespace NeoModLoader.services; -internal interface IPlatformSpecificModWorkshopService +internal interface IPlatformSpecificModWorkshopService : IWorkshopItemActions { void UploadModLoader(string changelog); @@ -13,4 +13,26 @@ Promise UploadMod(string name, string description, string previewImagePath, stri Promise EditMod(ulong fileID, string previewImagePath, string workshopPath, string changelog, ModDeclare mod_decl); void FindSubscribedMods(); ModDeclare GetNextModFromWorkshopItem(); + + /// + /// Whether Steam is running and connected. + /// + bool IsSteamOnline(); + + /// + /// One page (1-based, up to 50 items) of NML mods, or null when Steam returns nothing. + /// + /// Page number, starting at 1 + /// Order by recent popularity instead of publication date + Task QueryModsAsync(int pPage, bool pRankedByTrend); + + /// + /// Steam display name of a user, or null when Steam does not answer in time. + /// + Task GetPersonaNameAsync(ulong pSteamId); + + /// + /// Workshop description of an item as its author wrote it, with BBCode, or null when Steam returns nothing. + /// + Task GetDescriptionAsync(ulong pFileId); } \ No newline at end of file diff --git a/services/InstalledMod.cs b/services/InstalledMod.cs new file mode 100644 index 00000000..112435a2 --- /dev/null +++ b/services/InstalledMod.cs @@ -0,0 +1,139 @@ +using NeoModLoader.api; + +namespace NeoModLoader.services; + +/// +/// State of an installed mod as shown in Manage Mods. +/// +internal enum InstalledModStatus +{ + Enabled, + Disabled, + Failed, + MissingDependency, + + /// + /// Steam has a newer version of this Workshop mod. + /// + UpdateAvailable, + + /// + /// What is loaded now differs from the saved choice or the files on disk, until the next start. + /// + RestartRequired +} + +/// +/// Where a mod was found. +/// +internal enum ModSource +{ + Workshop, + ModsFolder, + NativeModsFolder, + BepInEx, + Other +} + +/// +/// Snapshot of one recognized mod, taken when Manage Mods refreshes. +/// +internal sealed class InstalledMod +{ + public ModDeclare Declaration { get; set; } + + /// + /// Localized display name. + /// + public string Name { get; set; } = ""; + + /// + /// Localized display author. + /// + public string Author { get; set; } = ""; + + public InstalledModStatus Status { get; set; } + + /// + /// The user's saved choice, which can differ from what is loaded until the next start. + /// + public bool Enabled { get; set; } + + public ModSource Source { get; set; } + + /// + /// When the mod's folder or mod.json was last written on this computer (installed, updated or edited). + /// + public DateTime LastChanged { get; set; } + + public bool HasProblem => Status is InstalledModStatus.Failed or InstalledModStatus.MissingDependency; + + /// NML's load state of the mod in this session + /// Whether the mod is saved as disabled + /// Whether a required dependency is not installed + /// Whether its files changed in a way only a restart applies + /// Whether Steam has a newer version + public static InstalledModStatus ResolveStatus(ModState pState, bool pDisabled, bool pMissingDependency, + bool pNeedsRestart, bool pUpdateAvailable) + { + if (pNeedsRestart) return InstalledModStatus.RestartRequired; + if (pState == ModState.LOADED) + { + // NML cannot unload a mod, so disabling a loaded one takes effect at the next start. + if (pDisabled) return InstalledModStatus.RestartRequired; + return pUpdateAvailable ? InstalledModStatus.UpdateAvailable : InstalledModStatus.Enabled; + } + + if (pDisabled) return pUpdateAvailable ? InstalledModStatus.UpdateAvailable : InstalledModStatus.Disabled; + + // Saved as enabled but still DISABLED in this session: it was enabled for the next start (Enable All). + if (pState == ModState.DISABLED) return InstalledModStatus.RestartRequired; + + return pMissingDependency ? InstalledModStatus.MissingDependency : InstalledModStatus.Failed; + } + + /// Declared mod type + /// Whether the mod was found in Steam's Workshop content folder + /// The mod's folder + /// The game's Mods folder + /// The game's StreamingAssets/mods folder + public static ModSource DetectSource(ModTypeEnum pModType, bool pIsWorkshopLoaded, string pFolderPath, + string pModsPath, string pNativeModsPath) + { + if (pModType == ModTypeEnum.BEPINEX) return ModSource.BepInEx; + if (pIsWorkshopLoaded) return ModSource.Workshop; + if (IsInside(pFolderPath, pModsPath)) return ModSource.ModsFolder; + if (IsInside(pFolderPath, pNativeModsPath)) return ModSource.NativeModsFolder; + return ModSource.Other; + } + + /// + /// Read the Workshop item id from a folder under "workshop/content/<game id>/<item id>". + /// + public static bool TryGetWorkshopId(string pFolderPath, ulong pGameId, out ulong pFileId) + { + pFileId = 0; + if (string.IsNullOrEmpty(pFolderPath)) return false; + + string[] segments = pFolderPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string game_id = pGameId.ToString(); + for (int i = 2; i < segments.Length - 1; i++) + { + if (segments[i] == game_id && segments[i - 1] == "content" && segments[i - 2] == "workshop") + { + return ulong.TryParse(segments[i + 1], out pFileId); + } + } + + return false; + } + + private static bool IsInside(string pPath, string pFolder) + { + if (string.IsNullOrEmpty(pPath) || string.IsNullOrEmpty(pFolder)) return false; + + string folder = Path.GetFullPath(pFolder).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return Path.GetFullPath(pPath).StartsWith(folder, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/services/InstalledModQuery.cs b/services/InstalledModQuery.cs new file mode 100644 index 00000000..70051955 --- /dev/null +++ b/services/InstalledModQuery.cs @@ -0,0 +1,91 @@ +namespace NeoModLoader.services; + +/// +/// Which mods Manage Mods shows. +/// +internal enum InstalledModFilter +{ + All, + Enabled, + Disabled, + + /// + /// Failed to load or missing a dependency. + /// + Problems, + Updates +} + +internal enum InstalledModSort +{ + NameAscending, + NameDescending, + + /// + /// Newest first. + /// + RecentlyChanged +} + +/// +/// What the search text is matched against. +/// +internal enum ModSearchMode +{ + Name, + Author +} + +/// +/// Filter, search and order of the Manage Mods list. +/// +internal sealed class InstalledModQuery +{ + public InstalledModFilter Filter { get; set; } = InstalledModFilter.All; + public InstalledModSort Sort { get; set; } = InstalledModSort.NameAscending; + public ModSearchMode SearchMode { get; set; } = ModSearchMode.Name; + + /// + /// Case-insensitive part of a name or author. Empty matches everything. + /// + public string SearchText { get; set; } = ""; + + public List Apply(IEnumerable pMods) + { + IEnumerable matches = pMods.Where(mod => MatchesFilter(mod) && MatchesSearch(mod)); + return Order(matches).ToList(); + } + + private bool MatchesFilter(InstalledMod pMod) + { + return Filter switch + { + InstalledModFilter.Enabled => pMod.Enabled, + InstalledModFilter.Disabled => !pMod.Enabled, + InstalledModFilter.Problems => pMod.HasProblem, + InstalledModFilter.Updates => pMod.Status == InstalledModStatus.UpdateAvailable, + _ => true + }; + } + + private bool MatchesSearch(InstalledMod pMod) + { + string search_text = SearchText?.Trim(); + if (string.IsNullOrEmpty(search_text)) return true; + + string field = SearchMode == ModSearchMode.Author ? pMod.Author : pMod.Name; + return field != null && field.IndexOf(search_text, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private IEnumerable Order(IEnumerable pMods) + { + return Sort switch + { + InstalledModSort.NameDescending => + pMods.OrderByDescending(mod => mod.Name, StringComparer.OrdinalIgnoreCase), + InstalledModSort.RecentlyChanged => pMods.OrderByDescending(mod => mod.LastChanged) + .ThenBy(mod => mod.Name, StringComparer.OrdinalIgnoreCase), + _ => pMods.OrderBy(mod => mod.Name, StringComparer.OrdinalIgnoreCase) + }; + } +} diff --git a/services/ModDownloadService.cs b/services/ModDownloadService.cs new file mode 100644 index 00000000..6fce3749 --- /dev/null +++ b/services/ModDownloadService.cs @@ -0,0 +1,191 @@ +using NeoModLoader.api; +using NeoModLoader.constants; +using NeoModLoader.utils; +using System.Text.RegularExpressions; +using RSG; + +namespace NeoModLoader.services; + +/// +/// Connects the Download Mods window to the Steam Workshop: the list of mods, the download queue, author names, +/// and what happens when a download finishes. +/// +/// +/// Nothing here compiles or loads a mod. A new download is saved as disabled; the player enables it in Manage +/// Mods through the existing enable path. +/// +internal static class ModDownloadService +{ + public const string ErrorNotAMod = "mod_hub_download_error_not_mod"; + + // Mods whose files changed on disk this session in a way only a restart applies. + private static readonly HashSet restart_pending = new(); + private static readonly Dictionary descriptions = new(); + private static WorkshopCatalog catalog; + private static WorkshopDownloadQueue queue; + private static WorkshopAuthorNames authors; + + public static WorkshopCatalog Catalog => catalog ??= + new WorkshopCatalog((page, ranked_by_trend) => Steam.QueryModsAsync(page, ranked_by_trend)); + + public static WorkshopDownloadQueue Queue => queue ??= CreateQueue(); + + public static WorkshopAuthorNames Authors => authors ??= + new WorkshopAuthorNames(steam_id => Steam.GetPersonaNameAsync(steam_id)); + + private static IPlatformSpecificModWorkshopService Steam => ModWorkshopService.Backend; + + /// + /// Whether the Workshop can be used now: , or why not. + /// + public static CatalogStatus CheckSteam() + { + Promise steam_initialized = ModWorkshopService.steamWorkshopPromise; + if (Others.is_editor || steam_initialized?.CurState != PromiseState.Resolved || Steam == null) + { + return CatalogStatus.SteamUnavailable; + } + + return Steam.IsSteamOnline() ? CatalogStatus.Ready : CatalogStatus.Offline; + } + + /// + /// Download a mod, or update it when it is installed already. + /// + public static void Download(ModListing pListing) + { + WorkshopItemState state = Steam.GetItemState(pListing.Id); + if (!state.Subscribed || !state.Installed) SetPending(pListing.Id, true); + Queue.Enqueue(pListing); + } + + public static ListingStatus GetStatus(ModListing pListing) + { + return ModListingQuery.ResolveStatus(Queue.Find(pListing.Id), Steam.GetItemState(pListing.Id)); + } + + /// + /// Queue every listed mod that Steam has an update for. Returns how many were queued. + /// + public static int UpdateAll() + { + List outdated = Catalog.Items.Where(listing => GetStatus(listing) == ListingStatus.UpdateAvailable) + .ToList(); + foreach (ModListing listing in outdated) Queue.Enqueue(listing); + return outdated.Count; + } + + /// + /// Whether Steam has a newer version of an installed Workshop mod. + /// + public static bool HasUpdate(ModDeclare pMod) + { + return pMod.IsWorkshopLoaded && CheckSteam() == CatalogStatus.Ready && + InstalledMod.TryGetWorkshopId(pMod.FolderPath, CoreConstants.GameId, out ulong file_id) && + Steam.GetItemState(file_id).NeedsUpdate; + } + + /// + /// Whether only a restart applies a change to the mod's files: it was updated while loaded (NML cannot + /// reload it) or unsubscribed (Steam deletes its files). + /// + public static bool NeedsRestart(string pModUid) + { + return restart_pending.Contains(pModUid); + } + + /// + /// Unsubscribe from an installed Workshop mod, so Steam deletes it. Returns false when Steam refused. + /// + public static async Task UnsubscribeAsync(ModDeclare pMod) + { + if (!InstalledMod.TryGetWorkshopId(pMod.FolderPath, CoreConstants.GameId, out ulong file_id)) return false; + + bool unsubscribed = await Steam.UnsubscribeAsync(file_id); + if (unsubscribed) restart_pending.Add(pMod.UID); + else LogService.LogWarning($"Steam refused to unsubscribe Workshop item {file_id} ({pMod.Name})"); + return unsubscribed; + } + + /// + /// Workshop description of an item as plain text, or null when Steam is unavailable. Kept for the game + /// session. + /// + public static async Task GetDescriptionAsync(ulong pFileId) + { + if (descriptions.TryGetValue(pFileId, out string cached)) return cached; + if (CheckSteam() != CatalogStatus.Ready) return null; + + string text; + try + { + text = await Steam.GetDescriptionAsync(pFileId); + } + catch (Exception e) + { + LogService.LogWarning($"Failed to get the Workshop description of {pFileId}: {e.Message}"); + return null; + } + + if (text == null) return null; + return descriptions[pFileId] = StripBBCode(text); + } + + /// + /// Plain text from Steam's BBCode: list items become dashes, images are dropped and other tags removed. + /// + internal static string StripBBCode(string pText) + { + string text = Regex.Replace(pText, @"\[img\].*?\[/img\]", "", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + text = text.Replace("[*]", "- "); + text = Regex.Replace(text, @"\[/?[a-z0-9*]+(=[^\]]*)?\]", "", RegexOptions.IgnoreCase); + return Regex.Replace(text, @"(\r?\n){3,}", "\n\n"); + } + + private static WorkshopDownloadQueue CreateQueue() + { + var download_queue = new WorkshopDownloadQueue(Steam, Install, Task.Delay, () => DateTime.UtcNow); + download_queue.Changed += entry => + { + if (entry.State is DownloadState.Cancelled or DownloadState.Failed) SetPending(entry.Listing.Id, false); + }; + return download_queue; + } + + private static void SetPending(ulong pFileId, bool pPending) + { + List pending = ModHubSettings.Instance.PendingDownloads; + bool changed = pPending ? !pending.Contains(pFileId) : pending.Contains(pFileId); + if (!changed) return; + + if (pPending) pending.Add(pFileId); + else pending.Remove(pFileId); + ModHubSettings.Instance.Save(); + } + + /// + /// Called by the queue once Steam has installed an item. Returns the locale key of an error, or null. + /// + private static string Install(string pDirectory) + { + if (ulong.TryParse(Path.GetFileName(pDirectory), out ulong file_id)) SetPending(file_id, false); + ModDeclare mod = ModInfoUtils.recogMod(pDirectory, false); + if (mod == null) return ErrorNotAMod; + + if (ModInfoUtils.TryGetRecognizedMod(mod.UID, out _)) + { + if (ModCompileLoadService.IsModLoaded(mod.UID)) restart_pending.Add(mod.UID); + return null; + } + + // New to this computer: saved as disabled, so neither this session nor the next start loads it. + ModInfoUtils.setModDisabled(mod.UID, true); + + // The startup scan only lists NeoModLoader-type Workshop mods; do the same here. + if (mod.ModType != ModTypeEnum.NEOMOD) return null; + if (string.IsNullOrEmpty(mod.RepoUrl)) mod.SetRepoUrlToWorkshopPage(Path.GetFileName(pDirectory)); + ModInfoUtils.EnsureRecognizedMod(mod); + return null; + } +} diff --git a/services/ModHubSettings.cs b/services/ModHubSettings.cs new file mode 100644 index 00000000..96da9420 --- /dev/null +++ b/services/ModHubSettings.cs @@ -0,0 +1,116 @@ +using NeoModLoader.constants; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace NeoModLoader.services; + +/// +/// Mod Hub preferences, stored in NML/mod_hub_settings.json. +/// +/// +/// Search text is deliberately not stored, so an old search never narrows the list after a restart. +/// Kept out of mod_compile_records.json, which maps mod UIDs to compile records. +/// +internal sealed class ModHubSettings +{ + private static readonly string default_path = Path.Combine(Paths.NMLPath, "mod_hub_settings.json"); + private static ModHubSettings instance; + private List browser_categories = new(); + private List pending_downloads = new(); + + [JsonProperty("manager_filter")] + [JsonConverter(typeof(StringEnumConverter))] + public InstalledModFilter ManagerFilter { get; set; } = InstalledModFilter.All; + + [JsonProperty("manager_sort")] + [JsonConverter(typeof(StringEnumConverter))] + public InstalledModSort ManagerSort { get; set; } = InstalledModSort.NameAscending; + + [JsonProperty("manager_search_mode")] + [JsonConverter(typeof(StringEnumConverter))] + public ModSearchMode ManagerSearchMode { get; set; } = ModSearchMode.Name; + + [JsonProperty("browser_sort")] + [JsonConverter(typeof(StringEnumConverter))] + public ListingSort BrowserSort { get; set; } = ListingSort.RecentlyUpdated; + + [JsonProperty("browser_updated_within")] + [JsonConverter(typeof(StringEnumConverter))] + public UpdatedWithin BrowserUpdatedWithin { get; set; } = UpdatedWithin.Anytime; + + [JsonProperty("browser_install")] + [JsonConverter(typeof(StringEnumConverter))] + public InstallFilter BrowserInstall { get; set; } = InstallFilter.All; + + [JsonProperty("browser_ai_content")] + [JsonConverter(typeof(StringEnumConverter))] + public AIContentFilter BrowserAIContent { get; set; } = AIContentFilter.Any; + + [JsonProperty("browser_categories")] + public List BrowserCategories + { + get => browser_categories ??= new List(); + set => browser_categories = value; + } + + [JsonProperty("browser_search_mode")] + [JsonConverter(typeof(StringEnumConverter))] + public ModSearchMode BrowserSearchMode { get; set; } = ModSearchMode.Name; + + /// + /// The player confirmed the notice that Workshop mods are unreviewed code. + /// + [JsonProperty("security_notice_accepted")] + public bool SecurityNoticeAccepted { get; set; } + + /// + /// Workshop items Download Mods subscribed to that Steam has not installed yet. If Steam finishes one while + /// the game is closed, the startup scan saves it as disabled, as Download Mods would have. + /// + [JsonProperty("pending_downloads")] + public List PendingDownloads + { + get => pending_downloads ??= new List(); + set => pending_downloads = value; + } + + /// + /// Settings of this game session, read from disk on first use. + /// + public static ModHubSettings Instance => instance ??= Load(default_path); + + /// + /// Read settings, falling back to defaults (with a warning) when the file is invalid. + /// + public static ModHubSettings Load(string pPath) + { + if (!File.Exists(pPath)) return new ModHubSettings(); + try + { + return JsonConvert.DeserializeObject(File.ReadAllText(pPath)) ?? + throw new JsonException("The file is empty."); + } + catch (Exception e) + { + LogService.LogWarning($"Ignoring invalid Mod Hub settings at {pPath}: {e.Message}"); + return new ModHubSettings(); + } + } + + public void Save() + { + Save(default_path); + } + + public void Save(string pPath) + { + try + { + File.WriteAllText(pPath, JsonConvert.SerializeObject(this, Formatting.Indented)); + } + catch (Exception e) + { + LogService.LogError($"Failed to save Mod Hub settings to {pPath}: {e.Message}"); + } + } +} diff --git a/services/ModListing.cs b/services/ModListing.cs new file mode 100644 index 00000000..f6ef8d1e --- /dev/null +++ b/services/ModListing.cs @@ -0,0 +1,105 @@ +using NeoModLoader.constants; + +namespace NeoModLoader.services; + +/// +/// AI content as declared by the uploader through NML's upload tags. +/// +internal enum ModAIContent +{ + /// + /// Uploaded before NML added AI tags, or by another tool. + /// + NotDeclared, + HumanAuthored, + AIAssisted, + AIMade +} + +/// +/// A mod offered for download, independent of where it is hosted. Built from Steam Workshop items today. +/// +internal sealed class ModListing +{ + // Tags written by ModDeclare.GetAIAttributionDisplay() at upload, lower-cased by Facepunch. + private const string ai_made_tag = "ai-made"; + private const string ai_assisted_tag = "ai-assisted"; + private const string human_authored_tag = "human authored"; + + public ulong Id { get; set; } + public string Title { get; set; } = ""; + public ulong AuthorId { get; set; } + + /// + /// Lower-case tags. + /// + public string[] Tags { get; set; } = Array.Empty(); + + public string PreviewUrl { get; set; } + public ulong Subscriptions { get; set; } + public uint VotesUp { get; set; } + public uint VotesDown { get; set; } + + /// + /// Rating from 0 to 1. + /// + public float Score { get; set; } + + public DateTime Created { get; set; } + public DateTime Updated { get; set; } + + public ModAIContent AIContent + { + get + { + if (HasTag(ai_made_tag)) return ModAIContent.AIMade; + if (HasTag(ai_assisted_tag)) return ModAIContent.AIAssisted; + return HasTag(human_authored_tag) ? ModAIContent.HumanAuthored : ModAIContent.NotDeclared; + } + } + + /// + /// The categories from this mod is tagged with. + /// + public IEnumerable Categories => + CoreConstants.ModCategories.Where(category => HasTag(category)); + + public bool HasTag(string pTag) + { + return Tags.Any(tag => string.Equals(tag, pTag, StringComparison.OrdinalIgnoreCase)); + } +} + +/// +/// One page of listings and how many listings match in total. +/// +internal sealed class ModListingPage +{ + public int TotalCount { get; set; } + public List Items { get; set; } = new(); +} + +/// +/// What Steam has on this computer for one Workshop item, read live on every call. +/// +internal struct WorkshopItemState +{ + public bool Subscribed; + public bool Installed; + public bool NeedsUpdate; + public bool Downloading; + public bool DownloadPending; + + /// + /// 0 to 1 while . + /// + public float Progress; + + /// + /// Install folder, null unless installed. + /// + public string Directory; + + public bool IsUpToDate => Installed && !NeedsUpdate && !Downloading && !DownloadPending; + public bool IsBusy => Downloading || DownloadPending; +} diff --git a/services/ModListingQuery.cs b/services/ModListingQuery.cs new file mode 100644 index 00000000..c998dd89 --- /dev/null +++ b/services/ModListingQuery.cs @@ -0,0 +1,192 @@ +namespace NeoModLoader.services; + +internal enum ListingSort +{ + RecentlyUpdated, + MostSubscribed, + + /// + /// Steam's trending order. Items Steam does not rank follow, by subscribers. + /// + Trending, + TopRated, + Newest, + Name +} + +internal enum UpdatedWithin +{ + Anytime, + Week, + Month, + ThreeMonths, + Year +} + +internal enum InstallFilter +{ + All, + NotInstalled, + Installed, + UpdateAvailable +} + +internal enum AIContentFilter +{ + Any, + HideAIMade, + + /// + /// Hide AI-made and AI-assisted mods. + /// + HideAI, + + /// + /// Only mods tagged Human Authored. Mods uploaded before NML added AI tags have no such tag. + /// + OnlyHumanAuthored +} + +/// +/// What a listing means for this computer, shown on its row. +/// +internal enum ListingStatus +{ + NotInstalled, + Queued, + Downloading, + Installed, + UpdateAvailable, + Failed +} + +/// +/// Filter, search and order of the Download Mods list. +/// +internal sealed class ModListingQuery +{ + public ListingSort Sort { get; set; } = ListingSort.RecentlyUpdated; + public UpdatedWithin UpdatedWithin { get; set; } = UpdatedWithin.Anytime; + public InstallFilter Install { get; set; } = InstallFilter.All; + public AIContentFilter AIContent { get; set; } = AIContentFilter.Any; + + /// + /// Categories from CoreConstants.ModCategories. A listing matches when it has any of them; empty matches all. + /// + public HashSet Categories { get; } = new(StringComparer.OrdinalIgnoreCase); + + public ModSearchMode SearchMode { get; set; } = ModSearchMode.Name; + + /// + /// Case-insensitive part of the title, or of the author's Steam name. Empty matches everything. + /// + public string SearchText { get; set; } = ""; + + /// All listings + /// Current time, for + /// Install state of a listing + /// Steam name of an author, or null when not known yet + /// Position of each listing in Steam's trending order + public List Apply(IEnumerable pListings, DateTime pNow, + Func pStatus, Func pAuthorName, + IReadOnlyDictionary pTrendingRank) + { + IEnumerable matches = pListings.Where(listing => + IsRecentEnough(listing, pNow) && MatchesInstall(pStatus(listing)) && MatchesAIContent(listing) && + MatchesCategories(listing) && MatchesSearch(listing, pAuthorName)); + return Order(matches, pTrendingRank).ToList(); + } + + /// + /// Status of a listing from its download entry (if any) and what Steam has on disk. + /// + public static ListingStatus ResolveStatus(DownloadEntry pEntry, WorkshopItemState pState) + { + if (pEntry != null) + { + switch (pEntry.State) + { + case DownloadState.Queued: + return ListingStatus.Queued; + case DownloadState.Downloading: + case DownloadState.Installing: + return ListingStatus.Downloading; + case DownloadState.Failed: + return ListingStatus.Failed; + } + } + + // Steam keeps an unsubscribed item installed (and may finish its download) until the game closes. + if (!pState.Subscribed) return ListingStatus.NotInstalled; + if (pState.Installed) return pState.NeedsUpdate ? ListingStatus.UpdateAvailable : ListingStatus.Installed; + return pState.IsBusy ? ListingStatus.Downloading : ListingStatus.NotInstalled; + } + + private bool IsRecentEnough(ModListing pListing, DateTime pNow) + { + int days = UpdatedWithin switch + { + UpdatedWithin.Week => 7, + UpdatedWithin.Month => 30, + UpdatedWithin.ThreeMonths => 90, + UpdatedWithin.Year => 365, + _ => 0 + }; + return days == 0 || pNow - pListing.Updated <= TimeSpan.FromDays(days); + } + + private bool MatchesInstall(ListingStatus pStatus) + { + bool installed = pStatus is ListingStatus.Installed or ListingStatus.UpdateAvailable; + return Install switch + { + InstallFilter.NotInstalled => !installed, + InstallFilter.Installed => installed, + InstallFilter.UpdateAvailable => pStatus == ListingStatus.UpdateAvailable, + _ => true + }; + } + + private bool MatchesAIContent(ModListing pListing) + { + ModAIContent content = pListing.AIContent; + return AIContent switch + { + AIContentFilter.HideAIMade => content != ModAIContent.AIMade, + AIContentFilter.HideAI => content is not (ModAIContent.AIMade or ModAIContent.AIAssisted), + AIContentFilter.OnlyHumanAuthored => content == ModAIContent.HumanAuthored, + _ => true + }; + } + + private bool MatchesCategories(ModListing pListing) + { + return Categories.Count == 0 || Categories.Any(pListing.HasTag); + } + + private bool MatchesSearch(ModListing pListing, Func pAuthorName) + { + string search_text = SearchText?.Trim(); + if (string.IsNullOrEmpty(search_text)) return true; + + string field = SearchMode == ModSearchMode.Author ? pAuthorName(pListing.AuthorId) : pListing.Title; + return field != null && field.IndexOf(search_text, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private IEnumerable Order(IEnumerable pListings, + IReadOnlyDictionary pTrendingRank) + { + return Sort switch + { + ListingSort.MostSubscribed => pListings.OrderByDescending(listing => listing.Subscriptions), + ListingSort.Trending => pListings + .OrderBy(listing => pTrendingRank.TryGetValue(listing.Id, out int rank) ? rank : int.MaxValue) + .ThenByDescending(listing => listing.Subscriptions), + ListingSort.TopRated => pListings.OrderByDescending(listing => listing.Score) + .ThenByDescending(listing => listing.VotesUp), + ListingSort.Newest => pListings.OrderByDescending(listing => listing.Created), + ListingSort.Name => pListings.OrderBy(listing => listing.Title, StringComparer.OrdinalIgnoreCase), + _ => pListings.OrderByDescending(listing => listing.Updated) + }; + } +} diff --git a/services/ModManagerService.cs b/services/ModManagerService.cs new file mode 100644 index 00000000..95a6d5dd --- /dev/null +++ b/services/ModManagerService.cs @@ -0,0 +1,224 @@ +using NeoModLoader.api; +using NeoModLoader.constants; +using NeoModLoader.utils; +using UnityEngine; + +namespace NeoModLoader.services; + +/// +/// Whether a dependency of a mod is available. +/// +internal enum DependencyState +{ + Loaded, + + /// + /// Recognized by NML but not loaded in this session. + /// + Installed, + Missing +} + +/// +/// Reads NML's recognized mods for Manage Mods and applies the user's choices through the existing +/// enable and disable paths. +/// +internal static class ModManagerService +{ + public static List GetInstalledMods() + { + return WorldBoxMod.AllRecognizedMods.Select(pair => Describe(pair.Key, pair.Value)).ToList(); + } + + public static InstalledMod Describe(ModDeclare pMod) + { + return Describe(pMod, WorldBoxMod.AllRecognizedMods[pMod]); + } + + private static InstalledMod Describe(ModDeclare pMod, ModState pState) + { + bool disabled = ModInfoUtils.isModDisabled(pMod.UID); + bool missing_dependency = pMod.Dependencies.Any(uid => GetDependencyState(uid) == DependencyState.Missing); + return new InstalledMod + { + Declaration = pMod, + Name = pMod.GetDisplayName() ?? "", + Author = pMod.GetDisplayAuthor() ?? "", + Enabled = !disabled, + Status = InstalledMod.ResolveStatus(pState, disabled, missing_dependency, + ModDownloadService.NeedsRestart(pMod.UID), ModDownloadService.HasUpdate(pMod)), + Source = InstalledMod.DetectSource(pMod.ModType, pMod.IsWorkshopLoaded, pMod.FolderPath, Paths.ModsPath, + Paths.NativeModsPath), + LastChanged = GetLastChanged(pMod.FolderPath) + }; + } + + public static DependencyState GetDependencyState(string pModUid) + { + if (!ModInfoUtils.TryGetRecognizedMod(pModUid, out ModDeclare dependency)) return DependencyState.Missing; + return WorldBoxMod.AllRecognizedMods[dependency] == ModState.LOADED + ? DependencyState.Loaded + : DependencyState.Installed; + } + + public static Sprite GetIcon(ModDeclare pMod) + { + if (!string.IsNullOrEmpty(pMod.IconPath)) + { + string icon_path = Path.Combine(pMod.FolderPath, pMod.IconPath); + Sprite icon = File.Exists(icon_path) ? SpriteLoadUtils.LoadSingleSprite(icon_path) : null; + if (icon != null) return icon; + } + + return InternalResourcesGetter.GetIcon(); + } + + /// + /// Enable a disabled mod (compiled and loaded now, as before) or disable an enabled one (a loaded mod stays + /// loaded until the next start). + /// + public static void Toggle(ModDeclare pMod) + { + if (ModInfoUtils.isModDisabled(pMod.UID)) + { + ModCompileLoadService.TryEnableMod(pMod); + } + else + { + ModCompileLoadService.DisableMod(pMod); + } + } + + public static int CountDisabled() + { + return WorldBoxMod.AllRecognizedMods.Keys.Count(mod => ModInfoUtils.isModDisabled(mod.UID)); + } + + public static int CountEnabled() + { + return WorldBoxMod.AllRecognizedMods.Keys.Count(mod => !ModInfoUtils.isModDisabled(mod.UID)); + } + + /// + /// Save every disabled mod as enabled. Nothing is compiled or loaded now; they load at the next start. + /// + public static void EnableAll() + { + foreach (ModDeclare mod in WorldBoxMod.AllRecognizedMods.Keys.ToList()) + { + if (ModInfoUtils.isModDisabled(mod.UID)) ModInfoUtils.setModDisabled(mod.UID, false, false); + } + + ModInfoUtils.SaveModRecords(); + } + + /// + /// Save every mod as disabled. Loaded mods stay loaded until the next start. + /// + public static void DisableAll() + { + foreach (ModDeclare mod in WorldBoxMod.AllRecognizedMods.Keys.ToList()) + { + if (!ModInfoUtils.isModDisabled(mod.UID)) ModDepenSolveService.SetModDesiredEnabled(mod, false, false); + } + + ModInfoUtils.SaveModRecords(); + } + + /// + /// The config of a loaded mod, or null when it has none. Every is + /// , so a config without any item counts as none. + /// + public static ModConfig GetConfig(ModDeclare pMod) + { + if (!WorldBoxMod.TryGetLoadedMod(pMod, out IMod mod) || mod is not IConfigurable configurable) return null; + + ModConfig config = configurable.GetConfig(); + return config != null && config._config.Values.Any(group => group.Count > 0) ? config : null; + } + + /// + /// Hot reload is a developer tool: offered only in the game's developer mode, as before. + /// + public static bool CanReload(ModDeclare pMod) + { + return Config.isEditor && ModReloadService.CanReload(pMod); + } + + /// + /// Whether Manage Mods offers to delete a mod: it is not loaded, and either sits in the Mods folder or + /// comes from the Steam Workshop. BepInEx plugins and mods in other folders are left alone. + /// + public static bool CanDelete(InstalledMod pMod) + { + if (pMod.Status is not (InstalledModStatus.Disabled or InstalledModStatus.Failed)) return false; + return pMod.Source switch + { + ModSource.ModsFolder => IsModsFolderEntry(pMod.Declaration.FolderPath), + ModSource.Workshop => ModDownloadService.CheckSteam() == CatalogStatus.Ready, + _ => false + }; + } + + /// + /// Delete a mod's folder from the Mods folder, or unsubscribe from a Workshop mod so Steam deletes it. + /// + /// False when the files could not be deleted or Steam refused + public static async Task DeleteAsync(InstalledMod pMod) + { + ModDeclare declaration = pMod.Declaration; + if (pMod.Source == ModSource.Workshop) return await ModDownloadService.UnsubscribeAsync(declaration); + if (!IsModsFolderEntry(declaration.FolderPath)) return false; + + try + { + Directory.Delete(declaration.FolderPath, true); + } + catch (Exception e) + { + LogService.LogWarning($"Failed to delete mod {declaration.Name} at {declaration.FolderPath}: {e.Message}"); + return false; + } + + WorldBoxMod.AllRecognizedMods.Remove(declaration); + return true; + } + + /// + /// Only a folder directly inside the Mods folder is ever deleted. + /// + private static bool IsModsFolderEntry(string pFolderPath) + { + if (string.IsNullOrEmpty(pFolderPath) || !Directory.Exists(pFolderPath)) return false; + char[] separators = { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + string parent = Path.GetDirectoryName(Path.GetFullPath(pFolderPath).TrimEnd(separators)); + return string.Equals(parent, Path.GetFullPath(Paths.ModsPath).TrimEnd(separators), + StringComparison.OrdinalIgnoreCase); + } + + public static void OpenFolder(string pFolderPath) + { + if (!Directory.Exists(pFolderPath)) + { + LogService.LogWarning($"Cannot open missing folder {pFolderPath}"); + return; + } + + Application.OpenURL(pFolderPath); + } + + private static DateTime GetLastChanged(string pFolderPath) + { + try + { + DateTime folder = Directory.GetLastWriteTimeUtc(pFolderPath); + DateTime mod_json = File.GetLastWriteTimeUtc(Path.Combine(pFolderPath, Paths.ModDeclarationFileName)); + return folder > mod_json ? folder : mod_json; + } + catch (Exception e) + { + LogService.LogWarning($"Cannot read the last write time of {pFolderPath}: {e.Message}"); + return DateTime.MinValue; + } + } +} diff --git a/services/ModWorkshopService.cs b/services/ModWorkshopService.cs index 1ee3be0f..6800a4ff 100644 --- a/services/ModWorkshopService.cs +++ b/services/ModWorkshopService.cs @@ -18,6 +18,11 @@ internal static class ModWorkshopService internal static Promise steamWorkshopPromise; private static IPlatformSpecificModWorkshopService workshopServiceBackend; + /// + /// The platform backend, used to browse and download mods. + /// + internal static IPlatformSpecificModWorkshopService Backend => workshopServiceBackend; + public static void Init() { steamWorkshopPromise = RF.GetStaticField("steamInitialized"); diff --git a/services/ModWorkshopServiceUnix.cs b/services/ModWorkshopServiceUnix.cs index 3ed21c32..a8f7fbc6 100644 --- a/services/ModWorkshopServiceUnix.cs +++ b/services/ModWorkshopServiceUnix.cs @@ -9,6 +9,7 @@ using UnityEngine; using unixsteamwork::Steamworks; using unixsteamwork::Steamworks.Ugc; +using WorkshopItem = unixsteamwork::Steamworks.Ugc.Item; namespace NeoModLoader.services; @@ -292,4 +293,92 @@ bool available(unixsteamwork::Steamworks.Ugc.Item item) return subscribedItems; } + + public bool IsSteamOnline() + { + return SteamClient.IsValid && SteamClient.IsLoggedOn; + } + + public async Task QueryModsAsync(int pPage, bool pRankedByTrend) + { + // Query is a struct: every builder method returns a changed copy. + Query query = Query.ItemsReadyToUse.WithTag(WorkshopTags.Mod).WithTag(WorkshopTags.UnverifiedMod) + .MatchAnyTag().WithoutTag(WorkshopTags.ModLoader); + query = pRankedByTrend ? query.RankedByTrend().WithTrendDays(7) : query.RankedByPublicationDate(); + + ResultPage? result = await query.GetPageAsync(pPage); + if (!result.HasValue) return null; + + // The page holds a Steam query handle until it is disposed. + using ResultPage page = result.Value; + var listings = new ModListingPage { TotalCount = page.TotalCount }; + foreach (WorkshopItem item in page.Entries) + { + listings.Items.Add(new ModListing + { + Id = item.Id.Value, + Title = item.Title ?? "", + AuthorId = item.Owner.Id.Value, + Tags = item.Tags ?? Array.Empty(), + PreviewUrl = item.PreviewImageUrl, + Subscriptions = item.NumSubscriptions, + VotesUp = item.VotesUp, + VotesDown = item.VotesDown, + Score = item.Score, + Created = item.Created, + Updated = item.Updated + }); + } + + return listings; + } + + public WorkshopItemState GetItemState(ulong pFileId) + { + var item = new WorkshopItem(pFileId); + bool installed = item.IsInstalled; + bool downloading = item.IsDownloading; + return new WorkshopItemState + { + Subscribed = item.IsSubscribed, + Installed = installed, + NeedsUpdate = item.NeedsUpdate, + Downloading = downloading, + DownloadPending = item.IsDownloadPending, + // DownloadBytesDownloaded reads 0 during a first download; DownloadAmount asks Steam directly. + Progress = downloading ? item.DownloadAmount : 0, + Directory = installed ? item.Directory : null + }; + } + + public Task SubscribeAsync(ulong pFileId) + { + return new WorkshopItem(pFileId).Subscribe(); + } + + public Task UnsubscribeAsync(ulong pFileId) + { + return new WorkshopItem(pFileId).Unsubscribe(); + } + + public bool StartDownload(ulong pFileId) + { + return SteamUGC.Download(pFileId, true); + } + + public async Task GetPersonaNameAsync(ulong pSteamId) + { + var friend = new Friend(pSteamId); + Task request = friend.RequestInfoAsync(); + if (await Task.WhenAny(request, Task.Delay(5000)) == request) await request; + + string name = friend.Name; + return string.IsNullOrEmpty(name) || name == "[unknown]" ? null : name; + } + + public async Task GetDescriptionAsync(ulong pFileId) + { + ResultPage? page = await Query.Items.WithFileId(pFileId).WithLongDescription(true).GetPageAsync(1); + return page?.Entries.Select(item => item.Description).FirstOrDefault(); + } } \ No newline at end of file diff --git a/services/ModWorkshopServiceWindows.cs b/services/ModWorkshopServiceWindows.cs index cc3eccb2..6b0830cd 100644 --- a/services/ModWorkshopServiceWindows.cs +++ b/services/ModWorkshopServiceWindows.cs @@ -9,6 +9,7 @@ using UnityEngine; using winsteamwork::Steamworks; using winsteamwork::Steamworks.Ugc; +using WorkshopItem = winsteamwork::Steamworks.Ugc.Item; namespace NeoModLoader.services; @@ -292,4 +293,92 @@ bool available(winsteamwork::Steamworks.Ugc.Item item) return subscribedItems; } + + public bool IsSteamOnline() + { + return SteamClient.IsValid && SteamClient.IsLoggedOn; + } + + public async Task QueryModsAsync(int pPage, bool pRankedByTrend) + { + // Query is a struct: every builder method returns a changed copy. + Query query = Query.ItemsReadyToUse.WithTag(WorkshopTags.Mod).WithTag(WorkshopTags.UnverifiedMod) + .MatchAnyTag().WithoutTag(WorkshopTags.ModLoader); + query = pRankedByTrend ? query.RankedByTrend().WithTrendDays(7) : query.RankedByPublicationDate(); + + ResultPage? result = await query.GetPageAsync(pPage); + if (!result.HasValue) return null; + + // The page holds a Steam query handle until it is disposed. + using ResultPage page = result.Value; + var listings = new ModListingPage { TotalCount = page.TotalCount }; + foreach (WorkshopItem item in page.Entries) + { + listings.Items.Add(new ModListing + { + Id = item.Id.Value, + Title = item.Title ?? "", + AuthorId = item.Owner.Id.Value, + Tags = item.Tags ?? Array.Empty(), + PreviewUrl = item.PreviewImageUrl, + Subscriptions = item.NumSubscriptions, + VotesUp = item.VotesUp, + VotesDown = item.VotesDown, + Score = item.Score, + Created = item.Created, + Updated = item.Updated + }); + } + + return listings; + } + + public WorkshopItemState GetItemState(ulong pFileId) + { + var item = new WorkshopItem(pFileId); + bool installed = item.IsInstalled; + bool downloading = item.IsDownloading; + return new WorkshopItemState + { + Subscribed = item.IsSubscribed, + Installed = installed, + NeedsUpdate = item.NeedsUpdate, + Downloading = downloading, + DownloadPending = item.IsDownloadPending, + // DownloadBytesDownloaded reads 0 during a first download; DownloadAmount asks Steam directly. + Progress = downloading ? item.DownloadAmount : 0, + Directory = installed ? item.Directory : null + }; + } + + public Task SubscribeAsync(ulong pFileId) + { + return new WorkshopItem(pFileId).Subscribe(); + } + + public Task UnsubscribeAsync(ulong pFileId) + { + return new WorkshopItem(pFileId).Unsubscribe(); + } + + public bool StartDownload(ulong pFileId) + { + return SteamUGC.Download(pFileId, true); + } + + public async Task GetPersonaNameAsync(ulong pSteamId) + { + var friend = new Friend(pSteamId); + Task request = friend.RequestInfoAsync(); + if (await Task.WhenAny(request, Task.Delay(5000)) == request) await request; + + string name = friend.Name; + return string.IsNullOrEmpty(name) || name == "[unknown]" ? null : name; + } + + public async Task GetDescriptionAsync(ulong pFileId) + { + ResultPage? page = await Query.Items.WithFileId(pFileId).WithLongDescription(true).GetPageAsync(1); + return page?.Entries.Select(item => item.Description).FirstOrDefault(); + } } \ No newline at end of file diff --git a/services/WorkshopAuthorNames.cs b/services/WorkshopAuthorNames.cs new file mode 100644 index 00000000..747e749e --- /dev/null +++ b/services/WorkshopAuthorNames.cs @@ -0,0 +1,63 @@ +namespace NeoModLoader.services; + +/// +/// Steam display names of Workshop authors, requested on demand and kept for the game session. +/// +/// +/// Workshop queries only return an author's Steam id. Each name is a separate request to Steam, so names +/// arrive a little after the list. Use it from Unity's main thread. +/// +internal sealed class WorkshopAuthorNames +{ + // Steam answers name requests one by one; a few at a time keeps it quick without flooding it. + private const int batch_size = 8; + + private readonly Func> request_name; + private readonly Dictionary names = new(); + private readonly HashSet requested = new(); + + /// Asks Steam for a name; null when unknown + public WorkshopAuthorNames(Func> pRequestName) + { + request_name = pRequestName; + } + + /// + /// Raised after each batch of names arrives. + /// + public event Action Changed; + + /// + /// The name of an author, or null while it is unknown. + /// + public string Get(ulong pSteamId) + { + return names.TryGetValue(pSteamId, out string name) ? name : null; + } + + /// + /// Request the names not requested yet. + /// + public async Task RequestAsync(IEnumerable pSteamIds) + { + List missing = pSteamIds.Where(id => requested.Add(id)).ToList(); + for (int start = 0; start < missing.Count; start += batch_size) + { + await Task.WhenAll(missing.Skip(start).Take(batch_size).Select(RequestOneAsync)); + Changed?.Invoke(); + } + } + + private async Task RequestOneAsync(ulong pSteamId) + { + try + { + string name = await request_name(pSteamId); + if (!string.IsNullOrEmpty(name)) names[pSteamId] = name; + } + catch (Exception e) + { + LogService.LogWarning($"Cannot get the Steam name of user {pSteamId}: {e.Message}"); + } + } +} diff --git a/services/WorkshopCatalog.cs b/services/WorkshopCatalog.cs new file mode 100644 index 00000000..8a270329 --- /dev/null +++ b/services/WorkshopCatalog.cs @@ -0,0 +1,186 @@ +using NeoModLoader.constants; + +namespace NeoModLoader.services; + +internal enum CatalogStatus +{ + /// + /// Nothing loaded yet. + /// + Empty, + Loading, + Ready, + + /// + /// The player stopped loading. holds the pages loaded so far. + /// + Cancelled, + SteamUnavailable, + Offline, + Failed +} + +/// +/// Every mod offered for NML on the Steam Workshop, loaded once per game session. +/// +/// +/// Steam can only order a few queries itself, and the filters (updated within, install state, categories, AI +/// content) are only correct on the full list. So every page is fetched (about 4 pages of 50 today, capped at +/// ), and the list is filtered and sorted locally by . +/// Use it from Unity's main thread, like . +/// +internal sealed class WorkshopCatalog +{ + public const int MaxPages = 40; + + private readonly Func> fetch_page; + private readonly HashSet known_ids = new(); + + // Incremented by every load and cancel, so a page that arrives for an older request is dropped. + private int generation; + + /// Fetches one 1-based page, ranked by trend when the flag is set; null on failure. + public WorkshopCatalog(Func> pFetchPage) + { + fetch_page = pFetchPage; + } + + public List Items { get; } = new(); + + /// + /// Position of each item in Steam's trending order, empty until finished. + /// + public Dictionary TrendingRank { get; } = new(); + + public CatalogStatus Status { get; private set; } = CatalogStatus.Empty; + + /// + /// Items Steam reports for the query, -1 before the first page arrives. + /// + public int TotalCount { get; private set; } = -1; + + public bool ReachedPageCap { get; private set; } + public bool IsLoadingTrending { get; private set; } + public bool IsLoading => Status == CatalogStatus.Loading || IsLoadingTrending; + + public event Action Changed; + + /// + /// Show that loading cannot start, e.g. because Steam is offline. + /// + public void SetUnavailable(CatalogStatus pStatus) + { + generation++; + IsLoadingTrending = false; + SetStatus(pStatus); + } + + /// + /// Load every page again. Pages appear in as they arrive. + /// + public async Task LoadAsync() + { + int request = ++generation; + IsLoadingTrending = false; + Items.Clear(); + known_ids.Clear(); + TrendingRank.Clear(); + TotalCount = -1; + ReachedPageCap = false; + SetStatus(CatalogStatus.Loading); + + List loaded = await FetchAllAsync(request, false, page => AddItems(page.Items)); + if (request != generation) return; + SetStatus(loaded == null ? CatalogStatus.Failed : CatalogStatus.Ready); + } + + /// + /// Load Steam's trending order, needed only for the Trending sort. On failure the order stays empty and + /// falls back to subscribers. + /// + public async Task LoadTrendingAsync() + { + if (TrendingRank.Count > 0 || IsLoading) return; + + int request = ++generation; + IsLoadingTrending = true; + Changed?.Invoke(); + List ranked = await FetchAllAsync(request, true, _ => { }); + if (request != generation) return; + + IsLoadingTrending = false; + for (int i = 0; i < (ranked?.Count ?? 0); i++) + { + if (!TrendingRank.ContainsKey(ranked[i].Id)) TrendingRank[ranked[i].Id] = i; + } + + Changed?.Invoke(); + } + + /// + /// Stop loading. Pages that arrive later are dropped. + /// + public void Cancel() + { + if (!IsLoading) return; + generation++; + IsLoadingTrending = false; + SetStatus(Status == CatalogStatus.Loading ? CatalogStatus.Cancelled : Status); + } + + /// + /// Fetch pages until Steam has no more. Returns null when a page failed or the request was replaced. + /// + private async Task> FetchAllAsync(int pRequest, bool pRankedByTrend, + Action pOnPage) + { + var all = new List(); + try + { + for (int page_number = 1; ; page_number++) + { + if (page_number > MaxPages) + { + ReachedPageCap = true; + return all; + } + + ModListingPage page = await fetch_page(page_number, pRankedByTrend); + if (pRequest != generation) return null; + if (page == null) + { + LogService.LogWarning($"Steam returned no result for Workshop page {page_number}."); + return null; + } + + if (!pRankedByTrend) TotalCount = page.TotalCount; + all.AddRange(page.Items); + pOnPage(page); + Changed?.Invoke(); + if (page.Items.Count == 0 || all.Count >= page.TotalCount) return all; + } + } + catch (Exception e) + { + LogService.LogError($"Workshop query failed: {e.Message}"); + LogService.LogException(e); + return null; + } + } + + private void AddItems(IEnumerable pItems) + { + foreach (ModListing item in pItems) + { + // Rankings can shift between page requests, so an item may arrive twice. + if (item.Id == CoreConstants.WorkshopFileId || !known_ids.Add(item.Id)) continue; + Items.Add(item); + } + } + + private void SetStatus(CatalogStatus pStatus) + { + Status = pStatus; + Changed?.Invoke(); + } +} diff --git a/services/WorkshopDownloadQueue.cs b/services/WorkshopDownloadQueue.cs new file mode 100644 index 00000000..ba0c8022 --- /dev/null +++ b/services/WorkshopDownloadQueue.cs @@ -0,0 +1,295 @@ +namespace NeoModLoader.services; + +/// +/// The Steam operations the download queue needs. Implemented by the platform backends. +/// +internal interface IWorkshopItemActions +{ + WorkshopItemState GetItemState(ulong pFileId); + Task SubscribeAsync(ulong pFileId); + Task UnsubscribeAsync(ulong pFileId); + + /// + /// Ask Steam to download a new item or update an installed one. False when Steam could not start it. + /// + bool StartDownload(ulong pFileId); +} + +internal enum DownloadState +{ + Queued, + Downloading, + Installing, + Completed, + Failed, + Cancelled +} + +/// +/// One requested download. +/// +internal sealed class DownloadEntry +{ + public DownloadEntry(ModListing pListing) + { + Listing = pListing; + } + + public ModListing Listing { get; } + public DownloadState State { get; internal set; } = DownloadState.Queued; + + /// + /// 0 to 1 while . + /// + public float Progress { get; internal set; } + + /// + /// Locale key of the reason when . + /// + public string Error { get; internal set; } + + public bool IsFinished => State is DownloadState.Completed or DownloadState.Failed or DownloadState.Cancelled; + + /// + /// The queue subscribed the item, so cancelling or failing before it is installed unsubscribes it again. + /// + internal bool SubscribedByQueue { get; set; } + + internal bool CancelRequested { get; set; } +} + +/// +/// Subscribes to, downloads and installs Workshop items one at a time. +/// +/// +/// Use it from Unity's main thread only. Its awaits then resume on the main thread through Unity's +/// synchronization context, so no locking is needed and can update the UI directly. +/// One item at a time keeps the progress accurate and a failing item never stops the others. +/// +internal sealed class WorkshopDownloadQueue +{ + public const string ErrorSubscribe = "mod_hub_download_error_subscribe"; + public const string ErrorNoProgress = "mod_hub_download_error_no_progress"; + public const string ErrorUnexpected = "mod_hub_download_error_unexpected"; + + private readonly IWorkshopItemActions steam; + private readonly Func install; + private readonly Func delay; + private readonly Func clock; + private readonly Dictionary entries = new(); + private readonly Queue waiting = new(); + private bool running; + + /// Steam operations + /// + /// Called with the install folder once Steam has installed an item. Returns the locale key of an error, or + /// null on success. + /// + /// Waits between state checks, outside tests + /// Current time, outside tests + public WorkshopDownloadQueue(IWorkshopItemActions pSteam, Func pInstall, + Func pDelay, Func pClock) + { + steam = pSteam; + install = pInstall; + delay = pDelay; + clock = pClock; + } + + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250); + + /// + /// Fail when a download makes no progress for this long, the usual sign of Steam being offline. + /// + public TimeSpan StallTimeout { get; set; } = TimeSpan.FromSeconds(90); + + /// + /// Fail when Steam neither downloads nor queues the item for this long after it was asked to. + /// + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// Raised whenever an entry changes state or progress. + /// + public event Action Changed; + + /// + /// Downloads queued or in progress. + /// + public int UnfinishedCount => entries.Values.Count(entry => !entry.IsFinished); + + /// + /// The latest entry for an item, finished or not, or null. + /// + public DownloadEntry Find(ulong pFileId) + { + return entries.TryGetValue(pFileId, out DownloadEntry entry) ? entry : null; + } + + /// + /// Queue a download. Returns the existing entry when the item is already queued or downloading. + /// + public DownloadEntry Enqueue(ModListing pListing) + { + DownloadEntry existing = Find(pListing.Id); + if (existing != null && !existing.IsFinished) return existing; + + var entry = new DownloadEntry(pListing); + entries[pListing.Id] = entry; + waiting.Enqueue(entry); + Changed?.Invoke(entry); + if (!running) _ = RunAsync(); + return entry; + } + + /// + /// Cancel a download. A waiting one stops at once; the active one stops at its next state check. + /// + public void Cancel(ulong pFileId) + { + DownloadEntry entry = Find(pFileId); + if (entry == null || entry.IsFinished) return; + + entry.CancelRequested = true; + if (entry.State == DownloadState.Queued) SetState(entry, DownloadState.Cancelled); + } + + private async Task RunAsync() + { + running = true; + try + { + while (waiting.Count > 0) + { + DownloadEntry entry = waiting.Dequeue(); + if (entry.IsFinished) continue; + try + { + await ProcessAsync(entry); + } + catch (Exception e) + { + // ProcessAsync handles its own errors; this only runs when handling them failed too. + LogService.LogException(e); + entry.Error = ErrorUnexpected; + SetState(entry, DownloadState.Failed); + } + } + } + finally + { + running = false; + } + } + + private async Task ProcessAsync(DownloadEntry pEntry) + { + ulong id = pEntry.Listing.Id; + try + { + SetState(pEntry, DownloadState.Downloading); + if (!steam.GetItemState(id).Subscribed) + { + if (!await steam.SubscribeAsync(id)) + { + await FailAsync(pEntry, ErrorSubscribe); + return; + } + + pEntry.SubscribedByQueue = true; + } + + steam.StartDownload(id); + string directory = await WaitUntilInstalledAsync(pEntry); + if (directory == null) return; + + SetState(pEntry, DownloadState.Installing); + string install_error = install(directory); + if (install_error != null) + { + await FailAsync(pEntry, install_error); + return; + } + + SetState(pEntry, DownloadState.Completed); + LogService.LogInfo($"Workshop item {id} ({pEntry.Listing.Title}) installed at {directory}"); + } + catch (Exception e) + { + LogService.LogError($"Workshop item {id} ({pEntry.Listing.Title}) failed: {e.Message}"); + LogService.LogException(e); + await FailAsync(pEntry, ErrorUnexpected); + } + } + + /// + /// Poll Steam until the item is installed and up to date. Returns its folder, or null when the entry was + /// cancelled or failed. + /// + private async Task WaitUntilInstalledAsync(DownloadEntry pEntry) + { + ulong id = pEntry.Listing.Id; + string last_signature = null; + DateTime last_change = clock(); + while (true) + { + if (pEntry.CancelRequested) + { + await RollbackSubscriptionAsync(pEntry); + SetState(pEntry, DownloadState.Cancelled); + return null; + } + + WorkshopItemState state = steam.GetItemState(id); + if (state.IsUpToDate) return state.Directory; + + SetProgress(pEntry, state.Progress); + string signature = $"{state.Downloading}|{state.DownloadPending}|{state.Installed}|{state.Progress:F3}"; + if (signature != last_signature) + { + last_signature = signature; + last_change = clock(); + } + else if (clock() - last_change > (state.IsBusy ? StallTimeout : IdleTimeout)) + { + await FailAsync(pEntry, ErrorNoProgress); + return null; + } + + await delay(PollInterval); + } + } + + private async Task FailAsync(DownloadEntry pEntry, string pErrorKey) + { + LogService.LogWarning($"Workshop item {pEntry.Listing.Id} ({pEntry.Listing.Title}) failed: {pErrorKey}"); + // Leave no subscription behind for an item the queue could not install. + if (!steam.GetItemState(pEntry.Listing.Id).Installed) await RollbackSubscriptionAsync(pEntry); + pEntry.Error = pErrorKey; + SetState(pEntry, DownloadState.Failed); + } + + private async Task RollbackSubscriptionAsync(DownloadEntry pEntry) + { + if (!pEntry.SubscribedByQueue) return; + pEntry.SubscribedByQueue = false; + if (!await steam.UnsubscribeAsync(pEntry.Listing.Id)) + { + LogService.LogWarning($"Steam refused to unsubscribe Workshop item {pEntry.Listing.Id}"); + } + } + + private void SetProgress(DownloadEntry pEntry, float pProgress) + { + // Notify on whole-percent steps only. + if ((int)(pEntry.Progress * 100) == (int)(pProgress * 100)) return; + pEntry.Progress = pProgress; + Changed?.Invoke(pEntry); + } + + private void SetState(DownloadEntry pEntry, DownloadState pState) + { + pEntry.State = pState; + Changed?.Invoke(pEntry); + } +} diff --git a/services/WorkshopTags.cs b/services/WorkshopTags.cs new file mode 100644 index 00000000..0c6e0878 --- /dev/null +++ b/services/WorkshopTags.cs @@ -0,0 +1,22 @@ +namespace NeoModLoader.services; + +/// +/// Steam Workshop tags that NML's uploads use to mark what an item is. +/// +internal static class WorkshopTags +{ + /// + /// Every upload since the upload check was removed (ModUploadAuthenticationService.Authed is always true). + /// + public const string Mod = "Mod"; + + /// + /// Uploads that skipped the old check. Treated like : the check never reviewed code. + /// + public const string UnverifiedMod = "Unverified Mod"; + + /// + /// NeoModLoader itself. + /// + public const string ModLoader = "Mod Loader"; +} diff --git a/ui/ConfirmWindow.cs b/ui/ConfirmWindow.cs new file mode 100644 index 00000000..45823529 --- /dev/null +++ b/ui/ConfirmWindow.cs @@ -0,0 +1,49 @@ +using NeoModLoader.api; +using UnityEngine; +using UnityEngine.UI; + +namespace NeoModLoader.ui; + +/// +/// Asks the player to confirm an action before it runs. +/// +internal class ConfirmWindow : AbstractWindow +{ + private Text message; + private Action on_confirm; + + /// Localized question that says what will happen + /// Runs when the player confirms, before returning to the previous window + public static void ShowWindow(string pMessage, Action pOnConfirm) + { + Instance.message.text = pMessage; + Instance.on_confirm = pOnConfirm; + ScrollWindow.showWindow(WindowId); + } + + protected override void Init() + { + BackgroundTransform.Find("Scroll View").gameObject.SetActive(false); + message = ModHubUI.CreateText(BackgroundTransform, "Message", new Vector2(200, 170), new Vector2(0, 25), 9, + TextAnchor.MiddleCenter); + ModHubUI.CreateTextButton(BackgroundTransform, "Confirm", "mod_hub_confirm", new Vector2(80, 24), + new Vector2(-45, -100), Confirm); + ModHubUI.CreateTextButton(BackgroundTransform, "Cancel", "mod_hub_cancel", new Vector2(80, 24), + new Vector2(45, -100), Close); + } + + private void Confirm() + { + Action action = on_confirm; + on_confirm = null; + // Run first, so the previous window shows the result when it is enabled again. + action?.Invoke(); + Close(); + } + + private void Close() + { + on_confirm = null; + GetComponent().clickBack(); + } +} diff --git a/ui/ModBrowserWindow.cs b/ui/ModBrowserWindow.cs new file mode 100644 index 00000000..24ae2e9b --- /dev/null +++ b/ui/ModBrowserWindow.cs @@ -0,0 +1,347 @@ +using NeoModLoader.api; +using NeoModLoader.constants; +using NeoModLoader.General; +using NeoModLoader.General.UI.Prefabs; +using NeoModLoader.services; +using NeoModLoader.ui.prefabs; +using NeoModLoader.utils; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace NeoModLoader.ui; + +/// +/// Browse the mods offered for NML on the Steam Workshop and download them. +/// +internal class ModBrowserWindow : AbstractListWindow +{ + // Fills the search line, so its clear button lines up with the end of the strip. + private const float search_width = 168; + private const float footer_button_width = 56; + + /// + /// Kept for this game session only, see . + /// + private static string search_text = ""; + + private readonly ModListingQuery query = new(); + private SimpleButton sort_button; + private SimpleButton updated_button; + private SimpleButton install_button; + private SimpleButton category_button; + private SimpleButton ai_button; + private SimpleButton search_mode_button; + private SimpleButton update_all_button; + private SimpleButton cancel_button; + private SimpleButton retry_button; + private CategoryFilterPanel category_panel; + private Text status_label; + private Text message_label; + private RectTransform progress; + private float scroll_position; + private int shown_count; + + // Set while hidden or when author names arrive; the list is rebuilt once instead of on every change. + private bool needs_refresh; + + private void Update() + { + if (!IsOpened || !needs_refresh) return; + needs_refresh = false; + Refresh(); + } + + protected override void Init() + { + LoadSettings(); + + RectTransform content = ModHubStyle.SetUpListWindow(BackgroundTransform, out Transform header, + out Transform footer); + ModHubStyle.StyleList(ContentTransform); + ModHubUI.AddHubTabs(BackgroundTransform.parent, WindowId); + CreateToolbar(header, ModHubStyle.InsertStrip(content, 1, "SearchLine")); + CreateBottomBar(footer); + + // Drawn over the list, in this order. + Transform scroll_view = ContentTransform.parent.parent; + message_label = ModHubStyle.EmptyListLabel(ContentTransform, null); + progress = ModHubStyle.ProgressBar(scroll_view); + category_panel = CategoryFilterPanel.Create(scroll_view, query.Categories, OnCategoriesChanged); + + ModDownloadService.Catalog.Changed += () => needs_refresh = true; + ModDownloadService.Authors.Changed += OnAuthorsChanged; + ModDownloadService.Queue.Changed += OnDownloadChanged; + } + + public override void OnNormalEnable() + { + Refresh(); + StartCoroutine(ModHubUI.RestoreScrollPosition(ContentTransform, scroll_position)); + if (ModDownloadService.Catalog.Status is not (CatalogStatus.Ready or CatalogStatus.Loading)) StartLoading(); + } + + public override void OnNormalDisable() + { + scroll_position = ContentTransform.localPosition.y; + category_panel.gameObject.SetActive(false); + } + + /// + /// Download a listing, after the one-time notice that Workshop mods are unreviewed code. + /// + internal void Download(ModListing pListing) + { + ModHubSettings settings = ModHubSettings.Instance; + if (settings.SecurityNoticeAccepted) + { + ModDownloadService.Download(pListing); + return; + } + + ConfirmWindow.ShowWindow(LM.Get("mod_hub_security_notice"), () => + { + settings.SecurityNoticeAccepted = true; + settings.Save(); + ModDownloadService.Download(pListing); + }); + } + + protected override AbstractListWindowItem CreateItemPrefab() + { + return ModBrowserRow.CreatePrefab(); + } + + private void StartLoading() + { + CatalogStatus steam = ModDownloadService.CheckSteam(); + if (steam != CatalogStatus.Ready) + { + ModDownloadService.Catalog.SetUnavailable(steam); + return; + } + + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + WorkshopCatalog catalog = ModDownloadService.Catalog; + await catalog.LoadAsync(); + if (query.Sort == ListingSort.Trending) await catalog.LoadTrendingAsync(); + await ModDownloadService.Authors.RequestAsync(catalog.Items.Select(listing => listing.AuthorId)); + } + + private void Refresh() + { + if (!IsOpened) + { + needs_refresh = true; + return; + } + + WorkshopCatalog catalog = ModDownloadService.Catalog; + query.SearchText = search_text; + List listings = query.Apply(catalog.Items, DateTime.UtcNow, ModDownloadService.GetStatus, + ModDownloadService.Authors.Get, catalog.TrendingRank); + ClearList(); + foreach (ModListing listing in listings) AddItemToList(listing); + + shown_count = listings.Count; + UpdateToolbar(); + UpdateMessage(); + UpdateBottomBar(); + } + + private void UpdateMessage() + { + CatalogStatus status = ModDownloadService.Catalog.Status; + string key = status switch + { + CatalogStatus.SteamUnavailable => "mod_hub_steam_unavailable", + CatalogStatus.Offline => "mod_hub_steam_offline", + CatalogStatus.Failed => "mod_hub_query_failed", + _ when shown_count == 0 && status != CatalogStatus.Loading => "mod_hub_no_match", + _ => null + }; + message_label.gameObject.SetActive(key != null); + if (key != null) message_label.text = LM.Get(key); + retry_button.gameObject.SetActive(status is CatalogStatus.SteamUnavailable or CatalogStatus.Offline + or CatalogStatus.Failed); + } + + private void UpdateBottomBar() + { + WorkshopCatalog catalog = ModDownloadService.Catalog; + WorkshopDownloadQueue queue = ModDownloadService.Queue; + string text = catalog.Status switch + { + CatalogStatus.Loading => string.Format(LM.Get("mod_hub_loading"), catalog.Items.Count, + Math.Max(catalog.TotalCount, 0)), + CatalogStatus.Cancelled => string.Format(LM.Get("mod_hub_loading_stopped"), catalog.Items.Count), + _ => string.Format(LM.Get("mod_hub_shown_count"), shown_count, catalog.Items.Count) + }; + if (queue.UnfinishedCount > 0) + { + text += "\n" + string.Format(LM.Get("mod_hub_downloads_waiting"), queue.UnfinishedCount); + } + if (catalog.ReachedPageCap) text += "\n" + LM.Get("mod_hub_page_cap"); + status_label.text = text; + + progress.parent.gameObject.SetActive(catalog.IsLoading && catalog.TotalCount > 0); + if (catalog.TotalCount > 0) ModHubStyle.SetProgress(progress, (float)catalog.Items.Count / catalog.TotalCount); + + ModHubUI.SetInteractable(update_all_button, catalog.Status == CatalogStatus.Ready); + ModHubUI.SetInteractable(cancel_button, catalog.IsLoading); + // Retry takes the place of Cancel loading, which has nothing to stop after an error. + cancel_button.gameObject.SetActive(!retry_button.gameObject.activeSelf); + } + + private void OnDownloadChanged(DownloadEntry pEntry) + { + if (ItemMap.TryGetValue(pEntry.Listing, out AbstractListWindowItem row)) + { + ((ModBrowserRow)row).UpdateStatus(); + } + + if (IsOpened) UpdateBottomBar(); + } + + private void OnAuthorsChanged() + { + // Rows read author names when hovered; only a search by author has to filter again. + if (query.SearchMode == ModSearchMode.Author && !string.IsNullOrWhiteSpace(search_text)) needs_refresh = true; + } + + private void OnCategoriesChanged() + { + SaveSettings(); + Refresh(); + } + + private void CreateToolbar(Transform pFilters, Transform pSearchLine) + { + sort_button = ModHubStyle.ToolbarButton(pFilters, "Sort", null, + () => ChangeQuery(() => query.Sort = ModHubUI.NextValue(query.Sort)), "mod_hub_sort"); + updated_button = ModHubStyle.ToolbarButton(pFilters, "Updated", ModHubUI.GameIcon("iconClock"), + () => ChangeQuery(() => query.UpdatedWithin = ModHubUI.NextValue(query.UpdatedWithin)), + "mod_hub_updated_within"); + install_button = ModHubStyle.ToolbarButton(pFilters, "Install", null, + () => ChangeQuery(() => query.Install = ModHubUI.NextValue(query.Install)), "mod_hub_install"); + category_button = ModHubStyle.ToolbarButton(pFilters, "Category", null, + () => category_panel.gameObject.SetActive(!category_panel.gameObject.activeSelf), "mod_hub_category"); + ai_button = ModHubStyle.ToolbarButton(pFilters, "AIContent", null, + () => ChangeQuery(() => query.AIContent = ModHubUI.NextValue(query.AIContent)), "mod_hub_ai"); + ModHubStyle.Spacer(pFilters); + ModHubStyle.ToolbarButton(pFilters, "Refresh", InternalResourcesGetter.GetReloadIcon(), StartLoading, + "mod_hub_refresh"); + + search_mode_button = ModHubStyle.ToolbarButton(pSearchLine, "SearchMode", null, + () => ChangeQuery(() => query.SearchMode = ModHubUI.NextValue(query.SearchMode)), "mod_hub_search_mode"); + TextInput search = ModHubStyle.SearchBox(pSearchLine, search_text, search_width, SetSearchText); + ModHubStyle.ToolbarButton(pSearchLine, "ClearSearch", ModHubUI.GameIcon("iconClose"), + () => search.input.text = "", "mod_hub_clear_search"); + } + + private void CreateBottomBar(Transform pFooter) + { + status_label = ModHubStyle.Label(pFooter, "Status", ModHubStyle.FontSmall, ModHubStyle.TextMuted); + // Up to three short lines: the count, waiting downloads and the page limit. + status_label.resizeTextForBestFit = true; + status_label.resizeTextMinSize = 4; + status_label.resizeTextMaxSize = ModHubStyle.FontSmall; + ModHubStyle.Size(status_label.gameObject, 0, ModHubStyle.ButtonHeight, 1); + + retry_button = FooterButton("Retry", "mod_hub_retry", StartLoading); + update_all_button = FooterButton("UpdateAll", "mod_hub_update_all", UpdateAll, true); + cancel_button = FooterButton("CancelLoading", "mod_hub_cancel_loading", ModDownloadService.Catalog.Cancel); + + SimpleButton FooterButton(string pName, string pTextKey, UnityAction pOnClick, bool pPrimary = false) + { + SimpleButton button = ModHubStyle.TextButton(pFooter, pName, pTextKey, pOnClick, pPrimary); + ModHubStyle.Size(button.gameObject, footer_button_width, ModHubStyle.ButtonHeight); + return button; + } + } + + private void UpdateAll() + { + if (ModDownloadService.UpdateAll() == 0) + { + InformationWindow.ShowWindow(LM.Get("mod_hub_all_up_to_date")); + } + } + + private void SetSearchText(string pText) + { + if (pText == search_text) return; + search_text = pText; + Refresh(); + } + + private void ChangeQuery(Action pChange) + { + pChange(); + SaveSettings(); + if (query.Sort == ListingSort.Trending) _ = ModDownloadService.Catalog.LoadTrendingAsync(); + Refresh(); + } + + private void LoadSettings() + { + ModHubSettings settings = ModHubSettings.Instance; + query.Sort = settings.BrowserSort; + query.UpdatedWithin = settings.BrowserUpdatedWithin; + query.Install = settings.BrowserInstall; + query.AIContent = settings.BrowserAIContent; + query.SearchMode = settings.BrowserSearchMode; + foreach (string category in settings.BrowserCategories.Where(CoreConstants.ModCategories.Contains)) + { + query.Categories.Add(category); + } + } + + private void SaveSettings() + { + ModHubSettings settings = ModHubSettings.Instance; + settings.BrowserSort = query.Sort; + settings.BrowserUpdatedWithin = query.UpdatedWithin; + settings.BrowserInstall = query.Install; + settings.BrowserAIContent = query.AIContent; + settings.BrowserSearchMode = query.SearchMode; + settings.BrowserCategories = query.Categories.ToList(); + settings.Save(); + } + + private void UpdateToolbar() + { + ModHubUI.ShowChoice(sort_button, ModHubUI.LocaleKey("mod_hub_sort", query.Sort), query.Sort switch + { + ListingSort.MostSubscribed => "iconPopulation", + ListingSort.Trending => "iconFavoriteStar", + ListingSort.TopRated => "iconRenown", + ListingSort.Newest => "iconNewWorld", + ListingSort.Name => "iconArrowDOWN", + _ => "iconAges" + }); + updated_button.TipButton.textOnClickDescription = + ModHubUI.LocaleKey("mod_hub_updated_within", query.UpdatedWithin); + ModHubUI.ShowChoice(install_button, ModHubUI.LocaleKey("mod_hub_install", query.Install), query.Install switch + { + InstallFilter.NotInstalled => "iconArrowDOWN", + InstallFilter.Installed => "iconOn", + InstallFilter.UpdateAvailable => "iconSaveCloud", + _ => "iconBooks" + }); + // The selected categories are not a fixed text, so they go through a locale key set here. + LM.AddToCurrentLocale("mod_hub_category_selected", query.Categories.Count == 0 + ? LM.Get("mod_hub_category_any") + : string.Join(", ", query.Categories)); + ModHubUI.ShowChoice(category_button, "mod_hub_category_selected", "iconFavoritesList"); + ModHubUI.ShowChoice(ai_button, ModHubUI.LocaleKey("mod_hub_ai_filter", query.AIContent), + query.AIContent == AIContentFilter.Any ? "iconBrain" : "iconWarning"); + ModHubUI.ShowChoice(search_mode_button, ModHubUI.LocaleKey("mod_hub_browser_search_mode", query.SearchMode), + query.SearchMode == ModSearchMode.Author ? "iconInterestingPeople" : "iconCityName"); + } + +} diff --git a/ui/ModHubStyle.cs b/ui/ModHubStyle.cs new file mode 100644 index 00000000..5fffac80 --- /dev/null +++ b/ui/ModHubStyle.cs @@ -0,0 +1,404 @@ +using NeoModLoader.General; +using NeoModLoader.General.UI.Prefabs; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace NeoModLoader.ui; + +/// +/// The Mod Hub's look in one place: spacing, colours, font sizes and the game sprites it uses, and factories +/// that build laid-out panels, labels and buttons from them. Windows arrange what these return with layout +/// groups instead of fixed positions. +/// +internal static class ModHubStyle +{ + public const int SpaceS = 4; + public const int SpaceM = 8; + public const int SpaceL = 12; + + public const int FontTitle = 9; + public const int FontBody = 7; + public const int FontSmall = 6; + + public const float ButtonHeight = 16; + public const float IconButtonSize = 16; + public const float ToolbarButtonSize = 20; + + public static readonly Color TextBright = new(0.96f, 0.96f, 0.92f); + public static readonly Color TextMuted = new(0.7f, 0.72f, 0.66f); + public static readonly Color Positive = new(0.45f, 0.9f, 0.45f); + public static readonly Color Negative = new(1f, 0.42f, 0.36f); + public static readonly Color Warning = new(1f, 0.72f, 0.25f); + public static readonly Color DividerColor = new(0f, 0f, 0f, 0.35f); + + // The room the stock frame's title and lower edge take inside the background. + private const float frame_top = 32; + private const float frame_bottom = 10; + private const float side_tab_icon_size = 18; + + // The game's own sprites, so the Hub matches the window frame around it. + public static Sprite Card => SpriteTextureLoader.getSprite("ui/special/windowInnerSlicedRound"); + public static Sprite Button => SpriteTextureLoader.getSprite("ui/special/special_buttonGray"); + public static Sprite PrimaryButton => SpriteTextureLoader.getSprite("ui/special/special_buttonRed"); + public static Sprite Pill => SpriteTextureLoader.getSprite("ui/special/darkInputFieldEmpty"); + + /// + /// Lay out a stock window as a header strip, the window's scroll list, and a footer strip. + /// + /// The content area, holding the header, the list and the footer in that order + public static RectTransform SetUpListWindow(Transform pBackground, out Transform pHeader, out Transform pFooter) + { + Transform gradient = pBackground.Find("Scrollgradient"); + if (gradient != null) gradient.gameObject.SetActive(false); + RectTransform content = ContentArea(pBackground); + Vertical(content.gameObject, SpaceM).childForceExpandWidth = true; + + pHeader = Strip(content, "Header", ToolbarButtonSize); + Transform scroll_view = pBackground.Find("Scroll View"); + scroll_view.SetParent(content, false); + Size(scroll_view.gameObject, pFlexibleHeight: 1); + FitScrollParts(scroll_view); + pFooter = Strip(content, "Footer", ButtonHeight); + return content; + } + + /// + /// Space the rows of a list window's content evenly, full width. + /// + public static void StyleList(Transform pListContent) + { + var layout = pListContent.GetComponent(); + layout.padding = new RectOffset(0, 0, 0, 0); + layout.spacing = SpaceS; + layout.childAlignment = TextAnchor.UpperCenter; + } + + /// + /// A muted message centred over a list, shown when it is empty. + /// + /// The list window's content + /// Locale key of the message, or null to set the text in code + public static Text EmptyListLabel(Transform pListContent, string pTextKey) + { + Transform scroll_view = pListContent.parent.parent; + Text label = Label(scroll_view, "EmptyMessage", FontBody, TextMuted, TextAnchor.MiddleCenter); + Stretch(label.rectTransform, SpaceL); + if (pTextKey != null) ModHubUI.SetLocaleKey(label, pTextKey); + return label; + } + + /// + /// One more toolbar strip like the header, at in the content of + /// . + /// + public static Transform InsertStrip(Transform pContent, int pIndex, string pName) + { + Transform strip = Strip(pContent, pName, ToolbarButtonSize); + strip.SetSiblingIndex(pIndex); + return strip; + } + + /// + /// A slim bar along the bottom edge of . Returns the fill for + /// . + /// + public static RectTransform ProgressBar(Transform pParent) + { + var bar = new GameObject("Progress", typeof(Image)); + bar.transform.SetParent(pParent, false); + bar.GetComponent().color = DividerColor; + bar.GetComponent().raycastTarget = false; + var rect = bar.GetComponent(); + rect.anchorMin = Vector2.zero; + rect.anchorMax = new Vector2(1, 0); + rect.pivot = new Vector2(0.5f, 0); + rect.sizeDelta = new Vector2(0, 2); + + var fill = new GameObject("Fill", typeof(Image)); + fill.transform.SetParent(bar.transform, false); + fill.GetComponent().color = Positive; + fill.GetComponent().raycastTarget = false; + var fill_rect = fill.GetComponent(); + Stretch(fill_rect, 0); + return fill_rect; + } + + public static void SetProgress(RectTransform pFill, float pProgress) + { + pFill.anchorMax = new Vector2(Mathf.Clamp01(pProgress), 1); + } + + /// + /// NML's text input, reporting every change so a list can filter while the player types. + /// + public static TextInput SearchBox(Transform pParent, string pText, float pWidth, UnityAction pOnChanged) + { + TextInput search = TextInput.Instantiate(pParent, pName: "Search"); + search.Setup(pText, pOnChanged); + search.SetSize(new Vector2(pWidth, ToolbarButtonSize)); + search.input.onValueChanged.AddListener(pOnChanged); + Size(search.gameObject, pWidth, ToolbarButtonSize); + return search; + } + + public static VerticalLayoutGroup Vertical(GameObject pObject, float pSpacing, int pPadding = 0) + { + var layout = pObject.AddComponent(); + SetUp(layout, pSpacing, pPadding, TextAnchor.UpperLeft); + return layout; + } + + public static HorizontalLayoutGroup Horizontal(GameObject pObject, float pSpacing, int pPadding = 0) + { + var layout = pObject.AddComponent(); + SetUp(layout, pSpacing, pPadding, TextAnchor.MiddleLeft); + return layout; + } + + /// + /// Fix the size a layout group gives . Negative values leave it to the layout. + /// + public static void Size(GameObject pObject, float pWidth = -1, float pHeight = -1, float pFlexibleWidth = -1, + float pFlexibleHeight = -1) + { + LayoutElement element = pObject.GetComponent(); + if (element == null) element = pObject.AddComponent(); + element.preferredWidth = pWidth; + element.minWidth = pWidth; + element.preferredHeight = pHeight; + element.minHeight = pHeight; + element.flexibleWidth = pFlexibleWidth; + element.flexibleHeight = pFlexibleHeight; + } + + /// + /// An empty object that takes the free space in a layout row, pushing what follows to the end. + /// + public static void Spacer(Transform pParent) + { + var spacer = new GameObject("Spacer", typeof(RectTransform)); + spacer.transform.SetParent(pParent, false); + Size(spacer, pFlexibleWidth: 1); + } + + /// + /// A 9-sliced image, such as . + /// + public static GameObject Box(Transform pParent, string pName, Sprite pSprite) + { + var obj = new GameObject(pName, typeof(Image)); + obj.transform.SetParent(pParent, false); + Image image = obj.GetComponent(); + image.sprite = pSprite; + image.type = Image.Type.Sliced; + return obj; + } + + /// + /// A thin line under a title. + /// + public static void Divider(Transform pParent) + { + var line = new GameObject("Divider", typeof(Image)); + line.transform.SetParent(pParent, false); + line.GetComponent().color = DividerColor; + line.GetComponent().raycastTarget = false; + Size(line, pHeight: 1); + } + + /// + /// A plain text sized by its layout group. Rich text is off: names and descriptions come from mods. + /// + public static Text Label(Transform pParent, string pName, int pFontSize, Color pColor, + TextAnchor pAlignment = TextAnchor.MiddleLeft) + { + var obj = new GameObject(pName, typeof(Text)); + obj.transform.SetParent(pParent, false); + Text text = obj.GetComponent(); + OT.InitializeCommonText(text); + text.supportRichText = false; + text.fontSize = pFontSize; + text.color = pColor; + text.alignment = pAlignment; + text.horizontalOverflow = HorizontalWrapMode.Wrap; + text.verticalOverflow = VerticalWrapMode.Truncate; + text.raycastTarget = false; + return text; + } + + /// + /// A tab on the left edge of a stock window, in the container the game uses for its own window tabs. + /// + /// The window, parent of its background + /// Name of the tab object + /// Icon shown on the tab + /// Whether the tab is the window it is on; the others are dimmed + /// Run on click + public static TipButton SideTab(Transform pWindow, string pName, Sprite pIcon, bool pCurrent, Action pOnClick) + { + // The back button's tab, so these match the frame. + Sprite sprite = pWindow.Find("BackButtonContainer/BackButtonBackground").GetComponent().sprite; + GameObject tab = Box(pWindow.Find("Background/Tabs"), pName, sprite); + tab.GetComponent().color = pCurrent ? Color.white : new Color(0.6f, 0.6f, 0.6f); + tab.AddComponent