From fde0787e767cb7699c859570c9db5f170d246456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 19:44:38 +0700 Subject: [PATCH 01/61] Localization: Lao support, quoted-CSV values, dynamic font atlas Ports the localization work from the Boy branch and fixes three things that would have blocked Module 3, which is far more text-heavy than Modules 1 and 2. - LocalizationManager now parses RFC-4180 CSV over the whole file rather than splitting on commas. Module 3 dialogue is full sentences ('she has been hot for three days, since Monday'), every one of which contains a comma. Quoted values may also contain line breaks. - Adds a Lao column alongside English, Vietnamese and French; adds LocalizedKey (accepts world-space TextMeshPro, not only TextMeshProUGUI) and ColorTexts. - Switches the Lao font asset to a dynamic atlas backed by Lao_SomVang.ttf. It was static with only 64 glyphs baked and 12 ASCII characters, enough for four menu strings and nothing else; the TTF carries all 95 printable ASCII and 87 Lao codepoints, so any string now renders. - Language choice persists in PlayerPrefs; GetLocalizedValue takes format args. LocalizedKey also had a discarded GetComponent call and the manager defaulted to a language the CSV did not contain. --- .../Fonts/Lao/Lao_SomVang SDF_Custom.asset | 6 +- .../Resources/Localization/ColorTexts.asset | 17 ++ .../Localization/ColorTexts.asset.meta | 8 + .../Localization/LocalizationData.csv | 9 +- .../Localization/ColorTexts.cs | 15 ++ .../Localization/ColorTexts.cs.meta | 2 + .../Localization/LocalizationManager.cs | 195 ++++++++++++++++-- .../Localization/LocalizedKey.cs | 84 ++++++++ .../Localization/LocalizedKey.cs.meta | 2 + .../Scripts by Simple/Localization/README.md | 36 +++- 10 files changed, 343 insertions(+), 31 deletions(-) create mode 100644 Assets/Resources/Localization/ColorTexts.asset create mode 100644 Assets/Resources/Localization/ColorTexts.asset.meta create mode 100644 Assets/Scripts/Scripts by Simple/Localization/ColorTexts.cs create mode 100644 Assets/Scripts/Scripts by Simple/Localization/ColorTexts.cs.meta create mode 100644 Assets/Scripts/Scripts by Simple/Localization/LocalizedKey.cs create mode 100644 Assets/Scripts/Scripts by Simple/Localization/LocalizedKey.cs.meta diff --git a/Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset b/Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset index 0e1f36ce..d579fdcc 100644 --- a/Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset +++ b/Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset @@ -212,9 +212,9 @@ MonoBehaviour: fontStyleModifier: 0 renderMode: 4165 includeFontFeatures: 0 - m_SourceFontFile: {fileID: 0} + m_SourceFontFile: {fileID: 12800000, guid: e40d793bbd2ea314699bf186daf54782, type: 3} m_SourceFontFilePath: - m_AtlasPopulationMode: 0 + m_AtlasPopulationMode: 1 InternalDynamicOS: 0 m_GlyphTable: - m_Index: 12 @@ -1361,7 +1361,7 @@ MonoBehaviour: m_AtlasTextures: - {fileID: -1891868753862136913} m_AtlasTextureIndex: 0 - m_IsMultiAtlasTexturesEnabled: 0 + m_IsMultiAtlasTexturesEnabled: 1 m_GetFontFeatures: 1 m_ClearDynamicDataOnBuild: 0 m_AtlasWidth: 1024 diff --git a/Assets/Resources/Localization/ColorTexts.asset b/Assets/Resources/Localization/ColorTexts.asset new file mode 100644 index 00000000..c65c463b --- /dev/null +++ b/Assets/Resources/Localization/ColorTexts.asset @@ -0,0 +1,17 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 413f1bf5c8b1ad34581f6e397b3e5264, type: 3} + m_Name: ColorTexts + m_EditorClassIdentifier: Assembly-CSharp::ColorTexts + colorTexts: + - text: Training Section + color: {r: 1, g: 0, b: 0, a: 1} diff --git a/Assets/Resources/Localization/ColorTexts.asset.meta b/Assets/Resources/Localization/ColorTexts.asset.meta new file mode 100644 index 00000000..0052513e --- /dev/null +++ b/Assets/Resources/Localization/ColorTexts.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5f0dc1b09e9941549a0778fb58221b93 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Localization/LocalizationData.csv b/Assets/Resources/Localization/LocalizationData.csv index 2d88f2a7..dfce0865 100644 --- a/Assets/Resources/Localization/LocalizationData.csv +++ b/Assets/Resources/Localization/LocalizationData.csv @@ -1,4 +1,5 @@ -Key , English, Vietnamese, French -mainMenu.start , Start , Bắt đầu , Commencer -mainMenu.end , End , Kết thúc , Fin -mainMenu.options, Options, Tùy chọn , Options +Key,English,Lao,Vietnamese,French +mainMenu.start,Start,ເລິ່ມ,Bắt đầu,Commencer +mainMenu.end,End,ຈົບ,Kết thúc,Fin +mainMenu.options,Options,ຕົວເລືອກ,Tùy chọn,Options +human.Data.bodytemperature,BodyTemp,ອຸນຫະພູມ,, diff --git a/Assets/Scripts/Scripts by Simple/Localization/ColorTexts.cs b/Assets/Scripts/Scripts by Simple/Localization/ColorTexts.cs new file mode 100644 index 00000000..11480f9b --- /dev/null +++ b/Assets/Scripts/Scripts by Simple/Localization/ColorTexts.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using UnityEngine; + +[CreateAssetMenu(fileName = "ColorTexts", menuName = "Localization/Color Texts")] +public class ColorTexts: ScriptableObject +{ + public List colorTexts = new List(); +} + +[System.Serializable] +public class ColorText +{ + public string text; + public Color color = Color.white; +} \ No newline at end of file diff --git a/Assets/Scripts/Scripts by Simple/Localization/ColorTexts.cs.meta b/Assets/Scripts/Scripts by Simple/Localization/ColorTexts.cs.meta new file mode 100644 index 00000000..4b66de08 --- /dev/null +++ b/Assets/Scripts/Scripts by Simple/Localization/ColorTexts.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 413f1bf5c8b1ad34581f6e397b3e5264 \ No newline at end of file diff --git a/Assets/Scripts/Scripts by Simple/Localization/LocalizationManager.cs b/Assets/Scripts/Scripts by Simple/Localization/LocalizationManager.cs index 30fcb7c7..358f6e48 100644 --- a/Assets/Scripts/Scripts by Simple/Localization/LocalizationManager.cs +++ b/Assets/Scripts/Scripts by Simple/Localization/LocalizationManager.cs @@ -1,20 +1,26 @@ using UnityEngine; using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; public class LocalizationManager : MonoBehaviour { public static LocalizationManager Instance { get; private set; } // The path to the CSV file within any "Resources" folder. - private const string CsvFilePath = "Localization/LocalizationData"; + private const string CsvFilePath = "Localization/LocalizationData"; + private const string ColorTextsPath = "Localization/ColorTexts"; + private const string LanguagePref = "Language"; // Data structure: Dictionary> private Dictionary> localizedData; - private string currentLanguage = "English"; + [SerializeField] private string currentLanguage = "English"; public delegate void LanguageChanged(); public static event LanguageChanged OnLanguageChanged; + private List colorTexts = new List(); + private void Awake() { if (Instance == null) @@ -22,6 +28,7 @@ private void Awake() Instance = this; DontDestroyOnLoad(gameObject); LoadLocalizationData(); + SetLanguage(PlayerPrefs.GetString(LanguagePref, currentLanguage)); } else { @@ -32,7 +39,7 @@ private void Awake() private void LoadLocalizationData() { localizedData = new Dictionary>(); - + // Load the TextAsset from the Resources folder. TextAsset csvFile = Resources.Load(CsvFilePath); @@ -42,39 +49,158 @@ private void LoadLocalizationData() return; } - // Split the file into lines. - string[] lines = csvFile.text.Split(new[] { '\r', '\n' }, System.StringSplitOptions.RemoveEmptyEntries); - if (lines.Length < 2) return; + ColorTexts autoColors = Resources.Load(ColorTextsPath); + if (autoColors != null) + { + colorTexts = autoColors.colorTexts; + } - // Parse the header to get language names. - string[] headers = lines[0].Split(','); - for (int i = 1; i < headers.Length; i++) + List> rows = ParseCsv(csvFile.text); + if (rows.Count < 2) return; + + // The first row names the languages: Key, English, Lao, ... + List headers = rows[0]; + for (int i = 1; i < headers.Count; i++) { - localizedData[headers[i].Trim()] = new Dictionary(); + string language = headers[i].Trim(); + if (language.Length == 0) continue; + localizedData[language] = new Dictionary(); } - // Parse each data row. - for (int i = 1; i < lines.Length; i++) + for (int r = 1; r < rows.Count; r++) { - string[] values = lines[i].Split(','); + List values = rows[r]; + if (values.Count == 0) continue; + string key = values[0].Trim(); + if (key.Length == 0 || key.StartsWith("#")) continue; // blank row or comment + + for (int c = 1; c < values.Count && c < headers.Count; c++) + { + string language = headers[c].Trim(); + if (language.Length == 0 || !localizedData.ContainsKey(language)) continue; + + localizedData[language][key] = ApplyAutoColors(values[c].Trim()); + } + } + + Debug.Log($"Localization data loaded: {rows.Count - 1} keys, {localizedData.Count} languages."); + } + + /// + /// RFC-4180 style CSV parse over the whole file at once. + /// Values may contain commas and line breaks as long as they are quoted, and a literal + /// double quote is written as "". Parsing the whole text (rather than splitting on newlines + /// first) is what makes multi-line values safe - Module 3 dialogue needs both. + /// + private static List> ParseCsv(string text) + { + var rows = new List>(); + var row = new List(); + var field = new StringBuilder(); + bool inQuotes = false; + bool rowHasContent = false; + + void EndField() + { + row.Add(field.ToString()); + field.Clear(); + } + + void EndRow() + { + EndField(); + rows.Add(row); + row = new List(); + rowHasContent = false; + } + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + + if (inQuotes) + { + if (c == '"') + { + if (i + 1 < text.Length && text[i + 1] == '"') + { + field.Append('"'); + i++; // skip the escaped quote + } + else + { + inQuotes = false; + } + } + else + { + field.Append(c); + } + continue; + } - for (int j = 1; j < values.Length && j < headers.Length; j++) + switch (c) { - string language = headers[j].Trim(); - string value = values[j].Trim(); - localizedData[language][key] = value; + case '"': + inQuotes = true; + rowHasContent = true; + break; + case ',': + EndField(); + rowHasContent = true; + break; + case '\r': + break; // handled by the \n that follows, or ignored on its own + case '\n': + if (rowHasContent || field.Length > 0) EndRow(); + break; + default: + field.Append(c); + if (c != ' ') rowHasContent = true; + break; } } - Debug.Log("Localization data loaded."); + if (rowHasContent || field.Length > 0) EndRow(); + + // A UTF-8 BOM on the first cell would otherwise become part of the first key. + if (rows.Count > 0 && rows[0].Count > 0) + { + rows[0][0] = rows[0][0].TrimStart(''); + } + + return rows; + } + + private string ApplyAutoColors(string originalText) + { + if (string.IsNullOrEmpty(originalText) || colorTexts.Count == 0) + return originalText; + + string modifiedText = originalText; + + foreach (var text in colorTexts) + { + if (string.IsNullOrEmpty(text.text)) continue; + + string hexColor = "#" + ColorUtility.ToHtmlStringRGB(text.color); + string pattern = Regex.Escape(text.text); + + // $0 re-inserts the matched text verbatim, so the original casing survives. + modifiedText = Regex.Replace(modifiedText, pattern, + $"$0", RegexOptions.IgnoreCase); + } + + return modifiedText; } public void SetLanguage(string languageName) { - if (localizedData.ContainsKey(languageName)) + if (localizedData != null && localizedData.ContainsKey(languageName)) { currentLanguage = languageName; + PlayerPrefs.SetString(LanguagePref, currentLanguage); OnLanguageChanged?.Invoke(); Debug.Log($"Language changed to: {currentLanguage}"); } @@ -84,9 +210,26 @@ public void SetLanguage(string languageName) } } + public string GetLanguage() + { + return currentLanguage; + } + + public IEnumerable GetLanguages() + { + return localizedData != null ? (IEnumerable)localizedData.Keys : new List(); + } + + public bool HasKey(string key) + { + return localizedData != null + && localizedData.ContainsKey(currentLanguage) + && localizedData[currentLanguage].ContainsKey(key); + } + public string GetLocalizedValue(string key) { - if (localizedData.ContainsKey(currentLanguage) && localizedData[currentLanguage].ContainsKey(key)) + if (HasKey(key)) { return localizedData[currentLanguage][key]; } @@ -94,4 +237,14 @@ public string GetLocalizedValue(string key) Debug.LogWarning($"Localization key '{key}' not found for language '{currentLanguage}'."); return key; } -} \ No newline at end of file + + /// + /// Substitutes {0}, {1}, ... into the localized string. Module 3 needs this for lines like + /// "she has been hot for {0} days". + /// + public string GetLocalizedValue(string key, params object[] args) + { + string value = GetLocalizedValue(key); + return args == null || args.Length == 0 ? value : string.Format(value, args); + } +} diff --git a/Assets/Scripts/Scripts by Simple/Localization/LocalizedKey.cs b/Assets/Scripts/Scripts by Simple/Localization/LocalizedKey.cs new file mode 100644 index 00000000..abe7fdbc --- /dev/null +++ b/Assets/Scripts/Scripts by Simple/Localization/LocalizedKey.cs @@ -0,0 +1,84 @@ +using UnityEngine; +using TMPro; + +/// +/// Drives a TMP label (and optionally a voice-over clip) from a localization key. +/// Supersedes : it accepts world-space TextMeshPro as well as +/// TextMeshProUGUI, which Module 3 needs for the in-world household panels. +/// +public class LocalizedKey : MonoBehaviour +{ + public string localizationKey; + public AudioSource audioSource; + public TMP_Text textComponent; + + private void Awake() + { + if (textComponent == null) textComponent = GetComponent(); + } + + private void Start() + { + UpdateText(); + LocalizationManager.OnLanguageChanged += UpdateText; + + if (audioSource != null) + { + UpdateAudioClip(); + LocalizationManager.OnLanguageChanged += UpdateAudioClip; + } + } + + private void OnDestroy() + { + LocalizationManager.OnLanguageChanged -= UpdateText; + if (audioSource != null) + { + LocalizationManager.OnLanguageChanged -= UpdateAudioClip; + } + } + + /// Swaps the key at runtime, e.g. when a villager's dialogue line changes. + public void SetKey(string key) + { + localizationKey = key; + UpdateText(); + if (audioSource != null) UpdateAudioClip(); + } + + public void UpdateText() + { + if (string.IsNullOrEmpty(localizationKey)) return; + + if (textComponent == null) + { + Debug.LogWarning("No TMP_Text component found on " + gameObject.name); + return; + } + + string localizedText = LocalizationManager.Instance.GetLocalizedValue(localizationKey); + if (!string.IsNullOrEmpty(localizedText)) + { + textComponent.text = localizedText; + } + } + + public void UpdateAudioClip() + { + if (string.IsNullOrEmpty(localizationKey)) return; + + string currentLanguage = LocalizationManager.Instance.GetLanguage(); + string audioClipPath = $"Localization/Audio/{currentLanguage}/{localizationKey}"; + + AudioClip loadedClip = Resources.Load(audioClipPath); + + if (loadedClip != null) + { + audioSource.clip = loadedClip; + } + else + { + Debug.LogWarning($"AudioClip not found at path: '{audioClipPath}'"); + } + } +} diff --git a/Assets/Scripts/Scripts by Simple/Localization/LocalizedKey.cs.meta b/Assets/Scripts/Scripts by Simple/Localization/LocalizedKey.cs.meta new file mode 100644 index 00000000..57382fc9 --- /dev/null +++ b/Assets/Scripts/Scripts by Simple/Localization/LocalizedKey.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f61b5f9c7b96dc45a5e18ecf511db65 \ No newline at end of file diff --git a/Assets/Scripts/Scripts by Simple/Localization/README.md b/Assets/Scripts/Scripts by Simple/Localization/README.md index b1ff0b2a..fa880ea0 100644 --- a/Assets/Scripts/Scripts by Simple/Localization/README.md +++ b/Assets/Scripts/Scripts by Simple/Localization/README.md @@ -1,5 +1,35 @@ ### Localization System for SIMPLE Unity template -1. Create/update language data: Edit the LocalizationData.csv in the Resources/Localization using the correct format. (DO NOT USE "," "/n" "/r" FOR VALUE) -2. Make text switch language based on the current setting: Require TextMeshPro. Make sure LocalizationManager (DontDestroyOnLoad) is in your first scene, add the component LocalizedText to that text gameobject and add the key for that text. -3. Change current language: call LocalizationManager.Instance.SetLanguage("language_name");. Make sure all language names and key names are the same as their corresponding ones in the csv file. \ No newline at end of file +1. **Language data** — edit `Assets/Resources/Localization/LocalizationData.csv`. The first column + is the key, every other column is a language named in the header row. + Values **may** contain commas, line breaks and quotes, as long as the value is wrapped in + double quotes and a literal quote is written as `""`: + + ```csv + Key,English,Lao + villager.fever3days,"She has been hot for three days, since Monday.",... + ``` + + A row whose key starts with `#` is treated as a comment and skipped. + +2. **Make a text switch language** — add `LocalizedKey` to the GameObject carrying a `TMP_Text` + (either `TextMeshProUGUI` or a world-space `TextMeshPro`) and set the key. Optionally assign an + `AudioSource` and the component will also load + `Resources/Localization/Audio//` as voice-over. + `LocalizedText` is the older UGUI-only equivalent and is kept for existing scenes. + +3. **Change language** — `LocalizationManager.Instance.SetLanguage("Lao");`. The choice is + remembered in `PlayerPrefs["Language"]`. `GetLanguages()` enumerates what the CSV offers. + +4. **Runtime substitution** — `GetLocalizedValue("key", arg0, arg1)` runs `string.Format` over the + value, for lines like `"she has been hot for {0} days"`. + +5. **Auto-colouring** — `Resources/Localization/ColorTexts.asset` lists words that should always be + tinted (warning signs, for instance). They are wrapped in `` at load time. + +6. **Fonts** — Lao renders through + `Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset`. + It is a **dynamic** atlas backed by `Lao_SomVang.ttf`, so any Lao or Latin glyph is rasterised + on demand rather than being limited to a pre-baked set. + +Make sure a `LocalizationManager` (it is `DontDestroyOnLoad`) exists in the first scene. From c00b82904f8fbc1d3f9d0cf2ae506237d1b2dcc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 19:44:53 +0700 Subject: [PATCH 02/61] Module 3: deterministic epidemic model and its calibration suite The spine of Module 3. A headless, seeded, reproducible transmission model with no UnityEngine dependency, plus the tests that hold it to the design's teaching goals. Why headless: section 5 of the design requires that, before any score is shown, the game replays what would have happened had the first patient been covered on day one. A counterfactual is only honest if the outbreak can be re-run from a seed with one input changed, which is impossible if the epidemic lives in Update(), coroutines and UnityEngine.Random. Random draws are keyed on (seed, day, stream, entity) rather than taken from a running sequence, so adding one net does not shift everybody else's draws and the two runs stay comparable. Mosquitoes are individuals, not per-household counts, because the trace-back replay has to name the source house, the container the mosquito came from and the night the patient slept unprotected. Only an individual carries that provenance. The tests encode section 5's 'designing it so the lesson cannot be avoided' as assertions. Over 200 seeds: do nothing 14.0 secondary cases cleared every container, no nets 10.5 still an outbreak net the well 13.6 no better than nothing net the first two patients, day 1 5.4 61% reduction net every visible patient 4.7 69% of the residue from invisible sources The assertions are relative rather than absolute so they survive re-calibration while still catching a parameter set that has stopped teaching the lesson. One deviation from CLAUDE.md, which says no assembly definitions for game code: the Sim folder gets one. It is safe because the assembly is a leaf that references nothing, Assembly-CSharp auto-references asmdefs so every MonoBehaviour can still use these types, and it is what makes NUnit tests possible - the first in this repo. Also adds ReclaimNet: a net can be carried on to the next patient, so the shortage that section 5 requires is about where the squad's attention goes rather than about a warehouse. --- Assets/1.TeamWorkspace/Module3.meta | 8 + Assets/1.TeamWorkspace/Module3/Data.meta | 8 + Assets/1.TeamWorkspace/Module3/Prefabs.meta | 8 + Assets/1.TeamWorkspace/Module3/Scenes.meta | 8 + Assets/1.TeamWorkspace/Module3/Scripts.meta | 8 + Assets/1.TeamWorkspace/Module3/Sim.meta | 8 + .../Module3/Sim/AEDES.Module3.Sim.asmdef | 14 + .../Module3/Sim/AEDES.Module3.Sim.asmdef.meta | 7 + Assets/1.TeamWorkspace/Module3/Sim/Csv.cs | 76 +++ .../1.TeamWorkspace/Module3/Sim/Csv.cs.meta | 11 + .../1.TeamWorkspace/Module3/Sim/Household.cs | 67 ++ .../Module3/Sim/Household.cs.meta | 11 + .../Module3/Sim/Module3Config.cs | 152 +++++ .../Module3/Sim/Module3Config.cs.meta | 11 + .../1.TeamWorkspace/Module3/Sim/Mosquito.cs | 37 ++ .../Module3/Sim/Mosquito.cs.meta | 11 + .../Module3/Sim/Neighbourhood.cs | 620 ++++++++++++++++++ .../Module3/Sim/Neighbourhood.cs.meta | 11 + .../Module3/Sim/ParameterSet.cs | 535 +++++++++++++++ .../Module3/Sim/ParameterSet.cs.meta | 11 + .../Module3/Sim/ParameterTimeline.cs | 101 +++ .../Module3/Sim/ParameterTimeline.cs.meta | 11 + Assets/1.TeamWorkspace/Module3/Sim/Person.cs | 108 +++ .../Module3/Sim/Person.cs.meta | 11 + .../Module3/Sim/PlayerAction.cs | 60 ++ .../Module3/Sim/PlayerAction.cs.meta | 11 + Assets/1.TeamWorkspace/Module3/Sim/Rng.cs | 78 +++ .../1.TeamWorkspace/Module3/Sim/Rng.cs.meta | 11 + .../Module3/Sim/ScenarioBuilder.cs | 242 +++++++ .../Module3/Sim/ScenarioBuilder.cs.meta | 11 + .../1.TeamWorkspace/Module3/Sim/SimRunner.cs | 93 +++ .../Module3/Sim/SimRunner.cs.meta | 11 + .../1.TeamWorkspace/Module3/Sim/Strategies.cs | 140 ++++ .../Module3/Sim/Strategies.cs.meta | 11 + .../Module3/Sim/TransmissionEvent.cs | 95 +++ .../Module3/Sim/TransmissionEvent.cs.meta | 11 + Assets/1.TeamWorkspace/Module3/Tests.meta | 8 + .../Tests/AEDES.Module3.Sim.Tests.asmdef | 24 + .../Tests/AEDES.Module3.Sim.Tests.asmdef.meta | 7 + .../Module3/Tests/Module3CalibrationTests.cs | 280 ++++++++ .../Tests/Module3CalibrationTests.cs.meta | 11 + .../Module3/Tests/ParameterCsvTests.cs | 139 ++++ .../Module3/Tests/ParameterCsvTests.cs.meta | 11 + 43 files changed, 3107 insertions(+) create mode 100644 Assets/1.TeamWorkspace/Module3.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Data.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Prefabs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scenes.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Csv.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Csv.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Household.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Household.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/ParameterTimeline.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/ParameterTimeline.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Person.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Person.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/PlayerAction.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/PlayerAction.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Rng.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Rng.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/ScenarioBuilder.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/ScenarioBuilder.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/SimRunner.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/SimRunner.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Strategies.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Strategies.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/TransmissionEvent.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/TransmissionEvent.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/Module3CalibrationTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/Module3CalibrationTests.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/ParameterCsvTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/ParameterCsvTests.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3.meta b/Assets/1.TeamWorkspace/Module3.meta new file mode 100644 index 00000000..7c4eb68e --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6bd536eb5d5f4293ae3cff215920e224 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Data.meta b/Assets/1.TeamWorkspace/Module3/Data.meta new file mode 100644 index 00000000..4b463c2b --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Data.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 69e4283a1a2047f5ba9d35c7d4c73d80 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Prefabs.meta b/Assets/1.TeamWorkspace/Module3/Prefabs.meta new file mode 100644 index 00000000..11de4f1e --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: faf92a3240ef47fd82bd80b906369726 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scenes.meta b/Assets/1.TeamWorkspace/Module3/Scenes.meta new file mode 100644 index 00000000..bfbff9fc --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scenes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e63ac22e1b9d4a7c87ce41b4028b4be6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scripts.meta b/Assets/1.TeamWorkspace/Module3/Scripts.meta new file mode 100644 index 00000000..82569ecc --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3de7504a150c49f3811b639c1025adf5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim.meta b/Assets/1.TeamWorkspace/Module3/Sim.meta new file mode 100644 index 00000000..22846be9 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ab2b49482fb244b8a4b2943c7b8ef8b4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef b/Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef new file mode 100644 index 00000000..b9ccfd7e --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef @@ -0,0 +1,14 @@ +{ + "name": "AEDES.Module3.Sim", + "rootNamespace": "Aedes.Module3.Sim", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef.meta b/Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef.meta new file mode 100644 index 00000000..d4e9339e --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8a92997bee8b45eba41e6b8a8079c421 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Csv.cs b/Assets/1.TeamWorkspace/Module3/Sim/Csv.cs new file mode 100644 index 00000000..eba96286 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Csv.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Text; + +namespace Aedes.Module3.Sim +{ + /// + /// A minimal RFC-4180 reader. The Sim assembly references nothing - not even UnityEngine - + /// so it cannot borrow the project's other CSV parser, and 60 lines is a cheaper price than + /// a dependency that would stop the model being testable outside the editor. + /// + public static class Csv + { + public static List> Parse(string text) + { + var rows = new List>(); + if (string.IsNullOrEmpty(text)) return rows; + + var row = new List(); + var field = new StringBuilder(); + bool inQuotes = false; + bool started = false; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + + if (inQuotes) + { + if (c == '"') + { + if (i + 1 < text.Length && text[i + 1] == '"') { field.Append('"'); i++; } + else inQuotes = false; + } + else field.Append(c); + continue; + } + + switch (c) + { + case '"': inQuotes = true; started = true; break; + case ',': row.Add(field.ToString()); field.Clear(); started = true; break; + case '\r': break; + case '\n': + if (started || field.Length > 0) + { + row.Add(field.ToString()); field.Clear(); + rows.Add(row); row = new List(); started = false; + } + break; + default: + field.Append(c); + if (c != ' ') started = true; + break; + } + } + + if (started || field.Length > 0) + { + row.Add(field.ToString()); + rows.Add(row); + } + + if (rows.Count > 0 && rows[0].Count > 0) rows[0][0] = rows[0][0].TrimStart(''); + return rows; + } + + public static string Quote(string value) + { + if (string.IsNullOrEmpty(value)) return ""; + bool needs = value.IndexOf(',') >= 0 || value.IndexOf('"') >= 0 + || value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0; + if (!needs) return value; + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Csv.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Csv.cs.meta new file mode 100644 index 00000000..43fb455e --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Csv.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ea3bf9505cb4d70b3af868815e0678b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Household.cs b/Assets/1.TeamWorkspace/Module3/Sim/Household.cs new file mode 100644 index 00000000..2a8b3aa0 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Household.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + public enum ScreenState { Missing, Torn, Intact } + + /// + /// The container kinds are Module 2's set (design section 11), reused deliberately. + /// They are NOT a task in Module 3 - section 5: "not a button, not a task, not a score". + /// They exist to set the mosquito population and to be named in the trace-back replay. + /// + public enum ContainerKind { WaterJar, Tyre, Bucket, PlantPot, DiscardedPlastic, RoofGutter } + + public sealed class Container + { + public int Id; + public int HouseholdId; + public ContainerKind Kind; + + /// + /// Set by what the squad left behind in Module 2 (section 5, and open question 3b). + /// A cleared container produces nothing; it is still there to be seen. + /// + public bool ClearedInModule2; + + public bool IsProductive => !ClearedInModule2; + } + + public sealed class Household + { + public int Id; + public string Name; + + /// Plot position along the lanes. Only used for adjacency. + public int LaneX; + public int LaneY; + + public readonly List ResidentIds = new List(); + public readonly List Containers = new List(); + + /// Houses close enough for a mosquito to reach. Section 5: the virus moves between + /// neighbours, not across a district. + public readonly List NeighbourIds = new List(); + + public ScreenState Screens = ScreenState.Torn; + public bool HasFan; + public bool HasAirConditioning; + public bool ClosesDuringBitingHours; + + /// Turn bookkeeping so the Coach can say which houses nobody has been to. + public int LastVisitedDay = -1; + public int VisitCount; + + public int ProductiveContainerCount + { + get + { + int n = 0; + for (int i = 0; i < Containers.Count; i++) + { + if (Containers[i].IsProductive) n++; + } + return n; + } + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Household.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Household.cs.meta new file mode 100644 index 00000000..40ed8483 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Household.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83f9ad31a54d412ca92f757c1406d12f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs new file mode 100644 index 00000000..81f599c2 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs @@ -0,0 +1,152 @@ +namespace Aedes.Module3.Sim +{ + /// + /// Every clinical and entomological number the model uses, in one place. + /// + /// Section 6 of the design says the ranking of protective measures will be reviewed by + /// Dr Marcombe and endorsed by CMPE, and section 5 leaves the asymptomatic share and the + /// infectious window open. So none of these are constants in code: they are data, and the + /// calibration tests re-check the module's teaching guarantees whenever they change. + /// + /// Timings are NOT compressed. Section 8 is explicit that the compression lives in the + /// calendar - the game skips days at the handover - so the numbers a student learns stay true. + /// + public sealed class Module3Config + { + // ======================================================================================= + // CLINICAL / ENTOMOLOGICAL. These describe the disease and must not be moved to make the + // game work. Section 8: "The compression is in the calendar, not in the biology." They + // change only on Dr Marcombe's advice and with CMPE's endorsement. + // ======================================================================================= + + // ---- Human course of infection (days) ------------------------------------------------- + /// Intrinsic incubation: bite to symptoms. Design section 5: "four to ten days". + public int IncubationMinDays = 4; + public int IncubationMaxDays = 10; + + /// Fever duration once symptoms begin. + public int FeverMinDays = 4; + public int FeverMaxDays = 7; + + /// Viraemia starts shortly before symptoms and runs into the febrile period. + /// Pending Marcombe: "confirm the period during which a patient is infectious to mosquitoes, + /// since that determines when netting them actually matters." + public int InfectiousDaysBeforeOnset = 1; + public int InfectiousDaysAfterOnset = 5; + + /// Section 5: "commonly estimated at half to three quarters" never feel ill. + public double AsymptomaticShare = 0.6; + public int AsymptomaticInfectiousDays = 5; + + /// + /// How infectious to mosquitoes someone with no symptoms is, relative to someone in the + /// thick of a fever. Section 5: "Netting the visible patients still removes the largest + /// and most infectious contributors, because virus levels peak during the fever." + /// + /// This one number is what makes covering the visible patients the strongest action in + /// the module while still leaving the residue that sends the debrief back to Module 2. + /// Set it to 1.0 and the lesson disappears, which is why a calibration test guards it. + /// + public double AsymptomaticRelativeInfectiousness = 0.35; + + /// Share of symptomatic cases that develop a warning sign (bleeding / persistent vomiting). + public double WarningSignShare = 0.08; + + /// Section 8: "Danger often arrives as the fever comes down." Warning signs appear around + /// defervescence, which is what punishes a Pilot who sees someone improving and moves on. + public int WarningSignOnsetOffsetMin = -1; + public int WarningSignOnsetOffsetMax = 1; + + /// How long an unreferred warning sign goes before the household takes itself to hospital. + /// Section 10: this is a consequence shown to the player, never a deduction. + public int UnreferredWarningGraceDays = 2; + + // ---- Extrinsic incubation (clinical) --------------------------------------------------- + /// Extrinsic incubation: "eight to twelve days before a mosquito that has fed on a case + /// can transmit" (design section 5). + public int ExtrinsicIncubationMinDays = 8; + public int ExtrinsicIncubationMaxDays = 12; + + // ======================================================================================= + // POPULATION RATES. Tuning knobs, not clinical claims. They set how fast an outbreak runs + // through a forty-person neighbourhood over a fifty-day session, so that a squad can see + // the difference their choices made inside one classroom period. The calibration tests + // are what keep them honest: change one and the module's teaching guarantees are re-checked. + // ======================================================================================= + + public int MosquitoLifespanMinDays = 10; + public int MosquitoLifespanMaxDays = 28; + + /// Adult mosquitoes emerging per productive container per day. + public double EmergencePerContainerPerDay = 0.9; + + /// Cap so a neglected neighbourhood stays playable rather than exploding. + public int MaxMosquitoesPerHousehold = 80; + + /// + /// Days' worth of emergence already on the wing when the squad arrives. Section 5: + /// "The mosquitoes are already flying and the virus is already in the neighbourhood." + /// Module 3 does not start from an empty sky. + /// + public int StandingPopulationDays = 10; + + /// Probability a given mosquito takes a blood meal on a given day. + // Tuned so a do-nothing neighbourhood sees a clearly growing outbreak that still leaves + /// most people well - an epidemic that infects everyone regardless of what the squad does + /// teaches nothing, because every strategy then looks the same. + public double DailyBiteProbability = 0.34; + + /// Aedes aegypti disperses very little - section 5: "commonly no more than about a hundred + /// metres in its lifetime, often only a few houses". This is why covering one patient + /// matters to the houses either side of them. + public double DailyDispersalProbability = 0.10; + + public double ProbabilityHumanToMosquito = 0.65; + public double ProbabilityMosquitoToHuman = 0.45; + + // ---- Protective measures (section 6) -------------------------------------------------- + // Multipliers on a person's chance of being bitten. Lower is better. The ORDER of these + // is the ranking the module teaches, so it carries weight and needs endorsing. + + /// A net over a RESTING patient. Section 6: this works "for a particular reason: Aedes + /// bites during the day, and the patient is in bed during the day." + public double NetOverRestingPerson = 0.10; + + /// The same net over someone who is up and about. It barely helps - they are not under it + /// when the biting happens. This single distinction is what makes netting the sick + /// dramatically stronger than netting the well, without any of it being scripted. + public double NetOverActivePerson = 0.92; + + public double IntactScreen = 0.40; + + /// A torn screen is very nearly no screen at all - which is exactly why noticing and + /// repairing one is worth the player's time (section 6). + public double TornScreen = 0.88; + + public double ClosedDuringBitingHours = 0.75; + public double ElectricFan = 0.70; + public double AirConditioning = 0.50; + public double Repellent = 0.60; + public int RepellentDurationDays = 1; + public double LongSleeves = 0.85; + + // ---- Session shape -------------------------------------------------------------------- + /// Section 5: "fewer than there are households. The player must choose who gets one, and + /// the choice is the whole point." + public int NetsPerSession = 4; + + /// Section 8: to be fixed in playtesting between two and four minutes. + public float TurnSeconds = 180f; + public int TurnsPerRound = 3; + public int Rounds = 6; + /// Days the calendar jumps at each handover ("Three days later."). + public int DaysPerHandover = 3; + + public static Module3Config Default => new Module3Config(); + + public Module3Config Clone() + { + return (Module3Config)MemberwiseClone(); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs.meta new file mode 100644 index 00000000..4a9cb5b8 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ea7ce31b80c44e286756d446e8d7c34 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs b/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs new file mode 100644 index 00000000..e1b7e2af --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs @@ -0,0 +1,37 @@ +namespace Aedes.Module3.Sim +{ + public enum MosquitoState { Susceptible, Exposed, Infectious } + + /// + /// Mosquitoes are individuals rather than a per-household count, and that is a design + /// requirement rather than a preference: section 5 asks the trace-back replay to show + /// "the house it came from, the container the mosquito came from, and the night the sick + /// person slept unprotected". Only an individual carries that provenance. + /// + /// A neighbourhood of ten plots over three weeks is a few hundred of these. It costs nothing. + /// + public sealed class Mosquito + { + public int Id; + public int HouseholdId; + public int OriginContainerId; + public int OriginHouseholdId; + public int EmergedDay; + public int LifespanDays; + + public MosquitoState State = MosquitoState.Susceptible; + public int InfectiousFromDay = -1; + + /// Who this mosquito took the virus from - the other half of the chain. + public int AcquiredFromPersonId = -1; + public int AcquiredFromHouseholdId = -1; + public int AcquiredOnDay = -1; + + public bool Alive = true; + + public bool CanTransmit(int day) + { + return State == MosquitoState.Infectious && day >= InfectiousFromDay; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs.meta new file mode 100644 index 00000000..36794a16 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 697761a640d34822a48f51fcd94d2b97 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs new file mode 100644 index 00000000..8e96f93f --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs @@ -0,0 +1,620 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// + /// The epidemic. Pure C# - no UnityEngine, no coroutines, no physics, no UnityEngine.Random. + /// + /// Everything Unity does in Module 3 is read this and send it s. + /// The reason for that separation is section 5's ending: before any score is shown, the game + /// replays what would have happened had the first patient been covered on day one. That is + /// only possible if the whole outbreak can be re-run from a seed with one input changed, and + /// it is impossible if the epidemic lives in Update(). + /// + public sealed class Neighbourhood + { + public readonly int Seed; + + /// + /// Parameters keyed by the day they took effect. GAMA may send a new set mid-session, so + /// the model reads the timeline rather than a fixed object - see ParameterTimeline. + /// + public readonly ParameterTimeline Parameters; + + /// The parameters in force today. + public Module3Config Config => Parameters.At(Day); + + public readonly List People = new List(); + public readonly List Households = new List(); + public readonly List Mosquitoes = new List(); + public readonly TransmissionLog Log = new TransmissionLog(); + + public int Day { get; private set; } + public int NetsRemaining { get; private set; } + + /// + /// Warning-sign cases still owed to this session. The scenario builder sets it; the next + /// symptomatic cases to appear take them up. See ScenarioOptions.GuaranteedWarningSignCases. + /// + public int PendingGuaranteedWarningSigns; + + private int nextMosquitoId; + + public Neighbourhood(int seed, Module3Config config) + : this(seed, new ParameterTimeline(config ?? Module3Config.Default)) { } + + public Neighbourhood(int seed, ParameterTimeline parameters) + { + Seed = seed; + Parameters = parameters ?? new ParameterTimeline(Module3Config.Default); + NetsRemaining = Config.NetsPerSession; + } + + /// + /// Takes a parameter set that arrived mid-session. It applies from the NEXT day, never + /// retroactively: people already infected keep the course of illness they were given when + /// they were infected, because rewriting that would make the trace-back replay a lie + /// about what the squad saw. + /// + public void StageParameters(Module3Config config, string origin) + { + Parameters.Stage(Day + 1, config, origin); + } + + public Person PersonById(int id) => id >= 0 && id < People.Count ? People[id] : null; + public Household HouseholdById(int id) => id >= 0 && id < Households.Count ? Households[id] : null; + + // ----------------------------------------------------------------------------------- + // Player actions + // ----------------------------------------------------------------------------------- + + public ActionResult Apply(PlayerAction action) + { + switch (action.Kind) + { + case ActionKind.Visit: + { + var h = HouseholdById(action.HouseholdId); + if (h == null) return Fail(action); + h.LastVisitedDay = Day; + h.VisitCount++; + return Ok(action, "m3.action.visited"); + } + + case ActionKind.PutUpNet: + { + var p = PersonById(action.PersonId); + if (p == null) return Fail(action); + if (p.HasNet) return Result(action, ActionOutcome.AlreadyDone, "m3.action.netAlready"); + if (NetsRemaining <= 0) + return Result(action, ActionOutcome.NoNetsRemaining, "m3.action.noNetsLeft"); + p.HasNet = true; + NetsRemaining--; + return Ok(action, "m3.action.netUp"); + } + + case ActionKind.ReclaimNet: + { + // The volunteer carries the net on to the next house. The budget is about + // where the squad's attention goes, not about a warehouse - but taking a net + // off someone who still needs it has to cost them, so it is never automatic. + var p = PersonById(action.PersonId); + if (p == null || !p.HasNet) return Fail(action); + p.HasNet = false; + NetsRemaining++; + return Result(action, ActionOutcome.NetReclaimed, "m3.action.netReclaimed"); + } + + case ActionKind.RepairScreen: + { + var h = HouseholdById(action.HouseholdId); + if (h == null) return Fail(action); + if (h.Screens == ScreenState.Intact) + return Result(action, ActionOutcome.AlreadyDone, "m3.action.screenAlready"); + h.Screens = ScreenState.Intact; + return Ok(action, "m3.action.screenFixed"); + } + + case ActionKind.SetFan: + { + var h = HouseholdById(action.HouseholdId); + if (h == null) return Fail(action); + h.HasFan = true; + return Ok(action, "m3.action.fanOn"); + } + + case ActionKind.AdviseClosingHours: + { + var h = HouseholdById(action.HouseholdId); + if (h == null) return Fail(action); + h.ClosesDuringBitingHours = true; + return Ok(action, "m3.action.closingHours"); + } + + case ActionKind.GiveRepellent: + { + var p = PersonById(action.PersonId); + if (p == null) return Fail(action); + p.RepellentUntilDay = Day + Config.RepellentDurationDays; + return Ok(action, "m3.action.repellent"); + } + + case ActionKind.BringWater: + { + var p = PersonById(action.PersonId); + if (p == null) return Fail(action); + p.HasFluids = true; + return Ok(action, "m3.action.water"); + } + + case ActionKind.HelpRest: + { + var p = PersonById(action.PersonId); + if (p == null) return Fail(action); + p.HasRested = true; + return Ok(action, "m3.action.rest"); + } + + case ActionKind.ReferToHealthCentre: + { + var p = PersonById(action.PersonId); + if (p == null) return Fail(action); + bool needed = p.HasVisibleWarningSign(Day); + p.Referred = true; + p.ReferredDay = Day; + if (needed) + { + p.State = HealthState.Hospitalised; + return Result(action, ActionOutcome.ReferralCorrect, "m3.action.referralCorrect"); + } + // Section 10: gently corrected, never punished. The person stays at home, + // which is where someone with a fever and no warning signs belongs. + return Result(action, ActionOutcome.ReferralNotNeeded, "m3.action.referralNotNeeded"); + } + } + + return Fail(action); + } + + private static ActionResult Ok(PlayerAction a, string key) => Result(a, ActionOutcome.Done, key); + private static ActionResult Fail(PlayerAction a) => Result(a, ActionOutcome.Invalid, "m3.action.invalid"); + private static ActionResult Result(PlayerAction a, ActionOutcome o, string key) => + new ActionResult { Action = a, Outcome = o, MessageKey = key }; + + // ----------------------------------------------------------------------------------- + // Time + // ----------------------------------------------------------------------------------- + + public void AdvanceDays(int days) + { + for (int i = 0; i < days; i++) AdvanceDay(); + } + + public void AdvanceDay() + { + Day++; + AdvancePeople(); + AdvanceMosquitoes(); + Bite(); + } + + private void AdvancePeople() + { + for (int i = 0; i < People.Count; i++) + { + var p = People[i]; + + if (p.State == HealthState.Exposed && Day >= p.InfectiousStartDay) + { + p.State = HealthState.Infectious; + } + + if (p.State == HealthState.Infectious) + { + int recoversOn = p.IsAsymptomatic ? p.InfectiousEndDay : Max(p.InfectiousEndDay, p.FeverEndDay); + if (Day > recoversOn) p.State = HealthState.Recovered; + } + + // An unreferred warning sign does not go away. Section 10: not points deducted, + // a consequence shown - "on the next visit that household has gone to hospital, + // and the player is told why." + if (p.HasVisibleWarningSign(Day) && !p.Referred + && Day >= p.WarningOnsetDay + Config.UnreferredWarningGraceDays) + { + p.State = HealthState.Hospitalised; + p.WentToHospitalUnaided = true; + } + } + } + + private void AdvanceMosquitoes() + { + // Death first, so a mosquito never bites on the day it should already be gone. + for (int i = 0; i < Mosquitoes.Count; i++) + { + var m = Mosquitoes[i]; + if (!m.Alive) continue; + if (Day - m.EmergedDay >= m.LifespanDays) m.Alive = false; + } + + // Extrinsic incubation. + for (int i = 0; i < Mosquitoes.Count; i++) + { + var m = Mosquitoes[i]; + if (m.Alive && m.State == MosquitoState.Exposed && Day >= m.InfectiousFromDay) + { + m.State = MosquitoState.Infectious; + } + } + + // Dispersal - only ever to an adjacent plot. + for (int i = 0; i < Mosquitoes.Count; i++) + { + var m = Mosquitoes[i]; + if (!m.Alive) continue; + var h = HouseholdById(m.HouseholdId); + if (h == null || h.NeighbourIds.Count == 0) continue; + if (!Rng.Chance(Config.DailyDispersalProbability, Seed, Day, RngStream.MosquitoDispersal, m.Id)) + continue; + int pick = Rng.Range(0, h.NeighbourIds.Count - 1, Seed, Day, RngStream.MosquitoDispersal, m.Id, 1); + m.HouseholdId = h.NeighbourIds[pick]; + } + + // Emergence from whatever containers the squad left behind in Module 2. + for (int i = 0; i < Households.Count; i++) + { + var h = Households[i]; + int productive = h.ProductiveContainerCount; + if (productive == 0) continue; + if (AliveInHousehold(h.Id) >= Config.MaxMosquitoesPerHousehold) continue; + + double expected = productive * Config.EmergencePerContainerPerDay; + int whole = (int)expected; + double frac = expected - whole; + int count = whole; + if (Rng.Unit(Seed, Day, RngStream.MosquitoEmergence, h.Id) < frac) count++; + + for (int n = 0; n < count; n++) + { + var container = PickProductiveContainer(h, n); + if (container == null) break; + SpawnMosquito(h, container, Day); + } + } + } + + /// + /// Puts an adult mosquito on the wing. Used by the daily emergence loop and by the + /// scenario builder, which seeds the population that is already flying on day one. + /// + public Mosquito SpawnMosquito(Household h, Container from, int emergedDay) + { + var m = new Mosquito + { + Id = nextMosquitoId, + HouseholdId = h.Id, + OriginHouseholdId = h.Id, + OriginContainerId = from.Id, + EmergedDay = emergedDay, + LifespanDays = Rng.Range(Config.MosquitoLifespanMinDays, Config.MosquitoLifespanMaxDays, + Seed, 0, RngStream.MosquitoLifespan, nextMosquitoId), + }; + nextMosquitoId++; + Mosquitoes.Add(m); + return m; + } + + public Container PickProductiveContainer(Household h, int salt) + { + int productive = h.ProductiveContainerCount; + if (productive == 0) return null; + int wanted = Rng.Range(0, productive - 1, Seed, Day, RngStream.Containers, h.Id, salt); + for (int i = 0; i < h.Containers.Count; i++) + { + if (!h.Containers[i].IsProductive) continue; + if (wanted-- == 0) return h.Containers[i]; + } + return null; + } + + private int AliveInHousehold(int householdId) + { + int n = 0; + for (int i = 0; i < Mosquitoes.Count; i++) + { + if (Mosquitoes[i].Alive && Mosquitoes[i].HouseholdId == householdId) n++; + } + return n; + } + + // ----------------------------------------------------------------------------------- + // Biting - where the chain is made and broken + // ----------------------------------------------------------------------------------- + + private readonly List biteCandidates = new List(); + private readonly List biteWeights = new List(); + + private void Bite() + { + for (int i = 0; i < Mosquitoes.Count; i++) + { + var m = Mosquitoes[i]; + if (!m.Alive) continue; + if (!Rng.Chance(Config.DailyBiteProbability, Seed, Day, RngStream.Bite, m.Id)) continue; + + var target = ChooseBiteTarget(m); + if (target == null) continue; + + // Mosquito to human. + if (m.CanTransmit(Day) && target.State == HealthState.Susceptible + && Rng.Chance(Config.ProbabilityMosquitoToHuman, Seed, Day, RngStream.MosquitoToHuman, m.Id)) + { + var source = PersonById(m.AcquiredFromPersonId); + Infect(target, m, source); + continue; + } + + // Human to mosquito. + double acquire = Config.ProbabilityHumanToMosquito + * (target.IsAsymptomatic ? Config.AsymptomaticRelativeInfectiousness : 1.0); + if (m.State == MosquitoState.Susceptible && target.IsInfectiousToMosquitoes(Day) + && Rng.Chance(acquire, Seed, Day, RngStream.HumanToMosquito, m.Id)) + { + m.State = MosquitoState.Exposed; + m.AcquiredFromPersonId = target.Id; + m.AcquiredFromHouseholdId = target.HouseholdId; + m.AcquiredOnDay = Day; + m.InfectiousFromDay = Day + Rng.Range( + Config.ExtrinsicIncubationMinDays, Config.ExtrinsicIncubationMaxDays, + Seed, 0, RngStream.MosquitoLifespan, m.Id, 7); + } + } + } + + private Person ChooseBiteTarget(Mosquito m) + { + var h = HouseholdById(m.HouseholdId); + if (h == null) return null; + + biteCandidates.Clear(); + biteWeights.Clear(); + double total = 0; + + for (int i = 0; i < h.ResidentIds.Count; i++) + { + var p = PersonById(h.ResidentIds[i]); + if (p == null || p.State == HealthState.Hospitalised) continue; + double w = ExposureWeight(p, h); + if (w <= 0) continue; + biteCandidates.Add(p); + biteWeights.Add(w); + total += w; + } + + if (biteCandidates.Count == 0 || total <= 0) return null; + + // A mosquito looking for a meal finds whoever is in the house; whether it gets one + // depends on what is between it and them. Protection is therefore a chance of + // FAILING to feed, not a smaller share of a fixed number of bites - otherwise + // netting one person would silently push the bites onto their family. + int pick = Rng.Range(0, biteCandidates.Count - 1, Seed, Day, RngStream.BiteTarget, m.Id); + var chosen = biteCandidates[pick]; + return Rng.Unit(Seed, Day, RngStream.BiteTarget, m.Id, 1) < biteWeights[pick] ? chosen : null; + } + + /// + /// How likely this person is to be bitten, 0 to 1. The ranking encoded here is section 6's + /// and is expected to be re-ordered after Dr Marcombe's review - which is why it reads off + /// rather than hard-coding anything. + /// + public double ExposureWeight(Person p, Household h) + { + double w = 1.0; + + if (p.HasNet) + { + // The one distinction the whole module rests on. A net over someone lying down + // during the day is close to total protection; the same net over someone who is + // up and about is worth almost nothing, because they are not under it when + // Aedes bites. Netting the sick and netting the well are therefore not variations + // of one action - they are different actions, and the model says so. + w *= p.IsRestingInBitingHours(Day) ? Config.NetOverRestingPerson : Config.NetOverActivePerson; + } + + switch (h.Screens) + { + case ScreenState.Intact: w *= Config.IntactScreen; break; + case ScreenState.Torn: w *= Config.TornScreen; break; + } + + if (h.HasAirConditioning) w *= Config.AirConditioning; + else if (h.HasFan) w *= Config.ElectricFan; + + if (h.ClosesDuringBitingHours) w *= Config.ClosedDuringBitingHours; + if (p.RepellentUntilDay >= Day) w *= Config.Repellent; + if (p.WearsLongSleeves) w *= Config.LongSleeves; + + return w; + } + + // ----------------------------------------------------------------------------------- + // Infection + // ----------------------------------------------------------------------------------- + + /// + /// Seeds an index case - the outbreak the squad arrives into. + /// pins the day symptoms show, so that "the first patient" + /// really is visible on the squad's first turn. Section 5 turns on being able to cover + /// them on day one, which is only a choice if they can be found on day one. + /// + public void SeedIndexCase(int personId, int daysAlreadyInfected, bool forceAsymptomatic = false, + int onsetDay = int.MinValue, bool forceSymptomatic = false) + { + var p = PersonById(personId); + if (p == null || p.State != HealthState.Susceptible) return; + + int exposed = Day - daysAlreadyInfected; + if (onsetDay != int.MinValue) + { + int incubation = Rng.Range(Config.IncubationMinDays, Config.IncubationMaxDays, + Seed, 0, RngStream.Incubation, p.Id); + exposed = onsetDay - incubation; + } + ScheduleInfection(p, exposed, forceAsymptomatic, forceSymptomatic); + Log.Add(new TransmissionEvent + { + Day = p.ExposedDay, + TargetPersonId = p.Id, + TargetHouseholdId = p.HouseholdId, + }); + if (Day >= p.InfectiousStartDay) p.State = HealthState.Infectious; + } + + private void Infect(Person target, Mosquito m, Person source) + { + ScheduleInfection(target, Day, false); + Log.Add(new TransmissionEvent + { + Day = Day, + TargetPersonId = target.Id, + TargetHouseholdId = target.HouseholdId, + SourcePersonId = m.AcquiredFromPersonId, + SourceHouseholdId = m.AcquiredFromHouseholdId, + MosquitoId = m.Id, + OriginContainerId = m.OriginContainerId, + OriginContainerKind = ContainerKindOf(m.OriginHouseholdId, m.OriginContainerId), + // Judged on the day the mosquito fed, which is the moment the squad could have + // acted - not on the day the new case shows up. + SourceWasVisiblyIll = source != null && source.IsFebrile(m.AcquiredOnDay), + SourceWasProtected = source != null && source.HasNet, + }); + } + + private ContainerKind ContainerKindOf(int householdId, int containerId) + { + var h = HouseholdById(householdId); + if (h == null) return ContainerKind.WaterJar; + for (int i = 0; i < h.Containers.Count; i++) + { + if (h.Containers[i].Id == containerId) return h.Containers[i].Kind; + } + return ContainerKind.WaterJar; + } + + /// + /// Lays out the whole course of one person's infection at the moment they are infected. + /// Every draw is keyed on the person's id and not on a running stream, so a counterfactual + /// re-run gives the same person the same incubation period - only the infection itself + /// differs. That is what makes the replay attributable to the net rather than to noise. + /// + private void ScheduleInfection(Person p, int exposedDay, bool forceAsymptomatic, bool forceSymptomatic = false) + { + p.State = HealthState.Exposed; + p.HasBeenInfected = true; + p.ExposedDay = exposedDay; + + int incubation = Rng.Range(Config.IncubationMinDays, Config.IncubationMaxDays, + Seed, 0, RngStream.Incubation, p.Id); + p.SymptomOnsetDay = exposedDay + incubation; + + p.IsAsymptomatic = forceSymptomatic + ? false + : forceAsymptomatic || Rng.Unit(Seed, 0, RngStream.Asymptomatic, p.Id) < Config.AsymptomaticShare; + + p.InfectiousStartDay = p.SymptomOnsetDay - Config.InfectiousDaysBeforeOnset; + + if (p.IsAsymptomatic) + { + p.FeverEndDay = -1; + p.InfectiousEndDay = p.SymptomOnsetDay + Config.AsymptomaticInfectiousDays; + return; + } + + int fever = Rng.Range(Config.FeverMinDays, Config.FeverMaxDays, + Seed, 0, RngStream.FeverDuration, p.Id); + p.FeverEndDay = p.SymptomOnsetDay + fever; + p.InfectiousEndDay = p.SymptomOnsetDay + Config.InfectiousDaysAfterOnset; + + bool warns = Rng.Unit(Seed, 0, RngStream.WarningSign, p.Id) < Config.WarningSignShare; + if (!warns && PendingGuaranteedWarningSigns > 0) + { + // The session still owes the squad a referral to make. See the note on + // ScenarioOptions.GuaranteedWarningSignCases - a teaching guarantee, not a + // statement about how often dengue turns severe. + warns = true; + PendingGuaranteedWarningSigns--; + } + + if (warns) + { + // Section 8: danger often arrives as the fever comes down. + int offset = Rng.Range(Config.WarningSignOnsetOffsetMin, Config.WarningSignOnsetOffsetMax, + Seed, 0, RngStream.WarningSignOnset, p.Id); + p.WarningOnsetDay = p.FeverEndDay + offset; + p.Warning = Rng.Unit(Seed, 0, RngStream.WarningSignOnset, p.Id, 1) < 0.5 + ? WarningSign.Bleeding + : WarningSign.PersistentVomiting; + } + } + + /// + /// Forces a warning sign onto a person who is already infected, at defervescence. + /// Used by the scenario builder to guarantee the squad meets the referral situation - + /// see ScenarioOptions.GuaranteedWarningSignCases for why that is a scenario decision + /// rather than a change to the clinical rate. + /// + public bool ForceWarningSign(int personId) + { + var p = PersonById(personId); + if (p == null || !p.HasBeenInfected || p.IsAsymptomatic) return false; + if (p.Warning != WarningSign.None) return true; + + int offset = Rng.Range(Config.WarningSignOnsetOffsetMin, Config.WarningSignOnsetOffsetMax, + Seed, 0, RngStream.WarningSignOnset, p.Id); + p.WarningOnsetDay = p.FeverEndDay + offset; + p.Warning = Rng.Unit(Seed, 0, RngStream.WarningSignOnset, p.Id, 1) < 0.5 + ? WarningSign.Bleeding + : WarningSign.PersistentVomiting; + return true; + } + + private static int Max(int a, int b) => a > b ? a : b; + + // ----------------------------------------------------------------------------------- + // Readouts + // ----------------------------------------------------------------------------------- + + public int TotalCases => Log.CaseCount; + + public int SecondaryCases + { + get + { + int n = 0; + for (int i = 0; i < Log.Events.Count; i++) if (!Log.Events[i].IsIndexCase) n++; + return n; + } + } + + public int AliveMosquitoCount + { + get + { + int n = 0; + for (int i = 0; i < Mosquitoes.Count; i++) if (Mosquitoes[i].Alive) n++; + return n; + } + } + + /// Visibly unwell right now - what a Pilot walking in would find. + public List VisiblyIll() + { + var list = new List(); + for (int i = 0; i < People.Count; i++) + { + if (People[i].IsFebrile(Day) || People[i].HasVisibleWarningSign(Day)) list.Add(People[i]); + } + return list; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs.meta new file mode 100644 index 00000000..c2b8ef22 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3ec5c27011f64ecc92c34e1293c0a2cf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs new file mode 100644 index 00000000..11016651 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs @@ -0,0 +1,535 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Aedes.Module3.Sim +{ + /// + /// How safe a parameter is to change while a session is already running. + /// + public enum ApplyScope + { + /// + /// Takes effect on the next simulated day. Rates and multipliers: nothing already + /// scheduled depends on them, so changing them mid-outbreak is consistent. + /// + NextDay, + + /// + /// Takes effect for people and mosquitoes infected AFTER the change. A person's incubation + /// period is fixed at the moment they are infected, so a new value cannot be applied + /// retroactively without rewriting history - the neighbourhood simply ends up holding two + /// generations under slightly different numbers, which is recorded rather than hidden. + /// + FutureInfections, + + /// + /// Cannot take effect until a new session. The neighbourhood is already built: changing + /// how many plots it has, or what Module 2 left in the yards, would mean rebuilding it + /// underneath the player. + /// + NextSession, + } + + public sealed class ParameterDescriptor + { + public string Key; + public string Unit; + public string Section; + public string Notes; + public ApplyScope Scope; + } + + /// + /// Everything GAMA hands over: the clinical and population numbers, plus the shape of the + /// neighbourhood the squad arrives into. + /// + /// Section 5 of the design asks for the mosquito population to be "driven by the GAMA model + /// rather than scripted", and open question 3b asks how the state of the yards carries over + /// from Module 2. Both arrive here. + /// + public sealed class ParameterSet + { + public Module3Config Config = Module3Config.Default; + public ScenarioOptions Scenario = ScenarioOptions.Default; + + /// Keys in the file that this build does not know about, kept for diagnostics. + public readonly List UnknownKeys = new List(); + + /// Rows that could not be read - a bad number, a missing column. + public readonly List Errors = new List(); + + /// + /// The keys this particular file actually carried. GAMA may send only what changed, so + /// knowing which keys were present is what lets the receiver work out whether anything + /// arrived that cannot take effect until the next session. + /// + public readonly List PresentKeys = new List(); + + /// Where this set came from: a Resources file, a GAMA message, a test. + public string Origin = "defaults"; + + /// GAMA's own identifier for the run that produced it, if it sent one. + public string SourceRunId = ""; + + public bool IsValid => Errors.Count == 0; + + public ParameterSet Clone() + { + var copy = new ParameterSet + { + Config = Config.Clone(), + Scenario = Scenario, + Origin = Origin, + SourceRunId = SourceRunId, + }; + copy.UnknownKeys.AddRange(UnknownKeys); + copy.Errors.AddRange(Errors); + copy.PresentKeys.AddRange(PresentKeys); + return copy; + } + } + + /// + /// Reads and writes the parameter CSV that GAMA produces. + /// + /// The writer matters as much as the reader: emits the current + /// values with their units, sections and notes, which is the document handed to the GAMA + /// modellers so both sides agree on key names before either writes code against them. + /// + public static class ParameterCsv + { + public const string KeyColumn = "key"; + public const string ValueColumn = "value"; + + private static readonly List Schema = BuildSchema(); + + public static IReadOnlyList Descriptors => Schema; + + // ----------------------------------------------------------------------------------- + + /// + /// What the file is applied on top of. GAMA may send only the keys that changed, and a + /// partial file must not silently reset everything else to the build's defaults - so + /// mid-session reads pass the set currently in force. + /// + public static ParameterSet Parse(string csvText, string origin = "csv", ParameterSet baseSet = null) + { + var set = baseSet != null ? baseSet.Clone() : new ParameterSet(); + set.Origin = origin; + set.Errors.Clear(); + set.UnknownKeys.Clear(); + set.PresentKeys.Clear(); + var rows = Csv.Parse(csvText); + + if (rows.Count == 0) + { + set.Errors.Add("The parameter file is empty."); + return set; + } + + // Locate the key and value columns, so GAMA is free to add columns of its own (units, + // provenance, a comment) without breaking the reader. Leading '#' lines are skipped: + // the template carries its own instructions at the top and must survive a round trip. + int keyCol = -1, valueCol = -1, headerRow = -1; + for (int r = 0; r < rows.Count && headerRow < 0; r++) + { + var candidate = rows[r]; + if (candidate.Count == 0) continue; + if (candidate[0].TrimStart().StartsWith("#")) continue; + + int k = -1, v = -1; + for (int i = 0; i < candidate.Count; i++) + { + string h = candidate[i].Trim().ToLowerInvariant(); + if (h == KeyColumn) k = i; + else if (h == ValueColumn) v = i; + } + if (k >= 0 && v >= 0) { keyCol = k; valueCol = v; headerRow = r; } + } + + if (headerRow < 0) + { + set.Errors.Add($"No header row with '{KeyColumn}' and '{ValueColumn}' columns was found."); + return set; + } + + for (int r = headerRow + 1; r < rows.Count; r++) + { + var row = rows[r]; + if (row.Count <= keyCol) continue; + + string key = row[keyCol].Trim(); + if (key.Length == 0 || key.StartsWith("#")) continue; + + string raw = row.Count > valueCol ? row[valueCol].Trim() : ""; + if (raw.Length == 0) continue; + + if (key == "source_run_id") { set.SourceRunId = raw; continue; } + + if (Apply(set, key, raw, out string error)) + { + set.PresentKeys.Add(key); + } + else if (error == null) set.UnknownKeys.Add(key); + else set.Errors.Add($"row {r + 1}: {error}"); + } + + Validate(set); + return set; + } + + /// + /// Emits the set as a CSV with units, sections, notes and the apply scope of each key. + /// This is the template to hand the GAMA side. + /// + public static string WriteTemplate(ParameterSet set) + { + var sb = new StringBuilder(); + sb.AppendLine("# AEDES Module 3 - simulation parameters"); + sb.AppendLine("# Generated by GAMA and read by Unity. Only 'key' and 'value' are read;"); + sb.AppendLine("# the other columns are documentation and may be reordered or extended."); + sb.AppendLine("# apply_scope says when a value sent mid-session can take effect:"); + sb.AppendLine("# next_day - from the next simulated day"); + sb.AppendLine("# future_infections - only for people infected after the change"); + sb.AppendLine("# next_session - not until the neighbourhood is rebuilt"); + sb.AppendLine("key,value,unit,section,apply_scope,notes"); + + for (int i = 0; i < Schema.Count; i++) + { + var d = Schema[i]; + sb.Append(d.Key).Append(',') + .Append(Csv.Quote(Read(set, d.Key))).Append(',') + .Append(Csv.Quote(d.Unit)).Append(',') + .Append(Csv.Quote(d.Section)).Append(',') + .Append(ScopeName(d.Scope)).Append(',') + .Append(Csv.Quote(d.Notes)).AppendLine(); + } + return sb.ToString(); + } + + /// + /// True if any of these keys cannot take effect until the neighbourhood is rebuilt. + /// + public static bool RequiresRestart(IEnumerable keys, out List blocking) + { + blocking = new List(); + foreach (var k in keys) + { + if (ScopeOf(k) == ApplyScope.NextSession) blocking.Add(k); + } + return blocking.Count > 0; + } + + public static ApplyScope ScopeOf(string key) + { + for (int i = 0; i < Schema.Count; i++) if (Schema[i].Key == key) return Schema[i].Scope; + return ApplyScope.NextSession; + } + + private static string ScopeName(ApplyScope s) => + s == ApplyScope.NextDay ? "next_day" + : s == ApplyScope.FutureInfections ? "future_infections" + : "next_session"; + + // ----------------------------------------------------------------------------------- + // Sanity checks. A parameter file that GAMA generated is still a file that can be wrong, + // and a classroom is a bad place to find out. + // ----------------------------------------------------------------------------------- + + private static void Validate(ParameterSet set) + { + var c = set.Config; + Range(set, "asymptomatic_share", c.AsymptomaticShare, 0, 1); + Range(set, "asymptomatic_relative_infectiousness", c.AsymptomaticRelativeInfectiousness, 0, 1); + Range(set, "warning_sign_share", c.WarningSignShare, 0, 1); + Range(set, "daily_bite_probability", c.DailyBiteProbability, 0, 1); + Range(set, "daily_dispersal_probability", c.DailyDispersalProbability, 0, 1); + Range(set, "probability_human_to_mosquito", c.ProbabilityHumanToMosquito, 0, 1); + Range(set, "probability_mosquito_to_human", c.ProbabilityMosquitoToHuman, 0, 1); + Range(set, "net_over_resting_person", c.NetOverRestingPerson, 0, 1); + Range(set, "net_over_active_person", c.NetOverActivePerson, 0, 1); + Range(set, "module2_container_clearance", set.Scenario.Module2.ContainerClearance, 0, 1); + + Ordered(set, "incubation", c.IncubationMinDays, c.IncubationMaxDays); + Ordered(set, "fever", c.FeverMinDays, c.FeverMaxDays); + Ordered(set, "extrinsic_incubation", c.ExtrinsicIncubationMinDays, c.ExtrinsicIncubationMaxDays); + Ordered(set, "mosquito_lifespan", c.MosquitoLifespanMinDays, c.MosquitoLifespanMaxDays); + + if (c.NetsPerSession >= set.Scenario.Lanes * set.Scenario.HouseholdsPerLane) + { + // Section 5: "The nets available to the player should be limited in number - fewer + // than there are households. Give them enough for everyone and there is nothing + // to learn." This one is a design invariant, not a preference. + set.Errors.Add($"nets_per_session ({c.NetsPerSession}) must be fewer than the number of " + + $"households ({set.Scenario.Lanes * set.Scenario.HouseholdsPerLane}); " + + "the shortage is what makes the player choose."); + } + + if (c.NetOverRestingPerson >= c.NetOverActivePerson) + { + set.Errors.Add("net_over_resting_person must be well below net_over_active_person: " + + "a net protects a patient who is lying down during the day, which is the " + + "distinction the module is built to teach."); + } + } + + private static void Range(ParameterSet set, string key, double v, double lo, double hi) + { + if (v < lo || v > hi) set.Errors.Add($"{key} = {v} is outside {lo}..{hi}."); + } + + private static void Ordered(ParameterSet set, string name, int min, int max) + { + if (min > max) set.Errors.Add($"{name}_min_days ({min}) is greater than {name}_max_days ({max})."); + if (min < 0) set.Errors.Add($"{name}_min_days ({min}) is negative."); + } + + // ----------------------------------------------------------------------------------- + + private static bool Apply(ParameterSet set, string key, string raw, out string error) + { + error = null; + var c = set.Config; + + switch (key) + { + // clinical - human + case "incubation_min_days": return Int(raw, ref c.IncubationMinDays, out error); + case "incubation_max_days": return Int(raw, ref c.IncubationMaxDays, out error); + case "fever_min_days": return Int(raw, ref c.FeverMinDays, out error); + case "fever_max_days": return Int(raw, ref c.FeverMaxDays, out error); + case "infectious_days_before_onset": return Int(raw, ref c.InfectiousDaysBeforeOnset, out error); + case "infectious_days_after_onset": return Int(raw, ref c.InfectiousDaysAfterOnset, out error); + case "asymptomatic_share": return Dbl(raw, ref c.AsymptomaticShare, out error); + case "asymptomatic_infectious_days": return Int(raw, ref c.AsymptomaticInfectiousDays, out error); + case "asymptomatic_relative_infectiousness": + return Dbl(raw, ref c.AsymptomaticRelativeInfectiousness, out error); + case "warning_sign_share": return Dbl(raw, ref c.WarningSignShare, out error); + case "warning_sign_onset_offset_min": return Int(raw, ref c.WarningSignOnsetOffsetMin, out error); + case "warning_sign_onset_offset_max": return Int(raw, ref c.WarningSignOnsetOffsetMax, out error); + case "unreferred_warning_grace_days": return Int(raw, ref c.UnreferredWarningGraceDays, out error); + + // clinical - mosquito + case "extrinsic_incubation_min_days": return Int(raw, ref c.ExtrinsicIncubationMinDays, out error); + case "extrinsic_incubation_max_days": return Int(raw, ref c.ExtrinsicIncubationMaxDays, out error); + + // population rates + case "mosquito_lifespan_min_days": return Int(raw, ref c.MosquitoLifespanMinDays, out error); + case "mosquito_lifespan_max_days": return Int(raw, ref c.MosquitoLifespanMaxDays, out error); + case "emergence_per_container_per_day": return Dbl(raw, ref c.EmergencePerContainerPerDay, out error); + case "max_mosquitoes_per_household": return Int(raw, ref c.MaxMosquitoesPerHousehold, out error); + case "standing_population_days": return Int(raw, ref c.StandingPopulationDays, out error); + case "daily_bite_probability": return Dbl(raw, ref c.DailyBiteProbability, out error); + case "daily_dispersal_probability": return Dbl(raw, ref c.DailyDispersalProbability, out error); + case "probability_human_to_mosquito": return Dbl(raw, ref c.ProbabilityHumanToMosquito, out error); + case "probability_mosquito_to_human": return Dbl(raw, ref c.ProbabilityMosquitoToHuman, out error); + + // protective measures + case "net_over_resting_person": return Dbl(raw, ref c.NetOverRestingPerson, out error); + case "net_over_active_person": return Dbl(raw, ref c.NetOverActivePerson, out error); + case "intact_screen": return Dbl(raw, ref c.IntactScreen, out error); + case "torn_screen": return Dbl(raw, ref c.TornScreen, out error); + case "closed_during_biting_hours": return Dbl(raw, ref c.ClosedDuringBitingHours, out error); + case "electric_fan": return Dbl(raw, ref c.ElectricFan, out error); + case "air_conditioning": return Dbl(raw, ref c.AirConditioning, out error); + case "repellent": return Dbl(raw, ref c.Repellent, out error); + case "repellent_duration_days": return Int(raw, ref c.RepellentDurationDays, out error); + case "long_sleeves": return Dbl(raw, ref c.LongSleeves, out error); + + // session shape + case "nets_per_session": return Int(raw, ref c.NetsPerSession, out error); + case "turn_seconds": return Flt(raw, ref c.TurnSeconds, out error); + case "turns_per_round": return Int(raw, ref c.TurnsPerRound, out error); + case "rounds": return Int(raw, ref c.Rounds, out error); + case "days_per_handover": return Int(raw, ref c.DaysPerHandover, out error); + } + + // scenario (struct, so it has to be read back out) + var s = set.Scenario; + bool ok; + switch (key) + { + case "households_per_lane": ok = Int(raw, ref s.HouseholdsPerLane, out error); break; + case "lanes": ok = Int(raw, ref s.Lanes, out error); break; + case "module2_container_clearance": + ok = Dbl(raw, ref s.Module2.ContainerClearance, out error); break; + case "index_case_head_start_days": ok = Int(raw, ref s.IndexCaseHeadStartDays, out error); break; + case "seed_asymptomatic_index_case": ok = Bool(raw, ref s.SeedAsymptomaticIndexCase, out error); break; + case "min_productive_containers_per_household": + ok = Int(raw, ref s.MinProductiveContainersPerHousehold, out error); break; + case "guaranteed_warning_sign_cases": + ok = Int(raw, ref s.GuaranteedWarningSignCases, out error); break; + default: return false; + } + set.Scenario = s; + return ok; + } + + private static string Read(ParameterSet set, string key) + { + var c = set.Config; + var s = set.Scenario; + switch (key) + { + case "incubation_min_days": return Str(c.IncubationMinDays); + case "incubation_max_days": return Str(c.IncubationMaxDays); + case "fever_min_days": return Str(c.FeverMinDays); + case "fever_max_days": return Str(c.FeverMaxDays); + case "infectious_days_before_onset": return Str(c.InfectiousDaysBeforeOnset); + case "infectious_days_after_onset": return Str(c.InfectiousDaysAfterOnset); + case "asymptomatic_share": return Str(c.AsymptomaticShare); + case "asymptomatic_infectious_days": return Str(c.AsymptomaticInfectiousDays); + case "asymptomatic_relative_infectiousness": return Str(c.AsymptomaticRelativeInfectiousness); + case "warning_sign_share": return Str(c.WarningSignShare); + case "warning_sign_onset_offset_min": return Str(c.WarningSignOnsetOffsetMin); + case "warning_sign_onset_offset_max": return Str(c.WarningSignOnsetOffsetMax); + case "unreferred_warning_grace_days": return Str(c.UnreferredWarningGraceDays); + case "extrinsic_incubation_min_days": return Str(c.ExtrinsicIncubationMinDays); + case "extrinsic_incubation_max_days": return Str(c.ExtrinsicIncubationMaxDays); + case "mosquito_lifespan_min_days": return Str(c.MosquitoLifespanMinDays); + case "mosquito_lifespan_max_days": return Str(c.MosquitoLifespanMaxDays); + case "emergence_per_container_per_day": return Str(c.EmergencePerContainerPerDay); + case "max_mosquitoes_per_household": return Str(c.MaxMosquitoesPerHousehold); + case "standing_population_days": return Str(c.StandingPopulationDays); + case "daily_bite_probability": return Str(c.DailyBiteProbability); + case "daily_dispersal_probability": return Str(c.DailyDispersalProbability); + case "probability_human_to_mosquito": return Str(c.ProbabilityHumanToMosquito); + case "probability_mosquito_to_human": return Str(c.ProbabilityMosquitoToHuman); + case "net_over_resting_person": return Str(c.NetOverRestingPerson); + case "net_over_active_person": return Str(c.NetOverActivePerson); + case "intact_screen": return Str(c.IntactScreen); + case "torn_screen": return Str(c.TornScreen); + case "closed_during_biting_hours": return Str(c.ClosedDuringBitingHours); + case "electric_fan": return Str(c.ElectricFan); + case "air_conditioning": return Str(c.AirConditioning); + case "repellent": return Str(c.Repellent); + case "repellent_duration_days": return Str(c.RepellentDurationDays); + case "long_sleeves": return Str(c.LongSleeves); + case "nets_per_session": return Str(c.NetsPerSession); + case "turn_seconds": return Str(c.TurnSeconds); + case "turns_per_round": return Str(c.TurnsPerRound); + case "rounds": return Str(c.Rounds); + case "days_per_handover": return Str(c.DaysPerHandover); + case "households_per_lane": return Str(s.HouseholdsPerLane); + case "lanes": return Str(s.Lanes); + case "module2_container_clearance": return Str(s.Module2.ContainerClearance); + case "index_case_head_start_days": return Str(s.IndexCaseHeadStartDays); + case "seed_asymptomatic_index_case": return s.SeedAsymptomaticIndexCase ? "true" : "false"; + case "min_productive_containers_per_household": return Str(s.MinProductiveContainersPerHousehold); + case "guaranteed_warning_sign_cases": return Str(s.GuaranteedWarningSignCases); + } + return ""; + } + + private static string Str(int v) => v.ToString(CultureInfo.InvariantCulture); + private static string Str(float v) => v.ToString("0.####", CultureInfo.InvariantCulture); + private static string Str(double v) => v.ToString("0.####", CultureInfo.InvariantCulture); + + private static bool Int(string raw, ref int target, out string error) + { + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v)) + { + target = v; error = null; return true; + } + // GAMA writes whole numbers as floats often enough that refusing "4.0" would be petty. + if (double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) + { + target = (int)d; error = null; return true; + } + error = $"'{raw}' is not a whole number."; return false; + } + + private static bool Dbl(string raw, ref double target, out string error) + { + if (double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double v)) + { + target = v; error = null; return true; + } + error = $"'{raw}' is not a number."; return false; + } + + private static bool Flt(string raw, ref float target, out string error) + { + if (float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float v)) + { + target = v; error = null; return true; + } + error = $"'{raw}' is not a number."; return false; + } + + private static bool Bool(string raw, ref bool target, out string error) + { + string v = raw.Trim().ToLowerInvariant(); + if (v == "true" || v == "1" || v == "yes") { target = true; error = null; return true; } + if (v == "false" || v == "0" || v == "no") { target = false; error = null; return true; } + error = $"'{raw}' is not true or false."; return false; + } + + private static List BuildSchema() + { + var l = new List(); + void D(string key, string unit, string section, ApplyScope scope, string notes) => + l.Add(new ParameterDescriptor { Key = key, Unit = unit, Section = section, Scope = scope, Notes = notes }); + + const string clin = "clinical"; + const string ento = "entomology"; + const string rates = "population-rates"; + const string prot = "protective-measures"; + const string sess = "session"; + const string scen = "scenario"; + + D("incubation_min_days", "days", clin, ApplyScope.FutureInfections, "Bite to symptoms, shortest. Design section 5: four to ten days. Do not compress."); + D("incubation_max_days", "days", clin, ApplyScope.FutureInfections, "Bite to symptoms, longest."); + D("fever_min_days", "days", clin, ApplyScope.FutureInfections, "Febrile period, shortest."); + D("fever_max_days", "days", clin, ApplyScope.FutureInfections, "Febrile period, longest."); + D("infectious_days_before_onset", "days", clin, ApplyScope.FutureInfections, "Viraemia begins this many days before symptoms."); + D("infectious_days_after_onset", "days", clin, ApplyScope.FutureInfections, "Last day a patient can infect a mosquito, counted from symptom onset."); + D("asymptomatic_share", "fraction 0-1", clin, ApplyScope.FutureInfections, "Share of infections that never feel ill. Section 5: half to three quarters. Pending Dr Marcombe."); + D("asymptomatic_infectious_days", "days", clin, ApplyScope.FutureInfections, "Length of the infectious window for someone with no symptoms."); + D("asymptomatic_relative_infectiousness", "fraction 0-1", clin, ApplyScope.FutureInfections, "How infectious to mosquitoes a symptomless case is, relative to a febrile one. Section 5: virus levels peak during fever. Below 1, or netting the visible patients stops being the strongest action."); + D("warning_sign_share", "fraction 0-1", clin, ApplyScope.FutureInfections, "Background rate of bleeding or persistent vomiting among symptomatic cases."); + D("warning_sign_onset_offset_min", "days", clin, ApplyScope.FutureInfections, "Earliest warning-sign onset relative to the end of fever. Section 8: danger arrives as the fever comes down."); + D("warning_sign_onset_offset_max", "days", clin, ApplyScope.FutureInfections, "Latest warning-sign onset relative to the end of fever."); + D("unreferred_warning_grace_days", "days", clin, ApplyScope.NextDay, "How long before an unreferred household takes itself to hospital. A consequence shown to the player, never a penalty."); + D("extrinsic_incubation_min_days", "days", ento, ApplyScope.FutureInfections, "Shortest time before a mosquito that fed on a case can transmit. Section 5: eight to twelve days."); + D("extrinsic_incubation_max_days", "days", ento, ApplyScope.FutureInfections, "Longest extrinsic incubation period."); + + D("mosquito_lifespan_min_days", "days", rates, ApplyScope.NextDay, "Shortest adult female lifespan."); + D("mosquito_lifespan_max_days", "days", rates, ApplyScope.NextDay, "Longest adult female lifespan."); + D("emergence_per_container_per_day", "adults/container/day", rates, ApplyScope.NextDay, "Adults emerging from each productive container each day. The main lever the GAMA container model drives."); + D("max_mosquitoes_per_household", "count", rates, ApplyScope.NextDay, "Ceiling per plot, so a neglected neighbourhood stays playable."); + D("standing_population_days", "days", rates, ApplyScope.NextSession, "Days' worth of emergence already on the wing on day one. Section 5: the mosquitoes are already flying."); + D("daily_bite_probability", "probability", rates, ApplyScope.NextDay, "Chance a given mosquito seeks a blood meal on a given day."); + D("daily_dispersal_probability", "probability", rates, ApplyScope.NextDay, "Chance of moving to an adjacent plot. Aedes aegypti travels very little."); + D("probability_human_to_mosquito", "probability", rates, ApplyScope.NextDay, "Chance a bite on an infectious person infects the mosquito."); + D("probability_mosquito_to_human", "probability", rates, ApplyScope.NextDay, "Chance a bite by an infectious mosquito infects the person."); + + D("net_over_resting_person", "multiplier 0-1", prot, ApplyScope.NextDay, "Bite risk under a net while lying down during the day. The strongest measure in the module."); + D("net_over_active_person", "multiplier 0-1", prot, ApplyScope.NextDay, "The same net over someone up and about - close to no protection, because they are not under it when Aedes bites. Must stay well above net_over_resting_person."); + D("intact_screen", "multiplier 0-1", prot, ApplyScope.NextDay, "Window and door screens in good repair."); + D("torn_screen", "multiplier 0-1", prot, ApplyScope.NextDay, "A screen with a hole in it - close to useless, which is why repairing it is worth the player's time."); + D("closed_during_biting_hours", "multiplier 0-1", prot, ApplyScope.NextDay, "Shutting up after dawn and before dusk. Costs nothing, available to everyone."); + D("electric_fan", "multiplier 0-1", prot, ApplyScope.NextDay, "Air movement disrupts flight and the plume mosquitoes home in on."); + D("air_conditioning", "multiplier 0-1", prot, ApplyScope.NextDay, "Include it, but the module must not depend on it - uncommon in these households."); + D("repellent", "multiplier 0-1", prot, ApplyScope.NextDay, "Applied to exposed skin."); + D("repellent_duration_days", "days", prot, ApplyScope.NextDay, "How long one application lasts."); + D("long_sleeves", "multiplier 0-1", prot, ApplyScope.NextDay, "Free, and uncomfortable with a fever."); + + D("nets_per_session", "count", sess, ApplyScope.NextSession, "Must be fewer than the number of households. Section 5: the choice is the whole point."); + D("turn_seconds", "seconds", sess, ApplyScope.NextDay, "Section 8: two to four minutes, fixed in playtesting."); + D("turns_per_round", "count", sess, ApplyScope.NextSession, "One per squad member."); + D("rounds", "count", sess, ApplyScope.NextSession, "Five or six makes a session."); + D("days_per_handover", "days", sess, ApplyScope.NextDay, "How far the calendar jumps when the headset changes hands."); + + D("households_per_lane", "count", scen, ApplyScope.NextSession, "Section 11: eight to ten plots along two or three lanes."); + D("lanes", "count", scen, ApplyScope.NextSession, "Number of lanes."); + D("module2_container_clearance", "fraction 0-1", scen, ApplyScope.NextSession, "What the squad cleared in Module 2. Open question 3b - this is how that state carries over."); + D("index_case_head_start_days", "days", scen, ApplyScope.NextSession, "How long the first patients have been ill when the squad arrives."); + D("seed_asymptomatic_index_case", "true/false", scen, ApplyScope.NextSession, "Section 5: one chain must begin where nobody was ever visibly ill."); + D("min_productive_containers_per_household", "count", scen, ApplyScope.NextSession, "Cryptic sites no sweep finds. Why perfect prevention still leaves mosquitoes."); + D("guaranteed_warning_sign_cases", "count", scen, ApplyScope.NextSession, "Teaching guarantee, not a clinical rate: how many referral situations a session must present."); + return l; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs.meta new file mode 100644 index 00000000..117b4c38 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91bc3f3c8206460b991d2628b7829d46 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ParameterTimeline.cs b/Assets/1.TeamWorkspace/Module3/Sim/ParameterTimeline.cs new file mode 100644 index 00000000..c6846430 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/ParameterTimeline.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// + /// The parameters in force on each day of a session, in order. + /// + /// GAMA can send a new CSV while a session is running, so "the config" is not one object - + /// it is a sequence of them with the days they took effect. Keeping it as a timeline rather + /// than overwriting a field is what lets the counterfactual replay stay honest: the replay + /// re-runs against the same timeline, so the difference between the two outbreaks is still + /// the net the squad did or did not put up, and not a parameter change that only the second + /// run happened to see. + /// + public sealed class ParameterTimeline + { + private struct Entry + { + public int Day; + public Module3Config Config; + public string Origin; + } + + private readonly List entries = new List(); + + public ParameterTimeline(Module3Config initial, string origin = "defaults") + { + entries.Add(new Entry { Day = int.MinValue, Config = initial ?? Module3Config.Default, Origin = origin }); + } + + /// Parameters that take effect on and stay until the next change. + public void Stage(int day, Module3Config config, string origin) + { + if (config == null) return; + + // A change staged for a day already covered replaces that entry rather than stacking, + // so re-sending the same file twice cannot drift the timeline. + for (int i = 0; i < entries.Count; i++) + { + if (entries[i].Day == day) + { + entries[i] = new Entry { Day = day, Config = config, Origin = origin }; + return; + } + } + + int at = entries.Count; + for (int i = 0; i < entries.Count; i++) + { + if (entries[i].Day > day) { at = i; break; } + } + entries.Insert(at, new Entry { Day = day, Config = config, Origin = origin }); + } + + public Module3Config At(int day) + { + Module3Config current = entries[0].Config; + for (int i = 1; i < entries.Count; i++) + { + if (entries[i].Day > day) break; + current = entries[i].Config; + } + return current; + } + + public string OriginAt(int day) + { + string origin = entries[0].Origin; + for (int i = 1; i < entries.Count; i++) + { + if (entries[i].Day > day) break; + origin = entries[i].Origin; + } + return origin; + } + + public int ChangeCount => entries.Count - 1; + + /// A copy the counterfactual can replay against without touching the live one. + public ParameterTimeline Clone() + { + var copy = new ParameterTimeline(entries[0].Config, entries[0].Origin); + for (int i = 1; i < entries.Count; i++) + { + copy.entries.Add(entries[i]); + } + return copy; + } + + /// For the Field Journal and the debrief: what changed, and when. + public List Describe() + { + var lines = new List(); + for (int i = 1; i < entries.Count; i++) + { + lines.Add($"day {entries[i].Day}: parameters from {entries[i].Origin}"); + } + return lines; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ParameterTimeline.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/ParameterTimeline.cs.meta new file mode 100644 index 00000000..6175157f --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/ParameterTimeline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: acd65cd76ddd466e950a6a8e32c8d594 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Person.cs b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs new file mode 100644 index 00000000..f76891db --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs @@ -0,0 +1,108 @@ +namespace Aedes.Module3.Sim +{ + public enum HealthState + { + Susceptible, + Exposed, // infected, incubating, nothing to see + Infectious, // can pass the virus to a mosquito that bites them + Recovered, + Hospitalised, + } + + public enum AgeBand { Child, Adult, Elder } + + /// The two warning signs the module teaches. Section 4 - deliberately only two. + public enum WarningSign { None, Bleeding, PersistentVomiting } + + public sealed class Person + { + public int Id; + public int HouseholdId; + public AgeBand Age; + public string Name; + + public HealthState State = HealthState.Susceptible; + + /// + /// True once this person has been infected and their course of illness scheduled. + /// + /// This exists because the day fields below can legitimately be NEGATIVE: an index case + /// was infected before the squad arrived, so their exposure, onset and infectious window + /// may all sit before day 0. Testing "is this day field >= 0" to mean "has this been set" + /// silently makes those people non-infectious, which is the outbreak's whole starting + /// point. Ask this flag instead. + /// + public bool HasBeenInfected; + + // Set when the person is infected; all derived from per-person RNG draws so they are + // stable across a counterfactual re-run. + public int ExposedDay = -1; + public int SymptomOnsetDay = -1; + public int FeverEndDay = -1; + public int InfectiousStartDay = -1; + public int InfectiousEndDay = -1; + public bool IsAsymptomatic; + + public WarningSign Warning = WarningSign.None; + public int WarningOnsetDay = -1; + + // What the volunteer has done for them. + public bool HasNet; + public bool WearsLongSleeves; + public int RepellentUntilDay = -1; + public bool Referred; + public int ReferredDay = -1; + public bool WentToHospitalUnaided; + public bool HasRested; + public bool HasFluids; + + /// + /// Visibly unwell on a given day - what a Pilot walking in would see. + /// + /// Deliberately a pure function of the scheduled days and NOT of the current State: the + /// trace-back replay asks "was the source visibly ill on the night the mosquito fed?", + /// which is a question about a day in the past, long after that person has recovered. + /// + public bool IsFebrile(int day) + { + return HasBeenInfected + && !IsAsymptomatic + && day >= SymptomOnsetDay + && day <= FeverEndDay; + } + + /// + /// Lying down during the hours Aedes bites. A febrile patient is; a well person is not. + /// This is the hinge the whole module turns on - see Module3Config.NetOverRestingPerson. + /// + public bool IsRestingInBitingHours(int day) + { + return IsFebrile(day) || State == HealthState.Hospitalised; + } + + /// Carrying enough virus for a biting mosquito to pick it up, on a given day. + public bool IsInfectiousToMosquitoes(int day) + { + return HasBeenInfected + && day >= InfectiousStartDay + && day <= InfectiousEndDay; + } + + public bool HasVisibleWarningSign(int day) + { + return Warning != WarningSign.None + && HasBeenInfected + && day >= WarningOnsetDay + && day <= WarningOnsetDay + 4; + } + + /// + /// True once the person has been ill and is now improving. Section 8: "feeling better is + /// not the all-clear" - the dangerous phase often begins as the fever drops. + /// + public bool IsDefervescing(int day) + { + return HasBeenInfected && !IsAsymptomatic && day >= FeverEndDay && day <= FeverEndDay + 2; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Person.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs.meta new file mode 100644 index 00000000..4bf25c03 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0e075262382b40c8bceb8a858931eb4a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/PlayerAction.cs b/Assets/1.TeamWorkspace/Module3/Sim/PlayerAction.cs new file mode 100644 index 00000000..3b5545a5 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/PlayerAction.cs @@ -0,0 +1,60 @@ +namespace Aedes.Module3.Sim +{ + /// + /// Everything the volunteer can do, and nothing else. + /// + /// There is no ClearContainer (section 5: "not a button, not a task, not a score") and no + /// Diagnose or Treat (sections 4 and 12: "The player never names the illness, never prescribes + /// anything and never gives medicine"). The absence of those verbs is itself a teaching device, + /// so this enum is a closed list on purpose - adding to it needs a reason from the design. + /// + public enum ActionKind + { + Visit, + PutUpNet, + ReclaimNet, + RepairScreen, + SetFan, + AdviseClosingHours, + GiveRepellent, + BringWater, + HelpRest, + ReferToHealthCentre, + } + + public struct PlayerAction + { + public ActionKind Kind; + public int PersonId; + public int HouseholdId; + + public static PlayerAction OnPerson(ActionKind kind, int personId) => + new PlayerAction { Kind = kind, PersonId = personId, HouseholdId = -1 }; + + public static PlayerAction OnHousehold(ActionKind kind, int householdId) => + new PlayerAction { Kind = kind, PersonId = -1, HouseholdId = householdId }; + } + + public enum ActionOutcome + { + /// Taking a net back from someone who no longer needs it, to use it on someone who does. + NetReclaimed, + Done, + /// The action was correct and is the strongest positive in the module (section 10). + ReferralCorrect, + /// Section 10: "Gently corrected, not punished. This is a teaching moment, not a penalty." + ReferralNotNeeded, + /// No nets left. Section 5: the shortage is the point. + NoNetsRemaining, + AlreadyDone, + Invalid, + } + + public struct ActionResult + { + public ActionOutcome Outcome; + public PlayerAction Action; + /// Localization key for what the game says back to the player. + public string MessageKey; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/PlayerAction.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/PlayerAction.cs.meta new file mode 100644 index 00000000..0d4dcf83 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/PlayerAction.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 38d1bed90e334e54976c469cbddaca84 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Rng.cs b/Assets/1.TeamWorkspace/Module3/Sim/Rng.cs new file mode 100644 index 00000000..70ac4a44 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Rng.cs @@ -0,0 +1,78 @@ +namespace Aedes.Module3.Sim +{ + /// + /// Which decision a random draw belongs to. Every draw in the model names its stream so that + /// two draws for the same entity on the same day never collide. + /// + public enum RngStream + { + Layout = 1, + Residents, + Containers, + IndexCase, + Incubation, + Asymptomatic, + FeverDuration, + WarningSign, + WarningSignOnset, + MosquitoEmergence, + MosquitoLifespan, + MosquitoDispersal, + Bite, + BiteTarget, + HumanToMosquito, + MosquitoToHuman, + } + + /// + /// A stateless, hash-based random source. + /// + /// This is deliberately NOT a sequential PRNG. Every draw is a pure function of + /// (seed, day, stream, entity, salt), so a draw for one mosquito on one day is completely + /// independent of how many draws anything else made. That is what makes the counterfactual + /// replay in section 5 of the design honest: re-running the outbreak with one extra net + /// changes the outcome because of the net, not because the whole random stream shifted. + /// + public static class Rng + { + private static ulong Mix(ulong x) + { + // splitmix64 finalizer + x += 0x9E3779B97F4A7C15UL; + ulong z = x; + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL; + return z ^ (z >> 31); + } + + private static ulong Hash(int seed, int day, RngStream stream, int entity, int salt) + { + ulong h = Mix((ulong)(uint)seed); + h = Mix(h ^ (ulong)(uint)day * 0x100000001B3UL); + h = Mix(h ^ (ulong)(uint)(int)stream * 0x9E3779B1UL); + h = Mix(h ^ (ulong)(uint)entity * 0xC2B2AE35UL); + h = Mix(h ^ (ulong)(uint)salt * 0x27D4EB2FUL); + return h; + } + + /// Uniform value in [0, 1). + public static double Unit(int seed, int day, RngStream stream, int entity, int salt = 0) + { + // 53 bits of mantissa, the standard way to get a double without bias. + return (Hash(seed, day, stream, entity, salt) >> 11) * (1.0 / 9007199254740992.0); + } + + public static bool Chance(double probability, int seed, int day, RngStream stream, int entity, int salt = 0) + { + return Unit(seed, day, stream, entity, salt) < probability; + } + + /// Uniform integer in [minInclusive, maxInclusive]. + public static int Range(int minInclusive, int maxInclusive, int seed, int day, RngStream stream, int entity, int salt = 0) + { + if (maxInclusive <= minInclusive) return minInclusive; + int span = maxInclusive - minInclusive + 1; + return minInclusive + (int)(Unit(seed, day, stream, entity, salt) * span); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Rng.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Rng.cs.meta new file mode 100644 index 00000000..ead14dcb --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Rng.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 65a762b8676b4b03ab3b95d0726f8c1a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ScenarioBuilder.cs b/Assets/1.TeamWorkspace/Module3/Sim/ScenarioBuilder.cs new file mode 100644 index 00000000..aa42afd6 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/ScenarioBuilder.cs @@ -0,0 +1,242 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// + /// What the squad left behind at the end of Module 2 (design section 5, open question 3b). + /// Module 3 begins after prevention has already been decided; this is how that decision + /// arrives. A squad that cleared the neighbourhood turns up to fewer mosquitoes. + /// + public struct Module2Outcome + { + /// 0 = nothing was cleared, 1 = every container in the neighbourhood was cleared. + public double ContainerClearance; + + public static Module2Outcome NothingCleared => new Module2Outcome { ContainerClearance = 0.0 }; + public static Module2Outcome Typical => new Module2Outcome { ContainerClearance = 0.35 }; + + /// + /// The best a squad can realistically manage. Not 1.0: a blocked gutter, water under a + /// slab and the jar behind the shed survive any sweep, which is why prevention lowers the + /// mosquito population without ever removing it - and why Module 3 still has work to do. + /// + public static Module2Outcome EverythingCleared => new Module2Outcome { ContainerClearance = 0.80 }; + } + + public struct ScenarioOptions + { + public int HouseholdsPerLane; + public int Lanes; + public Module2Outcome Module2; + + /// Days the symptomatic index case has already been ill when the squad arrives. + public int IndexCaseHeadStartDays; + + /// Section 5: "one chain begins at a house where nobody was ever visibly ill." + public bool SeedAsymptomaticIndexCase; + + /// + /// Even a squad that did Module 3 perfectly cannot clear what it cannot find - a blocked + /// gutter, water under a slab. Every plot keeps at least this many productive containers, + /// which is why "we cleared everything" never means "there are no mosquitoes" and why the + /// outbreak still runs for a squad that did the container work and nothing else. + /// + public int MinProductiveContainersPerHousehold; + + /// + /// How many cases are guaranteed to develop a warning sign during the session. + /// + /// This is a teaching guarantee, not a clinical claim. Section 4 calls the referral rule + /// "the heart of the module" and section 10 calls acting on it "the strongest positive", + /// so a session in which no warning sign ever appears has failed to teach the thing the + /// module says students will use most often in life. The background rate in + /// Module3Config.WarningSignShare stays honest for everybody else; this only makes sure + /// the squad meets the situation at all. + /// + public int GuaranteedWarningSignCases; + + public static ScenarioOptions Default => new ScenarioOptions + { + // Section 11: "eight to ten household plots packed along two or three narrow lanes". + HouseholdsPerLane = 5, + Lanes = 2, + Module2 = Module2Outcome.Typical, + IndexCaseHeadStartDays = 4, + SeedAsymptomaticIndexCase = true, + MinProductiveContainersPerHousehold = 2, + GuaranteedWarningSignCases = 2, + }; + } + + public static class ScenarioBuilder + { + public static Neighbourhood Build(int seed, Module3Config config, ScenarioOptions options) + { + var n = new Neighbourhood(seed, config); + + int containerId = 0; + + for (int y = 0; y < options.Lanes; y++) + { + for (int x = 0; x < options.HouseholdsPerLane; x++) + { + var h = new Household + { + Id = n.Households.Count, + LaneX = x, + LaneY = y, + Name = $"house.{y}.{x}", + }; + + // Section 6: screens are "widespread, though often torn or missing on one + // window - a good thing for the player to notice and fix." + double screenRoll = Rng.Unit(seed, 0, RngStream.Layout, h.Id, 1); + h.Screens = screenRoll < 0.35 ? ScreenState.Missing + : screenRoll < 0.80 ? ScreenState.Torn + : ScreenState.Intact; + + // Fans are "very common"; air conditioning is "uncommon in the households this + // module depicts" and the game must not depend on it. + h.HasFan = Rng.Unit(seed, 0, RngStream.Layout, h.Id, 2) < 0.35; + h.HasAirConditioning = Rng.Unit(seed, 0, RngStream.Layout, h.Id, 3) < 0.08; + + int containers = Rng.Range(2, 6, seed, 0, RngStream.Containers, h.Id); + for (int c = 0; c < containers; c++) + { + h.Containers.Add(new Container + { + Id = containerId++, + HouseholdId = h.Id, + Kind = (ContainerKind)Rng.Range(0, 5, seed, 0, RngStream.Containers, h.Id, 10 + c), + ClearedInModule2 = + Rng.Unit(seed, 0, RngStream.Containers, h.Id, 100 + c) < options.Module2.ContainerClearance, + }); + } + + EnsureResidualContainers(h, options.MinProductiveContainersPerHousehold); + n.Households.Add(h); + } + } + + // Neighbours: along the lane and directly across it. Section 5 - Aedes aegypti does not + // travel far, so the chain runs between neighbours rather than across a district. + for (int i = 0; i < n.Households.Count; i++) + { + var a = n.Households[i]; + for (int j = 0; j < n.Households.Count; j++) + { + if (i == j) continue; + var b = n.Households[j]; + int dx = a.LaneX - b.LaneX; if (dx < 0) dx = -dx; + int dy = a.LaneY - b.LaneY; if (dy < 0) dy = -dy; + if (dx + dy == 1) a.NeighbourIds.Add(b.Id); + } + } + + // Residents. Section 11: "ages varied, including children and older people." + for (int i = 0; i < n.Households.Count; i++) + { + var h = n.Households[i]; + int residents = Rng.Range(3, 5, seed, 0, RngStream.Residents, h.Id); + for (int r = 0; r < residents; r++) + { + var p = new Person + { + Id = n.People.Count, + HouseholdId = h.Id, + Age = (AgeBand)Rng.Range(0, 2, seed, 0, RngStream.Residents, h.Id, 10 + r), + Name = $"person.{h.Id}.{r}", + }; + h.ResidentIds.Add(p.Id); + n.People.Add(p); + } + } + + SeedStandingMosquitoes(n); + SeedIndexCases(n, options); + return n; + } + + private static void EnsureResidualContainers(Household h, int minimum) + { + for (int i = 0; i < h.Containers.Count && h.ProductiveContainerCount < minimum; i++) + { + h.Containers[i].ClearedInModule2 = false; + } + } + + /// + /// Section 5: "The mosquitoes are already flying and the virus is already in the + /// neighbourhood." The squad does not arrive to an empty sky - it arrives into the + /// population that the state of these yards has been producing for weeks. + /// + private static void SeedStandingMosquitoes(Neighbourhood n) + { + int days = n.Config.StandingPopulationDays; + for (int i = 0; i < n.Households.Count; i++) + { + var h = n.Households[i]; + int productive = h.ProductiveContainerCount; + if (productive == 0) continue; + + int total = (int)(productive * n.Config.EmergencePerContainerPerDay * days); + if (total > n.Config.MaxMosquitoesPerHousehold) total = n.Config.MaxMosquitoesPerHousehold; + + for (int k = 0; k < total; k++) + { + var c = n.PickProductiveContainer(h, 200 + k); + if (c == null) break; + // Staggered so the standing population ages out naturally rather than all at once. + n.SpawnMosquito(h, c, -Rng.Range(0, days - 1, n.Seed, 0, RngStream.MosquitoEmergence, h.Id, 300 + k)); + } + } + } + + private static void SeedIndexCases(Neighbourhood n, ScenarioOptions options) + { + // The visible patient the squad is sent to find: mid-lane, so the chain has somewhere + // to run in both directions. + // Section 1: "Dengue cases are rising in a neighbourhood." The squad arrives to more + // than one patient, which is what makes the net budget a choice from the first turn. + // Both show symptoms on day 0, so "cover the first two patients on the first day" + // is something a Pilot can actually do rather than a hidden requirement. + int middle = n.Households.Count / 2; + int patientZero = FirstAdult(n, middle); + if (patientZero >= 0) + n.SeedIndexCase(patientZero, options.IndexCaseHeadStartDays, false, onsetDay: 0, forceSymptomatic: true); + + int second = FirstAdult(n, middle + 1 < n.Households.Count ? middle + 1 : 0); + if (second >= 0 && second != patientZero) + n.SeedIndexCase(second, options.IndexCaseHeadStartDays, false, onsetDay: 0, forceSymptomatic: true); + + // One of the two patients the squad meets first develops a warning sign as their + // fever comes down - section 8: "Danger often arrives as the fever comes down." + // A Pilot who sees them looking better and moves on has made the module's mistake. + int forced = 0; + if (options.GuaranteedWarningSignCases > 0 && second >= 0 && n.ForceWarningSign(second)) forced++; + if (forced < options.GuaranteedWarningSignCases && patientZero >= 0 && n.ForceWarningSign(patientZero)) forced++; + n.PendingGuaranteedWarningSigns = options.GuaranteedWarningSignCases - forced; + + if (!options.SeedAsymptomaticIndexCase) return; + + // The source nobody can find. Section 5: "You cannot net a person who does not know + // they are infected." Placed at the far end so its chain is distinguishable on the + // outbreak map from patient zero's. + int farEnd = n.Households.Count - 1; + int invisible = FirstAdult(n, farEnd); + if (invisible >= 0) n.SeedIndexCase(invisible, options.IndexCaseHeadStartDays, forceAsymptomatic: true); + } + + private static int FirstAdult(Neighbourhood n, int householdId) + { + var h = n.HouseholdById(householdId); + if (h == null) return -1; + for (int i = 0; i < h.ResidentIds.Count; i++) + { + var p = n.PersonById(h.ResidentIds[i]); + if (p != null && p.Age == AgeBand.Adult) return p.Id; + } + return h.ResidentIds.Count > 0 ? h.ResidentIds[0] : -1; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ScenarioBuilder.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/ScenarioBuilder.cs.meta new file mode 100644 index 00000000..eb24bf6f --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/ScenarioBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 235714cd448b4124a412ecd941611725 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/SimRunner.cs b/Assets/1.TeamWorkspace/Module3/Sim/SimRunner.cs new file mode 100644 index 00000000..c966b01a --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/SimRunner.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + public struct RunResult + { + public int Seed; + public int TotalCases; + public int SecondaryCases; + public int PreventableCases; + public int CasesFromInvisibleSource; + public int HospitalisedUnaided; + public int FinalMosquitoCount; + public Neighbourhood Neighbourhood; + } + + /// + /// Plays a whole session without Unity. Used by the calibration tests, by the end-of-session + /// counterfactual, and by anyone who wants to see what a parameter change does before opening + /// the editor. + /// + public static class SimRunner + { + public static RunResult Run(int seed, Module3Config config, ScenarioOptions options, IStrategy strategy) + { + var n = ScenarioBuilder.Build(seed, config, options); + int turns = config.Rounds * config.TurnsPerRound; + + for (int turn = 0; turn < turns; turn++) + { + strategy?.OnTurn(n, turn); + // Section 8: the handover carries the time. The game skips days rather than + // speeding anything up, so the biology a student learns stays true. + n.AdvanceDays(config.DaysPerHandover); + } + + int unaided = 0; + for (int i = 0; i < n.People.Count; i++) if (n.People[i].WentToHospitalUnaided) unaided++; + + return new RunResult + { + Seed = seed, + TotalCases = n.TotalCases, + SecondaryCases = n.SecondaryCases, + PreventableCases = n.Log.PreventableCount, + CasesFromInvisibleSource = n.Log.InvisibleSourceCount, + HospitalisedUnaided = unaided, + FinalMosquitoCount = n.AliveMosquitoCount, + Neighbourhood = n, + }; + } + + /// + /// Section 5: "At the end, before any score is shown, the game replays what would have + /// happened had the first patient been covered on day one." + /// + /// The squad's own actions are replayed unchanged; the only difference is one net, put up + /// on turn zero. Because every random draw is keyed on the entity rather than drawn from a + /// running stream, the two outbreaks are genuinely comparable - the difference is the net. + /// + public static RunResult Counterfactual(int seed, Module3Config config, ScenarioOptions options, + ScriptedActions squadActions, int patientZeroId) + { + var withNet = new ScriptedActions(); + withNet.Add(0, PlayerAction.OnPerson(ActionKind.PutUpNet, patientZeroId)); + if (squadActions != null) withNet.Absorb(squadActions); + return Run(seed, config, options, withNet); + } + + /// + /// Runs one strategy over many seeds. The calibration harness asserts on the average, + /// because a single outbreak is noisy and a teaching guarantee should not be. + /// + public static double AverageSecondaryCases(int seedCount, Module3Config config, + ScenarioOptions options, System.Func strategy) + { + double total = 0; + for (int seed = 1; seed <= seedCount; seed++) + { + total += Run(seed, config, options, strategy()).SecondaryCases; + } + return total / seedCount; + } + + public static List RunMany(int seedCount, Module3Config config, + ScenarioOptions options, System.Func strategy) + { + var results = new List(seedCount); + for (int seed = 1; seed <= seedCount; seed++) results.Add(Run(seed, config, options, strategy())); + return results; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/SimRunner.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/SimRunner.cs.meta new file mode 100644 index 00000000..0a46992f --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/SimRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1717a9406ec4737b3722afc9022e26d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Strategies.cs b/Assets/1.TeamWorkspace/Module3/Sim/Strategies.cs new file mode 100644 index 00000000..277403bb --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Strategies.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// + /// A scripted stand-in for a squad. Called once per turn, before the calendar jumps. + /// Used by the calibration harness to prove the module's teaching guarantees hold, and by + /// the counterfactual replay to re-run an outbreak with one decision changed. + /// + public interface IStrategy + { + void OnTurn(Neighbourhood n, int turnIndex); + } + + /// The squad that does nothing. The outbreak the neighbourhood gets by default. + public sealed class DoNothing : IStrategy + { + public void OnTurn(Neighbourhood n, int turnIndex) { } + } + + /// + /// The instinct everybody has: protect the people who are still healthy. Section 5 says the + /// module "should let them try it, and then show them that the neighbourhood kept getting new + /// cases anyway - because the source was never covered." + /// + public sealed class NetTheWell : IStrategy + { + public void OnTurn(Neighbourhood n, int turnIndex) + { + if (n.NetsRemaining <= 0) return; + for (int i = 0; i < n.People.Count && n.NetsRemaining > 0; i++) + { + var p = n.People[i]; + if (p.HasNet) continue; + if (p.IsFebrile(n.Day)) continue; // pointedly skipping the sick + n.Apply(PlayerAction.OnPerson(ActionKind.PutUpNet, p.Id)); + } + } + } + + /// + /// Section 5: "A squad that covers the first two patients on the first day should see the + /// outbreak stop, even with containers still standing in the yards." + /// + public sealed class NetFirstPatients : IStrategy + { + private readonly int count; + public NetFirstPatients(int count = 2) { this.count = count; } + + public void OnTurn(Neighbourhood n, int turnIndex) + { + if (turnIndex > 0) return; // day one only + + // Only what a Pilot can actually see on a visit. A strategy that acted on the model's + // hidden state would flatter the module and prove nothing about what a squad can do. + var ill = n.VisiblyIll(); + int done = 0; + for (int i = 0; i < ill.Count && done < count; i++) + { + if (ill[i].HasNet) continue; + if (n.Apply(PlayerAction.OnPerson(ActionKind.PutUpNet, ill[i].Id)).Outcome == ActionOutcome.Done) done++; + } + } + } + + /// + /// The best a squad can actually do: cover every patient they can see, every turn. + /// The outbreak should nearly stop - but not completely, because of the cases nobody can see. + /// That residue is the bridge back to Module 2 (section 5). + /// + public sealed class NetEveryVisiblePatient : IStrategy + { + public void OnTurn(Neighbourhood n, int turnIndex) + { + // Take the nets back off people who are past it. A Pilot can work this out from the + // household's own words - section 7: "she has been hot for three days." + for (int i = 0; i < n.People.Count; i++) + { + var p = n.People[i]; + if (p.HasNet && !p.IsInfectiousToMosquitoes(n.Day)) + { + n.Apply(PlayerAction.OnPerson(ActionKind.ReclaimNet, p.Id)); + } + } + + // Cover the newest patients first: they have the most infectious days ahead of them, + // and a net put up on someone whose fever is nearly over buys almost nothing. + var ill = n.VisiblyIll(); + ill.Sort((a, b) => b.SymptomOnsetDay.CompareTo(a.SymptomOnsetDay)); + for (int i = 0; i < ill.Count && n.NetsRemaining > 0; i++) + { + if (ill[i].HasNet) continue; + n.Apply(PlayerAction.OnPerson(ActionKind.PutUpNet, ill[i].Id)); + } + + // And refer anyone with a warning sign - section 4, not a judgement call. + for (int i = 0; i < ill.Count; i++) + { + if (ill[i].HasVisibleWarningSign(n.Day) && !ill[i].Referred) + { + n.Apply(PlayerAction.OnPerson(ActionKind.ReferToHealthCentre, ill[i].Id)); + } + } + } + } + + /// Replays a recorded list of actions, which is how the counterfactual is built. + public sealed class ScriptedActions : IStrategy + { + private readonly Dictionary> byTurn = new Dictionary>(); + + public ScriptedActions Add(int turnIndex, PlayerAction action) + { + if (!byTurn.TryGetValue(turnIndex, out var list)) + { + list = new List(); + byTurn[turnIndex] = list; + } + list.Add(action); + return this; + } + + /// Copies another recording in on top of this one, keeping turn order. + public ScriptedActions Absorb(ScriptedActions other) + { + if (other == null) return this; + foreach (var pair in other.byTurn) + { + for (int i = 0; i < pair.Value.Count; i++) Add(pair.Key, pair.Value[i]); + } + return this; + } + + public void OnTurn(Neighbourhood n, int turnIndex) + { + if (!byTurn.TryGetValue(turnIndex, out var list)) return; + for (int i = 0; i < list.Count; i++) n.Apply(list[i]); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Strategies.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Strategies.cs.meta new file mode 100644 index 00000000..c1f95531 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Strategies.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9284472a174247aeaedb86bdd05cbc9f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/TransmissionEvent.cs b/Assets/1.TeamWorkspace/Module3/Sim/TransmissionEvent.cs new file mode 100644 index 00000000..8133133f --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/TransmissionEvent.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// + /// One link in the chain the player is breaking. Every field here exists because the + /// trace-back replay or the end-of-outbreak map needs it (design section 5). + /// + public sealed class TransmissionEvent + { + public int Day; + + public int TargetPersonId; + public int TargetHouseholdId; + + /// The person the mosquito had taken the virus from. -1 for a seeded index case. + public int SourcePersonId = -1; + public int SourceHouseholdId = -1; + + public int MosquitoId = -1; + public int OriginContainerId = -1; + public ContainerKind OriginContainerKind; + + /// + /// The source was visibly ill on the day the mosquito fed on them - so the squad could + /// have found and covered them. False means an asymptomatic source: the chain nobody + /// could have seen, and the bridge back to Module 2 in the debrief. + /// + public bool SourceWasVisiblyIll; + + /// The source had a net up when the mosquito fed on them. + public bool SourceWasProtected; + + /// + /// This link would not have happened had the source been covered - the sentence the + /// outbreak map is built to make visible. + /// + public bool WasPreventable => SourceWasVisiblyIll && !SourceWasProtected; + + public bool IsIndexCase => SourcePersonId < 0; + } + + public sealed class TransmissionLog + { + public readonly List Events = new List(); + + public void Add(TransmissionEvent e) => Events.Add(e); + + public int CaseCount => Events.Count; + + public int PreventableCount + { + get + { + int n = 0; + for (int i = 0; i < Events.Count; i++) if (Events[i].WasPreventable) n++; + return n; + } + } + + public int InvisibleSourceCount + { + get + { + int n = 0; + for (int i = 0; i < Events.Count; i++) + { + var e = Events[i]; + if (!e.IsIndexCase && !e.SourceWasVisiblyIll) n++; + } + return n; + } + } + + /// Walks a case back to the index case it descends from. + public List ChainTo(int personId) + { + var chain = new List(); + int current = personId; + for (int guard = 0; guard < 64; guard++) + { + TransmissionEvent found = null; + for (int i = 0; i < Events.Count; i++) + { + if (Events[i].TargetPersonId == current) { found = Events[i]; break; } + } + if (found == null) break; + chain.Insert(0, found); + if (found.IsIndexCase) break; + current = found.SourcePersonId; + } + return chain; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/TransmissionEvent.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/TransmissionEvent.cs.meta new file mode 100644 index 00000000..29a298d6 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/TransmissionEvent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a0451c0d3fa4df38c81991928c0689d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests.meta b/Assets/1.TeamWorkspace/Module3/Tests.meta new file mode 100644 index 00000000..1f4cb7ab --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0d836585b7504a1dbd6aa7b1ea07b771 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef b/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef new file mode 100644 index 00000000..ccbdea66 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef @@ -0,0 +1,24 @@ +{ + "name": "AEDES.Module3.Sim.Tests", + "rootNamespace": "Aedes.Module3.Sim.Tests", + "references": [ + "AEDES.Module3.Sim", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef.meta b/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef.meta new file mode 100644 index 00000000..347773d2 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2bde133d8341402ea38ec303a7685985 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/Module3CalibrationTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/Module3CalibrationTests.cs new file mode 100644 index 00000000..bd0a4a4a --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/Module3CalibrationTests.cs @@ -0,0 +1,280 @@ +using System; +using Aedes.Module3.Sim; +using NUnit.Framework; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// The module's teaching guarantees, written as tests. + /// + /// Section 5 of the design document does not describe these as nice-to-haves. It says the + /// module "should make it impossible to succeed by any other route" and lists the outcomes + /// that must hold. Each one is a test here. If a parameter change breaks one, the module has + /// stopped teaching the thing it exists to teach, and that should fail loudly rather than be + /// discovered by a class in Vientiane. + /// + /// This matters more now that the parameters come from a CSV that GAMA generates: this suite + /// is the acceptance gate for any parameter set, not just a tuning aid. Run it against a new + /// file before that file goes anywhere near a classroom. + /// + /// The assertions are deliberately RELATIVE - orderings and ratios, not absolute case counts - + /// so that they survive legitimate re-calibration while still catching a set that has stopped + /// teaching the lesson. + /// + [TestFixture] + public class Module3CalibrationTests + { + private const int Seeds = 200; + + private static Module3Config Config() => Module3Config.Default; + private static ScenarioOptions Typical() => ScenarioOptions.Default; + + private static ScenarioOptions AllCleared() + { + var o = ScenarioOptions.Default; + o.Module2 = Module2Outcome.EverythingCleared; + return o; + } + + private static double Average(ScenarioOptions options, Func strategy) + { + return SimRunner.AverageSecondaryCases(Seeds, Config(), options, strategy); + } + + // ----------------------------------------------------------------------------------- + // The model has to be re-runnable before any of the rest means anything. + // ----------------------------------------------------------------------------------- + + [Test] + public void SameSeedAndStrategyGivesTheSameOutbreak() + { + for (int seed = 1; seed <= 25; seed++) + { + var a = SimRunner.Run(seed, Config(), Typical(), new NetEveryVisiblePatient()); + var b = SimRunner.Run(seed, Config(), Typical(), new NetEveryVisiblePatient()); + + Assert.AreEqual(a.SecondaryCases, b.SecondaryCases, $"seed {seed} was not reproducible"); + Assert.AreEqual(a.Neighbourhood.Log.Events.Count, b.Neighbourhood.Log.Events.Count, + $"seed {seed} produced a different transmission log on re-run"); + } + } + + [Test] + public void CoveringPatientZeroOnDayOneIsAlmostAlwaysBetter() + { + // Section 5: "before any score is shown, the game replays what would have happened had + // the first patient been covered on day one." + // + // Asserted across seeds rather than on one, because an outbreak is stochastic: in a + // small minority of neighbourhoods, covering patient zero leaves mosquitoes to find + // somebody else and the counterfactual comes out no better. That is honest modelling, + // but it is also a classroom risk worth knowing the size of - if this rate climbs, the + // replay starts telling squads that the right action would have made things worse. + int worse = 0, counted = 0; + double totalReduction = 0; + + for (int seed = 1; seed <= Seeds; seed++) + { + var squad = new ScriptedActions(); + var asPlayed = SimRunner.Run(seed, Config(), Typical(), squad); + + int patientZero = -1; + foreach (var e in asPlayed.Neighbourhood.Log.Events) + { + if (e.IsIndexCase) { patientZero = e.TargetPersonId; break; } + } + if (patientZero < 0) continue; + + var covered = SimRunner.Counterfactual(seed, Config(), Typical(), squad, patientZero); + if (covered.PreventableCases > asPlayed.PreventableCases) worse++; + totalReduction += asPlayed.PreventableCases - covered.PreventableCases; + counted++; + } + + Assert.Greater(counted, 0, "the scenario seeded no index case"); + Assert.Greater(totalReduction / counted, 1.0, + "on average, covering patient zero on day one has to visibly shrink the outbreak, " + + $"but the reduction was only {totalReduction / counted:F2} preventable chains"); + Assert.Less(worse / (double)counted, 0.05, + $"the replay showed a WORSE outcome in {worse} of {counted} neighbourhoods; above about " + + "one in twenty this stops being acceptable noise and starts undermining the lesson"); + } + + // ----------------------------------------------------------------------------------- + // Section 5 - "Designing it so the lesson cannot be avoided" + // ----------------------------------------------------------------------------------- + + [Test] + public void ADoNothingNeighbourhoodSeesAnOutbreak() + { + double doNothing = Average(Typical(), () => new DoNothing()); + Assert.Greater(doNothing, 5.0, + "a squad that does nothing should watch a clearly growing outbreak, " + + $"but the average was only {doNothing:F2} secondary cases"); + } + + [Test] + public void ClearingEveryContainerDoesNotStopAnOutbreakAlreadyUnderWay() + { + // "A squad that clears every container in the neighbourhood but leaves the sick + // unprotected should still watch the outbreak grow. Containers reduce the mosquito + // population; they do not stop a virus that is already in a person." + double cleared = Average(AllCleared(), () => new DoNothing()); + double nettingTheSick = Average(Typical(), () => new NetFirstPatients(2)); + + Assert.Greater(cleared, 3.0, + $"prevention alone left only {cleared:F2} secondary cases - the outbreak has to still run, " + + "or the module teaches that Module 2's work was sufficient on its own"); + + Assert.Greater(cleared, nettingTheSick, + $"clearing containers ({cleared:F2}) must not beat covering the patients " + + $"({nettingTheSick:F2}) - that inverts the whole lesson of the module"); + } + + [Test] + public void NettingTheWellIsNoBetterThanDoingNothing() + { + // "A student will reach for the net to protect the healthy, which is the instinct + // everyone has. The module should let them try it, and then show them that the + // neighbourhood kept getting new cases anyway." + double doNothing = Average(Typical(), () => new DoNothing()); + double netTheWell = Average(Typical(), () => new NetTheWell()); + + Assert.Greater(netTheWell, doNothing * 0.85, + $"netting the well ({netTheWell:F2}) came out meaningfully better than doing nothing " + + $"({doNothing:F2}); the instinct students arrive with has to be shown not to work"); + } + + [Test] + public void CoveringTheFirstPatientsOnDayOneStopsTheOutbreak() + { + // "A squad that covers the first two patients on the first day should see the outbreak + // stop, even with containers still standing in the yards." + double doNothing = Average(Typical(), () => new DoNothing()); + double coveredEarly = Average(Typical(), () => new NetFirstPatients(2)); + + Assert.Less(coveredEarly, doNothing * 0.55, + $"covering the first two patients on day one only brought {doNothing:F2} down to " + + $"{coveredEarly:F2}; this is the strongest action in the module and it has to look like it"); + } + + [Test] + public void CoveringEveryVisiblePatientLeavesOnlyTheInvisibleChains() + { + // "The outbreak smoulders on even when every visible patient is covered, and a sharp + // student asks why. The honest answer is the bridge back to Module 2." + var results = SimRunner.RunMany(Seeds, Config(), Typical(), () => new NetEveryVisiblePatient()); + + double secondary = 0, invisible = 0; + foreach (var r in results) { secondary += r.SecondaryCases; invisible += r.CasesFromInvisibleSource; } + + Assert.Greater(secondary, 0, "covering every visible patient should not end the outbreak completely - " + + "the cases nobody can see are what sends the debrief back to Module 2"); + + Assert.Greater(invisible / secondary, 0.4, + $"only {invisible / secondary:P0} of the remaining cases came from sources nobody could see; " + + "the residue has to be mostly invisible or the bridge back to Module 2 does not hold up"); + } + + [Test] + public void EverySessionPresentsAtLeastOneReferralSituation() + { + // Section 4 calls the referral rule "the heart of the module"; section 10 calls acting + // on it "the strongest positive". A session where no warning sign ever appears has + // failed to teach the thing students will use most often in life. + var results = SimRunner.RunMany(Seeds, Config(), Typical(), () => new DoNothing()); + + int without = 0; + foreach (var r in results) + { + int warned = 0; + foreach (var p in r.Neighbourhood.People) if (p.Warning != WarningSign.None) warned++; + if (warned == 0) without++; + } + + Assert.AreEqual(0, without, + $"{without} of {Seeds} sessions presented no warning sign at all"); + } + + [Test] + public void ActingOnWarningSignsKeepsPeopleOutOfHospitalUnaided() + { + // Section 10: a missed warning sign is "not points deducted - a consequence shown." + // The consequence has to actually differ. + double ignored = 0, acted = 0; + for (int seed = 1; seed <= Seeds; seed++) + { + ignored += SimRunner.Run(seed, Config(), Typical(), new DoNothing()).HospitalisedUnaided; + acted += SimRunner.Run(seed, Config(), Typical(), new NetEveryVisiblePatient()).HospitalisedUnaided; + } + + Assert.Less(acted, ignored * 0.6, + $"a squad that refers ({acted / Seeds:F2} unaided hospitalisations) has to visibly differ from " + + $"one that does not ({ignored / Seeds:F2})"); + } + + [Test] + public void ThereAreFewerNetsThanHouseholds() + { + // "The nets available to the player should be limited in number - fewer than there are + // households. The player must choose who gets one, and the choice is the whole point." + var options = Typical(); + int households = options.Lanes * options.HouseholdsPerLane; + Assert.Less(Config().NetsPerSession, households, + "give the player a net for every household and there is nothing left to learn"); + } + + [Test] + public void ANetOverSomeoneUpAndAboutIsWorthFarLessThanOverAPatientInBed() + { + // The distinction the whole module turns on, and the one a re-calibration could + // quietly erase. Section 6: Aedes bites during the day, and the patient is in bed + // during the day. + var c = Config(); + Assert.Less(c.NetOverRestingPerson, c.NetOverActivePerson * 0.5, + "if a net helps a well person nearly as much as a patient, netting the sick stops " + + "being a different action from netting the well, and the module has no lesson left"); + } + + [Test] + public void SymptomlessCasesAreLessInfectiousThanFebrileOnes() + { + // Section 5: "Netting the visible patients still removes the largest and most + // infectious contributors, because virus levels peak during the fever." + Assert.Less(Config().AsymptomaticRelativeInfectiousness, 1.0, + "with symptomless cases as infectious as febrile ones, covering the patients a squad " + + "can actually find stops being the best available action"); + } + + // ----------------------------------------------------------------------------------- + // Section 8 - the compression is in the calendar, not the biology + // ----------------------------------------------------------------------------------- + + [Test] + public void ClinicalTimingsAreNotCompressed() + { + var c = Config(); + Assert.GreaterOrEqual(c.IncubationMinDays, 4, "bite to symptoms is four to ten days"); + Assert.LessOrEqual(c.IncubationMaxDays, 10, "bite to symptoms is four to ten days"); + Assert.GreaterOrEqual(c.ExtrinsicIncubationMinDays, 8, + "a mosquito needs eight to twelve days after feeding before it can transmit"); + Assert.LessOrEqual(c.ExtrinsicIncubationMaxDays, 12, + "a mosquito needs eight to twelve days after feeding before it can transmit"); + } + + [Test] + public void AnIndexCaseInfectedBeforeDayZeroIsStillInfectious() + { + // A regression guard. The squad arrives into an outbreak already under way, so an + // index case's exposure, onset and infectious window can all sit before day 0. + // Treating a negative day as "not set" silently makes the outbreak's starting point + // non-infectious, and every chain then traces to somewhere else. + var n = ScenarioBuilder.Build(7, Config(), Typical()); + int infectiousOnDayOne = 0; + foreach (var p in n.People) if (p.IsInfectiousToMosquitoes(1)) infectiousOnDayOne++; + + Assert.Greater(infectiousOnDayOne, 0, + "nobody was infectious on day one - the squad has arrived into an outbreak with no source"); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/Module3CalibrationTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/Module3CalibrationTests.cs.meta new file mode 100644 index 00000000..89e124cd --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/Module3CalibrationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27a8490ab74c46588723ad7ce7f5db4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/ParameterCsvTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/ParameterCsvTests.cs new file mode 100644 index 00000000..9139c0ae --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/ParameterCsvTests.cs @@ -0,0 +1,139 @@ +using Aedes.Module3.Sim; +using NUnit.Framework; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// The CSV that GAMA generates is the only route the simulation parameters take into the + /// game, including mid-session. These cover the things that would go wrong quietly. + /// + [TestFixture] + public class ParameterCsvTests + { + [Test] + public void TheTemplateRoundTrips() + { + var defaults = new ParameterSet(); + string csv = ParameterCsv.WriteTemplate(defaults); + var back = ParameterCsv.Parse(csv, "round-trip"); + + Assert.IsTrue(back.IsValid, string.Join("; ", back.Errors)); + Assert.IsEmpty(back.UnknownKeys, "the template contains a key the reader does not know"); + Assert.AreEqual(defaults.Config.IncubationMinDays, back.Config.IncubationMinDays); + Assert.AreEqual(defaults.Config.AsymptomaticShare, back.Config.AsymptomaticShare, 1e-4); + Assert.AreEqual(defaults.Scenario.Module2.ContainerClearance, + back.Scenario.Module2.ContainerClearance, 1e-4); + } + + [Test] + public void CommentLinesAboveTheHeaderAreSkipped() + { + string csv = "# a note from GAMA\n# another\nkey,value\nrounds,4\n"; + var set = ParameterCsv.Parse(csv); + Assert.IsTrue(set.IsValid, string.Join("; ", set.Errors)); + Assert.AreEqual(4, set.Config.Rounds); + } + + [Test] + public void APartialFileLeavesEverythingElseAlone() + { + // GAMA may send only what changed. A partial file must not reset the rest to the + // build's defaults, so mid-session reads layer onto what is already in force. + var current = ParameterCsv.Parse("key,value\nrounds,9\nnets_per_session,7\n"); + var update = ParameterCsv.Parse("key,value\ndaily_bite_probability,0.2\n", "gama", current); + + Assert.IsTrue(update.IsValid, string.Join("; ", update.Errors)); + Assert.AreEqual(0.2, update.Config.DailyBiteProbability, 1e-6); + Assert.AreEqual(9, update.Config.Rounds, "a key absent from the update was reset"); + Assert.AreEqual(7, update.Config.NetsPerSession, "a key absent from the update was reset"); + CollectionAssert.AreEqual(new[] { "daily_bite_probability" }, update.PresentKeys); + } + + [Test] + public void UnknownKeysAreReportedButDoNotFailTheFile() + { + // A newer GAMA model may know about parameters this build does not. That should be + // visible in the log, not fatal in a classroom. + var set = ParameterCsv.Parse("key,value\nrounds,5\nsome_future_parameter,3\n"); + Assert.IsTrue(set.IsValid); + CollectionAssert.Contains(set.UnknownKeys, "some_future_parameter"); + } + + [Test] + public void ValuesOutsideTheirRangeAreRefused() + { + var set = ParameterCsv.Parse("key,value\nasymptomatic_share,1.4\n"); + Assert.IsFalse(set.IsValid); + } + + [Test] + public void AFileThatWouldGiveEveryHouseholdANetIsRefused() + { + // Section 5: the shortage is what makes the player choose. This is a design + // invariant, so a parameter file cannot quietly remove it. + var set = ParameterCsv.Parse("key,value\nlanes,2\nhouseholds_per_lane,5\nnets_per_session,10\n"); + Assert.IsFalse(set.IsValid); + } + + [Test] + public void AFileThatMakesANetEquallyGoodForTheWellIsRefused() + { + var set = ParameterCsv.Parse("key,value\nnet_over_resting_person,0.9\nnet_over_active_person,0.92\n"); + Assert.IsFalse(set.IsValid); + } + + [Test] + public void WholeNumbersWrittenAsFloatsAreAccepted() + { + // GAMA writes integers as "4.0" often enough that refusing it would be petty. + var set = ParameterCsv.Parse("key,value\nrounds,6.0\n"); + Assert.IsTrue(set.IsValid, string.Join("; ", set.Errors)); + Assert.AreEqual(6, set.Config.Rounds); + } + + [Test] + public void StructuralKeysAreFlaggedAsNeedingANewSession() + { + Assert.IsTrue(ParameterCsv.RequiresRestart(new[] { "lanes" }, out var blocking)); + CollectionAssert.Contains(blocking, "lanes"); + + Assert.IsFalse(ParameterCsv.RequiresRestart(new[] { "daily_bite_probability" }, out _)); + } + + [Test] + public void AMidSessionChangeAppliesFromTheNextDayAndNotBefore() + { + var n = ScenarioBuilder.Build(1, Module3Config.Default, ScenarioOptions.Default); + n.AdvanceDays(5); + + double before = n.Config.EmergencePerContainerPerDay; + var changed = n.Config.Clone(); + changed.EmergencePerContainerPerDay = before + 5.0; + n.StageParameters(changed, "gama"); + + Assert.AreEqual(before, n.Config.EmergencePerContainerPerDay, 1e-6, + "a change must not take effect on the day it arrives - the day is already half simulated"); + + n.AdvanceDays(1); + Assert.AreEqual(before + 5.0, n.Config.EmergencePerContainerPerDay, 1e-6, + "the change should be in force on the following day"); + Assert.AreEqual(1, n.Parameters.ChangeCount); + } + + [Test] + public void ResendingTheSameChangeDoesNotStackTheTimeline() + { + var n = ScenarioBuilder.Build(1, Module3Config.Default, ScenarioOptions.Default); + n.AdvanceDays(3); + + var changed = n.Config.Clone(); + changed.DailyBiteProbability = 0.11; + n.StageParameters(changed, "gama"); + n.StageParameters(changed, "gama"); + n.StageParameters(changed, "gama"); + + Assert.AreEqual(1, n.Parameters.ChangeCount, + "a repeated delivery should replace the staged entry, not pile up"); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/ParameterCsvTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/ParameterCsvTests.cs.meta new file mode 100644 index 00000000..7e2718af --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/ParameterCsvTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6610dc46dde34471a91c76efc38bbf01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 4f3eedf22ce743a6cb297309e088c21ccf8550ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 19:45:06 +0700 Subject: [PATCH 03/61] Module 3: receive simulation parameters from GAMA as CSV, mid-session The simulation parameters are GAMA's to set, not ours to hand-pick in C# literals. This is the Unity side of that handover, built to take delivery while a session is already running. - ParameterCsv reads a key,value CSV with any extra columns GAMA wants to add, and WriteTemplate emits the current values with units, sections, apply scopes and notes. That template (Resources/Module3/Module3Parameters.csv) is the document to hand the GAMA modellers so both sides agree on key names before either writes code. - Validation refuses a file whole rather than half-applying it: out-of-range values, reversed min/max pairs, and two design invariants - a net for every household, or a net that helps a well person as much as a patient - either of which would quietly remove the module's lesson. - Partial files are layered onto what is in force, so GAMA can send only what changed. - ParameterTimeline keeps parameters as a sequence keyed by the day they took effect, not a single object. A mid-session change applies from the NEXT day and never retroactively: people already infected keep the course of illness they were given, or the trace-back replay becomes a lie about what the squad saw. The counterfactual replays against the same timeline, so the difference between the two outbreaks is still the net and not a parameter change only one run saw. - Structural keys (plot count, what Module 2 left in the yards) cannot rebuild the neighbourhood under a player who is standing in it, so they are held for the next session. - Unknown keys are logged, not fatal: a newer GAMA model may know more than this build. Module3GamaLink subclasses SimulationManager rather than editing it, per CLAUDE.md section 9. Only one SimulationManager subclass belongs on a GameObject - Modules 1 and 2 both have two fighting over the static Instance in Awake, and Module 3 should not inherit that. --- .../Module3/Scripts/Module3GamaLink.cs | 78 +++++++ .../Module3/Scripts/Module3GamaLink.cs.meta | 11 + .../Module3/Scripts/Module3Parameters.cs | 195 ++++++++++++++++++ .../Module3/Scripts/Module3Parameters.cs.meta | 11 + .../Resources/Module3/Module3Parameters.csv | 54 +++++ .../Module3/Module3Parameters.csv.meta | 7 + 6 files changed, 356 insertions(+) create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/Module3GamaLink.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/Module3GamaLink.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/Module3Parameters.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/Module3Parameters.cs.meta create mode 100644 Assets/Resources/Module3/Module3Parameters.csv create mode 100644 Assets/Resources/Module3/Module3Parameters.csv.meta diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/Module3GamaLink.cs b/Assets/1.TeamWorkspace/Module3/Scripts/Module3GamaLink.cs new file mode 100644 index 00000000..d8d21af0 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/Module3GamaLink.cs @@ -0,0 +1,78 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +/// +/// The Module 3 end of the SIMPLE bridge. Its only job today is to take delivery of the +/// simulation parameter CSV that GAMA generates, including mid-session, and hand it to +/// . +/// +/// Subclassing rather than editing SimulationManager is deliberate - see CLAUDE.md section 9, +/// "Extend, don't modify", which is what keeps pulling upstream template changes possible. +/// +/// GAMA sends it as an ordinary json_output whose first key is the one below: +/// +/// {"type": "json_output", +/// "contents": {"module3_params": "key,value\nemergence_per_container_per_day,2.4\n..."}} +/// +/// Only the keys that changed need to be present; a partial file is layered onto whatever is +/// already in force. What happens next - applied from the next day, held for the next session, +/// or rejected whole - is decided by Module3Parameters, not here. +/// +/// NOTE: only one SimulationManager subclass may sit on a GameObject. Unity calls Awake on +/// disabled components too, and SimulationManager.Awake assigns the static Instance, so two of +/// them fight over it in an order that depends on nothing you can see. Both Module 1 and +/// Module 2 have this problem (CLAUDE.md section 10.4); Module 3 should not inherit it. +/// +public class Module3GamaLink : SimulationManager +{ + protected override void ManageOtherMessages(string content) + { + base.ManageOtherMessages(content); + + if (string.IsNullOrEmpty(content)) return; + if (content.IndexOf(Module3Parameters.GamaMessageKey, System.StringComparison.Ordinal) < 0) return; + + string csv = ExtractCsv(content); + if (string.IsNullOrEmpty(csv)) + { + Debug.LogWarning($"Module 3: a '{Module3Parameters.GamaMessageKey}' message arrived but carried no CSV."); + return; + } + + if (Module3Parameters.Instance == null) + { + Debug.LogWarning("Module 3: parameters arrived from GAMA before Module3Parameters existed in the scene. " + + "Put the Module3Parameters component in the first scene that loads."); + return; + } + + Module3Parameters.Instance.ReceiveCsv(csv, "GAMA"); + } + + private static string ExtractCsv(string content) + { + try + { + JObject payload = JObject.Parse(content); + JToken token = payload[Module3Parameters.GamaMessageKey]; + if (token == null) return null; + + // Usually a single string. A GAMA list of rows is accepted too, because writing the + // file out line by line is the obvious thing to do at the other end and it would be + // unkind to make that a failure. + if (token.Type == JTokenType.Array) + { + var rows = new System.Text.StringBuilder(); + foreach (JToken line in token) rows.AppendLine(line.ToString()); + return rows.ToString(); + } + + return token.ToString(); + } + catch (System.Exception e) + { + Debug.LogError($"Module 3: could not read the parameter payload from GAMA: {e.Message}"); + return null; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/Module3GamaLink.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/Module3GamaLink.cs.meta new file mode 100644 index 00000000..2f0311b5 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/Module3GamaLink.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 073caad40c9d4441ac8bdba235b63232 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/Module3Parameters.cs b/Assets/1.TeamWorkspace/Module3/Scripts/Module3Parameters.cs new file mode 100644 index 00000000..4d206998 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/Module3Parameters.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using Aedes.Module3.Sim; +using UnityEngine; + +/// +/// Holds the simulation parameters in force, and takes delivery of new ones from GAMA while a +/// session is running. +/// +/// Design section 5 asks for the mosquito population to be "driven by the GAMA model rather than +/// scripted", and open question 3b asks how the state of the yards carries over from Module 2. +/// Both arrive as a CSV that GAMA generates; this is the Unity side of that handover. +/// +/// Three rules govern anything that arrives mid-session, and they exist because the thing on the +/// other end of this is a classroom: +/// +/// 1. A file that fails validation is rejected WHOLE. Never half-apply a broken parameter set - +/// a session running on a mixture of old and new numbers is worse than one running on old +/// numbers, because nobody can tell which they are looking at. +/// 2. Changes take effect from the next simulated day, never retroactively. People already +/// infected keep the course of illness they were given, or the trace-back replay becomes a +/// lie about what the squad actually saw. +/// 3. Keys that would rebuild the neighbourhood (plot count, what Module 2 left behind) are +/// held for the next session rather than applied under the player's feet. +/// +public class Module3Parameters : MonoBehaviour +{ + public static Module3Parameters Instance { get; private set; } + + /// The CSV baked into the build, used until GAMA says otherwise. + private const string DefaultCsvResource = "Module3/Module3Parameters"; + + /// What GAMA labels the payload with (the first key of its json_output contents). + public const string GamaMessageKey = "module3_params"; + + [SerializeField] private bool logOnReceive = true; + + /// The set in force right now, including anything GAMA has sent this session. + public ParameterSet Current { get; private set; } = new ParameterSet(); + + /// + /// Structural changes waiting for the next session, because they cannot be applied to a + /// neighbourhood that already exists. + /// + public ParameterSet PendingNextSession { get; private set; } + + /// Everything that has arrived this session, for the Field Journal and the debrief. + public readonly List DeliveryLog = new List(); + + public event Action OnParametersApplied; + public event Action OnParametersHeldForNextSession; + public event Action OnParametersRejected; + + /// Set by the session once a neighbourhood exists, so changes can be staged onto it. + public Neighbourhood LiveNeighbourhood { get; set; } + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + + Instance = this; + DontDestroyOnLoad(gameObject); + LoadBakedDefaults(); + } + + private void LoadBakedDefaults() + { + TextAsset csv = Resources.Load(DefaultCsvResource); + if (csv == null) + { + // Not fatal: Module3Config's own defaults are a complete, playable set. The module + // must still run on a headset that has never seen GAMA. + Debug.LogWarning($"Module 3: no baked parameter file at 'Resources/{DefaultCsvResource}'. " + + "Falling back to the built-in defaults."); + Current = new ParameterSet { Origin = "built-in defaults" }; + return; + } + + var parsed = ParameterCsv.Parse(csv.text, "baked CSV"); + if (!parsed.IsValid) + { + Debug.LogError("Module 3: the baked parameter file is invalid, using built-in defaults instead:\n " + + string.Join("\n ", parsed.Errors)); + Current = new ParameterSet { Origin = "built-in defaults" }; + return; + } + + Current = parsed; + Record($"loaded {parsed.PresentKeys.Count} parameters from the baked CSV"); + } + + // --------------------------------------------------------------------------------------- + + /// + /// Takes delivery of a parameter CSV. Safe to call at any point in a session; the routing + /// rules above decide what actually happens. + /// + public bool ReceiveCsv(string csvText, string origin) + { + if (string.IsNullOrEmpty(csvText)) + { + Debug.LogWarning($"Module 3: empty parameter payload from {origin}, ignored."); + return false; + } + + // Layered on what is in force, not on the build's defaults: GAMA is free to send only + // the handful of keys that changed. + var incoming = ParameterCsv.Parse(csvText, origin, Current); + + if (!incoming.IsValid) + { + string why = string.Join("\n ", incoming.Errors); + Debug.LogError($"Module 3: parameters from {origin} were REJECTED and nothing has changed:\n {why}"); + Record($"rejected a file from {origin} ({incoming.Errors.Count} problems)"); + OnParametersRejected?.Invoke(incoming); + return false; + } + + if (incoming.UnknownKeys.Count > 0) + { + // Not a failure: a newer GAMA model may know about keys this build does not. + Debug.LogWarning($"Module 3: ignoring {incoming.UnknownKeys.Count} unrecognised parameter(s) from " + + $"{origin}: {string.Join(", ", incoming.UnknownKeys)}"); + } + + bool midSession = LiveNeighbourhood != null; + bool structural = ParameterCsv.RequiresRestart(incoming.PresentKeys, out List blocking); + + if (midSession && structural) + { + // Applied now, this would rebuild the neighbourhood around a player who is standing + // in it. Hold the whole set until the next session instead. + PendingNextSession = incoming; + Debug.LogWarning($"Module 3: parameters from {origin} include {blocking.Count} setting(s) that " + + $"cannot change mid-session ({string.Join(", ", blocking)}). " + + "They will be used when the next neighbourhood is built."); + Record($"held a file from {origin} for the next session ({string.Join(", ", blocking)})"); + OnParametersHeldForNextSession?.Invoke(incoming); + return false; + } + + Current = incoming; + + if (midSession) + { + // From the next day, never retroactively. + LiveNeighbourhood.StageParameters(incoming.Config, origin); + Record($"applied {incoming.PresentKeys.Count} parameters from {origin} " + + $"from day {LiveNeighbourhood.Day + 1}"); + } + else + { + Record($"applied {incoming.PresentKeys.Count} parameters from {origin} before the session started"); + } + + if (logOnReceive) + { + Debug.Log($"Module 3: applied parameters from {origin}" + + (string.IsNullOrEmpty(incoming.SourceRunId) ? "" : $" (GAMA run {incoming.SourceRunId})") + + $": {string.Join(", ", incoming.PresentKeys)}"); + } + + OnParametersApplied?.Invoke(incoming); + return true; + } + + /// + /// Called when a new neighbourhood is about to be built. Anything GAMA sent that had to wait + /// for a rebuild takes effect here. + /// + public ParameterSet TakeForNewSession() + { + if (PendingNextSession != null) + { + Current = PendingNextSession; + PendingNextSession = null; + Record("applied the parameters that were waiting for a new session"); + } + return Current; + } + + private void Record(string what) + { + DeliveryLog.Add($"[{DateTime.Now:HH:mm:ss}] {what}"); + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/Module3Parameters.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/Module3Parameters.cs.meta new file mode 100644 index 00000000..ee04e0ce --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/Module3Parameters.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b41c0e615f2e4e64b92f966fccdfb299 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Module3/Module3Parameters.csv b/Assets/Resources/Module3/Module3Parameters.csv new file mode 100644 index 00000000..e532902b --- /dev/null +++ b/Assets/Resources/Module3/Module3Parameters.csv @@ -0,0 +1,54 @@ +# AEDES Module 3 - simulation parameters +# Generated by GAMA and read by Unity. Only 'key' and 'value' are read; +# the other columns are documentation and may be reordered or extended. +# apply_scope says when a value sent mid-session can take effect: +# next_day - from the next simulated day +# future_infections - only for people infected after the change +# next_session - not until the neighbourhood is rebuilt +key,value,unit,section,apply_scope,notes +incubation_min_days,4,days,clinical,future_infections,"Bite to symptoms, shortest. Design section 5: four to ten days. Do not compress." +incubation_max_days,10,days,clinical,future_infections,"Bite to symptoms, longest." +fever_min_days,4,days,clinical,future_infections,"Febrile period, shortest." +fever_max_days,7,days,clinical,future_infections,"Febrile period, longest." +infectious_days_before_onset,1,days,clinical,future_infections,Viraemia begins this many days before symptoms. +infectious_days_after_onset,5,days,clinical,future_infections,"Last day a patient can infect a mosquito, counted from symptom onset." +asymptomatic_share,0.6,fraction 0-1,clinical,future_infections,Share of infections that never feel ill. Section 5: half to three quarters. Pending Dr Marcombe. +asymptomatic_infectious_days,5,days,clinical,future_infections,Length of the infectious window for someone with no symptoms. +asymptomatic_relative_infectiousness,0.35,fraction 0-1,clinical,future_infections,"How infectious to mosquitoes a symptomless case is, relative to a febrile one. Section 5: virus levels peak during fever. Below 1, or netting the visible patients stops being the strongest action." +warning_sign_share,0.08,fraction 0-1,clinical,future_infections,Background rate of bleeding or persistent vomiting among symptomatic cases. +warning_sign_onset_offset_min,-1,days,clinical,future_infections,Earliest warning-sign onset relative to the end of fever. Section 8: danger arrives as the fever comes down. +warning_sign_onset_offset_max,1,days,clinical,future_infections,Latest warning-sign onset relative to the end of fever. +unreferred_warning_grace_days,2,days,clinical,next_day,"How long before an unreferred household takes itself to hospital. A consequence shown to the player, never a penalty." +extrinsic_incubation_min_days,8,days,entomology,future_infections,Shortest time before a mosquito that fed on a case can transmit. Section 5: eight to twelve days. +extrinsic_incubation_max_days,12,days,entomology,future_infections,Longest extrinsic incubation period. +mosquito_lifespan_min_days,10,days,population-rates,next_day,Shortest adult female lifespan. +mosquito_lifespan_max_days,28,days,population-rates,next_day,Longest adult female lifespan. +emergence_per_container_per_day,0.9,adults/container/day,population-rates,next_day,Adults emerging from each productive container each day. The main lever the GAMA container model drives. +max_mosquitoes_per_household,80,count,population-rates,next_day,"Ceiling per plot, so a neglected neighbourhood stays playable." +standing_population_days,10,days,population-rates,next_session,Days' worth of emergence already on the wing on day one. Section 5: the mosquitoes are already flying. +daily_bite_probability,0.34,probability,population-rates,next_day,Chance a given mosquito seeks a blood meal on a given day. +daily_dispersal_probability,0.1,probability,population-rates,next_day,Chance of moving to an adjacent plot. Aedes aegypti travels very little. +probability_human_to_mosquito,0.65,probability,population-rates,next_day,Chance a bite on an infectious person infects the mosquito. +probability_mosquito_to_human,0.45,probability,population-rates,next_day,Chance a bite by an infectious mosquito infects the person. +net_over_resting_person,0.1,multiplier 0-1,protective-measures,next_day,Bite risk under a net while lying down during the day. The strongest measure in the module. +net_over_active_person,0.92,multiplier 0-1,protective-measures,next_day,"The same net over someone up and about - close to no protection, because they are not under it when Aedes bites. Must stay well above net_over_resting_person." +intact_screen,0.4,multiplier 0-1,protective-measures,next_day,Window and door screens in good repair. +torn_screen,0.88,multiplier 0-1,protective-measures,next_day,"A screen with a hole in it - close to useless, which is why repairing it is worth the player's time." +closed_during_biting_hours,0.75,multiplier 0-1,protective-measures,next_day,"Shutting up after dawn and before dusk. Costs nothing, available to everyone." +electric_fan,0.7,multiplier 0-1,protective-measures,next_day,Air movement disrupts flight and the plume mosquitoes home in on. +air_conditioning,0.5,multiplier 0-1,protective-measures,next_day,"Include it, but the module must not depend on it - uncommon in these households." +repellent,0.6,multiplier 0-1,protective-measures,next_day,Applied to exposed skin. +repellent_duration_days,1,days,protective-measures,next_day,How long one application lasts. +long_sleeves,0.85,multiplier 0-1,protective-measures,next_day,"Free, and uncomfortable with a fever." +nets_per_session,4,count,session,next_session,Must be fewer than the number of households. Section 5: the choice is the whole point. +turn_seconds,180,seconds,session,next_day,"Section 8: two to four minutes, fixed in playtesting." +turns_per_round,3,count,session,next_session,One per squad member. +rounds,6,count,session,next_session,Five or six makes a session. +days_per_handover,3,days,session,next_day,How far the calendar jumps when the headset changes hands. +households_per_lane,5,count,scenario,next_session,Section 11: eight to ten plots along two or three lanes. +lanes,2,count,scenario,next_session,Number of lanes. +module2_container_clearance,0.35,fraction 0-1,scenario,next_session,What the squad cleared in Module 2. Open question 3b - this is how that state carries over. +index_case_head_start_days,4,days,scenario,next_session,How long the first patients have been ill when the squad arrives. +seed_asymptomatic_index_case,true,true/false,scenario,next_session,Section 5: one chain must begin where nobody was ever visibly ill. +min_productive_containers_per_household,2,count,scenario,next_session,Cryptic sites no sweep finds. Why perfect prevention still leaves mosquitoes. +guaranteed_warning_sign_cases,2,count,scenario,next_session,"Teaching guarantee, not a clinical rate: how many referral situations a session must present." diff --git a/Assets/Resources/Module3/Module3Parameters.csv.meta b/Assets/Resources/Module3/Module3Parameters.csv.meta new file mode 100644 index 00000000..8a0db567 --- /dev/null +++ b/Assets/Resources/Module3/Module3Parameters.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: eb42492e1dc94cc686f519f0512e7f32 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From ae4f0af7c2068b225220fe78cb280503af38ce75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 19:45:17 +0700 Subject: [PATCH 04/61] Module 3: implementation roadmap and 3D asset brief Documentation/Module3-Roadmap.md - the build plan: phases, what is salvageable from the Boy branch's Start_M3 greybox, the repo traps worth knowing before hitting them, and why none of the design's six open questions block code. Documentation/Module3-Asset-Brief.md - a self-contained brief for whoever produces the models, whether that is a generative 3D model, a contractor or someone in Blender. Format, scale, pivots, socket conventions, triangle budgets, file paths and naming, split into placeholder and final tiers so the placeholders unblock code immediately. The asset gap is much smaller than the design document implies: the Lao houses, fences, gates, PF_Bed, PF_MosquitoNet and the whole Module 2 container set already exist in Team Assets. What is genuinely missing is the fan, the two window screen states, a few handheld props and the villager animation clips. Villager health states are deliberately animation and material swaps rather than separate meshes - cheaper, reads better at conversational distance, and it keeps sick characters from being visually flagged, which section 5 is emphatic about. --- Documentation/Module3-Asset-Brief.md | 329 +++++++++++++++++++ Documentation/Module3-Roadmap.md | 460 +++++++++++++++++++++++++++ 2 files changed, 789 insertions(+) create mode 100644 Documentation/Module3-Asset-Brief.md create mode 100644 Documentation/Module3-Roadmap.md diff --git a/Documentation/Module3-Asset-Brief.md b/Documentation/Module3-Asset-Brief.md new file mode 100644 index 00000000..befb3ef4 --- /dev/null +++ b/Documentation/Module3-Asset-Brief.md @@ -0,0 +1,329 @@ +# Module 3 — 3D Asset Brief + +**For:** whoever or whatever is producing the models — a generative 3D model, a contracted artist, +or a team member working in Blender. You do not need to know Unity or the rest of the project. +Everything you need is in this file. + +**Project:** AEDES, a VR education programme for Lao secondary schools, running on Meta Quest 3. +Module 3 puts the player in a dense Vientiane neighbourhood as a health volunteer during a dengue +outbreak. They walk into people's homes, talk to them, and protect the ones who are unwell from +being bitten — putting up a mosquito net, fixing a torn window screen, setting a fan going. + +The assets below are the things the player looks at and picks up while doing that. + +--- + +## 1. Read this first: two tiers, and why + +Every asset on the list has a **placeholder** form and a **final** form, and the placeholder is +genuinely useful — it is not a courtesy. + +| | Placeholder | Final | +|---|---|---| +| Purpose | Unblocks the code, today | Ships to schools | +| Looks like | Correct size, correct shape, flat colour. A fan can be a cylinder and two boxes. | Stylised, matches the existing art | +| Must be right | **Dimensions, pivot, sockets, name, file path** | Everything, plus the above | +| Time | Minutes | Hours | + +**If you can only do one thing, do the placeholders for the whole list.** A grey box at the right +size with the right pivot in the right folder is worth more to this project than one beautiful fan, +because the gameplay code binds to paths and sockets, not to meshes. The mesh inside a prefab can +be swapped later with no other change. + +So: produce all placeholders first, confirm they load, then work through the finals. + +--- + +## 2. Hard technical requirements + +These are not preferences. An asset that misses any of them will be sent back. + +### Format +- **FBX** (binary, FBX 2020 or later). `.blend` is also accepted — Unity imports it directly — + but then the Blender file must contain only the asset, with no packed junk. +- **glTF/`.glb` is accepted for placeholders only.** + +### Scale and orientation +- **Real-world scale, in metres.** A water glass is 0.12 m tall, not 12 units. +- In Blender: Scene Properties → Units → Metric, Unit Scale `1.0`. +- **Apply all transforms before export** (`Ctrl+A` → All Transforms). Object scale must read + `1, 1, 1` and rotation `0, 0, 0`. +- FBX export axes: **Forward `-Z`, Up `Y`** (this is the Blender FBX exporter's default and is + what Unity expects). Do not "fix" rotation by rotating the object — fix it on export. +- The asset faces **+Z** — that is, the side a person would look at faces +Z. + +### Pivot / origin +This is the single most common thing to get wrong, and it breaks placement silently. + +- **Objects that sit on something** (fan, jar, bucket, bottle): origin at the **centre of the + footprint, at floor level** — the point where the object touches the ground, not its centre of mass. +- **Objects that mount into an opening** (window screen): origin at the **centre of the opening**, + in the plane of the wall. +- **Objects the player holds** (torch, repellent bottle, cloth, water glass): origin at the + **natural grip point**, oriented so that +Y is "up" when held normally and +Z points away from + the holder. + +### Triangle budget +Quest 3 is a standalone mobile GPU and the scene holds ten houses at once. + +| Asset class | Budget (triangles) | +|---|---| +| Small handheld props | ≤ 800 | +| Static floor props | ≤ 1,500 | +| Electric fan (with separated blade) | ≤ 3,000 | +| Window screen | ≤ 500 | +| Mosquito net | ≤ 2,000 | +| Building exterior | ≤ 8,000 | + +Under budget is better. There is no prize for detail that disappears at 2 m. + +### Materials and texturing +The project is **stylised / toon, with baked lighting**. It is not PBR and it is not realistic. + +- **One material per asset.** Not one per part. +- Do **not** author metallic/roughness/normal map sets. They will be discarded. +- Where possible, UV-unwrap onto the shared gradient atlas: + `Assets/1.TeamWorkspace/Team Assets/Textures/T_Gradients.png` + — this is how the rest of the project gets its colour, and it is why everything matches. + Each surface takes its colour by having its UV island sit on the right patch of that atlas. +- If a unique texture is genuinely needed, **512×512 maximum**, power-of-two, PNG. +- Unity-side the material will use `SHD_SimpleLit_Static` (or `SHD_SimpleLit_Static_2Sided` for + anything single-sided you can see the back of, such as netting). You do not need to set this up; + just do not build something that depends on a different shading model. + +### Naming +The project's conventions, and they are enforced: + +| Prefix | Means | Example | +|---|---|---| +| `SM_` | Static mesh | `SM_ElectricFan` | +| `SK_` | Skinned / rigged mesh | `SK_Villager_Adult` | +| `PF_` | Prefab | `PF_ElectricFan` | +| `M_` | Material | `M_ElectricFan` | +| `T_` | Texture | `T_Gradients` | +| `A_` | Animation clip | `A_Fan_Spin` | + +No spaces, no accents, no version suffixes like `_final_v2_NEW`. Match the existing spelling of +neighbouring assets exactly, **including its typos** — the project has several load-bearing ones +and "correcting" them breaks references. + +--- + +## 3. Where files go + +Everything lands under the shared art folder so Modules 1, 2 and 3 can all use it: + +``` +Assets/1.TeamWorkspace/Team Assets/ +├── Models/ ← the .fbx files +├── Materials/ ← M_*.mat +├── Textures/ ← T_*.png +├── Animations/ ← A_*.anim / clips +└── Prefabs/ + ├── Props/ ← small objects: fan, torch, repellent, vessels, cloth + ├── Furniture/ ← net, screens, anything room-scale + ├── Buildings/ ← health centre + └── Character/ ← villagers +``` + +**Deliver the `.fbx` into `Models/`.** Prefab creation happens on the Unity side; you do not need +to make prefabs unless you have the project open. + +If you are committing to the repository yourself: **always commit the `.meta` file that Unity +generates next to each asset.** A missing `.meta` breaks every reference to that asset for +everyone else on the team. This is the second most common way to cause a bad afternoon. + +--- + +## 4. What already exists — do not remake these + +A great deal of the neighbourhood is already built. Check before modelling: + +`Assets/1.TeamWorkspace/Team Assets/Prefabs/` + +- **Buildings:** `PF_Lao_House_One_FloorV2`, `PF_Lao_House_Two_FloorV3`, `PF_Pop_House`, + `PF_Door`, `PF_Fence`, `PF_WoodenFence`, `PF_Big_Gate`, `PF_Smaill_Gate` +- **Furniture:** `PF_Bed`, `PF_MosquitoNet`, `PF_Table`, `PF_Carpet_Red`, `PF_Carpet_Black`, + `PF_TV`, `PF_Refrigerator`, `PF_Pot&GasStove`, `PF_SpiceRack`, `PF_TireWithWater` +- **Props / containers:** `PF_Jar`, `PlasticBucket`, `PlasticBasin`, `PF_Lamp`, `PF_Car` +- **Characters:** `PF_CharacterV1`, `PF_CharacterV2`, plus a Supercyan character pack + +The water containers in particular are reused deliberately from Module 2 — the neighbourhood is +meant to feel like the same world the student has already worked in. + +--- + +## 5. The asset list + +### Priority A — the module does not work without these + +#### A1. Electric fan — `SM_ElectricFan` +A cheap floor-standing or table-top electric fan, the kind found in most Lao homes. + +- Size: ~0.35 m diameter head, ~0.75 m tall on a floor stand. +- **The blade must be a separate mesh object**, named `Blade`, with its origin at the centre of + the rotation axis, so it can be spun in code. If the blade is welded into the body the asset is + unusable. +- A protective cage around the blade is wanted — model it as simple crossed rings, not as a + faithful wire mesh. +- Pivot: centre of the base, at floor level. +- Socket: an empty named `Socket_Airflow` at the centre of the fan head, **+Z pointing the way the + air blows**. The game uses this to work out who is in the airflow. + +#### A2. Window screen, intact — `SM_WindowScreen` +An insect screen in a simple frame, fitted into a window opening. + +- **Must match the window openings of `PF_Lao_House_One_FloorV2`.** Open that prefab and measure. + If you cannot open it, build to 0.9 m wide × 1.1 m tall and say so on delivery so it can be adjusted. +- Geometry: a rectangular frame plus one flat quad for the mesh itself. The screen material is a + single alpha-cutout plane — **do not model individual wires.** +- Pivot: centre of the opening, in the plane of the wall, +Z facing out of the building. + +#### A3. Window screen, torn — `SM_WindowScreen_Torn` +The same asset with a hole in it. The player finds these and repairs them, so the damage has to be +**visible from across a room** — a tear roughly a fifth of the screen's area, at about head height, +with the flap bent outward. Subtlety is wrong here. + +- Same dimensions, same pivot, same material as A2. It must be able to swap in and out of the same + position with no offset. + +#### A4. Mosquito net, deployed — `SM_MosquitoNet_Deployed` +`PF_MosquitoNet` already exists — **look at it first.** What is needed is a version that hangs +correctly over `PF_Bed` with somebody lying under it. + +- Must drape over the existing `PF_Bed` with ~0.15 m clearance around the mattress and reach the + floor on all four sides. +- Single-sided geometry with an alpha-cutout net texture. It will be rendered two-sided in engine. +- Pivot: floor level, centred on the bed's footprint. +- Socket: `Socket_Hang` at the apex, where it would tie to a ceiling hook. + +#### A5. Mosquito net, stowed — `SM_MosquitoNet_Stowed` +The same net gathered up and tied off above the bed — what the room looks like before the player +puts it up. Same pivot as A4 so the two can be swapped in place. + +--- + +### Priority B — needed for the full set of player actions + +#### B1. Drinking vessel — `SM_WaterGlass` +A plain glass or enamel cup, ~0.12 m tall. Held by the player and handed to a patient. +Pivot at the grip point. Must read as "clean drinking water" at arm's length. + +#### B2. Water bottle / jug — `SM_WaterJug` +A household jug or large plastic bottle, ~0.3 m tall, for bringing fluids to a patient. +Pivot at floor level; add `Socket_Grip` at the handle or neck. + +#### B3. Repellent bottle — `SM_Repellent` +A small squeeze or spray bottle, ~0.15 m tall. Generic — **no real brand names, logos or +trade dress of any kind.** Pivot at grip point. + +#### B4. Cloth / towel — `SM_Cloth` +A folded damp cloth, ~0.3 × 0.2 m. Two versions if cheap to do: folded (on a table) and draped +(over a forehead or a bowl). Pivot at the centre of the underside. + +#### B5. Torch — `SM_Torch` +A hand torch for looking into dark corners of a room, ~0.2 m long. +Pivot at grip point, **+Z pointing out of the lens.** Socket: `Socket_Beam` at the lens face. + +--- + +### Priority C — depends on an open design decision + +#### C1. Health centre exterior — `SM_HealthCentre` +A small single-storey Lao rural health centre. + +> **Do not start this yet.** Whether the player travels to the health centre or referral happens +> off-screen is still undecided. It is listed so it is not a surprise later. Ask before beginning. + +--- + +### Priority D — villager appearance + +The module needs villagers in three visible states: **well**, **unwell without warning signs**, and +**unwell with a warning sign**, across varied ages including children and older people. + +**This is explicitly not three models per character.** Do not build separate "sick" meshes. The +states are communicated by: + +1. **Pose and animation** — standing and busy / sitting slumped / lying on a mat under a net. +2. **A skin material swap** — the toon gradient shading makes pallor a one-texture change. +3. **Props** — a cloth at the mouth, a bucket beside the bed, a damp towel. + +So what is wanted here is: **animation clips**, not meshes. Against the existing `PF_CharacterV1` / +`PF_CharacterV2` rigs: + +| Clip | Description | +|---|---| +| `A_Villager_Idle_Well` | Standing, relaxed, occasional small movement | +| `A_Villager_Sit_Unwell` | Sitting, slumped, head low, slow breathing | +| `A_Villager_Lie_Unwell` | Lying on a mat or bed, on the back, still | +| `A_Villager_Talk` | Light gesture while speaking, usable over any of the above | + +**A framing rule that matters more than the art.** A sick villager must never be made to look +frightening, contagious or repellent. Nothing that reads as "danger — infected person here". This +module teaches students to *go to* the people who are unwell and look after them; a design that +made students wary of sick neighbours would have done real harm. Tired, uncomfortable, needing +help — yes. Alarming — no. + +--- + +## 6. Sockets — the convention + +A "socket" is an **empty object** (Blender: Add → Empty → Plain Axes) parented to the mesh, named +`Socket_`. It carries no geometry. It tells the game where something attaches or points. + +- Name exactly as specified above — the code looks them up by name. +- Orientation matters: **+Z is "forward"** (the direction of airflow, a torch beam, the way a + screen faces out). +- Keep the empty's scale at `1, 1, 1`. + +--- + +## 7. Placeholder rules + +A placeholder must still get these right, because the code binds to them: + +- ✅ Correct **file name** and **folder** +- ✅ Correct **real-world dimensions** +- ✅ Correct **pivot / origin** +- ✅ All **sockets** present and correctly oriented +- ✅ Separate **`Blade`** object on the fan +- ✅ Single material, any flat colour +- ❌ Detail, texturing, bevels, and interesting silhouettes are all *not* required + +A cylinder plus two cubes is a perfectly good placeholder fan. A beautifully modelled fan with the +pivot in the middle of the head is not. + +--- + +## 8. Delivery checklist + +For each asset, before you call it done: + +- [ ] Real-world scale in metres +- [ ] All transforms applied (scale `1,1,1`, rotation `0,0,0`) +- [ ] Exported FBX with Forward `-Z`, Up `Y` +- [ ] Origin at the specified point +- [ ] Sockets present, named exactly, `+Z` forward +- [ ] Triangle count within budget — state the actual count on delivery +- [ ] One material +- [ ] Named per the `SM_` / `SK_` / `A_` convention +- [ ] No brand names, logos or real trade dress anywhere +- [ ] Placed in `Assets/1.TeamWorkspace/Team Assets/Models/` +- [ ] `.meta` file committed alongside it, if you are committing + +**On delivery, state for each asset:** the triangle count, the bounding box in metres, and anything +you had to guess at. Guesses are fine and expected — silent guesses are not. + +--- + +## 9. What not to do + +- Do not remake anything in §4. +- Do not deliver PBR texture sets — the project is toon-shaded with baked lighting. +- Do not exceed the triangle budgets; this ships to a standalone mobile headset. +- Do not use real brands, logos or packaging on the repellent, the fan or anything else. +- Do not model individual wires on the screens or individual threads on the net — both are flat + alpha-cutout planes. +- Do not make sick villagers look alarming or contagious. See §D. +- Do not rename or "tidy" existing assets, including ones whose names are misspelled. diff --git a/Documentation/Module3-Roadmap.md b/Documentation/Module3-Roadmap.md new file mode 100644 index 00000000..9ad91746 --- /dev/null +++ b/Documentation/Module3-Roadmap.md @@ -0,0 +1,460 @@ +# Module 3 — Implementation Roadmap + +Working plan for building *Module 3 — Community response to a dengue outbreak* in +`simple.BeDev.AEDES.UnityVR`. + +Source of truth for design: `AEDES_Module3_Game_Design_Rev3.docx` (Rev 3, 13 Aug 2026). +This document does not restate the design; it says **what gets coded, in what order, and why**. + +Target branch: `module-3` off `main`. + +--- + +## 0. The one architectural decision everything hangs on + +Read §5 of the design doc as a specification, not as flavour. It demands four things that, taken +together, dictate the architecture: + +1. A squad that clears every container but nets nobody **must** watch the outbreak grow. +2. A squad that nets the first two patients on day one **must** see the outbreak stop. +3. At the end, the game **replays what would have happened** had patient zero been covered on day one. +4. One chain must begin at a house where nobody was ever visibly ill (asymptomatic source). + +(3) is the hard one. A counterfactual replay means the epidemic has to be **re-runnable from a +seed with one input changed**, and the two runs must differ *only* because of that input. That is +impossible if the epidemic lives in `Update()` loops, coroutines, `Random.Range` and physics. + +**So: the epidemic is a deterministic, seeded, headless C# model with no UnityEngine dependency.** +Unity renders it and sends it player actions. It never *is* it. + +Everything in Phase 1 exists to make that true. If this decision is deferred, the trace-back +replay and the counterfactual ending get retrofitted at the end of the project, badly, or cut — +and they are the two moments the whole module is built to deliver. + +Corollary: it is unit-testable and playable-by-script long before there is a scene, a headset or +a single finished 3D model. That is what lets art and code run in parallel. + +### The one deviation from CLAUDE.md I'm proposing + +CLAUDE.md §2 says "no assembly definitions for game code", because adding one breaks the implicit +cross-folder references in `Assembly-CSharp`. I want one exception: + +``` +Assets/1.TeamWorkspace/Module3/Sim/AEDES.Module3.Sim.asmdef (no references at all) +Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef (EditMode, refs the above + NUnit) +``` + +This is safe precisely because the sim is a leaf: it references nothing, not even `UnityEngine`. +`Assembly-CSharp` auto-references assembly definitions, so every MonoBehaviour in the project can +still use `AEDES.Module3.Sim` types with no further wiring. Nothing existing changes. The payoff +is that the model gets real NUnit tests — the first automated tests in this repo — and the +calibration harness in Phase 1.5 becomes possible. + +--- + +## 1. What already exists (salvage audit) + +### From `origin/Boy`, commit `3f40c27 "Start_M3"` (13 Aug 2026) + +A greybox, not a foundation. Contents and verdict: + +| Asset | Verdict | +|---|---| +| `Assets/M3/Scenes/SampleScene.unity` | ~32 primitive cubes named `House (n)` on a plane, baked NavMesh, world-space canvas with `ToHos` / `ToHome` buttons. **Discard the scene**, keep the idea. | +| `Assets/M3/Scenes/Start.unity` | Title/menu scene with the Lao font. **Keep**, it is the M3 entry point. | +| `HumanM3.cs` (`BodyTemp`, `isSick`, `ToHosPital()`) | Prototype of the villager. Replaced by the Phase 3 view layer, but the *interaction shape* (look at player, raise a world-space panel) is right. | +| `RaomingHuman.cs` | NavMeshAgent random-walk. Useful for street-life NPCs; villagers in M3 are mostly in houses. **Keep as background-life only.** | +| `M3Manager.cs` | 16 lines, a singleton with a hospital transform. **Rewrite.** | +| `Player_Test.cs` | ⚠️ **Has `using UnityEditor.Rendering.LookDev;` in a runtime script.** This compiles in the editor and breaks the Android player build. Must not survive into `module-3`. | +| `SceneTransition.cs` | Clean fade-to-black scene loader, already URP-overlay-camera aware. **Keep verbatim.** | +| `CanvasFollower.cs` | Duplicate of the Module 2 one. **Use the Module 2 copy**, delete this. | +| `TestButton.cs` | Delete. | +| **Localization upgrade** — `LocalizedKey.cs`, `ColorTexts.cs`, `Lao_SomVang SDF_Custom.asset`, Lao column in `LocalizationData.csv` | ⭐ **The most valuable thing on the branch.** Module 3 is text-heavy and must ship in Lao. Cherry-pick this first. | + +### From the rest of the repo + +Reusable straight away, from `Team Assets/Prefabs/`: + +- `Buildings/`: `PF_Lao_House_One_FloorV2`, `PF_Lao_House_Two_FloorV3`, `PF_Door`, `PF_Fence`, + `PF_WoodenFence`, `PF_Big_Gate`, `PF_Smaill_Gate` — the neighbourhood exterior is largely already built. +- `Furniture/`: **`PF_MosquitoNet`**, `PF_Table`, `PF_Carpet_*`, `PF_TV`, `PF_Refrigerator`, + `PF_Pot&GasStove`, `PF_TireWithWater` — interiors are largely already dressed. +- `Prefabs/PF_Bed.prefab`, `Props/PF_Jar`, `PlasticBucket`, `PlasticBasin` — the container set from Module 2. +- `Character/PF_CharacterV1`, `PF_CharacterV2`, plus the Supercyan pack in `Imported by Simple/`. + +Reusable code patterns: + +- Module 2's `UIHand` / `UI_Hand_Button` / `CameraHandFollow` — the hand-attached radial menu is + exactly the shape Module 3's action verbs need (§4: "help them rest", "put up the net", …). +- Module 2's `M2Manager` — timer + game-over + `DontDestroyOnLoad` singleton, a template for `M3Session`. +- Module 1's `SaveManager` — JSON at `Application.persistentDataPath`, the mechanism for passing + Module 2's neighbourhood state into Module 3 (open question 3b). +- `SendRecieveData : SimulationManager` — the existing GAMA bridge subclass. See Phase 7. + +**Art gap is therefore much smaller than §11 implies.** Genuinely missing: electric fan, window +screen (intact + torn variants), repellent bottle, drinking vessel, cloth, torch, health-centre +exterior, and villager health-state presentation. That is the outsourcing list — see §4 below. + +--- + +## 2. Phases + +Each phase ends in something demonstrable. Phases 1–2 need no art and no headset. + +### Phase 0 — Groundwork *(small)* + +- Branch `module-3` off `main`. Work in a dedicated git worktree (shared checkout). +- Create `Assets/1.TeamWorkspace/Module3/{Sim,Scripts,Scenes,Prefabs,Data,Tests}`. +- Cherry-pick the localization upgrade from `origin/Boy` (`LocalizedKey`, `ColorTexts`, Lao TMP + font, CSV Lao column) onto `module-3`. Keep it isolated so it can be PR'd to `main` separately — + Modules 1 and 2 want it too. +- **Fix the CSV parser while doing it.** `LocalizationManager` splits on `,`. Module 3's strings + are full sentences ("she has been hot for three days"), all of which contain commas. Move to a + quoted-CSV or TSV parse. This is a hard blocker for every line of dialogue in the module and is + five minutes of work now versus a week of mangled strings later. +- Port `SceneTransition.cs` and `Start.unity` from `origin/Boy`; leave `Player_Test.cs` behind. +- Add the M3 scenes to `EditorBuildSettings`. Note `SaveManager` wipes the save file when the + active scene's build index is 0 **or 1** — check where M3 lands in the list before assuming + anything persists. + +**Done when:** an empty M3 scene loads from the Start scene on the headset and a Lao sentence +containing a comma renders correctly. + +--- + +### Phase 1 — The epidemic model *(the core; no Unity)* ⭐ + +`Assets/1.TeamWorkspace/Module3/Sim/` — pure C#, `System` only, deterministic, seeded. + +**Types** + +``` +Neighbourhood seed, day, Households[], TransmissionLog, NetBudget +Household id, plot position, Residents[], Containers[], screens (ok/torn/missing), + windowsClosedAtDusk, hasFan, mosquitoPopulation, visitLog +Person id, age band, HealthState, dayInfected, isAsymptomatic, + protection flags (net / screened room / fan / repellent / covered clothing) +HealthState Susceptible → Exposed → Infectious(Febrile | Asymptomatic) + → Warning(bleeding | vomiting) → Recovered | Hospitalised +MosquitoCohort per household: count, Susceptible / Exposed(EIP) / Infectious +TransmissionEvent day, sourcePerson, sourceHousehold, mosquitoOriginContainer, + targetPerson, targetHousehold, wasPreventable +``` + +**Rules to implement (all from §5 and §6)** + +- Container count → mosquito production per household. Vector density is *per household*, and + mosquitoes disperse only to **adjacent plots** (Aedes aegypti ~100 m lifetime range, §5). This + adjacency is what makes "cover your patient and you protect the neighbours" legible. +- Each simulated day: infectious people are bitten with a probability reduced by their protection + flags; a bite may infect a susceptible mosquito (mosquito enters EIP); infectious mosquitoes bite + susceptible residents of their own and adjacent households. +- Incubation 4–10 days; EIP 8–12 days. **Do not compress these** — §8 is explicit that the + compression lives in the calendar (time jumps at handover), not in the biology. +- Asymptomatic share configurable, default 50–75% (§5, pending Marcombe). One asymptomatic source + seeded such that exactly one end-of-game chain traces to a house with no visible illness. +- Warning-sign onset is weighted toward the *defervescence* window — "danger often arrives as the + fever comes down" (§8) — so that a Pilot who sees someone improving and moves on is punished by + the model, not by a script. +- Protection effectiveness ranking must be **data, not code** (`Data/Module3Config.asset` or JSON), + because §6 says Marcombe/CMPE will re-rank it. Net > screen > closing at dawn/dusk > fan > + repellent > long sleeves, each a multiplier on bite probability. + +**Player actions the model accepts** — and no others: + +`PutUpNet(personId)`, `RepairScreen(householdId)`, `SetFan(householdId)`, `AdviseClosingHours(householdId)`, +`GiveRepellent(personId)`, `BringWater(personId)`, `HelpRest(personId)`, `ReferToHealthCentre(personId)`, +`Visit(householdId)`. + +There is deliberately **no** `ClearContainer` action (§5: "not a button, not a task, not a score") +and **no** diagnose or treat action (§4, §12). + +**Done when:** `Neighbourhood.Run(seed, strategy, days)` produces an identical `TransmissionLog` +on two runs with the same seed, and a differing one when a single action is added. + +--- + +### Phase 1.5 — The calibration harness *(don't skip this)* ⭐ + +An EditMode test suite + a headless runner that plays scripted strategies over hundreds of seeds +and **asserts the design's guarantees hold**: + +| Strategy | Required outcome | +|---|---| +| Do nothing | Outbreak grows | +| Clear every container, net nobody | Outbreak still grows (§5 bullet 1) | +| Net every *well* person | Outbreak still grows — new cases keep appearing | +| Net the first two patients on day one | Outbreak stops, even with containers standing (§5 bullet 2) | +| Net all visible patients | Outbreak nearly stops, **but one chain survives** — the asymptomatic one (§5) | + +These are the module's learning objectives expressed as failing tests. If a parameter change +breaks one of them, the module stops teaching what it exists to teach, and CI (or `dev.sh`) says +so immediately. This is also the artefact to hand Dr Marcombe: "here is what the model does with +your numbers." + +**Done when:** all five assertions pass over ≥200 seeds, and the harness prints an outbreak curve +per strategy for the clinical review. + +--- + +### Phase 2 — Session, turns and handover *(thin Unity layer; still no art)* + +`Module3/Scripts/Session/` + +- `M3Session` — `DontDestroyOnLoad` singleton (pattern: `M2Manager`). Owns the `Neighbourhood`, + the round counter, the net budget, and the seed. +- `TurnTimer` — fixed 2–4 min, **enforced by the game, not the class** (§8). Length comes from + config, because it is unsettled (§13 item 3). A countdown that is visible on the cast, not only + to the Pilot (§8). +- `HandoverScreen` — on turn end: freeze, advance the model by *n* days, show "Three days later." + and the incoming-Pilot brief (who was visited, what was found, what was left undone, §8). +- `RoundController` — 3 turns = 1 round ≈ 1 week; 5–6 rounds = one outbreak of 2–3 weeks. +- `NetBudget` — fewer nets than households (§5). Non-negotiable; it is the whole choice. + +**Done when:** a full 5-round session can be played end-to-end with keyboard debug actions in a +grey scene, the outbreak evolves between turns, and the handover screen states the time jump. + +--- + +### Phase 3 — The household visit in VR *(art-light, greybox props)* + +`Module3/Scripts/Interaction/` + +- `HouseholdView` — binds a sim `Household` to scene objects: which residents are present, where + they are, whether a net/screen/fan is installed. Re-reads state on every turn start so returning + to a house shows change. +- `VillagerView` — three visible states (§11): well / unwell without warning signs / unwell with a + warning sign. Driven by **animation state + props**, not by separate meshes (see §4 below). +- `DialogueSystem` — plain-language answers only (§7 step 2): "she has been hot for three days", + "he has not kept water down since last night". Data-driven from the localization CSV so Lao and + English come from the same table. Never clinical terms. +- `ActionMenu` — hand-attached radial, reusing Module 2's `UIHand` / `UI_Hand_Button`. Verbs are + the Phase 1 action list, labelled as §4 requires: *help them rest*, *bring water*, *put up the + net*, *fix the screen*, *set the fan*, *get them to the health centre*. **Never "diagnose" or + "treat".** The absence of those buttons is itself a teaching device. +- `ReferralFlow` — referral is not a judgement call (§4). Bleeding or persistent vomiting → the + referral action is available and correct. An unnecessary referral is *gently corrected, never + penalised* (§10). +- `MosquitoView` — reuse `Mosquto.cs` from Module 2 (note: file/class name mismatch, don't rename + it without preserving the `.meta` GUID). Spawn density reads from the sim's per-household + mosquito count, so a cleared yard visibly differs from an uncleared one (§5). + +**Framing rule, enforced in code review:** no mechanic may isolate, mark, or penalise a sick +character; the player is never rewarded for avoiding them (§5). If a UI element ever reads as +"warning: infected person here", it is a bug. + +**Done when:** one household can be walked into, talked to, looked at, acted on, and left — and +the sim registers all of it. + +--- + +### Phase 4 — Consequence and evidence *(the payoff)* ⭐ + +The module's two strongest moments. Both are pure reads off the `TransmissionLog`. + +- `TraceBackReplay` — when a new case appears, a short camera path shows the source house, the + container the mosquito came from, and the night the patient slept unprotected (§5). Every field + it needs is already in `TransmissionEvent`. +- `OutbreakMap` — end-of-session neighbourhood map showing every chain that ran and every chain + that was stopped. A squad that netted patient zero on day one sees an almost empty map. +- `CounterfactualReplay` — **before any score is shown** (§5), re-run the model from the same seed + with `PutUpNet(patientZero)` injected on day 1, and show the map that would have resulted. This + is the single highest-value feature in the module and it is nearly free, *given Phase 1*. +- `AsymptomaticChain` — one chain on the map originating at a house where nobody was visibly ill, + as the hook for the Module 2 bridge in the debrief (§5). +- `FieldJournalExport` — the squad's record. Closes with one prompt: "Why did the outbreak stop, + or why did it not?" Export as text/JSON the facilitator can read aloud. + +**Done when:** a played session ends with trace-backs seen during play, then the map, then the +counterfactual, then the score — in that order. + +--- + +### Phase 5 — Squad layer *(low code, high classroom value)* + +- The Coach watches via Quest screencast — no code, but the HUD must be legible on a 2D cast: + countdown, current household, net budget remaining. +- `CoachBrief` — the handover summary screen, readable by someone not in the headset. +- Analyst's Field Journal is paper or a tablet; the game only has to export (Phase 4) and to make + the return-visit deltas legible ("we saw her on day one and she was only tired"). + +--- + +### Phase 6 — Art integration *(runs in parallel from Phase 0)* + +See §4 below for the asset plan. Code-side work is only: prefab contract, socket transforms, +greybox → final swap. Nothing in Phases 1–5 may block on an asset. + +--- + +### Phase 7 — GAMA and the Module 2 handoff + +§5 says the mosquito population "should be driven by the GAMA model rather than scripted", and +§13 item 3b leaves the Module 2 → Module 3 inheritance unsettled. Both are handled by one seam: + +```csharp +interface INeighbourhoodStateSource { + NeighbourhoodState Load(); // container counts per plot, starting mosquito density +} +``` + +- `LocalStateSource` — reads JSON from `Application.persistentDataPath` written by Module 2 + (the `SaveManager` pattern). Ships first, works with no network. +- `GamaStateSource` — subclass `SimulationManager` (never edit it, CLAUDE.md §9), override + `ManageOtherMessages`, take container/population state off the wire. +- `DesignerStateSource` — fixed presets for classroom use when Module 2 wasn't played. + +**Known traps before touching this:** `SendRecieveData` reads `GameManager.instance.score`, which +does not exist outside Module 1 — Module 3 will NRE the same way Module 2 does. And two +`SimulationManager` subclasses on one GameObject fight over `Instance` in `Awake`. Put exactly one +on the M3 manager object. + +--- + +### Phase 8 — Build, headset, playtest + +- Add M3 scenes to `EditorBuildSettings`, Android/IL2CPP build, on-headset pass. +- Drop in `Assets/Resources/Prefabs/Utils/Debug Overlay` for in-VR logs. +- Playtest to settle turn length (2 vs 3 vs 4 min) and round count (§13 item 3). The calibration + harness already tells us the *model* works at each length; playtesting decides which one a + fourteen-year-old can finish a household in. +- Success criterion from the doc, not from a bug tracker: **a class can say the sentence back + unprompted.** Test it with a class before calling the module done. + +--- + +## 3. Sequencing + +``` +Phase 0 ──┬─ Phase 1 ── Phase 1.5 ──┬─ Phase 2 ── Phase 3 ──┬─ Phase 4 ── Phase 5 ── Phase 8 + │ │ │ + └─ Phase 6 (art, parallel throughout) ────────────┘ + └─ Phase 7 (any time after Phase 1) +``` + +Phases 1 and 1.5 are the critical path and the part that most repays care. Phase 4 is nearly free +once Phase 1 is right, and nearly impossible if it is not. + +--- + +## 4. 3D models — what to make, what to reuse, what to outsource + +### Reuse first (already in the repo) + +The neighbourhood exterior and most interior dressing exist. `PF_Lao_House_One_FloorV2`, +`PF_Lao_House_Two_FloorV3`, fences, gates, doors, `PF_Bed`, **`PF_MosquitoNet`**, `PF_Table`, +carpets, stove, fridge, TV, plus the whole Module 2 container set (`PF_Jar`, `PF_TireWithWater`, +`PlasticBucket`, `PlasticBasin`). §11 asks for the neighbourhood to "feel like the same world the +student has already worked in" — reuse is the requirement, not a shortcut. + +### Villager health states — do this with animation, not models + +§11 asks for three visible states across varied ages. **Do not commission three meshes per +character.** Use one character rig per age band and express state through: + +- pose and animation (standing / sitting slumped / lying on a mat under a net), +- a material swap on the skin gradient map (the toon style makes pallor a one-texture change), +- and props: a cloth at the mouth, a bucket by the bed, a damp towel. + +This is cheaper, reads better in VR at conversational distance, and — importantly — keeps the +framing protective rather than marking sick characters with a visual "flag" (§5). + +### The genuine gap — the outsourcing list + +| Asset | Notes | Route | +|---|---|---| +| Electric fan (floor/table, blade animates) | Central to §6 — the realistic alternative where there's no screen | **Blender, by hand** — needs a separated, animated blade and a clean pivot | +| Window screen — intact | Must fit the existing Lao house window openings | **Blender, by hand** — dimensions must match `PF_Lao_House_*` | +| Window screen — torn | The variant the player repairs (§6) | Same source file, variant mesh | +| Rolled-up / deployed net variants | `PF_MosquitoNet` exists; need an "up" and "down" state | Modify the existing prefab | +| Repellent bottle | Small, static | **AI-generated or asset store** | +| Drinking vessel / water bottle | Small, static | **AI-generated or asset store** | +| Cloth / towel | Small, static (or a simple cloth sim) | **AI-generated or asset store** | +| Torch | Small, static, needs a light socket | **AI-generated or asset store** | +| Health centre exterior | Only if §13 item 4 resolves to "the player travels there" — **blocked on that decision**, so schedule it last | Blender or asset store | + +### Recommendation on AI-generated models + +Workable for the small static props (repellent, vessel, cloth, torch) via Meshy/Tripo/Rodin-class +tools. Not workable for the fan (needs an animatable blade on a correct pivot), the screens (must +match existing window geometry to the millimetre), or the net (must drape over our specific +`PF_Bed`). Anything that has to *fit* or *move* should be modelled by hand against the existing +prefab. Anything that just sits on a table can be generated. + +Whatever the source, everything goes through the same normalisation pass before it enters the +repo: decimate to budget, re-UV onto the shared `T_Gradients` atlas, assign an `SHD_SimpleLit_*` +material. Otherwise Module 3 will visibly not match Modules 1 and 2. + +### Prefab contract (give this to whoever makes the models) + +- **Format:** FBX, metres, Y-up / -Z forward, all transforms applied, origin at the floor contact + point (props: at the natural grip point). +- **Budget:** static props ≤ 1.5k triangles; the fan ≤ 3k; screens ≤ 500. +- **Materials:** exactly one per prop, URP, using `T_Gradients` where possible. No PBR texture sets + — the project is stylised/toon with baked lighting. +- **Naming:** `SM_` static mesh, `SK_` skinned, `PF_` prefab, `M_` material, `T_` texture, + `A_` animation (CLAUDE.md §8). Match existing spelling conventions exactly, typos included. +- **Sockets:** empty transforms named `Socket_Grip`, `Socket_Mount` where the object attaches to a + hand, a bed or a window. +- **Delivery:** drop into `Assets/1.TeamWorkspace/Team Assets/`, commit **with `.meta` files**. + +### Unblocking pattern + +Commit a **greybox placeholder prefab at the final path and name** for every asset above, on day +one of Phase 0. Code binds to the path and the sockets; art replaces the mesh inside the prefab +later. Nothing in Phases 1–5 ever waits for a model, and the swap is a one-file change with no +scene churn. + +--- + +## 5. Open questions — and why none of them block code + +§13 lists six unsettled items. Each maps to a config value rather than a code path: + +| §13 | Question | How code stays unblocked | +|---|---|---| +| 1 | Module title ("Code Red: Outbreak" vs "Vientiane Health Leader") | Localization key `module3.title`. Chosen at the last minute. | +| 2 | Marcombe / CMPE clinical review | Every clinical number lives in `Module3Config`. The calibration harness re-validates the design guarantees when they change. | +| 3 | Turn length (2–4 min), round count | Config values; `TurnTimer` reads them. Playtest decides. | +| 3b | Module 2 → Module 3 inheritance | `INeighbourhoodStateSource`, three implementations (Phase 7). | +| 4 | Is the health centre a place you travel to? | **The only one with real build cost** — it decides whether a health-centre interior/exterior and a travel loop are needed. Model the referral as an off-screen outcome first; adding travel later is additive. Chase this answer early. | +| 5 | Lao terminology for warning signs | Localization CSV rows; NUOL fills them in. | + +Item 4 is the one worth pushing for an answer on before Phase 3. + +--- + +## 6. Risks and repo traps + +1. **Unity YAML merge conflicts.** `Module_1_MainScene.unity` is ~38k lines and two people cannot + edit a scene at once. Build the M3 neighbourhood from **prefabs placed by a builder script** + driven by a data asset, not by hand-dragging 10 plots into a scene file. The scene stays small, + the layout becomes reviewable as data, and merges stop hurting. +2. **`Player_Test.cs` has an editor-only `using` in a runtime script** — will break the Android + build. Do not carry it over from `origin/Boy`. +3. **`SendRecieveData` reads `GameManager.instance`**, which only exists in Module 1. Module 3 must + not repeat Module 2's NRE. +4. **Two `SimulationManager` subclasses on one GameObject** fight over `Instance` in `Awake`. One only. +5. **`SaveManager` wipes the save at build index 0 or 1.** Check where the M3 scenes land in + `EditorBuildSettings` before relying on persistence for the Module 2 handoff. +6. **The localization CSV parser splits on `,`.** Fix in Phase 0 or every sentence in the module + breaks. This is the single most likely thing to waste a week. +7. **`Mosquto.cs` declares `class Mosquito`** — filename/class mismatch. References resolve by + GUID; renaming the file breaks them unless the `.meta` GUID is preserved. +8. **No CI and no tests in this repo today.** Phase 1.5 introduces the first ones. Keep them + runnable from `dev.sh` so they actually get run. + +--- + +## 7. First session's work + +If picking this up cold, in order: + +1. Branch `module-3` off `main` in a worktree. +2. Cherry-pick the Boy-branch localization upgrade and **fix the CSV parser**. +3. Create the `Module3/` folder tree and the two asmdefs. +4. Write `Person`, `Household`, `Neighbourhood`, `TransmissionEvent` and the day tick — no Unity. +5. Write the five calibration tests from Phase 1.5 and let them fail. +6. Tune the model until they pass. + +That is the module. Everything after it is presentation. From 6428f412a99a40d6279edd6fe7908d57e3dd947f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 19:45:48 +0700 Subject: [PATCH 05/61] Module 3: update the roadmap with build status and the GAMA parameter path --- Documentation/Module3-Roadmap.md | 57 +++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/Documentation/Module3-Roadmap.md b/Documentation/Module3-Roadmap.md index 9ad91746..f5366c1f 100644 --- a/Documentation/Module3-Roadmap.md +++ b/Documentation/Module3-Roadmap.md @@ -10,6 +10,47 @@ Target branch: `module-3` off `main`. --- +## Status + +| Phase | State | +|---|---| +| 0 — Groundwork | **Done.** Worktree, folder tree, asmdefs. Localization ported from `origin/Boy` with the CSV parser rewritten (RFC-4180, so sentences with commas work) and the Lao font switched to a dynamic atlas — it was static with only 64 glyphs baked. | +| 1 — Epidemic model | **Done.** `Module3/Sim/`, headless and deterministic. | +| 1.5 — Calibration harness | **Done.** `Module3/Tests/`, 200 seeds. All five of section 5's guarantees hold. | +| 7a — GAMA parameter delivery | **Done, brought forward.** Parameters now arrive as a GAMA-generated CSV, including mid-session. See the section below. | +| 2 — Session, turns, handover | Next. | +| 3–6, 7b, 8 | Not started. | + +### Calibration, 200 seeds + +| Strategy | Secondary cases | Design requirement | +|---|---|---| +| do nothing | 14.0 | outbreak grows ✅ | +| cleared every container, no nets | 10.5 | still grows ✅ | +| net the well | 13.6 | no better than nothing ✅ | +| net the first two patients, day 1 | 5.4 | outbreak stops ✅ | +| net every visible patient | 4.7 | 69% of residue from invisible sources ✅ | +| net visible + cleared containers | 3.1 | the two modules together ✅ | + +Every session presents at least one referral situation; unaided hospitalisations fall +from 2.3 to 0.7 when a squad refers. + +### Parameters come from GAMA + +They are no longer C# literals. GAMA generates a `key,value` CSV; Unity reads it, validates it +whole, and can take delivery **mid-session** over the existing WebSocket bridge. + +- Template and schema: `Assets/Resources/Module3/Module3Parameters.csv` — hand this to the GAMA + modellers; it carries units, sections, notes and an apply scope per key. +- Receiver: `Module3/Scripts/Module3Parameters.cs`; bridge: `Module3GamaLink.cs`. +- A mid-session change applies **from the next day, never retroactively** — people already + infected keep the course of illness they were given, or the trace-back replay stops being + true. Structural keys are held for the next session. +- **The calibration suite is now the acceptance gate for a GAMA parameter file**, not a tuning + aid. Run it against a new file before that file goes near a classroom. + +--- + ## 0. The one architectural decision everything hangs on Read §5 of the design doc as a specification, not as flavour. It demands four things that, taken @@ -437,12 +478,20 @@ Item 4 is the one worth pushing for an answer on before Phase 3. 4. **Two `SimulationManager` subclasses on one GameObject** fight over `Instance` in `Awake`. One only. 5. **`SaveManager` wipes the save at build index 0 or 1.** Check where the M3 scenes land in `EditorBuildSettings` before relying on persistence for the Module 2 handoff. -6. **The localization CSV parser splits on `,`.** Fix in Phase 0 or every sentence in the module - breaks. This is the single most likely thing to waste a week. +6. ~~**The localization CSV parser splits on `,`.**~~ Fixed — the parser is RFC-4180 now and + quoted values may contain commas and line breaks. 7. **`Mosquto.cs` declares `class Mosquito`** — filename/class mismatch. References resolve by GUID; renaming the file breaks them unless the `.meta` GUID is preserved. -8. **No CI and no tests in this repo today.** Phase 1.5 introduces the first ones. Keep them - runnable from `dev.sh` so they actually get run. +8. ~~**No CI and no tests in this repo today.**~~ Phase 1.5 added the first ones, under + `Module3/Tests/`. Still not wired into `dev.sh` — do that, or they will not get run. + +9. **A negative day is not an unset day.** An index case was infected before the squad arrived, + so their exposure, onset and infectious window can all sit before day 0. An early version + guarded with `InfectiousStartDay >= 0` to mean "has this been scheduled", which silently made + the outbreak's own starting cases non-infectious — every chain then traced to somewhere else, + nets did nothing, and a one-day shift in a seeded case swung the outbreak threefold. The model + now carries an explicit `Person.HasBeenInfected` flag and a regression test. Worth remembering + the shape of it: in this model, "unset" and "before the session started" look identical. --- From 7b19c4bf98c5e613334a9e76b54c1323caec1918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 20:29:27 +0700 Subject: [PATCH 06/61] Module 3: session, turns and the handover that carries the time Section 8 makes the handover the clock. A turn is two to four minutes, far too short for anything in dengue to develop, so the calendar jumps between turns rather than anything being sped up - 'The compression is in the calendar, not in the biology.' - Sim/Session.cs runs the turn and round structure and advances the model in whole days at a boundary, never continuously. No UnityEngine, so the whole session shape is testable headlessly; M3Session only supplies the clock and the events. - Sim/HandoverBrief.cs is what the Coach briefs the incoming Pilot with. Section 8: 'Every handover should show consequence.' So it carries new cases, patients who are unwell and uncovered, anyone needing a doctor now, households nobody has visited, and people whose fever has broken - 'Danger often arrives as the fever comes down.' - Session.RecordedActions() and PatientZeroId() are what the counterfactual replays. Patient zero is deliberately the first SYMPTOMATIC index case: replaying 'you could have covered them' only works if the squad could have found them. - M3DebugDriver plays a whole session from the keyboard, no headset and no art. Editor and development builds only. Two bugs the session tests caught: 1. UnreferredWarningGraceDays was 2 against a 3-day handover jump, so a warning sign could appear and send itself to hospital entirely between two turns. The squad never saw it and could not have acted, which quietly made section 4's referral rule unreachable rather than merely hard. Raised to 4, and ParameterCsv now refuses any file where the grace period does not outlast the handover. With this fixed, a squad that refers now ends a session with zero unaided hospitalisations, against 2.25 for one that does not. 2. The febrile and defervescing windows overlapped on the last day of fever, so on that day an uncovered patient was reported as uncovered and never as 'looking better' - masking exactly the moment the module wants the Coach to warn about. Calibration is unchanged: 14.1 secondary cases for a do-nothing squad against 4.7 for one that covers every visible patient. --- .../Module3/Scripts/M3DebugDriver.cs | 121 +++++++++ .../Module3/Scripts/M3DebugDriver.cs.meta | 11 + .../Module3/Scripts/M3Session.cs | 158 ++++++++++++ .../Module3/Scripts/M3Session.cs.meta | 11 + .../Module3/Sim/HandoverBrief.cs | 69 +++++ .../Module3/Sim/HandoverBrief.cs.meta | 11 + .../Module3/Sim/Module3Config.cs | 11 +- .../Module3/Sim/ParameterSet.cs | 10 + Assets/1.TeamWorkspace/Module3/Sim/Person.cs | 2 +- Assets/1.TeamWorkspace/Module3/Sim/Session.cs | 238 ++++++++++++++++++ .../Module3/Sim/Session.cs.meta | 11 + .../Module3/Tests/SessionTests.cs | 208 +++++++++++++++ .../Module3/Tests/SessionTests.cs.meta | 11 + Assets/Resources/Module3.meta | 8 + 14 files changed, 877 insertions(+), 3 deletions(-) create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/HandoverBrief.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/HandoverBrief.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Session.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Session.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/SessionTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/SessionTests.cs.meta create mode 100644 Assets/Resources/Module3.meta diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs new file mode 100644 index 00000000..fd6ec999 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs @@ -0,0 +1,121 @@ +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Text; +using Aedes.Module3.Sim; +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// Plays a whole Module 3 session from the keyboard, with no headset, no art and no scene. +/// +/// The point is to be able to check that the session, the handovers and the consequences work +/// long before there is anything to look at - and, later, to reproduce a bug a class hit by +/// typing the same actions against the same seed. +/// +/// Editor and development builds only; it compiles out of a release APK. +/// +public class M3DebugDriver : MonoBehaviour +{ + [SerializeField] private bool showOverlay = true; + + private int selectedHousehold; + private int selectedResident; + private string lastMessage = ""; + + private M3Session S => M3Session.Instance; + + private void Update() + { + var k = Keyboard.current; + if (k == null || S == null || S.Session == null) return; + + if (k.tabKey.wasPressedThisFrame) CycleHousehold(k.leftShiftKey.isPressed ? -1 : 1); + if (k.qKey.wasPressedThisFrame) CycleResident(-1); + if (k.eKey.wasPressedThisFrame) CycleResident(1); + + if (k.vKey.wasPressedThisFrame) DoHousehold(ActionKind.Visit); + if (k.nKey.wasPressedThisFrame) DoPerson(ActionKind.PutUpNet); + if (k.bKey.wasPressedThisFrame) DoPerson(ActionKind.ReclaimNet); + if (k.rKey.wasPressedThisFrame) DoPerson(ActionKind.ReferToHealthCentre); + if (k.wKey.wasPressedThisFrame) DoPerson(ActionKind.BringWater); + if (k.tKey.wasPressedThisFrame) DoPerson(ActionKind.HelpRest); + if (k.gKey.wasPressedThisFrame) DoPerson(ActionKind.GiveRepellent); + if (k.fKey.wasPressedThisFrame) DoHousehold(ActionKind.SetFan); + if (k.cKey.wasPressedThisFrame) DoHousehold(ActionKind.RepairScreen); + if (k.xKey.wasPressedThisFrame) DoHousehold(ActionKind.AdviseClosingHours); + + if (k.spaceKey.wasPressedThisFrame) S.EndTurn(); + } + + private void CycleHousehold(int delta) + { + int count = S.Neighbourhood.Households.Count; + selectedHousehold = (selectedHousehold + delta + count) % count; + selectedResident = 0; + } + + private void CycleResident(int delta) + { + var h = S.Neighbourhood.HouseholdById(selectedHousehold); + if (h == null || h.ResidentIds.Count == 0) return; + selectedResident = (selectedResident + delta + h.ResidentIds.Count) % h.ResidentIds.Count; + } + + private void DoHousehold(ActionKind kind) + { + var r = S.Do(PlayerAction.OnHousehold(kind, selectedHousehold)); + lastMessage = $"{kind} on house {selectedHousehold}: {r.Outcome}"; + } + + private void DoPerson(ActionKind kind) + { + var h = S.Neighbourhood.HouseholdById(selectedHousehold); + if (h == null || selectedResident >= h.ResidentIds.Count) return; + int personId = h.ResidentIds[selectedResident]; + var r = S.Do(PlayerAction.OnPerson(kind, personId)); + lastMessage = $"{kind} on person {personId}: {r.Outcome}"; + } + + private void OnGUI() + { + if (!showOverlay || S == null || S.Session == null) return; + + var sim = S.Neighbourhood; + var h = sim.HouseholdById(selectedHousehold); + + var sb = new StringBuilder(); + sb.AppendLine($"MODULE 3 seed {S.Seed} day {sim.Day} " + + $"turn {S.Session.TurnIndex + 1}/{S.Session.TotalTurns} " + + $"(round {S.Session.RoundIndex + 1}, pilot {S.Session.PilotIndex + 1})"); + sb.AppendLine($"turn time {Mathf.Max(0f, S.TurnSecondsRemaining):0}s " + + $"nets left {sim.NetsRemaining} cases {sim.TotalCases} " + + $"mosquitoes {sim.AliveMosquitoCount}"); + sb.AppendLine(); + + if (h != null) + { + sb.AppendLine($"HOUSE {h.Id} screens {h.Screens} fan {(h.HasFan ? "on" : "no")} " + + $"containers {h.ProductiveContainerCount} " + + $"{(h.VisitCount == 0 ? "NEVER VISITED" : $"last visit day {h.LastVisitedDay}")}"); + + for (int i = 0; i < h.ResidentIds.Count; i++) + { + var p = sim.PersonById(h.ResidentIds[i]); + string state = p.HasVisibleWarningSign(sim.Day) ? $"** {p.Warning} - DOCTOR NOW **" + : p.IsFebrile(sim.Day) ? "unwell, fever" + : p.IsDefervescing(sim.Day) ? "looking better (keep watching)" + : p.State == HealthState.Hospitalised ? "in hospital" + : "well"; + sb.AppendLine($" {(i == selectedResident ? ">" : " ")} p{p.Id} {p.Age} {state}" + + $"{(p.HasNet ? " [net]" : "")}"); + } + } + + sb.AppendLine(); + sb.AppendLine("TAB house Q/E person V visit N net B take net back R refer"); + sb.AppendLine("W water T rest G repellent F fan C fix screen X closing hours SPACE end turn"); + if (lastMessage.Length > 0) sb.AppendLine($"> {lastMessage}"); + + GUI.Label(new Rect(12, 12, 900, 460), sb.ToString()); + } +} +#endif diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs.meta new file mode 100644 index 00000000..b61aaaa9 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 24ed968bbf5646bfaecd042b6fbcd97e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs new file mode 100644 index 00000000..ac30d007 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs @@ -0,0 +1,158 @@ +using System; +using Aedes.Module3.Sim; +using UnityEngine; + +/// +/// Drives one Module 3 session: builds the neighbourhood, runs the turn clock, and moves the +/// calendar on at each handover. +/// +/// The session logic itself lives in Aedes.Module3.Sim.Session, which knows nothing about Unity +/// and is covered by tests. This class supplies the clock and the events, and nothing else - +/// keeping it thin is what lets the whole session shape be verified without entering play mode. +/// +/// Section 8 on the clock: a turn is "fixed and enforced by the game rather than left to the +/// class - an untimed turn becomes one confident student playing while the other two watch, +/// which is the failure mode the squad structure exists to prevent." +/// +public class M3Session : MonoBehaviour +{ + public static M3Session Instance { get; private set; } + + [Header("Session")] + [Tooltip("0 picks a fresh seed each session. Set a value to replay an exact neighbourhood.")] + [SerializeField] private int seed; + + [Tooltip("Start as soon as the scene loads. Off if a menu or tutorial starts it instead.")] + [SerializeField] private bool autoStart = true; + + [Header("Debug")] + [SerializeField] private bool logHandovers = true; + + public Session Session { get; private set; } + public Neighbourhood Neighbourhood => Session?.Neighbourhood; + public int Seed { get; private set; } + + /// Seconds left in this turn. The Coach watches this on the cast, not only the Pilot. + public float TurnSecondsRemaining { get; private set; } + public float TurnSecondsTotal { get; private set; } + public bool Running { get; private set; } + + public event Action OnTurnStarted; // turn index + public event Action OnHandover; + public event Action OnSessionFinished; + public event Action OnActionResolved; + + private ParameterSet parameters; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + DontDestroyOnLoad(gameObject); + } + + private void Start() + { + if (autoStart) StartSession(); + } + + public void StartSession() + { + // Anything GAMA sent that had to wait for a rebuild takes effect here. + parameters = Module3Parameters.Instance != null + ? Module3Parameters.Instance.TakeForNewSession() + : new ParameterSet(); + + Seed = seed != 0 ? seed : Environment.TickCount & 0x7FFFFFFF; + + var neighbourhood = ScenarioBuilder.Build(Seed, parameters.Config, parameters.Scenario); + Session = new Session(neighbourhood); + + // So a mid-session parameter delivery knows what to stage onto. + if (Module3Parameters.Instance != null) Module3Parameters.Instance.LiveNeighbourhood = neighbourhood; + + TurnSecondsTotal = parameters.Config.TurnSeconds; + TurnSecondsRemaining = TurnSecondsTotal; + Running = true; + + Debug.Log($"Module 3: session started, seed {Seed}, " + + $"{parameters.Config.Rounds} rounds x {parameters.Config.TurnsPerRound} turns of " + + $"{TurnSecondsTotal:0}s, parameters from {parameters.Origin}."); + + OnTurnStarted?.Invoke(0); + } + + private void Update() + { + if (!Running) return; + + TurnSecondsRemaining -= Time.deltaTime; + if (TurnSecondsRemaining > 0f) return; + + EndTurn(); + } + + /// + /// Ends the turn early. The timer is the rule, but a Pilot who has finished a household + /// should not have to stand around waiting for it. + /// + public void EndTurn() + { + if (!Running) return; + + HandoverBrief brief = Session.EndTurn(); + if (brief == null) return; + + if (logHandovers) LogBrief(brief); + + if (brief.IsFinalHandover) + { + Running = false; + if (Module3Parameters.Instance != null) Module3Parameters.Instance.LiveNeighbourhood = null; + OnSessionFinished?.Invoke(brief); + return; + } + + TurnSecondsRemaining = TurnSecondsTotal; + OnHandover?.Invoke(brief); + OnTurnStarted?.Invoke(Session.TurnIndex); + } + + /// Everything the interaction layer routes through. + public ActionResult Do(PlayerAction action) + { + if (Session == null) return new ActionResult { Action = action, Outcome = ActionOutcome.Invalid }; + + ActionResult result = Session.Do(action); + OnActionResolved?.Invoke(result); + return result; + } + + /// + /// Section 5: before any score is shown, replay what would have happened had the first patient + /// been covered on day one. Same seed, same squad actions, one extra net. + /// + public RunResult BuildCounterfactual() + { + if (Session == null) return default; + return SimRunner.Counterfactual(Seed, parameters.Config, parameters.Scenario, + Session.RecordedActions(), Session.PatientZeroId()); + } + + private static void LogBrief(HandoverBrief b) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"--- Handover: {b.DaysElapsed} days later (day {b.DayNow}), nets left {b.NetsRemaining}"); + if (b.NewCases.Count > 0) sb.AppendLine($" {b.NewCases.Count} new case(s) since the last turn"); + if (b.NeedReferralNow.Count > 0) sb.AppendLine($" {b.NeedReferralNow.Count} need a doctor NOW"); + if (b.UncoveredPatients.Count > 0) sb.AppendLine($" {b.UncoveredPatients.Count} unwell and uncovered"); + if (b.LookingBetterButWatch.Count > 0) sb.AppendLine($" {b.LookingBetterButWatch.Count} looking better - keep watching"); + if (b.HouseholdsNeverVisited.Count > 0) sb.AppendLine($" {b.HouseholdsNeverVisited.Count} household(s) nobody has visited"); + Debug.Log(sb.ToString()); + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs.meta new file mode 100644 index 00000000..93c21924 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae6967dcfcba4d74aed5c0275422655b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/HandoverBrief.cs b/Assets/1.TeamWorkspace/Module3/Sim/HandoverBrief.cs new file mode 100644 index 00000000..69de2ed4 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/HandoverBrief.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// One thing the incoming Pilot is told about a person. + public struct PersonNote + { + public int PersonId; + public int HouseholdId; + /// Localization key for the line the Coach reads out. + public string MessageKey; + } + + /// + /// What the Coach briefs the incoming Pilot with, assembled at the moment the headset changes + /// hands. + /// + /// Section 8 is specific about this: "Every handover should show consequence. The point of + /// passing the headset is that the world is different when you get it back." So this is not a + /// score screen - it is a list of what changed, what was missed, and what nobody has looked at + /// yet. Section 9 adds that the squad plays from the Analyst's record rather than from memory, + /// because a few minutes is not long enough to hold a neighbourhood in your head. + /// + public sealed class HandoverBrief + { + /// The turn that just ended (0-based). + public int TurnIndex; + public int RoundIndex; + + /// How far the calendar jumped. Shown as plain words: "Three days later." + public int DaysElapsed; + public int DayNow; + + /// Cases that appeared during the jump. Each one gets a trace-back replay. + public readonly List NewCases = new List(); + + /// Visibly unwell and with no net up. The work that is waiting. + public readonly List UncoveredPatients = new List(); + + /// + /// Bleeding or persistent vomiting, right now. Section 4: not a judgement call, and the + /// strongest positive in the module if acted on. + /// + public readonly List NeedReferralNow = new List(); + + /// + /// People whose fever has broken. Section 8: "Danger often arrives as the fever comes down. + /// A Pilot who sees someone looking better and moves on has made the mistake the module + /// teaches, and the next Pilot inherits it." + /// + public readonly List LookingBetterButWatch = new List(); + + /// Section 8: "A house nobody has visited is still a house nobody has visited, + /// and the Coach should be saying so." + public readonly List HouseholdsNeverVisited = new List(); + + /// Visited once, but not lately. Returning is what makes the record worth keeping. + public readonly List HouseholdsNotSeenRecently = new List(); + + public int NetsRemaining; + + /// Anything GAMA changed during the turn, so the squad is not misled by a silent shift. + public readonly List ParameterChanges = new List(); + + public bool IsFinalHandover; + + public bool HasUrgentWork => NeedReferralNow.Count > 0 || UncoveredPatients.Count > 0; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/HandoverBrief.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/HandoverBrief.cs.meta new file mode 100644 index 00000000..f8d6f53b --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/HandoverBrief.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d0b22ff799534b27b960988750ab76be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs index 81f599c2..7130389a 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs @@ -57,9 +57,16 @@ public sealed class Module3Config public int WarningSignOnsetOffsetMin = -1; public int WarningSignOnsetOffsetMax = 1; + /// /// How long an unreferred warning sign goes before the household takes itself to hospital. - /// Section 10: this is a consequence shown to the player, never a deduction. - public int UnreferredWarningGraceDays = 2; + /// Section 10: a consequence shown to the player, never a deduction. + /// + /// MUST be longer than DaysPerHandover. At two days against a three-day handover jump, a + /// warning sign could appear and resolve itself entirely between two turns - the squad + /// never saw it and could not have acted, which quietly removes the module's second + /// takeaway. ParameterCsv refuses a file that reintroduces that. + /// + public int UnreferredWarningGraceDays = 4; // ---- Extrinsic incubation (clinical) --------------------------------------------------- /// Extrinsic incubation: "eight to twelve days before a mosquito that has fed on a case diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs index 11016651..12b47947 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs @@ -266,6 +266,16 @@ private static void Validate(ParameterSet set) + "the shortage is what makes the player choose."); } + if (c.UnreferredWarningGraceDays <= c.DaysPerHandover) + { + // Section 4 calls the referral rule the heart of the module. If a warning sign can + // appear and resolve itself inside one handover jump, the squad never sees it and + // cannot act on it - the lesson becomes unreachable rather than merely hard. + set.Errors.Add($"unreferred_warning_grace_days ({c.UnreferredWarningGraceDays}) must be greater " + + $"than days_per_handover ({c.DaysPerHandover}), or a warning sign can come and go " + + "between two turns and the squad never gets the chance to refer."); + } + if (c.NetOverRestingPerson >= c.NetOverActivePerson) { set.Errors.Add("net_over_resting_person must be well below net_over_active_person: " diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Person.cs b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs index f76891db..ac420dea 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Person.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs @@ -102,7 +102,7 @@ public bool HasVisibleWarningSign(int day) /// public bool IsDefervescing(int day) { - return HasBeenInfected && !IsAsymptomatic && day >= FeverEndDay && day <= FeverEndDay + 2; + return HasBeenInfected && !IsAsymptomatic && day > FeverEndDay && day <= FeverEndDay + 3; } } } diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Session.cs b/Assets/1.TeamWorkspace/Module3/Sim/Session.cs new file mode 100644 index 00000000..a234f9bb --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Session.cs @@ -0,0 +1,238 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// What one Pilot did during one turn, for the Analyst's Field Journal. + public sealed class TurnRecord + { + public int TurnIndex; + public int RoundIndex; + public int DayStart; + public int DayEnd; + public readonly List Actions = new List(); + public readonly List HouseholdsVisited = new List(); + } + + /// + /// The turn and handover structure: who is in the headset, for how long, and how far the + /// calendar moves when they hand it over. + /// + /// Section 8 makes the handover carry the time. A turn is two to four minutes, which is far + /// too short for anything in dengue to develop, so the game skips days between turns rather + /// than speeding anything up - "The compression is in the calendar, not in the biology." That + /// is why this class advances the model in whole days at a boundary and never continuously. + /// + /// Deliberately free of UnityEngine so the whole session shape can be tested headlessly. The + /// MonoBehaviour that drives it only supplies the clock. + /// + public sealed class Session + { + public readonly Neighbourhood Neighbourhood; + public readonly List Journal = new List(); + + private readonly Module3Config config; + private int lastLoggedEventCount; + + public int TurnIndex { get; private set; } + public bool Finished { get; private set; } + + public Session(Neighbourhood neighbourhood) + { + Neighbourhood = neighbourhood; + config = neighbourhood.Config; + BeginTurn(); + } + + public int RoundIndex => config.TurnsPerRound > 0 ? TurnIndex / config.TurnsPerRound : 0; + + /// Which of the three squad members is in the headset this turn. + public int PilotIndex => config.TurnsPerRound > 0 ? TurnIndex % config.TurnsPerRound : 0; + + public int TotalTurns => config.Rounds * config.TurnsPerRound; + + public TurnRecord CurrentTurn => Journal.Count > 0 ? Journal[Journal.Count - 1] : null; + + // ----------------------------------------------------------------------------------- + + private void BeginTurn() + { + Journal.Add(new TurnRecord + { + TurnIndex = TurnIndex, + RoundIndex = RoundIndex, + DayStart = Neighbourhood.Day, + }); + lastLoggedEventCount = Neighbourhood.Log.Events.Count; + } + + /// + /// The situation the squad arrives into, for the first Pilot. The index cases are not + /// "new" - nothing happened to produce them during a turn - so they never appear in a + /// handover brief, and without this the opening patients would go unannounced. + /// + public HandoverBrief OpeningBrief() + { + var brief = BuildBrief(Neighbourhood.Day); + brief.DaysElapsed = 0; + brief.NewCases.Clear(); + for (int i = 0; i < Neighbourhood.Log.Events.Count; i++) + { + if (Neighbourhood.Log.Events[i].IsIndexCase) brief.NewCases.Add(Neighbourhood.Log.Events[i]); + } + return brief; + } + + /// Applies a player action and records it in the journal. + public ActionResult Do(PlayerAction action) + { + if (Finished) return new ActionResult { Action = action, Outcome = ActionOutcome.Invalid }; + + ActionResult result = Neighbourhood.Apply(action); + var turn = CurrentTurn; + turn.Actions.Add(result); + + if (action.Kind == ActionKind.Visit && result.Outcome == ActionOutcome.Done + && !turn.HouseholdsVisited.Contains(action.HouseholdId)) + { + turn.HouseholdsVisited.Add(action.HouseholdId); + } + + return result; + } + + /// + /// Ends the turn: the calendar jumps, the world moves on, and the Coach gets something to + /// brief the next Pilot with. + /// + public HandoverBrief EndTurn() + { + if (Finished) return null; + + var turn = CurrentTurn; + int before = Neighbourhood.Day; + + Neighbourhood.AdvanceDays(config.DaysPerHandover); + turn.DayEnd = Neighbourhood.Day; + + var brief = BuildBrief(before); + + TurnIndex++; + if (TurnIndex >= TotalTurns) + { + Finished = true; + brief.IsFinalHandover = true; + } + else + { + BeginTurn(); + } + + return brief; + } + + private HandoverBrief BuildBrief(int dayBefore) + { + var brief = new HandoverBrief + { + TurnIndex = TurnIndex, + RoundIndex = RoundIndex, + DaysElapsed = Neighbourhood.Day - dayBefore, + DayNow = Neighbourhood.Day, + NetsRemaining = Neighbourhood.NetsRemaining, + }; + + var events = Neighbourhood.Log.Events; + for (int i = lastLoggedEventCount; i < events.Count; i++) brief.NewCases.Add(events[i]); + + int day = Neighbourhood.Day; + + for (int i = 0; i < Neighbourhood.People.Count; i++) + { + var p = Neighbourhood.People[i]; + if (p.State == HealthState.Hospitalised) continue; + + if (p.HasVisibleWarningSign(day) && !p.Referred) + { + brief.NeedReferralNow.Add(new PersonNote + { + PersonId = p.Id, + HouseholdId = p.HouseholdId, + MessageKey = p.Warning == WarningSign.Bleeding + ? "m3.brief.bleeding" + : "m3.brief.vomiting", + }); + continue; + } + + if (p.IsFebrile(day) && !p.HasNet) + { + brief.UncoveredPatients.Add(new PersonNote + { + PersonId = p.Id, + HouseholdId = p.HouseholdId, + MessageKey = "m3.brief.uncovered", + }); + } + else if (p.IsDefervescing(day)) + { + // Feeling better is not the all-clear. + brief.LookingBetterButWatch.Add(new PersonNote + { + PersonId = p.Id, + HouseholdId = p.HouseholdId, + MessageKey = "m3.brief.lookingBetter", + }); + } + } + + for (int i = 0; i < Neighbourhood.Households.Count; i++) + { + var h = Neighbourhood.Households[i]; + if (h.VisitCount == 0) brief.HouseholdsNeverVisited.Add(h.Id); + else if (day - h.LastVisitedDay > config.DaysPerHandover * config.TurnsPerRound) + { + brief.HouseholdsNotSeenRecently.Add(h.Id); + } + } + + brief.ParameterChanges.AddRange(Neighbourhood.Parameters.Describe()); + return brief; + } + + // ----------------------------------------------------------------------------------- + + /// + /// Everything the squad did, replayable. This is what the counterfactual is built from - + /// section 5's "replays what would have happened had the first patient been covered on + /// day one" re-runs these same actions with one net added on turn zero. + /// + public ScriptedActions RecordedActions() + { + var script = new ScriptedActions(); + for (int t = 0; t < Journal.Count; t++) + { + var turn = Journal[t]; + for (int a = 0; a < turn.Actions.Count; a++) + { + script.Add(turn.TurnIndex, turn.Actions[a].Action); + } + } + return script; + } + + /// The first case seeded in this neighbourhood - who the counterfactual covers. + public int PatientZeroId() + { + var events = Neighbourhood.Log.Events; + for (int i = 0; i < events.Count; i++) + { + if (!events[i].IsIndexCase) continue; + var p = Neighbourhood.PersonById(events[i].TargetPersonId); + // The one the squad could actually have found: an index case nobody could see is + // not a fair thing to replay at them. + if (p != null && !p.IsAsymptomatic) return p.Id; + } + return events.Count > 0 ? events[0].TargetPersonId : -1; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Session.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Session.cs.meta new file mode 100644 index 00000000..61ea6170 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Session.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c164856aa5d34c008bad84877dcf4636 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/SessionTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/SessionTests.cs new file mode 100644 index 00000000..93534689 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/SessionTests.cs @@ -0,0 +1,208 @@ +using Aedes.Module3.Sim; +using NUnit.Framework; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// The turn and handover structure of section 8, where the handover carries the time. + /// + [TestFixture] + public class SessionTests + { + private static Session NewSession(int seed = 1) + { + return new Session(ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default)); + } + + [Test] + public void ASessionRunsToItsPlannedLengthAndThenStops() + { + var s = NewSession(); + var cfg = Module3Config.Default; + int expectedTurns = cfg.Rounds * cfg.TurnsPerRound; + + int turns = 0; + while (!s.Finished) + { + s.EndTurn(); + turns++; + Assert.Less(turns, expectedTurns + 5, "the session did not finish when it should have"); + } + + Assert.AreEqual(expectedTurns, turns); + Assert.AreEqual(expectedTurns * cfg.DaysPerHandover, s.Neighbourhood.Day, + "the calendar should have moved by the handover jump once per turn"); + } + + [Test] + public void TheCalendarOnlyMovesAtAHandover() + { + // Section 8: the game skips forward rather than speeding anything up. Nothing should + // advance while a Pilot is in the middle of a turn. + var s = NewSession(); + int day = s.Neighbourhood.Day; + + s.Do(PlayerAction.OnHousehold(ActionKind.Visit, 0)); + s.Do(PlayerAction.OnHousehold(ActionKind.SetFan, 0)); + Assert.AreEqual(day, s.Neighbourhood.Day, "acting inside a turn moved the calendar"); + + s.EndTurn(); + Assert.AreEqual(day + Module3Config.Default.DaysPerHandover, s.Neighbourhood.Day); + } + + [Test] + public void TheRolesRotateEveryTurn() + { + // Section 9: one student is in the headset at a time and the roles rotate, which is + // what stops one confident student playing while the other two watch. + var s = NewSession(); + var seen = new System.Collections.Generic.HashSet(); + for (int t = 0; t < Module3Config.Default.TurnsPerRound; t++) + { + seen.Add(s.PilotIndex); + s.EndTurn(); + } + Assert.AreEqual(Module3Config.Default.TurnsPerRound, seen.Count, + "every squad member should have had the headset once per round"); + } + + [Test] + public void TheBriefNamesHouseholdsNobodyHasVisited() + { + // Section 8: "A house nobody has visited is still a house nobody has visited, and the + // Coach should be saying so." + var s = NewSession(); + s.Do(PlayerAction.OnHousehold(ActionKind.Visit, 3)); + var brief = s.EndTurn(); + + CollectionAssert.DoesNotContain(brief.HouseholdsNeverVisited, 3); + Assert.Greater(brief.HouseholdsNeverVisited.Count, 0, + "with one household visited, the rest should be reported as unvisited"); + } + + [Test] + public void TheBriefFlagsPatientsWhoLookBetterButAreInTheDangerWindow() + { + // Section 8: "Danger often arrives as the fever comes down. A Pilot who sees someone + // looking better and moves on has made the mistake the module teaches." + // + // Asserted across seeds: a thin outbreak may genuinely have nobody defervescing on a + // handover day, and that is not a fault. What would be a fault is the Coach rarely + // having the warning to give. + const int seeds = 100; + int withWarning = 0; + + for (int seed = 1; seed <= seeds; seed++) + { + var s = NewSession(seed); + while (!s.Finished) + { + var brief = s.EndTurn(); + if (brief != null && brief.LookingBetterButWatch.Count > 0) { withWarning++; break; } + } + } + + Assert.Greater(withWarning / (double)seeds, 0.8, + $"the Coach was only able to warn about a recovering patient in {withWarning} of {seeds} " + + "sessions; this is the mistake the module is built to teach and it has to come up"); + } + + [Test] + public void EveryCaseSurfacesExactlyOnceAcrossTheSession() + { + // Every case gets a trace-back replay (section 5), so none may fall between two + // briefs. The index cases arrive with the squad and belong to the opening brief; + // everything after that belongs to the handover it appeared in. + var s = NewSession(7); + int reported = s.OpeningBrief().NewCases.Count; + int indexCases = reported; + + while (!s.Finished) + { + var brief = s.EndTurn(); + if (brief != null) reported += brief.NewCases.Count; + } + + Assert.Greater(indexCases, 0, "the opening brief named no starting patients"); + Assert.AreEqual(s.Neighbourhood.Log.Events.Count, reported, + "a case surfaced in no brief at all, or in more than one"); + } + + [Test] + public void AWarningSignCannotAppearAndResolveBetweenTwoTurns() + { + // The grace period has to outlast the handover jump. Otherwise a warning sign can come + // and go while the headset is being passed, the squad never sees it, and section 4's + // referral rule becomes unreachable rather than merely hard. + var c = Module3Config.Default; + Assert.Greater(c.UnreferredWarningGraceDays, c.DaysPerHandover, + "a warning sign could appear and send itself to hospital between two turns"); + } + + [Test] + public void AWarningSignIsBriefedBeforeItBecomesAHospitalVisit() + { + // The referral has to be actionable: the Coach must hear about it while the squad can + // still do something. Section 10 calls acting on it the strongest positive available. + var s = NewSession(3); + bool briefed = false; + while (!s.Finished) + { + var brief = s.EndTurn(); + if (brief != null && brief.NeedReferralNow.Count > 0) { briefed = true; break; } + } + Assert.IsTrue(briefed, "no referral situation ever reached the Coach"); + } + + [Test] + public void TheJournalRecordsWhatEachPilotDid() + { + // Section 9: the journal "is not paperwork bolted on afterwards; it is how the squad plays." + var s = NewSession(); + s.Do(PlayerAction.OnHousehold(ActionKind.Visit, 2)); + s.Do(PlayerAction.OnPerson(ActionKind.HelpRest, 0)); + s.EndTurn(); + s.Do(PlayerAction.OnHousehold(ActionKind.Visit, 5)); + s.EndTurn(); + + Assert.AreEqual(2, s.Journal[0].Actions.Count); + CollectionAssert.Contains(s.Journal[0].HouseholdsVisited, 2); + CollectionAssert.Contains(s.Journal[1].HouseholdsVisited, 5); + Assert.AreEqual(0, s.Journal[0].DayStart); + Assert.AreEqual(Module3Config.Default.DaysPerHandover, s.Journal[0].DayEnd); + } + + [Test] + public void TheRecordedActionsReplayIntoTheSameOutbreak() + { + // The counterfactual depends on this: replaying the squad's own actions against the + // same seed has to reproduce the session they actually played, or the comparison is + // against a different world. + var s = NewSession(5); + while (!s.Finished) + { + if (s.TurnIndex == 0) s.Do(PlayerAction.OnPerson(ActionKind.PutUpNet, s.PatientZeroId())); + s.EndTurn(); + } + + var replay = SimRunner.Run(5, Module3Config.Default, ScenarioOptions.Default, s.RecordedActions()); + Assert.AreEqual(s.Neighbourhood.TotalCases, replay.TotalCases, + "replaying the squad's recorded actions produced a different outbreak"); + } + + [Test] + public void PatientZeroIsSomeoneTheSquadCouldHaveFound() + { + // Replaying "you could have covered them" at a squad only works if the person was + // findable. An asymptomatic index case is not. + for (int seed = 1; seed <= 30; seed++) + { + var s = NewSession(seed); + var p = s.Neighbourhood.PersonById(s.PatientZeroId()); + Assert.IsNotNull(p, $"seed {seed} had no patient zero"); + Assert.IsFalse(p.IsAsymptomatic, + $"seed {seed} picked an invisible index case for the counterfactual"); + } + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/SessionTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/SessionTests.cs.meta new file mode 100644 index 00000000..06edef59 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/SessionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 554f603fa1b64ef583b7c2de31c17cb9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Module3.meta b/Assets/Resources/Module3.meta new file mode 100644 index 00000000..0ecc0e9d --- /dev/null +++ b/Assets/Resources/Module3.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 324f22237a4e4d3aa9e801f7a97cfe92 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From d0ce1027a6ff3ac7b4cd38edaab11e26e3e50f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 20:30:26 +0700 Subject: [PATCH 07/61] Module 3: keep the shipped parameter file from drifting Raising UnreferredWarningGraceDays left the committed Resources CSV carrying the old value, which the new validation rule then rejected - so every session would have logged an error at startup and fallen back to the built-in defaults, with nothing visible in play mode but a warning nobody reads. - Regenerated the file. - Added a 'Module 3 > Regenerate parameter template' menu item, which refuses to write a file that would not pass validation rather than moving the failure to startup. - Added tests that the shipped file exists, is accepted, matches the built-in defaults and carries every key in the schema. Regenerating by hand is exactly the step that gets forgotten, so it is now a test rather than a note in a document. --- Assets/1.TeamWorkspace/Module3/Editor.meta | 8 ++ .../Editor/Module3ParameterTemplate.cs | 39 ++++++++++ .../Editor/Module3ParameterTemplate.cs.meta | 11 +++ .../Module3/Tests/BakedParameterFileTests.cs | 74 +++++++++++++++++++ .../Tests/BakedParameterFileTests.cs.meta | 11 +++ .../Resources/Module3/Module3Parameters.csv | 2 +- 6 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 Assets/1.TeamWorkspace/Module3/Editor.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Editor/Module3ParameterTemplate.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Editor/Module3ParameterTemplate.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/BakedParameterFileTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/BakedParameterFileTests.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3/Editor.meta b/Assets/1.TeamWorkspace/Module3/Editor.meta new file mode 100644 index 00000000..809aea57 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 892df348c2f54e588a42b06d60d1fc56 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3ParameterTemplate.cs b/Assets/1.TeamWorkspace/Module3/Editor/Module3ParameterTemplate.cs new file mode 100644 index 00000000..aa0a5396 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3ParameterTemplate.cs @@ -0,0 +1,39 @@ +using System.IO; +using Aedes.Module3.Sim; +using UnityEditor; +using UnityEngine; + +/// +/// Regenerates the parameter CSV that ships in Resources and that the GAMA modellers work from. +/// +/// This exists because the file drifted once already: a default changed in Module3Config, the CSV +/// kept the old value, and the file the build loads at startup no longer passed its own +/// validation. Regenerating by hand is exactly the step that gets forgotten, so it is a menu item +/// and a test rather than a note in a document. +/// +public static class Module3ParameterTemplate +{ + public const string Path = "Assets/Resources/Module3/Module3Parameters.csv"; + + [MenuItem("Module 3/Regenerate parameter template")] + public static void Regenerate() + { + string csv = ParameterCsv.WriteTemplate(new ParameterSet()); + + var check = ParameterCsv.Parse(csv, "regenerated"); + if (!check.IsValid) + { + // The defaults in Module3Config no longer satisfy the rules in ParameterCsv. Writing + // the file anyway would just move the failure to startup. + Debug.LogError("Module 3: the built-in defaults do not pass validation, so the template was " + + "NOT written:\n " + string.Join("\n ", check.Errors)); + return; + } + + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)); + File.WriteAllText(Path, csv); + AssetDatabase.ImportAsset(Path); + Debug.Log($"Module 3: parameter template regenerated at {Path} " + + $"({ParameterCsv.Descriptors.Count} keys). Hand this to the GAMA modellers."); + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3ParameterTemplate.cs.meta b/Assets/1.TeamWorkspace/Module3/Editor/Module3ParameterTemplate.cs.meta new file mode 100644 index 00000000..6643d4f9 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3ParameterTemplate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 09d4a0fb657544439a6ace54a16d45c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/BakedParameterFileTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/BakedParameterFileTests.cs new file mode 100644 index 00000000..591c6d61 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/BakedParameterFileTests.cs @@ -0,0 +1,74 @@ +using Aedes.Module3.Sim; +using NUnit.Framework; +using UnityEngine; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// The CSV that ships in Resources is what every session starts from, so it has to be a file + /// the game will actually accept. + /// + /// This suite exists because it drifted once: a default moved in Module3Config, the committed + /// CSV kept the old value, and the result was a file that failed its own validation at + /// startup and silently fell back to the built-in defaults. Nothing would have shown that in + /// play mode except a warning nobody was reading. + /// + [TestFixture] + public class BakedParameterFileTests + { + private const string Resource = "Module3/Module3Parameters"; + + [Test] + public void TheShippedParameterFileExists() + { + Assert.IsNotNull(Resources.Load(Resource), + $"no parameter file at Resources/{Resource} - run Module 3 > Regenerate parameter template"); + } + + [Test] + public void TheShippedParameterFileIsAccepted() + { + var csv = Resources.Load(Resource); + Assert.IsNotNull(csv); + + var set = ParameterCsv.Parse(csv.text, "baked"); + Assert.IsTrue(set.IsValid, + "the shipped parameter file would be rejected at startup:\n " + string.Join("\n ", set.Errors)); + Assert.IsEmpty(set.UnknownKeys, + "the shipped file carries keys this build does not understand: " + string.Join(", ", set.UnknownKeys)); + } + + [Test] + public void TheShippedFileMatchesTheBuiltInDefaults() + { + // If these drift apart, whichever one a reader happens to look at is misleading. + var csv = Resources.Load(Resource); + Assert.IsNotNull(csv); + + var fromFile = ParameterCsv.Parse(csv.text, "baked"); + var defaults = new ParameterSet(); + + Assert.AreEqual(defaults.Config.UnreferredWarningGraceDays, fromFile.Config.UnreferredWarningGraceDays); + Assert.AreEqual(defaults.Config.DaysPerHandover, fromFile.Config.DaysPerHandover); + Assert.AreEqual(defaults.Config.NetsPerSession, fromFile.Config.NetsPerSession); + Assert.AreEqual(defaults.Config.AsymptomaticShare, fromFile.Config.AsymptomaticShare, 1e-4); + Assert.AreEqual(defaults.Config.NetOverRestingPerson, fromFile.Config.NetOverRestingPerson, 1e-4); + Assert.AreEqual(defaults.Scenario.Lanes, fromFile.Scenario.Lanes); + Assert.AreEqual(defaults.Scenario.HouseholdsPerLane, fromFile.Scenario.HouseholdsPerLane); + } + + [Test] + public void EveryKeyInTheSchemaAppearsInTheShippedFile() + { + var csv = Resources.Load(Resource); + Assert.IsNotNull(csv); + + var set = ParameterCsv.Parse(csv.text, "baked"); + foreach (var d in ParameterCsv.Descriptors) + { + CollectionAssert.Contains(set.PresentKeys, d.Key, + $"'{d.Key}' is in the schema but missing from the file the GAMA modellers work from"); + } + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/BakedParameterFileTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/BakedParameterFileTests.cs.meta new file mode 100644 index 00000000..c9a0243a --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/BakedParameterFileTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c30dd63c5894d0d9ef814fce2062ddb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Module3/Module3Parameters.csv b/Assets/Resources/Module3/Module3Parameters.csv index e532902b..34fbabf8 100644 --- a/Assets/Resources/Module3/Module3Parameters.csv +++ b/Assets/Resources/Module3/Module3Parameters.csv @@ -18,7 +18,7 @@ asymptomatic_relative_infectiousness,0.35,fraction 0-1,clinical,future_infection warning_sign_share,0.08,fraction 0-1,clinical,future_infections,Background rate of bleeding or persistent vomiting among symptomatic cases. warning_sign_onset_offset_min,-1,days,clinical,future_infections,Earliest warning-sign onset relative to the end of fever. Section 8: danger arrives as the fever comes down. warning_sign_onset_offset_max,1,days,clinical,future_infections,Latest warning-sign onset relative to the end of fever. -unreferred_warning_grace_days,2,days,clinical,next_day,"How long before an unreferred household takes itself to hospital. A consequence shown to the player, never a penalty." +unreferred_warning_grace_days,4,days,clinical,next_day,"How long before an unreferred household takes itself to hospital. A consequence shown to the player, never a penalty." extrinsic_incubation_min_days,8,days,entomology,future_infections,Shortest time before a mosquito that fed on a case can transmit. Section 5: eight to twelve days. extrinsic_incubation_max_days,12,days,entomology,future_infections,Longest extrinsic incubation period. mosquito_lifespan_min_days,10,days,population-rates,next_day,Shortest adult female lifespan. From d74c2953ca9f3ade322c7deffd38fc7d72813141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 20:30:50 +0700 Subject: [PATCH 08/61] Module 3: roadmap status after the session layer --- Documentation/Module3-Roadmap.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Documentation/Module3-Roadmap.md b/Documentation/Module3-Roadmap.md index f5366c1f..19432f6a 100644 --- a/Documentation/Module3-Roadmap.md +++ b/Documentation/Module3-Roadmap.md @@ -18,8 +18,9 @@ Target branch: `module-3` off `main`. | 1 — Epidemic model | **Done.** `Module3/Sim/`, headless and deterministic. | | 1.5 — Calibration harness | **Done.** `Module3/Tests/`, 200 seeds. All five of section 5's guarantees hold. | | 7a — GAMA parameter delivery | **Done, brought forward.** Parameters now arrive as a GAMA-generated CSV, including mid-session. See the section below. | -| 2 — Session, turns, handover | Next. | -| 3–6, 7b, 8 | Not started. | +| 2 — Session, turns, handover | **Done.** `Sim/Session.cs` + `HandoverBrief`, driven by `M3Session`. `M3DebugDriver` plays a whole session from the keyboard with no headset and no art. | +| 3 — Household visit in VR | Next. Needs the placeholder props from the asset brief. | +| 4–6, 7b, 8 | Not started. | ### Calibration, 200 seeds @@ -255,6 +256,17 @@ per strategy for the clinical review. **Done when:** a full 5-round session can be played end-to-end with keyboard debug actions in a grey scene, the outbreak evolves between turns, and the handover screen states the time jump. +**Done.** Two bugs the tests caught, both worth remembering the shape of: + +- `UnreferredWarningGraceDays` was 2 against a 3-day handover jump, so a warning sign could appear + *and* send itself to hospital between two turns. The squad never saw it and could not have acted + — section 4's referral rule was unreachable rather than merely hard. Any parameter where a + consequence is shorter than the handover jump has this failure mode; `ParameterCsv` now refuses + such a file. +- The febrile and defervescing windows overlapped on the last day of fever, so on that day an + uncovered patient was reported as uncovered and never as "looking better" — masking precisely + the moment section 8 wants the Coach to warn about. + --- ### Phase 3 — The household visit in VR *(art-light, greybox props)* From 5f6b581732e706a45e5d178e821d53c39912e8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 20:33:59 +0700 Subject: [PATCH 09/61] Module 3: refuse a net that helps the well nearly as much as a patient The validation rule only caught net_over_resting_person being worse than net_over_active_person, but 0.9 against 0.92 is not 'worse' - it is 'no better', and that is just as fatal: it makes netting the sick and netting the well the same action and the module has no lesson left. Tightened to at most half, which is the threshold the calibration suite already asserts, so a parameter file cannot pass validation and then fail calibration. Caught by running the suite in Unity rather than headlessly. Full EditMode run: 40 tests, 40 passing, across calibration, session, parameter CSV and baked-file drift. --- Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs | 5 +++++ Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs | 13 +++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs index 7130389a..1b86776e 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs @@ -149,6 +149,11 @@ public sealed class Module3Config /// Days the calendar jumps at each handover ("Three days later."). public int DaysPerHandover = 3; + internal string NetOverResting_Display() => + NetOverRestingPerson.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); + internal string NetOverActive_Display() => + NetOverActivePerson.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); + public static Module3Config Default => new Module3Config(); public Module3Config Clone() diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs index 12b47947..89c045ba 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs @@ -276,11 +276,16 @@ private static void Validate(ParameterSet set) + "between two turns and the squad never gets the chance to refer."); } - if (c.NetOverRestingPerson >= c.NetOverActivePerson) + if (c.NetOverRestingPerson >= c.NetOverActivePerson * 0.5) { - set.Errors.Add("net_over_resting_person must be well below net_over_active_person: " - + "a net protects a patient who is lying down during the day, which is the " - + "distinction the module is built to teach."); + // Not merely "lower than" - a net that helps a well person nearly as much as a + // patient makes netting the sick and netting the well the same action, and the + // module has no lesson left. This threshold matches the one the calibration suite + // asserts, so a file cannot pass here and fail there. + set.Errors.Add($"net_over_resting_person ({c.NetOverResting_Display()}) must be at most half of " + + $"net_over_active_person ({c.NetOverActive_Display()}): a net protects a patient " + + "who is lying down during the day, and that difference is the distinction the " + + "module is built to teach."); } } From 3074b550fb4253274d7bb29a49d21da301c6f0c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 22:06:25 +0700 Subject: [PATCH 10/61] Module 3: reconcile the asset brief with the delivered placeholders All nine placeholder assets are built and independently verified against the brief: within triangle budget, one material each, unit scale, real metres, Fan_Blade separated, both screens matching the measured Lao house window opening, and the rolled-up net sharing the deployed net's origin so the two swap 1:1. Three divergences from what the brief asked for, and the delivered asset wins each: - Socket names are Socket_Mount / Socket_Grip / Socket_Hook / Socket_Light, and the fan's moving part is Fan_Blade. No code had bound to a socket name yet, so the artefact is the source of truth and the brief was corrected. - One SM_DrinkingVessel rather than a separate glass and jug: the simulation has a single BringWater action, so two meshes served no mechanic. - The deployed net reuses the existing SM_MosquitoNet rather than being remodelled; only the rolled-up variant was new. The brief also asked for an airflow-direction socket on the fan that turns out not to be needed - SetFan applies to a household, not to a person. Recorded as D12 in the decisions log. --- Documentation/Module3-Asset-Brief.md | 51 ++++++-- Documentation/Module3-Decisions.md | 175 +++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 13 deletions(-) create mode 100644 Documentation/Module3-Decisions.md diff --git a/Documentation/Module3-Asset-Brief.md b/Documentation/Module3-Asset-Brief.md index befb3ef4..c0a4bc86 100644 --- a/Documentation/Module3-Asset-Brief.md +++ b/Documentation/Module3-Asset-Brief.md @@ -13,6 +13,33 @@ The assets below are the things the player looks at and picks up while doing tha --- +## 0. Status — the placeholders are delivered + +All nine placeholder assets exist, built with Blender from `Tools/Module3/make_placeholders.py` +and turned into prefabs by `Assets/Editor/Module3PlaceholderPrefabs.cs`. Verified independently +against this brief: every asset within its triangle budget, one material each, unit scale, real +metres, and `Fan_Blade` correctly separated. **Phase 3 code is no longer blocked on art.** + +Three things were done differently from what this brief originally asked for, and in each case +**the delivered asset is right and this brief has been corrected to match**: + +| Brief originally said | Delivered | Why the delivered version wins | +|---|---|---| +| `Socket_Airflow`, `Socket_Beam`, `Socket_Hang`; blade named `Blade` | `Socket_Mount`, `Socket_Light`, `Socket_Hook`; `Fan_Blade` | Consistent `Socket_Mount` across everything that attaches; names now fixed, code binds to these | +| `SM_WaterGlass` **and** `SM_WaterJug` | One `SM_DrinkingVessel` | The simulation has a single `BringWater` action; two meshes served no mechanic | +| `SM_MosquitoNet_Deployed` as a new mesh | Prefab reuses the existing `SM_MosquitoNet`; only `SM_MosquitoNet_RolledUp` is new | The deployed net already existed in Team Assets. Correctly measured so the two swap 1:1 | + +One thing this brief asked for that turned out not to be needed: **the fan has no airflow-direction +socket.** The simulation models a fan at household level (`SetFan` applies to the room, not to a +person), so there is nothing for a direction to feed. `Socket_Mount` is sufficient. + +The health centre was built despite being marked "do not start yet" — kept deliberately crude at +84 triangles, which is the right call while §13 item 4 is unresolved. + +**Still outstanding:** the villager animation clips in §D. Those are the remaining art dependency. + +--- + ## 1. Read this first: two tiers, and why Every asset on the list has a **placeholder** form and a **final** form, and the placeholder is @@ -167,8 +194,8 @@ A cheap floor-standing or table-top electric fan, the kind found in most Lao hom - A protective cage around the blade is wanted — model it as simple crossed rings, not as a faithful wire mesh. - Pivot: centre of the base, at floor level. -- Socket: an empty named `Socket_Airflow` at the centre of the fan head, **+Z pointing the way the - air blows**. The game uses this to work out who is in the airflow. +- Socket: `Socket_Mount` at the base. No airflow-direction socket is needed — the simulation + applies a fan to the room, not to a person. #### A2. Window screen, intact — `SM_WindowScreen` An insect screen in a simple frame, fitted into a window opening. @@ -195,7 +222,7 @@ correctly over `PF_Bed` with somebody lying under it. floor on all four sides. - Single-sided geometry with an alpha-cutout net texture. It will be rendered two-sided in engine. - Pivot: floor level, centred on the bed's footprint. -- Socket: `Socket_Hang` at the apex, where it would tie to a ceiling hook. +- Socket: `Socket_Hook` at the apex, where it would tie to a ceiling hook. #### A5. Mosquito net, stowed — `SM_MosquitoNet_Stowed` The same net gathered up and tied off above the bed — what the room looks like before the player @@ -205,15 +232,11 @@ puts it up. Same pivot as A4 so the two can be swapped in place. ### Priority B — needed for the full set of player actions -#### B1. Drinking vessel — `SM_WaterGlass` -A plain glass or enamel cup, ~0.12 m tall. Held by the player and handed to a patient. -Pivot at the grip point. Must read as "clean drinking water" at arm's length. - -#### B2. Water bottle / jug — `SM_WaterJug` -A household jug or large plastic bottle, ~0.3 m tall, for bringing fluids to a patient. -Pivot at floor level; add `Socket_Grip` at the handle or neck. +#### B1. Drinking vessel — `SM_DrinkingVessel` +A plain glass or enamel cup, ~0.11 m tall. Held by the player and handed to a patient. +`Socket_Grip` at the grip point. Must read as "clean drinking water" at arm's length. -#### B3. Repellent bottle — `SM_Repellent` +#### B3. Repellent bottle — `SM_RepellentBottle` A small squeeze or spray bottle, ~0.15 m tall. Generic — **no real brand names, logos or trade dress of any kind.** Pivot at grip point. @@ -272,7 +295,9 @@ help — yes. Alarming — no. A "socket" is an **empty object** (Blender: Add → Empty → Plain Axes) parented to the mesh, named `Socket_`. It carries no geometry. It tells the game where something attaches or points. -- Name exactly as specified above — the code looks them up by name. +- Name exactly as specified above — the code looks them up by name. The set in use is + `Socket_Mount` (attaches to a surface or opening), `Socket_Grip` (held in a hand), + `Socket_Hook` (hangs from above) and `Socket_Light` (a beam origin). - Orientation matters: **+Z is "forward"** (the direction of airflow, a torch beam, the way a screen faces out). - Keep the empty's scale at `1, 1, 1`. @@ -287,7 +312,7 @@ A placeholder must still get these right, because the code binds to them: - ✅ Correct **real-world dimensions** - ✅ Correct **pivot / origin** - ✅ All **sockets** present and correctly oriented -- ✅ Separate **`Blade`** object on the fan +- ✅ Separate **`Fan_Blade`** object on the fan - ✅ Single material, any flat colour - ❌ Detail, texturing, bevels, and interesting silhouettes are all *not* required diff --git a/Documentation/Module3-Decisions.md b/Documentation/Module3-Decisions.md new file mode 100644 index 00000000..2f19bd63 --- /dev/null +++ b/Documentation/Module3-Decisions.md @@ -0,0 +1,175 @@ +# Module 3 — Decisions taken in code + +Design decisions the implementation had to make that the design document +(`AEDES_Module3_Game_Design_Rev3.docx`) does not settle. Each is **in force now** on the +recommended option; none is blocking. They are listed here so they can be confirmed, overturned, +or carried into Dr Marcombe's clinical review and the CMPE co-creation workshop rather than +surviving only in commit messages. + +Items marked **clinical** should go in front of Dr Marcombe. Items marked **pedagogical** are for +the programme/teaching side. Items marked **technical** need no external sign-off, but the team +should know they were made. + +--- + +### D1 — The simulation is a separate, engine-free assembly · *technical* + +`Assets/1.TeamWorkspace/Module3/Sim/` has its own `.asmdef`, which CLAUDE.md §2 otherwise +forbids for game code. + +**Why:** the assembly references nothing, not even `UnityEngine`, so it cannot break the implicit +cross-folder references the rule exists to protect. `Assembly-CSharp` auto-references assembly +definitions, so every MonoBehaviour can still use these types. It is what makes the model unit +testable — the first automated tests in this repository. + +--- + +### D2 — The counterfactual covers the first *symptomatic* index case · *pedagogical* + +§5 asks the game to replay "what would have happened had the first patient been covered on day +one". The scenario seeds more than one index case, including an asymptomatic one. + +**In force:** patient zero for the replay is the first index case who was *visibly* ill. + +**Why:** replaying "you could have covered them" only teaches something if the squad could have +found them. Holding a class to account for a person with no symptoms would teach the opposite of +§5's framing, which is emphatic that nobody is at fault for an infection they cannot feel. + +--- + +### D3 — There is an opening brief as well as handover briefs · *pedagogical* + +**In force:** the first Pilot gets a brief describing the situation the squad arrives into. + +**Why:** the index cases are not "new" — nothing happened during a turn to produce them — so they +never appear in a handover brief. Without an opening brief the starting patients would go +unannounced and the first turn would begin blind. + +--- + +### D4 — Warning-sign cases are guaranteed per session, not left to the rate · *clinical* · *pedagogical* + +**In force:** `guaranteed_warning_sign_cases = 2`. The background rate +(`warning_sign_share = 0.08`) still governs everybody else. + +**Why:** §4 calls the referral rule "the heart of the module" and §10 calls acting on it "the +strongest positive". At the honest background rate, a large share of sessions would present no +warning sign at all and would simply fail to teach it. This separates clinical realism in the +model from a teaching guarantee in the scenario. + +**For review:** confirm this is an acceptable way to present it — students meet the situation +every session, which is far more often than a real volunteer would. + +--- + +### D5 — "Cleared everything" means 80%, with cryptic sites that survive any sweep · *clinical* + +**In force:** `Module2Outcome.EverythingCleared` sets clearance to 0.80, and every plot keeps at +least 2 productive containers (`min_productive_containers_per_household`). + +**Why:** at 100% clearance there are no mosquitoes and no outbreak, which contradicts §5's +requirement that a squad who cleared every container "should still watch the outbreak grow". A +blocked gutter or water under a slab survives any sweep, so perfect prevention is not achievable +and the module should not imply it is. + +**For review:** confirm the residual is realistic for Vientiane households. + +--- + +### D6 — A net can be carried on to the next patient · *pedagogical* + +**In force:** a `ReclaimNet` action exists. Taking a net back is always a deliberate choice, never +automatic. + +**Why:** §5 requires fewer nets than households so the player must choose. Without reclamation the +budget is exhausted in the first two turns and later patients are unreachable regardless of skill. +With it, the shortage is about where the squad's attention goes across three weeks, which is the +more interesting version of the same constraint. + +--- + +### D7 — A consequence must outlast the handover jump · *pedagogical* + +**In force:** `unreferred_warning_grace_days` (4) must exceed `days_per_handover` (3). A parameter +file that breaks this is refused. + +**Why:** at 2 days against a 3-day jump, a warning sign could appear *and* send itself to hospital +entirely between two turns. The squad never saw it and could not have acted — the referral rule +became unreachable rather than merely hard. This was a real bug, caught by the session tests. + +--- + +### D8 — The referral button is always offered, never hidden · *pedagogical* + +**In force:** "get them to the health centre" is available for any person, whatever their state. + +**Why:** §4 says referral "is not a judgement call and the player should not weigh it up". Hiding +the button until it is correct would teach hesitation and would do the discriminating *for* the +student. §10 handles the wrong call afterwards: gently corrected, never punished. + +--- + +### D9 — The counterfactual can occasionally show a worse outcome · *pedagogical* · **needs a call** + +**In force:** in roughly 1.5% of neighbourhoods, covering patient zero on day one leaves the +outbreak no smaller — mosquitoes find somebody else. A test bounds this at 5%. + +**Why it is left in:** it is honest. Outbreaks are stochastic and suppressing it would mean +fabricating the replay. + +**The risk:** in about one class in sixty, the game's closing moment tells a squad that the right +action would not have helped. The facilitator needs an answer for that, or the debrief needs to +frame the replay as "one way it could have gone" rather than "what would have happened". + +**This is the one item here that wants a decision from the programme side rather than a note.** + +--- + +### D10 — Mid-session parameters apply from the next day, never retroactively · *technical* + +**In force:** a parameter set arriving from GAMA mid-session takes effect on the following +simulated day. People already infected keep the course of illness they were given. Structural +values (plot count, what Module 2 left behind) are held until a new session. + +**Why:** rewriting an already-scheduled illness would make the trace-back replay a lie about what +the squad actually saw, and rebuilding the neighbourhood would do it underneath a standing player. + +--- + +### D11 — Timings are compressed in the calendar only · *clinical* + +**In force:** incubation 4–10 days and extrinsic incubation 8–12 days are used unmodified. A test +fails if either moves outside those bounds. Population rates (bite probability, emergence, +lifespan) are tuned to make an outbreak legible inside a 50-day session and are labelled as +tuning knobs, not clinical claims. + +**Why:** §8 — "The compression is in the calendar, not in the biology", which is what lets the +module tell CMPE that the timings a student learns remain true. + +--- + +### D12 — Socket names follow the delivered assets, not the original brief · *technical* + +**In force:** `Socket_Mount`, `Socket_Grip`, `Socket_Hook`, `Socket_Light`, and `Fan_Blade` for the +fan's moving part. The asset brief originally specified `Socket_Airflow`, `Socket_Beam`, +`Socket_Hang` and `Blade`. + +**Why:** the placeholder assets were built and verified before any code bound to a socket name, so +the artefact is the source of truth and the brief was corrected. `Socket_Mount` is also more +consistent — one name for everything that attaches to a surface or an opening. + +Related: the fan needs no airflow-direction socket. The simulation applies `SetFan` to a household, +not to a person, so there is nothing for a direction to feed. + +--- + +## Still open, from §13 of the design document + +| # | Question | Status | +|---|---|---| +| 1 | Module title | Localization key `module3.title`; decide late | +| 2 | Marcombe review, then CMPE approval | Outstanding — the items above marked *clinical* belong in it | +| 3 | Turn length and round count | Config values; playtest decides | +| 3b | How Module 2's state reaches Module 3 | Solved in principle: `module2_container_clearance` in the GAMA parameter CSV | +| 4 | Is the health centre a place the player travels to? | **Still the only open question with real build cost.** Modelled off-screen for now; adding travel later is additive | +| 5 | Lao terminology for the warning signs | Localization rows; NUOL to fill in | From de0edef7485d622fdc9e093e63fb9ae6fcbd8995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 22:25:41 +0700 Subject: [PATCH 11/61] Module 3: the debrief, the map, the trace-back and section 10 scoring The end of the session - section 5 calls these the moments the module is built to deliver. All of it is a read off the transmission log, which is what Phase 1's individual mosquitoes bought. - TraceBack turns a case into the replay section 5 asks for: the container the mosquito came out of, the person it fed on, and where it ended up. Every field was recorded at the moment of infection, so this is a read rather than a reconstruction. - OutbreakMap: every chain that ran and every chain that stopped, plus the households where the squad's cover held. - Scoring implements section 10, including its two structural rules - a broken chain outscores everything else combined (measured: 1030 points against 127 in played sessions, dominating in 155 of the 155 sessions that broke one), and nothing the player gets wrong ever subtracts. A missed warning sign and an unnecessary referral are notes worth zero points, because the module corrects by showing consequences. - FieldJournal exports the facilitator's copy, ending on section 5's one question: 'Why did the outbreak stop, or why did it not?' Two things worth recording: Chain-broken cannot be judged from the end state. A squad that plays well takes the net back once a patient is past it, so by the end almost nobody whose chain they broke still has a net - the first implementation scored zero in every session. Person now records WasCoveredWhileInfectious and EverFedOnWhileInfectious as they happen. The Module 2 bridge needed widening. Section 5 hangs the debrief on a chain that began where nobody was ever visibly ill, but that chain only appears in 67% of sessions for a squad that plays well - they have fewer chains of every kind, so the squads who most deserve the moment are least likely to see it. The map now also carries the people who caught it and never felt a thing, which exists in every session and makes the same point; the chain is the sharper version when it happens. --- .../Module3/Sim/FieldJournal.cs | 149 +++++++ .../Module3/Sim/FieldJournal.cs.meta | 11 + .../Module3/Sim/Module3Config.cs | 17 + .../Module3/Sim/Neighbourhood.cs | 9 + .../Module3/Sim/Observation.cs | 395 ++++++++++++++++++ .../Module3/Sim/Observation.cs.meta | 11 + .../Module3/Sim/OutbreakMap.cs | 193 +++++++++ .../Module3/Sim/OutbreakMap.cs.meta | 11 + .../Module3/Sim/ParameterSet.cs | 26 ++ Assets/1.TeamWorkspace/Module3/Sim/Person.cs | 13 + Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs | 126 ++++++ .../Module3/Sim/Scoring.cs.meta | 11 + .../1.TeamWorkspace/Module3/Sim/TraceBack.cs | 113 +++++ .../Module3/Sim/TraceBack.cs.meta | 11 + .../Module3/Tests/DebriefTests.cs | 304 ++++++++++++++ .../Module3/Tests/DebriefTests.cs.meta | 11 + .../Tests/LocalizationCoverageTests.cs | 198 +++++++++ .../Tests/LocalizationCoverageTests.cs.meta | 11 + .../Module3/Tests/ObservationTests.cs | 207 +++++++++ .../Module3/Tests/ObservationTests.cs.meta | 11 + .../Localization/LocalizationData.csv | 82 ++++ .../Resources/Module3/Module3Parameters.csv | 3 + .../Localization/LocalizationManager.cs | 36 +- 23 files changed, 1957 insertions(+), 2 deletions(-) create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Observation.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Observation.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/OutbreakMap.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/OutbreakMap.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/TraceBack.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/TraceBack.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs b/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs new file mode 100644 index 00000000..517c025d --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using System.Text; + +namespace Aedes.Module3.Sim +{ + /// + /// The squad's record of the session, for the facilitator to read from at the debrief. + /// + /// Section 9: the journal "is not paperwork bolted on afterwards; it is how the squad plays." + /// Section 5 gives it its last line: "Why did the outbreak stop, or why did it not?" - which + /// the Analyst answers in the squad's own words and the facilitator reads aloud. A class that + /// has understood will say some version of the sentence the module exists to teach. + /// + public sealed class FieldJournalExport + { + public int Seed; + public int Days; + public readonly List Turns = new List(); + public readonly List Chains = new List(); + public readonly List Unvisited = new List(); + + public Scorecard Score; + public OutbreakMap Map; + public CounterfactualComparison Counterfactual; + + /// The closing prompt. Deliberately the last thing on the page. + public const string ClosingPrompt = "Why did the outbreak stop, or why did it not?"; + + public string ToPlainText() + { + var sb = new StringBuilder(); + sb.AppendLine("SCIENTIFIC FIELD JOURNAL - Module 3"); + sb.AppendLine($"Neighbourhood {Seed}, {Days} days"); + sb.AppendLine(); + + sb.AppendLine("WHAT WE DID"); + for (int i = 0; i < Turns.Count; i++) sb.AppendLine(" " + Turns[i]); + sb.AppendLine(); + + sb.AppendLine("HOW EACH CASE HAPPENED"); + if (Chains.Count == 0) sb.AppendLine(" No new cases after we arrived."); + for (int i = 0; i < Chains.Count; i++) sb.AppendLine(" " + Chains[i]); + sb.AppendLine(); + + if (Unvisited.Count > 0) + { + sb.AppendLine("HOUSES WE NEVER GOT TO"); + for (int i = 0; i < Unvisited.Count; i++) sb.AppendLine(" " + Unvisited[i]); + sb.AppendLine(); + } + + if (Map != null) + { + sb.AppendLine("THE OUTBREAK"); + sb.AppendLine($" {Map.TotalCases} people fell ill in all; {Map.SecondaryCases} caught it here."); + sb.AppendLine($" {Map.PreventableCases} of those came from someone we could see was unwell " + + "and who had no net."); + sb.AppendLine($" {Map.CasesFromInvisibleSources} came from someone who never looked ill at all."); + if (Map.HouseholdsWhereTheChainStopped.Count > 0) + { + sb.AppendLine($" We stopped the chain in {Map.HouseholdsWhereTheChainStopped.Count} house(s)."); + } + sb.AppendLine(); + } + + if (Counterfactual != null) + { + sb.AppendLine("IF THE FIRST PATIENT HAD BEEN COVERED ON DAY ONE"); + sb.AppendLine(Counterfactual.SquadMatchedIt + ? " The same as what you did. You covered them." + : $" {Counterfactual.CasesAvoided} fewer people would have caught it."); + sb.AppendLine(); + } + + if (Score != null) + { + sb.AppendLine("WHAT WENT WELL"); + for (int i = 0; i < Score.Lines.Count; i++) + { + var l = Score.Lines[i]; + sb.AppendLine(l.IsNote + ? $" - {l.Reason} x{l.Count} (something to talk about, not a mark against you)" + : $" - {l.Reason} x{l.Count}"); + } + sb.AppendLine(); + } + + sb.AppendLine(ClosingPrompt); + sb.AppendLine(" ______________________________________________________________"); + return sb.ToString(); + } + } + + public static class FieldJournal + { + public static FieldJournalExport Build(Neighbourhood n, Session session, + CounterfactualComparison counterfactual = null) + { + var export = new FieldJournalExport + { + Seed = n.Seed, + Days = n.Day, + Map = OutbreakMap.Build(n), + Score = Scoring.Evaluate(n, session), + Counterfactual = counterfactual, + }; + + for (int t = 0; t < session.Journal.Count; t++) + { + var turn = session.Journal[t]; + int done = 0; + for (int a = 0; a < turn.Actions.Count; a++) + { + if (turn.Actions[a].Outcome != ActionOutcome.Invalid) done++; + } + export.Turns.Add($"Day {turn.DayStart}-{turn.DayEnd}: visited " + + $"{turn.HouseholdsVisited.Count} house(s), did {done} thing(s)."); + } + + var events = n.Log.Events; + for (int i = 0; i < events.Count; i++) + { + var e = events[i]; + if (e.IsIndexCase) + { + export.Chains.Add($"Day {e.Day}: someone in house {e.TargetHouseholdId} was already ill " + + "when we arrived."); + continue; + } + + string how = e.SourceWasVisiblyIll + ? (e.SourceWasProtected + ? "from a patient who was under a net" + : "from a patient we could see was unwell, with no net up") + : "from someone who never looked ill"; + + export.Chains.Add($"Day {e.Day}: house {e.TargetHouseholdId} caught it " + + $"from house {e.SourceHouseholdId}, {how}."); + } + + for (int i = 0; i < n.Households.Count; i++) + { + if (n.Households[i].VisitCount == 0) export.Unvisited.Add($"House {n.Households[i].Id}"); + } + + return export; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs.meta new file mode 100644 index 00000000..512f8736 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3d729d0c99de41189e439d006def7f2e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs index 1b86776e..d30d2789 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Module3Config.cs @@ -154,6 +154,23 @@ internal string NetOverResting_Display() => internal string NetOverActive_Display() => NetOverActivePerson.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); + // ---- Scoring (section 10) ------------------------------------------------------------- + // "Consistent with Modules 1 and 2: failure, then consequence, then success. The player is + // not scored on speed." Nothing a player gets wrong subtracts - there are no negative + // weights here and there should never be one. + + /// + /// Section 10: a broken chain is "scored higher than every other action in the module + /// combined, because it is the only one that stops the virus leaving the house." + /// A calibration test checks that this actually holds in played sessions, not just on paper. + /// + public int PointsChainBroken = 1000; + + /// "The strongest positive in the module" among the per-person actions. + public int PointsReferredCorrectly = 60; + + public int PointsGoodHomeCare = 10; + public static Module3Config Default => new Module3Config(); public Module3Config Clone() diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs index 8e96f93f..7f9e9d03 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs @@ -336,6 +336,14 @@ private int AliveInHousehold(int householdId) private void Bite() { + // Note who is under a net on a day they could have infected a mosquito, before any + // biting happens. This is what "netted before a mosquito could feed on them" means. + for (int i = 0; i < People.Count; i++) + { + var person = People[i]; + if (person.HasNet && person.IsInfectiousToMosquitoes(Day)) person.WasCoveredWhileInfectious = true; + } + for (int i = 0; i < Mosquitoes.Count; i++) { var m = Mosquitoes[i]; @@ -361,6 +369,7 @@ private void Bite() && Rng.Chance(acquire, Seed, Day, RngStream.HumanToMosquito, m.Id)) { m.State = MosquitoState.Exposed; + target.EverFedOnWhileInfectious = true; m.AcquiredFromPersonId = target.Id; m.AcquiredFromHouseholdId = target.HouseholdId; m.AcquiredOnDay = Day; diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Observation.cs b/Assets/1.TeamWorkspace/Module3/Sim/Observation.cs new file mode 100644 index 00000000..6f7c1099 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Observation.cs @@ -0,0 +1,395 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// + /// How a person appears to a volunteer standing in the room. Not a diagnosis. + /// + /// Section 4: "The player never names the illness, never prescribes anything and never gives + /// medicine. They observe, they arrange care, and they refer." So there is no "infected" here, + /// no "dengue", and no state the player could not have seen with their own eyes. A person + /// incubating the virus, or carrying it with no symptoms at all, looks exactly like a person + /// who is well - because they do. + /// + public enum VisibleCondition + { + /// Nothing to see. Includes people incubating and people who never feel ill. + Seems_Well, + + /// Feverish, aching, tired. Most people with dengue. Cared for at home. + Unwell, + + /// Bleeding, or cannot keep anything down. Goes to a doctor now. Section 4. + NeedsADoctorNow, + + /// + /// The fever has broken. Section 8: "feeling better is not the all-clear. The dangerous + /// phase often begins as the fever drops." + /// + Improving, + + AtTheHealthCentre, + } + + /// Something the player can see in the room, as a localization key. + public struct VisibleSign + { + public string Key; + public static VisibleSign Of(string key) => new VisibleSign { Key = key }; + } + + public sealed class PersonObservation + { + public int PersonId; + public int HouseholdId; + public AgeBand Age; + public VisibleCondition Condition; + + /// + /// What the household says, in days: "she has been hot for three days". Section 7 wants + /// plain language, not clinical terms, so this is what the family has noticed rather than + /// anything the model knows. + /// + public int DaysUnwell; + + /// The line the household gives, and the number to substitute into it. + public string DialogueKey; + public int DialogueDays; + + /// Things in the room. Section 7 step 3: "what is visible". + public readonly List Signs = new List(); + + public bool HasNet; + public bool HasRested; + public bool HasFluids; + public bool AlreadyReferred; + + public bool IsUnwell => Condition == VisibleCondition.Unwell + || Condition == VisibleCondition.NeedsADoctorNow; + } + + public sealed class HouseholdObservation + { + public int HouseholdId; + public ScreenState Screens; + public bool HasFan; + public bool HasAirConditioning; + public bool ClosesDuringBitingHours; + + /// + /// Containers in the yard. Present as scenery and as the visible reason there are + /// mosquitoes here - section 5 is explicit that clearing them is not an action in this + /// module, so this is something to notice and be told about, never something to do. + /// + public int VisibleContainers; + + /// Roughly how many mosquitoes are about. What a cleared yard looks like versus one that is not. + public int MosquitoesAbout; + + public int LastVisitedDay; + public bool NeverVisited; + + public readonly List Residents = new List(); + + public bool AnyoneUnwell + { + get + { + for (int i = 0; i < Residents.Count; i++) if (Residents[i].IsUnwell) return true; + return false; + } + } + } + + /// One verb offered to the player, with why it is or is not available. + public struct AvailableAction + { + public ActionKind Kind; + public int PersonId; + public int HouseholdId; + /// Localization key for the button. Never "diagnose", never "treat" - section 4. + public string LabelKey; + public bool Enabled; + /// If disabled, why - shown as a quiet note rather than a refusal. + public string DisabledReasonKey; + + public PlayerAction ToAction() => new PlayerAction + { + Kind = Kind, + PersonId = PersonId, + HouseholdId = HouseholdId, + }; + } + + /// + /// Turns model state into what the player sees and what they are offered. + /// + /// Keeping this out of the MonoBehaviours is what lets the module's two hardest rules be + /// tested rather than reviewed: that the player is never offered a clinical action, and that + /// nothing marks a sick person as a hazard. + /// + public static class Observe + { + public static HouseholdObservation Household(Neighbourhood n, int householdId) + { + var h = n.HouseholdById(householdId); + if (h == null) return null; + + int mosquitoes = 0; + for (int i = 0; i < n.Mosquitoes.Count; i++) + { + if (n.Mosquitoes[i].Alive && n.Mosquitoes[i].HouseholdId == householdId) mosquitoes++; + } + + var obs = new HouseholdObservation + { + HouseholdId = h.Id, + Screens = h.Screens, + HasFan = h.HasFan, + HasAirConditioning = h.HasAirConditioning, + ClosesDuringBitingHours = h.ClosesDuringBitingHours, + VisibleContainers = h.Containers.Count, + MosquitoesAbout = mosquitoes, + LastVisitedDay = h.LastVisitedDay, + NeverVisited = h.VisitCount == 0, + }; + + for (int i = 0; i < h.ResidentIds.Count; i++) + { + obs.Residents.Add(Person(n, h.ResidentIds[i])); + } + return obs; + } + + public static PersonObservation Person(Neighbourhood n, int personId) + { + var p = n.PersonById(personId); + if (p == null) return null; + int day = n.Day; + + var obs = new PersonObservation + { + PersonId = p.Id, + HouseholdId = p.HouseholdId, + Age = p.Age, + HasNet = p.HasNet, + HasRested = p.HasRested, + HasFluids = p.HasFluids, + AlreadyReferred = p.Referred, + }; + + if (p.State == HealthState.Hospitalised) + { + obs.Condition = VisibleCondition.AtTheHealthCentre; + obs.DialogueKey = "m3.say.atHealthCentre"; + return obs; + } + + if (p.HasVisibleWarningSign(day)) + { + obs.Condition = VisibleCondition.NeedsADoctorNow; + obs.DaysUnwell = DaysSinceOnset(p, day); + obs.DialogueDays = obs.DaysUnwell; + + if (p.Warning == WarningSign.Bleeding) + { + // Plain words. A fourteen-year-old has to be able to act on this without + // interpreting it - section 4. + obs.DialogueKey = "m3.say.bleeding"; + obs.Signs.Add(VisibleSign.Of("m3.see.bloodOnCloth")); + } + else + { + obs.DialogueKey = "m3.say.cannotKeepWaterDown"; + obs.Signs.Add(VisibleSign.Of("m3.see.bucketByTheBed")); + } + + AddRoomSigns(p, obs); + return obs; + } + + if (p.IsFebrile(day)) + { + obs.Condition = VisibleCondition.Unwell; + obs.DaysUnwell = DaysSinceOnset(p, day); + obs.DialogueDays = obs.DaysUnwell; + obs.DialogueKey = obs.DaysUnwell <= 1 ? "m3.say.hotSinceYesterday" : "m3.say.hotForDays"; + obs.Signs.Add(VisibleSign.Of("m3.see.restingOnTheBed")); + AddRoomSigns(p, obs); + return obs; + } + + if (p.IsDefervescing(day)) + { + // The household will say they are better. They may well not be. + obs.Condition = VisibleCondition.Improving; + obs.DaysUnwell = DaysSinceOnset(p, day); + obs.DialogueDays = obs.DaysUnwell; + obs.DialogueKey = "m3.say.feelingBetterToday"; + AddRoomSigns(p, obs); + return obs; + } + + // Everyone else looks well - including people incubating, and the people who will + // never feel ill at all. Section 5: the squad cannot act on what it cannot see, and + // a mechanic the player cannot act on is only frustration. + obs.Condition = VisibleCondition.Seems_Well; + obs.DialogueKey = "m3.say.everyoneFineHere"; + return obs; + } + + private static int DaysSinceOnset(Person p, int day) + { + int d = day - p.SymptomOnsetDay; + return d < 0 ? 0 : d; + } + + private static void AddRoomSigns(Person p, PersonObservation obs) + { + if (p.HasNet) obs.Signs.Add(VisibleSign.Of("m3.see.netIsUp")); + if (!p.HasFluids) obs.Signs.Add(VisibleSign.Of("m3.see.noWaterWithinReach")); + } + + // ----------------------------------------------------------------------------------- + + /// + /// The verbs offered for a person. Section 4: "the action buttons are things like 'help + /// them rest', 'bring water', 'put up the net', 'get them to the health centre' - never + /// 'diagnose' or 'treat'." + /// + public static List ActionsFor(Neighbourhood n, int personId) + { + var list = new List(); + var p = n.PersonById(personId); + if (p == null || p.State == HealthState.Hospitalised) return list; + + var obs = Person(n, personId); + + list.Add(new AvailableAction + { + Kind = ActionKind.PutUpNet, + PersonId = personId, + HouseholdId = p.HouseholdId, + LabelKey = "m3.do.putUpTheNet", + Enabled = !p.HasNet && n.NetsRemaining > 0, + DisabledReasonKey = p.HasNet ? "m3.why.netAlreadyUp" + : n.NetsRemaining <= 0 ? "m3.why.noNetsLeft" : null, + }); + + if (p.HasNet) + { + list.Add(new AvailableAction + { + Kind = ActionKind.ReclaimNet, + PersonId = personId, + HouseholdId = p.HouseholdId, + LabelKey = "m3.do.takeTheNetOn", + Enabled = true, + }); + } + + list.Add(new AvailableAction + { + Kind = ActionKind.BringWater, + PersonId = personId, + HouseholdId = p.HouseholdId, + LabelKey = "m3.do.bringWater", + Enabled = !p.HasFluids, + DisabledReasonKey = p.HasFluids ? "m3.why.hasWater" : null, + }); + + list.Add(new AvailableAction + { + Kind = ActionKind.HelpRest, + PersonId = personId, + HouseholdId = p.HouseholdId, + LabelKey = "m3.do.helpThemRest", + Enabled = !p.HasRested, + DisabledReasonKey = p.HasRested ? "m3.why.alreadyResting" : null, + }); + + list.Add(new AvailableAction + { + Kind = ActionKind.GiveRepellent, + PersonId = personId, + HouseholdId = p.HouseholdId, + LabelKey = "m3.do.repellentOnSkin", + Enabled = p.RepellentUntilDay < n.Day, + DisabledReasonKey = p.RepellentUntilDay >= n.Day ? "m3.why.repellentOn" : null, + }); + + // Always offered, never pre-judged. Section 10: an unnecessary referral is "gently + // corrected, not punished" AFTER the fact - hiding the button would teach hesitation, + // and section 4 is explicit that the player "should not weigh it up". + list.Add(new AvailableAction + { + Kind = ActionKind.ReferToHealthCentre, + PersonId = personId, + HouseholdId = p.HouseholdId, + LabelKey = "m3.do.getThemToTheHealthCentre", + Enabled = !p.Referred, + DisabledReasonKey = p.Referred ? "m3.why.alreadyGoing" : null, + }); + + return list; + } + + /// Verbs offered for the house itself rather than a person. + public static List ActionsFor(Neighbourhood n, int householdId, bool household) + { + var list = new List(); + var h = n.HouseholdById(householdId); + if (h == null) return list; + + list.Add(new AvailableAction + { + Kind = ActionKind.RepairScreen, + HouseholdId = householdId, + PersonId = -1, + LabelKey = h.Screens == ScreenState.Missing ? "m3.do.fitAScreen" : "m3.do.mendTheScreen", + Enabled = h.Screens != ScreenState.Intact, + DisabledReasonKey = h.Screens == ScreenState.Intact ? "m3.why.screensAreFine" : null, + }); + + list.Add(new AvailableAction + { + Kind = ActionKind.SetFan, + HouseholdId = householdId, + PersonId = -1, + LabelKey = "m3.do.setTheFanGoing", + Enabled = !h.HasFan, + DisabledReasonKey = h.HasFan ? "m3.why.fanAlreadyOn" : null, + }); + + list.Add(new AvailableAction + { + Kind = ActionKind.AdviseClosingHours, + HouseholdId = householdId, + PersonId = -1, + LabelKey = "m3.do.shutUpAtDawnAndDusk", + Enabled = !h.ClosesDuringBitingHours, + DisabledReasonKey = h.ClosesDuringBitingHours ? "m3.why.alreadyClosing" : null, + }); + + return list; + } + + /// + /// Every verb the module will ever offer. There is deliberately no ClearContainer + /// (section 5) and no Diagnose or Treat (sections 4 and 12) - a test asserts it. + /// + public static readonly ActionKind[] OfferedVerbs = + { + ActionKind.Visit, + ActionKind.PutUpNet, + ActionKind.ReclaimNet, + ActionKind.RepairScreen, + ActionKind.SetFan, + ActionKind.AdviseClosingHours, + ActionKind.GiveRepellent, + ActionKind.BringWater, + ActionKind.HelpRest, + ActionKind.ReferToHealthCentre, + }; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Observation.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Observation.cs.meta new file mode 100644 index 00000000..aa965452 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Observation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 533e67e391644a2584e0d72333a11d2c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/OutbreakMap.cs b/Assets/1.TeamWorkspace/Module3/Sim/OutbreakMap.cs new file mode 100644 index 00000000..8c980e41 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/OutbreakMap.cs @@ -0,0 +1,193 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + public sealed class ChainLink + { + public int FromHouseholdId; + public int ToHouseholdId; + public int Day; + public bool Preventable; + public bool FromAnInvisibleSource; + } + + public sealed class HouseholdOutcome + { + public int HouseholdId; + public int Cases; + public bool AnyoneWasCovered; + public bool Visited; + /// Cases that left this house and infected someone else. + public int CasesItPassedOn; + } + + /// + /// The neighbourhood at the end, showing every chain that ran and every chain that was + /// stopped. Section 5: "A squad that netted the first patient on day one sees a map with + /// almost nothing on it, and should understand exactly why." + /// + public sealed class OutbreakMap + { + public readonly List Links = new List(); + public readonly List Households = new List(); + + public int TotalCases; + public int SecondaryCases; + public int PreventableCases; + public int CasesFromInvisibleSources; + + /// + /// Households the squad covered someone in, and where the chain stopped there. This is + /// the thing to draw brightly: the outbreak that did not happen. + /// + public readonly List HouseholdsWhereTheChainStopped = new List(); + + /// + /// The one chain that began where nobody was ever visibly ill. Section 5 hangs the + /// bridge back to Module 2 on it: "The debrief asks the squad what could have stopped + /// that chain. Every answer they try fails until they reach the only true one." + /// + /// -1 when no such chain ran this session. That is commonest for a squad that played + /// well, because they have fewer chains of every kind - so the debrief falls back to + /// , which makes the same point. + /// + public int InvisibleChainHouseholdId = -1; + + /// + /// Houses where somebody caught it and never felt a thing, whether or not they passed it + /// on. Section 5: "the neighbourhood therefore contains sources the volunteer can never + /// find, because there is nothing to see." + /// + /// This is the evidence the debrief can always show. A chain from an invisible source is + /// the sharper version of the moment, but it does not happen every session, and the + /// lesson - you cannot net a person who does not know they are infected - does not + /// depend on one having run. + /// + public readonly List HouseholdsWithAnInvisibleCase = new List(); + + public int InvisibleCases; + + /// True when the debrief has something to show for the Module 2 bridge. + public bool HasInvisibleEvidence => + InvisibleChainHouseholdId >= 0 || HouseholdsWithAnInvisibleCase.Count > 0; + + public static OutbreakMap Build(Neighbourhood n) + { + var map = new OutbreakMap(); + + for (int i = 0; i < n.Households.Count; i++) + { + var h = n.Households[i]; + bool covered = false; + for (int r = 0; r < h.ResidentIds.Count; r++) + { + var p = n.PersonById(h.ResidentIds[r]); + if (p != null && p.HasNet) { covered = true; break; } + } + + map.Households.Add(new HouseholdOutcome + { + HouseholdId = h.Id, + AnyoneWasCovered = covered, + Visited = h.VisitCount > 0, + }); + } + + // People who were infected and never looked ill. Findable only in hindsight, which is + // exactly the point the debrief makes with them. + for (int i = 0; i < n.People.Count; i++) + { + var p = n.People[i]; + if (!p.HasBeenInfected || !p.IsAsymptomatic) continue; + map.InvisibleCases++; + if (!map.HouseholdsWithAnInvisibleCase.Contains(p.HouseholdId)) + { + map.HouseholdsWithAnInvisibleCase.Add(p.HouseholdId); + } + } + + var events = n.Log.Events; + for (int i = 0; i < events.Count; i++) + { + var e = events[i]; + map.TotalCases++; + map.Households[e.TargetHouseholdId].Cases++; + + if (e.IsIndexCase) continue; + + map.SecondaryCases++; + if (e.WasPreventable) map.PreventableCases++; + if (!e.SourceWasVisiblyIll) + { + map.CasesFromInvisibleSources++; + if (map.InvisibleChainHouseholdId < 0) map.InvisibleChainHouseholdId = e.SourceHouseholdId; + } + + if (e.SourceHouseholdId >= 0 && e.SourceHouseholdId < map.Households.Count) + { + map.Households[e.SourceHouseholdId].CasesItPassedOn++; + } + + map.Links.Add(new ChainLink + { + FromHouseholdId = e.SourceHouseholdId, + ToHouseholdId = e.TargetHouseholdId, + Day = e.Day, + Preventable = e.WasPreventable, + FromAnInvisibleSource = !e.SourceWasVisiblyIll, + }); + } + + // A household that had a case, where somebody was covered, and which never passed the + // virus on. That is a chain the squad stopped. + for (int i = 0; i < map.Households.Count; i++) + { + var o = map.Households[i]; + if (o.Cases > 0 && o.AnyoneWasCovered && o.CasesItPassedOn == 0) + { + map.HouseholdsWhereTheChainStopped.Add(o.HouseholdId); + } + } + + return map; + } + } + + /// + /// The squad's outbreak beside the one that would have happened had the first patient been + /// covered on day one. Section 5 puts this before any score is shown. + /// + public sealed class CounterfactualComparison + { + public OutbreakMap AsPlayed; + public OutbreakMap HadPatientZeroBeenCovered; + + public int CasesAvoided => AsPlayed.SecondaryCases - HadPatientZeroBeenCovered.SecondaryCases; + + /// + /// True when the squad already did as well as the replay. Section 5: "A squad that did so + /// sees their own outcome confirmed." + /// + public bool SquadMatchedIt => CasesAvoided <= 0; + + public string HeadlineKey => SquadMatchedIt + ? "m3.replay.youAlreadyDidThis" + : "m3.replay.thisNeedNotHaveHappened"; + } + + /// + /// What the facilitator puts to the squad about the cases nobody could see. Section 5: "The + /// debrief asks the squad what could have stopped that chain. Every answer they try fails + /// until they reach the only true one - fewer mosquitoes, which was Module 2." + /// + public static class InvisibleCaseDebrief + { + public static string PromptKeyFor(OutbreakMap map) + { + if (map == null || !map.HasInvisibleEvidence) return "m3.debrief.noInvisibleEvidence"; + return map.InvisibleChainHouseholdId >= 0 + ? "m3.debrief.chainFromNowhere" + : "m3.debrief.peopleWhoNeverKnew"; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/OutbreakMap.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/OutbreakMap.cs.meta new file mode 100644 index 00000000..d3c9895b --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/OutbreakMap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cd99ce7bb76b40c5a3ee15d7d87e44f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs index 89c045ba..cc58d624 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/ParameterSet.cs @@ -266,6 +266,20 @@ private static void Validate(ParameterSet set) + "the shortage is what makes the player choose."); } + if (c.PointsChainBroken < 0 || c.PointsReferredCorrectly < 0 || c.PointsGoodHomeCare < 0) + { + // Section 10 scores failure as a consequence shown, never as marks taken away. + set.Errors.Add("scoring weights cannot be negative: the module shows consequences rather than " + + "deducting points, and a negative weight would turn a teaching moment into a penalty."); + } + + if (c.PointsChainBroken <= c.PointsReferredCorrectly * 4) + { + set.Errors.Add($"points_chain_broken ({c.PointsChainBroken}) has to dominate " + + $"points_referred_correctly ({c.PointsReferredCorrectly}): section 10 scores a broken " + + "chain higher than every other action in the module combined."); + } + if (c.UnreferredWarningGraceDays <= c.DaysPerHandover) { // Section 4 calls the referral rule the heart of the module. If a warning sign can @@ -358,6 +372,11 @@ private static bool Apply(ParameterSet set, string key, string raw, out string e case "turns_per_round": return Int(raw, ref c.TurnsPerRound, out error); case "rounds": return Int(raw, ref c.Rounds, out error); case "days_per_handover": return Int(raw, ref c.DaysPerHandover, out error); + + // scoring + case "points_chain_broken": return Int(raw, ref c.PointsChainBroken, out error); + case "points_referred_correctly": return Int(raw, ref c.PointsReferredCorrectly, out error); + case "points_good_home_care": return Int(raw, ref c.PointsGoodHomeCare, out error); } // scenario (struct, so it has to be read back out) @@ -426,6 +445,9 @@ private static string Read(ParameterSet set, string key) case "turns_per_round": return Str(c.TurnsPerRound); case "rounds": return Str(c.Rounds); case "days_per_handover": return Str(c.DaysPerHandover); + case "points_chain_broken": return Str(c.PointsChainBroken); + case "points_referred_correctly": return Str(c.PointsReferredCorrectly); + case "points_good_home_care": return Str(c.PointsGoodHomeCare); case "households_per_lane": return Str(s.HouseholdsPerLane); case "lanes": return Str(s.Lanes); case "module2_container_clearance": return Str(s.Module2.ContainerClearance); @@ -537,6 +559,10 @@ void D(string key, string unit, string section, ApplyScope scope, string notes) D("rounds", "count", sess, ApplyScope.NextSession, "Five or six makes a session."); D("days_per_handover", "days", sess, ApplyScope.NextDay, "How far the calendar jumps when the headset changes hands."); + D("points_chain_broken", "points", sess, ApplyScope.NextDay, "Section 10: a broken chain outscores every other action in the module combined. A calibration test checks this holds in played sessions."); + D("points_referred_correctly", "points", sess, ApplyScope.NextDay, "Acting on bleeding or persistent vomiting - the strongest of the per-person actions."); + D("points_good_home_care", "points", sess, ApplyScope.NextDay, "Rest, fluids and a return visit."); + D("households_per_lane", "count", scen, ApplyScope.NextSession, "Section 11: eight to ten plots along two or three lanes."); D("lanes", "count", scen, ApplyScope.NextSession, "Number of lanes."); D("module2_container_clearance", "fraction 0-1", scen, ApplyScope.NextSession, "What the squad cleared in Module 2. Open question 3b - this is how that state carries over."); diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Person.cs b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs index ac420dea..6e5e3f3e 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Person.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs @@ -56,6 +56,19 @@ public sealed class Person public bool HasRested; public bool HasFluids; + /// + /// A mosquito took the virus from this person at some point. The chain left the house. + /// + public bool EverFedOnWhileInfectious; + + /// + /// There was a net over this person on at least one day they could have infected a + /// mosquito. Recorded as it happens, because scoring cannot read it off the end state: + /// a squad that plays well takes the net back once a patient is past it (see ReclaimNet), + /// so by the end of the session the people whose chains they broke have no net at all. + /// + public bool WasCoveredWhileInfectious; + /// /// Visibly unwell on a given day - what a Pilot walking in would see. /// diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs b/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs new file mode 100644 index 00000000..da08cef5 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + public enum ScoreReason + { + ChainBroken, + ReferredCorrectly, + GoodHomeCare, + /// Not a deduction. Section 10: "not points deducted - a consequence shown." + MissedWarningSign, + /// Not a penalty. Section 10: "gently corrected, not punished." + UnnecessaryReferral, + } + + public struct ScoreLine + { + public ScoreReason Reason; + public int Count; + public int Points; + public string MessageKey; + /// A teaching moment rather than anything that moves the number. + public bool IsNote; + } + + /// + /// Section 10's scoring: "failure, then consequence, then success. The player is not scored + /// on speed." + /// + /// Two rules from that section are structural rather than cosmetic and are enforced by tests: + /// breaking a chain outweighs everything else the squad can do put together, and nothing the + /// player gets wrong ever subtracts. A missed warning sign and an unnecessary referral appear + /// as notes with zero points, because the module corrects them by showing what happened, not + /// by taking marks away. + /// + public sealed class Scorecard + { + public readonly List Lines = new List(); + + public int Total; + public int ChainsBroken; + public int CorrectReferrals; + public int HouseholdsGivenGoodCare; + public int MissedWarningSigns; + public int UnnecessaryReferrals; + + /// Points from breaking chains, as against points from everything else. + public int ChainPoints; + public int OtherPoints; + } + + public static class Scoring + { + public static Scorecard Evaluate(Neighbourhood n, Session session) + { + var card = new Scorecard(); + var cfg = n.Config; + + for (int i = 0; i < n.People.Count; i++) + { + var p = n.People[i]; + if (!p.HasBeenInfected) continue; + + // Section 10: "A sick person netted or screened before a mosquito could feed on + // them." Only counted for people the squad could actually have found - covering + // someone with no symptoms is not something a player can aim at. + if (!p.IsAsymptomatic && p.WasCoveredWhileInfectious && !p.EverFedOnWhileInfectious) + { + card.ChainsBroken++; + } + + if (p.Warning != WarningSign.None) + { + if (p.WentToHospitalUnaided) card.MissedWarningSigns++; + else if (p.Referred) card.CorrectReferrals++; + } + else if (p.Referred) + { + card.UnnecessaryReferrals++; + } + } + + // Good home care: rest, fluids, and a return visit. Section 7 step 6 - "Move on, and + // come back. Returning matters." + for (int i = 0; i < n.Households.Count; i++) + { + var h = n.Households[i]; + if (h.VisitCount < 2) continue; + + for (int r = 0; r < h.ResidentIds.Count; r++) + { + var p = n.PersonById(h.ResidentIds[r]); + if (p != null && p.HasRested && p.HasFluids) { card.HouseholdsGivenGoodCare++; break; } + } + } + + card.ChainPoints = card.ChainsBroken * cfg.PointsChainBroken; + card.OtherPoints = card.CorrectReferrals * cfg.PointsReferredCorrectly + + card.HouseholdsGivenGoodCare * cfg.PointsGoodHomeCare; + card.Total = card.ChainPoints + card.OtherPoints; + + Add(card, ScoreReason.ChainBroken, card.ChainsBroken, card.ChainsBroken * cfg.PointsChainBroken, + "m3.score.chainBroken", false); + Add(card, ScoreReason.ReferredCorrectly, card.CorrectReferrals, + card.CorrectReferrals * cfg.PointsReferredCorrectly, "m3.score.referredCorrectly", false); + Add(card, ScoreReason.GoodHomeCare, card.HouseholdsGivenGoodCare, + card.HouseholdsGivenGoodCare * cfg.PointsGoodHomeCare, "m3.score.goodHomeCare", false); + + // Both of these are worth exactly zero, on purpose. + Add(card, ScoreReason.MissedWarningSign, card.MissedWarningSigns, 0, "m3.score.missedWarningSign", true); + Add(card, ScoreReason.UnnecessaryReferral, card.UnnecessaryReferrals, 0, + "m3.score.unnecessaryReferral", true); + + return card; + } + + private static void Add(Scorecard card, ScoreReason reason, int count, int points, string key, bool note) + { + if (count == 0) return; + card.Lines.Add(new ScoreLine + { + Reason = reason, Count = count, Points = points, MessageKey = key, IsNote = note, + }); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs.meta new file mode 100644 index 00000000..6703aa17 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cb514bc6c526464eb3959384199df01b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/TraceBack.cs b/Assets/1.TeamWorkspace/Module3/Sim/TraceBack.cs new file mode 100644 index 00000000..f81d8e8d --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/TraceBack.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + /// One beat of the trace-back replay, in the order the camera visits it. + public struct TraceBackStep + { + public string MessageKey; + public int HouseholdId; + public int PersonId; + public int ContainerId; + public int Day; + } + + /// + /// Why this person fell ill, shown rather than explained. + /// + /// Section 5: "When a new case appears, the game traces it back. A short replay shows the + /// house it came from, the container the mosquito came from, and the night the sick person + /// slept unprotected." + /// + /// Every field this needs was recorded at the moment of infection, so building one is a read + /// rather than a reconstruction. That is the whole reason mosquitoes are individuals. + /// + public sealed class TraceBackReplay + { + public int NewCasePersonId; + public int NewCaseHouseholdId; + public int Day; + public bool WasPreventable; + public bool SourceWasInvisible; + public readonly List Steps = new List(); + + /// The single line the Analyst writes in the Field Journal. + public string SummaryKey; + } + + public static class TraceBack + { + public static TraceBackReplay Build(Neighbourhood n, TransmissionEvent e) + { + if (e == null) return null; + + var replay = new TraceBackReplay + { + NewCasePersonId = e.TargetPersonId, + NewCaseHouseholdId = e.TargetHouseholdId, + Day = e.Day, + WasPreventable = e.WasPreventable, + SourceWasInvisible = !e.IsIndexCase && !e.SourceWasVisiblyIll, + }; + + if (e.IsIndexCase) + { + // The outbreak the squad arrived into. There is nothing behind it to show. + replay.SummaryKey = "m3.trace.arrivedIll"; + replay.Steps.Add(new TraceBackStep + { + MessageKey = "m3.trace.arrivedIll", + HouseholdId = e.TargetHouseholdId, + PersonId = e.TargetPersonId, + Day = e.Day, + }); + return replay; + } + + // 1. The water the mosquito came out of. + replay.Steps.Add(new TraceBackStep + { + MessageKey = "m3.trace.fromThisContainer", + HouseholdId = e.SourceHouseholdId, + ContainerId = e.OriginContainerId, + Day = e.Day, + }); + + // 2. The person it fed on, and whether anyone could have known. + replay.Steps.Add(new TraceBackStep + { + MessageKey = e.SourceWasVisiblyIll + ? (e.SourceWasProtected ? "m3.trace.fedOnCoveredPatient" : "m3.trace.fedOnUncoveredPatient") + : "m3.trace.fedOnSomeoneWhoSeemedWell", + HouseholdId = e.SourceHouseholdId, + PersonId = e.SourcePersonId, + Day = e.Day, + }); + + // 3. Where it ended up. + replay.Steps.Add(new TraceBackStep + { + MessageKey = e.SourceHouseholdId == e.TargetHouseholdId + ? "m3.trace.bitSomeoneInTheSameHouse" + : "m3.trace.bitSomeoneNextDoor", + HouseholdId = e.TargetHouseholdId, + PersonId = e.TargetPersonId, + Day = e.Day, + }); + + replay.SummaryKey = e.WasPreventable ? "m3.trace.summaryPreventable" + : replay.SourceWasInvisible ? "m3.trace.summaryInvisible" + : "m3.trace.summaryCovered"; + return replay; + } + + /// Every replay for the cases that appeared during one handover. + public static List ForBrief(Neighbourhood n, HandoverBrief brief) + { + var list = new List(); + if (brief == null) return list; + for (int i = 0; i < brief.NewCases.Count; i++) list.Add(Build(n, brief.NewCases[i])); + return list; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/TraceBack.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/TraceBack.cs.meta new file mode 100644 index 00000000..fd008b4b --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/TraceBack.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e33621ef9c1c440c80972e7f1617748f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs new file mode 100644 index 00000000..2af959c5 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs @@ -0,0 +1,304 @@ +using System.Collections.Generic; +using Aedes.Module3.Sim; +using NUnit.Framework; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// The end of the session: the trace-back replays, the outbreak map, the counterfactual, and + /// the scorecard. Section 5 calls these the moments the module is built to deliver, so they + /// are held to the design rather than left to look right. + /// + [TestFixture] + public class DebriefTests + { + private static Session PlayOut(int seed, IStrategy strategy = null) + { + var n = ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default); + var s = new Session(n); + while (!s.Finished) + { + strategy?.OnTurn(n, s.TurnIndex); + s.EndTurn(); + } + return s; + } + + // ---- trace-back ------------------------------------------------------------------ + + [Test] + public void EveryCaseCanBeTracedBack() + { + // Section 5: "When a new case appears, the game traces it back." Every case, not most. + var s = PlayOut(7); + foreach (var e in s.Neighbourhood.Log.Events) + { + var replay = TraceBack.Build(s.Neighbourhood, e); + Assert.IsNotNull(replay, $"case for person {e.TargetPersonId} had no replay"); + Assert.Greater(replay.Steps.Count, 0, "a replay with no steps shows the squad nothing"); + Assert.IsNotNull(replay.SummaryKey); + } + } + + [Test] + public void ASecondaryCaseNamesTheHouseTheContainerAndTheBite() + { + // "A short replay shows the house it came from, the container the mosquito came from, + // and the night the sick person slept unprotected." + var s = PlayOut(7); + TransmissionEvent secondary = s.Neighbourhood.Log.Events.Find(e => !e.IsIndexCase); + Assert.IsNotNull(secondary, "seed 7 produced no secondary case"); + + var replay = TraceBack.Build(s.Neighbourhood, secondary); + Assert.AreEqual(3, replay.Steps.Count, "a secondary case should show container, source, and bite"); + Assert.AreEqual(secondary.SourceHouseholdId, replay.Steps[0].HouseholdId); + Assert.AreEqual(secondary.OriginContainerId, replay.Steps[0].ContainerId); + Assert.AreEqual(secondary.SourcePersonId, replay.Steps[1].PersonId); + Assert.AreEqual(secondary.TargetPersonId, replay.Steps[2].PersonId); + } + + [Test] + public void AnIndexCaseIsShownAsAlreadyIllRatherThanBlamedOnAnyone() + { + var s = PlayOut(1); + var index = s.Neighbourhood.Log.Events.Find(e => e.IsIndexCase); + var replay = TraceBack.Build(s.Neighbourhood, index); + + Assert.AreEqual("m3.trace.arrivedIll", replay.SummaryKey); + Assert.IsFalse(replay.WasPreventable, "the outbreak the squad arrived into is not their failure"); + } + + // ---- the map --------------------------------------------------------------------- + + [Test] + public void TheMapAccountsForEveryCase() + { + var s = PlayOut(5); + var map = OutbreakMap.Build(s.Neighbourhood); + + Assert.AreEqual(s.Neighbourhood.Log.Events.Count, map.TotalCases); + Assert.AreEqual(s.Neighbourhood.SecondaryCases, map.SecondaryCases); + Assert.AreEqual(map.SecondaryCases, map.Links.Count, "every secondary case is one link on the map"); + + int summed = 0; + foreach (var h in map.Households) summed += h.Cases; + Assert.AreEqual(map.TotalCases, summed, "a case was drawn in no household"); + } + + [Test] + public void TheDebriefAlwaysHasSomethingToShowAboutTheCasesNobodyCouldSee() + { + // Section 5 hangs the bridge back to Module 2 on this moment, so it cannot be absent. + // A chain from an invisible source is the sharper version, but a well-playing squad + // has fewer chains of every kind and only sees one about two thirds of the time - + // so the map also carries the people who caught it and never felt a thing, which is + // available in every session and makes the same point. + int withEvidence = 0; + for (int seed = 1; seed <= 60; seed++) + { + var map = OutbreakMap.Build(PlayOut(seed, new NetEveryVisiblePatient()).Neighbourhood); + if (map.HasInvisibleEvidence) withEvidence++; + } + + Assert.AreEqual(60, withEvidence, + $"only {withEvidence} of 60 neighbourhoods gave the debrief anything to point at"); + } + + [Test] + public void ASharperChainFromAnInvisibleSourceTurnsUpOftenEnoughToMatter() + { + int withChain = 0; + for (int seed = 1; seed <= 60; seed++) + { + var map = OutbreakMap.Build(PlayOut(seed, new NetEveryVisiblePatient()).Neighbourhood); + if (map.InvisibleChainHouseholdId >= 0) withChain++; + } + + // Measured at about two thirds. Asserted below that so a real regression shows up, + // without pretending the moment is guaranteed - see the decisions log. + Assert.Greater(withChain / 60.0, 0.5, + $"a chain from an invisible source appeared in only {withChain} of 60 neighbourhoods"); + } + + [Test] + public void TheDebriefPromptMatchesWhatTheMapCanActuallyShow() + { + for (int seed = 1; seed <= 20; seed++) + { + var map = OutbreakMap.Build(PlayOut(seed, new NetEveryVisiblePatient()).Neighbourhood); + string key = InvisibleCaseDebrief.PromptKeyFor(map); + + if (map.InvisibleChainHouseholdId >= 0) + Assert.AreEqual("m3.debrief.chainFromNowhere", key); + else if (map.HouseholdsWithAnInvisibleCase.Count > 0) + Assert.AreEqual("m3.debrief.peopleWhoNeverKnew", key); + else + Assert.AreEqual("m3.debrief.noInvisibleEvidence", key); + } + } + + [Test] + public void ASquadThatCoveredItsPatientsSeesANearlyEmptyMap() + { + // "A squad that netted the first patient on day one sees a map with almost nothing on + // it, and should understand exactly why." + double covered = 0, ignored = 0; + for (int seed = 1; seed <= 60; seed++) + { + covered += OutbreakMap.Build(PlayOut(seed, new NetEveryVisiblePatient()).Neighbourhood).Links.Count; + ignored += OutbreakMap.Build(PlayOut(seed, new DoNothing()).Neighbourhood).Links.Count; + } + + Assert.Less(covered, ignored * 0.6, + $"the map for a squad that covered its patients ({covered / 60:F1} links) has to look visibly " + + $"emptier than for one that did not ({ignored / 60:F1})"); + } + + // ---- the counterfactual ---------------------------------------------------------- + + [Test] + public void TheCounterfactualConfirmsASquadThatAlreadyCoveredPatientZero() + { + // Section 5: "A squad that did so sees their own outcome confirmed." + var n = ScenarioBuilder.Build(9, Module3Config.Default, ScenarioOptions.Default); + var s = new Session(n); + s.Do(PlayerAction.OnPerson(ActionKind.PutUpNet, s.PatientZeroId())); + while (!s.Finished) s.EndTurn(); + + var replay = SimRunner.Counterfactual(9, Module3Config.Default, ScenarioOptions.Default, + s.RecordedActions(), s.PatientZeroId()); + var comparison = new CounterfactualComparison + { + AsPlayed = OutbreakMap.Build(n), + HadPatientZeroBeenCovered = OutbreakMap.Build(replay.Neighbourhood), + }; + + Assert.IsTrue(comparison.SquadMatchedIt, + "a squad that covered patient zero on day one was told it could have done better"); + Assert.AreEqual("m3.replay.youAlreadyDidThis", comparison.HeadlineKey); + } + + // ---- scoring (section 10) -------------------------------------------------------- + + [Test] + public void BreakingAChainOutscoresEverythingElsePutTogether() + { + // Section 10: "Scored higher than every other action in the module combined, because + // it is the only one that stops the virus leaving the house." Checked in played + // sessions rather than on paper, because the weights alone do not prove it. + int checkedSessions = 0; + for (int seed = 1; seed <= 60; seed++) + { + var s = PlayOut(seed, new NetEveryVisiblePatient()); + var card = Scoring.Evaluate(s.Neighbourhood, s); + if (card.ChainsBroken == 0) continue; + + checkedSessions++; + Assert.Greater(card.ChainPoints, card.OtherPoints, + $"seed {seed}: {card.ChainsBroken} broken chain(s) scored {card.ChainPoints}, " + + $"behind {card.OtherPoints} from everything else"); + } + + Assert.Greater(checkedSessions, 20, "too few sessions broke a chain to test this meaningfully"); + } + + [Test] + public void NothingThePlayerGetsWrongEverSubtracts() + { + // Section 10: a missed warning sign is "not points deducted - a consequence shown", + // and an unnecessary referral is "gently corrected, not punished". + for (int seed = 1; seed <= 30; seed++) + { + var s = PlayOut(seed, new DoNothing()); + var card = Scoring.Evaluate(s.Neighbourhood, s); + + Assert.GreaterOrEqual(card.Total, 0, $"seed {seed} produced a negative score"); + foreach (var line in card.Lines) + { + Assert.GreaterOrEqual(line.Points, 0, $"{line.Reason} took marks away"); + if (line.Reason == ScoreReason.MissedWarningSign + || line.Reason == ScoreReason.UnnecessaryReferral) + { + Assert.AreEqual(0, line.Points, $"{line.Reason} should cost nothing"); + Assert.IsTrue(line.IsNote, $"{line.Reason} should read as a teaching moment"); + } + } + } + } + + [Test] + public void ASquadThatDoesNothingScoresLessThanOneThatCoversItsPatients() + { + double idle = 0, working = 0; + for (int seed = 1; seed <= 40; seed++) + { + var a = PlayOut(seed, new DoNothing()); + var b = PlayOut(seed, new NetEveryVisiblePatient()); + idle += Scoring.Evaluate(a.Neighbourhood, a).Total; + working += Scoring.Evaluate(b.Neighbourhood, b).Total; + } + Assert.Greater(working, idle * 2, $"doing the work scored {working / 40:F0} against {idle / 40:F0} for doing nothing"); + } + + [Test] + public void CoveringSomeoneWhoNeverLookedIllIsNotScored() + { + // A player cannot aim at an invisible case, so it must not quietly count for or + // against them. Section 5: the squad cannot act on cases it cannot see. + var s = PlayOut(3, new NetEveryVisiblePatient()); + var card = Scoring.Evaluate(s.Neighbourhood, s); + + int asymptomaticNetted = 0; + foreach (var p in s.Neighbourhood.People) + { + if (p.HasBeenInfected && p.IsAsymptomatic && p.HasNet) asymptomaticNetted++; + } + + int visibleNetted = 0; + foreach (var p in s.Neighbourhood.People) + { + if (p.HasBeenInfected && !p.IsAsymptomatic && p.HasNet) visibleNetted++; + } + + Assert.LessOrEqual(card.ChainsBroken, visibleNetted, + $"{card.ChainsBroken} chains scored but only {visibleNetted} findable patients were covered " + + $"({asymptomaticNetted} symptomless people also had nets)"); + } + + // ---- the journal ----------------------------------------------------------------- + + [Test] + public void TheJournalEndsOnTheQuestionTheClassAnswers() + { + // Section 5: "The Field Journal closes with one prompt... The facilitator reads these + // aloud." It has to be the last thing on the page. + var s = PlayOut(7, new NetEveryVisiblePatient()); + var export = FieldJournal.Build(s.Neighbourhood, s); + string text = export.ToPlainText(); + + StringAssert.Contains(FieldJournalExport.ClosingPrompt, text); + int promptAt = text.IndexOf(FieldJournalExport.ClosingPrompt); + Assert.Greater(promptAt, text.Length / 2, "the closing prompt should be at the end, not the middle"); + } + + [Test] + public void TheJournalRecordsHowEachCaseHappened() + { + var s = PlayOut(7); + var export = FieldJournal.Build(s.Neighbourhood, s); + + Assert.AreEqual(s.Neighbourhood.Log.Events.Count, export.Chains.Count, + "the journal has to account for every case the squad could be asked about"); + Assert.AreEqual(s.Journal.Count, export.Turns.Count); + } + + [Test] + public void TheJournalNamesTheHousesNobodyReached() + { + var s = PlayOut(1); + var export = FieldJournal.Build(s.Neighbourhood, s); + Assert.Greater(export.Unvisited.Count, 0, + "a squad that visited nothing should be told which houses it never reached"); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs.meta new file mode 100644 index 00000000..7c567866 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb7941e92a064987a144c41678d37e7e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs new file mode 100644 index 00000000..1fd70b9a --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using Aedes.Module3.Sim; +using NUnit.Framework; +using UnityEngine; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// Every localization key the module emits must exist in the CSV. + /// + /// The failure mode this catches is quiet and ugly: a typo in a key means a raw + /// "m3.say.hotForDays" appears on a headset in front of a class, and nothing in play mode + /// says so. One such typo has already been caught this way. + /// + /// Keys are gathered by actually playing a session rather than from a hand-maintained list, + /// so a key added in code is covered without anyone remembering to register it. + /// + [TestFixture] + public class LocalizationCoverageTests + { + private const string CsvResource = "Localization/LocalizationData"; + + private static HashSet KeysInCsv() + { + var csv = Resources.Load(CsvResource); + Assert.IsNotNull(csv, $"no localization CSV at Resources/{CsvResource}"); + + var keys = new HashSet(); + var rows = Csv.Parse(csv.text); + for (int r = 1; r < rows.Count; r++) + { + if (rows[r].Count == 0) continue; + string key = rows[r][0].Trim(); + if (key.Length == 0 || key.StartsWith("#")) continue; + keys.Add(key); + } + return keys; + } + + /// Plays several sessions and collects every key the module would have shown. + private static HashSet KeysTheModuleEmits() + { + var used = new HashSet(); + + void Add(string k) { if (!string.IsNullOrEmpty(k)) used.Add(k); } + + for (int seed = 1; seed <= 12; seed++) + { + var n = ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default); + var session = new Session(n); + + void Sweep() + { + for (int h = 0; h < n.Households.Count; h++) + { + var ho = Observe.Household(n, h); + for (int i = 0; i < ho.Residents.Count; i++) + { + var po = ho.Residents[i]; + Add(po.DialogueKey); + for (int s = 0; s < po.Signs.Count; s++) Add(po.Signs[s].Key); + + foreach (var a in Observe.ActionsFor(n, po.PersonId)) + { + Add(a.LabelKey); + Add(a.DisabledReasonKey); + } + } + + foreach (var a in Observe.ActionsFor(n, h, household: true)) + { + Add(a.LabelKey); + Add(a.DisabledReasonKey); + } + } + } + + Sweep(); + foreach (var note in session.OpeningBrief().NeedReferralNow) Add(note.MessageKey); + + while (!session.Finished) + { + // Exercise every action so their result messages are collected too. + for (int h = 0; h < n.Households.Count; h++) + { + Add(session.Do(PlayerAction.OnHousehold(ActionKind.Visit, h)).MessageKey); + Add(session.Do(PlayerAction.OnHousehold(ActionKind.SetFan, h)).MessageKey); + Add(session.Do(PlayerAction.OnHousehold(ActionKind.RepairScreen, h)).MessageKey); + Add(session.Do(PlayerAction.OnHousehold(ActionKind.AdviseClosingHours, h)).MessageKey); + + var house = n.HouseholdById(h); + for (int i = 0; i < house.ResidentIds.Count; i++) + { + int pid = house.ResidentIds[i]; + Add(session.Do(PlayerAction.OnPerson(ActionKind.PutUpNet, pid)).MessageKey); + Add(session.Do(PlayerAction.OnPerson(ActionKind.ReclaimNet, pid)).MessageKey); + Add(session.Do(PlayerAction.OnPerson(ActionKind.BringWater, pid)).MessageKey); + Add(session.Do(PlayerAction.OnPerson(ActionKind.HelpRest, pid)).MessageKey); + Add(session.Do(PlayerAction.OnPerson(ActionKind.GiveRepellent, pid)).MessageKey); + Add(session.Do(PlayerAction.OnPerson(ActionKind.ReferToHealthCentre, pid)).MessageKey); + } + } + + Sweep(); + + var brief = session.EndTurn(); + if (brief == null) continue; + foreach (var note in brief.NeedReferralNow) Add(note.MessageKey); + foreach (var note in brief.UncoveredPatients) Add(note.MessageKey); + foreach (var note in brief.LookingBetterButWatch) Add(note.MessageKey); + + // The trace-back replay shown as each case appears. + foreach (var replay in TraceBack.ForBrief(n, brief)) + { + Add(replay.SummaryKey); + foreach (var step in replay.Steps) Add(step.MessageKey); + } + } + + // The debrief: the map headline and the scorecard. + foreach (var line in Scoring.Evaluate(n, session).Lines) Add(line.MessageKey); + Add(new CounterfactualComparison + { + AsPlayed = OutbreakMap.Build(n), + HadPatientZeroBeenCovered = OutbreakMap.Build(n), + }.HeadlineKey); + Add("m3.replay.thisNeedNotHaveHappened"); + Add(InvisibleCaseDebrief.PromptKeyFor(OutbreakMap.Build(n))); + Add("m3.debrief.chainFromNowhere"); + Add("m3.debrief.peopleWhoNeverKnew"); + Add("m3.debrief.noInvisibleEvidence"); + } + + return used; + } + + [Test] + public void EveryKeyTheModuleEmitsExistsInTheLocalizationCsv() + { + var inCsv = KeysInCsv(); + var emitted = KeysTheModuleEmits(); + + var missing = new List(); + foreach (var key in emitted) if (!inCsv.Contains(key)) missing.Add(key); + missing.Sort(); + + Assert.IsEmpty(missing, + "these keys would show as raw text on a headset:\n " + string.Join("\n ", missing)); + } + + [Test] + public void TheModuleEmitsAMeaningfulNumberOfKeys() + { + // Guards the test above from passing because the sweep silently collected nothing. + Assert.Greater(KeysTheModuleEmits().Count, 30); + } + + [Test] + public void NoKeyLooksLikeADiagnosisOrATreatment() + { + // Sections 4 and 12. The wording is the part a student actually reads, so the rule + // has to hold in the strings and not only in the code. + string[] forbidden = { "diagnos", "dengue", "prescri", "medicine", "tablet", "dose", "treat" }; + + foreach (var key in KeysTheModuleEmits()) + { + string lower = key.ToLowerInvariant(); + foreach (var word in forbidden) + { + Assert.IsFalse(lower.Contains(word), + $"'{key}' names something the volunteer never does"); + } + } + } + + [Test] + public void KeysAreNotDuplicatedInTheCsv() + { + var csv = Resources.Load(CsvResource); + Assert.IsNotNull(csv); + + var seen = new HashSet(); + var duplicates = new List(); + var rows = Csv.Parse(csv.text); + + for (int r = 1; r < rows.Count; r++) + { + if (rows[r].Count == 0) continue; + string key = rows[r][0].Trim(); + if (key.Length == 0 || key.StartsWith("#")) continue; + if (!seen.Add(key)) duplicates.Add(key); + } + + Assert.IsEmpty(duplicates, "duplicate keys, where the later row silently wins: " + + string.Join(", ", duplicates)); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs.meta new file mode 100644 index 00000000..a0ebde3e --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7540b537c0634928959da013a3353779 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs new file mode 100644 index 00000000..78931f61 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs @@ -0,0 +1,207 @@ +using System.Collections.Generic; +using Aedes.Module3.Sim; +using NUnit.Framework; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// What the player can see, and what they are offered to do about it. + /// + /// Sections 4, 5 and 12 impose rules that are easy to break by accident and hard to catch in + /// review: the player never diagnoses or treats, they cannot see who is infected, and nothing + /// marks a sick person as a hazard. These are tests rather than a style guide. + /// + [TestFixture] + public class ObservationTests + { + private static Neighbourhood Build(int seed = 1) => + ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default); + + [Test] + public void TheOfferedVerbsContainNoClinicalAction() + { + // Section 12: no medicines, no naming the illness, no procedures. Section 4: "never + // 'diagnose' or 'treat'." The verb list is a closed set for exactly this reason. + foreach (var verb in Observe.OfferedVerbs) + { + string name = verb.ToString().ToLowerInvariant(); + Assert.IsFalse(name.Contains("diagnos"), $"{verb} is a diagnostic action"); + Assert.IsFalse(name.Contains("treat"), $"{verb} is a treatment action"); + Assert.IsFalse(name.Contains("medicine") || name.Contains("drug"), $"{verb} dispenses medicine"); + Assert.IsFalse(name.Contains("test") || name.Contains("blood"), $"{verb} is a clinical procedure"); + } + } + + [Test] + public void ClearingContainersIsNotAVerb() + { + // Section 5: "Clearing containers is therefore not an action in this module at all - + // not a button, not a task, not a score." + foreach (var verb in Observe.OfferedVerbs) + { + string name = verb.ToString().ToLowerInvariant(); + Assert.IsFalse(name.Contains("container") || name.Contains("clear") || name.Contains("empty"), + $"{verb} lets the player act on a container; that was Module 2's work"); + } + } + + [Test] + public void EveryOfferedVerbIsOneTheModelAccepts() + { + var n = Build(); + var offered = new List(); + offered.AddRange(Observe.ActionsFor(n, n.People[0].Id)); + offered.AddRange(Observe.ActionsFor(n, 0, household: true)); + + foreach (var a in offered) + { + CollectionAssert.Contains(Observe.OfferedVerbs, a.Kind, + $"{a.Kind} is offered to the player but is not in the module's verb list"); + } + } + + [Test] + public void SomeoneIncubatingLooksExactlyLikeSomeoneWell() + { + // Section 5: the squad cannot act on cases it cannot see. Nothing in the observation + // may leak the model's hidden state. + var n = Build(); + var exposed = n.People.Find(p => p.State == HealthState.Exposed); + if (exposed == null) Assert.Ignore("this seed seeded no incubating person"); + + var obs = Observe.Person(n, exposed.Id); + Assert.AreEqual(VisibleCondition.Seems_Well, obs.Condition, + "an incubating person was distinguishable from a well one"); + Assert.IsEmpty(obs.Signs, "an incubating person showed something in the room"); + } + + [Test] + public void SomeoneWhoNeverFeelsIllIsNeverVisible() + { + // Section 5: "You cannot net a person who does not know they are infected." + for (int seed = 1; seed <= 40; seed++) + { + var n = Build(seed); + n.AdvanceDays(20); + + for (int i = 0; i < n.People.Count; i++) + { + var p = n.People[i]; + if (!p.IsAsymptomatic || !p.HasBeenInfected) continue; + if (p.State == HealthState.Hospitalised) continue; + + var obs = Observe.Person(n, p.Id); + Assert.AreEqual(VisibleCondition.Seems_Well, obs.Condition, + $"seed {seed}: a symptomless case was visible to the player as {obs.Condition}"); + } + } + } + + [Test] + public void AWarningSignReadsAsNeedingADoctorAndNotAsADiagnosis() + { + var n = Build(3); + Person found = null; + for (int d = 0; d < 40 && found == null; d++) + { + n.AdvanceDays(1); + found = n.People.Find(p => p.HasVisibleWarningSign(n.Day) && p.State != HealthState.Hospitalised); + } + Assert.IsNotNull(found, "no warning sign appeared in forty days"); + + var obs = Observe.Person(n, found.Id); + Assert.AreEqual(VisibleCondition.NeedsADoctorNow, obs.Condition); + Assert.IsNotNull(obs.DialogueKey); + StringAssert.StartsWith("m3.say.", obs.DialogueKey); + Assert.Greater(obs.Signs.Count, 0, "a warning sign should be visible in the room, not only spoken"); + } + + [Test] + public void ThePersonWhoseFeverHasBrokenSaysTheyAreBetter() + { + // Section 8: the trap. The household reports improvement; the danger window is open. + var n = Build(3); + Person found = null; + for (int d = 0; d < 40 && found == null; d++) + { + n.AdvanceDays(1); + found = n.People.Find(p => p.IsDefervescing(n.Day) && p.State != HealthState.Hospitalised + && !p.HasVisibleWarningSign(n.Day)); + } + Assert.IsNotNull(found, "nobody's fever broke in forty days"); + + var obs = Observe.Person(n, found.Id); + Assert.AreEqual(VisibleCondition.Improving, obs.Condition); + Assert.AreEqual("m3.say.feelingBetterToday", obs.DialogueKey); + } + + [Test] + public void TheHouseholdReportsDaysUnwellInPlainNumbers() + { + // Section 7: "she has been hot for three days" - what the family noticed, not what + // the model knows. It must count from symptoms, never from infection. + var n = Build(1); + n.AdvanceDays(3); + + for (int i = 0; i < n.People.Count; i++) + { + var p = n.People[i]; + if (!p.IsFebrile(n.Day)) continue; + + var obs = Observe.Person(n, p.Id); + Assert.AreEqual(n.Day - p.SymptomOnsetDay, obs.DaysUnwell); + Assert.GreaterOrEqual(obs.DaysUnwell, 0, "a household reported a negative number of days"); + Assert.LessOrEqual(obs.DaysUnwell, n.Day - p.ExposedDay, + "the household knew about the illness before the symptoms started"); + } + } + + [Test] + public void TheReferralActionIsAlwaysOfferedAndNeverPreJudged() + { + // Section 4: "This is not a judgement call and the player should not weigh it up." + // Hiding the button until it is correct would do the discriminating for the student. + var n = Build(); + for (int i = 0; i < 12; i++) + { + var actions = Observe.ActionsFor(n, n.People[i].Id); + var referral = actions.Find(a => a.Kind == ActionKind.ReferToHealthCentre); + Assert.AreNotEqual(default(AvailableAction), referral, + $"person {i} was not offered the referral action"); + Assert.IsTrue(referral.Enabled, "the referral action was greyed out for someone who has not been referred"); + } + } + + [Test] + public void AHouseholdObservationShowsTheYardWithoutOfferingToClearIt() + { + var n = Build(); + var obs = Observe.Household(n, 0); + + Assert.Greater(obs.VisibleContainers, 0, "the yard should show its containers - section 5 wants them seen"); + + var actions = Observe.ActionsFor(n, 0, household: true); + foreach (var a in actions) + { + Assert.AreNotEqual(ActionKind.Visit, a.Kind); + CollectionAssert.Contains(Observe.OfferedVerbs, a.Kind); + } + } + + [Test] + public void ANettedPatientShowsTheNetAsSomethingDoneForThem() + { + // Section 5 on framing: protective, never accusatory. What the room shows is care + // that has been given, not a containment marker. + var n = Build(1); + var patient = n.People.Find(p => p.IsFebrile(n.Day)); + Assert.IsNotNull(patient); + + n.Apply(PlayerAction.OnPerson(ActionKind.PutUpNet, patient.Id)); + var obs = Observe.Person(n, patient.Id); + + Assert.IsTrue(obs.HasNet); + Assert.IsTrue(obs.Signs.Exists(s => s.Key == "m3.see.netIsUp")); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs.meta new file mode 100644 index 00000000..836afa1c --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8240b456ecd64fbdb5b0aca615677153 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Localization/LocalizationData.csv b/Assets/Resources/Localization/LocalizationData.csv index dfce0865..8aea8c77 100644 --- a/Assets/Resources/Localization/LocalizationData.csv +++ b/Assets/Resources/Localization/LocalizationData.csv @@ -3,3 +3,85 @@ mainMenu.start,Start,ເລິ່ມ,Bắt đầu,Commencer mainMenu.end,End,ຈົບ,Kết thúc,Fin mainMenu.options,Options,ຕົວເລືອກ,Tùy chọn,Options human.Data.bodytemperature,BodyTemp,ອຸນຫະພູມ,, +# Module 3 dialogue. Lao is left blank where the wording is being agreed with NUOL,,,, +# (design document section 13 item 5); the game falls back to English until it lands.,,,, +module3.title,Vientiane Health Leader,,, +"# --- what the household says (section 7: plain language, never clinical terms) ---",,,, +m3.say.everyoneFineHere,"Everyone is well here, thank you.",,, +m3.say.hotSinceYesterday,She has been hot since yesterday.,,, +m3.say.hotForDays,She has been hot for {0} days.,,, +m3.say.feelingBetterToday,He says he feels better today.,,, +m3.say.bleeding,Her gums have been bleeding since this morning.,,, +m3.say.cannotKeepWaterDown,He has not kept water down since last night.,,, +m3.say.atHealthCentre,They took her to the health centre.,,, +# --- what the player can see in the room (section 7 step 3) ---,,,, +m3.see.restingOnTheBed,Resting on the bed,,, +m3.see.bucketByTheBed,A bucket beside the bed,,, +m3.see.bloodOnCloth,Blood on a cloth,,, +m3.see.netIsUp,The net is up over the bed,,, +m3.see.noWaterWithinReach,No water within reach,,, +"# --- what the volunteer can do (section 4: never diagnose, never treat) ---",,,, +m3.do.putUpTheNet,Put up the net,,, +m3.do.takeTheNetOn,Take the net on to someone else,,, +m3.do.bringWater,Bring water,,, +m3.do.helpThemRest,Help them rest,,, +m3.do.repellentOnSkin,Repellent on the skin,,, +m3.do.getThemToTheHealthCentre,Get them to the health centre,,, +m3.do.mendTheScreen,Mend the window screen,,, +m3.do.fitAScreen,Fit a window screen,,, +m3.do.setTheFanGoing,Set the fan going,,, +m3.do.shutUpAtDawnAndDusk,Shut up at dawn and dusk,,, +# --- quiet notes when something is already done ---,,,, +m3.why.netAlreadyUp,The net is already up.,,, +m3.why.noNetsLeft,There are no nets left. Choose who needs one most.,,, +m3.why.hasWater,She has water beside her.,,, +m3.why.alreadyResting,He is resting already.,,, +m3.why.repellentOn,Repellent is on.,,, +m3.why.alreadyGoing,They are already going to the health centre.,,, +m3.why.screensAreFine,The screens are in good repair.,,, +m3.why.fanAlreadyOn,The fan is already going.,,, +m3.why.alreadyClosing,They already shut up at dawn and dusk.,,, +"# --- what the game says back (section 10: corrected, never punished) ---",,,, +m3.action.visited,You look in on the household.,,, +m3.action.netUp,The net is up. Mosquitoes cannot reach her while she rests.,,, +m3.action.netAlready,The net is already up.,,, +m3.action.netReclaimed,You take the net down to use somewhere else.,,, +m3.action.noNetsLeft,No nets left. Think about who needs one most.,,, +m3.action.screenFixed,The screen is mended. Mosquitoes stay out of the room.,,, +m3.action.screenAlready,The screens are already sound.,,, +m3.action.fanOn,The fan is going. Mosquitoes are weak fliers.,,, +m3.action.closingHours,They will shut the windows after dawn and before dusk.,,, +m3.action.repellent,"Repellent on the skin, for a few hours.",,, +m3.action.water,"Plenty to drink, within reach.",,, +m3.action.rest,"Settled, and someone will check on her.",,, +m3.action.referralCorrect,Right call. They are on their way to the health centre now.,,, +m3.action.referralNotNeeded,"No need this time. With a fever and no bleeding or vomiting, she is better cared for at home.",,, +m3.action.invalid,Nothing to do here.,,, +# --- the Coach's handover brief (section 8) ---,,,, +m3.brief.bleeding,{0} is bleeding. She needs a doctor now.,,, +m3.brief.vomiting,{0} cannot keep water down. He needs a doctor now.,,, +m3.brief.uncovered,{0} is unwell and has no net.,,, +m3.brief.lookingBetter,{0}'s fever has broken. Keep watching - this is when it can turn.,,, +# --- trace-back replay (section 5) ---,,,, +m3.trace.arrivedIll,They were already ill when you arrived.,,, +m3.trace.fromThisContainer,A mosquito came out of this water.,,, +m3.trace.fedOnUncoveredPatient,"It fed on someone who was unwell, with no net up.",,, +m3.trace.fedOnCoveredPatient,"It fed on someone who was unwell, even with a net up.",,, +m3.trace.fedOnSomeoneWhoSeemedWell,It fed on someone who seemed perfectly well.,,, +m3.trace.bitSomeoneInTheSameHouse,Days later it bit someone in the same house.,,, +m3.trace.bitSomeoneNextDoor,Days later it bit someone a few doors along.,,, +m3.trace.summaryPreventable,A net over that patient would have stopped this.,,, +m3.trace.summaryInvisible,Nobody could have known this person was carrying it.,,, +m3.trace.summaryCovered,This one got through even with the net up.,,, +# --- the replay before the score (section 5) ---,,,, +m3.replay.thisNeedNotHaveHappened,Here is the outbreak that need not have happened.,,, +m3.replay.youAlreadyDidThis,You covered the first patient on day one. This is your neighbourhood.,,, +"# --- scoring (section 10: consequences shown, never marks taken away) ---",,,, +m3.score.chainBroken,You kept the virus from leaving a house.,,, +m3.score.referredCorrectly,You got someone to a doctor when they needed one.,,, +m3.score.goodHomeCare,"Rest, water, and you came back to check.",,, +m3.score.missedWarningSign,Someone went to hospital on their own. Worth talking about.,,, +m3.score.unnecessaryReferral,A fever with no warning signs is better cared for at home.,,, +m3.debrief.chainFromNowhere,One of these chains starts in a house where nobody was ever ill. What could have stopped it?,,, +m3.debrief.peopleWhoNeverKnew,{0} people here caught it and never felt a thing. You could not have found them. What would have protected them?,,, +m3.debrief.noInvisibleEvidence,"Nobody here carried it without knowing. That is luck, not the usual.",,, diff --git a/Assets/Resources/Module3/Module3Parameters.csv b/Assets/Resources/Module3/Module3Parameters.csv index 34fbabf8..f0a497a0 100644 --- a/Assets/Resources/Module3/Module3Parameters.csv +++ b/Assets/Resources/Module3/Module3Parameters.csv @@ -45,6 +45,9 @@ turn_seconds,180,seconds,session,next_day,"Section 8: two to four minutes, fixed turns_per_round,3,count,session,next_session,One per squad member. rounds,6,count,session,next_session,Five or six makes a session. days_per_handover,3,days,session,next_day,How far the calendar jumps when the headset changes hands. +points_chain_broken,1000,points,session,next_day,Section 10: a broken chain outscores every other action in the module combined. A calibration test checks this holds in played sessions. +points_referred_correctly,60,points,session,next_day,Acting on bleeding or persistent vomiting - the strongest of the per-person actions. +points_good_home_care,10,points,session,next_day,"Rest, fluids and a return visit." households_per_lane,5,count,scenario,next_session,Section 11: eight to ten plots along two or three lanes. lanes,2,count,scenario,next_session,Number of lanes. module2_container_clearance,0.35,fraction 0-1,scenario,next_session,What the squad cleared in Module 2. Open question 3b - this is how that state carries over. diff --git a/Assets/Scripts/Scripts by Simple/Localization/LocalizationManager.cs b/Assets/Scripts/Scripts by Simple/Localization/LocalizationManager.cs index 358f6e48..832ac094 100644 --- a/Assets/Scripts/Scripts by Simple/Localization/LocalizationManager.cs +++ b/Assets/Scripts/Scripts by Simple/Localization/LocalizationManager.cs @@ -223,10 +223,33 @@ public IEnumerable GetLanguages() public bool HasKey(string key) { return localizedData != null - && localizedData.ContainsKey(currentLanguage) - && localizedData[currentLanguage].ContainsKey(key); + && localizedData.TryGetValue(currentLanguage, out var table) + && table.TryGetValue(key, out string value) + && value.Length > 0; } + /// Keys present in English but not yet translated into the current language. + public List UntranslatedKeys() + { + var missing = new List(); + if (localizedData == null || !localizedData.TryGetValue(FallbackLanguage, out var english)) return missing; + + localizedData.TryGetValue(currentLanguage, out var table); + foreach (var pair in english) + { + if (table == null || !table.TryGetValue(pair.Key, out string v) || v.Length == 0) missing.Add(pair.Key); + } + return missing; + } + + /// + /// The language used when the current one has no value for a key. Translation lands + /// incrementally - Module 3's dialogue is written in English first and the Lao wording for + /// the warning signs is being agreed with the NUOL team - so an untranslated row should show + /// the English sentence rather than a raw key on a headset in a classroom. + /// + private const string FallbackLanguage = "English"; + public string GetLocalizedValue(string key) { if (HasKey(key)) @@ -234,6 +257,15 @@ public string GetLocalizedValue(string key) return localizedData[currentLanguage][key]; } + if (currentLanguage != FallbackLanguage + && localizedData != null + && localizedData.TryGetValue(FallbackLanguage, out var fallback) + && fallback.TryGetValue(key, out string english) + && english.Length > 0) + { + return english; + } + Debug.LogWarning($"Localization key '{key}' not found for language '{currentLanguage}'."); return key; } From 655af2230515cedeeb94a1c3e24c5a3f9cc39628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 22:20:58 +0700 Subject: [PATCH 12/61] =?UTF-8?q?Module=203:=20greybox=20placeholder=20mod?= =?UTF-8?q?els=20for=20the=20=C2=A74=20asset=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the "Unblocking pattern" from the Module 3 roadmap §4: a greybox placeholder at the final path and name for every genuinely-missing asset, so code can bind to prefab paths and Socket_* transforms now and art can swap the mesh inside the prefab later with no scene churn. Nine meshes, generated procedurally and committed with their FBXs: electric fan (animatable blade), intact and torn window screens, rolled-up mosquito net, repellent bottle, drinking vessel, cloth, torch, and a provisional health centre massing. Each maps to a Phase 1 player action. Dimensions are measured off existing repo art rather than invented: the window leaves give a 1.50 x 1.00 m opening with a 0.1202 m reveal, and the rolled-up net shares an origin and canopy height with SM_MosquitoNet so the two net states swap at one transform. Only the rolled-up state was modelled -- the deployed one reuses the shipped mesh, and PF_MosquitoNet is untouched. Tooling: - Tools/Module3/make_placeholders.py parametric meshes, enforces tri budgets - Tools/Module3/measure_reference.py measures existing art - Tools/Module3/make_unity_assets.py stable GUIDs + placeholder material - Assets/Editor/Module3PlaceholderPrefabs.cs AEDES > Module 3 > Build / Verify Placeholder Prefabs. Idempotent, so prefab GUIDs and scene references survive a re-run. Verify checks tri budget, measured size and socket survival. Verified by importing into Unity 6000.3.8f1 and inspecting the built prefabs: all sizes, identity root transforms, socket orientations and the fan's blade pivot. Documentation/Module3-Placeholder-Assets.md records the numbers and the Blender->Unity axis/unit traps involved, which a Blender round-trip cannot catch. --- .../Materials/M_Module3Placeholder.mat | 110 ++++ .../Materials/M_Module3Placeholder.mat.meta | 8 + .../Team Assets/Models/SM_Cloth.meta | 8 + .../Team Assets/Models/SM_Cloth/SM_Cloth.fbx | Bin 0 -> 16844 bytes .../Models/SM_Cloth/SM_Cloth.fbx.meta | 110 ++++ .../Team Assets/Models/SM_DrinkingVessel.meta | 8 + .../SM_DrinkingVessel/SM_DrinkingVessel.fbx | Bin 0 -> 18092 bytes .../SM_DrinkingVessel.fbx.meta | 110 ++++ .../Team Assets/Models/SM_ElectricFan.meta | 8 + .../Models/SM_ElectricFan/SM_ElectricFan.fbx | Bin 0 -> 32620 bytes .../SM_ElectricFan/SM_ElectricFan.fbx.meta | 110 ++++ .../Team Assets/Models/SM_HealthCentre.meta | 8 + .../SM_HealthCentre/SM_HealthCentre.fbx | Bin 0 -> 17532 bytes .../SM_HealthCentre/SM_HealthCentre.fbx.meta | 110 ++++ .../Models/SM_MosquitoNet_RolledUp.meta | 8 + .../SM_MosquitoNet_RolledUp.fbx | Bin 0 -> 20860 bytes .../SM_MosquitoNet_RolledUp.fbx.meta | 110 ++++ .../Models/SM_RepellentBottle.meta | 8 + .../SM_RepellentBottle/SM_RepellentBottle.fbx | Bin 0 -> 19468 bytes .../SM_RepellentBottle.fbx.meta | 110 ++++ .../Team Assets/Models/SM_Torch.meta | 8 + .../Team Assets/Models/SM_Torch/SM_Torch.fbx | Bin 0 -> 20236 bytes .../Models/SM_Torch/SM_Torch.fbx.meta | 110 ++++ .../Team Assets/Models/SM_WindowScreen.meta | 8 + .../SM_WindowScreen/SM_WindowScreen.fbx | Bin 0 -> 17132 bytes .../SM_WindowScreen/SM_WindowScreen.fbx.meta | 110 ++++ .../Models/SM_WindowScreenTorn.meta | 8 + .../SM_WindowScreenTorn.fbx | Bin 0 -> 17836 bytes .../SM_WindowScreenTorn.fbx.meta | 110 ++++ .../Prefabs/Buildings/PF_HealthCentre.prefab | 124 +++++ .../Buildings/PF_HealthCentre.prefab.meta | 7 + .../Prefabs/Buildings/PF_WindowScreen.prefab | 124 +++++ .../Buildings/PF_WindowScreen.prefab.meta | 7 + .../Buildings/PF_WindowScreenTorn.prefab | 124 +++++ .../Buildings/PF_WindowScreenTorn.prefab.meta | 7 + .../Prefabs/Furniture/PF_ElectricFan.prefab | 306 +++++++++++ .../Furniture/PF_ElectricFan.prefab.meta | 7 + .../Team Assets/Prefabs/Props/PF_Cloth.prefab | 124 +++++ .../Prefabs/Props/PF_Cloth.prefab.meta | 7 + .../Prefabs/Props/PF_DrinkingVessel.prefab | 124 +++++ .../Props/PF_DrinkingVessel.prefab.meta | 7 + .../Props/PF_MosquitoNet_Deployed.prefab | 93 ++++ .../Props/PF_MosquitoNet_Deployed.prefab.meta | 7 + .../Props/PF_MosquitoNet_RolledUp.prefab | 156 ++++++ .../Props/PF_MosquitoNet_RolledUp.prefab.meta | 7 + .../Prefabs/Props/PF_RepellentBottle.prefab | 124 +++++ .../Props/PF_RepellentBottle.prefab.meta | 7 + .../Team Assets/Prefabs/Props/PF_Torch.prefab | 156 ++++++ .../Prefabs/Props/PF_Torch.prefab.meta | 7 + Assets/Editor/Module3PlaceholderPrefabs.cs | 352 ++++++++++++ .../Editor/Module3PlaceholderPrefabs.cs.meta | 11 + Documentation/Module3-Placeholder-Assets.md | 216 ++++++++ Tools/Module3/make_placeholders.py | 504 ++++++++++++++++++ Tools/Module3/make_unity_assets.py | 266 +++++++++ Tools/Module3/measure_reference.py | 77 +++ 55 files changed, 4131 insertions(+) create mode 100644 Assets/1.TeamWorkspace/Team Assets/Materials/M_Module3Placeholder.mat create mode 100644 Assets/1.TeamWorkspace/Team Assets/Materials/M_Module3Placeholder.mat.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_Cloth.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_Cloth/SM_Cloth.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_Cloth/SM_Cloth.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_DrinkingVessel.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_DrinkingVessel/SM_DrinkingVessel.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_DrinkingVessel/SM_DrinkingVessel.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan/SM_ElectricFan.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan/SM_ElectricFan.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre/SM_HealthCentre.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre/SM_HealthCentre.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp/SM_MosquitoNet_RolledUp.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp/SM_MosquitoNet_RolledUp.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle/SM_RepellentBottle.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle/SM_RepellentBottle.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch/SM_Torch.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch/SM_Torch.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen/SM_WindowScreen.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen/SM_WindowScreen.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn/SM_WindowScreenTorn.fbx create mode 100644 Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn/SM_WindowScreenTorn.fbx.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_HealthCentre.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_HealthCentre.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreen.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreen.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreenTorn.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreenTorn.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Furniture/PF_ElectricFan.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Furniture/PF_ElectricFan.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Cloth.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Cloth.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_DrinkingVessel.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_DrinkingVessel.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_RolledUp.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_RolledUp.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_RepellentBottle.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_RepellentBottle.prefab.meta create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Torch.prefab create mode 100644 Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Torch.prefab.meta create mode 100644 Assets/Editor/Module3PlaceholderPrefabs.cs create mode 100644 Assets/Editor/Module3PlaceholderPrefabs.cs.meta create mode 100644 Documentation/Module3-Placeholder-Assets.md create mode 100644 Tools/Module3/make_placeholders.py create mode 100644 Tools/Module3/make_unity_assets.py create mode 100644 Tools/Module3/measure_reference.py diff --git a/Assets/1.TeamWorkspace/Team Assets/Materials/M_Module3Placeholder.mat b/Assets/1.TeamWorkspace/Team Assets/Materials/M_Module3Placeholder.mat new file mode 100644 index 00000000..58033514 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Materials/M_Module3Placeholder.mat @@ -0,0 +1,110 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: M_Module3Placeholder + m_Shader: {fileID: -6465566751694194690, guid: 850a53dfcf2e85a4d8365db9ad81bf96, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: {} + disabledShaderPasses: + - MOTIONVECTORS + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _Texture_01: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _Additionnal_Color: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _Dissolve: 0 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _EnvironmentReflections: 1 + - _Fade: 0 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Highlight: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueControl: 0 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _ShadowLimit: -0.14 + - _Smoothness: 0.5 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _Texture: 0 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.62, g: 0.62, b: 0.6, a: 1} + - _Color: {r: 0.62, g: 0.62, b: 0.6, a: 1} + - _Color1: {r: 0, g: 0, b: 0, a: 0} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _Highlight_Color: {r: 1, g: 1, b: 1, a: 1} + - _Multiply_Color: {r: 1, g: 1, b: 1, a: 1} + - _Shadow_Color: {r: 0.75, g: 0.75, b: 0.78, a: 0} + - _SpecColor: {r: 0.2, g: 0.2, b: 0.2, a: 1} + - _UV_Tiling: {r: 1, g: 1, b: 0, a: 0} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &6168096758479283809 +MonoBehaviour: + m_ObjectHideFlags: 11 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 diff --git a/Assets/1.TeamWorkspace/Team Assets/Materials/M_Module3Placeholder.mat.meta b/Assets/1.TeamWorkspace/Team Assets/Materials/M_Module3Placeholder.mat.meta new file mode 100644 index 00000000..c26f8cb0 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Materials/M_Module3Placeholder.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c989336e24c6a8a08592ba0d9eb1cbd6 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_Cloth.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_Cloth.meta new file mode 100644 index 00000000..bd89845f --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_Cloth.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4f28d2e1cf9e51cdee8b8f67a9272f14 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_Cloth/SM_Cloth.fbx b/Assets/1.TeamWorkspace/Team Assets/Models/SM_Cloth/SM_Cloth.fbx new file mode 100644 index 0000000000000000000000000000000000000000..c952d954068c3152ca3b09404bc82ddfd2fba541 GIT binary patch literal 16844 zcmcg!33wD$wyr=zRzo0zqM~V#MbwamB`gZrLrX$JIth!;ODdg8Qqo=3R(FR)k!2jk zQBcNtK5^6$+~>`xym>Cj13}OcHI6#ZCpw~RinszUh)Q7oQ+2DmI^9*>B=5cZQB?K4 z_x$(VbIv{Y+*1W}_^uXS;cex`3v9)r#4BAk8+RUecm&5in8*dnP_)k$_!dDa>+}hd zSCplz(HMs-0ar$#xj|5<(f-9ij_Wk^NI?(1jlkC#+S1D;r_$x|fv&F0BiPZztt4nx zhLk7*ueOSA!QtcGo;T5=kDVBR#gLyE8sZlUykh?xvp8v}O{kJ3U%NdSRYL%wqYNzN zyX@zpa-5+uezxpa>}R8PmZ5e~l_>drf_*UR=Ns!Cf>V}S?88v+Gt{S6i7uDu)f%^g z$&m6_)-w;aiRwgU#c>&a6uSfo^0r;<_3_OvLDi7Gdb};$HhDt+gnV0NokM-jn>-=+ zE=+O&XvX16b~pt|uuHA7gE*;h$<4fresXcamL#;)dj+MG_wge^2`0F+aK)1X9LCn^ ztd-Op5OoWFq!?zBR3-R$!fH3`z1+}{P$&9af&&=bIzx4$!{2<3;Pj~t8x0MEiu}HI zNQDSqGt?)|5xSPhN{d%*`PkS}D|CothiXfYp(U|IcDn^h8-xG@bv;uiJZjq4Z@>sc%anCW}_OkUO z47^kWKi>qLIRF!UTIqomb0wref~e9EX@LaTh5ZV3@Gy-PRduQy^@^Yz9mH&IUJm}` zO)JQ?<>pRT|4hiu&HYyf&Gb9O2wg6^1Pc+6Nf|8V6(C{e+!$PtDNUe~_xc=shtOi8 z;8BA@=0%bUkn~f-D;RW*#tr$%h|m`EvrzGWGxRbI9Xms0^fSpJbDlCNjjGd1iBaSPawVIj@M+UO8S>n27Cx>(C=7?RvG+3FyIw71k|;Q8YJyyS~# zM|bbowrk@hf4ubMvD2&{i7+i>$0YHSxn}3);g2nk87)jVw-nBU!27lP>_`IwE9u&P z;}Cp4I7DwJ!zXO4>k!lu^fh zCVboy8QYolxEO=`HhkP5rRBJ%QSu=i5*6tj^QDbQx8Z1#CUQR`pRAG7w$O{*k^_?4 ze=&%u8UzJW^^)jwIC+;)&O3eBgGlc!vLAtu`Z(9`Zf5GH%>ZR#VKUq%&7%+(=0R2- zVC2lv8i7WZHCsa?^DJ^Vi`e8#WEU8MS#s)AZAGzzhSjR^z%h;@Jv_;HEJ8Q%NET8m zwDNwJuLL`y!kf5J(&YBcqP5`-&0JN^6= zoheAW09TUUxpLB`duSDK>Ri+yIry~203YJ0Bs7r3#^Pdfap1V%6sol-mMmBf2DGYL zo&t?Q8JXx(q$;*Z65UXTbFsxPiq4_sA3&o?!5eT%wX*D+3p=O`?Gx$OaCNd;OG>TK zDky^F6uco_mu3flrov=JtSH3OUOQ-Yxdt413;H%08koRI0#CAPnrezB8TE)kEQD?q z8^Qr-ppcS*jgKnH=@`)<2#zLSQ{sdS|?>j_Yc3nhstNOO2ZyD1CB6A+JGZ_a){T(up$-~e#S^g z21zSrd2u8^5GF^En3M*Hmb#NfjS?nTQz6K1!KZZ1VN&e1w(N}5Qn*!uxBYTbA1fik z-2>qNMd40B3{G&RxFB45EH0KiaeP<3c>{c^tV}Fb-H-M|U8K}qE#VI7sMKmaFR<7HbtxyH?78J#O$>=Oi zh!*-@bks|T31z_15NJLw$diKRd44QzMWU<}qDI9dtBakMTCeRAHJk`~GL$*EKu>O} zR`wyBm!;~~RX*lp&j=9K;vfP@I?=VC!zZ@3N`lwB zAbMEKal2vwS{Mt^5MiUp{e0p)5_qxD7y(n>*!PCdHt5l|BeJ4Mj}q(%_} zvY{xV58LiZhHCIdHL};&U)ageJ<04+T)lj~wIjL>guP~|^U@D5wBG`??=EOKlD#y^ z@!BNGj~jS3UD4H+WTwmcW=k^DwY=4W%);$_XGvzt*6oSGWgQ&v=_s{6!>%2adb5XmG6f?v4 zOYs7@mY3Run0DAPS(X?vZLXp`@&NG|KF>8#N<}%q9E)N4AG0kNu!!QNMG^xE&VPcxp z=uw(%3N%g8aTf6yW&B!-q&8P{WLQuInK=#YXqT5nIzGjr?YFG^RINMqZ*H#8hI4I) zfD}GH%J3mAgRd+69*>N?JHbWB>Ff?g!7)&OV-QGg+VNdTr};ZAdP$QsT4Nnx#5!eS z%N0RDW--FBXym4a6I520B3ouga2E&+TMFfnDxEHyakGk4M`JliT1BPwNG1H?3x+)# zt>B(wR>BVKRACha6BTvV7>(m{4VB_ILe=-E-oL{zBkfSjfk`I9(oGbl8PayWenvq2 zJEJc{8)k|lt%2Ug!b=|6d8;NBm;O?dg7gqWk+U%(`q8FpOwlFbcNHbfMBX)Ntk#ES zXn2Xz%t$Fp3D2UF){#Yb_?1?irfb?|sjVIadI`zaZN&fto`;Jmno^fYUa46^lec3; zq2hkS$j52qBTeVb4;k={M8ZT$(_?Tk zanlHLl8%9!QY19|UMt~*z19f4==ei8Hw-Q&ZjLc-$~4)K=T;qzhew#fUd%?64?3zP z+_y}|L6}HbOi8JmAf&S^2DUg(Vi+uOK=P_a>WDJ+gYayw>m4AZ-sQNU>tyOID{kIZ z#d~52k|R0GA=uUo8jA;JvESWixLUxGd?sov(wZVTgIp|M%O9Qf1mmYl1E8};sp!QU zHm5+J%te1&1_HH&>+U$?Z3fyQKGK~LI(8oCLvtiEhX#Zs97{_X;rherqZojs$d(cE zR}lt?o!{e(UxUwb;FAEqi&rdsmbsIdf`l}&XYg4%o`ETIJHD3DlV4|c>}ba;V#<43 zE`dpfPczcV8Y!7afV8aBBTEz~S*@Z;g@Z6GX^#v9qie6PT3z&sge+Oe1KrgUZdkK{ zNb;D33y9=r@LrrD#v~CH_zeF#Ix9$!sJ$Ea+9qfpD3JR;E98}S<(P-R8$bl==bH}M zxmfTuRw$x}iwo(e25j^W`zK6_c6jnK$&{ihckyl7J3(sB#7KGx*_8E4^`4Drb|9YM zxVR+!1#o44_mxP*fE8MTj;h8Im+Wij;`DZv1$xu_>zKxP1igq+(!gLHL;tFKDJpNy z(K>YFISCCu5uYox%*HPw+lH$%&e_zoAlG~7)t5R|$15G^(x)XonoS60Kg&!_mJ9YDq>F@q<$B~NsPgjK( z^QXSpo@u+j>ClJ;S8qJWl~s4LaNq5I+sw^VyGN{@I(zwmx*G=GdELfM*}Jb@yDaV7 z4F`rFDtS4td)2KEtsBgBr#^dDL9RBHJ291^HL|O#O_pe$LZ=$*i~3Y=Qm2}|dSS|r znvD1d7EhY+o2#~*KDIi3-jKI#IraHjRZHIZuJX$9Yu<|QQT}#tSHh{4pKj{9`MS>Y z-ar57-scWp{p=SxKQwl(%y~Oy>%M&DroH>KrH|H++j9GtZ_N0qYjavGB@vdW!Fc09+Y2s-nF~&>#I7sTkf*U>lwrI!B9e3OPk;oN8^#y zaiz-RSv%*X{HliIw%@(s&XMk(rrIHH$Gmw%jx4{lt*JO^fAh!x^WIDE+neH2YJbrNZ1IuSzpI9-j?VR}qO9oeWyW;1+iG`rs#xOOmD|4y)h{KH!Uf^@u z>==*R$M_)=3VfE*iXX=f4e{#7HPn3^Lrur?+1PtMgo97yu@#dKQS-U9Y>K;>GUhXa zR8$TRd@|FUgxprCsK62JVEyY+lN?}Gq~$_|^!a~MeAu>iaSb(!OA9kpS*5}2>wN;U z62Q(VLqkwj3t25i`)0Bt7aWk4X1Hq^*-$i@OYAJPswQ41Yl4es6KFz1qQ-yVn{4Oi$s& z`d?1mc>46-8}}Sr@%rdt@6&SZ8Sv3xFMIsD<_}-r&T)5rHfl{98 z47D@`J07uehFbayBXfgvKz&0?9qt6vgsU^|rrYzbxB$tUl=w)%yLSmE*2{_2bDg)WCRiqD? z$L)Np`bgT=yEp9Y$+;z4I5v8Yef%R&=N1-xnegu$bEe+h^J1p+g!h9NkDOOdPSNIm3ZwI`nL^{fj->?b-L>Ot}#dmGR z{}w5B3I@mG!oQ=#cU+ZenMtFlqZ)+=BS9^FLv}-9;4;I|0h>6C#tzEavl0@6egz6L zYc=rcX9jRZ>HSq7e06$xYvDu;M+zljBFrb{v#+sZ&8>oh6B)|I8VZF3QQiaV>xih; zawj14wUZJ|S!v$i(3Y&W)$xn9e-ycsHPJC3ktNDX;tsJ|+JqBJ5ZNP`QU3u1d&YNQ z+NkXB9XLMgAwARLhR^PRN7v(d=&T9Qg z6Yrb1=}GksbkqD*V_z76LHn=HsSKqb>f`}orp~2?wqEMIlQo5^^BGnft2z(j6iMnt z#K|S;i@mZg?;kicL8BBqRW(A5ewQ$@hO#j2vJ0O_adCQI2lX3yd2k8=&63@evIdGd z(n6B6BziRs+hm~aZO!i*&bT3lu?s}%#sw4@2JLUr6OHJ!Uj2|d>|r=UG=eL}1vrW; z^(<`Ej6kHs{sQ391oJgKa@{&!4EX?#dyMf@q|wv#qGxr{NiC&e4~G~#q+~X|*t5Dg z@66O@gCLQ}Ntb7m)Z@4%0O%!NJz-=mUK4da#~3n*SI3J%yl>6=hS6th6H3M{kVUf0 z9!)Ep42vYqBCsM^P{&t#B0}NE)OkK+sAJri=O{k5?H}6=GGQgM*-(;8Ayh;CoAfHnoTH@sv1QvOgRScuRhwewa{Vy09o zr$idDTzLsfxV9~|u+ZdvUV-^kO04oP0x4>ExfVgRLKExX60+I>1c%aGC{Fb63Ykv4 zO!M({oXWowoZyjG(ki^dY=>$tVTr+o7LYBaW0&jD`F|`a2AA7%_P0;GqtIVIt<)WyE0Tbs%Ij zpmBn$rAR_4D1SvXEkaKGWOSrhhOYv%`VuQB30G4pp;#mC1(d*qpbcE+XaEtvbQCE? zGiy~H1uw05l2`>%8ZlzU8mgVbE3gQYYGjlMFa#TT*_NWX$XS#`!xa3)D`?>vr-=ov zAi*EJe5(K|F`iaMshN^Oe#t~?juicnDXHa^SbEcPIi+A5K|pQ#g6Hnmp(mkqb4YC3#N^FCfsNEH^kt>V-BUpAi%PMbLd%beI|RqrZiXhk&1f1_i+^ zBo5#840ASpMnw!91Hs>G_Bm+{ z2qf9C^NonoXkb88ClGwJhj#XO1mo8)JQ|(R_AF3f?f!6q^MiW`!j+y0QZ+NUN@3&% zIR*X-4HZMNQ-VJX?}FygOOR|3TtLrKOSX1rZ!Lr~hd-Fix+?bbmW3d%uMq0K>FoQOCdZc5Wg9!JPW zu;e%`G$1$10qX4-ff&g`;6f@~A=QW^Vj1NtmS|uOLc2%Naj@`V9)siLk;L3M89j|(Oco+A4{&Fe3V0Vp z5`dxb3;?K@dH`B!kp8EekAA~$xQo#9jO`t#*3fca6{uh$Ou9`Qd!z>d8AxTQRFYb9 z%PDm+w2H2VqFZ>waz*2huMeRzF4B6yWyMtv9DXDXdx3zFg;^xmKON`6>kL#1C_s(% zg^TD#1lt7(6~eA!(Szyw1ubKiOrYTzqXSg&WDM)6kjlXvlG#RPQK+n&ZUGt!1*3t$ zY9dW*g24|0b?fMR>%s`jmb98kMN=wDA)(YdRp$??e{BseD}^2f>uI$=&~kd!4(L%( z_fuX0Q8;Q~AIU~hTd|s9wiw8Q?o^S{7yuj6No`@`!%Q4>_-LRA0SfP_fpvH!Y|ftR z2Ww8~(TUrVHGX0kC0EMO4YB+CMkWNu$z-o^Od?6g>M2lw^*rGMtD`ZS9v{9-!}Qo) z*y!O`fI|$_Ho&1jiKKH)=n+#r9uv}D^`wC`JzL*6&`6I!VjxWd)nawjC||=k)c8@f zoYJTg1BezI^eulvYE-#Flsa|{nvWzM<=z3{|3l>thBY|C3E=|e;-i-rodDZ+W;CA! zV=Ap0CS-;m_J!%BSe;3Y9~Kxu6eqkZ;t4=RJr02jctp*Fs-$v^!%Tu5Y@}2s)g&@D zjihO^u|(#W(X5;h4`7MWX5O$-P& z=-es=Mx|iYs8Z5QXUDGAV7i1HffVS`Q3BusdK{)E(i&LK(~6MjXf>sIS400!fInt| z(N-LAsB{kf&bVD+t$+;kaB&?4i|_a2S4nXFS$@$X1oyqz{Wrj(eFn3z;J|_~l~|z$ zn}CYh$xxr$LOp>6ShIm`c(m`)t!Ekse)rzJ0f13jUPsgAc#0ueuQo+F)exmMYd_wwiZJpjgD4OYW0++m$d@HSrdS!ngY}g zB5x>_F^;C00u(}koLSMZzu|^7kWw^F?db6XP$LQ1Md1b{R7GiZgR6*ln09LjDsL~o@gbk$RczKUesK5y7yAMHKAlQpS_Gg8}ah%7i!5$rA zl+0jvUTT!gU|&9Agv`jWQ)ZORAd>?jJZeZK;sk?CiV0aiR+H%4{)$*iCDkxH8UqW8 zG(yE2rpP>M+{c~+9r~Cb+gdBQ5xVzqF>eSC8UQuT!(Ox@)+SU4|N^3jjKFEHDMSIW{bcrQ`K&pUj|aHK{(5tq$|IAef4QeeE0yLiq4e zh6chi@O4F;Qb|MH9pUuEbZ+XRz&4OOG0+A#{o%Wi1jA=qct~UA$g&P#$T~)1eN_|% zkwrbtLZQH|F+pI65Ta#<2-X1sZ%V-!QlLcUz@M!26XqZ+<9!qhiIr2rl+3>z>9h8uF zh!ROiah1|IijG!?D7q+46%D)T&{$d#6AlDi(uvlcfCdoU1ukM}3|&I-iir{$I2}U@ z2KOZ)cVfv=-9d1)CJhuPlKp>!4h9tjUQiny*9$Ia6Xo}jOVw)Vrfei1;p-3gnslp0 zfQKZ+1#nE_@OWpLyaP-f#24;Y2tS@cNOYic@Tiv^_8{RLccQ?1XbWQ_k`R@mcm&QQ zZX$pjwZr2^2my^awK3&1I@Zu0bfh=thQOJ`O$p(~ht&;^+{^%@6vHxu9L(y~4^(7Q zcQx7RgG=VLFKtV7_-~ja%0-+;})VF|V!?r#Ngjm}bF0ge=tW2xq zVp)(_Y04s5-(cAerZt|$%mLFYPX5kt69EqOGmK>s`xL_Q^kVc_{)PKq!cPzjfO`$0 zR1GI=5(+<=Yx=Yd2$B#kh@Z~1da%LvHBo{g&=2D2Y1;PBl>=SVxS&-Sur z*aGwUrihlzrZf?LDydRnrc;kuu;CeY6$phhJdQFgF+}An6UVS;f>_)ETH=zV8&Hsc)=szuT5%fAJjsQ z(G<2E2KRaQe8^ny%vQJtwXC2(BZco4qJD!CBs&8?31;#&V~3beuv<#o9i09}KfCE) z3j1CDSv29brQ-*^G7i^%;bphxnb2`#JNtF+9!vUPkL^?HWM_Bpc>MDNZhhw+D=kbL zR_s=>)OpPPGpEW9gkOIUTrzZfueA~CkRE#ulx(|w_OMe(__71{`@fz#J1MkuW<>Yw zn%BoWT~a)_boyAq;f_grPM3bYcwxoBJ=^HoQ>C@JF0X%o<1}-MdvW~Xc}bt$??|T~ zx?i4Lo^aS}=4a&*fB8w%qw6B~q@Q`XJ|y=-esJmKkm@g!zjK;0bBj|*X}?7)Uk%B2 zv#qw4_I%~?W8I3X?bY9Bzg#}M?qW@zlY52REcd(^_lp&g*~|T^E3)#lD|c0wkE?i6 z>V7-^+O6!0Jz>?g!u%5<#p@p@-H`4rK6lOA?`XQsKgT{_R-NW|>A>lm!>(7JK3%b{ z>SRelL2c#L>a^=G3o2jSsY=ZLx$fMue{zm}Td}3?*x~I*3o2h1J^guP($d$RixTfv z`rIo#H6$OOl&==#Q|Sj?=O6J|Zs{IS`ddjsQD)NJzbseUP7W^qro&3xOGhu2_55%1 z?)Lk}R=-(yJ9}hSew}-MZf3>N2-Q&fL0)P_!BeyBPt(inUkcw?O2T)|v;%)blg@;n z_Mx;aF@{#4y{QCd^CRU>cI)^hRE0{H3wgJJwEYH^B4r56>^pS^vAcs{gggTkTbel>;h+_AROER_3$MX3OF& zh0}Ir`q>x$`r_*so9_t9FFyMEj$KyAtTofOX7xC+>h{U_lQAmGYw_EhRFf-bR*pZk zzP4(0e$A!2%@1c3mnT=KL+1VWOjh=$;)Us@HO-F>IX>*muH&=PiVJU7_RLy%;@K?s z(SPUU=HBeF`N{Dy-95Y?tsK)O**tH}vW#TwPrmWX3Hfc{%aPYYG6G&kRd!AAOkY~% zrU_1OlP$P(wlp+*N$|N91(WZ#Ozc{8^Ri3gh?*8-dS`mqb{geKFU?3+V=Iu zx|;mNKg(wp$GcY?T$c27Ue0UzBezS%-^AyPE=}LG%m6nj->*sc$s@`?0tT?~iHf1r z(of+L?Xd^01hY0d;sJ}{_41`o=ZCl6@B8nmRvGfIM=q*WTKwyj*MY#Z!-wv_-1k(w z2g?S|&79fR+%vUT&Z}E7r;@kXbWvYE;%zhf-Y!Ysz){bad=s2D^x46#F`MH4Ozb>E zbB>yP;k#k<@8LIR#ODO9P#l$?>k*%;&Tnoz_UEVjozkb3T=?ZcLPT=- zTnc8a7=I`c^yF33e$*5V1;cp-=FF#ASe+udHt^gDR&Gkffj_W-g&fX6%xM4)x?YLI zbJr1q){xmn`@vO15}fpeQWZ>Nr$~J`5Q0DF;8>M9!YfLH+usB;$){arz{4sg3m!;X z4GUXNss*gdaesG9)32yFUCroM){L@1#t08u~Rp!e(S{(K0 zy=qbKEe=7gT6J^XBrt39@rOTH`EAXSO4qCznqKUFYTDnY=Z@`}?Vf+^KWqQ_^!)Y5 zpOpR7edXYgZh1CO%3ho=`1;Yt+9y7FoyTU~N%h%MGk@WeTbJT1j}<=|8?DaD^?Vpx zoSs5`a^>`vksD@r|9f)ixyMhY#J>tEn!B-dgP&8}>hn8lebtBlnvq(SlDj>}(k5Yq zP1(1#S2L6R_6B50@*i9Kc9DJFx<_}LqO6A%=TgU94sb7j@!RpE`M%ZGb8Vjvys&B0 z8SC_;e{C>>ceg2aCas%)rM&0IS%v?8k>R@HfZLl7UT(V~&fVoU zYt)~97ww1JzMTGOu1(Flm#4Q>Zg6|lJUeqmMDdsT`wQ167e?5deLuZf|0TpR7J?eB ziC%RFQw@|PnEi0xzx^VQwiZ_=g|?Uw9i|ixir2~ry*;_hrH3R^$wKVu?7CtG;3hwy+75pTaeZDsP$1}-n{ghNtylK2^I5+1o_o#o`OP+N zoVTWIZS2yS!NJeh&#z7PUsLFpGNSI{oQjjJYy*FsPc{=q-%Hz{z z5r2N7-jOnq~FW=uo-kmrW_h70b^x&+A@VJWl*F_f%n5dXA#f{+5g1 zg+5ni%${*Pd!1`)z>>w?H;i_1Pf${br48YT?Dgg{2RI@1(kZV|hGn%-xGg*Wu~a zMrb&&=Zk37LN341jP7fjFf8#muISH@%iUj?^|U5^rh`_aqhJt zQ%00`_%^6^fsZI(oOjK&Qc^VV!iK75q8;OMw(kycRuui~tM94=IXm*sY~3AyCAG!D zf1CzP_)50jp;LjCm-)Tb%V(_J+O6vH&5z~=)ppK(Bpg;WVWn48?{-(LJRRGwS~$PP zV^Ft}mS)XHW`N_?ryj8L2E{P*sU5#_auGwOi^_RjSf`6F<`vXWFF%u9oYo9YFHC?# zCKJIIx3Tt`GPUdgn|0W){UG4-!HEmaeQZcxs)geM66 zmbB}M&Va#5XSA{9h8hzSgbv!qa7n~(EPPUusja*+)XGJIfU6-^Bpk>2t#IaiY-(FT zZy@E5WB0qby@fv&hEG)kt;_Z~VQuD*8})2<*-$k)5bktm@q9+>9FL$d5|Fv1v)}$3 zDA2#h!v(;~_A+Oa^mIq3GYx|C=SduR?PKyn&|UO@ix#ME;npb&%;6o|tjZ@7SM z;+q$)Lm@Sjk^w^NX7%BD#;4PGWi6SqF!5~m55O}?5r#Z0$)SL^*;SH@%S}sfh+@Ff zoB3le@(ka+vaOQeeIBei`@=giXU5}HSeSx2u?y;iQR)qe@sVNf^?d6X(!~SG*`#C0 zT|j|`42FxurQYL6?Cyjk`^uPLrft6p@2hKJRHM0JQHG{0>L+gACQt0ujvyksLLd+&a@9# z2|E}9CE}TWf+Y}i@MfhM#*(8g-{t^AAdmn6T8pP%E;w#ny#~y+jWEPhFNbG>dM~@@ z5qb#cqCQ%}O&c*c8Yy@4=mkne%fgl#N#8!$?&hHe2-F6e2OzMg)`!O5Nguxurf?Jn z#J3K%1DR>*luv!V*50&2!JZJPKY;w^P=pH#GjCBdxP^phr};pR$V+cEZu-#Hm+Gy? zMaZ?)R_U$A#qxO+w4WKskFua^I>R5|UdS$XAuCqI2`{ZCwK|JMHl%F#FN literal 0 HcmV?d00001 diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_DrinkingVessel/SM_DrinkingVessel.fbx.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_DrinkingVessel/SM_DrinkingVessel.fbx.meta new file mode 100644 index 00000000..ae2b38af --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_DrinkingVessel/SM_DrinkingVessel.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: 8f55afd0395ad797a143eca38f175516 +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 1 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 0 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 0 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan.meta new file mode 100644 index 00000000..cddc147a --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 35ee52f082cb1ed48e2c736baa3b0ca8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan/SM_ElectricFan.fbx b/Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan/SM_ElectricFan.fbx new file mode 100644 index 0000000000000000000000000000000000000000..5791ba0721df60621a853349171a5de8b62c9879 GIT binary patch literal 32620 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 OwKgrOb@1E5{(k@fcMOF9 literal 0 HcmV?d00001 diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan/SM_ElectricFan.fbx.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan/SM_ElectricFan.fbx.meta new file mode 100644 index 00000000..ce7ba020 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_ElectricFan/SM_ElectricFan.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: 5aea078488f3c94940014fe5754b9a87 +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 1 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 0 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 0 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre.meta new file mode 100644 index 00000000..8d54363b --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4dd9d0e753f6a3e9501bf58a90eae2c4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre/SM_HealthCentre.fbx b/Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre/SM_HealthCentre.fbx new file mode 100644 index 0000000000000000000000000000000000000000..34e9541544ca5a86f9dc074761c13cf668f27436 GIT binary patch literal 17532 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 G@c#gVbI6JS literal 0 HcmV?d00001 diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre/SM_HealthCentre.fbx.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre/SM_HealthCentre.fbx.meta new file mode 100644 index 00000000..c52f907a --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_HealthCentre/SM_HealthCentre.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: 30116ee7517996c9ad38d7c9a07b74f0 +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 1 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 0 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 0 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp.meta new file mode 100644 index 00000000..a08572ce --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 08038b311ebb15a69cbbe95bee2aa91f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp/SM_MosquitoNet_RolledUp.fbx b/Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp/SM_MosquitoNet_RolledUp.fbx new file mode 100644 index 0000000000000000000000000000000000000000..3a8071cf70b5c94199527f9603fb647e0a05450d GIT binary patch literal 20860 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 literal 0 HcmV?d00001 diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp/SM_MosquitoNet_RolledUp.fbx.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp/SM_MosquitoNet_RolledUp.fbx.meta new file mode 100644 index 00000000..4a1a748a --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_MosquitoNet_RolledUp/SM_MosquitoNet_RolledUp.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: b538d811550d65e156d14b6cb997381f +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 1 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 0 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 0 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle.meta new file mode 100644 index 00000000..6e947cc7 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c3075c700a72a37c5570dbec495e66ea +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle/SM_RepellentBottle.fbx b/Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle/SM_RepellentBottle.fbx new file mode 100644 index 0000000000000000000000000000000000000000..8799bf147ba808cfe66aa547fe425c844832091a GIT binary patch literal 19468 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 JB%4NQ{ts&I04V?f literal 0 HcmV?d00001 diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle/SM_RepellentBottle.fbx.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle/SM_RepellentBottle.fbx.meta new file mode 100644 index 00000000..93a526e0 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_RepellentBottle/SM_RepellentBottle.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: 5ed76c5f71e155e35a688bca7e427877 +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 1 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 0 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 0 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch.meta new file mode 100644 index 00000000..912927a5 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c5c2324b8527e1a88edecc4ddd09bc89 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch/SM_Torch.fbx b/Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch/SM_Torch.fbx new file mode 100644 index 0000000000000000000000000000000000000000..d237408081c78fb61da15d36a962ee2d5ccc8d28 GIT binary patch literal 20236 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 literal 0 HcmV?d00001 diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch/SM_Torch.fbx.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch/SM_Torch.fbx.meta new file mode 100644 index 00000000..490d25ad --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_Torch/SM_Torch.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: 6c854803331a7a8911b678f4670e0027 +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 1 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 0 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 0 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen.meta new file mode 100644 index 00000000..4bce9618 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 03e477398674e471385f19a1d90166e2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen/SM_WindowScreen.fbx b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen/SM_WindowScreen.fbx new file mode 100644 index 0000000000000000000000000000000000000000..4fcb15cd4678b41a5088d70da4d8619e784c56ca GIT binary patch literal 17132 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 literal 0 HcmV?d00001 diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen/SM_WindowScreen.fbx.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen/SM_WindowScreen.fbx.meta new file mode 100644 index 00000000..10ff8fcc --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreen/SM_WindowScreen.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: dcfa0eaa38f1b33d15417809bad136a1 +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 1 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 0 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 0 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn.meta new file mode 100644 index 00000000..7b463608 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 618fa05f0baa3c898e5b5d308d105bde +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn/SM_WindowScreenTorn.fbx b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn/SM_WindowScreenTorn.fbx new file mode 100644 index 0000000000000000000000000000000000000000..9fcfb1266d7bc9b80552ef57510c78ff01733b03 GIT binary patch literal 17836 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 literal 0 HcmV?d00001 diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn/SM_WindowScreenTorn.fbx.meta b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn/SM_WindowScreenTorn.fbx.meta new file mode 100644 index 00000000..85e77e23 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SM_WindowScreenTorn/SM_WindowScreenTorn.fbx.meta @@ -0,0 +1,110 @@ +fileFormatVersion: 2 +guid: 86bd0137c65cbb67d8d489db3212acb9 +ModelImporter: + serializedVersion: 24200 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 0 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 0 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 0 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 1 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + referencedClips: [] + importAnimation: 0 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 0 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_HealthCentre.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_HealthCentre.prefab new file mode 100644 index 00000000..d628dfa5 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_HealthCentre.prefab @@ -0,0 +1,124 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &8953984357288351270 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 508113804882902743} + - component: {fileID: 6208186161483310639} + - component: {fileID: 621689745462528642} + m_Layer: 0 + m_Name: PF_HealthCentre + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &508113804882902743 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8953984357288351270} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 4564701794004341099} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &6208186161483310639 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8953984357288351270} + m_Mesh: {fileID: -1160199339206188333, guid: 30116ee7517996c9ad38d7c9a07b74f0, type: 3} +--- !u!23 &621689745462528642 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8953984357288351270} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!1 &9205863966436772169 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4564701794004341099} + m_Layer: 0 + m_Name: Socket_Entrance + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &4564701794004341099 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9205863966436772169} + serializedVersion: 2 + m_LocalRotation: {x: 7.575973e-23, y: 0.00000004371139, z: 1, w: -0.0000000754979} + m_LocalPosition: {x: -0, y: 0.00000024478376, z: 5.6} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 508113804882902743} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_HealthCentre.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_HealthCentre.prefab.meta new file mode 100644 index 00000000..e6deb008 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_HealthCentre.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b50995560aafb4700951fa9e51406040 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreen.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreen.prefab new file mode 100644 index 00000000..65290261 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreen.prefab @@ -0,0 +1,124 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &4218030108338740149 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4413263099668050794} + m_Layer: 0 + m_Name: Socket_Mount + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &4413263099668050794 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4218030108338740149} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 6009993182549689671} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &7809737920270896784 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6009993182549689671} + - component: {fileID: 7961638854228328075} + - component: {fileID: 1546811760232873997} + m_Layer: 0 + m_Name: PF_WindowScreen + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6009993182549689671 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7809737920270896784} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 4413263099668050794} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &7961638854228328075 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7809737920270896784} + m_Mesh: {fileID: -1306216796308130733, guid: dcfa0eaa38f1b33d15417809bad136a1, type: 3} +--- !u!23 &1546811760232873997 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7809737920270896784} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreen.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreen.prefab.meta new file mode 100644 index 00000000..9a74bbbc --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreen.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 13938d27b67924acb8b9f225bfbebcad +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreenTorn.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreenTorn.prefab new file mode 100644 index 00000000..6f72bd4b --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreenTorn.prefab @@ -0,0 +1,124 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &3815764103173382851 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5269373451546157047} + m_Layer: 0 + m_Name: Socket_Mount + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &5269373451546157047 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3815764103173382851} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8961661586683659898} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &5549106017413172908 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8961661586683659898} + - component: {fileID: 3549776881492934574} + - component: {fileID: 2894045801055964411} + m_Layer: 0 + m_Name: PF_WindowScreenTorn + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8961661586683659898 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5549106017413172908} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 5269373451546157047} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &3549776881492934574 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5549106017413172908} + m_Mesh: {fileID: -4562251905635594728, guid: 86bd0137c65cbb67d8d489db3212acb9, type: 3} +--- !u!23 &2894045801055964411 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5549106017413172908} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreenTorn.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreenTorn.prefab.meta new file mode 100644 index 00000000..66372ce9 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_WindowScreenTorn.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e7faa9cecfd2b4d5d89649a638db5dcb +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Furniture/PF_ElectricFan.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Furniture/PF_ElectricFan.prefab new file mode 100644 index 00000000..27b2d858 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Furniture/PF_ElectricFan.prefab @@ -0,0 +1,306 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &568483039628718336 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8361533434325871227} + - component: {fileID: 7507550410486398501} + - component: {fileID: 8348783417657877050} + m_Layer: 0 + m_Name: PF_ElectricFan + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8361533434325871227 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568483039628718336} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 9163958023252700201} + - {fileID: 2630808574693119624} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &7507550410486398501 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568483039628718336} + m_Mesh: {fileID: -2708989599258122951, guid: 5aea078488f3c94940014fe5754b9a87, type: 3} +--- !u!23 &8348783417657877050 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568483039628718336} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!1 &2034334432058185116 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2630808574693119624} + m_Layer: 0 + m_Name: Socket_Mount + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2630808574693119624 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2034334432058185116} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8361533434325871227} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &3862010748688867024 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 9163958023252700201} + - component: {fileID: 651341298883180826} + - component: {fileID: 4834508624058667925} + m_Layer: 0 + m_Name: Fan_Head + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &9163958023252700201 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3862010748688867024} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 344567494768510392} + m_Father: {fileID: 8361533434325871227} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &651341298883180826 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3862010748688867024} + m_Mesh: {fileID: 1403853363702810737, guid: 5aea078488f3c94940014fe5754b9a87, type: 3} +--- !u!23 &4834508624058667925 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3862010748688867024} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!1 &7637220264809307793 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 344567494768510392} + - component: {fileID: 1872303429554498740} + - component: {fileID: 6586755726353836735} + m_Layer: 0 + m_Name: Fan_Blade + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &344567494768510392 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7637220264809307793} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0.3, z: 0.034999985} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 9163958023252700201} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &1872303429554498740 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7637220264809307793} + m_Mesh: {fileID: -2589504093240404945, guid: 5aea078488f3c94940014fe5754b9a87, type: 3} +--- !u!23 &6586755726353836735 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7637220264809307793} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Furniture/PF_ElectricFan.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Furniture/PF_ElectricFan.prefab.meta new file mode 100644 index 00000000..ac942474 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Furniture/PF_ElectricFan.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1a512d9cfb3c943968cb86b6385f9813 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Cloth.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Cloth.prefab new file mode 100644 index 00000000..5d31788b --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Cloth.prefab @@ -0,0 +1,124 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &1987030467269949642 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1067895100620892576} + m_Layer: 0 + m_Name: Socket_Grip + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1067895100620892576 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1987030467269949642} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0.06, z: -0.0000000026226832} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 2880224216350563330} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &4776475708457436854 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2880224216350563330} + - component: {fileID: 2330597143989175691} + - component: {fileID: 4754975119008688412} + m_Layer: 0 + m_Name: PF_Cloth + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2880224216350563330 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4776475708457436854} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1067895100620892576} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &2330597143989175691 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4776475708457436854} + m_Mesh: {fileID: -4918598716126501196, guid: dccb4b1e965fcae661ffa25660ca8fb2, type: 3} +--- !u!23 &4754975119008688412 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4776475708457436854} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Cloth.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Cloth.prefab.meta new file mode 100644 index 00000000..3632654b --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Cloth.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f8bc01ffa1829489eba01be592255d90 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_DrinkingVessel.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_DrinkingVessel.prefab new file mode 100644 index 00000000..2732cf8f --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_DrinkingVessel.prefab @@ -0,0 +1,124 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &2169434627407269859 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3555906282430596475} + m_Layer: 0 + m_Name: Socket_Grip + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &3555906282430596475 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2169434627407269859} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0.06, z: -0.0000000026226832} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 2095973780558422967} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &4264012803921672569 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2095973780558422967} + - component: {fileID: 3841204435604879604} + - component: {fileID: 860758458269284243} + m_Layer: 0 + m_Name: PF_DrinkingVessel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2095973780558422967 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4264012803921672569} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 3555906282430596475} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &3841204435604879604 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4264012803921672569} + m_Mesh: {fileID: 5904289946681584008, guid: 8f55afd0395ad797a143eca38f175516, type: 3} +--- !u!23 &860758458269284243 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4264012803921672569} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_DrinkingVessel.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_DrinkingVessel.prefab.meta new file mode 100644 index 00000000..a6f12b7e --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_DrinkingVessel.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1205767829ddd4d5fbeba004e6a05381 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab new file mode 100644 index 00000000..737c2baf --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab @@ -0,0 +1,93 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &9050063049380606954 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8333836139450375325} + - component: {fileID: 5414489047284841716} + - component: {fileID: 2382584433963464480} + m_Layer: 0 + m_Name: PF_MosquitoNet_Deployed + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8333836139450375325 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9050063049380606954} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &5414489047284841716 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9050063049380606954} + m_Mesh: {fileID: 4711208715938537054, guid: d5763a53ca86cb04d91536e7a64d4527, type: 3} +--- !u!23 &2382584433963464480 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9050063049380606954} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 7456401416323629024, guid: d5763a53ca86cb04d91536e7a64d4527, type: 3} + - {fileID: 6626336981348453546, guid: d5763a53ca86cb04d91536e7a64d4527, type: 3} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab.meta new file mode 100644 index 00000000..5e1fdbc4 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 323d42b385de34be49dbf434fea14fdf +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_RolledUp.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_RolledUp.prefab new file mode 100644 index 00000000..7ca27161 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_RolledUp.prefab @@ -0,0 +1,156 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &2741069837296957615 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7939062504562726360} + - component: {fileID: 363575683805279270} + - component: {fileID: 9060709582860188262} + m_Layer: 0 + m_Name: PF_MosquitoNet_RolledUp + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &7939062504562726360 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2741069837296957615} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 8868360889378988784} + - {fileID: 8528367611377599601} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &363575683805279270 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2741069837296957615} + m_Mesh: {fileID: 2003880380992826810, guid: b538d811550d65e156d14b6cb997381f, type: 3} +--- !u!23 &9060709582860188262 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2741069837296957615} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!1 &4033536300716883522 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8528367611377599601} + m_Layer: 0 + m_Name: Socket_Mount + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8528367611377599601 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4033536300716883522} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 7939062504562726360} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &8897259960954203718 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8868360889378988784} + m_Layer: 0 + m_Name: Socket_Hook + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8868360889378988784 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8897259960954203718} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 1.39, z: -0.00000006075883} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 7939062504562726360} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_RolledUp.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_RolledUp.prefab.meta new file mode 100644 index 00000000..575b963c --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_MosquitoNet_RolledUp.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c3a6de9b4f911457f8017f861c161445 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_RepellentBottle.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_RepellentBottle.prefab new file mode 100644 index 00000000..007b0299 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_RepellentBottle.prefab @@ -0,0 +1,124 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &5708675653171567090 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1395398199436954044} + - component: {fileID: 7549973690389153842} + - component: {fileID: 2238078749113421751} + m_Layer: 0 + m_Name: PF_RepellentBottle + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1395398199436954044 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5708675653171567090} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 8001892206300915953} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &7549973690389153842 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5708675653171567090} + m_Mesh: {fileID: -1339732221972324114, guid: 5ed76c5f71e155e35a688bca7e427877, type: 3} +--- !u!23 &2238078749113421751 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5708675653171567090} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!1 &7741716634689093191 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8001892206300915953} + m_Layer: 0 + m_Name: Socket_Grip + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8001892206300915953 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7741716634689093191} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0.075, z: -0.0000000032783543} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1395398199436954044} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_RepellentBottle.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_RepellentBottle.prefab.meta new file mode 100644 index 00000000..74a519cf --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_RepellentBottle.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 562d24747e4d34b8ca5b52a5b467fd39 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Torch.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Torch.prefab new file mode 100644 index 00000000..f7e66c7d --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Torch.prefab @@ -0,0 +1,156 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &138998192962868714 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8887075806264392408} + - component: {fileID: 4848442135766907817} + - component: {fileID: 7100301002660867351} + m_Layer: 0 + m_Name: PF_Torch + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8887075806264392408 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 138998192962868714} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 7368991984835803424} + - {fileID: 5047364520684124434} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &4848442135766907817 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 138998192962868714} + m_Mesh: {fileID: -3760958739429886168, guid: 6c854803331a7a8911b678f4670e0027, type: 3} +--- !u!23 &7100301002660867351 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 138998192962868714} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: c989336e24c6a8a08592ba0d9eb1cbd6, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!1 &2284289964840828082 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7368991984835803424} + m_Layer: 0 + m_Name: Socket_Grip + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &7368991984835803424 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2284289964840828082} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: -0, y: 0.065, z: -0.0000000028412401} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8887075806264392408} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &7786132466500657446 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5047364520684124434} + m_Layer: 0 + m_Name: Socket_Light + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &5047364520684124434 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7786132466500657446} + serializedVersion: 2 + m_LocalRotation: {x: 0.00000010677015, y: 0.7071069, z: 0.70710665, w: -1.5502382e-14} + m_LocalPosition: {x: -0, y: 0.176, z: -0.000000007693204} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8887075806264392408} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Torch.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Torch.prefab.meta new file mode 100644 index 00000000..481bc171 --- /dev/null +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Torch.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 60cfa2bf89fbc463b83cbf5a4f0c6e97 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/Module3PlaceholderPrefabs.cs b/Assets/Editor/Module3PlaceholderPrefabs.cs new file mode 100644 index 00000000..f4b783d6 --- /dev/null +++ b/Assets/Editor/Module3PlaceholderPrefabs.cs @@ -0,0 +1,352 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; + +/// +/// Builds the Module 3 greybox placeholder prefabs at their FINAL paths and +/// names, per the "Unblocking pattern" in Documentation/Module3-Roadmap.md §4: +/// code binds to the prefab path and the Socket_* transforms now, and art +/// replaces the mesh inside the prefab later with no scene churn. +/// +/// The meshes come from Tools/Module3/make_placeholders.py (Blender). This +/// script only does the Unity half: instantiate each model, assign the +/// placeholder material, save the prefab, and check the §4 contract held on +/// import (sockets present, triangles within budget). +/// +/// Re-running is safe and idempotent -- it overwrites the prefabs in place, so +/// the GUIDs and every scene reference to them survive. +/// +public static class Module3PlaceholderPrefabs +{ + private const string MenuBase = "AEDES/Module 3/"; + private const string BuildPath = MenuBase + "Build Placeholder Prefabs"; + private const string VerifyPath = MenuBase + "Verify Placeholder Contract"; + + private const string Models = "Assets/1.TeamWorkspace/Team Assets/Models"; + private const string Prefabs = "Assets/1.TeamWorkspace/Team Assets/Prefabs"; + private const string Materials = "Assets/1.TeamWorkspace/Team Assets/Materials"; + private const string PlaceholderMaterial = Materials + "/M_Module3Placeholder.mat"; + + private class Entry + { + public string Model; // source .fbx + public string Prefab; // destination .prefab + public int TriBudget; // §4 budget, 0 = not specified by the contract + public string[] Sockets; // sockets the contract expects to survive import + public Vector3 Size; // expected size in metres, Unity axes (Y = up) + public bool Greybox = true; // false = finished art, keep its own materials + public string Note; + } + + /// + /// The §4 outsourcing list, plus the two net states. Paths here ARE the + /// contract -- changing one is a breaking change for any code or scene that + /// has already bound to it. + /// + private static readonly Entry[] Entries = + { + new Entry { + Model = Models + "/SM_ElectricFan/SM_ElectricFan.fbx", + Prefab = Prefabs + "/Furniture/PF_ElectricFan.prefab", + TriBudget = 3000, + Sockets = new[] { "Socket_Mount" }, + Size = new Vector3(0.306f, 0.453f, 0.21f), + Note = "Fan_Blade is a separate child on the spin axis; animate its local Z.", + }, + new Entry { + Model = Models + "/SM_WindowScreen/SM_WindowScreen.fbx", + Prefab = Prefabs + "/Buildings/PF_WindowScreen.prefab", + TriBudget = 500, + Sockets = new[] { "Socket_Mount" }, + Size = new Vector3(1.5f, 1f, 0.04f), + Note = "1.50 x 1.00 m, sized to the measured Lao house window opening.", + }, + new Entry { + Model = Models + "/SM_WindowScreenTorn/SM_WindowScreenTorn.fbx", + Prefab = Prefabs + "/Buildings/PF_WindowScreenTorn.prefab", + TriBudget = 500, + Sockets = new[] { "Socket_Mount" }, + Size = new Vector3(1.5f, 1f, 0.099f), + Note = "Repair target for RepairScreen(householdId). Same opening as PF_WindowScreen.", + }, + new Entry { + Model = Models + "/SM_MosquitoNet_RolledUp/SM_MosquitoNet_RolledUp.fbx", + Prefab = Prefabs + "/Props/PF_MosquitoNet_RolledUp.prefab", + TriBudget = 1500, + Sockets = new[] { "Socket_Mount", "Socket_Hook" }, + Size = new Vector3(0.32f, 0.589f, 0.32f), + Note = "The 'up' state. Shares an origin with PF_MosquitoNet_Deployed -- swap at one transform.", + }, + // The 'down' state is existing finished art, not a placeholder. It gets a + // prefab of its own only so the two net states are addressed by symmetric + // paths; the mesh and materials are the shipped ones. + new Entry { + Model = Models + "/SM_MosquitoNet/SM_MosquitoNet.fbx", + Prefab = Prefabs + "/Props/PF_MosquitoNet_Deployed.prefab", + TriBudget = 0, + Sockets = new string[0], + Size = new Vector3(1.56f, 1.383f, 1.766f), + Greybox = false, + Note = "Existing art. Reuses SM_MosquitoNet; PF_MosquitoNet stays as it is.", + }, + new Entry { + Model = Models + "/SM_RepellentBottle/SM_RepellentBottle.fbx", + Prefab = Prefabs + "/Props/PF_RepellentBottle.prefab", + TriBudget = 1500, + Sockets = new[] { "Socket_Grip" }, + Size = new Vector3(0.071f, 0.191f, 0.06f), + Note = "GiveRepellent(personId).", + }, + new Entry { + Model = Models + "/SM_DrinkingVessel/SM_DrinkingVessel.fbx", + Prefab = Prefabs + "/Props/PF_DrinkingVessel.prefab", + TriBudget = 1500, + Sockets = new[] { "Socket_Grip" }, + Size = new Vector3(0.084f, 0.11f, 0.084f), + Note = "BringWater(personId).", + }, + new Entry { + Model = Models + "/SM_Cloth/SM_Cloth.fbx", + Prefab = Prefabs + "/Props/PF_Cloth.prefab", + TriBudget = 1500, + Sockets = new[] { "Socket_Grip" }, + Size = new Vector3(0.4f, 0.06f, 0.26f), + Note = "HelpRest(personId) dressing. §5: dresses the scene, never flags the person.", + }, + new Entry { + Model = Models + "/SM_Torch/SM_Torch.fbx", + Prefab = Prefabs + "/Props/PF_Torch.prefab", + TriBudget = 1500, + Sockets = new[] { "Socket_Grip", "Socket_Light" }, + Size = new Vector3(0.064f, 0.171f, 0.066f), + Note = "Parent a Light to Socket_Light with identity rotation; its forward is the beam.", + }, + new Entry { + Model = Models + "/SM_HealthCentre/SM_HealthCentre.fbx", + Prefab = Prefabs + "/Buildings/PF_HealthCentre.prefab", + TriBudget = 2000, + Sockets = new[] { "Socket_Entrance" }, + Size = new Vector3(12.6f, 3.65f, 10.3f), + Note = "PROVISIONAL massing. §13 item 4 has not resolved whether the player travels here.", + }, + }; + + [MenuItem(BuildPath)] + public static void Build() + { + AssetDatabase.Refresh(); + + var material = AssetDatabase.LoadAssetAtPath(PlaceholderMaterial); + if (material == null) + { + Debug.LogError($"[Module3] Missing {PlaceholderMaterial}. " + + "Run Tools/Module3/make_unity_assets.py first."); + return; + } + + var log = new StringBuilder("[Module3] Placeholder prefabs\n"); + int built = 0, failed = 0; + + // Deliberately NOT wrapped in StartAssetEditing/StopAssetEditing: + // SaveAsPrefabAsset has to import the asset it just wrote, and import is + // paused inside that block, so every save fails silently. + foreach (var e in Entries) + { + if (BuildOne(e, material, log)) built++; + else failed++; + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + log.Append($"\n{built} built, {failed} failed."); + if (failed > 0) Debug.LogError(log.ToString()); + else Debug.Log(log.ToString()); + } + + private static bool BuildOne(Entry e, Material material, StringBuilder log) + { + var model = AssetDatabase.LoadAssetAtPath(e.Model); + if (model == null) + { + log.Append($" MISSING MODEL {e.Model}\n"); + return false; + } + + var dir = Path.GetDirectoryName(e.Prefab).Replace('\\', '/'); + if (!AssetDatabase.IsValidFolder(dir)) + { + log.Append($" MISSING FOLDER {dir}\n"); + return false; + } + + var instance = (GameObject)PrefabUtility.InstantiatePrefab(model); + if (instance == null) + { + log.Append($" INSTANTIATE FAILED {e.Model}\n"); + return false; + } + + try + { + instance.name = Path.GetFileNameWithoutExtension(e.Prefab); + + // Unity's FBX importer puts a -90 deg X rotation on the root node of + // a Z-up-declared file. Our meshes are already authored in Unity's + // axes (Tools/Module3/make_placeholders.py does the conversion into + // the vertex data, because letting the exporter bake it displaces + // parented children like Fan_Blade), so that rotation is a leftover + // and the prefab root should sit at identity like the rest of the + // repo's art. The Size check below is what proves this is right: if + // the importer ever stops adding it, the reported bounds change and + // Verify says so. + instance.transform.localRotation = Quaternion.identity; + instance.transform.localPosition = Vector3.zero; + + // Break the link to the model so the saved prefab is a plain prefab + // whose mesh reference art can later repoint, rather than a variant + // that fights every re-import. + PrefabUtility.UnpackPrefabInstance( + instance, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + + if (e.Greybox) + { + foreach (var r in instance.GetComponentsInChildren(true)) + { + var mats = new Material[Mathf.Max(1, r.sharedMaterials.Length)]; + for (int i = 0; i < mats.Length; i++) mats[i] = material; + r.sharedMaterials = mats; + } + } + + var saved = PrefabUtility.SaveAsPrefabAsset(instance, e.Prefab, out bool ok); + if (!ok || saved == null) + { + log.Append($" SAVE FAILED {e.Prefab}\n"); + return false; + } + + log.Append($" {Describe(e, instance)}\n"); + return true; + } + finally + { + Object.DestroyImmediate(instance); + } + } + + /// + /// Reports what actually survived the FBX import, which is the only way to + /// know the §4 contract held: Unity keeps or drops empty nodes depending on + /// importer settings, and a silently-dropped socket would strand the code + /// that expects it. + /// + private static string Describe(Entry e, GameObject instance) + { + int tris = 0; + foreach (var mf in instance.GetComponentsInChildren(true)) + if (mf.sharedMesh != null) tris += mf.sharedMesh.triangles.Length / 3; + + var missing = new List(); + foreach (var s in e.Sockets) + if (FindDeep(instance.transform, s) == null) missing.Add(s); + + var size = LocalBoundsSize(instance); + + var sb = new StringBuilder(); + sb.Append($"{Path.GetFileName(e.Prefab),-34} {tris,5} tris"); + sb.Append($" size {size.x:0.###} x {size.y:0.###} x {size.z:0.###} m"); + if (e.Size != Vector3.zero) + { + const float tol = 0.02f; // 2 cm; reported sizes are rounded + bool fits = Mathf.Abs(size.x - e.Size.x) <= tol + && Mathf.Abs(size.y - e.Size.y) <= tol + && Mathf.Abs(size.z - e.Size.z) <= tol; + if (!fits) + sb.Append($" WRONG SIZE, expected {e.Size.x:0.###} x " + + $"{e.Size.y:0.###} x {e.Size.z:0.###}"); + } + if (e.TriBudget > 0) + sb.Append(tris <= e.TriBudget + ? $" (budget {e.TriBudget}, ok)" + : $" (OVER BUDGET {e.TriBudget})"); + if (e.Sockets.Length > 0) + sb.Append(missing.Count == 0 + ? $" sockets ok: {string.Join(", ", e.Sockets)}" + : $" MISSING SOCKETS: {string.Join(", ", missing)}"); + return sb.ToString(); + } + + /// + /// Combined mesh bounds of the whole hierarchy, in the root's local space -- + /// i.e. the size the prefab has when dropped into a scene at scale 1. This + /// is the number that decides whether PF_WindowScreen actually fits a Lao + /// house window, so it is worth printing on every run. + /// + private static Vector3 LocalBoundsSize(GameObject root) + { + var toRoot = root.transform.worldToLocalMatrix; + bool any = false; + Vector3 min = Vector3.zero, max = Vector3.zero; + + foreach (var mf in root.GetComponentsInChildren(true)) + { + var mesh = mf.sharedMesh; + if (mesh == null) continue; + var m = toRoot * mf.transform.localToWorldMatrix; + var b = mesh.bounds; + for (int i = 0; i < 8; i++) + { + var corner = b.center + Vector3.Scale(b.extents, new Vector3( + (i & 1) == 0 ? -1 : 1, + (i & 2) == 0 ? -1 : 1, + (i & 4) == 0 ? -1 : 1)); + var p = m.MultiplyPoint3x4(corner); + if (!any) { min = max = p; any = true; } + else { min = Vector3.Min(min, p); max = Vector3.Max(max, p); } + } + } + return any ? max - min : Vector3.zero; + } + + private static Transform FindDeep(Transform root, string name) + { + if (root.name == name) return root; + foreach (Transform c in root) + { + var hit = FindDeep(c, name); + if (hit != null) return hit; + } + return null; + } + + [MenuItem(VerifyPath)] + public static void Verify() + { + var log = new StringBuilder("[Module3] Placeholder contract\n"); + int problems = 0; + + foreach (var e in Entries) + { + var prefab = AssetDatabase.LoadAssetAtPath(e.Prefab); + if (prefab == null) + { + log.Append($" NOT BUILT {e.Prefab}\n"); + problems++; + continue; + } + + var line = Describe(e, prefab); + if (line.Contains("OVER BUDGET") || line.Contains("MISSING SOCKETS") + || line.Contains("WRONG SIZE")) problems++; + log.Append($" {line}\n"); + if (!string.IsNullOrEmpty(e.Note)) log.Append($" {e.Note}\n"); + } + + log.Append($"\n{problems} problem(s)."); + if (problems > 0) Debug.LogWarning(log.ToString()); + else Debug.Log(log.ToString()); + } +} diff --git a/Assets/Editor/Module3PlaceholderPrefabs.cs.meta b/Assets/Editor/Module3PlaceholderPrefabs.cs.meta new file mode 100644 index 00000000..5d72ce2f --- /dev/null +++ b/Assets/Editor/Module3PlaceholderPrefabs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f62ad0bd07e6d9aa5378d679a94bc54a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Documentation/Module3-Placeholder-Assets.md b/Documentation/Module3-Placeholder-Assets.md new file mode 100644 index 00000000..46c33418 --- /dev/null +++ b/Documentation/Module3-Placeholder-Assets.md @@ -0,0 +1,216 @@ +# Module 3 — greybox placeholder assets + +Implements the **Unblocking pattern** from `Module3-Roadmap.md` §4: + +> Commit a greybox placeholder prefab at the final path and name for every asset above, on day one +> of Phase 0. Code binds to the path and the sockets; art replaces the mesh inside the prefab later. +> Nothing in Phases 1–5 ever waits for a model, and the swap is a one-file change with no scene churn. + +These are **stand-ins**. The silhouettes are disposable. The **dimensions, origins, pivots and socket +names are the deliverable**, because code and scenes bind to them. + +Everything below was verified by importing into Unity 6000.3.8f1 and inspecting the built prefabs — +not asserted from the Blender side. That distinction mattered; see *Axis and unit traps*. + +--- + +## What exists + +| Prefab (bind to this) | Source mesh | Tris / budget | Size (m, Unity XYZ) | Sockets | +|---|---|---|---|---| +| `Prefabs/Furniture/PF_ElectricFan.prefab` | `SM_ElectricFan` | 664 / 3000 | 0.306 × 0.453 × 0.21 | `Socket_Mount` | +| `Prefabs/Buildings/PF_WindowScreen.prefab` | `SM_WindowScreen` | 60 / 500 | 1.5 × 1.0 × 0.04 | `Socket_Mount` | +| `Prefabs/Buildings/PF_WindowScreenTorn.prefab` | `SM_WindowScreenTorn` | 96 / 500 | 1.5 × 1.0 × 0.099 | `Socket_Mount` | +| `Prefabs/Props/PF_MosquitoNet_RolledUp.prefab` | `SM_MosquitoNet_RolledUp` | 224 / 1500 | 0.32 × 0.589 × 0.32 | `Socket_Mount`, `Socket_Hook` | +| `Prefabs/Props/PF_MosquitoNet_Deployed.prefab` | `SM_MosquitoNet` *(existing art)* | 298 | 1.56 × 1.383 × 1.766 | — | +| `Prefabs/Props/PF_RepellentBottle.prefab` | `SM_RepellentBottle` | 180 / 1500 | 0.071 × 0.191 × 0.06 | `Socket_Grip` | +| `Prefabs/Props/PF_DrinkingVessel.prefab` | `SM_DrinkingVessel` | 120 / 1500 | 0.084 × 0.11 × 0.084 | `Socket_Grip` | +| `Prefabs/Props/PF_Cloth.prefab` | `SM_Cloth` | 36 / 1500 | 0.4 × 0.06 × 0.26 | `Socket_Grip` | +| `Prefabs/Props/PF_Torch.prefab` | `SM_Torch` | 208 / 1500 | 0.064 × 0.171 × 0.066 | `Socket_Grip`, `Socket_Light` | +| `Prefabs/Buildings/PF_HealthCentre.prefab` | `SM_HealthCentre` | 84 / 2000 | 12.6 × 3.65 × 10.3 | `Socket_Entrance` | + +Paths are relative to `Assets/1.TeamWorkspace/Team Assets/`. Meshes live at +`Team Assets/Models//.fbx`, following the existing folder-per-model convention. + +All greybox assets share one material, `Team Assets/Materials/M_Module3Placeholder.mat` — flat grey +on the project's own `SHD_SimpleLit_Static` graph with no texture assigned, so a placeholder is +unmistakable while still rendering correctly in URP. + +Every asset maps to a Phase 1 player action, which is why this is the list and not a longer one: + +| Action | Asset | +|---|---| +| `SetFan(householdId)` | `PF_ElectricFan` | +| `RepairScreen(householdId)` | `PF_WindowScreenTorn` → `PF_WindowScreen` | +| `PutUpNet(personId)` | `PF_MosquitoNet_RolledUp` → `PF_MosquitoNet_Deployed` | +| `GiveRepellent(personId)` | `PF_RepellentBottle` | +| `BringWater(personId)` | `PF_DrinkingVessel` | +| `HelpRest(personId)` | `PF_Cloth` | +| `ReferToHealthCentre(personId)` | `PF_HealthCentre` *(provisional)* | +| night visits | `PF_Torch` | + +There is deliberately no container-clearing prop: §5 is explicit that clearing containers is +"not a button, not a task, not a score". + +--- + +## Dimensions measured from existing assets + +The screens and the net are not invented sizes. They were measured off the shipped art so Module 3 +fits the world it reuses (§11: "feel like the same world the student has already worked in"). + +| Source asset | Measurement | Used for | +|---|---|---| +| `SM_Windows/SM_WindowRight.fbx` + `SM_WindowLeft.fbx` | two 0.75 × 1.00 m leaves meeting on the centre line → **1.50 × 1.00 m opening**, reveal 0.1202 m deep | screen outer size; screen depth held to 0.04 m so it sits inside the reveal | +| `SM_MosquitoNet/SM_MosquitoNet.fbx` | dome 1.5596 × 1.7661 footprint, **1.391 m tall**, base on the ground plane, centred in plan | the rolled-up variant shares that origin and canopy height, so the two net prefabs swap at one transform | +| `SM_Bed/SM_Bed.fbx` | 1.5961 × 1.9368 × 1.1807 m | reference only — the net is sized off the net, not the bed | + +Re-measure anything at any time: + +```bash +blender -b --factory-startup --python Tools/Module3/measure_reference.py -- +``` + +--- + +## Conventions, and two judgement calls + +The §4 contract is met as written: FBX, metres, Y-up / −Z forward, transforms applied, one material +per prop, `SM_`/`PF_`/`M_` naming, sockets as empties, `.meta` files committed. Two points needed a +decision: + +**1. Origin at the base; the grip is a socket.** §4 says "origin at the floor contact point (props: +at the natural grip point)". These props both *sit* in the world and get *picked up*, and a grip +origin makes every placement float. So all props keep a base origin — they drop onto a surface at +y = 0 — and the grip is carried by `Socket_Grip`, which is what sockets are for. Code should attach +to the socket, so this stays true even if a prop is later hand-authored with a grip origin. + +**2. The deployed net reuses existing art.** Only the rolled-up state was genuinely missing, so only +it was modelled. `PF_MosquitoNet_Deployed` is a thin prefab over the shipped `SM_MosquitoNet` mesh +with its own materials intact, existing so the two states are addressed by symmetric paths. +**`PF_MosquitoNet` itself is untouched.** + +### Sockets + +| Socket | Meaning | +|---|---| +| `Socket_Mount` | where the object attaches to floor, bed or window | +| `Socket_Grip` | the hand | +| `Socket_Hook` | ceiling tie point of the rolled-up net | +| `Socket_Light` | torch beam origin — **its forward is the beam**, so a `Light` parented with identity rotation aims correctly (verified: forward = `(0, 1, 0)`) | +| `Socket_Entrance` | health-centre door — forward faces **out**, so a player arriving along it walks in (verified: forward = `(0, 0, 1)`) | + +A socket that encodes no direction is left at identity rotation. + +### The fan blade turns + +§6 makes the fan the realistic alternative where there is no screen, so the blade has to actually +move. Hierarchy is `Fan_Base → Fan_Head → Fan_Blade`, and `Fan_Blade` is a separate object whose +origin sits exactly on the spin axis at local `(0, 0.3, 0.035)` with identity rotation and unit +scale. **Animate its local Z.** The fan faces Unity +Z. + +--- + +## Regenerating + +```bash +# 1. meshes — writes Team Assets/Models//.fbx, enforces the §4 tri budgets +blender -b --factory-startup --python Tools/Module3/make_placeholders.py + +# 2. .meta files with stable GUIDs, plus the placeholder material +python3 Tools/Module3/make_unity_assets.py + +# 3. prefabs, in Unity: AEDES ▸ Module 3 ▸ Build Placeholder Prefabs +# check the contract: AEDES ▸ Module 3 ▸ Verify Placeholder Contract +``` + +Step 3 is `Assets/Editor/Module3PlaceholderPrefabs.cs`. It is idempotent — it overwrites the prefabs +in place, so prefab GUIDs and every scene reference to them survive a re-run. It reports, per prefab, +the triangle count against budget, the measured size against the expected size, and whether the +expected sockets actually survived the FBX import, because a silently-dropped socket would strand the +code that binds to it. + +Headless equivalent, useful from CI or a script: + +```bash +/Applications/Unity/Hub/Editor/6000.3.8f1/Unity.app/Contents/MacOS/Unity \ + -batchmode -nographics -quit -projectPath . \ + -executeMethod Module3PlaceholderPrefabs.Build -logFile /tmp/m3.log +``` + +Note that Unity batchmode exits 0 even when work failed — read the `[Module3]` block in the log +rather than trusting the exit code. + +GUIDs are `md5("aedes-m3-placeholder:" + asset path)`, so regenerating is idempotent and two people +generating independently get identical GUIDs. Blender is only needed to *regenerate*; the FBXes are +committed. + +--- + +## Axis and unit traps (read before touching the exporter) + +Four Unity round-trips were needed to get this right, and **a Blender round-trip cannot catch any of +it** — Blender reads its own conventions back consistently, so the FBXes looked perfect while being +wrong in Unity. Always verify in Unity. + +1. **`UnitScaleFactor`.** The repo's existing art declares `100.0` (centimetres). Exporting with + `apply_scale_options='FBX_SCALE_NONE'` declares `1.0`, and Unity then scales everything by 0.01 — + a 1.5 m screen arrives 15 mm wide. Fixed with **`apply_scale_options='FBX_SCALE_UNITS'`**, which + puts the unit scaling in the FBX header and leaves transforms in metres. + +2. **`bake_space_transform` mangles hierarchies.** It bakes the Z-up → Y-up conversion into vertex + data, which is what gives an identity root — but it applies the rotation inconsistently across a + parented hierarchy. Tested directly: `Fan_Blade` picked up a spurious 90° X rotation while its + sibling `Fan_Head` did not, inflating the fan from 0.306 × 0.453 × 0.21 to 0.306 × 0.503 × 0.531. + **Do not enable it on anything with children.** + +3. **So the conversion is done explicitly**, in `convert_to_yup()`: mesh data and local offsets are + rotated into Unity's axes in Blender, and the FBX is then exported with the exporter's own + conversion disabled (`axis_forward='-Y', axis_up='Z'`, which makes its conversion matrix the + identity). Meshes keep identity rotation; the rotation lives in the vertices. This is exact and + hierarchy-safe. + +4. **Unity still adds −90° X to the root node** of a Z-up-declared FBX. Since the data is already + converted, that rotation is a leftover, so the prefab builder sets the root back to identity — + matching the rest of the repo's art. The size assertion in `Verify` is the guard: if the importer + ever stops adding it, the reported bounds change and `Verify` says so. + +--- + +## Handing these to whoever makes the real models + +Replacing a placeholder is deliberately a one-file change: + +1. Model the real asset to the dimensions in the table above. +2. Export over `Team Assets/Models//.fbx`, keeping the **node names** + (`Fan_Base` / `Fan_Head` / `Fan_Blade`) and the **`Socket_*` empties** at the same points. +3. **Keep the existing `.fbx.meta`** — that is what preserves the GUID, and with it every reference. +4. Re-run *Build Placeholder Prefabs*, then assign the real material in place of + `M_Module3Placeholder`. + +No prefab path changes, no scene edits, no re-binding in code. + +The §4 routing still stands: the fan, the screens and the net variants must be modelled **by hand**, +because they have to fit or move. The bottle, vessel, cloth and torch are the ones that can be +AI-generated or bought, since they only sit on a surface — and each now has a placeholder giving the +exact size to hit. + +--- + +## Caveats + +- **Greybox, not art.** Primitive-built stand-ins. Do not ship them. +- **The health centre is provisional.** §13 item 4 has not resolved whether the player travels there; + referral is modelled as an off-screen outcome first. It is crude massing on purpose, so a travel + loop can be greyboxed the day that question is answered. It is the one asset whose dimensions carry + no authority. +- **Window fit is inferred from the shutter leaves**, not from a hole measured in the house mesh — + the house is a single mesh, so the opening cannot be isolated. The leaves occupy the same aperture + a screen would, so it is the right reference, but the first person to place `PF_WindowScreen` in a + real Lao house prefab should eyeball it and adjust `WINDOW_W` / `WINDOW_H` in + `make_placeholders.py` if it is off. +- **Screen depth** is 0.04 m against the 0.1202 m reveal; the torn variant's peeled flaps reach + 0.099 m, still inside it. +- **Nothing here is in a scene yet.** These are prefabs and meshes only, which is the point — §6.1 + of the roadmap asks for the M3 neighbourhood to be built from prefabs placed by a builder script + driven by a data asset, not by hand-dragging into a scene file. diff --git a/Tools/Module3/make_placeholders.py b/Tools/Module3/make_placeholders.py new file mode 100644 index 00000000..a3929f17 --- /dev/null +++ b/Tools/Module3/make_placeholders.py @@ -0,0 +1,504 @@ +#!/usr/bin/env blender --python +""" +Greybox placeholder models for Module 3 (AEDES). + +Implements the "Unblocking pattern" of Documentation/Module3-Roadmap.md Sec.4: +every genuinely-missing asset gets a placeholder mesh at its FINAL path and name, +with the FINAL socket transforms, so code can bind to paths now and art can swap +the mesh inside the prefab later with no scene churn. + +Run: + blender -b --factory-startup --python Tools/Module3/make_placeholders.py + +Everything here is deliberately primitive-built and parametric. These are +greybox stand-ins whose *dimensions and pivots* are the deliverable, not their +silhouettes. Numbers marked MEASURED were taken off the existing repo assets +with Tools/Module3/measure_reference.py and must not be changed casually -- +the screens have to fit the Lao house window openings and the net variants have +to swap 1:1 with PF_MosquitoNet. + +Prefab contract (roadmap Sec.4): + FBX, metres, Y-up / -Z forward, transforms applied, one material per prop, + sockets as empties named Socket_*, SM_ naming. +""" + +import bpy, bmesh, math, os, sys, json +from mathutils import Vector, Matrix + +# --------------------------------------------------------------------------- +# Reference dimensions MEASURED from existing repo assets +# --------------------------------------------------------------------------- +# SM_Windows/SM_WindowRight.fbx + SM_WindowLeft.fbx: two 0.75 x 1.00 m leaves +# meeting on the centre line, frame reveal 0.1202 m deep. +WINDOW_W = 1.50 # MEASURED full opening width +WINDOW_H = 1.00 # MEASURED full opening height +WINDOW_REVEAL = 0.12 # MEASURED reveal depth the screen must sit inside + +# SM_MosquitoNet/SM_MosquitoNet.fbx: dome 1.5596 x 1.7661 footprint, 1.391 tall, +# base on z=0, origin centred in plan. The rolled-up variant must share that +# origin so the two prefabs are drop-in swaps at one transform. +NET_TOP = 1.39 # MEASURED deployed canopy height + +# SM_Bed/SM_Bed.fbx: 1.5961 x 1.9368 x 1.1807 -- kept for reference; the net +# is sized off the net, not the bed. +BED_W, BED_L = 1.60, 1.94 + +TRI_BUDGET = { # roadmap Sec.4 "Budget" + "SM_ElectricFan": 3000, + "SM_WindowScreen": 500, + "SM_WindowScreenTorn": 500, + "SM_MosquitoNet_RolledUp": 1500, + "SM_RepellentBottle": 1500, + "SM_DrinkingVessel": 1500, + "SM_Cloth": 1500, + "SM_Torch": 1500, + # Not in the contract table (it is a building, and Sec.13 item 4 has not + # resolved whether it is even needed). Kept crude on purpose. + "SM_HealthCentre": 2000, +} + +MATERIAL = "M_Module3Placeholder" + +# --------------------------------------------------------------------------- +# Small primitive helpers. All build in Blender's Z-up metres; the FBX export +# converts to Y-up / -Z forward at the end. +# --------------------------------------------------------------------------- + +def reset(): + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.context.scene.unit_settings.system = 'METRIC' + bpy.context.scene.unit_settings.scale_length = 1.0 + +def _added(): + return bpy.context.active_object + +def box(size, at=(0, 0, 0), rot=(0, 0, 0)): + bpy.ops.mesh.primitive_cube_add(size=1, location=at, rotation=rot) + o = _added() + o.scale = size + return o + +def cyl(r, h, at=(0, 0, 0), seg=16, rot=(0, 0, 0)): + bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=h, vertices=seg, + location=at, rotation=rot) + return _added() + +def frustum(r1, r2, h, at=(0, 0, 0), seg=16, rot=(0, 0, 0)): + bpy.ops.mesh.primitive_cone_add(radius1=r1, radius2=r2, depth=h, + vertices=seg, location=at, rotation=rot) + return _added() + +def ring(major, minor, at=(0, 0, 0), rot=(0, 0, 0), mseg=16, nseg=6): + bpy.ops.mesh.primitive_torus_add(major_radius=major, minor_radius=minor, + major_segments=mseg, minor_segments=nseg, + location=at, rotation=rot) + return _added() + +def join(parts, name): + """Join parts into one mesh object, apply all transforms, name it.""" + bpy.ops.object.select_all(action='DESELECT') + for p in parts: + p.select_set(True) + bpy.context.view_layer.objects.active = parts[0] + if len(parts) > 1: + bpy.ops.object.join() + o = bpy.context.active_object + # "all transforms applied" -- bake rotation/scale/location into the mesh. + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + o.name = name + o.data.name = name + return o + +def shade_flat(o): + for p in o.data.polygons: + p.use_smooth = False + +def material(o): + """One material per prop (roadmap Sec.4).""" + m = bpy.data.materials.get(MATERIAL) or bpy.data.materials.new(MATERIAL) + m.diffuse_color = (0.62, 0.62, 0.60, 1.0) + o.data.materials.clear() + o.data.materials.append(m) + +def socket(name, at=(0, 0, 0), facing=None, parent=None): + """An empty marking an attachment point. The local offset IS the data. + + `facing` is a direction in this script's authoring frame (Z-up). The socket + is rotated so its local +Z points that way, because after the Y-up + conversion below, local +Z becomes Unity's forward. So a Light or a player + spawn parented to the socket with identity rotation faces the right way. + Sockets with no facing keep identity rotation. + """ + bpy.ops.object.empty_add(type='PLAIN_AXES', radius=0.05, location=at) + e = _added() + e.name = name + if facing is not None: + f = Vector(facing).normalized() + e.rotation_euler = f.to_track_quat('Z', 'Y').to_euler() + e["m3_facing"] = list(f) # re-derived by convert_to_yup() + if parent: + e.parent = parent + # Parents here sit at the origin with identity rotation, so the parent + # inverse is identity; being explicit keeps the local offset readable. + e.matrix_parent_inverse = Matrix.Identity(4) + return e + + +# --------------------------------------------------------------------------- +# Z-up (Blender) -> Y-up (Unity) conversion, done explicitly. +# +# The obvious route -- let the FBX exporter convert via axis_up='Y' -- does not +# work cleanly here: +# * bake_space_transform=False leaves the conversion as a -90deg X rotation on +# the prefab ROOT. The prefab looks upright, but "all transforms applied" +# (contract, Sec.4) is false, and anything that zeroes, re-parents or drives +# the root rotation tips the object over. +# * bake_space_transform=True bakes it into vertex data but mangles PARENTED +# hierarchies -- it displaced Fan_Blade and inflated the fan's bounds from +# 0.306 x 0.453 x 0.21 to 0.306 x 0.503 x 0.531. +# +# So the conversion is applied here instead, and the FBX is then exported with +# the conversion disabled. Every object ends up with identity rotation, unit +# scale, and mesh data already in Unity's axes. Because each parent sits at the +# origin with identity rotation, a child's local offset lives in the same frame +# as its parent's, so rotating locations and mesh data by R is exact. +# --------------------------------------------------------------------------- + +YUP = Matrix.Rotation(math.radians(-90), 4, 'X') # Blender +Z up -> Unity +Y up + + +def convert_to_yup(): + """Rotate every object's mesh data and local offset into Unity's axes. + + Meshes keep identity rotation: the rotation goes into the vertices. A socket + that encodes a direction has its facing re-derived in the new frame; a socket + that does not stays identity, so its axes line up with its parent's. + """ + for o in bpy.data.objects: + if o.type == 'MESH': + o.data.transform(YUP) + o.location = YUP @ Vector(o.location) + if o.type == 'EMPTY': + f = o.get("m3_facing") + o.rotation_euler = ((YUP @ Vector(f)).to_track_quat('Z', 'Y').to_euler() + if f else (0.0, 0.0, 0.0)) + bpy.context.view_layer.update() + +def tris(o): + return sum(len(p.vertices) - 2 for p in o.data.polygons) + +# --------------------------------------------------------------------------- +# Assets +# --------------------------------------------------------------------------- + +def electric_fan(): + """Table/floor fan. Sec.6 calls this out as the realistic alternative where + there is no screen, so the blade has to actually turn: Fan_Blade is a + separate object whose origin sits exactly on the spin axis. + + Spin axis: Blender +Y, which the FBX axis conversion turns into Unity +Z. + So in Unity the animator spins Fan_Blade around its LOCAL Z. + """ + parts = [] + parts.append(cyl(0.105, 0.030, at=(0, 0, 0.015), seg=16)) # base disc + parts.append(cyl(0.022, 0.200, at=(0, 0, 0.130), seg=12)) # column + parts.append(box((0.055, 0.055, 0.045), at=(0, 0, 0.250))) # yoke + base = join(parts, "Fan_Base") + shade_flat(base); material(base) + + HUB_Z = 0.300 # height of the spin axis + parts = [] + # motor housing, behind the blade (+Y is "back", the fan faces -Y) + parts.append(cyl(0.055, 0.090, at=(0, 0.045, HUB_Z), seg=12, + rot=(math.radians(90), 0, 0))) + # guard: two rings either side of the blade plane + parts.append(ring(0.145, 0.008, at=(0, -0.075, HUB_Z), + rot=(math.radians(90), 0, 0), mseg=16, nseg=6)) + parts.append(ring(0.145, 0.008, at=(0, 0.005, HUB_Z), + rot=(math.radians(90), 0, 0), mseg=16, nseg=6)) + for i in range(4): # spokes + a = math.radians(45 + i * 90) + parts.append(box((0.010, 0.008, 0.290), at=(0, -0.035, HUB_Z), + rot=(0, a, 0))) + head = join(parts, "Fan_Head") + shade_flat(head); material(head) + + # Blade: authored around the world origin so its own origin is the pivot, + # then offset by location only. Rotation and scale stay identity. + parts = [] + parts.append(cyl(0.022, 0.030, at=(0, 0, 0), seg=10, + rot=(math.radians(90), 0, 0))) # hub + for i in range(3): + a = math.radians(i * 120) + b = box((0.115, 0.006, 0.055), at=(0, 0, 0)) + # push outward along local X, then twist for pitch, then space 120 deg + b.location = (0.075 * math.cos(a), 0.0, 0.075 * math.sin(a)) + b.rotation_euler = (0, -a + math.radians(20), 0) + # re-place after rotation so the blade root meets the hub + b.location = (0.075 * math.cos(a), 0.0, 0.075 * math.sin(a)) + parts.append(b) + blade = join(parts, "Fan_Blade") + shade_flat(blade); material(blade) + + blade.parent = head + blade.location = (0, -0.035, HUB_Z) # pivot on the spin axis + head.parent = base + + socket("Socket_Mount", at=(0, 0, 0), parent=base) + return base, [base, head, blade] + + +def window_screen(torn=False): + """Insect screen for the existing Lao house window opening. + + Origin: bottom-centre of the opening, so it drops onto a sill centre line. + Depth is kept inside the MEASURED 0.12 m reveal. + """ + fw, d = 0.035, 0.040 # frame section, total depth + W, H = WINDOW_W, WINDOW_H + assert d <= WINDOW_REVEAL, "screen deeper than the measured window reveal" + + parts = [] + parts.append(box((W, d, fw), at=(0, 0, fw / 2))) # bottom rail + parts.append(box((W, d, fw), at=(0, 0, H - fw / 2))) # top rail + parts.append(box((fw, d, H - 2 * fw), at=(-(W - fw) / 2, 0, H / 2))) # stile + parts.append(box((fw, d, H - 2 * fw), at=((W - fw) / 2, 0, H / 2))) # stile + + iw, ih = W - 2 * fw, H - 2 * fw # inner opening the mesh spans + if not torn: + parts.append(box((iw, 0.006, ih), at=(0, 0, H / 2))) # intact mesh + name = "SM_WindowScreen" + else: + # A tear in the lower outboard corner: the mesh survives as an L, with + # two flaps peeled into the hole. This is the variant the player repairs. + hole_w, hole_h = iw * 0.42, ih * 0.38 + parts.append(box((iw, 0.006, ih - hole_h), + at=(0, 0, H / 2 + hole_h / 2))) # upper band + parts.append(box((iw - hole_w, 0.006, hole_h), + at=(-hole_w / 2, 0, H / 2 - (ih - hole_h) / 2))) # lower-left + parts.append(box((hole_w * 0.55, 0.005, hole_h * 0.5), + at=(iw / 2 - hole_w * 0.30, -0.012, + H / 2 - (ih - hole_h) / 2 + hole_h * 0.22), + rot=(math.radians(-18), 0, math.radians(7)))) # flap + parts.append(box((hole_w * 0.40, 0.005, hole_h * 0.36), + at=(iw / 2 - hole_w * 0.62, -0.008, + H / 2 - (ih - hole_h) / 2 - hole_h * 0.18), + rot=(math.radians(14), 0, math.radians(-10)))) # flap + name = "SM_WindowScreenTorn" + + o = join(parts, name) + shade_flat(o); material(o) + socket("Socket_Mount", at=(0, 0, 0), parent=o) + return o, [o] + + +def mosquito_net_rolled_up(): + """The "up" state of PF_MosquitoNet (roadmap Sec.4). + + The deployed state reuses the existing SM_MosquitoNet -- no new mesh for + that. This is the gathered bundle tied up out of the way. Origin sits on + z=0 like the deployed net so the two prefabs swap at one transform. + """ + parts = [] + parts.append(frustum(0.10, 0.16, 0.48, at=(0, 0, 1.06), seg=12)) # bundle + parts.append(ring(0.10, 0.022, at=(0, 0, 0.82), mseg=12, nseg=6)) # hem + parts.append(cyl(0.060, 0.090, at=(0, 0, NET_TOP - 0.045), seg=10)) # gather + o = join(parts, "SM_MosquitoNet_RolledUp") + shade_flat(o); material(o) + socket("Socket_Mount", at=(0, 0, 0), parent=o) + socket("Socket_Hook", at=(0, 0, NET_TOP), parent=o) + return o, [o] + + +def repellent_bottle(): + parts = [] + parts.append(cyl(0.030, 0.115, at=(0, 0, 0.0575), seg=14)) + parts.append(frustum(0.030, 0.018, 0.030, at=(0, 0, 0.130), seg=14)) + parts.append(cyl(0.012, 0.016, at=(0, 0, 0.153), seg=10)) + parts.append(box((0.050, 0.032, 0.030), at=(-0.010, 0, 0.176))) # head + parts.append(cyl(0.006, 0.020, at=(-0.032, 0, 0.180), seg=8, + rot=(0, math.radians(90), 0))) # nozzle + o = join(parts, "SM_RepellentBottle") + shade_flat(o); material(o) + socket("Socket_Grip", at=(0, 0, 0.075), parent=o) + return o, [o] + + +def drinking_vessel(): + """Sec.4 "drinking vessel / water bottle" -- for the "bring water" action.""" + parts = [] + parts.append(frustum(0.032, 0.042, 0.110, at=(0, 0, 0.055), seg=16)) + parts.append(cyl(0.038, 0.004, at=(0, 0, 0.100), seg=16)) # water surface + o = join(parts, "SM_DrinkingVessel") + shade_flat(o); material(o) + socket("Socket_Grip", at=(0, 0, 0.060), parent=o) + return o, [o] + + +def cloth(): + """Folded cloth/towel -- one of the Sec.4 props that dresses an unwell + villager without flagging them (Sec.5 framing rule).""" + parts = [] + parts.append(box((0.400, 0.260, 0.022), at=(0.000, 0, 0.011))) + parts.append(box((0.385, 0.245, 0.020), at=(0.006, 0, 0.032))) + parts.append(box((0.370, 0.230, 0.018), at=(-0.005, 0, 0.051))) + o = join(parts, "SM_Cloth") + shade_flat(o); material(o) + socket("Socket_Grip", at=(0, 0, 0.060), parent=o) + return o, [o] + + +def torch(): + """Hand torch. Stands upright so it can sit on a shelf; the hand uses + Socket_Grip. Socket_Light sits just outside the lens and its local -Y + points along the beam, which the axis conversion turns into Unity +Z -- + so a Light parented to it with identity rotation shines down the beam. + """ + parts = [] + parts.append(cyl(0.021, 0.130, at=(0, 0, 0.065), seg=14)) # body + parts.append(cyl(0.023, 0.008, at=(0, 0, 0.004), seg=14)) # tail cap + parts.append(frustum(0.021, 0.033, 0.035, at=(0, 0, 0.1475), seg=14)) + parts.append(cyl(0.031, 0.006, at=(0, 0, 0.168), seg=14)) # lens + o = join(parts, "SM_Torch") + shade_flat(o); material(o) + socket("Socket_Grip", at=(0, 0, 0.065), parent=o) + # +Z here is up, which is where an upright torch points its beam. + socket("Socket_Light", at=(0, 0, 0.176), facing=(0, 0, 1), parent=o) + return o, [o] + + +def health_centre(): + """PROVISIONAL massing only. Sec.13 item 4 has not resolved whether the + player travels to the health centre; referral is modelled as an off-screen + outcome first. This exists so a travel loop can be greyboxed the day that + question is answered, and is scheduled last on purpose. + """ + parts = [] + parts.append(box((12.0, 8.0, 3.40), at=(0, 0, 1.70))) # block + parts.append(box((12.6, 8.6, 0.25), at=(0, 0, 3.525))) # roof + parts.append(box((4.0, 2.0, 0.18), at=(0, -5.0, 2.70))) # canopy + parts.append(box((0.16, 0.16, 2.70), at=(-1.8, -5.9, 1.35))) # post + parts.append(box((0.16, 0.16, 2.70), at=(1.8, -5.9, 1.35))) # post + parts.append(box((1.60, 0.15, 2.20), at=(0, -4.02, 1.10))) # door + parts.append(box((2.40, 0.10, 0.70), at=(0, -4.06, 2.95))) # sign + o = join(parts, "SM_HealthCentre") + shade_flat(o); material(o) + # The porch is on -Y, so the entrance faces -Y: a player arriving along the + # socket's forward walks in through the door. + socket("Socket_Entrance", at=(0, -5.6, 0), facing=(0, -1, 0), parent=o) + return o, [o] + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- + +ASSETS = [ + ("SM_ElectricFan", electric_fan), + ("SM_WindowScreen", lambda: window_screen(False)), + ("SM_WindowScreenTorn", lambda: window_screen(True)), + ("SM_MosquitoNet_RolledUp", mosquito_net_rolled_up), + ("SM_RepellentBottle", repellent_bottle), + ("SM_DrinkingVessel", drinking_vessel), + ("SM_Cloth", cloth), + ("SM_Torch", torch), + ("SM_HealthCentre", health_centre), +] + + +def export(root, name, out_dir): + """One FBX per asset, into Team Assets/Models//.fbx, matching + the existing folder-per-model convention.""" + d = os.path.join(out_dir, name) + os.makedirs(d, exist_ok=True) + path = os.path.join(d, name + ".fbx") + + bpy.ops.object.select_all(action='DESELECT') + def sel(o): + o.select_set(True) + for c in o.children: + sel(c) + sel(root) + bpy.context.view_layer.objects.active = root + + bpy.ops.export_scene.fbx( + filepath=path, + use_selection=True, + apply_unit_scale=True, + # FBX_SCALE_UNITS puts the metre->centimetre unit scaling into the FBX + # header's UnitScaleFactor (100.0) and leaves object transforms in + # metres. This is what the repo's existing art declares, and it is what + # Unity's importer expects with useFileUnits/useFileScale enabled. + # With FBX_SCALE_NONE the header says 1.0 and Unity scales everything + # down by 100x -- and a Blender round-trip will NOT catch it, because + # Blender reads its own convention back consistently. Verify in Unity. + apply_scale_options='FBX_SCALE_UNITS', + global_scale=1.0, + # The data is ALREADY in Unity's axes (see convert_to_yup), so the + # exporter must not convert again: passing Blender's own native axes + # makes its conversion matrix the identity. The FBX still satisfies the + # contract's "Y-up / -Z forward" -- that is what the geometry now is. + axis_forward='-Y', + axis_up='Z', + object_types={'EMPTY', 'MESH'}, + use_mesh_modifiers=True, + mesh_smooth_type='FACE', + bake_space_transform=False, + add_leaf_bones=False, + path_mode='AUTO', + ) + return path + + +def main(): + argv = sys.argv + out_dir = None + if "--out" in argv: + out_dir = argv[argv.index("--out") + 1] + if not out_dir: + here = os.path.dirname(os.path.abspath(__file__)) + repo = os.path.dirname(os.path.dirname(here)) + out_dir = os.path.join(repo, "Assets", "1.TeamWorkspace", + "Team Assets", "Models") + report, failures = [], [] + + for name, build in ASSETS: + reset() + root, meshes = build() + convert_to_yup() + t = sum(tris(m) for m in meshes) + budget = TRI_BUDGET[name] + ok = t <= budget + if not ok: + failures.append(f"{name}: {t} tris over budget {budget}") + # bounds, world space, across the mesh parts + pts = [] + for m in meshes: + pts += [m.matrix_world @ Vector(c) for c in m.bound_box] + mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + socks = sorted(o.name for o in bpy.data.objects if o.type == 'EMPTY') + path = export(root, name, out_dir) + report.append({ + "asset": name, + "tris": t, "budget": budget, "within_budget": ok, + "size_m_unity_yup": [round(v, 4) for v in (mx - mn)], + "origin_offset": [round(v, 4) for v in mn], + "parts": [m.name for m in meshes], + "sockets": socks, + "fbx": os.path.relpath(path, os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(path)))))), + }) + + print("===REPORT===") + print(json.dumps(report, indent=1)) + if failures: + print("===BUDGET FAILURES===") + for f in failures: + print(" ", f) + sys.exit(1) + print("===OK=== %d assets" % len(report)) + + +if __name__ == "__main__": + main() diff --git a/Tools/Module3/make_unity_assets.py b/Tools/Module3/make_unity_assets.py new file mode 100644 index 00000000..714ab0a3 --- /dev/null +++ b/Tools/Module3/make_unity_assets.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +Unity-side companions for the Module 3 greybox placeholders: + - a .meta per generated FBX and per model folder, so the assets arrive with + stable GUIDs instead of whatever the first person to open Unity gets + (roadmap Sec.4: "commit with .meta files"; CLAUDE.md Sec.9: a missing .meta + breaks every reference to that asset for everyone else) + - M_Module3Placeholder.mat, one flat grey URP material on the project's own + SHD_SimpleLit_Static graph + +Run after make_placeholders.py: + python3 Tools/Module3/make_unity_assets.py + +GUIDs are md5("aedes-m3-placeholder:" + unity asset path), so re-running is +idempotent and two people generating these independently get the same GUIDs. + +The FBX importer settings are cloned from an existing repo model +(SM_Bed.fbx.meta) so the placeholders import exactly like the art around them, +with one deliberate change: materialImportMode is set to 0 (None). The +placeholders carry no embedded materials; the prefab builder assigns +M_Module3Placeholder explicitly, so the material is a real, editable asset on +the project's own shader rather than an FBX-embedded Standard-shader material. +""" + +import hashlib, os, re, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(os.path.dirname(HERE)) +ASSETS = os.path.join(REPO, "Assets") +TEAM = os.path.join(ASSETS, "1.TeamWorkspace", "Team Assets") +MODELS = os.path.join(TEAM, "Models") +MATERIALS = os.path.join(TEAM, "Materials") +TEMPLATE_META = os.path.join(MODELS, "SM_Bed", "SM_Bed.fbx.meta") + +# Existing project assets the material is built from. +SHADER_GUID = "850a53dfcf2e85a4d8365db9ad81bf96" # SHD_SimpleLit_Static +SHADER_FILEID = "-6465566751694194690" +URP_ASSETVERSION_GUID = "d0353a89b1f911e48b9e16bdc9f2e058" + +PLACEHOLDERS = [ + "SM_ElectricFan", + "SM_WindowScreen", + "SM_WindowScreenTorn", + "SM_MosquitoNet_RolledUp", + "SM_RepellentBottle", + "SM_DrinkingVessel", + "SM_Cloth", + "SM_Torch", + "SM_HealthCentre", +] + +MATERIAL_NAME = "M_Module3Placeholder" +GREY = (0.62, 0.62, 0.60) + + +def unity_path(abs_path): + return "Assets/" + os.path.relpath(abs_path, ASSETS).replace(os.sep, "/") + + +def guid_for(abs_path): + h = hashlib.md5(("aedes-m3-placeholder:" + unity_path(abs_path)).encode()) + return h.hexdigest() + + +def write(path, text): + existing = None + if os.path.exists(path): + with open(path) as f: + existing = f.read() + if existing == text: + return "unchanged" + with open(path, "w") as f: + f.write(text) + return "written" if existing is None else "updated" + + +FOLDER_META = """fileFormatVersion: 2 +guid: {guid} +folderAsset: yes +DefaultImporter: + externalObjects: {{}} + userData: + assetBundleName: + assetBundleVariant: +""" + + +def fbx_meta(template, guid): + out = re.sub(r"^guid: .*$", "guid: " + guid, template, count=1, flags=re.M) + # No embedded materials -- the prefab assigns M_Module3Placeholder. + out = re.sub(r"^(\s*)materialImportMode: .*$", r"\1materialImportMode: 0", + out, count=1, flags=re.M) + return out + + +MATERIAL_TEMPLATE = """%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {{fileID: 0}} + m_PrefabInstance: {{fileID: 0}} + m_PrefabAsset: {{fileID: 0}} + m_Name: {name} + m_Shader: {{fileID: {shader_fileid}, guid: {shader_guid}, type: 3}} + m_Parent: {{fileID: 0}} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: {{}} + disabledShaderPasses: + - MOTIONVECTORS + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {{fileID: 0}} + m_Scale: {{x: 1, y: 1}} + m_Offset: {{x: 0, y: 0}} + - _BumpMap: + m_Texture: {{fileID: 0}} + m_Scale: {{x: 1, y: 1}} + m_Offset: {{x: 0, y: 0}} + - _EmissionMap: + m_Texture: {{fileID: 0}} + m_Scale: {{x: 1, y: 1}} + m_Offset: {{x: 0, y: 0}} + - _MainTex: + m_Texture: {{fileID: 0}} + m_Scale: {{x: 1, y: 1}} + m_Offset: {{x: 0, y: 0}} + - _Texture_01: + m_Texture: {{fileID: 0}} + m_Scale: {{x: 1, y: 1}} + m_Offset: {{x: 0, y: 0}} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _Additionnal_Color: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _Dissolve: 0 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _EnvironmentReflections: 1 + - _Fade: 0 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Highlight: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueControl: 0 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _ShadowLimit: -0.14 + - _Smoothness: 0.5 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _Texture: 0 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {{r: {r}, g: {g}, b: {b}, a: 1}} + - _Color: {{r: {r}, g: {g}, b: {b}, a: 1}} + - _Color1: {{r: 0, g: 0, b: 0, a: 0}} + - _EmissionColor: {{r: 0, g: 0, b: 0, a: 1}} + - _Highlight_Color: {{r: 1, g: 1, b: 1, a: 1}} + - _Multiply_Color: {{r: 1, g: 1, b: 1, a: 1}} + - _Shadow_Color: {{r: 0.75, g: 0.75, b: 0.78, a: 0}} + - _SpecColor: {{r: 0.2, g: 0.2, b: 0.2, a: 1}} + - _UV_Tiling: {{r: 1, g: 1, b: 0, a: 0}} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &6168096758479283809 +MonoBehaviour: + m_ObjectHideFlags: 11 + m_CorrespondingSourceObject: {{fileID: 0}} + m_PrefabInstance: {{fileID: 0}} + m_PrefabAsset: {{fileID: 0}} + m_GameObject: {{fileID: 0}} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {{fileID: 11500000, guid: {urp_version_guid}, type: 3}} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 +""" + +ASSET_META = """fileFormatVersion: 2 +guid: {guid} +NativeFormatImporter: + externalObjects: {{}} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: +""" + + +def main(): + if not os.path.exists(TEMPLATE_META): + sys.exit("missing importer template: " + TEMPLATE_META) + with open(TEMPLATE_META) as f: + template = f.read() + + results = [] + missing = [] + + for name in PLACEHOLDERS: + folder = os.path.join(MODELS, name) + fbx = os.path.join(folder, name + ".fbx") + if not os.path.exists(fbx): + missing.append(unity_path(fbx)) + continue + # folder .meta + fm = folder + ".meta" + results.append((unity_path(folder), + write(fm, FOLDER_META.format(guid=guid_for(folder))))) + # fbx .meta + results.append((unity_path(fbx), + write(fbx + ".meta", fbx_meta(template, guid_for(fbx))))) + + # placeholder material + os.makedirs(MATERIALS, exist_ok=True) + mat = os.path.join(MATERIALS, MATERIAL_NAME + ".mat") + r, g, b = GREY + results.append((unity_path(mat), write(mat, MATERIAL_TEMPLATE.format( + name=MATERIAL_NAME, shader_fileid=SHADER_FILEID, + shader_guid=SHADER_GUID, urp_version_guid=URP_ASSETVERSION_GUID, + r=r, g=g, b=b)))) + results.append((unity_path(mat) + ".meta", + write(mat + ".meta", + ASSET_META.format(guid=guid_for(mat))))) + + w = max(len(p) for p, _ in results) + for p, s in results: + print(f" {s:<9} {p}") + print(f"\n{len(results)} files; material GUID {guid_for(mat)}") + if missing: + print("\nMissing FBX (run make_placeholders.py first):") + for m in missing: + print(" ", m) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/Tools/Module3/measure_reference.py b/Tools/Module3/measure_reference.py new file mode 100644 index 00000000..75486df1 --- /dev/null +++ b/Tools/Module3/measure_reference.py @@ -0,0 +1,77 @@ +#!/usr/bin/env blender --python +""" +Measure existing repo art, so placeholder dimensions are derived from the real +assets rather than invented. + + blender -b --factory-startup --python Tools/Module3/measure_reference.py -- [b.fbx ...] + +Prints, per file, each node's world-space size and bounds, its triangle count, +and the file's overall bounding box. Sizes come back in Blender's Z-up metres: +"height" is the third component. These numbers are the source of the MEASURED +constants at the top of make_placeholders.py. +""" + +import bpy, sys, os, json +from mathutils import Vector + + +def bounds(objs): + pts = [] + for o in objs: + if o.type == 'MESH': + pts += [o.matrix_world @ Vector(c) for c in o.bound_box] + if not pts: + return None, None + mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + return mn, mx + + +def main(): + if "--" not in sys.argv: + sys.exit("pass FBX paths after --") + paths = sys.argv[sys.argv.index("--") + 1:] + + out = {} + for p in paths: + bpy.ops.wm.read_factory_settings(use_empty=True) + try: + # Blender 5.x ships the newer importer; older builds only have the + # Python one. + if hasattr(bpy.ops.wm, "fbx_import"): + bpy.ops.wm.fbx_import(filepath=p) + else: + bpy.ops.import_scene.fbx(filepath=p) + except Exception as e: + out[os.path.basename(p)] = {"IMPORT_FAILED": str(e)} + continue + + objs = list(bpy.data.objects) + info = {"nodes": []} + for o in objs: + rec = {"name": o.name, "type": o.type, + "parent": o.parent.name if o.parent else None, + "local_loc": [round(v, 4) for v in o.location], + "scale": [round(v, 4) for v in o.scale]} + if o.type == 'MESH': + mn, mx = bounds([o]) + rec["size"] = [round(v, 4) for v in (mx - mn)] + rec["min"] = [round(v, 4) for v in mn] + rec["max"] = [round(v, 4) for v in mx] + rec["tris"] = sum(len(pg.vertices) - 2 for pg in o.data.polygons) + rec["materials"] = [m.name for m in o.data.materials if m] + info["nodes"].append(rec) + + mn, mx = bounds(objs) + if mn: + info["total_size"] = [round(v, 4) for v in (mx - mn)] + info["total_min"] = [round(v, 4) for v in mn] + info["total_max"] = [round(v, 4) for v in mx] + out[os.path.basename(p)] = info + + print("===MEASURED===") + print(json.dumps(out, indent=1)) + + +if __name__ == "__main__": + main() From 7c254d46fc38e3c6df91494328a505887d7f3970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 25 Sep 2026 22:29:53 +0700 Subject: [PATCH 13/61] Module 3: bind the household and villager views to the placeholder props Phase 3's view layer, now that the greybox props are in. - Module3Props is the single place the prefab paths and socket names live, so replacing a greybox mesh with real art stays a non-event. It sits in the Sim assembly because it is pure string constants and because an asmdef cannot reference Assembly-CSharp - the dependency only runs the other way - so the tests could not otherwise see it. - HouseholdView holds no state of its own: a house is whatever the model says it is when you walk in, which is what makes a return visit worth making (section 8). Screens swap torn to intact, the net swaps rolled-up to deployed at one transform, the fan appears and its Fan_Blade turns. - VillagerView shows the three visible states through pose, a skin material swap and props rather than three meshes. Section 5 constrains this class specifically: no icon, no outline, no colour coding, nothing overhead. A sick villager looks tired, the way a person does. If anything here reads as 'warning: infected', it is a bug. PlaceholderAssetTests is the part worth keeping: if a prefab path or socket name moves, nothing fails at compile time and nothing fails in play mode either - a prop just quietly stops appearing. These assert the paths resolve, the fan blade is a separate object with its own mesh, both net states and both screen states swap at one transform, every handheld prop carries Socket_Grip, and nothing is over budget. 77 tests passing. --- .../Module3/Scripts/HouseholdView.cs | 168 ++++++++++++++++++ .../Module3/Scripts/HouseholdView.cs.meta | 11 ++ .../Module3/Scripts/VillagerView.cs | 105 +++++++++++ .../Module3/Scripts/VillagerView.cs.meta | 11 ++ .../Module3/Sim/Module3Props.cs | 71 ++++++++ .../Module3/Sim/Module3Props.cs.meta | 11 ++ .../Tests/AEDES.Module3.Sim.Tests.asmdef | 2 +- .../Module3/Tests/PlaceholderAssetTests.cs | 136 ++++++++++++++ .../Tests/PlaceholderAssetTests.cs.meta | 11 ++ 9 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Module3Props.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/Module3Props.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/PlaceholderAssetTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/PlaceholderAssetTests.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs new file mode 100644 index 00000000..6c1fd409 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs @@ -0,0 +1,168 @@ +using Aedes.Module3.Sim; +using UnityEngine; + +/// +/// Shows one household as the simulation currently has it, and re-reads on every turn start. +/// +/// Section 8: "Every handover should show consequence. The point of passing the headset is that +/// the world is different when you get it back." So this holds no state of its own - a house is +/// whatever the model says it is at the moment you walk in, which is what makes a return visit +/// worth making. +/// +public class HouseholdView : MonoBehaviour +{ + [Tooltip("Which plot in the neighbourhood this is.")] + public int householdId; + + [Header("Where things go")] + [Tooltip("Window opening the screen mounts into. Uses the prefab's Socket_Mount.")] + [SerializeField] private Transform screenMount; + [SerializeField] private Transform fanStand; + [SerializeField] private Transform bedNetAnchor; + + [Header("Prefabs (leave empty to load from Module3Props)")] + [SerializeField] private GameObject screenIntactPrefab; + [SerializeField] private GameObject screenTornPrefab; + [SerializeField] private GameObject fanPrefab; + [SerializeField] private GameObject netDeployedPrefab; + [SerializeField] private GameObject netRolledUpPrefab; + + private GameObject screenInstance, fanInstance, netInstance; + private ScreenState shownScreens = (ScreenState)(-1); + private bool shownFan, shownNet; + private bool netShownAtAll; + + private void OnEnable() + { + if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted += OnTurnStarted; + Refresh(); + } + + private void OnDisable() + { + if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted -= OnTurnStarted; + } + + private void OnTurnStarted(int turnIndex) => Refresh(); + + /// Called after any action so the room changes under the player's hands. + public void Refresh() + { + var sim = M3Session.Instance != null ? M3Session.Instance.Neighbourhood : null; + if (sim == null) return; + + var h = sim.HouseholdById(householdId); + if (h == null) return; + + ShowScreens(h.Screens); + ShowFan(h.HasFan); + ShowNet(AnyoneHereHasANet(sim, h)); + } + + private static bool AnyoneHereHasANet(Neighbourhood sim, Household h) + { + for (int i = 0; i < h.ResidentIds.Count; i++) + { + var p = sim.PersonById(h.ResidentIds[i]); + if (p != null && p.HasNet) return true; + } + return false; + } + + private void ShowScreens(ScreenState state) + { + if (state == shownScreens) return; + shownScreens = state; + + if (screenInstance != null) Destroy(screenInstance); + if (screenMount == null || state == ScreenState.Missing) return; + + GameObject prefab = state == ScreenState.Intact + ? Resolve(ref screenIntactPrefab, Module3Props.WindowScreenIntact) + : Resolve(ref screenTornPrefab, Module3Props.WindowScreenTorn); + + screenInstance = Place(prefab, screenMount); + } + + private void ShowFan(bool on) + { + if (on == shownFan && fanInstance != null) return; + shownFan = on; + + if (!on) + { + if (fanInstance != null) Destroy(fanInstance); + return; + } + + if (fanStand == null || fanInstance != null) return; + fanInstance = Place(Resolve(ref fanPrefab, Module3Props.ElectricFan), fanStand); + if (fanInstance != null) fanInstance.AddComponent(); + } + + private void ShowNet(bool deployed) + { + if (netShownAtAll && deployed == shownNet) return; + shownNet = deployed; + netShownAtAll = true; + + if (netInstance != null) Destroy(netInstance); + if (bedNetAnchor == null) return; + + // The two net prefabs share an origin, so this is a straight swap at one transform. + GameObject prefab = deployed + ? Resolve(ref netDeployedPrefab, Module3Props.MosquitoNetDeployed) + : Resolve(ref netRolledUpPrefab, Module3Props.MosquitoNetRolledUp); + + netInstance = Place(prefab, bedNetAnchor); + } + + private static GameObject Place(GameObject prefab, Transform at) + { + if (prefab == null || at == null) return null; + GameObject go = Instantiate(prefab, at.position, at.rotation, at); + go.transform.localPosition = Vector3.zero; + go.transform.localRotation = Quaternion.identity; + return go; + } + + private static GameObject Resolve(ref GameObject cached, string path) + { + if (cached != null) return cached; +#if UNITY_EDITOR + cached = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (cached == null) Debug.LogWarning($"Module 3: no prefab at {path}"); +#else + // In a player build the prefab must be wired in the inspector or live under Resources. + Debug.LogWarning($"Module 3: prefab reference not assigned for {path}"); +#endif + return cached; + } +} + +/// +/// Turns the fan's blade. The blade is a separate mesh with its origin on the rotation axis for +/// exactly this reason - a welded blade would make the prop unusable. +/// +public class FanBladeSpin : MonoBehaviour +{ + [SerializeField] private float degreesPerSecond = 720f; + private Transform blade; + + private void Awake() + { + blade = transform.Find(Module3Props.FanBlade); + if (blade == null) + { + foreach (var t in GetComponentsInChildren()) + { + if (t.name == Module3Props.FanBlade) { blade = t; break; } + } + } + } + + private void Update() + { + if (blade != null) blade.Rotate(0f, 0f, degreesPerSecond * Time.deltaTime, Space.Self); + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs.meta new file mode 100644 index 00000000..79032227 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a5025411d784bb4b7745921f42035ae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs new file mode 100644 index 00000000..ca32566c --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs @@ -0,0 +1,105 @@ +using Aedes.Module3.Sim; +using UnityEngine; + +/// +/// Shows one villager as they appear to a volunteer standing in the room. +/// +/// The three visible states of section 11 are pose, a skin material swap and props - not three +/// meshes. That is cheaper, reads better at conversational distance, and it keeps the framing +/// right. +/// +/// Section 5 is emphatic about that framing and it is a constraint on this class specifically: +/// "No mechanic should isolate, mark or penalise a sick character, and the player is never +/// rewarded for avoiding them - they are rewarded for going to them." So there is no icon, no +/// outline, no colour coding and nothing overhead. A sick villager looks tired and uncomfortable, +/// the way a person does. If anything here ever reads as "warning: infected", it is a bug. +/// +public class VillagerView : MonoBehaviour +{ + private static readonly int RestingParam = Animator.StringToHash("Resting"); + private static readonly int UnwellParam = Animator.StringToHash("Unwell"); + private static readonly int TalkingParam = Animator.StringToHash("Talking"); + + [Tooltip("Which resident of the neighbourhood this is.")] + public int personId; + + [Header("Presentation")] + [SerializeField] private Animator animator; + [SerializeField] private Renderer skinRenderer; + [SerializeField] private Material wellSkin; + [SerializeField] private Material unwellSkin; + + [Header("Props in the room")] + [Tooltip("A cloth at the mouth, for someone who cannot keep anything down.")] + [SerializeField] private GameObject cloth; + [Tooltip("Water within reach, once the volunteer has brought it.")] + [SerializeField] private GameObject drinkingVessel; + [Tooltip("A bucket beside the bed.")] + [SerializeField] private GameObject bucket; + + public VisibleCondition Condition { get; private set; } + public PersonObservation Observation { get; private set; } + + private void OnEnable() + { + if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted += OnTurnStarted; + Refresh(); + } + + private void OnDisable() + { + if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted -= OnTurnStarted; + } + + private void OnTurnStarted(int turnIndex) => Refresh(); + + public void Refresh() + { + var sim = M3Session.Instance != null ? M3Session.Instance.Neighbourhood : null; + if (sim == null) return; + + Observation = Observe.Person(sim, personId); + if (Observation == null) return; + Condition = Observation.Condition; + + bool unwell = Observation.IsUnwell; + bool resting = unwell || Condition == VisibleCondition.Improving; + + if (animator != null) + { + animator.SetBool(UnwellParam, unwell); + animator.SetBool(RestingParam, resting); + } + + if (skinRenderer != null && wellSkin != null && unwellSkin != null) + { + skinRenderer.sharedMaterial = unwell ? unwellSkin : wellSkin; + } + + // Props follow what the observation says is visible, so the room and the dialogue can + // never disagree with each other. + Show(cloth, HasSign("m3.see.bloodOnCloth")); + Show(bucket, HasSign("m3.see.bucketByTheBed")); + Show(drinkingVessel, Observation.HasFluids); + } + + private bool HasSign(string key) + { + if (Observation == null) return false; + for (int i = 0; i < Observation.Signs.Count; i++) + { + if (Observation.Signs[i].Key == key) return true; + } + return false; + } + + public void SetTalking(bool talking) + { + if (animator != null) animator.SetBool(TalkingParam, talking); + } + + private static void Show(GameObject go, bool visible) + { + if (go != null && go.activeSelf != visible) go.SetActive(visible); + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs.meta new file mode 100644 index 00000000..d2b19fc2 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51d5654f679c4a65a61fd93639a811a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Module3Props.cs b/Assets/1.TeamWorkspace/Module3/Sim/Module3Props.cs new file mode 100644 index 00000000..75582ce4 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Module3Props.cs @@ -0,0 +1,71 @@ +namespace Aedes.Module3.Sim +{ + /// + /// Where the Module 3 props live, in one place. + /// + /// The placeholder assets are greybox stand-ins whose dimensions, pivots, socket names and + /// PATHS are the deliverable - art replaces the mesh inside each prefab later and nothing else + /// changes (see Documentation/Module3-Placeholder-Assets.md). Binding to these constants rather + /// than to literals scattered through the view code is what makes that swap a non-event. + /// + /// Each of these exists because a Phase 1 player action needs something to show for it. There is + /// deliberately no container-clearing prop: section 5 of the design is explicit that clearing + /// containers is "not a button, not a task, not a score". + /// + public static class Module3Props + { + private const string Root = "Assets/1.TeamWorkspace/Team Assets/Prefabs/"; + + // SetFan(householdId) + public const string ElectricFan = Root + "Furniture/PF_ElectricFan.prefab"; + + // RepairScreen(householdId): torn becomes intact, in the same place + public const string WindowScreenTorn = Root + "Buildings/PF_WindowScreenTorn.prefab"; + public const string WindowScreenIntact = Root + "Buildings/PF_WindowScreen.prefab"; + + // PutUpNet(personId): rolled up becomes deployed, over the same bed + public const string MosquitoNetRolledUp = Root + "Props/PF_MosquitoNet_RolledUp.prefab"; + public const string MosquitoNetDeployed = Root + "Props/PF_MosquitoNet_Deployed.prefab"; + + // GiveRepellent / BringWater / HelpRest + public const string RepellentBottle = Root + "Props/PF_RepellentBottle.prefab"; + public const string DrinkingVessel = Root + "Props/PF_DrinkingVessel.prefab"; + public const string Cloth = Root + "Props/PF_Cloth.prefab"; + + // Night visits + public const string Torch = Root + "Props/PF_Torch.prefab"; + + // ReferToHealthCentre - provisional, pending design question 13.4 (whether the player + // travels there at all, or referral happens off-screen). + public const string HealthCentre = Root + "Buildings/PF_HealthCentre.prefab"; + + // Reused from the existing world, not made for Module 3. + public const string Bed = Root + "PF_Bed.prefab"; + public const string LaoHouse = Root + "Buildings/PF_Lao_House_One_FloorV2.prefab"; + + /// Socket names, as the delivered assets carry them (decision D12). + public static class Socket + { + /// Attaches to a surface or into an opening. + public const string Mount = "Socket_Mount"; + /// Held in a hand. + public const string Grip = "Socket_Grip"; + /// Hangs from above - the net's ceiling tie. + public const string Hook = "Socket_Hook"; + /// Where a beam starts, on the torch. + public const string Light = "Socket_Light"; + /// The way into the health centre. + public const string Entrance = "Socket_Entrance"; + } + + /// The fan's moving part, spun around its local Z by the view. + public const string FanBlade = "Fan_Blade"; + + public static readonly string[] AllPrefabs = + { + ElectricFan, WindowScreenTorn, WindowScreenIntact, + MosquitoNetRolledUp, MosquitoNetDeployed, + RepellentBottle, DrinkingVessel, Cloth, Torch, HealthCentre, + }; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Module3Props.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/Module3Props.cs.meta new file mode 100644 index 00000000..ccae9dc2 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/Module3Props.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4e52715a0544469890700fc90a15b2d8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef b/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef index ccbdea66..20c70485 100644 --- a/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef +++ b/Assets/1.TeamWorkspace/Module3/Tests/AEDES.Module3.Sim.Tests.asmdef @@ -21,4 +21,4 @@ ], "versionDefines": [], "noEngineReferences": false -} +} \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module3/Tests/PlaceholderAssetTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/PlaceholderAssetTests.cs new file mode 100644 index 00000000..b984761d --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/PlaceholderAssetTests.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// The placeholder props exist where the code expects them, with the sockets the code looks + /// up by name. + /// + /// The whole unblocking pattern rests on paths and socket names staying put while the meshes + /// inside get replaced by real art. If one moves, nothing fails at compile time and nothing + /// fails in play mode either - a prop just quietly stops appearing. These tests are the only + /// thing that would catch that. + /// + [TestFixture] + public class PlaceholderAssetTests + { + [Test] + public void EveryPropThatCodeBindsToExists() + { + var missing = new List(); + foreach (string path in Module3Props.AllPrefabs) + { + if (AssetDatabase.LoadAssetAtPath(path) == null) missing.Add(path); + } + + Assert.IsEmpty(missing, + "code binds to these prefab paths and nothing is there:\n " + string.Join("\n ", missing)); + } + + [Test] + public void TheFanBladeIsASeparateObjectTheViewCanTurn() + { + // A welded blade makes the prop unusable, which is why the asset brief called it out. + var fan = AssetDatabase.LoadAssetAtPath(Module3Props.ElectricFan); + Assert.IsNotNull(fan, "no fan prefab"); + + Transform blade = null; + foreach (var t in fan.GetComponentsInChildren(true)) + { + if (t.name == Module3Props.FanBlade) { blade = t; break; } + } + + Assert.IsNotNull(blade, $"the fan has no '{Module3Props.FanBlade}' object to rotate"); + Assert.IsNotNull(blade.GetComponent(), "the blade carries no mesh of its own"); + } + + [Test] + public void TheTwoNetStatesSwapAtOneTransform() + { + // PutUpNet swaps rolled-up for deployed. If their origins disagree the net jumps + // across the room at the moment the player does the most important thing in the module. + var rolled = AssetDatabase.LoadAssetAtPath(Module3Props.MosquitoNetRolledUp); + var deployed = AssetDatabase.LoadAssetAtPath(Module3Props.MosquitoNetDeployed); + Assert.IsNotNull(rolled); + Assert.IsNotNull(deployed); + + Assert.AreEqual(Vector3.zero, rolled.transform.localPosition); + Assert.AreEqual(Vector3.zero, deployed.transform.localPosition); + } + + [Test] + public void TheScreenStatesSwapAtOneTransform() + { + var torn = AssetDatabase.LoadAssetAtPath(Module3Props.WindowScreenTorn); + var intact = AssetDatabase.LoadAssetAtPath(Module3Props.WindowScreenIntact); + Assert.IsNotNull(torn); + Assert.IsNotNull(intact); + + var tornBounds = Bounds(torn); + var intactBounds = Bounds(intact); + + // Both have to fill the same window opening; depth may differ where the tear sticks out. + Assert.AreEqual(intactBounds.size.x, tornBounds.size.x, 0.02f, "the screens are different widths"); + Assert.AreEqual(intactBounds.size.y, tornBounds.size.y, 0.02f, "the screens are different heights"); + } + + [Test] + public void HandheldPropsCarryAGripSocket() + { + string[] handheld = + { + Module3Props.RepellentBottle, Module3Props.DrinkingVessel, + Module3Props.Cloth, Module3Props.Torch, + }; + + foreach (string path in handheld) + { + var go = AssetDatabase.LoadAssetAtPath(path); + Assert.IsNotNull(go, path); + + bool found = false; + foreach (var t in go.GetComponentsInChildren(true)) + { + if (t.name == Module3Props.Socket.Grip) { found = true; break; } + } + Assert.IsTrue(found, $"{path} has no {Module3Props.Socket.Grip} for a hand to hold it by"); + } + } + + [Test] + public void PropsAreWithinTheMobileTriangleBudget() + { + // Quest 3 is a standalone mobile GPU holding ten houses at once. + var over = new List(); + foreach (string path in Module3Props.AllPrefabs) + { + var go = AssetDatabase.LoadAssetAtPath(path); + if (go == null) continue; + + int tris = 0; + foreach (var mf in go.GetComponentsInChildren(true)) + { + if (mf.sharedMesh != null) tris += mf.sharedMesh.triangles.Length / 3; + } + if (tris > 8000) over.Add($"{path}: {tris} triangles"); + } + + Assert.IsEmpty(over, "over budget:\n " + string.Join("\n ", over)); + } + + private static Bounds Bounds(GameObject go) + { + var b = new Bounds(Vector3.zero, Vector3.zero); + bool first = true; + foreach (var r in go.GetComponentsInChildren(true)) + { + if (first) { b = r.bounds; first = false; } + else b.Encapsulate(r.bounds); + } + return b; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/PlaceholderAssetTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/PlaceholderAssetTests.cs.meta new file mode 100644 index 00000000..50beb74e --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/PlaceholderAssetTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6be177180f73416e8190cdef43b718e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 0908393e03887d57396aebe3159d9be460503a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Sat, 26 Sep 2026 06:49:22 +0700 Subject: [PATCH 14/61] Module 3: animation brief for the villagers The remaining art dependency, specified the way the prop brief was: self-contained, names real paths, and states the contract the code already binds to. Two things worth knowing that only turned up on inspection: - SK_Character is a Unity Humanoid rig (animationType 3, avatar configured), so clips can be retargeted from any humanoid source rather than hand-keyed. That changes the shape of the job considerably and the brief leads with it. - There is no Supercyan character pack in this branch, contrary to what CLAUDE.md and the original asset brief imply. SK_Character is the only human mesh, and the one existing human clip is Armature_walk. Corrected in the asset brief. The animator parameter names (Unwell, Resting, Talking) are fixed because VillagerView is already written and tested against them, so the brief states them as a contract rather than a suggestion. It also says not to touch NpcAnimationController, which Module 1's street NPCs use. The framing rule from design section 5 leads the document rather than being a footnote: a sick villager must never look frightening or contagious. The module teaches students to go to the people who are unwell, and animation is where that could most easily be undone. Age variety is raised as a question with a recommendation rather than a task, since there is only one human mesh and adding more is a costed decision. --- Documentation/Module3-Animation-Brief.md | 208 +++++++++++++++++++++++ Documentation/Module3-Asset-Brief.md | 12 +- 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 Documentation/Module3-Animation-Brief.md diff --git a/Documentation/Module3-Animation-Brief.md b/Documentation/Module3-Animation-Brief.md new file mode 100644 index 00000000..b829f16f --- /dev/null +++ b/Documentation/Module3-Animation-Brief.md @@ -0,0 +1,208 @@ +# Module 3 — Villager Animation Brief + +**For:** whoever is producing the animation — a generative model, an animator, or someone working +in Blender. You do not need to know Unity or the rest of the project. Everything you need is here. + +**This follows on from** `Module3-Asset-Brief.md`, whose nine placeholder props are delivered and +merged. This is the remaining art dependency: the villagers themselves. + +--- + +## 1. What this is for + +AEDES Module 3 is a VR module for Lao secondary schools. The player is a health volunteer walking +into people's homes during a dengue outbreak. They talk to the household, look around the room, and +protect whoever is unwell from being bitten. + +So the player spends the module **standing in a room, two metres from a person, looking at them and +deciding how they are doing.** That is the whole reason this animation matters. There is no combat, +no traversal, no spectacle. There is a person on a bed and a fourteen-year-old trying to work out +whether they are all right. + +--- + +## 2. The rule that outranks everything else in this brief + +A sick villager must **never** be made to look frightening, contagious, or repellent. + +The module exists to teach students to *go to* the people who are unwell and look after them. Its +design document is explicit: no mechanic may isolate, mark or penalise a sick character, and the +player is never rewarded for avoiding them — they are rewarded for going to them. A version of this +module that left students wary of sick neighbours would have done real harm. + +In animation terms: + +| Yes | No | +|---|---| +| Tired, heavy, uncomfortable, slow | Twitching, convulsing, rasping, clawing | +| Wants to be still | Lurching, staggering, reaching toward the player | +| A person having a bad few days | A horror-game infected | +| Withdrawn, quiet | Aggressive, or pitiable to the point of being upsetting | + +Aim for **"my mum with a bad fever"**, not "a patient". If a clip would look at home in a zombie +game, it is wrong, however well made. + +--- + +## 3. The rig — read this before you start + +**File:** `Assets/1.TeamWorkspace/Team Assets/Models/SK_Character/SK_Character.fbx` + +**It is a Unity Humanoid rig** (`animationType: 3`, avatar configured). That is the single most +useful fact in this brief, and it means: + +- **You can retarget.** Any humanoid animation — Mixamo, an existing library, a generative + animation tool that outputs humanoid FBX — can be retargeted onto this character by Unity. You do + not have to hand-key from scratch. +- **You do not have to match the bone names.** Unity maps them through the avatar. (For reference + the skeleton is Blender-authored with `.L`/`.R` suffixes, IK bones, and at least one misspelling — + `pelis` for the pelvis. Do not "fix" it; the project has several load-bearing typos and renaming + breaks references.) +- **Deliver clips as Humanoid**, not Generic. A Generic clip will not retarget and will be sent back. + +**What already exists:** exactly one human clip, `Armature_walk.anim`, plus a controller +`NpcAnimationController.controller` with an `isWalk` bool. **Do not modify either** — Module 1's +street NPCs use them. Module 3 gets its own controller (§6). + +There is **one** human mesh in the project. There is no character pack to draw on. + +--- + +## 4. The clips + +Four clips. All are **idle-class**: the character stays where it is. + +### C1 — `A_Villager_Idle_Well` +A person who is fine, at home, mildly occupied. Standing or sitting, weight shifting occasionally, +a look around the room, maybe folding something. Should survive being looped for a minute without +becoming obviously cyclic. + +- **Loops:** yes, seamlessly. +- **Length:** 6–12 s. +- **Note:** this is the *baseline*. Most people in the neighbourhood are well, including people who + are infected and will never feel a thing — the player is not supposed to be able to tell. Do not + add "subtly off" cues. Well is well. + +### C2 — `A_Villager_Sit_Unwell` +Sitting on the edge of a bed or a mat, fever, three days in. Shoulders down, head heavy, slow +breathing, occasional small shift of weight because nothing is comfortable. Eyes mostly down. + +- **Loops:** yes. +- **Length:** 8–14 s. Slow. +- **Note:** the tell is *weight* and *stillness*, not tremor. They are tired, not fitting. + +### C3 — `A_Villager_Lie_Unwell` +Lying on their back on a bed or a floor mat. Almost still. Breathing visible in the chest. Perhaps +one slow turn of the head across the loop. + +- **Loops:** yes. +- **Length:** 10–20 s. Very slow. +- **Note:** this one plays **under a mosquito net**, which is the module's single most important + action. Keep the silhouette compact and within roughly a 0.9 m wide footprint so it reads through + the netting and does not clip it. The net prefab is + `Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab` — load it with the bed + (`Team Assets/Prefabs/PF_Bed.prefab`) and check. + +### C4 — `A_Villager_Talk` +A light speaking gesture — a hand, a small nod, head turning slightly toward the listener. This is +an **additive / upper-body overlay**: it plays *on top of* C1, C2 or C3, so the character can talk +while standing, while sitting hunched, or while lying down. + +- **Loops:** yes. +- **Length:** 3–6 s. +- **Deliver two variants if cheap:** `A_Villager_Talk` (upright) and `A_Villager_Talk_Lying` + (reduced, mostly head). If you only do one, do the upright one. +- **Note:** no mouth shapes needed — there is no facial rig and no lip sync. + +--- + +## 5. Technical contract + +- **Format:** FBX, one clip per file, or one FBX containing all clips as takes — either is fine. +- **Rig type:** **Humanoid.** Set Animation Type to Humanoid on import; deliver against + `SK_Character` or any standard humanoid skeleton that retargets to it. +- **Root motion:** **none.** These are in-place idles. Bake root motion out; the character must not + drift. A clip that translates will walk villagers through walls. +- **Frame rate:** 30 fps. Do not deliver 24 or 60. +- **Looping:** C1–C4 all loop. First and last pose must match; check Unity's loop-match indicator + reads green, or state on delivery that it does not and why. +- **Naming:** `A_` prefix, exactly as in §4. The project's convention is `SM_` static mesh, + `SK_` skinned, `PF_` prefab, `M_` material, `T_` texture, `A_` animation. +- **Location:** `Assets/1.TeamWorkspace/Team Assets/Animations/A_Villager/` +- **Commit the `.meta` file** next to every asset. A missing `.meta` breaks every reference to that + asset for everyone else on the team. + +--- + +## 6. The animator controller + +Create **a new controller** — do not extend `NpcAnimationController`, which Module 1 uses. + +**File:** `Assets/1.TeamWorkspace/Team Assets/Animations/A_Villager/AC_Villager_M3.controller` + +The gameplay code already drives exactly three parameters. These names are fixed — they are hashed +in `VillagerView.cs` and the code is already written and tested against them: + +| Parameter | Type | Meaning | +|---|---|---| +| `Unwell` | bool | The person is visibly ill — fever, or a warning sign | +| `Resting` | bool | The person is lying down or sat down because of it | +| `Talking` | bool | The household is speaking to the player right now | + +Required behaviour: + +| `Unwell` | `Resting` | Plays | +|---|---|---| +| false | false | `A_Villager_Idle_Well` | +| true | false | `A_Villager_Sit_Unwell` | +| true | true | `A_Villager_Lie_Unwell` | +| false | true | `A_Villager_Sit_Unwell` — someone whose fever has broken but who is still resting | + +`Talking` layers on top of whichever of those is playing, as an additive or upper-body-masked layer. + +Transitions should be **slow** — 0.3–0.5 s. A villager snapping between poses at a handover would +undercut the whole thing. Nothing here needs a blend tree. + +--- + +## 7. Age variety — a question, not a task + +The simulation gives every villager an age band: **Child, Adult or Elder**, and the design document +asks for "ages varied, including children and older people". There is only one human mesh. + +**Do not model new characters for this yet.** Instead, tell us which of these you think is right: + +1. Scale and proportion variation on the one mesh (cheap, reads acceptably at 2 m, slightly wrong). +2. Two more meshes — a child and an elder — sharing the same Humanoid skeleton so all four clips + retarget to all three with no extra animation work. +3. Age carried entirely by clothing and material, with body shape unchanged. + +Option 2 is probably right, but it is a modelling job with a cost, and it should be a decision +rather than something that happens by default. Say what you would do and roughly what it costs. + +--- + +## 8. How to check your own work + +Before delivering, in Unity: + +1. Drop `PF_CharacterV1` in a scene, assign `AC_Villager_M3`. +2. Tick `Unwell` and `Resting` on and off in the Animator window and watch the transitions. +3. Add `PF_Bed` and `PF_MosquitoNet_Deployed` around the lying pose. Confirm no clipping through the + net and that the person is readable through it. +4. Watch `A_Villager_Idle_Well` loop for sixty seconds. If you can see the loop point, it needs work. +5. Stand a camera at eye height, two metres away, and look. That is the actual viewing condition. + +**On delivery, state:** clip lengths, whether each loop matches cleanly, what you retargeted from if +anything, and anything you had to guess at. Guesses are expected — silent guesses are not. + +--- + +## 9. What not to do + +- Do not modify `NpcAnimationController` or `Armature_walk.anim` — Module 1 depends on both. +- Do not deliver Generic clips. Humanoid only. +- Do not bake in root motion. +- Do not add facial animation or lip sync; there is no facial rig. +- Do not rename existing bones, including the misspelled ones. +- Do not make illness look frightening. See §2 — it outranks everything else here. diff --git a/Documentation/Module3-Asset-Brief.md b/Documentation/Module3-Asset-Brief.md index c0a4bc86..cb541c60 100644 --- a/Documentation/Module3-Asset-Brief.md +++ b/Documentation/Module3-Asset-Brief.md @@ -36,7 +36,9 @@ person), so there is nothing for a direction to feed. `Socket_Mount` is sufficie The health centre was built despite being marked "do not start yet" — kept deliberately crude at 84 triangles, which is the right call while §13 item 4 is unresolved. -**Still outstanding:** the villager animation clips in §D. Those are the remaining art dependency. +**Still outstanding:** the villager animation clips in §D — now specified in full in +`Module3-Animation-Brief.md`, which supersedes §D of this document. That is the remaining art +dependency. --- @@ -265,6 +267,11 @@ A small single-storey Lao rural health centre. The module needs villagers in three visible states: **well**, **unwell without warning signs**, and **unwell with a warning sign**, across varied ages including children and older people. +> **Superseded by `Module3-Animation-Brief.md`.** That document specifies the clips, the rig, the +> animator contract and the framing rule in full, and corrects two things stated below: the rig is +> a Unity **Humanoid** (so clips can be retargeted rather than hand-authored), and there is no +> Supercyan character pack in the project — `SK_Character` is the only human mesh. + **This is explicitly not three models per character.** Do not build separate "sick" meshes. The states are communicated by: @@ -272,6 +279,9 @@ states are communicated by: 2. **A skin material swap** — the toon gradient shading makes pallor a one-texture change. 3. **Props** — a cloth at the mouth, a bucket beside the bed, a damp towel. +All three are already wired: `VillagerView` drives the animator parameters, swaps the skin material +and shows or hides the props from what the simulation says is visible in the room. + So what is wanted here is: **animation clips**, not meshes. Against the existing `PF_CharacterV1` / `PF_CharacterV2` rigs: From 8e4bb2dcd9dfb1b0330586a79c1e1b974dd956a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Sat, 26 Sep 2026 07:00:01 +0700 Subject: [PATCH 15/61] Module 3: a scene that runs, built from data rather than dressed by hand Everything up to now was verified by tests and had never run as a game - there was no Module 3 scene at all. There is now, and it builds a playable neighbourhood. The scene is a generated artefact, not something anyone hand-dresses. CLAUDE.md 9 and the roadmap both name Unity YAML merges as the likeliest thing to hurt this project: Module 1's main scene is ~38,000 lines and two people editing it conflict irreconcilably. Module_3_MainScene is 682 lines and 28 objects - lighting, ground, Managers, Neighbourhood, Player - and the neighbourhood itself is laid out at runtime by M3NeighbourhoodBuilder. Regenerating via AEDES > Module 3 > Generate Main Scene is how you change it. The layout derives from the same parameters GAMA sets: lanes and households_per_lane decide both the model's adjacency and where the houses physically stand, so the plot you can see from the next plot stays the plot a mosquito can reach. They cannot drift. Module3GamaLink is deliberately NOT in the scene. Only one SimulationManager subclass may sit on a GameObject - Unity calls Awake on disabled components too and it assigns the static Instance, so two of them race (CLAUDE.md 10.4). Module 3 runs standalone; turning the GAMA link on is a deliberate one-component addition. The smoke test fails if a second one ever appears. Module3SceneSmokeTest opens the scene and builds a real neighbourhood, because the builder and views are MonoBehaviours in Assembly-CSharp and an asmdef cannot reference the predefined assemblies - so the Sim suite cannot reach them. It catches what matters: unassigned prefab references, missing managers, overlapping plots, a builder that silently produces nothing. Result: seed 39783304, 10 households, 38 residents, 252 mosquitoes, 2 visibly ill on day one, 4 nets for 10 households. --- .../Module3/Editor/Module3SceneBuilder.cs | 158 ++++ .../Editor/Module3SceneBuilder.cs.meta | 11 + .../Module3/Editor/Module3SceneSmokeTest.cs | 89 +++ .../Editor/Module3SceneSmokeTest.cs.meta | 11 + .../Module3/Scenes/Module_3_MainScene.unity | 682 ++++++++++++++++++ .../Scenes/Module_3_MainScene.unity.meta | 7 + .../Module3/Scripts/HouseholdView.cs | 8 + .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 158 ++++ .../Scripts/M3NeighbourhoodBuilder.cs.meta | 11 + .../Module3/Scripts/VillagerView.cs | 25 + ProjectSettings/EditorBuildSettings.asset | 3 + 11 files changed, 1163 insertions(+) create mode 100644 Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity create mode 100644 Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs new file mode 100644 index 00000000..af095cc6 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using Aedes.Module3.Sim; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +/// +/// Generates the Module 3 scene from scratch. +/// +/// The scene is a build artefact, not something anybody hand-dresses. CLAUDE.md §9 and the roadmap +/// both name Unity YAML merges as the likeliest thing to hurt this project - Module 1's main scene +/// is about 38,000 lines and two people editing one conflict irreconcilably. So the scene contains +/// four root objects and nothing else; the neighbourhood is laid out at runtime by +/// M3NeighbourhoodBuilder from the simulation's own parameters. +/// +/// Regenerating is how you change the scene. If you find yourself dragging things into it by hand, +/// the change probably belongs in this file instead. +/// +public static class Module3SceneBuilder +{ + public const string ScenePath = "Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity"; + + private const string House = "Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_Lao_House_One_FloorV2.prefab"; + private const string Bed = "Assets/1.TeamWorkspace/Team Assets/Prefabs/PF_Bed.prefab"; + private const string Villager = "Assets/1.TeamWorkspace/Team Assets/Prefabs/Character/PF_CharacterV1.prefab"; + private const string Container = "Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Jar.prefab"; + private const string DebugOverlay = "Assets/Resources/Prefabs/Utils/Debug Overlay.prefab"; + + [MenuItem("AEDES/Module 3/Generate Main Scene")] + public static void Generate() + { + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + BuildLighting(); + BuildGround(); + BuildManagers(); + BuildNeighbourhood(); + BuildPlayer(); + + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(ScenePath)); + EditorSceneManager.SaveScene(scene, ScenePath); + AssetDatabase.Refresh(); + + RegisterInBuildSettings(); + + Debug.Log($"Module 3: generated {ScenePath}. The neighbourhood itself is built at runtime, " + + "so this scene stays small and two people can work on the module at once."); + } + + // ------------------------------------------------------------------------------------- + + private static void BuildLighting() + { + var sun = new GameObject("Directional Light"); + var light = sun.AddComponent(); + light.type = LightType.Directional; + light.intensity = 1.1f; + light.shadows = LightShadows.Soft; + sun.transform.rotation = Quaternion.Euler(48f, 150f, 0f); + } + + private static void BuildGround() + { + // A plain plane under the lanes. Real ground art is a later job; nothing binds to this. + var ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + ground.name = "Ground"; + ground.transform.localScale = new Vector3(12f, 1f, 12f); + ground.transform.position = new Vector3(18f, 0f, 8f); + ground.isStatic = true; + } + + private static void BuildManagers() + { + var managers = new GameObject("Managers"); + + // Localization first: everything else reads keys through it, and it is DontDestroyOnLoad. + managers.AddComponent(); + + // Parameters before the session, so the session starts from whatever GAMA last sent. + managers.AddComponent(); + managers.AddComponent(); + + // Only ONE SimulationManager subclass may sit on a GameObject - Unity calls Awake on + // disabled components too and SimulationManager.Awake assigns the static Instance, so two + // of them race (CLAUDE.md §10.4). Module3GamaLink is therefore NOT added here: the module + // runs standalone, and turning the GAMA link on means adding that one component plus a + // ConnectionManager, deliberately, to this object. + + var overlay = AssetDatabase.LoadAssetAtPath(DebugOverlay); + if (overlay != null) + { + var instance = (GameObject)PrefabUtility.InstantiatePrefab(overlay); + instance.name = "Debug Overlay"; + instance.transform.SetParent(managers.transform, false); + } + } + + private static void BuildNeighbourhood() + { + var root = new GameObject("Neighbourhood"); + var builder = root.AddComponent(); + + var so = new SerializedObject(builder); + Assign(so, "housePrefab", House); + Assign(so, "bedPrefab", Bed); + Assign(so, "villagerPrefab", Villager); + Assign(so, "containerPrefab", Container); + so.ApplyModifiedPropertiesWithoutUndo(); + } + + private static void BuildPlayer() + { + var player = new GameObject("Player"); + player.transform.position = new Vector3(0f, 1.6f, -4f); + + var camera = player.AddComponent(); + camera.nearClipPlane = 0.05f; + camera.farClipPlane = 200f; + player.AddComponent(); + player.tag = "MainCamera"; + + // Keyboard driver so a whole session can be played without a headset. It compiles out of + // a release build, so leaving it in the scene costs nothing. + player.AddComponent(); + } + + private static void Assign(SerializedObject so, string field, string assetPath) + { + var prop = so.FindProperty(field); + if (prop == null) return; + + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + if (asset == null) Debug.LogWarning($"Module 3: no prefab at {assetPath}; '{field}' left empty."); + prop.objectReferenceValue = asset; + } + + private static void RegisterInBuildSettings() + { + var scenes = new List(EditorBuildSettings.scenes); + foreach (var s in scenes) + { + if (s.path == ScenePath) { s.enabled = true; EditorBuildSettings.scenes = scenes.ToArray(); return; } + } + + scenes.Add(new EditorBuildSettingsScene(ScenePath, true)); + EditorBuildSettings.scenes = scenes.ToArray(); + + // SaveManager wipes the save file when the active scene's build index is 0 or 1 + // (CLAUDE.md §10). Worth knowing where Module 3 landed before relying on persistence. + int index = scenes.Count - 1; + if (index <= 1) + { + Debug.LogWarning($"Module 3 is at build index {index}. SaveManager deletes the save file at " + + "index 0 or 1, so anything carried over from Module 2 will be wiped."); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs.meta b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs.meta new file mode 100644 index 00000000..5a3c4ecf --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93ec8e99810943f1aefc480c8dced953 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs new file mode 100644 index 00000000..fab447ed --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using Aedes.Module3.Sim; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +/// +/// Opens the generated scene and actually builds a neighbourhood in it. +/// +/// This is not a unit test and cannot be: the builder and the views are MonoBehaviours in +/// Assembly-CSharp, and a Unity assembly definition cannot reference the predefined assemblies, +/// so the Sim test suite cannot see them. What it can do is run the real thing and report, which +/// catches the failures that matter here - an unassigned prefab reference, a missing component, +/// a builder that silently produces nothing. +/// +/// Run headlessly: +/// Unity -batchmode -quit -executeMethod Module3SceneSmokeTest.Run +/// +public static class Module3SceneSmokeTest +{ + [MenuItem("AEDES/Module 3/Smoke Test Main Scene")] + public static void Run() + { + var failures = new List(); + EditorSceneManager.OpenScene(Module3SceneBuilder.ScenePath, OpenSceneMode.Single); + + var session = Object.FindFirstObjectByType(); + var parameters = Object.FindFirstObjectByType(); + var builder = Object.FindFirstObjectByType(); + + if (session == null) failures.Add("no M3Session in the scene"); + if (parameters == null) failures.Add("no Module3Parameters in the scene"); + if (builder == null) failures.Add("no M3NeighbourhoodBuilder in the scene"); + if (Object.FindFirstObjectByType() == null) + failures.Add("no LocalizationManager - every string would render as a raw key"); + + // Only one SimulationManager subclass may exist, or they race over the static Instance. + var simManagers = Object.FindObjectsByType(FindObjectsInactive.Include, + FindObjectsSortMode.None); + if (simManagers.Length > 1) + failures.Add($"{simManagers.Length} SimulationManager subclasses in the scene; Awake order decides " + + "which wins (CLAUDE.md 10.4)"); + + if (builder != null) + { + var so = new SerializedObject(builder); + foreach (string field in new[] { "housePrefab", "bedPrefab", "villagerPrefab", "containerPrefab" }) + { + var prop = so.FindProperty(field); + if (prop == null || prop.objectReferenceValue == null) + failures.Add($"builder field '{field}' is unassigned; that part of the plot will be missing"); + } + } + + // Build a real neighbourhood, not a mocked one. + if (session != null && builder != null && failures.Count == 0) + { + parameters.SendMessage("Awake", SendMessageOptions.DontRequireReceiver); + session.StartSession(); + builder.Build(); + + var sim = session.Neighbourhood; + if (sim == null) failures.Add("the session produced no neighbourhood"); + else + { + if (builder.Households.Count != sim.Households.Count) + failures.Add($"built {builder.Households.Count} plots for {sim.Households.Count} households"); + if (builder.Villagers.Count != sim.People.Count) + failures.Add($"built {builder.Villagers.Count} villagers for {sim.People.Count} residents"); + + // Two plots must not stand in the same place, or the neighbourhood is a single pile. + var seen = new HashSet(); + foreach (var h in builder.Households) + { + if (!seen.Add(h.transform.position)) + failures.Add($"plot {h.householdId} overlaps another at {h.transform.position}"); + } + + Debug.Log($"Module 3 smoke test: seed {session.Seed}, {sim.Households.Count} households, " + + $"{sim.People.Count} residents, {sim.AliveMosquitoCount} mosquitoes, " + + $"{sim.VisiblyIll().Count} visibly ill on day one, " + + $"{sim.NetsRemaining} nets for {sim.Households.Count} households."); + } + } + + if (failures.Count == 0) Debug.Log("===SMOKE OK=== the scene builds a playable neighbourhood"); + else Debug.LogError("===SMOKE FAILED===\n " + string.Join("\n ", failures)); + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs.meta b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs.meta new file mode 100644 index 00000000..11998325 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e459738861d548f49bd1c27a16f2c8ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity new file mode 100644 index 00000000..3d98c117 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity @@ -0,0 +1,682 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 2 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 1 + m_PVRFilteringGaussRadiusAO: 1 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1001 &280358773 +PrefabInstance: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Modification: + serializedVersion: 3 + m_TransformParent: {fileID: 1465108593} + m_Modifications: + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_Pivot.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_Pivot.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_AnchorMax.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_AnchorMax.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_AnchorMin.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_AnchorMin.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_SizeDelta.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalPosition.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalRotation.w + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalRotation.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalRotation.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalRotation.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_AnchoredPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalEulerAnglesHint.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalEulerAnglesHint.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_LocalEulerAnglesHint.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 7462567982894648384, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: m_Name + value: Debug Overlay + objectReference: {fileID: 0} + - target: {fileID: 7727549739388232037, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} + m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] + m_SourcePrefab: {fileID: 100100000, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} +--- !u!224 &280358774 stripped +RectTransform: + m_CorrespondingSourceObject: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} + m_PrefabInstance: {fileID: 280358773} + m_PrefabAsset: {fileID: 0} +--- !u!1 &1128201650 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1128201654} + - component: {fileID: 1128201653} + - component: {fileID: 1128201652} + - component: {fileID: 1128201651} + m_Layer: 0 + m_Name: Player + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1128201651 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1128201650} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 24ed968bbf5646bfaecd042b6fbcd97e, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3DebugDriver + showOverlay: 1 +--- !u!81 &1128201652 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1128201650} + m_Enabled: 1 +--- !u!20 &1128201653 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1128201650} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.05 + far clip plane: 200 + field of view: 60 + orthographic: 0 + orthographic size: 5 + m_Depth: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &1128201654 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1128201650} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 1.6, z: -4} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1465108589 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1465108593} + - component: {fileID: 1465108592} + - component: {fileID: 1465108591} + - component: {fileID: 1465108590} + m_Layer: 0 + m_Name: Managers + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1465108590 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1465108589} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: ae6967dcfcba4d74aed5c0275422655b, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3Session + seed: 0 + autoStart: 1 + logHandovers: 1 +--- !u!114 &1465108591 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1465108589} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b41c0e615f2e4e64b92f966fccdfb299, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::Module3Parameters + logOnReceive: 1 +--- !u!114 &1465108592 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1465108589} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a2c0fb59e797f9c4394b260c04181050, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::LocalizationManager + currentLanguage: English +--- !u!4 &1465108593 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1465108589} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 280358774} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1556478078 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1556478080} + - component: {fileID: 1556478079} + m_Layer: 0 + m_Name: Directional Light + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!108 &1556478079 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1556478078} + m_Enabled: 1 + serializedVersion: 12 + m_Type: 1 + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_Intensity: 1.1 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 0.5, y: 0.5} + m_Shadows: + m_Type: 2 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShadowRadius: 0 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &1556478080 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1556478078} + serializedVersion: 2 + m_LocalRotation: {x: 0.105271205, y: 0.88241714, z: -0.39287746, w: 0.236443} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1755470773 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1755470777} + - component: {fileID: 1755470776} + - component: {fileID: 1755470775} + - component: {fileID: 1755470774} + m_Layer: 0 + m_Name: Ground + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 2147483647 + m_IsActive: 1 +--- !u!23 &1755470774 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1755470773} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!64 &1755470775 +MeshCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1755470773} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 5 + m_Convex: 0 + m_CookingOptions: 30 + m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} +--- !u!33 &1755470776 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1755470773} + m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} +--- !u!4 &1755470777 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1755470773} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 18, y: 0, z: 8} + m_LocalScale: {x: 12, y: 1, z: 12} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1874886780 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1874886782} + - component: {fileID: 1874886781} + m_Layer: 0 + m_Name: Neighbourhood + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1874886781 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1874886780} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5c49cece3445418896358bc6162cd43c, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3NeighbourhoodBuilder + plotWidth: 9 + laneSpacing: 16 + housePrefab: {fileID: 6484222673902141143, guid: 908f7032114933a47b79326aa4169a8b, type: 3} + bedPrefab: {fileID: 8638829071811766795, guid: 5e86aaec090b5034cbda1bc9a0cf13c4, type: 3} + villagerPrefab: {fileID: 5270409977889807214, guid: 79fdf6a8b358f664f92b8fa1ae1d67bd, type: 3} + containerPrefab: {fileID: 7376524743313560442, guid: 56df000b2c3087b448db7963fd9f2578, type: 3} + bedOffset: {x: 0, y: 0, z: 2.2} + screenOffset: {x: 1.6, y: 1.4, z: 0.1} + fanOffset: {x: -1.4, y: 0, z: 2} + villagerSpacing: 0.9 +--- !u!4 &1874886782 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1874886780} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 1556478080} + - {fileID: 1755470777} + - {fileID: 1465108593} + - {fileID: 1874886782} + - {fileID: 1128201654} diff --git a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity.meta b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity.meta new file mode 100644 index 00000000..bcc6f80c --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 040e1520071c948c698619f43ea0e298 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs index 6c1fd409..aa8878aa 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs @@ -32,6 +32,14 @@ public class HouseholdView : MonoBehaviour private bool shownFan, shownNet; private bool netShownAtAll; + /// Wired by M3NeighbourhoodBuilder, which creates the anchors as it lays out a plot. + public void Bind(Transform screen, Transform fan, Transform net) + { + screenMount = screen; + fanStand = fan; + bedNetAnchor = net; + } + private void OnEnable() { if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted += OnTurnStarted; diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs new file mode 100644 index 00000000..8602afe0 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using Aedes.Module3.Sim; +using UnityEngine; + +/// +/// Builds the neighbourhood from prefabs at runtime, from the simulation's own layout. +/// +/// Deliberately not a hand-dressed scene. CLAUDE.md §9 and the roadmap both flag Unity YAML +/// merges as the thing most likely to hurt this project - Module 1's main scene is about 38,000 +/// lines and two people cannot edit one at once. A neighbourhood placed by a builder keeps the +/// scene file to a handful of root objects, makes the layout reviewable as numbers rather than as +/// serialised transforms, and means two people can work on Module 3 at the same time. +/// +/// The geometry comes from the same parameters GAMA sets. `lanes` and `households_per_lane` decide +/// both how the model's adjacency works and where the houses physically stand, so the two can +/// never drift apart - the plot you can see from the next plot is the plot a mosquito can reach. +/// +public class M3NeighbourhoodBuilder : MonoBehaviour +{ + [Header("Layout (metres)")] + [Tooltip("Centre-to-centre spacing along a lane. Section 11 wants plots packed close enough " + + "that the player can see one house from the next.")] + [SerializeField] private float plotWidth = 9f; + + [Tooltip("Distance across the lane, between the two facing rows.")] + [SerializeField] private float laneSpacing = 16f; + + [Header("Prefabs")] + [SerializeField] private GameObject housePrefab; + [SerializeField] private GameObject bedPrefab; + [SerializeField] private GameObject villagerPrefab; + [SerializeField] private GameObject containerPrefab; + + [Header("Anchors inside a plot (local to the house)")] + [SerializeField] private Vector3 bedOffset = new Vector3(0f, 0f, 2.2f); + [SerializeField] private Vector3 screenOffset = new Vector3(1.6f, 1.4f, 0.1f); + [SerializeField] private Vector3 fanOffset = new Vector3(-1.4f, 0f, 2.0f); + [SerializeField] private float villagerSpacing = 0.9f; + + public readonly List Households = new List(); + public readonly List Villagers = new List(); + + private bool built; + + private void Start() + { + if (M3Session.Instance == null) + { + Debug.LogError("Module 3: no M3Session in the scene, so there is no neighbourhood to build."); + return; + } + + if (M3Session.Instance.Neighbourhood != null) Build(); + else M3Session.Instance.OnTurnStarted += BuildOnFirstTurn; + } + + private void BuildOnFirstTurn(int turnIndex) + { + M3Session.Instance.OnTurnStarted -= BuildOnFirstTurn; + Build(); + } + + public void Build() + { + if (built) return; + built = true; + + var sim = M3Session.Instance.Neighbourhood; + + for (int i = 0; i < sim.Households.Count; i++) + { + BuildPlot(sim, sim.Households[i]); + } + + Debug.Log($"Module 3: built {Households.Count} plots and {Villagers.Count} villagers " + + $"from seed {M3Session.Instance.Seed}."); + } + + private void BuildPlot(Neighbourhood sim, Household h) + { + // Two rows facing each other across a lane, which is what makes the transmission chain + // plausible - section 5: houses close together are not set dressing. + bool farSide = h.LaneY % 2 == 1; + var plotPosition = new Vector3(h.LaneX * plotWidth, 0f, farSide ? laneSpacing : 0f); + var facing = Quaternion.Euler(0f, farSide ? 180f : 0f, 0f); + + var plot = new GameObject($"Plot_{h.Id}"); + plot.transform.SetParent(transform, false); + plot.transform.SetPositionAndRotation(plotPosition, facing); + + if (housePrefab != null) Instantiate(housePrefab, plot.transform); + + var bed = bedPrefab != null + ? Instantiate(bedPrefab, plot.transform).transform + : Anchor(plot.transform, "BedAnchor", bedOffset); + bed.localPosition = bedOffset; + + var screenMount = Anchor(plot.transform, "ScreenMount", screenOffset); + var fanStand = Anchor(plot.transform, "FanStand", fanOffset); + var netAnchor = Anchor(plot.transform, "NetAnchor", bedOffset); + + var view = plot.AddComponent(); + view.householdId = h.Id; + view.Bind(screenMount, fanStand, netAnchor); + Households.Add(view); + + // The containers are scenery and the visible reason there are mosquitoes here. Section 5: + // they are "not a button, not a task, not a score" - nothing here is interactive. + if (containerPrefab != null) + { + for (int c = 0; c < h.Containers.Count; c++) + { + if (!h.Containers[c].IsProductive) continue; + var yard = Anchor(plot.transform, $"Container_{h.Containers[c].Id}", + new Vector3(-2.5f + c * 0.9f, 0f, -1.8f)); + Instantiate(containerPrefab, yard); + } + } + + for (int r = 0; r < h.ResidentIds.Count; r++) + { + BuildVillager(sim, plot.transform, h.ResidentIds[r], r, bed); + } + } + + private void BuildVillager(Neighbourhood sim, Transform plot, int personId, int index, Transform bed) + { + var spot = Anchor(plot, $"Villager_{personId}", + new Vector3(-1.2f + index * villagerSpacing, 0f, 1.0f)); + + var villager = villagerPrefab != null ? Instantiate(villagerPrefab, spot) : new GameObject("Villager"); + if (villagerPrefab == null) villager.transform.SetParent(spot, false); + + var view = villager.GetComponent(); + if (view == null) view = villager.AddComponent(); + view.personId = personId; + view.RestingPlace = bed; + Villagers.Add(view); + } + + private static Transform Anchor(Transform parent, string name, Vector3 localPosition) + { + var t = new GameObject(name).transform; + t.SetParent(parent, false); + t.localPosition = localPosition; + return t; + } + + /// Where plot stands, for the map and the Coach view. + public Vector3 PlotPosition(int householdId) + { + for (int i = 0; i < Households.Count; i++) + { + if (Households[i].householdId == householdId) return Households[i].transform.position; + } + return Vector3.zero; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs.meta new file mode 100644 index 00000000..1c1263f4 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c49cece3445418896358bc6162cd43c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs index ca32566c..3a3a8e64 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs @@ -37,6 +37,16 @@ public class VillagerView : MonoBehaviour [Tooltip("A bucket beside the bed.")] [SerializeField] private GameObject bucket; + /// + /// Where this person lies down when they are unwell. Section 6: the net works here because + /// Aedes bites during the day and the patient is in bed during the day, so a villager who is + /// resting has to actually be on the bed the net is over. + /// + public Transform RestingPlace { get; set; } + + private Vector3 standingPosition; + private bool capturedStandingPosition; + public VisibleCondition Condition { get; private set; } public PersonObservation Observation { get; private set; } @@ -71,6 +81,8 @@ public void Refresh() animator.SetBool(RestingParam, resting); } + MoveToRestingPlace(resting); + if (skinRenderer != null && wellSkin != null && unwellSkin != null) { skinRenderer.sharedMaterial = unwell ? unwellSkin : wellSkin; @@ -83,6 +95,19 @@ public void Refresh() Show(drinkingVessel, Observation.HasFluids); } + private void MoveToRestingPlace(bool resting) + { + if (RestingPlace == null) return; + + if (!capturedStandingPosition) + { + standingPosition = transform.position; + capturedStandingPosition = true; + } + + transform.position = resting ? RestingPlace.position : standingPosition; + } + private bool HasSign(string key) { if (Observation == null) return false; diff --git a/ProjectSettings/EditorBuildSettings.asset b/ProjectSettings/EditorBuildSettings.asset index ba4bf5e3..b32e1b1a 100644 --- a/ProjectSettings/EditorBuildSettings.asset +++ b/ProjectSettings/EditorBuildSettings.asset @@ -59,6 +59,9 @@ EditorBuildSettings: - enabled: 0 path: Assets/Scenes/Scenes by Simple/Code Examples/Receive Water Data.unity guid: e432add6f06a341039153cf5e865ad9d + - enabled: 1 + path: Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity + guid: 040e1520071c948c698619f43ea0e298 m_configObjects: Unity.XR.Oculus.Settings: {fileID: 11400000, guid: bfa1182bd221b4ca89619141f66f1260, type: 2} Unity.XR.WindowsMR.Settings: {fileID: 11400000, guid: dc5a169419fa04987b057f65238cf3ba, type: 2} From 81cddb68364ba3f7a32d83e339653b565252c9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Sat, 26 Sep 2026 07:00:24 +0700 Subject: [PATCH 16/61] Module 3: roadmap status after the scene --- Documentation/Module3-Roadmap.md | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/Documentation/Module3-Roadmap.md b/Documentation/Module3-Roadmap.md index 19432f6a..22a026ed 100644 --- a/Documentation/Module3-Roadmap.md +++ b/Documentation/Module3-Roadmap.md @@ -18,9 +18,14 @@ Target branch: `module-3` off `main`. | 1 — Epidemic model | **Done.** `Module3/Sim/`, headless and deterministic. | | 1.5 — Calibration harness | **Done.** `Module3/Tests/`, 200 seeds. All five of section 5's guarantees hold. | | 7a — GAMA parameter delivery | **Done, brought forward.** Parameters now arrive as a GAMA-generated CSV, including mid-session. See the section below. | -| 2 — Session, turns, handover | **Done.** `Sim/Session.cs` + `HandoverBrief`, driven by `M3Session`. `M3DebugDriver` plays a whole session from the keyboard with no headset and no art. | -| 3 — Household visit in VR | Next. Needs the placeholder props from the asset brief. | -| 4–6, 7b, 8 | Not started. | +| 2 — Session, turns, handover | **Done.** `Sim/Session.cs` + `HandoverBrief`, driven by `M3Session`. `M3DebugDriver` plays a whole session from the keyboard. | +| 3 — Household visit | **Mostly done.** `Observe`, dialogue, `HouseholdView`, `VillagerView`, prop binding. Remaining: the hand-attached radial action menu, which needs an XR rig. | +| 4 — Consequence and evidence | **Done.** `TraceBack`, `OutbreakMap`, counterfactual comparison, §10 `Scoring`, `FieldJournal`. | +| 6 — Art integration | **Props done and merged** (`module3-placeholder-art`). Villager animation clips outstanding — see `Module3-Animation-Brief.md`. | +| — Scene | **Done.** `Module_3_MainScene` is generated, 682 lines, and builds a playable neighbourhood at runtime. | +| 5 — Squad layer | Next. The data all exists (`HandoverBrief`, `FieldJournal`); it needs a cast-legible HUD. | +| 7b — Module 2 handoff | Not started. `INeighbourhoodStateSource`; the GAMA parameter half is done. | +| 8 — Build and headset | Not started. **The scene has a plain camera, not an XR rig — Module 3 is not VR yet.** | ### Calibration, 200 seeds @@ -36,6 +41,18 @@ Target branch: `module-3` off `main`. Every session presents at least one referral situation; unaided hospitalisations fall from 2.3 to 0.7 when a squad refers. +### The scene is generated, not dressed + +`AEDES > Module 3 > Generate Main Scene` rebuilds `Module_3_MainScene` from scratch; the +neighbourhood is laid out at runtime by `M3NeighbourhoodBuilder` from the simulation's own +`lanes` / `households_per_lane`. The scene file stays at a few hundred lines, so two people can +work on Module 3 at once — which was risk 1 below. + +`AEDES > Module 3 > Smoke Test Main Scene` opens it and builds a real neighbourhood, catching +unassigned prefab references, missing managers and overlapping plots. It is not a unit test and +cannot be: the builder and views are MonoBehaviours in `Assembly-CSharp`, and an assembly +definition cannot reference the predefined assemblies, so the Sim suite cannot reach them. + ### Parameters come from GAMA They are no longer C# literals. GAMA generates a `key,value` CSV; Unity reads it, validates it @@ -479,10 +496,10 @@ Item 4 is the one worth pushing for an answer on before Phase 3. ## 6. Risks and repo traps -1. **Unity YAML merge conflicts.** `Module_1_MainScene.unity` is ~38k lines and two people cannot - edit a scene at once. Build the M3 neighbourhood from **prefabs placed by a builder script** - driven by a data asset, not by hand-dragging 10 plots into a scene file. The scene stays small, - the layout becomes reviewable as data, and merges stop hurting. +1. ~~**Unity YAML merge conflicts.**~~ Handled: the scene is generated and the neighbourhood is + built at runtime. `Module_3_MainScene` is 682 lines against Module 1's ~38,000. Keep it that + way — if you find yourself dragging things into the scene, the change belongs in + `Module3SceneBuilder` instead. 2. **`Player_Test.cs` has an editor-only `using` in a runtime script** — will break the Android build. Do not carry it over from `origin/Boy`. 3. **`SendRecieveData` reads `GameManager.instance`**, which only exists in Module 1. Module 3 must From 456144287f677ead0d603e92fef016c6ac259955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Sat, 26 Sep 2026 07:01:07 +0700 Subject: [PATCH 17/61] Module 3: correct the animation brief's account of what art already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three factual corrections, all verified against the assets: - The brief said there is one human mesh and no character pack to draw on. Neither is true. SK_Character carries three body meshes on its one skeleton (fodo, nasa, Roger), and the Supercyan Character Pack Free Sample is in the project with 12 Humanoid animation FBXs that retarget onto the rig directly. That is a retarget source the brief was telling the animator they did not have, and it changes §7's premise: the gap is a child and an elder specifically, not bodies in general. - The rig FBX also carries idle/walk/t-pose takes with no clip ranges configured, so there is an existing idle to build the well-idle from. - Gave the lying pose the measured net and bed dimensions (1.56 x 1.77 m dome, 1.39 m tall, over a 1.60 x 1.94 m bed) instead of only a silhouette width. Also noted that age needs no animation of its own, since one clip set retargets to every body. --- Documentation/Module3-Animation-Brief.md | 52 +++++++++++++++++------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/Documentation/Module3-Animation-Brief.md b/Documentation/Module3-Animation-Brief.md index b829f16f..2d6d3b43 100644 --- a/Documentation/Module3-Animation-Brief.md +++ b/Documentation/Module3-Animation-Brief.md @@ -60,11 +60,23 @@ useful fact in this brief, and it means: breaks references.) - **Deliver clips as Humanoid**, not Generic. A Generic clip will not retarget and will be sent back. -**What already exists:** exactly one human clip, `Armature_walk.anim`, plus a controller -`NpcAnimationController.controller` with an `isWalk` bool. **Do not modify either** — Module 1's -street NPCs use them. Module 3 gets its own controller (§6). - -There is **one** human mesh in the project. There is no character pack to draw on. +**What already exists:** + +- `Armature_walk.anim` and the controller `NpcAnimationController.controller` (one `isWalk` bool), + both sitting beside the rig. **Do not modify either** — Module 1's street NPCs use them. Module 3 + gets its own controller (§6). +- The rig FBX itself carries takes named `idle`, `walk` and a t-pose. No clip ranges are configured + on it, so Unity exposes those takes as-is. The `idle` take is a reasonable thing to build C1 from + rather than keying from nothing. +- **Three body meshes share this one skeleton** — `fodo`, `nasa` and `Roger`, at 592 / 712 / 656 + triangles, 18 vertex groups each. The neighbourhood already has three villager bodies, and one + set of clips drives all of them. This matters for §7. +- **There is a humanoid character pack in the project you may retarget from:** + `Assets/1.TeamWorkspace/Imported by Simple/Supercyan Character Pack Free Sample/`. Its mesh and + all **12** of its animation FBXs are Humanoid, so they retarget onto `SK_Character` directly — + `common_people@idle`, `@walk`, `@pickup` and `@wave` are the ones worth looking at. It is + **read-only**: the project rule is that nothing under `Imported by Simple/` gets edited, but + retargeting *from* it is fine. --- @@ -98,10 +110,11 @@ one slow turn of the head across the loop. - **Loops:** yes. - **Length:** 10–20 s. Very slow. - **Note:** this one plays **under a mosquito net**, which is the module's single most important - action. Keep the silhouette compact and within roughly a 0.9 m wide footprint so it reads through - the netting and does not clip it. The net prefab is - `Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab` — load it with the bed - (`Team Assets/Prefabs/PF_Bed.prefab`) and check. + action. The net is a dome measuring **1.56 × 1.77 m in plan and 1.39 m tall**, over a bed of + **1.60 × 1.94 m**, both centred on the same point. Keep the lying silhouette inside roughly a + 0.9 m width — arms in, nothing flung out — so it reads through the netting and never clips it. + Load `Team Assets/Prefabs/Props/PF_MosquitoNet_Deployed.prefab` with the bed + (`Team Assets/Prefabs/PF_Bed.prefab`) and check against the real props. ### C4 — `A_Villager_Talk` A light speaking gesture — a hand, a small nod, head turning slightly toward the listener. This is @@ -168,18 +181,27 @@ undercut the whole thing. Nothing here needs a blend tree. ## 7. Age variety — a question, not a task The simulation gives every villager an age band: **Child, Adult or Elder**, and the design document -asks for "ages varied, including children and older people". There is only one human mesh. +asks for "ages varied, including children and older people". + +The variety problem is already half solved: three body meshes share the skeleton (§3). But all +three are adult-proportioned, so the gap is specifically **a child and an elder**, not bodies in +general. -**Do not model new characters for this yet.** Instead, tell us which of these you think is right: +**Do not model new characters for this yet.** Tell us which of these you think is right: -1. Scale and proportion variation on the one mesh (cheap, reads acceptably at 2 m, slightly wrong). -2. Two more meshes — a child and an elder — sharing the same Humanoid skeleton so all four clips - retarget to all three with no extra animation work. -3. Age carried entirely by clothing and material, with body shape unchanged. +1. Scale and proportion variation on the three existing meshes (cheap, but a scaled adult reads as + a small adult, not as a child). +2. Two more meshes — a child and an elder — on the same Humanoid skeleton, so all four clips + retarget to all five bodies with no extra animation work. +3. Age carried by clothing and material on the existing three, with body shape unchanged. Option 2 is probably right, but it is a modelling job with a cost, and it should be a decision rather than something that happens by default. Say what you would do and roughly what it costs. +Either way, **age does not need its own animation**: one set of clips retargets to every body. If +playtesting later says an elder needs a different weight of movement, that is a follow-up variant, +not part of this delivery. + --- ## 8. How to check your own work From 3704c4363b37afb2d463ecf2c2c77c2a4f2f1737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Sat, 26 Sep 2026 07:04:13 +0700 Subject: [PATCH 18/61] Module 3: tell the animator how much of the illness the body should carry The brief listed four clips but never said how they divide the work with the dialogue and the props, which leaves the two performances that matter most to guesswork. Both are already decided by Observation.cs: - A warning sign is signalled by an explicit line ("there is blood", "she cannot keep water down") plus a prop in the room (cloth, bucket). So the body must NOT escalate for it. If it does, students learn to scan for the most dramatic person instead of learning the two signs, and a quiet person with a bucket by the bed gets walked past. - Defervescence is the opposite: the household says they are feeling better and AddRoomSigns contributes nothing about the illness, so the pose is the only honest signal the player gets. It has to read as better than the fever pose but not well, which is the hardest ask in the brief and was not stated. Added as a subsection of section 4 rather than renumbering. --- Documentation/Module3-Animation-Brief.md | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Documentation/Module3-Animation-Brief.md b/Documentation/Module3-Animation-Brief.md index 2d6d3b43..bc58fdbe 100644 --- a/Documentation/Module3-Animation-Brief.md +++ b/Documentation/Module3-Animation-Brief.md @@ -127,6 +127,38 @@ while standing, while sitting hunched, or while lying down. (reduced, mostly head). If you only do one, do the upright one. - **Note:** no mouth shapes needed — there is no facial rig and no lip sync. +### How much the body should carry + +Four clips cover five things the player can see, because most of what distinguishes those five is +carried by **what the household says** and **what is lying in the room** — not by the body. The +code that decides what a player observes is already written, and it splits the work like this: + +| What the player is seeing | What tells them | What the body does | +|---|---|---| +| Someone well | nothing at all | C1 | +| Fever, a few days in | they say *"she has been hot for three days"*, and there is bedding | C2 or C3, tired and heavy | +| **Needs a doctor now** | they say *"there is blood"* or *"she cannot keep water down"*, **and** there is a cloth or a bucket in the room | **the same C2 or C3 — do not escalate** | +| **The fever has broken** | they say *"he is feeling better today"* — **and nothing else** | C2, sitting, visibly better than the fever version | + +Two consequences, and they are the difference between a clip that teaches and one that does not. + +**Do not put severity into the performance.** The warning signs already have two signals: a plain +sentence and a prop. If the body escalates on top of that, students learn to scan a room for the +most dramatic person instead of learning the two signs they are meant to carry out of the module — +and a quiet person with a bucket beside the bed gets walked past. **The person who needs a doctor +should not look more alarming than the person with a fever.** They may well look calmer. That is +not a mistake in the brief. + +**The fever breaking is the one place where the body is the only honest signal.** When someone is +past the fever, the household says they are better, and there is no prop in the room to say +otherwise. In the real illness this is often exactly when the dangerous phase begins — which is why +the module is built to make a student who sees someone improving and moves on regret it. That makes +this pose the hardest ask in the brief, because it has to be genuinely ambiguous: **better than the +fever pose — more upright, more present, someone you could plausibly leave alone — but not well.** +If they read as fine, the student is being tricked rather than taught. If they read as still +obviously ill, there is nothing to learn. Aim for the version of *"no, no, I'm all right"* that +nobody in the room quite believes. + --- ## 5. Technical contract From 3c6252f6dc418c3bed253498c13e9ae3e7d0b723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Sat, 26 Sep 2026 07:07:04 +0700 Subject: [PATCH 19/61] Module 3: the squad layer - Coach brief, handover screen, debrief, journal Section 9 puts two of the three students outside the headset with jobs to do, and section 8 says the countdown 'should be visible to the Coach, not only to the Pilot'. So this is built for the cast, not the headset: a screen-space overlay with large type that a Coach and an Analyst can work from on a phone or a laptop. CoachBrief lives in the engine-free assembly because the ORDER is a teaching decision, not a layout one. Whatever is top of a list a fourteen-year-old reads aloud against a clock is what the squad does next, so it is tested: - Anyone needing a doctor is always first, and the line cap can never truncate them away. Section 4 calls the referral rule the heart of the module; third on a list it may as well not be there. - Someone whose fever has broken is flagged AHEAD of someone visibly unwell. The obviously ill patient will get attention anyway; the one who seems fine will not unless the Coach says so, and section 8 makes that the module's signature mistake. - The brief is capped at six lines so it stays sayable in the time it takes to pass a headset over. The cap yields to referrals and to nothing else. - Every urgent line names a house, because the Coach is holding the map. The debrief follows section 5's fixed order - the map, then the replay of what would have happened had the first patient been covered on day one, and only then the score. M3JournalWriter writes the Field Journal to persistentDataPath at session end, ending on section 5's question for the facilitator to read aloud. A failed write is logged and swallowed: the class is still in the room and the screen still has to show them their outbreak. 84 tests passing; the scene still regenerates and smoke-tests clean at 744 lines. --- .../Module3/Editor/Module3SceneBuilder.cs | 21 + .../Module3/Editor/Module3SceneSmokeTest.cs | 4 + .../Module3/Scenes/Module_3_MainScene.unity | 408 ++++++++++-------- .../1.TeamWorkspace/Module3/Scripts/M3Hud.cs | 269 ++++++++++++ .../Module3/Scripts/M3Hud.cs.meta | 11 + .../Module3/Scripts/M3JournalWriter.cs | 65 +++ .../Module3/Scripts/M3JournalWriter.cs.meta | 11 + .../1.TeamWorkspace/Module3/Sim/CoachBrief.cs | 150 +++++++ .../Module3/Sim/CoachBrief.cs.meta | 11 + .../Module3/Tests/CoachBriefTests.cs | 157 +++++++ .../Module3/Tests/CoachBriefTests.cs.meta | 11 + .../Tests/LocalizationCoverageTests.cs | 11 + .../Localization/LocalizationData.csv | 15 + 13 files changed, 971 insertions(+), 173 deletions(-) create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/CoachBrief.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Sim/CoachBrief.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/CoachBriefTests.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Tests/CoachBriefTests.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs index af095cc6..53ffd91d 100644 --- a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs @@ -26,6 +26,8 @@ public static class Module3SceneBuilder private const string Villager = "Assets/1.TeamWorkspace/Team Assets/Prefabs/Character/PF_CharacterV1.prefab"; private const string Container = "Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PF_Jar.prefab"; private const string DebugOverlay = "Assets/Resources/Prefabs/Utils/Debug Overlay.prefab"; + private const string LaoFont = + "Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset"; [MenuItem("AEDES/Module 3/Generate Main Scene")] public static void Generate() @@ -37,6 +39,7 @@ public static void Generate() BuildManagers(); BuildNeighbourhood(); BuildPlayer(); + BuildSquadScreens(); System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(ScenePath)); EditorSceneManager.SaveScene(scene, ScenePath); @@ -125,6 +128,24 @@ private static void BuildPlayer() player.AddComponent(); } + private static void BuildSquadScreens() + { + // The Coach and the Analyst watch the cast, not the headset (section 9), so this is a + // screen-space overlay rather than anything in world space. + var squad = new GameObject("Squad Screens"); + var hud = squad.AddComponent(); + squad.AddComponent(); + + // One font for English and Lao: the Lao face is a dynamic atlas and carries all 95 + // printable ASCII as well as the Lao block, so nothing needs a fallback. + var so = new SerializedObject(hud); + var font = so.FindProperty("font"); + var asset = AssetDatabase.LoadAssetAtPath(LaoFont); + if (asset == null) Debug.LogWarning($"Module 3: no font at {LaoFont}; the HUD will use the TMP default."); + if (font != null) font.objectReferenceValue = asset; + so.ApplyModifiedPropertiesWithoutUndo(); + } + private static void Assign(SerializedObject so, string field, string assetPath) { var prop = so.FindProperty(field); diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs index fab447ed..6548d366 100644 --- a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneSmokeTest.cs @@ -33,6 +33,10 @@ public static void Run() if (builder == null) failures.Add("no M3NeighbourhoodBuilder in the scene"); if (Object.FindFirstObjectByType() == null) failures.Add("no LocalizationManager - every string would render as a raw key"); + if (Object.FindFirstObjectByType() == null) + failures.Add("no M3Hud - the Coach and the Analyst would have nothing to work from"); + if (Object.FindFirstObjectByType() == null) + failures.Add("no M3JournalWriter - the facilitator gets no record of the session"); // Only one SimulationManager subclass may exist, or they race over the static Instance. var simManagers = Object.FindObjectsByType(FindObjectsInactive.Include, diff --git a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity index 3d98c117..7ab478a5 100644 --- a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity +++ b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity @@ -119,13 +119,13 @@ NavMeshSettings: debug: m_Flags: 0 m_NavMeshData: {fileID: 0} ---- !u!1001 &280358773 +--- !u!1001 &164932925 PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: serializedVersion: 3 - m_TransformParent: {fileID: 1465108593} + m_TransformParent: {fileID: 1651663558} m_Modifications: - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} propertyPath: m_Pivot.x @@ -220,12 +220,12 @@ PrefabInstance: m_AddedGameObjects: [] m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} ---- !u!224 &280358774 stripped +--- !u!224 &164932926 stripped RectTransform: m_CorrespondingSourceObject: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} - m_PrefabInstance: {fileID: 280358773} + m_PrefabInstance: {fileID: 164932925} m_PrefabAsset: {fileID: 0} ---- !u!1 &1128201650 +--- !u!1 &168299017 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -233,10 +233,10 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1128201654} - - component: {fileID: 1128201653} - - component: {fileID: 1128201652} - - component: {fileID: 1128201651} + - component: {fileID: 168299021} + - component: {fileID: 168299020} + - component: {fileID: 168299019} + - component: {fileID: 168299018} m_Layer: 0 m_Name: Player m_TagString: MainCamera @@ -244,34 +244,34 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1128201651 +--- !u!114 &168299018 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1128201650} + m_GameObject: {fileID: 168299017} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 24ed968bbf5646bfaecd042b6fbcd97e, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::M3DebugDriver showOverlay: 1 ---- !u!81 &1128201652 +--- !u!81 &168299019 AudioListener: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1128201650} + m_GameObject: {fileID: 168299017} m_Enabled: 1 ---- !u!20 &1128201653 +--- !u!20 &168299020 Camera: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1128201650} + m_GameObject: {fileID: 168299017} m_Enabled: 1 serializedVersion: 2 m_ClearFlags: 1 @@ -316,13 +316,13 @@ Camera: m_OcclusionCulling: 1 m_StereoConvergence: 10 m_StereoSeparation: 0.022 ---- !u!4 &1128201654 +--- !u!4 &168299021 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1128201650} + m_GameObject: {fileID: 168299017} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 1.6, z: -4} @@ -331,7 +331,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1465108589 +--- !u!1 &448279866 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -339,172 +339,60 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1465108593} - - component: {fileID: 1465108592} - - component: {fileID: 1465108591} - - component: {fileID: 1465108590} + - component: {fileID: 448279869} + - component: {fileID: 448279868} + - component: {fileID: 448279867} m_Layer: 0 - m_Name: Managers + m_Name: Squad Screens m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1465108590 +--- !u!114 &448279867 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1465108589} + m_GameObject: {fileID: 448279866} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: ae6967dcfcba4d74aed5c0275422655b, type: 3} + m_Script: {fileID: 11500000, guid: 472e785512124d0b833406f0236dbefd, type: 3} m_Name: - m_EditorClassIdentifier: Assembly-CSharp::M3Session - seed: 0 - autoStart: 1 - logHandovers: 1 ---- !u!114 &1465108591 + m_EditorClassIdentifier: Assembly-CSharp::M3JournalWriter + writeOnSessionEnd: 1 +--- !u!114 &448279868 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1465108589} + m_GameObject: {fileID: 448279866} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: b41c0e615f2e4e64b92f966fccdfb299, type: 3} + m_Script: {fileID: 11500000, guid: b9612f98ce22409390fd09f0cdf17959, type: 3} m_Name: - m_EditorClassIdentifier: Assembly-CSharp::Module3Parameters - logOnReceive: 1 ---- !u!114 &1465108592 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1465108589} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: a2c0fb59e797f9c4394b260c04181050, type: 3} - m_Name: - m_EditorClassIdentifier: Assembly-CSharp::LocalizationManager - currentLanguage: English ---- !u!4 &1465108593 + m_EditorClassIdentifier: Assembly-CSharp::M3Hud + font: {fileID: 11400000, guid: d54e351c41d2e4344b35f0bf3e77ec76, type: 2} + baseFontSize: 28 + handoverSeconds: 12 +--- !u!4 &448279869 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1465108589} + m_GameObject: {fileID: 448279866} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 280358774} - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1556478078 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1556478080} - - component: {fileID: 1556478079} - m_Layer: 0 - m_Name: Directional Light - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!108 &1556478079 -Light: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1556478078} - m_Enabled: 1 - serializedVersion: 12 - m_Type: 1 - m_Color: {r: 1, g: 1, b: 1, a: 1} - m_Intensity: 1.1 - m_Range: 10 - m_SpotAngle: 30 - m_InnerSpotAngle: 21.80208 - m_CookieSize2D: {x: 0.5, y: 0.5} - m_Shadows: - m_Type: 2 - m_Resolution: -1 - m_CustomResolution: -1 - m_Strength: 1 - m_Bias: 0.05 - m_NormalBias: 0.4 - m_NearPlane: 0.2 - m_CullingMatrixOverride: - e00: 1 - e01: 0 - e02: 0 - e03: 0 - e10: 0 - e11: 1 - e12: 0 - e13: 0 - e20: 0 - e21: 0 - e22: 1 - e23: 0 - e30: 0 - e31: 0 - e32: 0 - e33: 1 - m_UseCullingMatrixOverride: 0 - m_Cookie: {fileID: 0} - m_DrawHalo: 0 - m_Flare: {fileID: 0} - m_RenderMode: 0 - m_CullingMask: - serializedVersion: 2 - m_Bits: 4294967295 - m_RenderingLayerMask: 1 - m_Lightmapping: 4 - m_LightShadowCasterMode: 0 - m_AreaSize: {x: 1, y: 1} - m_BounceIntensity: 1 - m_ColorTemperature: 6570 - m_UseColorTemperature: 0 - m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} - m_UseBoundingSphereOverride: 0 - m_UseViewFrustumForShadowCasterCull: 1 - m_ForceVisible: 0 - m_ShadowRadius: 0 - m_ShadowAngle: 0 - m_LightUnit: 1 - m_LuxAtDistance: 1 - m_EnableSpotReflector: 1 ---- !u!4 &1556478080 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1556478078} - serializedVersion: 2 - m_LocalRotation: {x: 0.105271205, y: 0.88241714, z: -0.39287746, w: 0.236443} - m_LocalPosition: {x: 0, y: 0, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1755470773 +--- !u!1 &960010835 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -512,10 +400,10 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1755470777} - - component: {fileID: 1755470776} - - component: {fileID: 1755470775} - - component: {fileID: 1755470774} + - component: {fileID: 960010839} + - component: {fileID: 960010838} + - component: {fileID: 960010837} + - component: {fileID: 960010836} m_Layer: 0 m_Name: Ground m_TagString: Untagged @@ -523,13 +411,13 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 2147483647 m_IsActive: 1 ---- !u!23 &1755470774 +--- !u!23 &960010836 MeshRenderer: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1755470773} + m_GameObject: {fileID: 960010835} m_Enabled: 1 m_CastShadows: 1 m_ReceiveShadows: 1 @@ -572,13 +460,13 @@ MeshRenderer: m_SortingOrder: 0 m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} ---- !u!64 &1755470775 +--- !u!64 &960010837 MeshCollider: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1755470773} + m_GameObject: {fileID: 960010835} m_Material: {fileID: 0} m_IncludeLayers: serializedVersion: 2 @@ -594,21 +482,21 @@ MeshCollider: m_Convex: 0 m_CookingOptions: 30 m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} ---- !u!33 &1755470776 +--- !u!33 &960010838 MeshFilter: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1755470773} + m_GameObject: {fileID: 960010835} m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} ---- !u!4 &1755470777 +--- !u!4 &960010839 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1755470773} + m_GameObject: {fileID: 960010835} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 18, y: 0, z: 8} @@ -617,7 +505,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1874886780 +--- !u!1 &1315144566 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -625,8 +513,8 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1874886782} - - component: {fileID: 1874886781} + - component: {fileID: 1315144568} + - component: {fileID: 1315144567} m_Layer: 0 m_Name: Neighbourhood m_TagString: Untagged @@ -634,13 +522,13 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1874886781 +--- !u!114 &1315144567 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1874886780} + m_GameObject: {fileID: 1315144566} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 5c49cece3445418896358bc6162cd43c, type: 3} @@ -656,18 +544,191 @@ MonoBehaviour: screenOffset: {x: 1.6, y: 1.4, z: 0.1} fanOffset: {x: -1.4, y: 0, z: 2} villagerSpacing: 0.9 ---- !u!4 &1874886782 +--- !u!4 &1315144568 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1315144566} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1651663554 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1651663558} + - component: {fileID: 1651663557} + - component: {fileID: 1651663556} + - component: {fileID: 1651663555} + m_Layer: 0 + m_Name: Managers + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1651663555 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1651663554} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: ae6967dcfcba4d74aed5c0275422655b, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3Session + seed: 0 + autoStart: 1 + logHandovers: 1 +--- !u!114 &1651663556 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1651663554} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b41c0e615f2e4e64b92f966fccdfb299, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::Module3Parameters + logOnReceive: 1 +--- !u!114 &1651663557 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1651663554} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a2c0fb59e797f9c4394b260c04181050, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::LocalizationManager + currentLanguage: English +--- !u!4 &1651663558 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1874886780} + m_GameObject: {fileID: 1651663554} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 164932926} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1940555583 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1940555585} + - component: {fileID: 1940555584} + m_Layer: 0 + m_Name: Directional Light + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!108 &1940555584 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1940555583} + m_Enabled: 1 + serializedVersion: 12 + m_Type: 1 + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_Intensity: 1.1 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 0.5, y: 0.5} + m_Shadows: + m_Type: 2 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShadowRadius: 0 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &1940555585 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1940555583} + serializedVersion: 2 + m_LocalRotation: {x: 0.105271205, y: 0.88241714, z: -0.39287746, w: 0.236443} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} @@ -675,8 +736,9 @@ Transform: SceneRoots: m_ObjectHideFlags: 0 m_Roots: - - {fileID: 1556478080} - - {fileID: 1755470777} - - {fileID: 1465108593} - - {fileID: 1874886782} - - {fileID: 1128201654} + - {fileID: 1940555585} + - {fileID: 960010839} + - {fileID: 1651663558} + - {fileID: 1315144568} + - {fileID: 168299021} + - {fileID: 448279869} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs new file mode 100644 index 00000000..bc7c1873 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs @@ -0,0 +1,269 @@ +using System.Collections.Generic; +using System.Text; +using Aedes.Module3.Sim; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +/// +/// Everything the squad reads: the running HUD, the handover screen, and the debrief. +/// +/// Built for the CAST, not for the headset. Section 9 puts two of the three students outside the +/// headset with jobs to do - the Coach holds the map of what has been visited and warns the Pilot +/// what is being missed, the Analyst writes the record - and both of them are looking at a phone +/// or a laptop screen mirroring the play. Section 8 adds that the countdown "should be visible to +/// the Coach, not only to the Pilot". +/// +/// So this is a screen-space overlay with large type. A HUD that only works inside the headset +/// would leave two thirds of the squad watching, which is the failure mode the squad structure +/// exists to prevent. +/// +/// The canvas is built in code so the scene file stays small enough to merge - see +/// Module3SceneBuilder. +/// +public class M3Hud : MonoBehaviour +{ + [SerializeField] private TMP_FontAsset font; + [SerializeField] private int baseFontSize = 28; + + [Tooltip("Seconds the handover screen stays up before the next turn can start.")] + [SerializeField] private float handoverSeconds = 12f; + + private TMP_Text statusLine; + private TMP_Text coachPanel; + private GameObject handoverPanel; + private TMP_Text handoverText; + private GameObject debriefPanel; + private TMP_Text debriefText; + + private float handoverShownAt = -1f; + private readonly StringBuilder sb = new StringBuilder(); + + private void Awake() + { + BuildCanvas(); + } + + private void OnEnable() + { + if (M3Session.Instance == null) return; + M3Session.Instance.OnHandover += ShowHandover; + M3Session.Instance.OnSessionFinished += ShowDebrief; + M3Session.Instance.OnActionResolved += ShowActionFeedback; + } + + private void OnDisable() + { + if (M3Session.Instance == null) return; + M3Session.Instance.OnHandover -= ShowHandover; + M3Session.Instance.OnSessionFinished -= ShowDebrief; + M3Session.Instance.OnActionResolved -= ShowActionFeedback; + } + + private void Update() + { + UpdateStatusLine(); + + if (handoverShownAt > 0f && Time.time - handoverShownAt > handoverSeconds) + { + handoverShownAt = -1f; + if (handoverPanel != null) handoverPanel.SetActive(false); + } + } + + // ------------------------------------------------------------------------------------- + + private void UpdateStatusLine() + { + var s = M3Session.Instance; + if (s == null || s.Session == null || statusLine == null) return; + + var sim = s.Neighbourhood; + float left = Mathf.Max(0f, s.TurnSecondsRemaining); + + // The countdown is the Coach's instrument as much as the Pilot's, so it goes first and + // turns red where a class can see it from across a table. + string clock = $"{Mathf.FloorToInt(left / 60f)}:{Mathf.FloorToInt(left % 60f):00}"; + string colour = left <= 30f ? "#E4572E" : "#F2F2F2"; + + statusLine.text = + $"{clock} " + + $"Turn {s.Session.TurnIndex + 1}/{s.Session.TotalTurns} " + + $"Round {s.Session.RoundIndex + 1} Player {s.Session.PilotIndex + 1} " + + $"Day {sim.Day} Nets {sim.NetsRemaining}"; + } + + private void ShowActionFeedback(ActionResult result) + { + if (coachPanel == null || string.IsNullOrEmpty(result.MessageKey)) return; + + // Section 10: an unnecessary referral is "gently corrected, not punished", so this reads + // as advice rather than as an error, and nothing about it is red. + coachPanel.text = Localized(result.MessageKey); + } + + private void ShowHandover(HandoverBrief brief) + { + if (handoverPanel == null) return; + + sb.Clear(); + sb.AppendLine($"{Localized(CoachBrief.TimeJumpKey(brief.DaysElapsed), brief.DaysElapsed)}"); + sb.AppendLine(); + + var lines = CoachBrief.Compose(brief); + if (lines.Count == 0) + { + sb.AppendLine(Localized("m3.coach.nothingUrgent")); + } + else + { + for (int i = 0; i < lines.Count; i++) + { + var l = lines[i]; + string mark = l.Urgency == BriefUrgency.Now ? "!" + : l.Urgency == BriefUrgency.Soon ? "-" + : "."; + sb.AppendLine($"{mark} {Localized(l.MessageKey, l.Args)}"); + } + } + + sb.AppendLine(); + sb.AppendLine($"{Localized("m3.coach.handItOver")}"); + + handoverText.text = sb.ToString(); + handoverPanel.SetActive(true); + handoverShownAt = Time.time; + } + + private void ShowDebrief(HandoverBrief finalBrief) + { + if (debriefPanel == null) return; + + var s = M3Session.Instance; + var sim = s.Neighbourhood; + var map = OutbreakMap.Build(sim); + + // Section 5 fixes this order: the map, then the replay, and only then the score. + // "At the end, before any score is shown, the game replays what would have happened." + sb.Clear(); + sb.AppendLine($"{Localized("m3.debrief.title")}"); + sb.AppendLine(); + sb.AppendLine(Localized("m3.debrief.caseCount", map.TotalCases, map.SecondaryCases)); + sb.AppendLine(Localized("m3.debrief.preventable", map.PreventableCases)); + + if (map.HouseholdsWhereTheChainStopped.Count > 0) + { + sb.AppendLine(Localized("m3.debrief.chainsStopped", map.HouseholdsWhereTheChainStopped.Count)); + } + + sb.AppendLine(); + var counterfactual = new CounterfactualComparison + { + AsPlayed = map, + HadPatientZeroBeenCovered = OutbreakMap.Build(s.BuildCounterfactual().Neighbourhood), + }; + sb.AppendLine($"{Localized(counterfactual.HeadlineKey)}"); + if (!counterfactual.SquadMatchedIt) + { + sb.AppendLine(Localized("m3.debrief.casesAvoided", counterfactual.CasesAvoided)); + } + + sb.AppendLine(); + sb.AppendLine(Localized(InvisibleCaseDebrief.PromptKeyFor(map), map.InvisibleCases)); + + sb.AppendLine(); + var card = Scoring.Evaluate(sim, s.Session); + for (int i = 0; i < card.Lines.Count; i++) + { + var line = card.Lines[i]; + string text = Localized(line.MessageKey); + sb.AppendLine(line.IsNote + ? $"{text} (x{line.Count})" + : $"{text} (x{line.Count})"); + } + + sb.AppendLine(); + sb.AppendLine($"{FieldJournalExport.ClosingPrompt}"); + + debriefText.text = sb.ToString(); + debriefPanel.SetActive(true); + if (handoverPanel != null) handoverPanel.SetActive(false); + } + + private static string Localized(string key, params object[] args) + { + if (string.IsNullOrEmpty(key)) return ""; + if (LocalizationManager.Instance == null) return key; + return args == null || args.Length == 0 + ? LocalizationManager.Instance.GetLocalizedValue(key) + : LocalizationManager.Instance.GetLocalizedValue(key, args); + } + + // ------------------------------------------------------------------------------------- + + private void BuildCanvas() + { + var canvasGo = new GameObject("M3 HUD Canvas"); + canvasGo.transform.SetParent(transform, false); + + var canvas = canvasGo.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + + var scaler = canvasGo.AddComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = new Vector2(1280f, 720f); + + statusLine = Text(canvasGo.transform, "Status", new Vector2(0f, 1f), new Vector2(1f, 1f), + new Vector2(24f, -48f), new Vector2(-24f, -8f), TextAlignmentOptions.TopLeft, baseFontSize); + + coachPanel = Text(canvasGo.transform, "Feedback", new Vector2(0f, 0f), new Vector2(1f, 0f), + new Vector2(24f, 16f), new Vector2(-24f, 72f), TextAlignmentOptions.BottomLeft, baseFontSize - 4); + + handoverPanel = Panel(canvasGo.transform, "Handover", new Color(0.04f, 0.05f, 0.07f, 0.94f)); + handoverText = Text(handoverPanel.transform, "HandoverText", Vector2.zero, Vector2.one, + new Vector2(96f, 72f), new Vector2(-96f, -72f), TextAlignmentOptions.TopLeft, baseFontSize + 4); + handoverPanel.SetActive(false); + + debriefPanel = Panel(canvasGo.transform, "Debrief", new Color(0.04f, 0.05f, 0.07f, 0.97f)); + debriefText = Text(debriefPanel.transform, "DebriefText", Vector2.zero, Vector2.one, + new Vector2(96f, 56f), new Vector2(-96f, -56f), TextAlignmentOptions.TopLeft, baseFontSize); + debriefPanel.SetActive(false); + } + + private static GameObject Panel(Transform parent, string name, Color colour) + { + var go = new GameObject(name, typeof(RectTransform)); + go.transform.SetParent(parent, false); + + var rt = (RectTransform)go.transform; + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.one; + rt.offsetMin = Vector2.zero; + rt.offsetMax = Vector2.zero; + + go.AddComponent().color = colour; + return go; + } + + private TMP_Text Text(Transform parent, string name, Vector2 anchorMin, Vector2 anchorMax, + Vector2 offsetMin, Vector2 offsetMax, TextAlignmentOptions align, int size) + { + var go = new GameObject(name, typeof(RectTransform)); + go.transform.SetParent(parent, false); + + var rt = (RectTransform)go.transform; + rt.anchorMin = anchorMin; + rt.anchorMax = anchorMax; + rt.offsetMin = offsetMin; + rt.offsetMax = offsetMax; + + var text = go.AddComponent(); + if (font != null) text.font = font; + text.fontSize = size; + text.alignment = align; + text.color = new Color(0.95f, 0.95f, 0.95f); + text.richText = true; + text.text = ""; + return text; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs.meta new file mode 100644 index 00000000..823861dd --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b9612f98ce22409390fd09f0cdf17959 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs new file mode 100644 index 00000000..6d7b6575 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using Aedes.Module3.Sim; +using UnityEngine; + +/// +/// Writes the squad's Field Journal to disk when the session ends. +/// +/// Section 9 makes the Analyst's record how the squad plays rather than paperwork bolted on +/// afterwards, and section 5 has the facilitator read the closing answers aloud. On a Quest the +/// file lands in the app's own storage and comes off with `adb pull`; in the editor it goes in +/// the project's persistent data path. +/// +public class M3JournalWriter : MonoBehaviour +{ + [SerializeField] private bool writeOnSessionEnd = true; + + public string LastWrittenPath { get; private set; } + + private void OnEnable() + { + if (M3Session.Instance != null) M3Session.Instance.OnSessionFinished += Write; + } + + private void OnDisable() + { + if (M3Session.Instance != null) M3Session.Instance.OnSessionFinished -= Write; + } + + private void Write(HandoverBrief finalBrief) + { + if (!writeOnSessionEnd) return; + + var s = M3Session.Instance; + if (s == null || s.Session == null) return; + + var map = OutbreakMap.Build(s.Neighbourhood); + var comparison = new CounterfactualComparison + { + AsPlayed = map, + HadPatientZeroBeenCovered = OutbreakMap.Build(s.BuildCounterfactual().Neighbourhood), + }; + + var export = FieldJournal.Build(s.Neighbourhood, s.Session, comparison); + + try + { + string dir = Path.Combine(Application.persistentDataPath, "Module3Journals"); + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, + $"journal_{DateTime.Now:yyyyMMdd_HHmmss}_seed{s.Seed}.txt"); + File.WriteAllText(path, export.ToPlainText()); + + LastWrittenPath = path; + Debug.Log($"Module 3: Field Journal written to {path}"); + } + catch (Exception e) + { + // Never let a failed write take the debrief down with it - the class is still in + // the room and the screen still has to show them their outbreak. + Debug.LogError($"Module 3: could not write the Field Journal: {e.Message}"); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs.meta new file mode 100644 index 00000000..219ba2d0 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 472e785512124d0b833406f0236dbefd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Sim/CoachBrief.cs b/Assets/1.TeamWorkspace/Module3/Sim/CoachBrief.cs new file mode 100644 index 00000000..ccf2ab4e --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/CoachBrief.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; + +namespace Aedes.Module3.Sim +{ + public enum BriefUrgency + { + /// Someone needs a doctor. Section 4: not a judgement call. + Now, + /// Work waiting: a patient with no net, a house nobody has been to. + Soon, + /// Context the incoming Pilot should have. + Background, + } + + public struct BriefLine + { + public BriefUrgency Urgency; + public string MessageKey; + public object[] Args; + /// Where to look, for the Coach's map. -1 when it is not about one house. + public int HouseholdId; + } + + /// + /// Turns a handover into the few sentences the Coach actually says out loud. + /// + /// Section 9: the Coach "holds the map of which households have been visited and when, and + /// warns the Pilot what is being missed", and the squad "plays from the journal, not from + /// memory". Section 8 gives them two to four minutes to do it in, which is the real design + /// constraint here - a screen listing everything is a screen nobody reads aloud. So this is + /// ordered by urgency and capped. + /// + /// It lives in the engine-free assembly so the ordering can be tested. What the Coach is told + /// first decides what the squad does next, and that is too important to leave to a UI layout. + /// + public static class CoachBrief + { + /// + /// How many lines the Coach gets. More than this and it stops being something a + /// fourteen-year-old reads out in the time it takes to pass a headset over. + /// + public const int MaxLines = 6; + + public static List Compose(HandoverBrief brief, int maxLines = MaxLines) + { + var lines = new List(); + if (brief == null) return lines; + + // 1. Anyone who needs a doctor, always first and never truncated away. + for (int i = 0; i < brief.NeedReferralNow.Count; i++) + { + var note = brief.NeedReferralNow[i]; + lines.Add(new BriefLine + { + Urgency = BriefUrgency.Now, + MessageKey = note.MessageKey, + Args = new object[] { note.HouseholdId }, + HouseholdId = note.HouseholdId, + }); + } + + // 2. The trap: someone whose fever has broken. Section 8 - "A Pilot who sees someone + // looking better and moves on has made the mistake the module teaches." It outranks + // uncovered patients because it is the one the squad will otherwise walk past. + for (int i = 0; i < brief.LookingBetterButWatch.Count; i++) + { + var note = brief.LookingBetterButWatch[i]; + lines.Add(new BriefLine + { + Urgency = BriefUrgency.Soon, + MessageKey = note.MessageKey, + Args = new object[] { note.HouseholdId }, + HouseholdId = note.HouseholdId, + }); + } + + // 3. Unwell and no net - the work that is actually waiting. + for (int i = 0; i < brief.UncoveredPatients.Count; i++) + { + var note = brief.UncoveredPatients[i]; + lines.Add(new BriefLine + { + Urgency = BriefUrgency.Soon, + MessageKey = note.MessageKey, + Args = new object[] { note.HouseholdId }, + HouseholdId = note.HouseholdId, + }); + } + + // 4. Houses nobody has been to. Section 8: "the Coach should be saying so." + if (brief.HouseholdsNeverVisited.Count > 0) + { + lines.Add(new BriefLine + { + Urgency = BriefUrgency.Soon, + MessageKey = "m3.coach.neverVisited", + Args = new object[] { brief.HouseholdsNeverVisited.Count }, + HouseholdId = brief.HouseholdsNeverVisited[0], + }); + } + + // 5. New cases since the last turn, as one line rather than one each. + if (brief.NewCases.Count > 0) + { + lines.Add(new BriefLine + { + Urgency = BriefUrgency.Background, + MessageKey = "m3.coach.newCases", + Args = new object[] { brief.NewCases.Count }, + HouseholdId = -1, + }); + } + + if (brief.NetsRemaining == 0) + { + lines.Add(new BriefLine + { + Urgency = BriefUrgency.Background, + MessageKey = "m3.coach.noNetsLeft", + Args = null, + HouseholdId = -1, + }); + } + + // Truncate, but never drop a referral - if the cap would cut one, the cap loses. + if (lines.Count <= maxLines) return lines; + + var trimmed = new List(); + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].Urgency == BriefUrgency.Now) trimmed.Add(lines[i]); + } + for (int i = 0; i < lines.Count && trimmed.Count < maxLines; i++) + { + if (lines[i].Urgency != BriefUrgency.Now) trimmed.Add(lines[i]); + } + return trimmed; + } + + /// + /// The time jump, as plain words rather than a number. Section 8: 'The screen states + /// plainly how far: "Three days later."' + /// + public static string TimeJumpKey(int days) + { + if (days <= 0) return "m3.coach.sameDay"; + return days == 1 ? "m3.coach.oneDayLater" : "m3.coach.daysLater"; + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Sim/CoachBrief.cs.meta b/Assets/1.TeamWorkspace/Module3/Sim/CoachBrief.cs.meta new file mode 100644 index 00000000..015d018c --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Sim/CoachBrief.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 82644126f0774b2895a87bb682c2473e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/CoachBriefTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/CoachBriefTests.cs new file mode 100644 index 00000000..95c24d99 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/CoachBriefTests.cs @@ -0,0 +1,157 @@ +using Aedes.Module3.Sim; +using NUnit.Framework; + +namespace Aedes.Module3.Sim.Tests +{ + /// + /// What the Coach is told, and in what order. + /// + /// This is not cosmetic. Section 9 gives the Coach the job of warning the Pilot what is being + /// missed, and section 8 gives them the length of a handover to do it in. Whatever is at the + /// top of that list is what the squad does next, so the ordering is a teaching decision and + /// belongs in tests rather than in a UI layout. + /// + [TestFixture] + public class CoachBriefTests + { + private static Session PlayTo(int seed, System.Func until, int maxTurns = 50) + { + var n = ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default); + var s = new Session(n); + for (int t = 0; t < maxTurns && !s.Finished; t++) + { + var brief = s.EndTurn(); + if (brief != null && until(brief)) return s; + } + return s; + } + + [Test] + public void AnyoneNeedingADoctorComesFirst() + { + // Section 4 calls the referral rule the heart of the module. If it is third on a list + // a fourteen-year-old is reading aloud against a clock, it may as well not be there. + for (int seed = 1; seed <= 40; seed++) + { + var n = ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default); + var s = new Session(n); + + while (!s.Finished) + { + var brief = s.EndTurn(); + if (brief == null || brief.NeedReferralNow.Count == 0) continue; + + var lines = CoachBrief.Compose(brief); + Assert.Greater(lines.Count, 0); + Assert.AreEqual(BriefUrgency.Now, lines[0].Urgency, + $"seed {seed}: somebody needed a doctor and it was not the first thing said"); + } + } + } + + [Test] + public void AReferralIsNeverTruncatedAwayByTheLineCap() + { + // The cap exists so the brief stays sayable. It must not be able to hide the one + // thing the module most wants a student to act on. + var brief = new HandoverBrief { DaysElapsed = 3, NetsRemaining = 0 }; + for (int i = 0; i < 10; i++) + { + brief.UncoveredPatients.Add(new PersonNote { PersonId = i, HouseholdId = i, MessageKey = "m3.brief.uncovered" }); + brief.HouseholdsNeverVisited.Add(i); + } + brief.NeedReferralNow.Add(new PersonNote { PersonId = 99, HouseholdId = 4, MessageKey = "m3.brief.bleeding" }); + + var lines = CoachBrief.Compose(brief, maxLines: 3); + + Assert.LessOrEqual(lines.Count, 3); + Assert.AreEqual(BriefUrgency.Now, lines[0].Urgency); + Assert.AreEqual("m3.brief.bleeding", lines[0].MessageKey); + } + + [Test] + public void SomeoneWhoLooksBetterIsFlaggedAheadOfSomeoneWhoLooksIll() + { + // Section 8: the Pilot who sees someone improving and moves on has made the module's + // mistake. A patient who is visibly unwell will get attention anyway; the one who + // seems fine will not, unless the Coach says so. + var brief = new HandoverBrief { DaysElapsed = 3, NetsRemaining = 2 }; + brief.UncoveredPatients.Add(new PersonNote { PersonId = 1, HouseholdId = 1, MessageKey = "m3.brief.uncovered" }); + brief.LookingBetterButWatch.Add(new PersonNote { PersonId = 2, HouseholdId = 2, MessageKey = "m3.brief.lookingBetter" }); + + var lines = CoachBrief.Compose(brief); + + int better = lines.FindIndex(l => l.MessageKey == "m3.brief.lookingBetter"); + int uncovered = lines.FindIndex(l => l.MessageKey == "m3.brief.uncovered"); + Assert.Greater(better, -1); + Assert.Greater(uncovered, -1); + Assert.Less(better, uncovered, + "the patient who looks better has to be mentioned before the one who obviously is not"); + } + + [Test] + public void TheBriefStaysShortEnoughToSayOutLoud() + { + for (int seed = 1; seed <= 40; seed++) + { + var n = ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default); + var s = new Session(n); + while (!s.Finished) + { + var brief = s.EndTurn(); + if (brief == null) continue; + + int urgent = brief.NeedReferralNow.Count; + var lines = CoachBrief.Compose(brief); + + // The cap yields only to referrals, and only as far as it must. + Assert.LessOrEqual(lines.Count, System.Math.Max(CoachBrief.MaxLines, urgent), + $"seed {seed}: a handover brief ran to {lines.Count} lines"); + } + } + } + + [Test] + public void ABriefWithNothingUrgentStillPointsSomewhere() + { + var brief = new HandoverBrief { DaysElapsed = 3, NetsRemaining = 4 }; + brief.HouseholdsNeverVisited.Add(6); + + var lines = CoachBrief.Compose(brief); + Assert.Greater(lines.Count, 0, "the Coach was left with nothing to say"); + Assert.AreEqual(6, lines[0].HouseholdId, "the brief should point at the house nobody has been to"); + } + + [Test] + public void TheTimeJumpIsStatedInWordsNotJustANumber() + { + // Section 8: 'The screen states plainly how far: "Three days later."' + Assert.AreEqual("m3.coach.oneDayLater", CoachBrief.TimeJumpKey(1)); + Assert.AreEqual("m3.coach.daysLater", CoachBrief.TimeJumpKey(3)); + Assert.AreEqual("m3.coach.sameDay", CoachBrief.TimeJumpKey(0)); + } + + [Test] + public void EveryLineCarriesAHouseToLookAtOrIsDeliberatelyGeneral() + { + // The Coach is holding the map. A warning they cannot locate is not much use. + for (int seed = 1; seed <= 20; seed++) + { + var n = ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default); + var s = new Session(n); + while (!s.Finished) + { + var brief = s.EndTurn(); + if (brief == null) continue; + + foreach (var line in CoachBrief.Compose(brief)) + { + if (line.Urgency == BriefUrgency.Background) continue; + Assert.GreaterOrEqual(line.HouseholdId, 0, + $"'{line.MessageKey}' is urgent but names no house"); + } + } + } + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Tests/CoachBriefTests.cs.meta b/Assets/1.TeamWorkspace/Module3/Tests/CoachBriefTests.cs.meta new file mode 100644 index 00000000..b6e7fac8 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Tests/CoachBriefTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d91efd981f742fd8ca9d3ae68d902bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs index 1fd70b9a..4cf68465 100644 --- a/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs +++ b/Assets/1.TeamWorkspace/Module3/Tests/LocalizationCoverageTests.cs @@ -110,6 +110,9 @@ void Sweep() foreach (var note in brief.LookingBetterButWatch) Add(note.MessageKey); // The trace-back replay shown as each case appears. + foreach (var line in CoachBrief.Compose(brief)) Add(line.MessageKey); + Add(CoachBrief.TimeJumpKey(brief.DaysElapsed)); + foreach (var replay in TraceBack.ForBrief(n, brief)) { Add(replay.SummaryKey); @@ -129,6 +132,14 @@ void Sweep() Add("m3.debrief.chainFromNowhere"); Add("m3.debrief.peopleWhoNeverKnew"); Add("m3.debrief.noInvisibleEvidence"); + foreach (var k in new[] + { + "m3.coach.nothingUrgent", "m3.coach.handItOver", "m3.coach.neverVisited", + "m3.coach.newCases", "m3.coach.noNetsLeft", "m3.coach.sameDay", + "m3.coach.oneDayLater", "m3.coach.daysLater", + "m3.debrief.title", "m3.debrief.caseCount", "m3.debrief.preventable", + "m3.debrief.chainsStopped", "m3.debrief.casesAvoided", + }) Add(k); } return used; diff --git a/Assets/Resources/Localization/LocalizationData.csv b/Assets/Resources/Localization/LocalizationData.csv index 8aea8c77..b515de52 100644 --- a/Assets/Resources/Localization/LocalizationData.csv +++ b/Assets/Resources/Localization/LocalizationData.csv @@ -85,3 +85,18 @@ m3.score.unnecessaryReferral,A fever with no warning signs is better cared for a m3.debrief.chainFromNowhere,One of these chains starts in a house where nobody was ever ill. What could have stopped it?,,, m3.debrief.peopleWhoNeverKnew,{0} people here caught it and never felt a thing. You could not have found them. What would have protected them?,,, m3.debrief.noInvisibleEvidence,"Nobody here carried it without knowing. That is luck, not the usual.",,, +# --- what the Coach reads out at a handover (sections 8 and 9) ---,,,, +m3.coach.daysLater,{0} days later.,,, +m3.coach.oneDayLater,One day later.,,, +m3.coach.sameDay,Later the same day.,,, +m3.coach.neverVisited,{0} houses nobody has been to yet.,,, +m3.coach.newCases,{0} new people have fallen ill since your last turn.,,, +m3.coach.noNetsLeft,No nets left. You can take one back from someone who is past it.,,, +m3.coach.nothingUrgent,Nothing urgent. Go and see the houses you have missed.,,, +m3.coach.handItOver,Hand the headset over.,,, +"# --- the debrief (section 5: map, then replay, then score) ---",,,, +m3.debrief.title,How the outbreak went,,, +m3.debrief.caseCount,"{0} people fell ill. {1} of them caught it here, while you were working.",,, +m3.debrief.preventable,"{0} of those came from someone you could see was unwell, with no net up.",,, +m3.debrief.chainsStopped,You stopped it spreading out of {0} houses.,,, +m3.debrief.casesAvoided,{0} fewer people would have caught it.,,, From 339a276753bce83d9190a0ff0ebc705dd249a178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Sat, 26 Sep 2026 07:07:15 +0700 Subject: [PATCH 20/61] Module 3: roadmap status after the squad layer --- Documentation/Module3-Roadmap.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation/Module3-Roadmap.md b/Documentation/Module3-Roadmap.md index 22a026ed..1a059fb3 100644 --- a/Documentation/Module3-Roadmap.md +++ b/Documentation/Module3-Roadmap.md @@ -23,9 +23,9 @@ Target branch: `module-3` off `main`. | 4 — Consequence and evidence | **Done.** `TraceBack`, `OutbreakMap`, counterfactual comparison, §10 `Scoring`, `FieldJournal`. | | 6 — Art integration | **Props done and merged** (`module3-placeholder-art`). Villager animation clips outstanding — see `Module3-Animation-Brief.md`. | | — Scene | **Done.** `Module_3_MainScene` is generated, 682 lines, and builds a playable neighbourhood at runtime. | -| 5 — Squad layer | Next. The data all exists (`HandoverBrief`, `FieldJournal`); it needs a cast-legible HUD. | -| 7b — Module 2 handoff | Not started. `INeighbourhoodStateSource`; the GAMA parameter half is done. | -| 8 — Build and headset | Not started. **The scene has a plain camera, not an XR rig — Module 3 is not VR yet.** | +| 5 — Squad layer | **Done.** `CoachBrief` (ordering tested in the Sim assembly), `M3Hud` for the cast, the handover screen, the §5-ordered debrief, and `M3JournalWriter`. | +| 7b — Module 2 handoff | Next. `INeighbourhoodStateSource`; the GAMA parameter half is done. | +| 3 remainder / 8 — XR rig, build, headset | Not started. **The scene has a plain camera, not an XR rig — Module 3 is playable but not yet VR.** This is where a headset becomes necessary. | ### Calibration, 200 seeds From 08bf2fb0087c7404a29b41da9817ae03761426e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Sat, 26 Sep 2026 07:09:09 +0700 Subject: [PATCH 21/61] Module 3: give the improving villager its own clip, and send the referred one away Two things the view layer and the brief disagreed about, both found by reading Observation.cs against the brief. A fifth clip, A_Villager_Sit_Improving. The brief's section 4 says someone whose fever has broken must read as visibly better than someone with a fever, and then section 6 mapped both to A_Villager_Sit_Unwell. The animator parameters already separate the two states - Unwell=false, Resting=true is reachable only from defervescence - so this costs no code, only a clip the list was missing. It is the clip worth most care: the household line says they are feeling better and AddRoomSigns contributes nothing about the illness, so the pose is the only honest signal the player gets. VillagerView now hides the body of anyone at the health centre. Nothing was handling AtTheHealthCentre, so a referred villager went on standing in their house looking perfectly well - undoing the feedback for the one action the module most wants to reward, and leaving the household talking to someone who is not there. Hidden rather than deactivated, because deactivating the GameObject would stop OnTurnStarted arriving and strand them there for the rest of the session. Only skinned renderers are touched, so the props keep following the observation. Also recorded in the brief why Unwell/Resting deliberately cannot tell a warning sign apart from a fever: those two must look the same. --- .../Module3/Scripts/VillagerView.cs | 24 ++++++++++++++ Documentation/Module3-Animation-Brief.md | 31 ++++++++++++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs index 3a3a8e64..d2f18d20 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs @@ -75,6 +75,12 @@ public void Refresh() bool unwell = Observation.IsUnwell; bool resting = unwell || Condition == VisibleCondition.Improving; + // Someone who has gone to the health centre is not in the room. Nothing was hiding them, + // so a referred villager went on standing in their house looking perfectly well - which + // quietly undoes the feedback for the one action the module most wants to reward, and + // leaves the household talking to somebody who is not there. + SetBodyVisible(Condition != VisibleCondition.AtTheHealthCentre); + if (animator != null) { animator.SetBool(UnwellParam, unwell); @@ -95,6 +101,24 @@ public void Refresh() Show(drinkingVessel, Observation.HasFluids); } + /// + /// Hides the body without deactivating this GameObject. Deactivating it would stop + /// OnTurnStarted arriving, and the villager would stay at the health centre for the rest of + /// the session even after they came home. + /// + /// Only skinned renderers are touched, which is exactly the character and none of the props, + /// so the cloth and the bucket go on following the observation - and they hide themselves + /// anyway, because an observation of someone at the health centre carries no signs. + /// + private void SetBodyVisible(bool visible) + { + var bodies = GetComponentsInChildren(true); + for (int i = 0; i < bodies.Length; i++) + { + if (bodies[i].enabled != visible) bodies[i].enabled = visible; + } + } + private void MoveToRestingPlace(bool resting) { if (RestingPlace == null) return; diff --git a/Documentation/Module3-Animation-Brief.md b/Documentation/Module3-Animation-Brief.md index bc58fdbe..eac78c85 100644 --- a/Documentation/Module3-Animation-Brief.md +++ b/Documentation/Module3-Animation-Brief.md @@ -82,7 +82,7 @@ useful fact in this brief, and it means: ## 4. The clips -Four clips. All are **idle-class**: the character stays where it is. +Five clips. All are **idle-class**: the character stays where it is. ### C1 — `A_Villager_Idle_Well` A person who is fine, at home, mildly occupied. Standing or sitting, weight shifting occasionally, @@ -127,6 +127,18 @@ while standing, while sitting hunched, or while lying down. (reduced, mostly head). If you only do one, do the upright one. - **Note:** no mouth shapes needed — there is no facial rig and no lip sync. +### C5 — `A_Villager_Sit_Improving` +The same person as C2, a few days later: the fever has broken. Sitting up, **noticeably better than +C2** — straighter back, head up, present in the room, able to hold a conversation — and still not +well. Tired around the edges. Someone you could believe was on the mend. + +- **Loops:** yes. +- **Length:** 8–12 s. +- **Note:** read *How much the body should carry*, immediately below, before animating this one. It + is the hardest clip in the brief and the only one where the body is the sole honest signal, so it + deserves more of your time than C2 and C3 together. If it ends up indistinguishable from C2 the + state may as well not exist; if it reads as fully well, the module misleads instead of teaching. + ### How much the body should carry Four clips cover five things the player can see, because most of what distinguishes those five is @@ -138,7 +150,7 @@ code that decides what a player observes is already written, and it splits the w | Someone well | nothing at all | C1 | | Fever, a few days in | they say *"she has been hot for three days"*, and there is bedding | C2 or C3, tired and heavy | | **Needs a doctor now** | they say *"there is blood"* or *"she cannot keep water down"*, **and** there is a cloth or a bucket in the room | **the same C2 or C3 — do not escalate** | -| **The fever has broken** | they say *"he is feeling better today"* — **and nothing else** | C2, sitting, visibly better than the fever version | +| **The fever has broken** | they say *"he is feeling better today"* — **and nothing else** | **C5**, sitting, visibly better than C2 | Two consequences, and they are the difference between a clip that teaches and one that does not. @@ -201,7 +213,16 @@ Required behaviour: | false | false | `A_Villager_Idle_Well` | | true | false | `A_Villager_Sit_Unwell` | | true | true | `A_Villager_Lie_Unwell` | -| false | true | `A_Villager_Sit_Unwell` — someone whose fever has broken but who is still resting | +| false | true | **`A_Villager_Sit_Improving`** — the fever has broken, and they are still resting | + +Note what the two bools deliberately do **and do not** separate. They tell someone who is improving +apart from someone with a fever, because those must look different. They do **not** tell someone who +needs a doctor apart from someone with a fever, because those must look the *same* — the warning +sign is carried by the line and the prop, and escalating the body would teach the wrong lesson. That +collapse is the design, not a gap in the parameters. + +A villager who has gone to the health centre needs no clip. The game hides their body while they are +away, so whatever the controller is playing goes unseen. `Talking` layers on top of whichever of those is playing, as an additive or upper-body-masked layer. @@ -241,7 +262,9 @@ not part of this delivery. Before delivering, in Unity: 1. Drop `PF_CharacterV1` in a scene, assign `AC_Villager_M3`. -2. Tick `Unwell` and `Resting` on and off in the Animator window and watch the transitions. +2. Tick `Unwell` and `Resting` on and off in the Animator window and watch the transitions. Check + all four combinations, including `Unwell` off with `Resting` on — that is C5, and it is the one + most easily left wired to the wrong clip. 3. Add `PF_Bed` and `PF_MosquitoNet_Deployed` around the lying pose. Confirm no clipping through the net and that the person is readable through it. 4. Watch `A_Villager_Idle_Well` loop for sixty seconds. If you can see the loop point, it needs work. From 7505460ca9c271d81c280d3fba7baa0ea4550736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:28:33 +0700 Subject: [PATCH 22/61] Lao font: make the dynamic atlas readable so glyphs can be added The atlas was switched to dynamic population but its texture kept m_IsReadable: 0, so TextMeshPro could not add any glyph beyond the 60 baked ones: every scene load logged 'Unable to add the requested character ... make the texture readable' (22 times in Module 3), and any Lao text needing another glyph rendered with gaps. --- .../TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset b/Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset index d579fdcc..46935614 100644 --- a/Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset +++ b/Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang SDF_Custom.asset @@ -127,7 +127,7 @@ Texture2D: m_MipsStripped: 0 m_TextureFormat: 1 m_MipCount: 1 - m_IsReadable: 0 + m_IsReadable: 1 m_IsPreProcessed: 0 m_IgnoreMipmapLimit: 1 m_MipmapLimitGroupName: From b9d82f83ac247359178d67c4a0416bd6b65d8045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:28:33 +0700 Subject: [PATCH 23/61] Module 3: space plots 11 m apart so neighbouring houses stop intersecting At 9 m the Lao houses (8.55 m wide, 9.6 m roof) stood 0.45 m apart with their roofs overlapping by 0.57 m - no way through between them and z-fighting where the roofs cross (measured in play). Spacing is visual only; the model's adjacency comes from lanes / households_per_lane. --- .../1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity | 2 +- .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity index 7ab478a5..2cf73180 100644 --- a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity +++ b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity @@ -534,7 +534,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 5c49cece3445418896358bc6162cd43c, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::M3NeighbourhoodBuilder - plotWidth: 9 + plotWidth: 11 laneSpacing: 16 housePrefab: {fileID: 6484222673902141143, guid: 908f7032114933a47b79326aa4169a8b, type: 3} bedPrefab: {fileID: 8638829071811766795, guid: 5e86aaec090b5034cbda1bc9a0cf13c4, type: 3} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs index 8602afe0..6f6ee335 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -19,8 +19,10 @@ public class M3NeighbourhoodBuilder : MonoBehaviour { [Header("Layout (metres)")] [Tooltip("Centre-to-centre spacing along a lane. Section 11 wants plots packed close enough " - + "that the player can see one house from the next.")] - [SerializeField] private float plotWidth = 9f; + + "that the player can see one house from the next. The Lao house is 8.55 m wide with a " + + "9.6 m roof, so below ~10 m neighbours touch and their roofs intersect. Visual only: " + + "adjacency comes from lanes / households_per_lane.")] + [SerializeField] private float plotWidth = 11f; [Tooltip("Distance across the lane, between the two facing rows.")] [SerializeField] private float laneSpacing = 16f; From d53ff5c07520bf89f829019ab4a3c4f1cd575425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:28:33 +0700 Subject: [PATCH 24/61] Module 3: debug driver frames the selected house The desktop camera never moved from the generated spawn point, 1 m from house 0's front wall and facing it, so a session played from the keyboard showed a blank wall throughout and never the house being acted on. The driver now stands the camera in the lane in front of the selected house (Tab cycles), including at session start. --- .../Module3/Scripts/M3DebugDriver.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs index fd6ec999..dfd86012 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs @@ -23,10 +23,17 @@ public class M3DebugDriver : MonoBehaviour private M3Session S => M3Session.Instance; + [Tooltip("Move this camera to the lane in front of the selected house. Without it the desktop " + + "view never moved from the spawn point, which faces house 0's wall.")] + [SerializeField] private bool frameSelectedHouse = true; + private int framedHousehold = -1; + private void Update() { var k = Keyboard.current; - if (k == null || S == null || S.Session == null) return; + if (S == null || S.Session == null) return; + if (frameSelectedHouse && framedHousehold != selectedHousehold) FrameHousehold(selectedHousehold); + if (k == null) return; if (k.tabKey.wasPressedThisFrame) CycleHousehold(k.leftShiftKey.isPressed ? -1 : 1); if (k.qKey.wasPressedThisFrame) CycleResident(-1); @@ -53,6 +60,20 @@ private void CycleHousehold(int delta) selectedResident = 0; } + /// Stand in the lane in front of the house, looking at it. + private void FrameHousehold(int householdId) + { + foreach (var view in FindObjectsByType(FindObjectsSortMode.None)) + { + if (view.householdId != householdId) continue; + var plot = view.transform; + transform.position = plot.position + plot.forward * 7f + Vector3.up * 1.6f; + transform.rotation = Quaternion.LookRotation(plot.position + Vector3.up * 1.2f - transform.position); + framedHousehold = householdId; + return; + } + } + private void CycleResident(int delta) { var h = S.Neighbourhood.HouseholdById(selectedHousehold); From f0ac5fc81c1fe57869867708278922551ff175b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:30:18 +0700 Subject: [PATCH 25/61] Module 3: put the yard containers in the yard, not inside the house The builder placed each plot's productive containers at local z = -1.8, inside the 6.4 m-deep Lao house (checked: every jar sat within the house footprint). They are the visible reason mosquitoes are around (section 5), so they now stand in front of the house on the lane side (yardDepth 4.6, the footprint ends at 3.4). --- .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs index 6f6ee335..e1c349bd 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -33,6 +33,10 @@ public class M3NeighbourhoodBuilder : MonoBehaviour [SerializeField] private GameObject villagerPrefab; [SerializeField] private GameObject containerPrefab; + [Tooltip("Where the yard containers stand, in front of the house on the lane side. The house " + + "footprint ends at z = 3.4.")] + [SerializeField] private float yardDepth = 4.6f; + [Header("Anchors inside a plot (local to the house)")] [SerializeField] private Vector3 bedOffset = new Vector3(0f, 0f, 2.2f); [SerializeField] private Vector3 screenOffset = new Vector3(1.6f, 1.4f, 0.1f); @@ -113,8 +117,10 @@ private void BuildPlot(Neighbourhood sim, Household h) for (int c = 0; c < h.Containers.Count; c++) { if (!h.Containers[c].IsProductive) continue; + // In the lane-side yard, in front of the house. At z = -1.8 they were inside the + // 6.4 m-deep house, where nobody walking the lane could see them. var yard = Anchor(plot.transform, $"Container_{h.Containers[c].Id}", - new Vector3(-2.5f + c * 0.9f, 0f, -1.8f)); + new Vector3(-2.5f + c * 0.9f, 0f, yardDepth)); Instantiate(containerPrefab, yard); } } From a6be003d41c82f9419e53d50356a317563a2e079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:32:34 +0700 Subject: [PATCH 26/61] Module 3: HUD and journal actually subscribe to the session's events M3Hud and M3JournalWriter subscribed in OnEnable only if M3Session.Instance already existed - but the session sets Instance in its own Awake, and Awake/OnEnable order across GameObjects is undefined. In the generated scene they lost the race: OnHandover, OnSessionFinished and OnActionResolved had no listeners (checked in play), so the handover screen, the debrief and action feedback never appeared and the Field Journal was never written. Subscribe again in Start, which runs after every Awake, guarding against doubles. --- .../1.TeamWorkspace/Module3/Scripts/M3Hud.cs | 30 +++++++++++++------ .../Module3/Scripts/M3JournalWriter.cs | 18 +++++++++-- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs index bc7c1873..9d17b763 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs @@ -44,20 +44,32 @@ private void Awake() BuildCanvas(); } - private void OnEnable() + // M3Session sets Instance in its Awake, and Awake/OnEnable order across GameObjects is not + // defined. When this OnEnable ran first it silently subscribed to nothing, so the handover + // screen, the debrief and action feedback never appeared. Start runs after every Awake in the + // scene, so try again there. + private M3Session subscribedTo; + + private void OnEnable() => Subscribe(); + private void Start() => Subscribe(); + + private void Subscribe() { - if (M3Session.Instance == null) return; - M3Session.Instance.OnHandover += ShowHandover; - M3Session.Instance.OnSessionFinished += ShowDebrief; - M3Session.Instance.OnActionResolved += ShowActionFeedback; + var s = M3Session.Instance; + if (s == null || subscribedTo == s) return; + s.OnHandover += ShowHandover; + s.OnSessionFinished += ShowDebrief; + s.OnActionResolved += ShowActionFeedback; + subscribedTo = s; } private void OnDisable() { - if (M3Session.Instance == null) return; - M3Session.Instance.OnHandover -= ShowHandover; - M3Session.Instance.OnSessionFinished -= ShowDebrief; - M3Session.Instance.OnActionResolved -= ShowActionFeedback; + if (subscribedTo == null) return; + subscribedTo.OnHandover -= ShowHandover; + subscribedTo.OnSessionFinished -= ShowDebrief; + subscribedTo.OnActionResolved -= ShowActionFeedback; + subscribedTo = null; } private void Update() diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs index 6d7b6575..dc457c9e 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs @@ -17,14 +17,26 @@ public class M3JournalWriter : MonoBehaviour public string LastWrittenPath { get; private set; } - private void OnEnable() + // Same race as M3Hud: when this OnEnable ran before M3Session's Awake it subscribed to nothing + // and the journal was never written. Start runs after every Awake, so try again there. + private M3Session subscribedTo; + + private void OnEnable() => Subscribe(); + private void Start() => Subscribe(); + + private void Subscribe() { - if (M3Session.Instance != null) M3Session.Instance.OnSessionFinished += Write; + var s = M3Session.Instance; + if (s == null || subscribedTo == s) return; + s.OnSessionFinished += Write; + subscribedTo = s; } private void OnDisable() { - if (M3Session.Instance != null) M3Session.Instance.OnSessionFinished -= Write; + if (subscribedTo == null) return; + subscribedTo.OnSessionFinished -= Write; + subscribedTo = null; } private void Write(HandoverBrief finalBrief) From c007f30e67c9402cb96ca9b6ad8e44f529735669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:33:54 +0700 Subject: [PATCH 27/61] Module 3: the 'nothing urgent' brief no longer sends you to houses you have missed CoachBrief only falls back to m3.coach.nothingUrgent when it has no lines, and an unvisited house always produces a line - so 'Go and see the houses you have missed' was shown exactly when no house had been missed (seen after a turn that visited all ten). Point at return visits instead, which section 8 is about. English only; the Lao column is still to be translated. --- Assets/Resources/Localization/LocalizationData.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Resources/Localization/LocalizationData.csv b/Assets/Resources/Localization/LocalizationData.csv index b515de52..9cd29c90 100644 --- a/Assets/Resources/Localization/LocalizationData.csv +++ b/Assets/Resources/Localization/LocalizationData.csv @@ -92,7 +92,7 @@ m3.coach.sameDay,Later the same day.,,, m3.coach.neverVisited,{0} houses nobody has been to yet.,,, m3.coach.newCases,{0} new people have fallen ill since your last turn.,,, m3.coach.noNetsLeft,No nets left. You can take one back from someone who is past it.,,, -m3.coach.nothingUrgent,Nothing urgent. Go and see the houses you have missed.,,, +m3.coach.nothingUrgent,Nothing urgent. Look in again on anyone who was unwell - people change in three days.,,, m3.coach.handItOver,Hand the headset over.,,, "# --- the debrief (section 5: map, then replay, then score) ---",,,, m3.debrief.title,How the outbreak went,,, From 6422c47cbd5f384c67532fa308214071b5827d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:33:54 +0700 Subject: [PATCH 28/61] Module 3: hold the turn clock while the handover screen is up The next turn's clock started the moment the previous one ended, under the 12 s handover screen - measured: 179.6 -> 173.6 s while the panel was still showing. Every incoming Pilot lost the handover, about 3.6 minutes over an 18-turn session. M3Session.HoldTurnClock(seconds), called by M3Hud when it shows the handover. --- Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs | 2 ++ .../1.TeamWorkspace/Module3/Scripts/M3Session.cs | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs index 9d17b763..7a763a7b 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs @@ -145,6 +145,8 @@ private void ShowHandover(HandoverBrief brief) handoverText.text = sb.ToString(); handoverPanel.SetActive(true); handoverShownAt = Time.time; + // The next Pilot's turn starts when they can see, not while the old one is still reading. + if (M3Session.Instance != null) M3Session.Instance.HoldTurnClock(handoverSeconds); } private void ShowDebrief(HandoverBrief finalBrief) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs index ac30d007..b6b55894 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs @@ -37,6 +37,9 @@ public class M3Session : MonoBehaviour public float TurnSecondsTotal { get; private set; } public bool Running { get; private set; } + /// The turn clock does not run before this time - the handover screen is still up. + private float clockHeldUntil; + public event Action OnTurnStarted; // turn index public event Action OnHandover; public event Action OnSessionFinished; @@ -85,6 +88,7 @@ public void StartSession() private void Update() { if (!Running) return; + if (Time.time < clockHeldUntil) return; TurnSecondsRemaining -= Time.deltaTime; if (TurnSecondsRemaining > 0f) return; @@ -118,6 +122,16 @@ public void EndTurn() OnTurnStarted?.Invoke(Session.TurnIndex); } + /// + /// Holds the turn clock while the handover screen is up. The clock used to start the instant + /// the turn ended, so every incoming Pilot lost the whole handover (12 s of a 180 s turn, + /// measured) before touching anything. + /// + public void HoldTurnClock(float seconds) + { + clockHeldUntil = Mathf.Max(clockHeldUntil, Time.time + seconds); + } + /// Everything the interaction layer routes through. public ActionResult Do(PlayerAction action) { From a86f2c9fa389cdbbc8703d1095827ba3c3a44663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:45:17 +0700 Subject: [PATCH 29/61] Module 3 HUD: status bar says the session is over instead of 'Turn 19/18' After the final handover Session.TurnIndex is one past the end, so under the debrief the bar read 'Turn 19/18 Round 7 Player 1' with a frozen 2:58 clock (harness, full 18-turn session). When the session is not running, show 'Session over' with the day and nets instead. --- Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs index 7a763a7b..3a421b49 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs @@ -91,6 +91,15 @@ private void UpdateStatusLine() if (s == null || s.Session == null || statusLine == null) return; var sim = s.Neighbourhood; + + // After the last handover the session's turn index sits one past the end: the bar read + // "Turn 19/18 Round 7" under the debrief, with the clock frozen. + if (!s.Running) + { + statusLine.text = $"Session over Day {sim.Day} Nets {sim.NetsRemaining}"; + return; + } + float left = Mathf.Max(0f, s.TurnSecondsRemaining); // The countdown is the Coach's instrument as much as the Pilot's, so it goes first and From e052b9dec19c32e2e196043db6977cb900ec31ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:52:03 +0700 Subject: [PATCH 30/61] Module 3: an unneeded referral no longer silences the patient's later warning sign Referring someone without a warning sign is 'gently corrected, never punished' (section 10) - they stay home. But the code still set Person.Referred, and that flag then: - greyed out 'get them to the health centre' ('already going'), - kept their warning sign out of the handover brief, - stopped the unreferred-warning consequence from ever firing, and - scored them as a correct referral once the sign appeared. Found in a playtest by referring a well villager. Referred is now set only when the person actually goes; the unneeded case sets ReferredWithoutNeed, which Scoring counts as UnnecessaryReferrals. A well person is told 'they are well' rather than being described as having a fever. New test AnEarlyReferralDoesNotSilenceALaterWarningSign fails on the old code and passes now; the whole suite (85) passes. --- .../Module3/Sim/Neighbourhood.cs | 17 ++++++---- Assets/1.TeamWorkspace/Module3/Sim/Person.cs | 3 ++ Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs | 2 +- .../Module3/Tests/ObservationTests.cs | 34 +++++++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs index 7f9e9d03..618681bc 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs @@ -159,17 +159,22 @@ public ActionResult Apply(PlayerAction action) { var p = PersonById(action.PersonId); if (p == null) return Fail(action); - bool needed = p.HasVisibleWarningSign(Day); - p.Referred = true; - p.ReferredDay = Day; - if (needed) + if (p.HasVisibleWarningSign(Day)) { + p.Referred = true; + p.ReferredDay = Day; p.State = HealthState.Hospitalised; return Result(action, ActionOutcome.ReferralCorrect, "m3.action.referralCorrect"); } // Section 10: gently corrected, never punished. The person stays at home, - // which is where someone with a fever and no warning signs belongs. - return Result(action, ActionOutcome.ReferralNotNeeded, "m3.action.referralNotNeeded"); + // which is where someone with a fever and no warning signs belongs - so they + // are NOT marked Referred. That flag used to be set here too, and it then + // greyed out the referral action, kept their warning sign out of the handover + // brief, stopped the unreferred-warning consequence from ever firing, and + // scored them as a correct referral when the sign did come. + p.ReferredWithoutNeed = true; + return Result(action, ActionOutcome.ReferralNotNeeded, + p.IsFebrile(Day) ? "m3.action.referralNotNeeded" : "m3.action.referralNotNeededWell"); } } diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Person.cs b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs index 6e5e3f3e..69d755de 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Person.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Person.cs @@ -50,8 +50,11 @@ public sealed class Person public bool HasNet; public bool WearsLongSleeves; public int RepellentUntilDay = -1; + /// Set only when the person actually went to the health centre. public bool Referred; public int ReferredDay = -1; + /// Referred while they did not need it: gently corrected, and they stayed home. + public bool ReferredWithoutNeed; public bool WentToHospitalUnaided; public bool HasRested; public bool HasFluids; diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs b/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs index da08cef5..641efc5a 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Scoring.cs @@ -74,7 +74,7 @@ public static Scorecard Evaluate(Neighbourhood n, Session session) if (p.WentToHospitalUnaided) card.MissedWarningSigns++; else if (p.Referred) card.CorrectReferrals++; } - else if (p.Referred) + else if (p.ReferredWithoutNeed) { card.UnnecessaryReferrals++; } diff --git a/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs index 78931f61..0b9690de 100644 --- a/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs +++ b/Assets/1.TeamWorkspace/Module3/Tests/ObservationTests.cs @@ -172,6 +172,40 @@ public void TheReferralActionIsAlwaysOfferedAndNeverPreJudged() } } + [Test] + public void AnEarlyReferralDoesNotSilenceALaterWarningSign() + { + // Section 10: an unnecessary referral is "gently corrected, never punished". Found in + // a playtest: referring someone before they needed it marked them Referred, which then + // greyed out the referral button and hid their warning sign when it came. + for (int seed = 1; seed < 40; seed++) + { + var n = Build(seed); + Person p = null; + for (int d = 0; d < 40 && p == null; d++) + { + foreach (var q in n.People) + if (q.Warning != WarningSign.None && q.WarningOnsetDay > n.Day && q.State != HealthState.Hospitalised) + { p = q; break; } + if (p == null) n.AdvanceDay(); + } + if (p == null) continue; + + var early = n.Apply(PlayerAction.OnPerson(ActionKind.ReferToHealthCentre, p.Id)); + Assert.AreEqual(ActionOutcome.ReferralNotNeeded, early.Outcome); + + while (n.Day < p.WarningOnsetDay) n.AdvanceDay(); + Assert.IsTrue(p.HasVisibleWarningSign(n.Day), "the warning sign never appeared"); + + var referral = Observe.ActionsFor(n, p.Id).Find(a => a.Kind == ActionKind.ReferToHealthCentre); + Assert.IsTrue(referral.Enabled, "an early, unneeded referral greyed out the one that was needed"); + Assert.AreEqual(ActionOutcome.ReferralCorrect, + n.Apply(PlayerAction.OnPerson(ActionKind.ReferToHealthCentre, p.Id)).Outcome); + return; + } + Assert.Inconclusive("no seed produced a warning sign to test with"); + } + [Test] public void AHouseholdObservationShowsTheYardWithoutOfferingToClearIt() { From 58d1a99bffa95082fda63d43f9a119a299604c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 09:52:03 +0700 Subject: [PATCH 31/61] Module 3 text: consistent 'they' for villagers; handover lines name the house - The sim has no gender, and the English lines picked one at random per line, so one patient was 'She has been hot for 3 days', then 'He says he feels better today', then 'They took her to the health centre'. All villager lines now use 'they'. - The handover brief passes a household id as {0}, so lines rendered as '3 is unwell and has no net' and "3's fever has broken". They now read 'Someone in house 3 ...'. - New m3.action.referralNotNeededWell for referring someone who is well. English column only; the Lao column still needs translating. --- .../Localization/LocalizationData.csv | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/Assets/Resources/Localization/LocalizationData.csv b/Assets/Resources/Localization/LocalizationData.csv index 9cd29c90..784f954a 100644 --- a/Assets/Resources/Localization/LocalizationData.csv +++ b/Assets/Resources/Localization/LocalizationData.csv @@ -8,12 +8,12 @@ human.Data.bodytemperature,BodyTemp,ອຸນຫະພູມ,, module3.title,Vientiane Health Leader,,, "# --- what the household says (section 7: plain language, never clinical terms) ---",,,, m3.say.everyoneFineHere,"Everyone is well here, thank you.",,, -m3.say.hotSinceYesterday,She has been hot since yesterday.,,, -m3.say.hotForDays,She has been hot for {0} days.,,, -m3.say.feelingBetterToday,He says he feels better today.,,, -m3.say.bleeding,Her gums have been bleeding since this morning.,,, -m3.say.cannotKeepWaterDown,He has not kept water down since last night.,,, -m3.say.atHealthCentre,They took her to the health centre.,,, +m3.say.hotSinceYesterday,They have been hot since yesterday.,,, +m3.say.hotForDays,They have been hot for {0} days.,,, +m3.say.feelingBetterToday,They say they feel better today.,,, +m3.say.bleeding,Their gums have been bleeding since this morning.,,, +m3.say.cannotKeepWaterDown,They have not kept water down since last night.,,, +m3.say.atHealthCentre,Someone took them to the health centre.,,, # --- what the player can see in the room (section 7 step 3) ---,,,, m3.see.restingOnTheBed,Resting on the bed,,, m3.see.bucketByTheBed,A bucket beside the bed,,, @@ -34,8 +34,8 @@ m3.do.shutUpAtDawnAndDusk,Shut up at dawn and dusk,,, # --- quiet notes when something is already done ---,,,, m3.why.netAlreadyUp,The net is already up.,,, m3.why.noNetsLeft,There are no nets left. Choose who needs one most.,,, -m3.why.hasWater,She has water beside her.,,, -m3.why.alreadyResting,He is resting already.,,, +m3.why.hasWater,They have water beside them.,,, +m3.why.alreadyResting,They are resting already.,,, m3.why.repellentOn,Repellent is on.,,, m3.why.alreadyGoing,They are already going to the health centre.,,, m3.why.screensAreFine,The screens are in good repair.,,, @@ -43,7 +43,7 @@ m3.why.fanAlreadyOn,The fan is already going.,,, m3.why.alreadyClosing,They already shut up at dawn and dusk.,,, "# --- what the game says back (section 10: corrected, never punished) ---",,,, m3.action.visited,You look in on the household.,,, -m3.action.netUp,The net is up. Mosquitoes cannot reach her while she rests.,,, +m3.action.netUp,The net is up. Mosquitoes cannot reach them while they rest.,,, m3.action.netAlready,The net is already up.,,, m3.action.netReclaimed,You take the net down to use somewhere else.,,, m3.action.noNetsLeft,No nets left. Think about who needs one most.,,, @@ -53,15 +53,16 @@ m3.action.fanOn,The fan is going. Mosquitoes are weak fliers.,,, m3.action.closingHours,They will shut the windows after dawn and before dusk.,,, m3.action.repellent,"Repellent on the skin, for a few hours.",,, m3.action.water,"Plenty to drink, within reach.",,, -m3.action.rest,"Settled, and someone will check on her.",,, +m3.action.rest,"Settled, and someone will check on them.",,, m3.action.referralCorrect,Right call. They are on their way to the health centre now.,,, -m3.action.referralNotNeeded,"No need this time. With a fever and no bleeding or vomiting, she is better cared for at home.",,, +m3.action.referralNotNeeded,"No need this time. With a fever and no bleeding or vomiting, they are better cared for at home.",,, +m3.action.referralNotNeededWell,"No need - they are well. Look in on them again next time.",,, m3.action.invalid,Nothing to do here.,,, # --- the Coach's handover brief (section 8) ---,,,, -m3.brief.bleeding,{0} is bleeding. She needs a doctor now.,,, -m3.brief.vomiting,{0} cannot keep water down. He needs a doctor now.,,, -m3.brief.uncovered,{0} is unwell and has no net.,,, -m3.brief.lookingBetter,{0}'s fever has broken. Keep watching - this is when it can turn.,,, +m3.brief.bleeding,Someone in house {0} is bleeding. They need a doctor now.,,, +m3.brief.vomiting,Someone in house {0} cannot keep water down. They need a doctor now.,,, +m3.brief.uncovered,Someone in house {0} is unwell and has no net.,,, +m3.brief.lookingBetter,Someone in house {0} has stopped being hot. Keep watching - this is when it can turn.,,, # --- trace-back replay (section 5) ---,,,, m3.trace.arrivedIll,They were already ill when you arrived.,,, m3.trace.fromThisContainer,A mosquito came out of this water.,,, From 8f38a2b0a6619a466ba13c5040cb95b90dafa5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 10:01:46 +0700 Subject: [PATCH 32/61] Module 3: judge 'was the source under a net' on the day the mosquito fed TransmissionEvent.SourceWasVisiblyIll was judged on the feeding day, as its comment says, but SourceWasProtected read the source's HasNet on the day the new case was logged - days to weeks later, after extrinsic incubation. A patient bitten before the squad arrived and netted on day 0 was then reported as 'a patient who was under a net' (a playtest journal had 8 such cases), which: - told the class the net had failed when it was never up at the time, and - dropped those cases from WasPreventable / PreventableCases, so netting someone late retroactively erased cases the squad could have seen. The mosquito now records whether its source was netted when it fed. --- Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs | 2 ++ Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs b/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs index e1b7e2af..deb0cbba 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Mosquito.cs @@ -26,6 +26,8 @@ public sealed class Mosquito public int AcquiredFromPersonId = -1; public int AcquiredFromHouseholdId = -1; public int AcquiredOnDay = -1; + /// Whether the person it fed on had a net up at that moment. + public bool AcquiredFromNettedSource; public bool Alive = true; diff --git a/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs index 618681bc..aa766698 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/Neighbourhood.cs @@ -378,6 +378,7 @@ private void Bite() m.AcquiredFromPersonId = target.Id; m.AcquiredFromHouseholdId = target.HouseholdId; m.AcquiredOnDay = Day; + m.AcquiredFromNettedSource = target.HasNet; m.InfectiousFromDay = Day + Rng.Range( Config.ExtrinsicIncubationMinDays, Config.ExtrinsicIncubationMaxDays, Seed, 0, RngStream.MosquitoLifespan, m.Id, 7); @@ -500,7 +501,10 @@ private void Infect(Person target, Mosquito m, Person source) // Judged on the day the mosquito fed, which is the moment the squad could have // acted - not on the day the new case shows up. SourceWasVisiblyIll = source != null && source.IsFebrile(m.AcquiredOnDay), - SourceWasProtected = source != null && source.HasNet, + // Also judged on the feeding day. Reading the source's HasNet *now* reported a + // patient bitten before the squad arrived, and netted later, as "a patient who was + // under a net" - blaming the net, and uncounting a case the squad could see. + SourceWasProtected = source != null && m.AcquiredFromNettedSource, }); } From 99c03bff4d82a31b713c3e53a34399e2243ed7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 10:01:46 +0700 Subject: [PATCH 33/61] Module 3 journal: readable score lines and same-house cases - 'WHAT WENT WELL' printed the raw enum ('ReferredCorrectly x2'): the Sim assembly cannot reach the localization table. ToPlainText takes an optional key lookup, which M3JournalWriter supplies; without one the enum name is split into words. - A case passed on inside one house read 'house 6 caught it from house 6'; it now says 'from someone in the same house'. --- .../Module3/Scripts/M3JournalWriter.cs | 3 +- .../Module3/Sim/FieldJournal.cs | 32 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs index dc457c9e..a49ccdac 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3JournalWriter.cs @@ -62,7 +62,8 @@ private void Write(HandoverBrief finalBrief) string path = Path.Combine(dir, $"journal_{DateTime.Now:yyyyMMdd_HHmmss}_seed{s.Seed}.txt"); - File.WriteAllText(path, export.ToPlainText()); + File.WriteAllText(path, export.ToPlainText(key => + LocalizationManager.Instance != null ? LocalizationManager.Instance.GetLocalizedValue(key) : null)); LastWrittenPath = path; Debug.Log($"Module 3: Field Journal written to {path}"); diff --git a/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs b/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs index 517c025d..d3ee1333 100644 --- a/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs +++ b/Assets/1.TeamWorkspace/Module3/Sim/FieldJournal.cs @@ -26,7 +26,26 @@ public sealed class FieldJournalExport /// The closing prompt. Deliberately the last thing on the page. public const string ClosingPrompt = "Why did the outbreak stop, or why did it not?"; - public string ToPlainText() + /// + /// The score line's player-facing text. The Sim assembly cannot reach the localization + /// table, so the caller passes a lookup; the journal used to print the raw enum + /// ("ReferredCorrectly x2"). Without a lookup the enum name is split into words. + /// + private static string Label(ScoreLine l, System.Func text) + { + string t = text != null && !string.IsNullOrEmpty(l.MessageKey) ? text(l.MessageKey) : null; + if (!string.IsNullOrEmpty(t) && t != l.MessageKey) return t; + string name = l.Reason.ToString(); + var sb = new StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i])) sb.Append(' '); + sb.Append(i > 0 ? char.ToLowerInvariant(name[i]) : name[i]); + } + return sb.ToString(); + } + + public string ToPlainText(System.Func text = null) { var sb = new StringBuilder(); sb.AppendLine("SCIENTIFIC FIELD JOURNAL - Module 3"); @@ -78,9 +97,10 @@ public string ToPlainText() for (int i = 0; i < Score.Lines.Count; i++) { var l = Score.Lines[i]; + string label = Label(l, text); sb.AppendLine(l.IsNote - ? $" - {l.Reason} x{l.Count} (something to talk about, not a mark against you)" - : $" - {l.Reason} x{l.Count}"); + ? $" - {label} x{l.Count} (something to talk about, not a mark against you)" + : $" - {label} x{l.Count}"); } sb.AppendLine(); } @@ -134,8 +154,10 @@ public static FieldJournalExport Build(Neighbourhood n, Session session, : "from a patient we could see was unwell, with no net up") : "from someone who never looked ill"; - export.Chains.Add($"Day {e.Day}: house {e.TargetHouseholdId} caught it " - + $"from house {e.SourceHouseholdId}, {how}."); + string from = e.SourceHouseholdId == e.TargetHouseholdId + ? "from someone in the same house" + : $"from house {e.SourceHouseholdId}"; + export.Chains.Add($"Day {e.Day}: house {e.TargetHouseholdId} caught it {from}, {how}."); } for (int i = 0; i < n.Households.Count; i++) From e7fcdbcbd72581b432375be4f61a0c09fc197296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 10:03:22 +0700 Subject: [PATCH 34/61] Module 3 tests: a net put up after the bite is not blamed for the case Regression test for 8f38a2b: nets each source the moment a mosquito feeds on it un-netted, then checks that mosquito's eventual transmission is not logged as SourceWasProtected. Fails on the old HasNet-at-logging read, passes now. --- .../Module3/Tests/DebriefTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs b/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs index 2af959c5..5780ffb8 100644 --- a/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs +++ b/Assets/1.TeamWorkspace/Module3/Tests/DebriefTests.cs @@ -26,6 +26,38 @@ private static Session PlayOut(int seed, IStrategy strategy = null) // ---- trace-back ------------------------------------------------------------------ + [Test] + public void ANetPutUpAfterTheBiteIsNotBlamedForTheCase() + { + // Found in a playtest journal: patients bitten before the squad arrived and netted on + // day 0 were later reported as "a patient who was under a net", because the net was + // read on the day the new case appeared rather than the day the mosquito fed. + for (int seed = 1; seed < 30; seed++) + { + var n = ScenarioBuilder.Build(seed, Module3Config.Default, ScenarioOptions.Default); + var watched = new HashSet(); + for (int d = 0; d < 60; d++) + { + n.AdvanceDay(); + foreach (var m in n.Mosquitoes) + { + if (m.AcquiredOnDay != n.Day || m.AcquiredFromNettedSource || watched.Contains(m.Id)) continue; + watched.Add(m.Id); + var source = n.PersonById(m.AcquiredFromPersonId); + if (source != null) source.HasNet = true; // netted only after the bite + } + foreach (var e in n.Log.Events) + { + if (!watched.Contains(e.MosquitoId)) continue; + Assert.IsFalse(e.SourceWasProtected, + "a case was blamed on a net that went up after the mosquito had fed"); + return; + } + } + } + Assert.Inconclusive("no watched mosquito transmitted within the window"); + } + [Test] public void EveryCaseCanBeTracedBack() { From 612777084671ac6b2557ef5853970076b62c5b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 10:05:44 +0700 Subject: [PATCH 35/61] Module 3: the room shows each villager's real state from the first turn, and reacts to actions Two refresh-timing bugs in the views, found by looking inside a house with a feverish villager on turn 1: - VillagerView refreshed in OnEnable, which Instantiate/AddComponent runs before the builder sets personId - so every villager showed person 0's condition until the next turn (a febrile patient stood in the room 'Seems_Well'; after the turn she was 'Unwell' and on her bed). HouseholdView did the same before Bind, drawing house 0's state with no anchors and caching it, which could stop the real house's screen from ever being placed. - Neither view refreshed after an action, although HouseholdView.Refresh says it is 'called after any action': a net, screen, fan or water appeared three days later instead of under the Pilot's hands. The builder refreshes each view once bound, both views also refresh on OnActionResolved, and a runtime-added VillagerView wires the Animator and skin renderer from the prefab so clips and materials can plug in. --- .../Module3/Scripts/HouseholdView.cs | 25 ++++++++++++++++--- .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 5 ++++ .../Module3/Scripts/VillagerView.cs | 23 +++++++++++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs index aa8878aa..fbe87852 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs @@ -38,21 +38,40 @@ public void Bind(Transform screen, Transform fan, Transform net) screenMount = screen; fanStand = fan; bedNetAnchor = net; + bound = true; + Refresh(); } + // AddComponent runs OnEnable before the builder has set householdId or called Bind, so an + // unconditional Refresh there drew house 0's state with no anchors - and cached it, which then + // stopped the real house's screen from ever being placed when its state matched. + private bool bound; + private void OnEnable() { - if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted += OnTurnStarted; - Refresh(); + if (M3Session.Instance != null) + { + M3Session.Instance.OnTurnStarted += OnTurnStarted; + M3Session.Instance.OnActionResolved += OnActionResolved; + } + if (bound) Refresh(); } private void OnDisable() { - if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted -= OnTurnStarted; + if (M3Session.Instance != null) + { + M3Session.Instance.OnTurnStarted -= OnTurnStarted; + M3Session.Instance.OnActionResolved -= OnActionResolved; + } } private void OnTurnStarted(int turnIndex) => Refresh(); + // Nothing called Refresh after an action, so a net, screen or fan appeared three days later + // at the next turn instead of when the Pilot put it up. + private void OnActionResolved(ActionResult result) => Refresh(); + /// Called after any action so the room changes under the player's hands. public void Refresh() { diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs index e1c349bd..990768e0 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -143,6 +143,11 @@ private void BuildVillager(Neighbourhood sim, Transform plot, int personId, int if (view == null) view = villager.AddComponent(); view.personId = personId; view.RestingPlace = bed; + view.WireFrom(villager); + // Instantiate/AddComponent already ran OnEnable -> Refresh with personId still 0, so on + // the first turn every villager showed person 0's condition: a patient with a fever stood + // there looking well until the next turn. Refresh now that it knows who it is. + view.Refresh(); Villagers.Add(view); } diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs index d2f18d20..251634ec 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs @@ -52,17 +52,36 @@ public class VillagerView : MonoBehaviour private void OnEnable() { - if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted += OnTurnStarted; + if (M3Session.Instance != null) + { + M3Session.Instance.OnTurnStarted += OnTurnStarted; + M3Session.Instance.OnActionResolved += OnActionResolved; + } Refresh(); } private void OnDisable() { - if (M3Session.Instance != null) M3Session.Instance.OnTurnStarted -= OnTurnStarted; + if (M3Session.Instance != null) + { + M3Session.Instance.OnTurnStarted -= OnTurnStarted; + M3Session.Instance.OnActionResolved -= OnActionResolved; + } } private void OnTurnStarted(int turnIndex) => Refresh(); + // Water, rest and referral change what the room shows; do it when they happen, not at the + // next turn. + private void OnActionResolved(ActionResult result) => Refresh(); + + /// For a view added at runtime to a prefab that was not built for it. + public void WireFrom(GameObject body) + { + if (animator == null) animator = body.GetComponentInChildren(); + if (skinRenderer == null) skinRenderer = body.GetComponentInChildren(); + } + public void Refresh() { var sim = M3Session.Instance != null ? M3Session.Instance.Neighbourhood : null; From 49e8a1f88615d5ec62a8b858056728240a0169fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 10:10:25 +0700 Subject: [PATCH 36/61] Debug overlay: hidden until a controller sequence reveals it SIMPLE's Debug Overlay sat in the middle of the Module 3 view as a grey panel of red console text, in the way of the class and the Coach's cast. It is still wanted for on-headset debugging, so it is gated, not removed: - Headset: hold BOTH grips, then press A, B, A, B on the right controller within 3 s to toggle it. Read through UnityEngine.XR.InputDevices, like Modules 1 and 2. - Editor: the backquote key. Only the overlay's root canvas is hidden; DebugManager keeps collecting lines, so revealing it shows what just happened. The gate attaches itself at runtime to every DebugManager in each loaded scene - no scene edit. Checked in play: hidden on start, backquote toggles; the sequence logic (DebugOverlayGate.Feed) toggles on grips + A B A B, and not without grips, when slower than 3 s, or when a grip is released midway. --- .../Module3/Scripts/DebugOverlayGate.cs | 142 ++++++++++++++++++ .../Module3/Scripts/DebugOverlayGate.cs.meta | 2 + 2 files changed, 144 insertions(+) create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/DebugOverlayGate.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/DebugOverlayGate.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/DebugOverlayGate.cs b/Assets/1.TeamWorkspace/Module3/Scripts/DebugOverlayGate.cs new file mode 100644 index 00000000..55a28b0b --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/DebugOverlayGate.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.SceneManagement; + +/// +/// Keeps SIMPLE's in-headset "Debug Overlay" (DebugManager) hidden until someone asks for it. +/// +/// The overlay was always on: a grey panel of console text in the middle of the view, which is +/// in the way of the class and of the Coach watching the cast. It is still wanted for on-headset +/// debugging, so it is gated rather than removed. +/// +/// Headset: hold BOTH grips, then press A, B, A, B on the right controller within 3 seconds. +/// Editor: the backquote key ( ` ). +/// +/// Only the canvases are hidden, never the GameObject: DebugManager keeps collecting log lines +/// while hidden, so revealing it shows what just happened. +/// +/// Attached automatically to every DebugManager in each loaded scene, so no scene edit is +/// needed. The controllers are read through UnityEngine.XR.InputDevices, the API Modules 1 and 2 +/// already rely on for the Quest. +/// +public class DebugOverlayGate : MonoBehaviour +{ + public enum Press { A, B } + + /// The sequence pressed on the right controller while both grips are held. + public static readonly Press[] Sequence = { Press.A, Press.B, Press.A, Press.B }; + + [Tooltip("Seconds allowed from the first press of the sequence to the last.")] + [SerializeField] private float window = 3f; + [SerializeField] private bool visibleOnStart = false; + + public bool Visible { get; private set; } + + private Canvas[] canvases; + private int progress; + private float startedAt; + private bool lastA, lastB; + private static readonly List devices = new List(); + + private void Awake() + { + // DebugManager sits on the text object; the canvas (and its background) is a parent. + var parent = GetComponentInParent(true); + canvases = parent != null ? new[] { parent.rootCanvas } : GetComponentsInChildren(true); + SetVisible(visibleOnStart); + } + + private void Update() + { + var keyboard = Keyboard.current; + if (keyboard != null && keyboard.backquoteKey.wasPressedThisFrame) SetVisible(!Visible); + + ReadControllers(out bool gripsHeld, out bool a, out bool b); + bool aDown = a && !lastA, bDown = b && !lastB; + lastA = a; + lastB = b; + if (Feed(gripsHeld, aDown, bDown, Time.unscaledTime)) SetVisible(!Visible); + } + + /// + /// Advances the sequence by one frame of input. Returns true when it completes. Kept separate + /// from the device reads so the logic can be driven without a headset. + /// + public bool Feed(bool gripsHeld, bool aDown, bool bDown, float now) + { + if (progress > 0 && now - startedAt > window) progress = 0; + if (!gripsHeld) { progress = 0; return false; } + if (!aDown && !bDown) return false; + + Press? pressed = aDown && !bDown ? Press.A : bDown && !aDown ? Press.B : (Press?)null; + if (pressed != Sequence[progress]) + { + // A wrong press starts over - counting it as a new first step if it is one. + progress = pressed == Sequence[0] ? 1 : 0; + startedAt = now; + return false; + } + + if (progress == 0) startedAt = now; + progress++; + if (progress < Sequence.Length) return false; + + progress = 0; + return true; + } + + public void SetVisible(bool visible) + { + Visible = visible; + if (canvases == null) return; + for (int i = 0; i < canvases.Length; i++) + if (canvases[i] != null) canvases[i].enabled = visible; + } + + private static void ReadControllers(out bool gripsHeld, out bool a, out bool b) + { + bool leftGrip = false, rightGrip = false; + a = b = false; + + UnityEngine.XR.InputDevices.GetDevicesWithCharacteristics( + UnityEngine.XR.InputDeviceCharacteristics.Controller, devices); + for (int i = 0; i < devices.Count; i++) + { + var d = devices[i]; + if (!d.isValid) continue; + d.TryGetFeatureValue(UnityEngine.XR.CommonUsages.gripButton, out bool grip); + if ((d.characteristics & UnityEngine.XR.InputDeviceCharacteristics.Left) != 0) + { + leftGrip |= grip; + continue; + } + rightGrip |= grip; + if (d.TryGetFeatureValue(UnityEngine.XR.CommonUsages.primaryButton, out bool p)) a |= p; + if (d.TryGetFeatureValue(UnityEngine.XR.CommonUsages.secondaryButton, out bool s)) b |= s; + } + gripsHeld = leftGrip && rightGrip; + } + + // ---- automatic attachment ---- + + private static bool installed; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void Install() + { + AttachToAll(); + if (installed) return; + installed = true; + SceneManager.sceneLoaded += (scene, mode) => AttachToAll(); + } + + private static void AttachToAll() + { + foreach (var manager in FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) + { + if (manager.GetComponent() == null) + manager.gameObject.AddComponent(); + } + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/DebugOverlayGate.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/DebugOverlayGate.cs.meta new file mode 100644 index 00000000..645f84d7 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/DebugOverlayGate.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ea3ce0668c7d341ecabeabde9aac40bc \ No newline at end of file From 33763b510c3f2bbfd0518b6ffce15464f3b34303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 10:18:06 +0700 Subject: [PATCH 37/61] Module 3: mount the window screen in the window, not mid-room screenOffset (1.6, 1.4, 0.1) is the middle of the Lao house, so every house's window screen (1.5 x 1.0 m, pivot at its bottom edge) hung at head height in the room as a black panel with no window behind it - seen walking into a house in a first-person capture. It now sits in the back-wall window (SM_WindowLeft (1), centred at 1.10, 1.04, -2.87 in house space). Scene's serialized value updated to match. --- .../1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity | 2 +- .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity index 2cf73180..928fa7d2 100644 --- a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity +++ b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity @@ -541,7 +541,7 @@ MonoBehaviour: villagerPrefab: {fileID: 5270409977889807214, guid: 79fdf6a8b358f664f92b8fa1ae1d67bd, type: 3} containerPrefab: {fileID: 7376524743313560442, guid: 56df000b2c3087b448db7963fd9f2578, type: 3} bedOffset: {x: 0, y: 0, z: 2.2} - screenOffset: {x: 1.6, y: 1.4, z: 0.1} + screenOffset: {x: 1.1, y: 0.54, z: -2.78} fanOffset: {x: -1.4, y: 0, z: 2} villagerSpacing: 0.9 --- !u!4 &1315144568 diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs index 990768e0..b7b86843 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -39,7 +39,11 @@ public class M3NeighbourhoodBuilder : MonoBehaviour [Header("Anchors inside a plot (local to the house)")] [SerializeField] private Vector3 bedOffset = new Vector3(0f, 0f, 2.2f); - [SerializeField] private Vector3 screenOffset = new Vector3(1.6f, 1.4f, 0.1f); + + // In the window on the back wall (SM_WindowLeft (1), centred at 1.10, 1.04, -2.87 in house + // space); the screen's pivot is its bottom edge. It was at (1.6, 1.4, 0.1) - the middle of the + // room - where it hung at head height as a black panel with no window behind it. + [SerializeField] private Vector3 screenOffset = new Vector3(1.1f, 0.54f, -2.78f); [SerializeField] private Vector3 fanOffset = new Vector3(-1.4f, 0f, 2.0f); [SerializeField] private float villagerSpacing = 0.9f; From 283f916c5dba10469377c68f0b0cb9e72402a1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 10:30:27 +0700 Subject: [PATCH 38/61] Module 3: villagers stand still, facing the visitor, sized by age Seen in a first-person capture: every villager ran on the spot inside the house, in a single-file row facing a wall, all the same size. - The villager prefab is Module 1's street NPC, whose Human component walks waypoints and sets the animator's isWalk on Start. The builder disables it and clears isWalk, so they play the character's own idle clip. - They stand in a loose fan about 2.6 m from the side door the volunteer comes in by, turned towards it, instead of a row across the room. - Children are 65% size and elders 94%, with a little per-person variation, so a household reads as a family until villager art exists (the model has one outfit; its extra meshes do not render on their own). --- .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs index b7b86843..16ffd94f 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -137,12 +137,32 @@ private void BuildPlot(Neighbourhood sim, Household h) private void BuildVillager(Neighbourhood sim, Transform plot, int personId, int index, Transform bed) { - var spot = Anchor(plot, $"Villager_{personId}", - new Vector3(-1.2f + index * villagerSpacing, 0f, 1.0f)); + // A loose fan facing the side door the volunteer comes in by, rather than a single-file + // row across the room facing a wall, which read as a queue. + int count = sim.HouseholdById(sim.PersonById(personId).HouseholdId).ResidentIds.Count; + Vector3 door = new Vector3(3.0f, 0f, -1.27f); + Vector3 away = new Vector3(-0.84f, 0f, 0.54f); // from the door towards the middle of the room + float angle = (index - (count - 1) * 0.5f) * 32f; + Vector3 local = door + Quaternion.Euler(0f, angle, 0f) * away * 2.6f; + var spot = Anchor(plot, $"Villager_{personId}", local); + spot.localRotation = Quaternion.LookRotation(door - local, Vector3.up); var villager = villagerPrefab != null ? Instantiate(villagerPrefab, spot) : new GameObject("Villager"); if (villagerPrefab == null) villager.transform.SetParent(spot, false); + // The prefab is Module 1's street NPC: its Human component walks waypoints and sets + // isWalk on Start, so every villager ran on the spot indoors. They stand at home here. + var walker = villager.GetComponent(); + if (walker != null) walker.enabled = false; + var animator = villager.GetComponentInChildren(); + if (animator != null) animator.SetBool("isWalk", false); + + // One body for everyone until the art arrives, so at least size them by age. + var person = sim.PersonById(personId); + float size = person.Age == AgeBand.Child ? 0.65f : person.Age == AgeBand.Elder ? 0.94f : 1f; + size *= 1f + ((personId * 37) % 7 - 3) * 0.012f; // a little natural variation + villager.transform.localScale = Vector3.one * size; + var view = villager.GetComponent(); if (view == null) view = villager.AddComponent(); view.personId = personId; From 7ede75b04fc537c348e2b9a7dbacb59e1baed0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 10:45:13 +0700 Subject: [PATCH 39/61] Module 3: rural surroundings - sky, grass, paddies, tree line, hills From the lanes the horizon was an empty white-blue band over a grey plane. M3Surroundings (on the Neighbourhood object, called by the builder once the plots stand) adds, at runtime and from a seed: - the FS002_Day skybox Module 1 uses, and light linear haze, - grass to the horizon (the scene's grey ground recoloured to match) and a dirt track down the lane, - rice paddies with dykes either side of the village, some flooded, - palms and banana plants round the houses and a broken tree line further out, from the Team Assets nature prefabs (colliders stripped), - a single low-poly ring of hills, facing inwards, with seamless noise. Scenery only - nothing interactive, nothing the model reads. The camera's far clip is raised to 520 m so the hills draw. The scene generator wires the same references, so a regenerated scene keeps it. Checked from eye height in the lane and behind the houses. --- .../Module3/Editor/Module3SceneBuilder.cs | 29 +++ .../Module3/Scenes/Module_3_MainScene.unity | 24 ++ .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 9 + .../Module3/Scripts/M3Surroundings.cs | 233 ++++++++++++++++++ .../Module3/Scripts/M3Surroundings.cs.meta | 11 + 5 files changed, 306 insertions(+) create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3Surroundings.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3Surroundings.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs index 53ffd91d..a7263c98 100644 --- a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs @@ -110,6 +110,35 @@ private static void BuildNeighbourhood() Assign(so, "villagerPrefab", Villager); Assign(so, "containerPrefab", Container); so.ApplyModifiedPropertiesWithoutUndo(); + + // Sky, paddies, tree line and hills, built at runtime around the neighbourhood. + var surroundings = new SerializedObject(root.AddComponent()); + surroundings.FindProperty("skybox").objectReferenceValue = + AssetDatabase.LoadAssetAtPath(SkyboxMaterial); + AssignArray(surroundings, "nearTrees", CoconutTree, BananaTree, CoconutTree); + AssignArray(surroundings, "farTrees", BroadTree, CoconutTree, Bush1, Bush2, BroadTree); + surroundings.ApplyModifiedPropertiesWithoutUndo(); + } + + private const string Nature = "Assets/1.TeamWorkspace/Team Assets/Prefabs/Neture/"; + private const string CoconutTree = Nature + "PF_CoconutTree.prefab"; + private const string BananaTree = Nature + "PF_BananaTree.prefab"; + private const string BroadTree = Nature + "PF_Tree.prefab"; + private const string Bush1 = Nature + "PF_Bush_1.prefab"; + private const string Bush2 = Nature + "PF_Bush_2.prefab"; + private const string SkyboxMaterial = "Assets/Packages/Fantasy Skybox FREE/Panoramics/FS002/FS002_Day.mat"; + + private static void AssignArray(SerializedObject so, string field, params string[] assetPaths) + { + var prop = so.FindProperty(field); + if (prop == null) return; + prop.arraySize = assetPaths.Length; + for (int i = 0; i < assetPaths.Length; i++) + { + var asset = AssetDatabase.LoadAssetAtPath(assetPaths[i]); + if (asset == null) Debug.LogWarning($"Module 3: no prefab at {assetPaths[i]}; '{field}' entry left empty."); + prop.GetArrayElementAtIndex(i).objectReferenceValue = asset; + } } private static void BuildPlayer() diff --git a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity index 928fa7d2..61f03e4a 100644 --- a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity +++ b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity @@ -515,6 +515,7 @@ GameObject: m_Component: - component: {fileID: 1315144568} - component: {fileID: 1315144567} + - component: {fileID: 1315144569} m_Layer: 0 m_Name: Neighbourhood m_TagString: Untagged @@ -522,6 +523,29 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 +--- !u!114 &1315144569 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1315144566} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 36c690e00f104255ab262692b0e9d8bc, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3Surroundings + skybox: {fileID: 2100000, guid: 9ca22510633fbfd4399f1704cd9d5c72, type: 2} + nearTrees: + - {fileID: 6203155623420049342, guid: 7dcd0f2b9fbd0674c9b6165e61ac25b8, type: 3} + - {fileID: 3795862147833374437, guid: b08f169dc6dae7944851f10e81cc2766, type: 3} + - {fileID: 6203155623420049342, guid: 7dcd0f2b9fbd0674c9b6165e61ac25b8, type: 3} + farTrees: + - {fileID: 5422006966436116915, guid: e4b0d659ebb80cc4d9f48d4c96fa109b, type: 3} + - {fileID: 6203155623420049342, guid: 7dcd0f2b9fbd0674c9b6165e61ac25b8, type: 3} + - {fileID: 3390473397902004910, guid: 918173df8e2d56b4eb9e3a448232c3ed, type: 3} + - {fileID: 6272981711262200860, guid: 05b9e9c4dedbc004db82ea74d8b2d9a5, type: 3} + - {fileID: 5422006966436116915, guid: e4b0d659ebb80cc4d9f48d4c96fa109b, type: 3} --- !u!114 &1315144567 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs index 16ffd94f..af824c4c 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -82,6 +82,15 @@ public void Build() BuildPlot(sim, sim.Households[i]); } + var surroundings = GetComponent(); + if (surroundings != null && Households.Count > 0) + { + var village = new Bounds(Households[0].transform.position, Vector3.zero); + foreach (var h in Households) village.Encapsulate(h.transform.position); + village.Expand(new Vector3(10f, 0f, 8f)); // the houses themselves, plus their yards + surroundings.Build(village); + } + Debug.Log($"Module 3: built {Households.Count} plots and {Villagers.Count} villagers " + $"from seed {M3Session.Instance.Seed}."); } diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Surroundings.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Surroundings.cs new file mode 100644 index 00000000..4fdd8b82 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Surroundings.cs @@ -0,0 +1,233 @@ +using System.Collections.Generic; +using UnityEngine; + +/// +/// The countryside around the Module 3 neighbourhood: sky, grass, rice paddies, a tree line and +/// hills on the horizon. +/// +/// Before this the village stood on a grey plane under Unity's default gradient sky, so from +/// inside the lanes the horizon was an empty white-blue band. Like the neighbourhood itself it is +/// built at runtime from a seed rather than dressed by hand, so the scene file stays small and +/// the surroundings always fit whatever layout the builder produced. +/// +/// Scenery only: no colliders, nothing the model or the player interacts with. +/// +public class M3Surroundings : MonoBehaviour +{ + [Header("Art")] + [SerializeField] private Material skybox; + [Tooltip("Palms and banana plants, placed close to the houses.")] + [SerializeField] private GameObject[] nearTrees; + [Tooltip("Broad trees and bushes for the tree line further out.")] + [SerializeField] private GameObject[] farTrees; + + [Header("Layout")] + [SerializeField] private int seed = 11; + [SerializeField] private float hillRadius = 330f; + [SerializeField] private float farClip = 520f; + + [Header("Colours")] + [SerializeField] private Color grass = new Color(0.36f, 0.55f, 0.24f); + [SerializeField] private Color track = new Color(0.55f, 0.45f, 0.32f); + [SerializeField] private Color dyke = new Color(0.47f, 0.40f, 0.27f); + [SerializeField] private Color paddyGreen = new Color(0.42f, 0.70f, 0.26f); + [SerializeField] private Color paddyWater = new Color(0.40f, 0.52f, 0.46f); + [SerializeField] private Color hills = new Color(0.30f, 0.44f, 0.36f); + [SerializeField] private Color haze = new Color(0.74f, 0.82f, 0.88f); + + private System.Random rng; + private Transform root; + private readonly Dictionary materials = new Dictionary(); + + /// Called by M3NeighbourhoodBuilder once the plots stand; covers the houses. + public void Build(Bounds village) + { + if (root != null) Destroy(root.gameObject); + root = new GameObject("Surroundings (runtime)").transform; + root.SetParent(transform, false); + rng = new System.Random(seed); + + BuildSky(); + BuildGround(village); + BuildPaddies(village); + BuildTrees(village); + BuildHills(village.center); + } + + private void BuildSky() + { + if (skybox != null) + { + RenderSettings.skybox = skybox; + DynamicGI.UpdateEnvironment(); + } + // A little haze so the hills and the far tree line recede instead of ending in a hard edge. + RenderSettings.fog = true; + RenderSettings.fogMode = FogMode.Linear; + RenderSettings.fogColor = haze; + RenderSettings.fogStartDistance = 70f; + RenderSettings.fogEndDistance = hillRadius * 1.9f; + + var cam = Camera.main; + if (cam != null) cam.farClipPlane = Mathf.Max(cam.farClipPlane, farClip); + } + + private void BuildGround(Bounds village) + { + // Grass to the horizon, and the scene's own grey ground plane turned to grass to match. + Quad("Fields", village.center + Vector3.down * 0.03f, new Vector2(hillRadius * 2.2f, hillRadius * 2.2f), grass); + var sceneGround = GameObject.Find("Ground"); + if (sceneGround != null && sceneGround.TryGetComponent(out var groundRenderer)) + groundRenderer.sharedMaterial = Mat(grass, 0.05f); + + // A dirt track down the lane between the two rows of houses. + Quad("Track", new Vector3(village.center.x, 0.005f, village.center.z), + new Vector2(village.size.x + 60f, 3.2f), track); + } + + private void BuildPaddies(Bounds village) + { + // Paddies either side of the village, beyond its back yards: the landscape a Lao + // neighbourhood actually sits in, and flat, so it costs almost nothing to draw. + const float cell = 13f, bank = 0.7f; + float x0 = village.min.x - 70f, x1 = village.max.x + 70f; + foreach (float side in new[] { -1f, 1f }) + { + float zStart = side > 0 ? village.max.z + 14f : village.min.z - 14f; + for (int row = 0; row < 5; row++) + { + float z = zStart + side * (row * cell + cell * 0.5f); + for (float x = x0; x < x1; x += cell) + { + if (Next() < 0.12f) continue; // a few gaps read as fallow ground + var centre = new Vector3(x + cell * 0.5f, 0f, z); + Quad("Dyke", centre + Vector3.up * 0.01f, new Vector2(cell, cell), dyke); + bool flooded = Next() < 0.35f; + var colour = flooded ? paddyWater : Vary(paddyGreen, 0.06f); + var paddy = Quad("Paddy", centre + Vector3.up * 0.02f, new Vector2(cell - bank, cell - bank), colour); + if (flooded) paddy.GetComponent().sharedMaterial = Mat(colour, 0.85f); + } + } + } + } + + private void BuildTrees(Bounds village) + { + var keepOut = village; + keepOut.Expand(new Vector3(6f, 0f, 6f)); + + // Palms and bananas close around the houses, as in the Module 1 village. + if (nearTrees != null && nearTrees.Length > 0) + { + for (int i = 0; i < 46; i++) + { + var p = RingPoint(village, 3f, 14f); + if (keepOut.Contains(new Vector3(p.x, keepOut.center.y, p.z))) continue; + Place(nearTrees, p, 0.9f, 1.3f); + } + } + + // A thicker, broken tree line further out - the edge of the next village, or the forest. + if (farTrees != null && farTrees.Length > 0) + { + for (int i = 0; i < 160; i++) + { + float a = (float)(Next() * Mathf.PI * 2f); + float r = Mathf.Lerp(95f, 160f, (float)Next()); + var p = village.center + new Vector3(Mathf.Cos(a) * r * 1.2f, 0f, Mathf.Sin(a) * r); + Place(farTrees, p, 1.4f, 2.4f); + } + } + } + + /// A point in a band around the village's footprint. + private Vector3 RingPoint(Bounds b, float inner, float outer) + { + float d = Mathf.Lerp(inner, outer, (float)Next()); + switch (rng.Next(4)) + { + case 0: return new Vector3(Mathf.Lerp(b.min.x - d, b.max.x + d, (float)Next()), 0f, b.max.z + d); + case 1: return new Vector3(Mathf.Lerp(b.min.x - d, b.max.x + d, (float)Next()), 0f, b.min.z - d); + case 2: return new Vector3(b.min.x - d, 0f, Mathf.Lerp(b.min.z, b.max.z, (float)Next())); + default: return new Vector3(b.max.x + d, 0f, Mathf.Lerp(b.min.z, b.max.z, (float)Next())); + } + } + + private void Place(GameObject[] prefabs, Vector3 at, float minScale, float maxScale) + { + var prefab = prefabs[rng.Next(prefabs.Length)]; + if (prefab == null) return; + var tree = Instantiate(prefab, at, Quaternion.Euler(0f, (float)Next() * 360f, 0f), root); + tree.transform.localScale *= Mathf.Lerp(minScale, maxScale, (float)Next()); + foreach (var c in tree.GetComponentsInChildren()) Destroy(c); + } + + /// A low ring of hills on the horizon: one mesh, a few hundred triangles. + private void BuildHills(Vector3 centre) + { + const int segments = 72; + var verts = new List(); + var tris = new List(); + float phase1 = (float)Next() * 10f, phase2 = (float)Next() * 10f; + for (int i = 0; i <= segments; i++) + { + float a = i / (float)segments * Mathf.PI * 2f; + var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)); + // Noise sampled round a circle, so the ring closes without a cliff where it meets. + float cx = Mathf.Cos(a), cz = Mathf.Sin(a); + float h = 24f + 18f * Mathf.PerlinNoise(phase1 + cx * 1.6f, phase1 + cz * 1.6f) + + 22f * Mathf.Pow(Mathf.PerlinNoise(phase2 + cx * 4f, phase2 + cz * 4f), 2f); + verts.Add(centre + dir * (hillRadius - 60f)); // foot, near side + verts.Add(centre + dir * hillRadius + Vector3.up * h); // ridge + verts.Add(centre + dir * (hillRadius + 70f) + Vector3.up * h * 0.4f); // far shoulder + if (i == segments) break; + int k = i * 3; + // Wound to face the village: the player only ever sees the inner slopes. + tris.AddRange(new[] { k, k + 3, k + 1, k + 1, k + 3, k + 4, k + 1, k + 4, k + 2, k + 2, k + 4, k + 5 }); + } + var mesh = new Mesh { name = "Hills" }; + mesh.SetVertices(verts); + mesh.SetTriangles(tris, 0); + mesh.RecalculateNormals(); + + var go = new GameObject("Hills"); + go.transform.SetParent(root, false); + go.AddComponent().sharedMesh = mesh; + go.AddComponent().sharedMaterial = Mat(hills, 0f); + } + + // ---------- helpers ---------- + + private GameObject Quad(string name, Vector3 centre, Vector2 size, Color colour) + { + var q = GameObject.CreatePrimitive(PrimitiveType.Quad); + q.name = name; + Destroy(q.GetComponent()); + q.transform.SetParent(root, false); + q.transform.position = centre; + q.transform.rotation = Quaternion.Euler(90f, 0f, 0f); + q.transform.localScale = new Vector3(size.x, size.y, 1f); + q.GetComponent().sharedMaterial = Mat(colour, 0.05f); + return q; + } + + private Material Mat(Color colour, float smoothness) + { + var key = new Color(colour.r, colour.g, colour.b, smoothness); + if (materials.TryGetValue(key, out var m)) return m; + var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"); + m = new Material(shader) { color = colour }; + if (m.HasProperty("_BaseColor")) m.SetColor("_BaseColor", colour); + if (m.HasProperty("_Smoothness")) m.SetFloat("_Smoothness", smoothness); + materials[key] = m; + return m; + } + + private Color Vary(Color c, float amount) + { + float v = 1f + ((float)Next() * 2f - 1f) * amount; + return new Color(c.r * v, c.g * v, c.b * v); + } + + private double Next() => rng.NextDouble(); +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Surroundings.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/M3Surroundings.cs.meta new file mode 100644 index 00000000..6bc50708 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Surroundings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 36c690e00f104255ab262692b0e9d8bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 3e4b12027d7b272a9522189f8e58e20bcbdbeda1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 13:42:07 +0700 Subject: [PATCH 40/61] Module 3: beds render, villagers lie on them, bigger fan, one yard jar at most - PF_Bed pointed at a mesh GUID that has never matched any asset (since it was first committed), so beds never rendered. It now nests SM_Bed.fbx; the root GameObject id is unchanged so the scene's bedPrefab reference still resolves. - NpcAnimationController lacked the Resting/Unwell/Talking parameters that VillagerView sets, so those calls did nothing and a resting villager was teleported to the bed still standing. Add the parameters. With no lying clip for this rig yet, VillagerView tips the villager onto their back, centred on the mattress (rig origin is at the feet) at mattress height. - The fan is a correct 0.3 m table fan but read as a toy on the floor next to a full-size door; scale it 2.5x uniformly when placed (a non-uniform scale would skew the spinning blade). - The model gives every house 2-5 productive containers and the builder drew one jar each, lining every yard. Draw at most one, in about half the houses, by a stable per-house roll. Presentation only; sim container counts unchanged. --- .../Module3/Scripts/HouseholdView.cs | 11 +- .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 26 ++-- .../Module3/Scripts/VillagerView.cs | 26 +++- .../NpcAnimationController.controller | 18 +++ .../Team Assets/Prefabs/PF_Bed.prefab | 120 +++++++++--------- 5 files changed, 131 insertions(+), 70 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs index fbe87852..d08d7635 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/HouseholdView.cs @@ -20,6 +20,11 @@ public class HouseholdView : MonoBehaviour [SerializeField] private Transform fanStand; [SerializeField] private Transform bedNetAnchor; + [Tooltip("PF_ElectricFan is a 0.3 m table fan; stood on the floor it looks like a toy beside a " + + "full-size door. Scaled uniformly (a non-uniform scale would skew the spinning blade) " + + "up to a floor-standing fan.")] + [SerializeField] private float fanScale = 2.5f; + [Header("Prefabs (leave empty to load from Module3Props)")] [SerializeField] private GameObject screenIntactPrefab; [SerializeField] private GameObject screenTornPrefab; @@ -124,7 +129,11 @@ private void ShowFan(bool on) if (fanStand == null || fanInstance != null) return; fanInstance = Place(Resolve(ref fanPrefab, Module3Props.ElectricFan), fanStand); - if (fanInstance != null) fanInstance.AddComponent(); + if (fanInstance != null) + { + fanInstance.transform.localScale = Vector3.one * fanScale; + fanInstance.AddComponent(); + } } private void ShowNet(bool deployed) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs index af824c4c..2e8aa48c 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -37,6 +37,10 @@ public class M3NeighbourhoodBuilder : MonoBehaviour + "footprint ends at z = 3.4.")] [SerializeField] private float yardDepth = 4.6f; + [Tooltip("Share of houses that show a water jar in the yard - never more than one each.")] + [Range(0, 100)] + [SerializeField] private int yardJarPercent = 50; + [Header("Anchors inside a plot (local to the house)")] [SerializeField] private Vector3 bedOffset = new Vector3(0f, 0f, 2.2f); @@ -125,17 +129,19 @@ private void BuildPlot(Neighbourhood sim, Household h) // The containers are scenery and the visible reason there are mosquitoes here. Section 5: // they are "not a button, not a task, not a score" - nothing here is interactive. - if (containerPrefab != null) + // + // The model gives every household two to five productive containers (and always at least + // two, so a cleared yard is never a promise - section 5), but drawing one jar per container + // lined every yard with identical pots. Scenery only needs to say "water stands here": one + // jar at most, and a stable per-house roll leaves some yards bare. This is presentation + // only - the simulation's container counts are untouched. + if (containerPrefab != null && h.ProductiveContainerCount > 0 + && (int)(((uint)h.Id * 2654435761u >> 16) % 100u) < yardJarPercent) { - for (int c = 0; c < h.Containers.Count; c++) - { - if (!h.Containers[c].IsProductive) continue; - // In the lane-side yard, in front of the house. At z = -1.8 they were inside the - // 6.4 m-deep house, where nobody walking the lane could see them. - var yard = Anchor(plot.transform, $"Container_{h.Containers[c].Id}", - new Vector3(-2.5f + c * 0.9f, 0f, yardDepth)); - Instantiate(containerPrefab, yard); - } + // In the lane-side yard, in front of the house. At z = -1.8 they were inside the + // 6.4 m-deep house, where nobody walking the lane could see them. + var yard = Anchor(plot.transform, $"Container_{h.Id}", new Vector3(-2.5f, 0f, yardDepth)); + Instantiate(containerPrefab, yard); } for (int r = 0; r < h.ResidentIds.Count; r++) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs index 251634ec..b94d6ecb 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/VillagerView.cs @@ -44,7 +44,11 @@ public class VillagerView : MonoBehaviour /// public Transform RestingPlace { get; set; } + [Tooltip("Height of the mattress surface above the bed anchor (measured on PF_Bed; the body's own thickness is added on top).")] + [SerializeField] private float mattressHeight = 0.6f; + private Vector3 standingPosition; + private Quaternion standingRotation; private bool capturedStandingPosition; public VisibleCondition Condition { get; private set; } @@ -138,6 +142,14 @@ private void SetBodyVisible(bool visible) } } + /// + /// NpcAnimationController has only an idle pose and a walk cycle - there is no lying-down + /// clip for this rig yet, so a resting villager is laid down by tipping the standing pose flat + /// rather than by switching animation states. The rig is tipped onto its back (a +90 pitch + /// would leave the chest facing the mattress) along the bed's own orientation, and shifted half + /// a body length because the rig's origin is at the feet - without that the body hangs off the + /// end of the bed. Once a real lying animation exists this can go back to a plain position move. + /// private void MoveToRestingPlace(bool resting) { if (RestingPlace == null) return; @@ -145,10 +157,22 @@ private void MoveToRestingPlace(bool resting) if (!capturedStandingPosition) { standingPosition = transform.position; + standingRotation = transform.rotation; capturedStandingPosition = true; } - transform.position = resting ? RestingPlace.position : standingPosition; + if (resting) + { + float s = transform.lossyScale.y; // children are smaller, so the offsets scale with them + Vector3 feetOffset = RestingPlace.rotation * new Vector3(0f, 0f, 0.85f * s); + transform.SetPositionAndRotation( + RestingPlace.position + feetOffset + Vector3.up * (mattressHeight + 0.12f * s), + RestingPlace.rotation * Quaternion.Euler(-90f, 0f, 0f)); + } + else + { + transform.SetPositionAndRotation(standingPosition, standingRotation); + } } private bool HasSign(string key) diff --git a/Assets/1.TeamWorkspace/Team Assets/Models/SK_Character/NpcAnimationController.controller b/Assets/1.TeamWorkspace/Team Assets/Models/SK_Character/NpcAnimationController.controller index f814e3a8..b81e05dc 100644 --- a/Assets/1.TeamWorkspace/Team Assets/Models/SK_Character/NpcAnimationController.controller +++ b/Assets/1.TeamWorkspace/Team Assets/Models/SK_Character/NpcAnimationController.controller @@ -117,6 +117,24 @@ AnimatorController: m_DefaultInt: 0 m_DefaultBool: 0 m_Controller: {fileID: 9100000} + - m_Name: Resting + m_Type: 4 + m_DefaultFloat: 0 + m_DefaultInt: 0 + m_DefaultBool: 0 + m_Controller: {fileID: 9100000} + - m_Name: Unwell + m_Type: 4 + m_DefaultFloat: 0 + m_DefaultInt: 0 + m_DefaultBool: 0 + m_Controller: {fileID: 9100000} + - m_Name: Talking + m_Type: 4 + m_DefaultFloat: 0 + m_DefaultInt: 0 + m_DefaultBool: 0 + m_Controller: {fileID: 9100000} m_AnimatorLayers: - serializedVersion: 5 m_Name: Base Layer diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/PF_Bed.prefab b/Assets/1.TeamWorkspace/Team Assets/Prefabs/PF_Bed.prefab index f19ff5e8..c30356e5 100644 --- a/Assets/1.TeamWorkspace/Team Assets/Prefabs/PF_Bed.prefab +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/PF_Bed.prefab @@ -9,8 +9,6 @@ GameObject: serializedVersion: 6 m_Component: - component: {fileID: 1916797586561666228} - - component: {fileID: 4076142744423006993} - - component: {fileID: 1980044386120206359} m_Layer: 0 m_Name: PF_Bed m_TagString: Untagged @@ -30,63 +28,69 @@ Transform: m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: [] + m_Children: + - {fileID: 3699188088790311621} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!33 &4076142744423006993 -MeshFilter: +--- !u!1001 &3809093703797998894 +PrefabInstance: m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 8638829071811766795} - m_Mesh: {fileID: -2228417370725966487, guid: 1b0caa29c5b777742b1a25808befe293, type: 3} ---- !u!23 &1980044386120206359 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} + serializedVersion: 2 + m_Modification: + serializedVersion: 3 + m_TransformParent: {fileID: 1916797586561666228} + m_Modifications: + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalPosition.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalRotation.w + value: 1 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalRotation.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalRotation.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalRotation.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalEulerAnglesHint.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalEulerAnglesHint.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_LocalEulerAnglesHint.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 919132149155446097, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + propertyPath: m_Name + value: SM_Bed + objectReference: {fileID: 0} + m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] + m_SourcePrefab: {fileID: 100100000, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} +--- !u!4 &3699188088790311621 stripped +Transform: + m_CorrespondingSourceObject: {fileID: -8679921383154817045, guid: 754b2cd7b980b8748b0285f42691b3a8, type: 3} + m_PrefabInstance: {fileID: 3809093703797998894} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 8638829071811766795} - m_Enabled: 1 - m_CastShadows: 1 - m_ReceiveShadows: 1 - m_DynamicOccludee: 1 - m_StaticShadowCaster: 0 - m_MotionVectors: 1 - m_LightProbeUsage: 1 - m_ReflectionProbeUsage: 1 - m_RayTracingMode: 2 - m_RayTraceProcedural: 0 - m_RayTracingAccelStructBuildFlagsOverride: 0 - m_RayTracingAccelStructBuildFlags: 1 - m_SmallMeshCulling: 1 - m_ForceMeshLod: -1 - m_MeshLodSelectionBias: 0 - m_RenderingLayerMask: 1 - m_RendererPriority: 0 - m_Materials: - - {fileID: 2100000, guid: 110fa983936f1e143b9815691536677f, type: 2} - m_StaticBatchInfo: - firstSubMesh: 0 - subMeshCount: 0 - m_StaticBatchRoot: {fileID: 0} - m_ProbeAnchor: {fileID: 0} - m_LightProbeVolumeOverride: {fileID: 0} - m_ScaleInLightmap: 1 - m_ReceiveGI: 1 - m_PreserveUVs: 0 - m_IgnoreNormalsForChartDetection: 0 - m_ImportantGI: 0 - m_StitchLightmapSeams: 1 - m_SelectedEditorRenderState: 3 - m_MinimumChartSize: 4 - m_AutoUVMaxDistance: 0.5 - m_AutoUVMaxAngle: 89 - m_LightmapParameters: {fileID: 0} - m_GlobalIlluminationMeshLod: 0 - m_SortingLayerID: 0 - m_SortingLayer: 0 - m_SortingOrder: 0 - m_MaskInteraction: 0 - m_AdditionalVertexStreams: {fileID: 0} From ee75b7ebb6a868fb383034be86bbe4c73b378afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 13:52:14 +0700 Subject: [PATCH 41/61] Module 3: the Pilot plays in a headset, with controllers Until now Module 3 was a plain camera and a keyboard driver. It now plays the way it ships: an XR rig, controller locomotion, pointing at people, panels in the room, and a headset-safe HUD. Driven and checked in the editor purely through simulated Quest controllers - no scripted camera. The rig (M3XRPlayer) - Spawns the XRI starter rig at the head of the lane. Left stick walks (1.4 m/s - the sample's 2.5 is a jog), right stick snap-turns or teleports. The plain camera + M3DebugDriver stay as the desktop fallback (AEDES > Module 3 > Use Desktop Camera). - Each turn's clock is held until the incoming Pilot presses A ("ready"), so handing over the headset does not eat their time. Left menu button asks "End your turn now? A = yes, B = no". - Body capsule capped at 1.6 m and step offset 0.3 (M3CappedBodyManipulator). Talking to people - Every villager is a target (M3VillagerTarget): point + trigger, or grip. The press is detected as an edge of "held" - XRI's own ReadWasPerformedThisFrame missed it depending on update order. - M3PersonPanel: a panel at arm's reach, angled off so the person stays visible - who they are, what you can see, what they tell you, then the closed verb list (never 'diagnose' or 'treat') for them and for the house, with the reason under any disabled action. All from Observe, so it can show nothing the model says you could not see. Pressable with the ray. - Walking into a house counts as looking in (M3VisitTracker). HUD - M3Hud was a screen-space overlay, which a headset does not draw. In VR it becomes a world-space canvas that follows the head at 1.7 m (casting mirrors the headset, so the Coach still reads the countdown). Handover, start card and end-turn question are in it. Status text is localized. Found by actually walking through the village - The Lao houses were modelled with the doors CLOSED (solid slabs with colliders in real openings): nobody could go in. Doors are swung open. - The doorway was 1.53 m clear - lower than the 1.7 m villagers who live in it and than a standing adult's head. A first fix capped the body at 1.3 m, which only let the head push through the wall above the door. Houses are now scaled 1.4x (doorway 2.1 m); everything placed in house space, plot spacing, lane width and the yard scale with it. - CharacterController sweeps up by its step offset before stepping: at the sample's 0.5 m it hit the lintel and refused to enter at all. - Villagers and beds stood on the ground under the raised floor. Checked in play with head heights 1.25 / 1.6 / 1.85 m: all walk in, a visit is recorded, the ray hovers the patient, trigger opens the panel, a ray press puts up a net (nets 4 -> 3), menu/B/A end the turn, the handover holds the clock, and the next Pilot's A starts it. Sim suite: 86/86. --- .../Module3/Editor/M3DesktopCameraMenu.cs | 22 + .../Editor/M3DesktopCameraMenu.cs.meta | 2 + .../Module3/Editor/Module3SceneBuilder.cs | 17 + .../Module3/Scenes/Module_3_MainScene.unity | 651 ++++++++++-------- .../Scripts/M3CappedBodyManipulator.cs | 35 + .../Scripts/M3CappedBodyManipulator.cs.meta | 2 + .../Module3/Scripts/M3DebugDriver.cs | 3 +- .../1.TeamWorkspace/Module3/Scripts/M3Hud.cs | 145 +++- .../Module3/Scripts/M3NeighbourhoodBuilder.cs | 87 ++- .../Module3/Scripts/M3PersonPanel.cs | 204 ++++++ .../Module3/Scripts/M3PersonPanel.cs.meta | 2 + .../Module3/Scripts/M3Session.cs | 21 +- .../1.TeamWorkspace/Module3/Scripts/M3Ui.cs | 160 +++++ .../Module3/Scripts/M3Ui.cs.meta | 2 + .../Module3/Scripts/M3VillagerTarget.cs | 54 ++ .../Module3/Scripts/M3VillagerTarget.cs.meta | 2 + .../Module3/Scripts/M3VisitTracker.cs | 51 ++ .../Module3/Scripts/M3VisitTracker.cs.meta | 2 + .../Module3/Scripts/M3XRPlayer.cs | 144 ++++ .../Module3/Scripts/M3XRPlayer.cs.meta | 2 + .../Localization/LocalizationData.csv | 15 + 21 files changed, 1317 insertions(+), 306 deletions(-) create mode 100644 Assets/1.TeamWorkspace/Module3/Editor/M3DesktopCameraMenu.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Editor/M3DesktopCameraMenu.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3CappedBodyManipulator.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3CappedBodyManipulator.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3PersonPanel.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3PersonPanel.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3Ui.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3Ui.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3VillagerTarget.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3VillagerTarget.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3VisitTracker.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3VisitTracker.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3XRPlayer.cs create mode 100644 Assets/1.TeamWorkspace/Module3/Scripts/M3XRPlayer.cs.meta diff --git a/Assets/1.TeamWorkspace/Module3/Editor/M3DesktopCameraMenu.cs b/Assets/1.TeamWorkspace/Module3/Editor/M3DesktopCameraMenu.cs new file mode 100644 index 00000000..b793b161 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor/M3DesktopCameraMenu.cs @@ -0,0 +1,22 @@ +using UnityEditor; + +/// +/// AEDES > Module 3 > Use Desktop Camera: play the scene with the plain camera and the keyboard +/// driver instead of the XR rig - for working on the scene without a headset. Off by default, so +/// the scene plays the way it ships. +/// +public static class M3DesktopCameraMenu +{ + private const string Path = "AEDES/Module 3/Use Desktop Camera"; + + [MenuItem(Path)] + private static void Toggle() => + EditorPrefs.SetBool(M3XRPlayer.DesktopCameraPref, !EditorPrefs.GetBool(M3XRPlayer.DesktopCameraPref, false)); + + [MenuItem(Path, true)] + private static bool Validate() + { + Menu.SetChecked(Path, EditorPrefs.GetBool(M3XRPlayer.DesktopCameraPref, false)); + return true; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Editor/M3DesktopCameraMenu.cs.meta b/Assets/1.TeamWorkspace/Module3/Editor/M3DesktopCameraMenu.cs.meta new file mode 100644 index 00000000..c65c4cc5 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Editor/M3DesktopCameraMenu.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6cb8de62c92124962b7b4c3a7627d9d9 \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs index a7263c98..b70fc559 100644 --- a/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Editor/Module3SceneBuilder.cs @@ -155,8 +155,25 @@ private static void BuildPlayer() // Keyboard driver so a whole session can be played without a headset. It compiles out of // a release build, so leaving it in the scene costs nothing. player.AddComponent(); + + // The Pilot in the headset. At runtime this swaps the plain camera above for the XR rig + // and turns each turn's clock over to the person wearing it; the camera stays as the + // desktop fallback if the rig prefab is ever missing. + var xr = new SerializedObject(player.AddComponent()); + var rig = AssetDatabase.LoadAssetAtPath(XRRigPrefab); + if (rig == null) Debug.LogWarning($"Module 3: no XR rig at {XRRigPrefab}; the scene will run on the desktop camera."); + xr.FindProperty("rigPrefab").objectReferenceValue = rig; + xr.ApplyModifiedPropertiesWithoutUndo(); + + // The conversation panel and the "walking in counts as a visit" tracker. + var pilot = new GameObject("Pilot Interaction"); + pilot.AddComponent(); + pilot.AddComponent(); } + private const string XRRigPrefab = + "Assets/Samples/XR Interaction Toolkit/3.3.2/Starter Assets/Prefabs/XR Origin (XR Rig).prefab"; + private static void BuildSquadScreens() { // The Coach and the Analyst watch the cast, not the headset (section 9), so this is a diff --git a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity index 61f03e4a..d2e8b796 100644 --- a/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity +++ b/Assets/1.TeamWorkspace/Module3/Scenes/Module_3_MainScene.unity @@ -119,13 +119,13 @@ NavMeshSettings: debug: m_Flags: 0 m_NavMeshData: {fileID: 0} ---- !u!1001 &164932925 +--- !u!1001 &21232646 PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: serializedVersion: 3 - m_TransformParent: {fileID: 1651663558} + m_TransformParent: {fileID: 621284223} m_Modifications: - target: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} propertyPath: m_Pivot.x @@ -220,12 +220,12 @@ PrefabInstance: m_AddedGameObjects: [] m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} ---- !u!224 &164932926 stripped +--- !u!224 &21232647 stripped RectTransform: m_CorrespondingSourceObject: {fileID: 681044183928047451, guid: 4fccca00c0b9f4feaae590af3158112b, type: 3} - m_PrefabInstance: {fileID: 164932925} + m_PrefabInstance: {fileID: 21232646} m_PrefabAsset: {fileID: 0} ---- !u!1 &168299017 +--- !u!1 &147967243 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -233,105 +233,96 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 168299021} - - component: {fileID: 168299020} - - component: {fileID: 168299019} - - component: {fileID: 168299018} + - component: {fileID: 147967245} + - component: {fileID: 147967244} m_Layer: 0 - m_Name: Player - m_TagString: MainCamera + m_Name: Directional Light + m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &168299018 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 168299017} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 24ed968bbf5646bfaecd042b6fbcd97e, type: 3} - m_Name: - m_EditorClassIdentifier: Assembly-CSharp::M3DebugDriver - showOverlay: 1 ---- !u!81 &168299019 -AudioListener: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 168299017} - m_Enabled: 1 ---- !u!20 &168299020 -Camera: +--- !u!108 &147967244 +Light: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 168299017} + m_GameObject: {fileID: 147967243} m_Enabled: 1 - serializedVersion: 2 - m_ClearFlags: 1 - m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} - m_projectionMatrixMode: 1 - m_GateFitMode: 2 - m_FOVAxisMode: 0 - m_Iso: 200 - m_ShutterSpeed: 0.005 - m_Aperture: 16 - m_FocusDistance: 10 - m_FocalLength: 50 - m_BladeCount: 5 - m_Curvature: {x: 2, y: 11} - m_BarrelClipping: 0.25 - m_Anamorphism: 0 - m_SensorSize: {x: 36, y: 24} - m_LensShift: {x: 0, y: 0} - m_NormalizedViewPortRect: - serializedVersion: 2 - x: 0 - y: 0 - width: 1 - height: 1 - near clip plane: 0.05 - far clip plane: 200 - field of view: 60 - orthographic: 0 - orthographic size: 5 - m_Depth: 0 + serializedVersion: 12 + m_Type: 1 + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_Intensity: 1.1 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 0.5, y: 0.5} + m_Shadows: + m_Type: 2 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 m_CullingMask: serializedVersion: 2 m_Bits: 4294967295 - m_RenderingPath: -1 - m_TargetTexture: {fileID: 0} - m_TargetDisplay: 0 - m_TargetEye: 3 - m_HDR: 1 - m_AllowMSAA: 1 - m_AllowDynamicResolution: 0 - m_ForceIntoRT: 0 - m_OcclusionCulling: 1 - m_StereoConvergence: 10 - m_StereoSeparation: 0.022 ---- !u!4 &168299021 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShadowRadius: 0 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &147967245 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 168299017} + m_GameObject: {fileID: 147967243} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 1.6, z: -4} + m_LocalRotation: {x: 0.105271205, y: 0.88241714, z: -0.39287746, w: 0.236443} + m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &448279866 +--- !u!1 &183188902 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -339,51 +330,54 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 448279869} - - component: {fileID: 448279868} - - component: {fileID: 448279867} + - component: {fileID: 183188905} + - component: {fileID: 183188904} + - component: {fileID: 183188903} m_Layer: 0 - m_Name: Squad Screens + m_Name: Pilot Interaction m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &448279867 +--- !u!114 &183188903 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 448279866} + m_GameObject: {fileID: 183188902} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 472e785512124d0b833406f0236dbefd, type: 3} + m_Script: {fileID: 11500000, guid: 7c6a3afe40aef47fc808ff3c1b448d34, type: 3} m_Name: - m_EditorClassIdentifier: Assembly-CSharp::M3JournalWriter - writeOnSessionEnd: 1 ---- !u!114 &448279868 + m_EditorClassIdentifier: Assembly-CSharp::M3VisitTracker + footprint: + m_Center: {x: 0.1, y: 1, z: 0.2} + m_Extent: {x: 4.2, y: 1.5, z: 3.1} +--- !u!114 &183188904 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 448279866} + m_GameObject: {fileID: 183188902} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: b9612f98ce22409390fd09f0cdf17959, type: 3} + m_Script: {fileID: 11500000, guid: 408f8ef993d364a8cb76c189c5258708, type: 3} m_Name: - m_EditorClassIdentifier: Assembly-CSharp::M3Hud - font: {fileID: 11400000, guid: d54e351c41d2e4344b35f0bf3e77ec76, type: 2} - baseFontSize: 28 - handoverSeconds: 12 ---- !u!4 &448279869 + m_EditorClassIdentifier: Assembly-CSharp::M3PersonPanel + closeDistance: 5.5 + panelDistance: 1.3 + sideAngle: 28 + unitsPerMetre: 850 +--- !u!4 &183188905 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 448279866} + m_GameObject: {fileID: 183188902} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} @@ -392,7 +386,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &960010835 +--- !u!1 &249431428 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -400,112 +394,125 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 960010839} - - component: {fileID: 960010838} - - component: {fileID: 960010837} - - component: {fileID: 960010836} + - component: {fileID: 249431433} + - component: {fileID: 249431432} + - component: {fileID: 249431431} + - component: {fileID: 249431430} + - component: {fileID: 249431429} m_Layer: 0 - m_Name: Ground - m_TagString: Untagged + m_Name: Player + m_TagString: MainCamera m_Icon: {fileID: 0} m_NavMeshLayer: 0 - m_StaticEditorFlags: 2147483647 + m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!23 &960010836 -MeshRenderer: +--- !u!114 &249431429 +MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 960010835} + m_GameObject: {fileID: 249431428} m_Enabled: 1 - m_CastShadows: 1 - m_ReceiveShadows: 1 - m_DynamicOccludee: 1 - m_StaticShadowCaster: 0 - m_MotionVectors: 1 - m_LightProbeUsage: 1 - m_ReflectionProbeUsage: 1 - m_RayTracingMode: 2 - m_RayTraceProcedural: 0 - m_RayTracingAccelStructBuildFlagsOverride: 0 - m_RayTracingAccelStructBuildFlags: 1 - m_SmallMeshCulling: 1 - m_ForceMeshLod: -1 - m_MeshLodSelectionBias: 0 - m_RenderingLayerMask: 1 - m_RendererPriority: 0 - m_Materials: - - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} - m_StaticBatchInfo: - firstSubMesh: 0 - subMeshCount: 0 - m_StaticBatchRoot: {fileID: 0} - m_ProbeAnchor: {fileID: 0} - m_LightProbeVolumeOverride: {fileID: 0} - m_ScaleInLightmap: 1 - m_ReceiveGI: 1 - m_PreserveUVs: 1 - m_IgnoreNormalsForChartDetection: 0 - m_ImportantGI: 0 - m_StitchLightmapSeams: 1 - m_SelectedEditorRenderState: 3 - m_MinimumChartSize: 4 - m_AutoUVMaxDistance: 0.5 - m_AutoUVMaxAngle: 89 - m_LightmapParameters: {fileID: 0} - m_GlobalIlluminationMeshLod: 0 - m_SortingLayerID: 0 - m_SortingLayer: 0 - m_SortingOrder: 0 - m_MaskInteraction: 0 - m_AdditionalVertexStreams: {fileID: 0} ---- !u!64 &960010837 -MeshCollider: + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 2fed4f92322294c508d81e190307d6e7, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3XRPlayer + rigPrefab: {fileID: 1717954561962503725, guid: f6336ac4ac8b4d34bc5072418cdc62a0, type: 3} + spawnPosition: {x: -8, y: 0, z: 8} + spawnYaw: 90 + walkSpeed: 1.4 + maxBodyHeight: 1.6 + stepOffset: 0.3 +--- !u!114 &249431430 +MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 960010835} - m_Material: {fileID: 0} - m_IncludeLayers: - serializedVersion: 2 - m_Bits: 0 - m_ExcludeLayers: - serializedVersion: 2 - m_Bits: 0 - m_LayerOverridePriority: 0 - m_IsTrigger: 0 - m_ProvidesContacts: 0 + m_GameObject: {fileID: 249431428} m_Enabled: 1 - serializedVersion: 5 - m_Convex: 0 - m_CookingOptions: 30 - m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} ---- !u!33 &960010838 -MeshFilter: + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 24ed968bbf5646bfaecd042b6fbcd97e, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3DebugDriver + showOverlay: 1 + frameSelectedHouse: 1 +--- !u!81 &249431431 +AudioListener: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 960010835} - m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} ---- !u!4 &960010839 + m_GameObject: {fileID: 249431428} + m_Enabled: 1 +--- !u!20 &249431432 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 249431428} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.05 + far clip plane: 200 + field of view: 60 + orthographic: 0 + orthographic size: 5 + m_Depth: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &249431433 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 960010835} + m_GameObject: {fileID: 249431428} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 18, y: 0, z: 8} - m_LocalScale: {x: 12, y: 1, z: 12} + m_LocalPosition: {x: 0, y: 1.6, z: -4} + m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1315144566 +--- !u!1 &283339669 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -513,9 +520,9 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1315144568} - - component: {fileID: 1315144567} - - component: {fileID: 1315144569} + - component: {fileID: 283339672} + - component: {fileID: 283339671} + - component: {fileID: 283339670} m_Layer: 0 m_Name: Neighbourhood m_TagString: Untagged @@ -523,13 +530,13 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1315144569 +--- !u!114 &283339670 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1315144566} + m_GameObject: {fileID: 283339669} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 36c690e00f104255ab262692b0e9d8bc, type: 3} @@ -546,35 +553,49 @@ MonoBehaviour: - {fileID: 3390473397902004910, guid: 918173df8e2d56b4eb9e3a448232c3ed, type: 3} - {fileID: 6272981711262200860, guid: 05b9e9c4dedbc004db82ea74d8b2d9a5, type: 3} - {fileID: 5422006966436116915, guid: e4b0d659ebb80cc4d9f48d4c96fa109b, type: 3} ---- !u!114 &1315144567 + seed: 11 + hillRadius: 330 + farClip: 520 + grass: {r: 0.36, g: 0.55, b: 0.24, a: 1} + track: {r: 0.55, g: 0.45, b: 0.32, a: 1} + dyke: {r: 0.47, g: 0.4, b: 0.27, a: 1} + paddyGreen: {r: 0.42, g: 0.7, b: 0.26, a: 1} + paddyWater: {r: 0.4, g: 0.52, b: 0.46, a: 1} + hills: {r: 0.3, g: 0.44, b: 0.36, a: 1} + haze: {r: 0.74, g: 0.82, b: 0.88, a: 1} +--- !u!114 &283339671 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1315144566} + m_GameObject: {fileID: 283339669} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 5c49cece3445418896358bc6162cd43c, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::M3NeighbourhoodBuilder - plotWidth: 11 - laneSpacing: 16 + plotWidth: 14.5 + laneSpacing: 18 housePrefab: {fileID: 6484222673902141143, guid: 908f7032114933a47b79326aa4169a8b, type: 3} bedPrefab: {fileID: 8638829071811766795, guid: 5e86aaec090b5034cbda1bc9a0cf13c4, type: 3} villagerPrefab: {fileID: 5270409977889807214, guid: 79fdf6a8b358f664f92b8fa1ae1d67bd, type: 3} containerPrefab: {fileID: 7376524743313560442, guid: 56df000b2c3087b448db7963fd9f2578, type: 3} + yardDepth: 6.2 + houseScale: 1.4 + modelFloorHeight: 0.178 + yardJarPercent: 50 bedOffset: {x: 0, y: 0, z: 2.2} screenOffset: {x: 1.1, y: 0.54, z: -2.78} fanOffset: {x: -1.4, y: 0, z: 2} villagerSpacing: 0.9 ---- !u!4 &1315144568 +--- !u!4 &283339672 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1315144566} + m_GameObject: {fileID: 283339669} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} @@ -583,7 +604,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1651663554 +--- !u!1 &621284219 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -591,10 +612,10 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1651663558} - - component: {fileID: 1651663557} - - component: {fileID: 1651663556} - - component: {fileID: 1651663555} + - component: {fileID: 621284223} + - component: {fileID: 621284222} + - component: {fileID: 621284221} + - component: {fileID: 621284220} m_Layer: 0 m_Name: Managers m_TagString: Untagged @@ -602,13 +623,13 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1651663555 +--- !u!114 &621284220 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1651663554} + m_GameObject: {fileID: 621284219} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: ae6967dcfcba4d74aed5c0275422655b, type: 3} @@ -617,49 +638,50 @@ MonoBehaviour: seed: 0 autoStart: 1 logHandovers: 1 ---- !u!114 &1651663556 + waitForPilot: 1 +--- !u!114 &621284221 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1651663554} + m_GameObject: {fileID: 621284219} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: b41c0e615f2e4e64b92f966fccdfb299, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::Module3Parameters logOnReceive: 1 ---- !u!114 &1651663557 +--- !u!114 &621284222 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1651663554} + m_GameObject: {fileID: 621284219} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: a2c0fb59e797f9c4394b260c04181050, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::LocalizationManager currentLanguage: English ---- !u!4 &1651663558 +--- !u!4 &621284223 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1651663554} + m_GameObject: {fileID: 621284219} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: - - {fileID: 164932926} + - {fileID: 21232647} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1940555583 +--- !u!1 &1463573598 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -667,102 +689,183 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1940555585} - - component: {fileID: 1940555584} + - component: {fileID: 1463573601} + - component: {fileID: 1463573600} + - component: {fileID: 1463573599} m_Layer: 0 - m_Name: Directional Light + m_Name: Squad Screens m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!108 &1940555584 -Light: +--- !u!114 &1463573599 +MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1940555583} + m_GameObject: {fileID: 1463573598} m_Enabled: 1 - serializedVersion: 12 - m_Type: 1 - m_Color: {r: 1, g: 1, b: 1, a: 1} - m_Intensity: 1.1 - m_Range: 10 - m_SpotAngle: 30 - m_InnerSpotAngle: 21.80208 - m_CookieSize2D: {x: 0.5, y: 0.5} - m_Shadows: - m_Type: 2 - m_Resolution: -1 - m_CustomResolution: -1 - m_Strength: 1 - m_Bias: 0.05 - m_NormalBias: 0.4 - m_NearPlane: 0.2 - m_CullingMatrixOverride: - e00: 1 - e01: 0 - e02: 0 - e03: 0 - e10: 0 - e11: 1 - e12: 0 - e13: 0 - e20: 0 - e21: 0 - e22: 1 - e23: 0 - e30: 0 - e31: 0 - e32: 0 - e33: 1 - m_UseCullingMatrixOverride: 0 - m_Cookie: {fileID: 0} - m_DrawHalo: 0 - m_Flare: {fileID: 0} - m_RenderMode: 0 - m_CullingMask: - serializedVersion: 2 - m_Bits: 4294967295 - m_RenderingLayerMask: 1 - m_Lightmapping: 4 - m_LightShadowCasterMode: 0 - m_AreaSize: {x: 1, y: 1} - m_BounceIntensity: 1 - m_ColorTemperature: 6570 - m_UseColorTemperature: 0 - m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} - m_UseBoundingSphereOverride: 0 - m_UseViewFrustumForShadowCasterCull: 1 - m_ForceVisible: 0 - m_ShadowRadius: 0 - m_ShadowAngle: 0 - m_LightUnit: 1 - m_LuxAtDistance: 1 - m_EnableSpotReflector: 1 ---- !u!4 &1940555585 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 472e785512124d0b833406f0236dbefd, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3JournalWriter + writeOnSessionEnd: 1 +--- !u!114 &1463573600 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1463573598} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b9612f98ce22409390fd09f0cdf17959, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::M3Hud + font: {fileID: 11400000, guid: d54e351c41d2e4344b35f0bf3e77ec76, type: 2} + baseFontSize: 28 + handoverSeconds: 12 + vrDistance: 1.7 + vrWidth: 2.3 + vrFollowSpeed: 4 +--- !u!4 &1463573601 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1940555583} + m_GameObject: {fileID: 1463573598} serializedVersion: 2 - m_LocalRotation: {x: 0.105271205, y: 0.88241714, z: -0.39287746, w: 0.236443} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1994200177 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1994200181} + - component: {fileID: 1994200180} + - component: {fileID: 1994200179} + - component: {fileID: 1994200178} + m_Layer: 0 + m_Name: Ground + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 2147483647 + m_IsActive: 1 +--- !u!23 &1994200178 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1994200177} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!64 &1994200179 +MeshCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1994200177} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 5 + m_Convex: 0 + m_CookingOptions: 30 + m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} +--- !u!33 &1994200180 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1994200177} + m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} +--- !u!4 &1994200181 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1994200177} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 18, y: 0, z: 8} + m_LocalScale: {x: 12, y: 1, z: 12} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1660057539 &9223372036854775807 SceneRoots: m_ObjectHideFlags: 0 m_Roots: - - {fileID: 1940555585} - - {fileID: 960010839} - - {fileID: 1651663558} - - {fileID: 1315144568} - - {fileID: 168299021} - - {fileID: 448279869} + - {fileID: 147967245} + - {fileID: 1994200181} + - {fileID: 621284223} + - {fileID: 283339672} + - {fileID: 249431433} + - {fileID: 183188905} + - {fileID: 1463573601} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3CappedBodyManipulator.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3CappedBodyManipulator.cs new file mode 100644 index 00000000..9bfcd284 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3CappedBodyManipulator.cs @@ -0,0 +1,35 @@ +using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit.Locomotion; + +/// +/// The XR rig's body capsule, with a ceiling on its height. +/// +/// The stock manipulator makes the capsule exactly as tall as the wearer's head is high. That +/// leaves no room to move under a beam or an eave for anyone tall, so the capsule is capped a +/// little below head height; anyone shorter than the cap is unaffected. (The village doorways +/// are 2.1 m clear, so this is not what lets people through the door - a first version capped +/// it at 1.3 m to squeeze under a 1.5 m doorway and the head simply pushed through the wall +/// above it, which is why the houses were scaled up instead.) +/// +public class M3CappedBodyManipulator : CharacterControllerBodyManipulator +{ + /// Tallest the capsule may be, in metres. + public float maxCapsuleHeight = 1.6f; + + public override CollisionFlags MoveBody(Vector3 motion) + { + if (linkedBody == null || characterController == null) + return CollisionFlags.None; + + var ground = linkedBody.GetBodyGroundLocalPosition(); + float height = Mathf.Min(linkedBody.xrOrigin.CameraInOriginSpaceHeight - ground.y, maxCapsuleHeight); + characterController.height = height; + characterController.center = new Vector3(ground.x, ground.y + height * 0.5f + characterController.skinWidth, ground.z); + + if (characterController.enabled) + return characterController.Move(motion); + + linkedBody.originTransform.position += motion; + return CollisionFlags.None; + } +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3CappedBodyManipulator.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/M3CappedBodyManipulator.cs.meta new file mode 100644 index 00000000..a9e0cab7 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3CappedBodyManipulator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1d65434bfe8e843128d2f3b6d194523f \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs index dfd86012..170d0072 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3DebugDriver.cs @@ -50,6 +50,7 @@ private void Update() if (k.cKey.wasPressedThisFrame) DoHousehold(ActionKind.RepairScreen); if (k.xKey.wasPressedThisFrame) DoHousehold(ActionKind.AdviseClosingHours); + if (k.enterKey.wasPressedThisFrame) S.PilotReady(); if (k.spaceKey.wasPressedThisFrame) S.EndTurn(); } @@ -133,7 +134,7 @@ private void OnGUI() sb.AppendLine(); sb.AppendLine("TAB house Q/E person V visit N net B take net back R refer"); - sb.AppendLine("W water T rest G repellent F fan C fix screen X closing hours SPACE end turn"); + sb.AppendLine("W water T rest G repellent F fan C fix screen X closing hours ENTER ready SPACE end turn"); if (lastMessage.Length > 0) sb.AppendLine($"> {lastMessage}"); GUI.Label(new Rect(12, 12, 900, 460), sb.ToString()); diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs index 3a421b49..022e83ad 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Hud.cs @@ -14,9 +14,12 @@ /// or a laptop screen mirroring the play. Section 8 adds that the countdown "should be visible to /// the Coach, not only to the Pilot". /// -/// So this is a screen-space overlay with large type. A HUD that only works inside the headset -/// would leave two thirds of the squad watching, which is the failure mode the squad structure -/// exists to prevent. +/// So this is large type. On a desktop it is a screen-space overlay. In the headset an overlay is +/// not drawn at all, so there it becomes a world-space canvas that follows the Pilot's head at +/// arm's length - and because casting mirrors the headset view, the Coach and the Analyst still +/// read the same countdown on their screens. A HUD that only works inside the headset would leave +/// two thirds of the squad watching, which is the failure mode the squad structure exists to +/// prevent. /// /// The canvas is built in code so the scene file stays small enough to merge - see /// Module3SceneBuilder. @@ -26,21 +29,34 @@ public class M3Hud : MonoBehaviour [SerializeField] private TMP_FontAsset font; [SerializeField] private int baseFontSize = 28; - [Tooltip("Seconds the handover screen stays up before the next turn can start.")] + [Tooltip("Seconds the handover screen stays up when the session does not wait for the Pilot.")] [SerializeField] private float handoverSeconds = 12f; + [Header("In the headset")] + [Tooltip("How far in front of the Pilot the HUD floats.")] + [SerializeField] private float vrDistance = 1.7f; + [Tooltip("Width of the HUD in metres at that distance (the canvas is 1280 x 720 units).")] + [SerializeField] private float vrWidth = 2.3f; + [Tooltip("How quickly the HUD catches up when the Pilot turns their head.")] + [SerializeField] private float vrFollowSpeed = 4f; + private TMP_Text statusLine; private TMP_Text coachPanel; private GameObject handoverPanel; private TMP_Text handoverText; private GameObject debriefPanel; private TMP_Text debriefText; + private TMP_Text banner; + private Canvas canvas; + private bool worldSpace; + private Vector3 smoothForward = Vector3.forward; private float handoverShownAt = -1f; private readonly StringBuilder sb = new StringBuilder(); private void Awake() { + M3Ui.Font = font; // shared with the conversation panel and the other in-world UI BuildCanvas(); } @@ -51,7 +67,13 @@ private void Awake() private M3Session subscribedTo; private void OnEnable() => Subscribe(); - private void Start() => Subscribe(); + + private void Start() + { + Subscribe(); + // The XR player builds its rig in its own Awake, so by Start there is a headset camera. + if (M3XRPlayer.Instance != null && M3XRPlayer.Instance.Rig != null) MakeWorldSpace(); + } private void Subscribe() { @@ -75,14 +97,88 @@ private void OnDisable() private void Update() { UpdateStatusLine(); + UpdateBanner(); - if (handoverShownAt > 0f && Time.time - handoverShownAt > handoverSeconds) + var s = M3Session.Instance; + if (s == null || s.Session == null || handoverPanel == null) return; + + if (s.Running && s.WaitingForPilot) + { + // The turn is held until the Pilot presses A. The first turn has no handover to show, + // so it gets a plain "you are player N" card instead. + if (!handoverPanel.activeSelf) ShowStartCard(); + } + else if (handoverPanel.activeSelf && handoverShownAt >= 0f + && (!waitedForPilot || Time.time - handoverShownAt > 0.25f) + && (waitedForPilot || Time.time - handoverShownAt > handoverSeconds)) { handoverShownAt = -1f; - if (handoverPanel != null) handoverPanel.SetActive(false); + handoverPanel.SetActive(false); } } + private bool waitedForPilot; + + private void LateUpdate() + { + if (!worldSpace || Camera.main == null) return; + + // Float at arm's length, catching up with the head a little behind it - glued to the eyes a + // HUD is uncomfortable, and it makes the whole view feel like a screen stuck to the face. + var head = Camera.main.transform; + Vector3 flat = head.forward; + flat.y = 0f; + if (flat.sqrMagnitude < 0.01f) flat = smoothForward; + smoothForward = Vector3.Slerp(smoothForward, flat.normalized, 1f - Mathf.Exp(-vrFollowSpeed * Time.deltaTime)); + Vector3 at = head.position + smoothForward * vrDistance; + at.y = head.position.y - 0.05f; + canvas.transform.position = at; + canvas.transform.rotation = Quaternion.LookRotation(smoothForward, Vector3.up); + } + + private void MakeWorldSpace() + { + if (worldSpace || canvas == null) return; + worldSpace = true; + canvas.renderMode = RenderMode.WorldSpace; + canvas.worldCamera = Camera.main; + canvas.sortingOrder = 20; + var rt = (RectTransform)canvas.transform; + rt.sizeDelta = new Vector2(1280f, 720f); + rt.localScale = Vector3.one * (vrWidth / 1280f); + var scaler = canvas.GetComponent(); + if (scaler != null) scaler.enabled = false; // a world canvas is sized by its transform + canvas.transform.SetParent(null, true); + smoothForward = Camera.main.transform.forward; + smoothForward.y = 0f; + if (smoothForward.sqrMagnitude < 0.01f) smoothForward = Vector3.forward; + smoothForward.Normalize(); + } + + private void UpdateBanner() + { + if (banner == null) return; + var xr = M3XRPlayer.Instance; + bool asking = xr != null && xr.ConfirmingEndTurn; + banner.text = asking ? $"{Localized("m3.ui.endTurnAsk")}" : ""; + banner.transform.parent.gameObject.SetActive(asking); + } + + private void ShowStartCard() + { + var s = M3Session.Instance; + sb.Clear(); + sb.AppendLine($"{Localized("m3.ui.playerN", s.Session.PilotIndex + 1)}"); + sb.AppendLine(); + sb.AppendLine(Localized("m3.ui.startCard")); + sb.AppendLine(); + sb.AppendLine($"{Localized("m3.ui.pressAReady")}"); + handoverText.text = sb.ToString(); + handoverPanel.SetActive(true); + handoverShownAt = Time.time; + waitedForPilot = true; + } + // ------------------------------------------------------------------------------------- private void UpdateStatusLine() @@ -96,7 +192,7 @@ private void UpdateStatusLine() // "Turn 19/18 Round 7" under the debrief, with the clock frozen. if (!s.Running) { - statusLine.text = $"Session over Day {sim.Day} Nets {sim.NetsRemaining}"; + statusLine.text = Localized("m3.hud.sessionOver", sim.Day, sim.NetsRemaining); return; } @@ -107,11 +203,10 @@ private void UpdateStatusLine() string clock = $"{Mathf.FloorToInt(left / 60f)}:{Mathf.FloorToInt(left % 60f):00}"; string colour = left <= 30f ? "#E4572E" : "#F2F2F2"; - statusLine.text = - $"{clock} " - + $"Turn {s.Session.TurnIndex + 1}/{s.Session.TotalTurns} " - + $"Round {s.Session.RoundIndex + 1} Player {s.Session.PilotIndex + 1} " - + $"Day {sim.Day} Nets {sim.NetsRemaining}"; + statusLine.text = Localized("m3.hud.status", + $"{clock}", + s.Session.TurnIndex + 1, s.Session.TotalTurns, s.Session.RoundIndex + 1, + s.Session.PilotIndex + 1, sim.Day, sim.NetsRemaining); } private void ShowActionFeedback(ActionResult result) @@ -151,11 +246,20 @@ private void ShowHandover(HandoverBrief brief) sb.AppendLine(); sb.AppendLine($"{Localized("m3.coach.handItOver")}"); + var session = M3Session.Instance; + waitedForPilot = session != null && session.WaitingForPilot; + if (waitedForPilot) + { + // The next Pilot presses A once the headset is on; the clock starts then, not before. + sb.AppendLine(); + sb.AppendLine($"{Localized("m3.ui.pressAReady")}"); + } + handoverText.text = sb.ToString(); handoverPanel.SetActive(true); handoverShownAt = Time.time; - // The next Pilot's turn starts when they can see, not while the old one is still reading. - if (M3Session.Instance != null) M3Session.Instance.HoldTurnClock(handoverSeconds); + // Without a ready press, hold the clock for the length of the screen instead. + if (!waitedForPilot && session != null) session.HoldTurnClock(handoverSeconds); } private void ShowDebrief(HandoverBrief finalBrief) @@ -229,7 +333,7 @@ private void BuildCanvas() var canvasGo = new GameObject("M3 HUD Canvas"); canvasGo.transform.SetParent(transform, false); - var canvas = canvasGo.AddComponent(); + canvas = canvasGo.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; var scaler = canvasGo.AddComponent(); @@ -242,6 +346,15 @@ private void BuildCanvas() coachPanel = Text(canvasGo.transform, "Feedback", new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(24f, 16f), new Vector2(-24f, 72f), TextAlignmentOptions.BottomLeft, baseFontSize - 4); + var bannerPanel = Panel(canvasGo.transform, "Banner", new Color(0.05f, 0.06f, 0.08f, 0.95f)); + var bannerRt = (RectTransform)bannerPanel.transform; + bannerRt.anchorMin = new Vector2(0.5f, 0.5f); + bannerRt.anchorMax = new Vector2(0.5f, 0.5f); + bannerRt.sizeDelta = new Vector2(760f, 120f); + banner = Text(bannerPanel.transform, "BannerText", Vector2.zero, Vector2.one, + new Vector2(20f, 10f), new Vector2(-20f, -10f), TextAlignmentOptions.Center, baseFontSize + 4); + bannerPanel.SetActive(false); + handoverPanel = Panel(canvasGo.transform, "Handover", new Color(0.04f, 0.05f, 0.07f, 0.94f)); handoverText = Text(handoverPanel.transform, "HandoverText", Vector2.zero, Vector2.one, new Vector2(96f, 72f), new Vector2(-96f, -72f), TextAlignmentOptions.TopLeft, baseFontSize + 4); diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs index 2e8aa48c..64a18b0f 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3NeighbourhoodBuilder.cs @@ -22,10 +22,10 @@ public class M3NeighbourhoodBuilder : MonoBehaviour + "that the player can see one house from the next. The Lao house is 8.55 m wide with a " + "9.6 m roof, so below ~10 m neighbours touch and their roofs intersect. Visual only: " + "adjacency comes from lanes / households_per_lane.")] - [SerializeField] private float plotWidth = 11f; + [SerializeField] private float plotWidth = 14.5f; [Tooltip("Distance across the lane, between the two facing rows.")] - [SerializeField] private float laneSpacing = 16f; + [SerializeField] private float laneSpacing = 18f; [Header("Prefabs")] [SerializeField] private GameObject housePrefab; @@ -35,7 +35,20 @@ public class M3NeighbourhoodBuilder : MonoBehaviour [Tooltip("Where the yard containers stand, in front of the house on the lane side. The house " + "footprint ends at z = 3.4.")] - [SerializeField] private float yardDepth = 4.6f; + [SerializeField] private float yardDepth = 6.2f; + + [Header("House size")] + [Tooltip("How much the Lao house model is scaled up. As modelled its doorway is 1.53 m clear - lower " + + "than the 1.7 m villagers who live in it, and lower than a standing adult's head - so the " + + "Pilot's headset pushed through the wall above the door. 1.4 makes it 2.1 m, with the " + + "house 12 x 9 m inside. Everything placed in house space (bed, screen, fan, villagers, " + + "yard) is multiplied by this too; plotWidth and laneSpacing are metres and already allow for it.")] + [SerializeField] private float houseScale = 1.4f; + + [Tooltip("Height of the house floor above the ground in the model (0.178 m); scaled with the house. " + + "Things standing inside - beds, villagers - sit on it, not on the ground under it.")] + [SerializeField] private float modelFloorHeight = 0.178f; + private float FloorY => modelFloorHeight * houseScale; [Tooltip("Share of houses that show a water jar in the yard - never more than one each.")] [Range(0, 100)] @@ -54,6 +67,15 @@ public class M3NeighbourhoodBuilder : MonoBehaviour public readonly List Households = new List(); public readonly List Villagers = new List(); + private static M3NeighbourhoodBuilder current; + /// How much larger than its model the house stands (1 before the neighbourhood exists). + public static float HouseScale => current != null ? current.houseScale : 1f; + private static readonly List none = new List(); + /// The plots of the neighbourhood that is standing now (empty before it is built). + public static IReadOnlyList HouseholdViews => current != null ? current.Households : none; + + private void Awake() => current = this; + private bool built; private void Start() @@ -91,7 +113,7 @@ public void Build() { var village = new Bounds(Households[0].transform.position, Vector3.zero); foreach (var h in Households) village.Encapsulate(h.transform.position); - village.Expand(new Vector3(10f, 0f, 8f)); // the houses themselves, plus their yards + village.Expand(new Vector3(16f, 0f, 14f)); // the houses themselves, plus their yards surroundings.Build(village); } @@ -111,16 +133,22 @@ private void BuildPlot(Neighbourhood sim, Household h) plot.transform.SetParent(transform, false); plot.transform.SetPositionAndRotation(plotPosition, facing); - if (housePrefab != null) Instantiate(housePrefab, plot.transform); + if (housePrefab != null) + { + var house = Instantiate(housePrefab, plot.transform); + house.transform.localScale = Vector3.one * houseScale; + OpenTheDoors(house); + } var bed = bedPrefab != null ? Instantiate(bedPrefab, plot.transform).transform : Anchor(plot.transform, "BedAnchor", bedOffset); - bed.localPosition = bedOffset; + bed.localPosition = bedOffset * houseScale + Vector3.up * FloorY; - var screenMount = Anchor(plot.transform, "ScreenMount", screenOffset); - var fanStand = Anchor(plot.transform, "FanStand", fanOffset); - var netAnchor = Anchor(plot.transform, "NetAnchor", bedOffset); + var screenMount = Anchor(plot.transform, "ScreenMount", screenOffset * houseScale); + screenMount.localScale = Vector3.one * houseScale; // the window is scaled with the house + var fanStand = Anchor(plot.transform, "FanStand", fanOffset * houseScale + Vector3.up * FloorY); + var netAnchor = Anchor(plot.transform, "NetAnchor", bedOffset * houseScale + Vector3.up * FloorY); var view = plot.AddComponent(); view.householdId = h.Id; @@ -140,7 +168,7 @@ private void BuildPlot(Neighbourhood sim, Household h) { // In the lane-side yard, in front of the house. At z = -1.8 they were inside the // 6.4 m-deep house, where nobody walking the lane could see them. - var yard = Anchor(plot.transform, $"Container_{h.Id}", new Vector3(-2.5f, 0f, yardDepth)); + var yard = Anchor(plot.transform, $"Container_{h.Id}", new Vector3(-2.5f * houseScale, 0f, yardDepth)); Instantiate(containerPrefab, yard); } @@ -150,17 +178,36 @@ private void BuildPlot(Neighbourhood sim, Household h) } } + /// + /// The Lao house model has its doors closed: each is a solid slab, with a collider, filling a + /// real opening in the wall - so a Pilot walking up to a house was stopped dead at the door + /// (found by walking in with the thumbstick; a fly-through camera never touched them). The + /// pivot of each leaf is its hinge edge, so swinging it 90 degrees about the vertical opens the + /// doorway and leaves the leaf standing flat against the wall. + /// + private static void OpenTheDoors(GameObject house) + { + foreach (var t in house.GetComponentsInChildren(true)) + { + if (t.name.StartsWith("Door")) + t.localRotation = Quaternion.Euler(0f, -90f, 0f) * t.localRotation; + } + } + private void BuildVillager(Neighbourhood sim, Transform plot, int personId, int index, Transform bed) { // A loose fan facing the side door the volunteer comes in by, rather than a single-file // row across the room facing a wall, which read as a queue. int count = sim.HouseholdById(sim.PersonById(personId).HouseholdId).ResidentIds.Count; - Vector3 door = new Vector3(3.0f, 0f, -1.27f); + Vector3 door = new Vector3(3.0f, 0f, -1.27f) * houseScale; Vector3 away = new Vector3(-0.84f, 0f, 0.54f); // from the door towards the middle of the room - float angle = (index - (count - 1) * 0.5f) * 32f; - Vector3 local = door + Quaternion.Euler(0f, angle, 0f) * away * 2.6f; + float angle = (index - (count - 1) * 0.5f) * 22f; + Vector3 local = door + Quaternion.Euler(0f, angle, 0f) * away * (2.6f * houseScale); + local.y = FloorY; // on the floor, not the ground beneath it var spot = Anchor(plot, $"Villager_{personId}", local); - spot.localRotation = Quaternion.LookRotation(door - local, Vector3.up); + var faceDoor = door - local; + faceDoor.y = 0f; + spot.localRotation = Quaternion.LookRotation(faceDoor, Vector3.up); var villager = villagerPrefab != null ? Instantiate(villagerPrefab, spot) : new GameObject("Villager"); if (villagerPrefab == null) villager.transform.SetParent(spot, false); @@ -188,6 +235,18 @@ private void BuildVillager(Neighbourhood sim, Transform plot, int personId, int // there looking well until the next turn. Refresh now that it knows who it is. view.Refresh(); Villagers.Add(view); + + // Someone to talk to: point at them and pull the trigger, or reach out. Same on every + // villager, sick or well (section 5 - nothing marks a sick person). + if (villager.GetComponentInChildren() == null) + { + var capsule = villager.AddComponent(); + capsule.center = new Vector3(0f, 0.85f, 0f); + capsule.height = 1.7f; + capsule.radius = 0.32f; + } + var target = villager.GetComponent() ?? villager.AddComponent(); + target.personId = personId; } private static Transform Anchor(Transform parent, string name, Vector3 localPosition) diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3PersonPanel.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3PersonPanel.cs new file mode 100644 index 00000000..29d41943 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3PersonPanel.cs @@ -0,0 +1,204 @@ +using System.Collections.Generic; +using Aedes.Module3.Sim; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +/// +/// The conversation with one villager, as a panel standing beside them in the room. +/// +/// It is the whole household visit of section 7 in one place: who this is, what you can see +/// (step 3), what the family tells you in plain words (step 2), and what you can do about it - +/// the closed verb list of section 4, never "diagnose" or "treat". Everything on it comes from +/// Observe, so the panel can show nothing the model says the volunteer could not have seen. +/// +/// Framing rule (section 5): every villager opens the same panel the same way. Nothing about a +/// sick person's panel looks like a warning; it is a conversation, not an alert. +/// +/// Pressable with the ray (point and pull the trigger) or by poking it with a finger. +/// +public class M3PersonPanel : MonoBehaviour +{ + public static M3PersonPanel Instance { get; private set; } + + [SerializeField] private float closeDistance = 5.5f; + + [Header("Where the panel floats")] + [Tooltip("How far from the Pilot's head. A menu is read and pressed at arm's reach: beside the " + + "villager, 3 m away, its text was a few pixels tall.")] + [SerializeField] private float panelDistance = 1.3f; + [Tooltip("Degrees to the side of the villager, so the panel does not hide the person you are talking to.")] + [SerializeField] private float sideAngle = 28f; + [Tooltip("Canvas units per metre. Lower = bigger panel and text.")] + [SerializeField] private float unitsPerMetre = 850f; + + public int PersonId { get; private set; } = -1; + public bool IsOpen => root != null && root.gameObject.activeSelf; + + private RectTransform root; + private RectTransform stack; + private TextMeshProUGUI feedback; + private Transform villager; + private string lastFeedbackKey; + + private M3Session S => M3Session.Instance; + + private void Awake() + { + Instance = this; + root = M3Ui.WorldCanvas("Person Panel", transform, new Vector2(620f, 900f), unitsPerMetre); + M3Ui.Panel(root, "Background", M3Ui.PanelColour); + stack = M3Ui.Stack(root, "Content", 7f, new RectOffset(26, 26, 22, 22)); + root.gameObject.SetActive(false); + } + + private void OnEnable() + { + if (S != null) { S.OnActionResolved += OnAction; S.OnTurnStarted += OnTurn; } + } + + private void OnDisable() + { + if (S != null) { S.OnActionResolved -= OnAction; S.OnTurnStarted -= OnTurn; } + } + + private void Start() + { + // Subscribe again once M3Session has certainly woken (see M3Hud for the race). + OnDisable(); + OnEnable(); + } + + private void OnTurn(int _) => Close(); + + private void OnAction(ActionResult r) + { + if (!IsOpen) return; + lastFeedbackKey = r.MessageKey; + Rebuild(); + } + + /// Opens the conversation with , standing beside . + public void Open(int personId, Transform body) + { + if (S == null || S.Session == null || !S.Running) return; + PersonId = personId; + villager = body; + lastFeedbackKey = null; + + // Talking to someone is looking in on their household. + var p = S.Neighbourhood.PersonById(personId); + if (p != null) M3VisitTracker.Visit(p.HouseholdId); + + Place(); + root.gameObject.SetActive(true); + Rebuild(); + } + + public void Close() + { + if (root != null) root.gameObject.SetActive(false); + PersonId = -1; + villager = null; + } + + private void Update() + { + if (!IsOpen) return; + var head = Camera.main != null ? Camera.main.transform : null; + if (head == null || villager == null) return; + Vector3 d = villager.position - head.position; + d.y = 0f; + if (d.magnitude > closeDistance) Close(); + } + + /// + /// At arm's reach in the direction of the villager, turned a little to the right of them so + /// both can be seen, and facing the Pilot. + /// + private void Place() + { + var head = Camera.main.transform; + Vector3 toVillager = villager.position - head.position; + toVillager.y = 0f; + if (toVillager.sqrMagnitude < 0.01f) toVillager = head.forward; + toVillager.Normalize(); + + // Never further than the villager, so the panel is between the two of you. + float dist = Mathf.Min(panelDistance, Mathf.Max(0.8f, Vector3.Distance(head.position, villager.position) - 0.5f)); + Vector3 dir = Quaternion.Euler(0f, sideAngle, 0f) * toVillager; + Vector3 at = head.position + dir * dist; + at.y = head.position.y - 0.1f; + root.position = at; + Vector3 look = at - head.position; + look.y = 0f; + root.rotation = Quaternion.LookRotation(look, Vector3.up); + } + + private void Rebuild() + { + M3Ui.Clear(stack); + var sim = S.Neighbourhood; + var obs = Observe.Person(sim, PersonId); + if (obs == null) { Close(); return; } + + // Who. + M3Ui.Line(stack, $"{M3Ui.L(WhoKey(obs.Age))}", 38f, M3Ui.Text); + + // What you can see. + M3Ui.Line(stack, M3Ui.L("m3.ui.youSee"), 22f, M3Ui.Muted); + var seen = new List(); + foreach (var sign in obs.Signs) seen.Add(M3Ui.L(sign.Key)); + if (seen.Count == 0) seen.Add(M3Ui.L("m3.see.nothingOutOfTheOrdinary")); + M3Ui.Line(stack, string.Join("\n", seen), 28f, M3Ui.Text); + + // What the household says. + if (!string.IsNullOrEmpty(obs.DialogueKey)) + { + M3Ui.Line(stack, M3Ui.L("m3.ui.theySay"), 22f, M3Ui.Muted); + M3Ui.Line(stack, $"“{M3Ui.L(obs.DialogueKey, obs.DialogueDays)}”", 28f, M3Ui.Accent); + } + + if (obs.Condition == VisibleCondition.AtTheHealthCentre) + { + AddFooter(); + return; + } + + // What you can do for them. + M3Ui.Line(stack, M3Ui.L("m3.ui.forThem"), 22f, M3Ui.Muted); + foreach (var a in Observe.ActionsFor(sim, PersonId)) AddAction(a); + + // And for the house. + M3Ui.Line(stack, M3Ui.L("m3.ui.forTheHouse"), 22f, M3Ui.Muted); + foreach (var a in Observe.ActionsFor(sim, obs.HouseholdId, household: true)) AddAction(a); + + AddFooter(); + } + + private void AddAction(AvailableAction a) + { + var action = a.ToAction(); + M3Ui.Button(stack, M3Ui.L(a.LabelKey), 50f, () => S.Do(action), a.Enabled, 26f); + if (!a.Enabled && !string.IsNullOrEmpty(a.DisabledReasonKey)) + M3Ui.Line(stack, M3Ui.L(a.DisabledReasonKey), 20f, M3Ui.Muted); + } + + private void AddFooter() + { + if (!string.IsNullOrEmpty(lastFeedbackKey)) + feedback = M3Ui.Line(stack, M3Ui.L(lastFeedbackKey), 24f, M3Ui.Accent); + M3Ui.Button(stack, M3Ui.L("m3.ui.close"), 46f, Close, true, 24f); + LayoutRebuilder.ForceRebuildLayoutImmediate(stack); + // Grow the card to fit its content, so a short conversation is not a tall empty panel. + float h = LayoutUtility.GetPreferredHeight(stack); + root.sizeDelta = new Vector2(root.sizeDelta.x, Mathf.Max(300f, h)); + } + + private static string WhoKey(AgeBand age) => age switch + { + AgeBand.Child => "m3.who.child", + AgeBand.Elder => "m3.who.elder", + _ => "m3.who.adult", + }; +} diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3PersonPanel.cs.meta b/Assets/1.TeamWorkspace/Module3/Scripts/M3PersonPanel.cs.meta new file mode 100644 index 00000000..a50ac607 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3PersonPanel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 408f8ef993d364a8cb76c189c5258708 \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs index b6b55894..0b82ed0a 100644 --- a/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Session.cs @@ -40,6 +40,23 @@ public class M3Session : MonoBehaviour /// The turn clock does not run before this time - the handover screen is still up. private float clockHeldUntil; + [Tooltip("Hold each turn's clock until the Pilot says they are ready (A). In a headset the next " + + "Pilot still has to put it on; a fixed delay either wastes their turn or starts it " + + "while they are fumbling with the strap.")] + [SerializeField] private bool waitForPilot = true; + + /// True while a turn is waiting for its Pilot to press ready. + public bool WaitingForPilot { get; private set; } + public event Action OnPilotReady; + + /// The Pilot is in the headset and ready: start the clock. + public void PilotReady() + { + if (!WaitingForPilot) return; + WaitingForPilot = false; + OnPilotReady?.Invoke(); + } + public event Action OnTurnStarted; // turn index public event Action OnHandover; public event Action OnSessionFinished; @@ -82,13 +99,14 @@ public void StartSession() + $"{parameters.Config.Rounds} rounds x {parameters.Config.TurnsPerRound} turns of " + $"{TurnSecondsTotal:0}s, parameters from {parameters.Origin}."); + WaitingForPilot = waitForPilot; OnTurnStarted?.Invoke(0); } private void Update() { if (!Running) return; - if (Time.time < clockHeldUntil) return; + if (WaitingForPilot || Time.time < clockHeldUntil) return; TurnSecondsRemaining -= Time.deltaTime; if (TurnSecondsRemaining > 0f) return; @@ -118,6 +136,7 @@ public void EndTurn() } TurnSecondsRemaining = TurnSecondsTotal; + WaitingForPilot = waitForPilot; OnHandover?.Invoke(brief); OnTurnStarted?.Invoke(Session.TurnIndex); } diff --git a/Assets/1.TeamWorkspace/Module3/Scripts/M3Ui.cs b/Assets/1.TeamWorkspace/Module3/Scripts/M3Ui.cs new file mode 100644 index 00000000..f1aef4b6 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module3/Scripts/M3Ui.cs @@ -0,0 +1,160 @@ +using System; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; +using UnityEngine.XR.Interaction.Toolkit.UI; + +/// +/// Building blocks for Module 3's in-headset UI: world-space canvases that the XR ray and poke +/// interactors can press, text, panels and buttons, and the localization lookup they all use. +/// +/// Built in code like the rest of the module's UI (M3Hud, Module2HUD), so there is no prefab or +/// scene YAML to merge. +/// +public static class M3Ui +{ + public static readonly Color PanelColour = new Color(0.05f, 0.06f, 0.08f, 0.92f); + public static readonly Color ButtonColour = new Color(0.20f, 0.42f, 0.33f, 1f); + public static readonly Color ButtonDisabled = new Color(0.25f, 0.27f, 0.30f, 1f); + public static readonly Color Muted = new Color(0.68f, 0.72f, 0.77f, 1f); + public static readonly Color Text = new Color(0.95f, 0.95f, 0.95f, 1f); + public static readonly Color Accent = new Color(0.90f, 0.70f, 0.36f, 1f); + + /// The Lao-capable font the HUD uses; null falls back to the TMP default. + public static TMP_FontAsset Font; + + public static string L(string key, params object[] args) + { + if (string.IsNullOrEmpty(key)) return ""; + if (LocalizationManager.Instance == null) return key; + return args == null || args.Length == 0 + ? LocalizationManager.Instance.GetLocalizedValue(key) + : LocalizationManager.Instance.GetLocalizedValue(key, args); + } + + /// + /// A world-space canvas wide, laid out in UI units at + /// , pressable by XR rays and pokes. + /// + public static RectTransform WorldCanvas(string name, Transform parent, Vector2 sizeUnits, float unitsPerMetre = 1000f) + { + var go = new GameObject(name, typeof(RectTransform)); + if (parent != null) go.transform.SetParent(parent, false); + var canvas = go.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + canvas.sortingOrder = 30; // above the head-following HUD (20): the nearer thing draws on top + go.AddComponent().dynamicPixelsPerUnit = 3f; + go.AddComponent(); + var rt = (RectTransform)go.transform; + rt.sizeDelta = sizeUnits; + rt.localScale = Vector3.one / unitsPerMetre; + if (Camera.main != null) canvas.worldCamera = Camera.main; + return rt; + } + + public static RectTransform Rect(string name, Transform parent, Vector2 anchorMin, Vector2 anchorMax, + Vector2 offsetMin, Vector2 offsetMax) + { + var go = new GameObject(name, typeof(RectTransform)); + go.transform.SetParent(parent, false); + var rt = (RectTransform)go.transform; + rt.anchorMin = anchorMin; + rt.anchorMax = anchorMax; + rt.offsetMin = offsetMin; + rt.offsetMax = offsetMax; + return rt; + } + + public static Image Panel(Transform parent, string name, Color colour) + { + var rt = Rect(name, parent, Vector2.zero, Vector2.one, Vector2.zero, Vector2.zero); + var img = rt.gameObject.AddComponent(); + img.color = colour; + return img; + } + + public static TextMeshProUGUI Label(Transform parent, string name, float size, Color colour, + TextAlignmentOptions align = TextAlignmentOptions.TopLeft) + { + var rt = Rect(name, parent, Vector2.zero, Vector2.one, Vector2.zero, Vector2.zero); + var t = rt.gameObject.AddComponent(); + if (Font != null) t.font = Font; + t.fontSize = size; + t.color = colour; + t.alignment = align; + t.richText = true; + t.textWrappingMode = TextWrappingModes.Normal; + t.raycastTarget = false; + return t; + } + + /// Adds a LayoutElement asking for a fixed or preferred height. + public static T Height(T c, float height) where T : Component + { + var le = c.gameObject.GetComponent() ?? c.gameObject.AddComponent(); + le.preferredHeight = height; + le.minHeight = height; + return c; + } + + public static Button Button(Transform parent, string label, float height, UnityAction onClick, + bool enabled = true, float fontSize = 30f) + { + var rt = Rect("Button: " + label, parent, Vector2.zero, Vector2.one, Vector2.zero, Vector2.zero); + var img = rt.gameObject.AddComponent(); + img.color = enabled ? ButtonColour : ButtonDisabled; + var button = rt.gameObject.AddComponent