diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddf855..7057c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- SDK guide tools escape titles, descriptions, paths, tags, and lobby metadata as proper string literals in C++, C#, and GDScript, so quotes, Windows backslashes, and line breaks no longer break the generated code. Contributed by @ect2000. (#36) +- SDK guides compile against their bindings and use every input. `steam_createLobby`'s C# uses Steamworks.NET's real enum names (`ELobbyType.k_ELobbyTypePublic`), and its GDScript callback no longer shadows `connect`. `steam_uploadWorkshopItem` now returns the C# (Steamworks.NET) block its description promised, and its GDScript sets the description and tags. The C++ and C# examples register their call results. (#54) - `steam_getReviews` and `steam_getAppReviewSummary` now call `IUserReviewsService/GetAppReviews`. Valve disabled the Store `/appreviews` endpoint on 2026-10-22, so both tools failed on every call. Both remain keyless. `steam_getReviews` adds a `funny` sort and accepts a comma-separated `language` list. `steam_getAppReviewSummary` now counts all purchase types, matching the store page total. (#27) - `steam_setAchievement` and `steam_clearAchievement` report success only when Steam returns EResult 1. A response with no `result` object used to read as success. Failures now include the EResult. (#52) - `steam_grantInventoryItem` sends the documented AddItem parameters: one `itemdefid[n]` per copy (`quantity` capped at 100), `notify` as `1`/`0`, and a `requestid` so Steam executes a grant at most once. The dry run generates a `requestid`; pass it to the live call. New optional `itempropsjson` input. (#28) diff --git a/src/tools/__tests__/sdk-guides.test.ts b/src/tools/__tests__/sdk-guides.test.ts index 939533c..fb62291 100644 --- a/src/tools/__tests__/sdk-guides.test.ts +++ b/src/tools/__tests__/sdk-guides.test.ts @@ -82,3 +82,50 @@ describe("SDK guides", () => { expect(lobby.csharp).toContain('SetLobbyData(lobbyId, "empty", "")'); }); }); + +describe("SDK guide correctness (#54)", () => { + it("lobby guide uses Steamworks.NET enum names, registers call results, and avoids shadowing connect", async () => { + const lobby = await callGuide("steam_createLobby", { type: "friends_only", max_members: 8 }); + expect(lobby.csharp).toContain("ELobbyType.k_ELobbyTypeFriendsOnly"); + expect(lobby.csharp).not.toMatch(/ELobbyType\.FriendsOnly\b/); + expect(lobby.csharp).toContain("CallResult.Create(OnLobbyCreated)"); + expect(lobby.csharp).toContain("m_LobbyCreated.Set(call);"); + expect(lobby.cpp).toContain("m_LobbyCreated.Set(call, this, &LobbyCreator::OnLobbyCreated);"); + expect(lobby.cpp).toContain("CCallResult m_LobbyCreated;"); + expect(lobby.gdscript).toContain("Steam.lobby_created.connect(_on_lobby_created)"); + expect(lobby.gdscript).toContain("this_connect: int"); + expect(lobby.gdscript).not.toMatch(/\(connect: int/); + }); + + it("Workshop guide returns every promised language and uses every input", async () => { + const guide = await callGuide("steam_uploadWorkshopItem", { + appid: 480, + title: "Map pack", + description: "Three maps", + tags: ["Maps", "Co-op"], + visibility: "unlisted", + }); + for (const language of ["cpp", "csharp", "gdscript"]) { + const code = guide[language] as string; + expect(code, language).toContain("Map pack"); + expect(code, language).toContain("Three maps"); + expect(code, language).toContain("Co-op"); + } + expect(guide.csharp).toContain("EWorkshopFileType.k_EWorkshopFileTypeCommunity"); + expect(guide.csharp).toContain( + "ERemoteStoragePublishedFileVisibility.k_ERemoteStoragePublishedFileVisibilityUnlisted", + ); + expect(guide.csharp).toContain('new List { "Maps", "Co-op" }'); + expect(guide.cpp).toContain("m_ItemCreated.Set(call, this, &WorkshopUploader::OnItemCreated);"); + expect(guide.cpp).toContain("m_ItemSubmitted.Set(submit, this, &WorkshopUploader::OnItemSubmitted);"); + expect(guide.gdscript).toContain('Steam.setItemTags(handle, ["Maps", "Co-op"])'); + expect(guide.gdscript).toContain('Steam.setItemDescription(handle, "Three maps")'); + }); + + it("omits optional calls when inputs are absent", async () => { + const guide = await callGuide("steam_uploadWorkshopItem", { appid: 480, title: "Solo" }); + expect(guide.csharp).not.toContain("SetItemTags"); + expect(guide.gdscript).not.toContain("setItemTags"); + expect(guide.gdscript).toContain('# Steam.setItemDescription(handle, "...")'); + }); +}); diff --git a/src/tools/createLobby.ts b/src/tools/createLobby.ts index 052228c..a2a3abb 100644 --- a/src/tools/createLobby.ts +++ b/src/tools/createLobby.ts @@ -67,39 +67,52 @@ export function register(server: McpServer): void { max_members, metadata: metadata ?? {}, cpp: [ - "// Include Steam headers", '#include "steam/steam_api.h"', "", - `// Create lobby (async — result via CCallResult)`, - `SteamAPICall_t call = SteamMatchmaking()->CreateLobby(${enumValue}, ${max_members});`, - "// Register callback for LobbyCreated_t", + "// Call SteamAPI_RunCallbacks() every frame so the call result fires.", + "class LobbyCreator {", + "public:", + " void Create() {", + ` SteamAPICall_t call = SteamMatchmaking()->CreateLobby(${enumValue}, ${max_members});`, + " m_LobbyCreated.Set(call, this, &LobbyCreator::OnLobbyCreated);", + " }", "", - "// In callback handler:", - "void OnLobbyCreated(LobbyCreated_t* pResult, bool bIOFailure) {", - " if (pResult->m_eResult != k_EResultOK) { /* handle error */ }", - " CSteamID lobbyId = pResult->m_ulSteamIDLobby;", - metaCpp, - "}", + "private:", + " void OnLobbyCreated(LobbyCreated_t* pResult, bool bIOFailure) {", + " if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ return; }", + " CSteamID lobbyId(pResult->m_ulSteamIDLobby);", + metaCpp.replace(/^ {2}/gm, " "), + " }", + "", + " CCallResult m_LobbyCreated;", + "};", ].join("\n"), csharp: [ - "// Using Steamworks.NET", - `var call = SteamMatchmaking.CreateLobby(ELobbyType.${enumValue.replace("k_ELobbyType", "")}, ${max_members});`, - "// Use CallResult to handle the result", + "// Using Steamworks.NET (keep a reference to the CallResult so it is not collected)", + "using Steamworks;", + "", + "private CallResult m_LobbyCreated;", + "", + "void CreateLobby() {", + " m_LobbyCreated = CallResult.Create(OnLobbyCreated);", + ` SteamAPICall_t call = SteamMatchmaking.CreateLobby(ELobbyType.${enumValue}, ${max_members});`, + " m_LobbyCreated.Set(call);", + "}", "", "void OnLobbyCreated(LobbyCreated_t result, bool bIOFailure) {", - " if (result.m_eResult != EResult.k_EResultOK) { /* handle error */ }", + " if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ return; }", " var lobbyId = new CSteamID(result.m_ulSteamIDLobby);", metaCs, "}", ].join("\n"), gdscript: [ - "# Using GodotSteam", - `var lobby_type = Steam.LOBBY_TYPE_${type.toUpperCase()}`, - `Steam.createLobby(lobby_type, ${max_members})`, - "# Connect to lobby_created signal", + "# Using GodotSteam (Godot 4)", + "func _ready() -> void:", + " Steam.lobby_created.connect(_on_lobby_created)", + ` Steam.createLobby(Steam.LOBBY_TYPE_${type.toUpperCase()}, ${max_members})`, "", - "func _on_lobby_created(connect: int, lobby_id: int) -> void:", - " if connect != 1:", + "func _on_lobby_created(this_connect: int, lobby_id: int) -> void:", + " if this_connect != 1:", " print(\"Failed to create lobby\")", " return", metaGd, diff --git a/src/tools/uploadWorkshopItem.ts b/src/tools/uploadWorkshopItem.ts index c5b0535..9ce88be 100644 --- a/src/tools/uploadWorkshopItem.ts +++ b/src/tools/uploadWorkshopItem.ts @@ -51,6 +51,11 @@ export function register(server: McpServer): void { }; const vis = visibility ?? "public"; const visEnum = visMap[vis]; + const tagList = tags ?? []; + const cppContent = escapeStringLiteral(content_path ?? "/path/to/content", "cpp"); + const cppPreview = escapeStringLiteral(preview_path ?? "/path/to/preview.jpg", "cpp"); + const csContent = escapeStringLiteral(content_path ?? "C:/path/to/content", "csharp"); + const csPreview = escapeStringLiteral(preview_path ?? "C:/path/to/preview.jpg", "csharp"); const guide = { note: "Workshop uploads are a two-step client-side SDK operation: CreateItem then SubmitItemUpdate. There is no HTTP endpoint for file uploads. Use the code below in a game client or upload tool.", @@ -71,55 +76,120 @@ export function register(server: McpServer): void { cpp: [ '#include "steam/steam_api.h"', "", - `// Step 1: Create the item`, - `SteamAPICall_t call = SteamUGC()->CreateItem(${appid}, k_EWorkshopFileTypeCommunity);`, - "// Handle CreateItemResult_t callback...", + "// Call SteamAPI_RunCallbacks() every frame so the call results fire.", + "class WorkshopUploader {", + "public:", + " void Upload() {", + ` SteamAPICall_t call = SteamUGC()->CreateItem(${appid}, k_EWorkshopFileTypeCommunity);`, + " m_ItemCreated.Set(call, this, &WorkshopUploader::OnItemCreated);", + " }", "", - "// Step 2-4: In the CreateItem callback:", - "void OnItemCreated(CreateItemResult_t* pResult, bool bIOFailure) {", - " if (pResult->m_eResult != k_EResultOK) { /* handle error */ }", - " PublishedFileId_t fileId = pResult->m_nPublishedFileId;", + "private:", + " void OnItemCreated(CreateItemResult_t* pResult, bool bIOFailure) {", + " if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ return; }", + " if (pResult->m_bUserNeedsToAcceptWorkshopLegalAgreement) { /* show the Workshop legal agreement */ }", "", - ` UGCUpdateHandle_t handle = SteamUGC()->StartItemUpdate(${appid}, fileId);`, - ` SteamUGC()->SetItemTitle(handle, ${escapeStringLiteral(title, "cpp")});`, + ` UGCUpdateHandle_t handle = SteamUGC()->StartItemUpdate(${appid}, pResult->m_nPublishedFileId);`, + ` SteamUGC()->SetItemTitle(handle, ${escapeStringLiteral(title, "cpp")});`, description - ? ` SteamUGC()->SetItemDescription(handle, ${escapeStringLiteral(description, "cpp")});` - : " // SteamUGC()->SetItemDescription(handle, \"...\");", - ` SteamUGC()->SetItemContent(handle, ${escapeStringLiteral(content_path ?? "/path/to/content", "cpp")});`, - ` SteamUGC()->SetItemPreview(handle, ${escapeStringLiteral(preview_path ?? "/path/to/preview.jpg", "cpp")});`, - ` SteamUGC()->SetItemVisibility(handle, ${visEnum});`, - ...(tags && tags.length > 0 + ? ` SteamUGC()->SetItemDescription(handle, ${escapeStringLiteral(description, "cpp")});` + : " // SteamUGC()->SetItemDescription(handle, \"...\");", + ` SteamUGC()->SetItemContent(handle, ${cppContent});`, + ` SteamUGC()->SetItemPreview(handle, ${cppPreview});`, + ` SteamUGC()->SetItemVisibility(handle, ${visEnum});`, + ...(tagList.length > 0 ? [ - " SteamParamStringArray_t tagArray;", - ` const char* tagStrings[] = { ${tags.map((t) => escapeStringLiteral(t, "cpp")).join(", ")} };`, - ` tagArray.m_ppStrings = tagStrings;`, - ` tagArray.m_nNumStrings = ${tags.length};`, - " SteamUGC()->SetItemTags(handle, &tagArray);", + ` const char* tagStrings[] = { ${tagList.map((t) => escapeStringLiteral(t, "cpp")).join(", ")} };`, + " SteamParamStringArray_t tagArray;", + " tagArray.m_ppStrings = tagStrings;", + ` tagArray.m_nNumStrings = ${tagList.length};`, + " SteamUGC()->SetItemTags(handle, &tagArray);", ] : []), "", - ' SteamUGC()->SubmitItemUpdate(handle, "Initial upload");', - " // Handle SubmitItemUpdateResult_t callback...", + ' SteamAPICall_t submit = SteamUGC()->SubmitItemUpdate(handle, "Initial upload");', + " m_ItemSubmitted.Set(submit, this, &WorkshopUploader::OnItemSubmitted);", + " }", + "", + " void OnItemSubmitted(SubmitItemUpdateResult_t* pResult, bool bIOFailure) {", + " if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ }", + " }", + "", + " CCallResult m_ItemCreated;", + " CCallResult m_ItemSubmitted;", + "};", + ].join("\n"), + csharp: [ + "// Using Steamworks.NET (keep references to the CallResults so they are not collected)", + "using System.Collections.Generic;", + "using Steamworks;", + "", + "private CallResult m_ItemCreated;", + "private CallResult m_ItemSubmitted;", + "", + "void Upload() {", + " m_ItemCreated = CallResult.Create(OnItemCreated);", + " m_ItemSubmitted = CallResult.Create(OnItemSubmitted);", + ` SteamAPICall_t call = SteamUGC.CreateItem(new AppId_t(${appid}), EWorkshopFileType.k_EWorkshopFileTypeCommunity);`, + " m_ItemCreated.Set(call);", + "}", + "", + "void OnItemCreated(CreateItemResult_t result, bool bIOFailure) {", + " if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ return; }", + " if (result.m_bUserNeedsToAcceptWorkshopLegalAgreement) { /* show the Workshop legal agreement */ }", + "", + ` UGCUpdateHandle_t handle = SteamUGC.StartItemUpdate(new AppId_t(${appid}), result.m_nPublishedFileId);`, + ` SteamUGC.SetItemTitle(handle, ${escapeStringLiteral(title, "csharp")});`, + description + ? ` SteamUGC.SetItemDescription(handle, ${escapeStringLiteral(description, "csharp")});` + : " // SteamUGC.SetItemDescription(handle, \"...\");", + ` SteamUGC.SetItemContent(handle, ${csContent});`, + ` SteamUGC.SetItemPreview(handle, ${csPreview});`, + ` SteamUGC.SetItemVisibility(handle, ERemoteStoragePublishedFileVisibility.${visEnum});`, + ...(tagList.length > 0 + ? [ + ` SteamUGC.SetItemTags(handle, new List { ${tagList.map((t) => escapeStringLiteral(t, "csharp")).join(", ")} });`, + ] + : []), + "", + ' m_ItemSubmitted.Set(SteamUGC.SubmitItemUpdate(handle, "Initial upload"));', + "}", + "", + "void OnItemSubmitted(SubmitItemUpdateResult_t result, bool bIOFailure) {", + " if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ }", "}", ].join("\n"), gdscript: [ - "# Using GodotSteam", - `var app_id = ${appid}`, + "# Using GodotSteam (Godot 4)", + `var app_id: int = ${appid}`, "", - "# Step 1: Create item", - "Steam.createItem(app_id, Steam.WORKSHOP_FILE_TYPE_COMMUNITY)", - "# Connect to item_created signal", + "func _ready() -> void:", + " Steam.item_created.connect(_on_item_created)", + " Steam.item_updated.connect(_on_item_updated)", + " Steam.createItem(app_id, Steam.WORKSHOP_FILE_TYPE_COMMUNITY)", "", "func _on_item_created(result: int, file_id: int, accept_tos: bool) -> void:", " if result != Steam.RESULT_OK:", ' print("Failed to create item")', " return", - " var handle = Steam.startItemUpdate(app_id, file_id)", + " if accept_tos:", + " pass # Show the Workshop legal agreement", + " var handle: int = Steam.startItemUpdate(app_id, file_id)", ` Steam.setItemTitle(handle, ${escapeStringLiteral(title, "gdscript")})`, + description + ? ` Steam.setItemDescription(handle, ${escapeStringLiteral(description, "gdscript")})` + : ' # Steam.setItemDescription(handle, "...")', ` Steam.setItemContent(handle, ${escapeStringLiteral(content_path ?? "res://workshop_content", "gdscript")})`, ` Steam.setItemPreview(handle, ${escapeStringLiteral(preview_path ?? "res://preview.png", "gdscript")})`, ` Steam.setItemVisibility(handle, Steam.REMOTE_STORAGE_PUBLISHED_FILE_VISIBILITY_${vis.toUpperCase()})`, + ...(tagList.length > 0 + ? [` Steam.setItemTags(handle, [${tagList.map((t) => escapeStringLiteral(t, "gdscript")).join(", ")}])`] + : []), ' Steam.submitItemUpdate(handle, "Initial upload")', + "", + "func _on_item_updated(result: int, accept_tos: bool) -> void:", + " if result != Steam.RESULT_OK:", + ' print("Failed to submit item")', ].join("\n"), references: [ "https://partner.steamgames.com/doc/api/ISteamUGC#CreateItem",