diff --git a/docs/announcements/README.md b/docs/announcements/README.md index 7a273de..30823ea 100644 --- a/docs/announcements/README.md +++ b/docs/announcements/README.md @@ -1,12 +1,23 @@ # Creator announcements -Use the `announcement` email template for service notices to creators. It takes a title and a plain-text message, preserves paragraph breaks, and makes HTTP/HTTPS URLs clickable. HTML in either field is escaped. It reuses the existing Hackdex email styling and invites replies. +Use the `announcement` email template for service notices to creators. It takes a title and a text message, preserves paragraph breaks, and makes HTTP/HTTPS URLs clickable. Start a line with `## ` for a section heading or `### ` for a smaller heading, such as a question. Markdown images are also supported as described below; other Markdown formatting is not supported. HTML in either field is escaped. It reuses the existing Hackdex email styling and invites replies. The script requires Node 22.18 or newer and installed project dependencies. It uses Node's built-in TypeScript support; no additional runner is needed. Run commands from the repository root. ## Draft and preview -Copy `docs/announcements/terms-1.1.0.json` for a new announcement. Give it a unique, stable `id`, a `title` used as the email subject, a `message`, and `ready: false`. The ID identifies this mailing, including its delivery records. Never change it to retry the same mailing. +Give each announcement a unique, stable `id`, a `title` used as the email subject, and `ready: false`. Use `messageFile` to keep the body in a separate Markdown file: + +```json +{ + "id": "ai-disclosure-explained", + "ready": false, + "title": "How Hackdex's AI disclosure system will work", + "messageFile": "ai-disclosure-explained.md" +} +``` + +The path is resolved relative to the JSON file. Existing announcements can keep their inline `message` field; provide exactly one of `message` or `messageFile`. Both use the same formatting described above. Missing or empty message files stop the script. The ID identifies this mailing, including its delivery records. Never change it to retry the same mailing. ```sh npm run email:announcement -- docs/announcements/terms-1.1.0.json @@ -16,6 +27,18 @@ This default mode renders HTML and plain-text previews without any network acces The Terms announcement uses October 3, 2026 as the effective date and November 2, 2026 as the deadline for existing hacks. Before sending, confirm these dates match the published Terms, including the 30-day grace period, and that the updated Terms and FAQ are live. Send by September 26 to provide seven days of notice. Set `ready` to `true` only after reviewing the final copy. Production sending rejects drafts and unresolved `{{...}}` placeholders. The script does not validate legal deadlines or publish policy changes. +## Screenshots + +Replace each screenshot placeholder in the message with a Markdown image on its own line, separated from surrounding text by blank lines: + +```md +![AI disclosure form with a player preview](https://raw.githubusercontent.com/Hackdex-App/hackdex-website/FULL_COMMIT_SHA/docs/announcements/assets/ai-disclosure/form.png) +``` + +Commit and push the screenshots first, then replace `FULL_COMMIT_SHA` with the full hash of that commit. Use a public HTTP/HTTPS URL that returns the image itself. Encode spaces and parentheses in URLs as `%20`, `%28`, and `%29`. Image titles and escaped brackets in alt text are not supported. + +Images fit the email's width, preserve their proportions, and link to the full-size image. The text in square brackets supplies alt text; put any visible caption in a separate paragraph. The plain-text email keeps the image description and URL in Markdown form. Regenerate the preview and send yourself a test to check loading and readability before sending to creators. + ## Configuration The npm command loads `.env.local`, then `.env.announcements` if present. Existing shell environment variables take precedence. Use `.env.announcements` for the intended production credentials and verify the source URL printed during preparation. diff --git a/docs/announcements/ai-disclosure-explained.json b/docs/announcements/ai-disclosure-explained.json new file mode 100644 index 0000000..3e3c4e6 --- /dev/null +++ b/docs/announcements/ai-disclosure-explained.json @@ -0,0 +1,6 @@ +{ + "id": "ai-disclosure-explained", + "ready": false, + "title": "How Hackdex's AI disclosure system will work", + "messageFile": "ai-disclosure-explained.md" +} diff --git a/docs/announcements/ai-disclosure-explained.md b/docs/announcements/ai-disclosure-explained.md new file mode 100644 index 0000000..37265f4 --- /dev/null +++ b/docs/announcements/ai-disclosure-explained.md @@ -0,0 +1,59 @@ +Hi, + +In my previous email, I mentioned Hackdex's upcoming AI disclosure form. It will give creators a way to describe how AI contributed to their hack and help players understand that usage. I wanted to share a look at how it will work, what players will see, and why we chose this approach. + +The form is not available yet; it will be available by October 3, 2026. Hacks submitted before October 3 have until November 2 to complete it. Those listings will not be removed for a missing AI disclosure form before that deadline. + +## How the form will work + +The form will let you describe the extent of AI use in different parts of your hack. It will also include an optional explanation where you can give players more context in your own words. + +The screenshots below show a work in progress. The final design and wording may change before release. + +![Hackdex AI Disclosure Form Example](https://images.hackdex.app/hackdex-ai-disclosure-form-20260929125937.png) + +Disclose content and code that you or your team intentionally add to the game and know was generated or modified with AI. This includes contributor-supplied work you know involved AI, even if you did not use AI yourself, and work edited after generation. The exception for material inherited from a romhack base is explained below. + +For graphics, music and sound, story and dialogue, translation, and event scripts, you will choose None, Some, or Most/All. Code will also offer A little, for uses such as one or two AI-assisted bug fixes. The form will provide guidance to help you choose. + +## What players will see + +The disclosure will distinguish AI use in code from AI use in other content and show the level you selected. A small AI-assisted code change will be shown as a small amount of AI use in code. + +![Hackdex AI disclosure in code only](https://images.hackdex.app/hackdex-ai-disclosure-code-only-20260928130049.png) + +If you select None throughout, the display will reflect that you have no AI use to disclose within the scope of the form. These are creator-reported disclosures. + +![Hackdex AI disclosure form with none selected](https://images.hackdex.app/hackdex-ai-disclosure-none-20260928130120.png) + +## Why we chose this approach + +AI use can mean a small bug fix or substantial parts of a game. We want players to be able to see those differences and creators to have room to explain their work. + +AI-generated content and code remain allowed on Hackdex, subject to our other rules. We chose disclosure because we believe a blanket ban could encourage people to hide AI use. Our aim is to make honest disclosure straightforward and give players information they can use to decide what they want to play. + +We're continuing to learn from the wider romhacking community's feedback as we develop this system. We want to create a space where creators feel comfortable sharing their work and players can make informed decisions about the hacks they play. We ask for your patience as we listen and improve this approach alongside the community. + +## Common questions + +### What about romhack bases such as pokeemerald-expansion? + +As you may know, pokeemerald-expansion does not strictly disallow contributions made or assisted with AI. However, the Senate (Expansion's maintainer team) continues to uphold rigorous standards of quality for each contribution made. As such, any AI generated or assisted contributions made to Expansion or similar romhack bases do not currently need to be counted in your disclosure form. This policy may change should any romhack bases with heavy AI usage arise. Your credits section will still be required to include the romhack base used by your hack. + +### What if I use a community tool whose creator used AI to build it? + +Using that tool does not, by itself, require disclosure. You can mention your use of tools in the optional explanation if you wish. + +### What if I choose the wrong level? + +Use the guidance in the form to make a good-faith estimate. If you realize a selection is inaccurate, update it. We may ask for a correction when a disclosure is missing or inaccurate. Deliberate misrepresentation or repeated violations can lead to removal of a listing, account suspension, or a ban. + +### What do I need to do, and when? + +Once the form is available, every creator will need to complete it. If you have no AI use to disclose, select None for each category. A note in your description does not replace the form. Every hack also needs a Credits section in its Hackdex description, regardless of AI use. + +Hacks submitted before October 3 have until November 2 to meet the credits and disclosure requirements. New submissions must meet them from October 3. + +Terms: https://www.hackdex.app/terms +FAQ: https://www.hackdex.app/faq +Your hacks: https://www.hackdex.app/dashboard \ No newline at end of file diff --git a/scripts/send-announcement.mts b/scripts/send-announcement.mts index 171471a..51a476f 100644 --- a/scripts/send-announcement.mts +++ b/scripts/send-announcement.mts @@ -46,10 +46,23 @@ export async function loadAnnouncement(file: string): Promise { if (!isRecord(value) || typeof value.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,79}$/.test(value.id) || typeof value.title !== "string" || !value.title.trim() || /[\r\n]/.test(value.title) || - typeof value.message !== "string" || !value.message.trim() || typeof value.ready !== "boolean") { - throw new Error("Announcement needs an id in lowercase kebab-case, a title, a message, and a ready boolean."); + typeof value.ready !== "boolean") { + throw new Error("Announcement needs an id in lowercase kebab-case, a title, and a ready boolean."); } - return { id: value.id, title: value.title, message: value.message, ready: value.ready }; + if (value.message !== undefined && value.messageFile !== undefined) { + throw new Error("Announcement needs either message or messageFile, not both."); + } + let message = value.message; + if (value.messageFile !== undefined) { + if (typeof value.messageFile !== "string" || !value.messageFile.trim()) { + throw new Error("Announcement needs a nonempty messageFile path."); + } + message = await readFile(path.resolve(path.dirname(file), value.messageFile), "utf8"); + } + if (typeof message !== "string" || !message.trim()) { + throw new Error("Announcement needs a nonempty message or messageFile containing text."); + } + return { id: value.id, title: value.title, message, ready: value.ready }; } /** Read approved hacks or hacks with uploaded patches, including pending and unpublished entries. */ diff --git a/scripts/send-announcement.test.mts b/scripts/send-announcement.test.mts index 3a05a41..e7cbb52 100644 --- a/scripts/send-announcement.test.mts +++ b/scripts/send-announcement.test.mts @@ -34,6 +34,73 @@ test("announcement escapes content, preserves paragraphs, and links URLs", async assert.ok(!html.includes('href="javascript:')); }); +test("announcement renders section and question headings", async () => { + const html = await renderEmail("announcement", { + title: "Feature announcement", + message: "Intro.\n\n## How it works\n\nFirst paragraph.\n\nSecond paragraph.\r\n\r\n### A question?\r\n\r\nAn answer.", + }); + assert.match(html, /]*font-size:20px[^>]*>How it works<\/h2>/); + assert.match(html, /]*font-weight:700[^>]*>A question\?<\/h3>/); + assert.ok(html.includes("First paragraph.

Second paragraph.")); + assert.doesNotMatch(html, /

/); + assert.ok(!html.includes("## ")); +}); + +test("announcement heading text stays escaped", async () => { + const html = await renderEmail("announcement", { + title: "Feature announcement", + message: '## & news\n\nRead https://www.hackdex.app/faq.', + }); + assert.match(html, /]*><img src=x onerror="alert\(1\)"> & news<\/h2>/); + assert.ok(!html.includes(" { + const html = await renderEmail("announcement", { + title: "Feature announcement", + message: '## Preview\n\n![Form & player preview](https://example.com/form.png?v=1&size=large)\n\n![Code disclosure](https://example.com/code%20disclosure.png)\n\nRead https://www.hackdex.app/faq.', + }); + const screenshots = [...html.matchAll(/]*href="(https:\/\/example\.com\/[^\"]+)"[^>]*>\s*(]*>)\s*<\/a>/g)]; + assert.equal(screenshots.length, 2); + assert.equal(screenshots[0][1], "https://example.com/form.png?v=1&size=large"); + assert.match(screenshots[0][2], /alt="Form & player preview"/); + assert.match(screenshots[1][2], /alt="Code disclosure"/); + for (const [, url, img] of screenshots) { + assert.ok(img.includes(`src="${url}"`)); + assert.match(img, /width:100%/); + assert.match(img, /height:auto/); + } + assert.ok(html.includes('href="https://www.hackdex.app/faq"')); + assert.ok(!html.includes("![Form")); + assert.ok(!html.includes("![Code")); +}); + +test("announcement screenshots cannot inject HTML through alt text", async () => { + const html = await renderEmail("announcement", { + title: "Feature announcement", + message: '![Preview " onerror="alert(1)