diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs new file mode 100644 index 000000000..3501ed2c9 --- /dev/null +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +// The third-party attributions the shipped game must show the player. One list, owned here: the credits screen only +// renders it, and CreditsTests holds it against the licence files in Assets. Unity-free so the headless build checks it. +public class Credit +{ + public string Name; + public string Licence; + // What the licence requires shown: copyright holders, and any change notice. + public string Notice; + // The licence file in the repo that carries the full text and that this entry answers for. + public string LicenceFile; +} + +public static class Credits +{ + public const string TwemojiFile = "Assets/Fonts/Emoji/LICENSE.txt"; + public const string PlaydeadFile = "Assets/Plugins/Playdead/LICENSE.txt"; + public const string NounProjectFile = "Assets/Resources/Sprites/Attribution.txt"; + // Where the player build loads the Noun Project credit lines from (Resources path, no extension). + public const string NounProjectResource = "Sprites/Attribution"; + + // The full list. The Noun Project icon credits are the lines of its attribution file, whose text the caller supplies + // (Unity reads it as a Resources TextAsset; tests read it from disk), so the file stays the one copy of those names. + public static IReadOnlyList All(string nounProjectAttribution) + { + var credits = new List + { + new Credit + { + Name = "Twemoji", + Licence = "CC BY 4.0", + Notice = "Copyright (c) 2022-present Jason Sofonia & Justine De Caires; copyright (c) 2014-2021 Twitter. " + + "Graphics licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). " + + "Converted to a colour font; the artwork itself is not edited.", + LicenceFile = TwemojiFile + }, + new Credit + { + Name = "Playdead temporal anti-aliasing", + Licence = "MIT", + Notice = "Copyright (c) 2015 Playdead", + LicenceFile = PlaydeadFile + }, + new Credit + { + Name = "Noun Project icons", + Licence = "Noun Project attribution licence", + Notice = string.Join("\n", NounProjectLines(nounProjectAttribution)), + LicenceFile = NounProjectFile + } + }; + Validate(credits); + return credits; + } + + // The icon credit lines of the attribution file: everything after its "Noun Project" heading. + public static IEnumerable NounProjectLines(string attribution) => + (attribution ?? "").Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).Skip(1); + + // Throws on an entry a player-facing screen could not honestly show. + public static void Validate(IReadOnlyList credits) + { + foreach (var credit in credits) + if (string.IsNullOrWhiteSpace(credit.Name) || string.IsNullOrWhiteSpace(credit.Licence) || + string.IsNullOrWhiteSpace(credit.Notice) || string.IsNullOrWhiteSpace(credit.LicenceFile)) + throw new InvalidOperationException($"Credit '{credit.Name}' is missing its name, licence, notice or licence file"); + var duplicate = credits.GroupBy(credit => credit.LicenceFile).FirstOrDefault(group => group.Count() > 1); + if (duplicate != null) throw new InvalidOperationException($"Licence file {duplicate.Key} is credited twice"); + } +} diff --git a/Assets/Scripts/ServerShared/Credits.cs.meta b/Assets/Scripts/ServerShared/Credits.cs.meta new file mode 100644 index 000000000..e88730bb6 --- /dev/null +++ b/Assets/Scripts/ServerShared/Credits.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3333d3c0203441fbadd64fad27b60e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 9eef387ca..88600e516 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -136,9 +136,41 @@ public class MainMenu : MonoBehaviour ShowSettings(); Fade(true); }); + _nextMenu.panel.AddButton("Credits", + () => + { + ShowCredits(); + Fade(true); + }); _nextMenu.panel.AddButton("Quit", Application.Quit); } + // The third-party attributions the build must show, one button per entry with its notice in the detail pane. The + // list is Credits.All; this only renders it. + private void ShowCredits() + { + var credits = Credits.All(Resources.Load(Credits.NounProjectResource).text); + Next("credits"); + _nextMenu.detail.gameObject.SetActive(true); + _nextMenu.detail.Title.text = _nextMenu.panel.Title.text; + _nextMenu.detail.Title.color = Color.clear; + var selected = 0; + _nextMenu.detail.AddProperty(() => credits[selected].Name); + _nextMenu.detail.AddProperty(() => credits[selected].Licence); + _nextMenu.detail.AddProperty(() => credits[selected].Notice); + for (var i = 0; i < credits.Count; i++) + { + var index = i; + _nextMenu.panel.AddButton(credits[i].Name, () => selected = index); + } + _nextMenu.panel.AddButton("Back", + () => + { + ShowMain(); + Fade(false); + }); + } + // New Game: every way to set up a run, each with its brief. A release build lists only the modes; the other ways // to set up a run and the test arenas are listed only in editor and development builds. private void ShowScenarios() diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs new file mode 100644 index 000000000..8f2691cf7 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; + +namespace Aetheria.Shared.Tests +{ + // The credits list against the repo's own licence files and the main menu that must reach it. + public class CreditsTests + { + private static string Root() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Assets", "Scripts", "UI", "MainMenu.cs"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); + } + + private static string Read(string relative) => File.ReadAllText(Path.Combine(Root(), relative)); + + private static System.Collections.Generic.IReadOnlyList Load() => Credits.All(Read(Credits.NounProjectFile)); + + [Fact] + public void EveryLicenceFileUnderAssetsIsCredited() + { + var root = Root(); + var found = Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".meta")) + .Where(path => { var name = Path.GetFileName(path); return name.StartsWith("LICENSE", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Attribution", StringComparison.OrdinalIgnoreCase); }) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .ToArray(); + Assert.Contains(Credits.TwemojiFile, found); + var credited = Load().Select(credit => credit.LicenceFile).ToHashSet(); + Assert.Empty(found.Where(path => !credited.Contains(path))); + Assert.Empty(credited.Where(path => !File.Exists(Path.Combine(root, path)))); + } + + [Fact] + public void TwemojiComesFirstWithTheHoldersItsLicenceNames() + { + var twemoji = Load()[0]; + Assert.Equal("Twemoji", twemoji.Name); + Assert.Contains("CC BY 4.0", twemoji.Licence); + Assert.Contains("Jason Sofonia & Justine De Caires", Read(Credits.TwemojiFile)); + Assert.Contains("Jason Sofonia & Justine De Caires", twemoji.Notice); + Assert.Contains("Twitter", twemoji.Notice); + } + + [Fact] + public void EveryIconCreditInTheAttributionFileIsShown() + { + var lines = Credits.NounProjectLines(Read(Credits.NounProjectFile)).ToArray(); + Assert.True(lines.Length > 50); + var notice = Load().Single(credit => credit.LicenceFile == Credits.NounProjectFile).Notice.Split('\n'); + Assert.Equal(lines, notice); + } + + [Fact] + public void EntriesNameTheirLicenceAndHolder() + { + foreach (var credit in Load()) + { + Assert.False(string.IsNullOrWhiteSpace(credit.Name)); + Assert.False(string.IsNullOrWhiteSpace(credit.Licence)); + Assert.False(string.IsNullOrWhiteSpace(credit.Notice)); + } + Assert.Contains("Playdead", Read(Credits.PlaydeadFile)); + Assert.Contains("Playdead", Load().Single(credit => credit.LicenceFile == Credits.PlaydeadFile).Notice); + } + + [Fact] + public void AnEmptyEntryIsRefused() + { + Assert.Throws(() => Credits.Validate(new[] { new Credit { Name = "x", Licence = "", Notice = "n", LicenceFile = "f" } })); + } + + // The Unity menu is not compilable headlessly, so this reads its source: the main menu must offer Credits and + // the Credits screen must render the Shared list. It proves the wiring exists, not that the screen draws. + [Fact] + public void MainMenuOffersACreditsPathThatRendersTheList() + { + var menu = Read("Assets/Scripts/UI/MainMenu.cs"); + var main = menu.Substring(menu.IndexOf("private void ShowMain()", StringComparison.Ordinal)); + main = main.Substring(0, main.IndexOf("private void ShowScenarios()", StringComparison.Ordinal)); + Assert.Contains("AddButton(\"Credits\"", main); + Assert.Contains("ShowCredits()", main); + Assert.Contains("Credits.All(", menu); + } + } +}