feat: Purgatory Final duel vs La Muerte + SFX wiring + runtime options menu

- Duel flow: mating piece vs boss while board collapses (PurgatoryDuelManager/Rules/UI/MuerteQuotes)
- GameState.PurgatoryDuel + CampaignManager rescue/restart hooks + SquareClick routing
- Dice-based rescue of most valuable dead identity; final defeat restarts Chapter 1
- 6 SFX clips wired in Ch1/2/3 via AudioClipAssigner
- OptionsMenuController runtime audio/graphics panel, wired to MainMenu
- Tests migrated to Assets/Tests/EditMode subfolders + PurgatoryDuelRulesTests (200 total)
- Editor tools: DiagAssign, SpeakerDatabaseSetup, PurgatoryCanvasMaintenance
This commit is contained in:
2026-08-24 19:39:52 -03:00
parent 9fdafe8e04
commit f7d56066c0
79 changed files with 23516 additions and 20267 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f0fad760a5c49f94ea668f15bad0b624
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6255e3e53840c534a9b742ff3b0e50cf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: d1116462f5aa2ed4faa8c787e3daecda
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: e23a5aff8454f13418bf5946c74c1b95
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: 82487001f2a9b4443a0d2a2ca1c05c41
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: 52b4cb0754a08f844ab08975a263ef77
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: 294db75531d2c664da0b7cd6bc38c9de
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: a5ac5d35758bfc741b5602b80736c4e8
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:
+87
View File
@@ -0,0 +1,87 @@
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using AjedrezPurgatorio.Audio;
namespace AjedrezPurgatorio.EditorTools
{
/// <summary>
/// Asigna los SFX placeholder (Assets/Audio/SFX/*.wav) a todos los
/// AudioManager de las escenas y guarda las escenas modificadas.
/// Ejecutar con:
/// Unity -batchmode -projectPath . -executeMethod
/// AjedrezPurgatorio.EditorTools.AudioClipAssigner.AssignPlaceholderClipsToAllScenes
/// </summary>
public static class AudioClipAssigner
{
private static readonly (string field, string file)[] Bindings =
{
("_moveSFX", "move"),
("_captureSFX", "capture"),
("_checkSFX", "check"),
("_checkmateSFX", "checkmate"),
("_promotionSFX", "promotion"),
("_invalidMoveSFX","invalid_move"),
};
private static readonly string[] SceneNames =
{
"MainMenu", "Chapter1", "Chapter2", "Chapter3",
};
public static void AssignPlaceholderClipsToAllScenes()
{
int assignedTotal = 0;
try
{
foreach (string sceneName in SceneNames)
{
Scene scene = EditorSceneManager.OpenScene(
$"Assets/Scenes/{sceneName}.unity", OpenSceneMode.Single);
foreach (AudioManager am in Object.FindObjectsByType<AudioManager>(
FindObjectsInactive.Include, FindObjectsSortMode.None))
{
var so = new SerializedObject(am);
int assigned = 0;
foreach (var (field, file) in Bindings)
{
SerializedProperty prop = so.FindProperty(field);
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(
$"Assets/Audio/SFX/{file}.wav");
if (prop == null || clip == null || prop.objectReferenceValue != null)
continue;
prop.objectReferenceValue = clip;
assigned++;
}
if (assigned > 0)
{
so.ApplyModifiedPropertiesWithoutUndo();
EditorSceneManager.MarkSceneDirty(scene);
Debug.Log($"[AudioClipAssigner] '{sceneName}': {assigned} clips asignados a AudioManager.");
}
assignedTotal += assigned;
}
EditorSceneManager.SaveScene(scene);
}
Debug.Log($"[AudioClipAssigner] COMPLETO: {assignedTotal} referencias asignadas.");
EditorApplication.Exit(0);
}
catch (System.Exception ex)
{
Debug.LogError($"[AudioClipAssigner] FALLÓ: {ex}");
EditorApplication.Exit(1);
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8cfc3f0fcd46dc94c86a1fb4f5bca113
+29
View File
@@ -0,0 +1,29 @@
using System.Reflection;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class DiagAssign2
{
public static void Run()
{
var db = AssetDatabase.LoadAssetAtPath<SpeakerDatabase>("Assets/Game/Data/Resources/SpeakerDatabase.asset");
var scene = EditorSceneManager.OpenScene("Assets/Scenes/Chapter1.unity", OpenSceneMode.Single);
var ui = Object.FindFirstObjectByType<DialogueUI>(FindObjectsInactive.Include);
var field = typeof(DialogueUI).GetField("_speakerDatabase", BindingFlags.NonPublic | BindingFlags.Instance);
field.SetValue(ui, db);
Debug.Log($"[Diag] tras reflexion={field.GetValue(ui) != null}");
EditorUtility.SetDirty(ui);
EditorSceneManager.MarkSceneDirty(scene);
bool ok = EditorSceneManager.SaveScene(scene);
Debug.Log($"[Diag] SaveScene ok={ok}");
var scene2 = EditorSceneManager.OpenScene("Assets/Scenes/Chapter1.unity", OpenSceneMode.Single);
var ui2 = Object.FindFirstObjectByType<DialogueUI>(FindObjectsInactive.Include);
Debug.Log($"[Diag] tras recargar={field.GetValue(ui2) != null}");
EditorApplication.Exit(0);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b27ce28b927f4e4d91a89cd4ef5deda
+469
View File
@@ -0,0 +1,469 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using AjedrezPurgatorio.UI;
namespace AjedrezPurgatorio.EditorTools
{
/// <summary>
/// Herramientas de mantenimiento para las escenas de capítulo:
/// 1. Elimina jerarquías de UI duplicadas (sets completos creados por corridas
/// repetidas de los scripts de setup bajo PurgatoryCanvas).
/// 2. Copia los paneles finales (Victory/Defeat/Memorial) desde Chapter3 a la escena activa.
///
/// Uso: abrir la escena objetivo -> ejecutar menú -> revisar en jerarquía -> guardar (Ctrl+S).
/// Ninguna operación guarda la escena automáticamente.
/// </summary>
public static class PurgatoryCanvasMaintenance
{
private const string Chapter3ScenePath = "Assets/Scenes/Chapter3.unity";
private const string MenuRoot = "Tools/Purgatory UI/";
// ------------------------------------------------------------------
// 1) Deduplicación de paneles
// ------------------------------------------------------------------
[MenuItem(MenuRoot + "Deduplicate Panels (Active Scene)")]
public static void DeduplicateActiveScene()
{
Scene scene = EditorSceneManager.GetActiveScene();
if (!scene.IsValid() || string.IsNullOrEmpty(scene.path))
{
EditorUtility.DisplayDialog("Deduplicate Panels",
"Abre una escena guardada antes de ejecutar la deduplicación.", "OK");
return;
}
HashSet<int> protectedAncestorIds = CollectProtectedAncestorIds(scene);
List<DuplicateGroup> groups = CollectDuplicateGroups(scene);
if (groups.Count == 0)
{
EditorUtility.DisplayDialog("Deduplicate Panels",
$"No se encontraron grupos duplicados en '{scene.name}'.", "OK");
return;
}
// Planificar sin destruir nada todavía.
List<GameObject> toDelete = new List<GameObject>();
StringBuilder summary = new StringBuilder();
int keptTotal = 0;
foreach (DuplicateGroup group in groups.OrderByDescending(g => g.Depth))
{
List<Transform> alive = group.Members.Where(t => t != null).ToList();
if (alive.Count < 2)
continue;
List<Transform> survivors =
alive.Where(t => IsProtected(t.gameObject, protectedAncestorIds)).ToList();
if (survivors.Count == 0)
{
survivors.Add(alive.OrderByDescending(t => t.GetSiblingIndex()).First());
}
foreach (Transform t in alive.Where(t => !survivors.Contains(t)))
toDelete.Add(t.gameObject);
keptTotal += survivors.Count;
summary.AppendLine(
$"{group.DisplayName}: conservar {survivors.Count}, eliminar {alive.Count - survivors.Count}");
}
if (toDelete.Count == 0)
{
EditorUtility.DisplayDialog("Deduplicate Panels",
"Hay grupos duplicados pero todos sus miembros están protegidos por referencias serializadas. " +
"No se eliminará nada.", "OK");
return;
}
string preview = summary.ToString();
if (preview.Length > 1200)
preview = preview.Substring(0, 1200) + "\n...";
bool confirmed = EditorUtility.DisplayDialog(
"Deduplicate Panels",
$"Escena '{scene.name}': se eliminarán {toDelete.Count} objetos duplicados " +
$"(quedan {keptTotal} copias funcionales).\n\n{preview}\n\n" +
"La operación es reversible con Ctrl+Z y NO guarda la escena.",
"Eliminar duplicados", "Cancelar");
if (!confirmed)
return;
int removed = 0;
foreach (GameObject go in toDelete)
{
if (go == null)
continue; // ya murió junto a un ancestro eliminado
Undo.DestroyObjectImmediate(go);
removed++;
}
EditorSceneManager.MarkSceneDirty(scene);
Debug.Log($"[PurgatoryCanvasMaintenance] Deduplicación completa en '{scene.name}': " +
$"{removed} objetos eliminados. Revisa la jerarquía y guarda la escena.");
EditorUtility.DisplayDialog("Deduplicate Panels",
$"{removed} objetos eliminados en '{scene.name}'.\n\n" +
"Revisa la jerarquía y guarda la escena (Ctrl+S).", "OK");
}
private sealed class DuplicateGroup
{
public string DisplayName;
public int Depth;
public List<Transform> Members = new List<Transform>();
}
/// <summary>
/// Recolecta los instance IDs de todos los objetos referenciados por campos
/// serializados de la escena, incluyendo todos sus ancestros. Así se conserva
/// la copia exacta a la que apuntan campos como PurgatoryManager._offerUI,
/// aunque la referencia apunte a un hijo profundo del panel.
/// </summary>
private static HashSet<int> CollectProtectedAncestorIds(Scene scene)
{
HashSet<int> protectedIds = new HashSet<int>();
foreach (GameObject root in scene.GetRootGameObjects())
{
foreach (Component comp in root.GetComponentsInChildren<Component>(true))
{
if (comp == null)
continue; // script faltante
TryCollectReferences(comp, protectedIds);
}
}
HashSet<int> ancestors = new HashSet<int>();
foreach (int id in protectedIds)
{
UnityEngine.Object obj = EditorUtility.InstanceIDToObject(id);
Transform t = null;
if (obj is Component component && component != null)
t = component.transform;
else if (obj is GameObject go && go != null)
t = go.transform;
while (t != null)
{
ancestors.Add(t.gameObject.GetInstanceID());
t = t.parent;
}
}
return ancestors;
}
private static void TryCollectReferences(Component comp, HashSet<int> into)
{
try
{
SerializedObject so = new SerializedObject(comp);
SerializedProperty prop = so.GetIterator();
bool enterChildren = true;
while (prop.NextVisible(enterChildren))
{
enterChildren = true;
if (prop.propertyType == SerializedPropertyType.ObjectReference &&
prop.objectReferenceValue != null)
{
into.Add(prop.objectReferenceValue.GetInstanceID());
}
}
}
catch (System.Exception e)
{
Debug.LogWarning($"[PurgatoryCanvasMaintenance] No se pudo leer {comp.GetType().Name}: {e.Message}");
}
}
/// <summary>
/// Agrupa RectTransform hermanos con nombre idéntico. Solo considera objetos
/// con padre (no raíces) para acotar el riesgo al UI generado por setup.
/// </summary>
private static List<DuplicateGroup> CollectDuplicateGroups(Scene scene)
{
Dictionary<string, DuplicateGroup> byKey = new Dictionary<string, DuplicateGroup>();
foreach (GameObject root in scene.GetRootGameObjects())
{
foreach (RectTransform rt in root.GetComponentsInChildren<RectTransform>(true))
{
if (rt == null || rt.parent == null)
continue;
Transform parent = rt.parent;
string key = parent.GetInstanceID() + "|" + rt.name;
if (!byKey.TryGetValue(key, out DuplicateGroup group))
{
group = new DuplicateGroup
{
DisplayName = GetPath(rt),
Depth = CountDepth(rt)
};
byKey[key] = group;
}
group.Members.Add(rt);
}
}
return byKey.Values.Where(g => g.Members.Count > 1).ToList();
}
private static bool IsProtected(GameObject go, HashSet<int> protectedAncestorIds)
{
return protectedAncestorIds.Contains(go.GetInstanceID());
}
private static int CountDepth(Transform t)
{
int depth = 0;
while (t.parent != null)
{
depth++;
t = t.parent;
}
return depth;
}
private static string GetPath(Transform t)
{
var parts = new List<string>();
while (t != null)
{
parts.Insert(0, t.name);
t = t.parent;
}
return string.Join("/", parts);
}
// ------------------------------------------------------------------
// 2) Copia de paneles finales desde Chapter3
// ------------------------------------------------------------------
[MenuItem(MenuRoot + "Copy End Screens from Chapter3 (Active Scene)")]
public static void CopyEndScreensFromChapter3()
{
Scene active = EditorSceneManager.GetActiveScene();
if (!active.IsValid() || string.IsNullOrEmpty(active.path))
{
EditorUtility.DisplayDialog("Copy End Screens",
"Abre una escena guardada (Chapter1 o Chapter2) primero.", "OK");
return;
}
if (active.path == Chapter3ScenePath)
{
EditorUtility.DisplayDialog("Copy End Screens",
"La escena activa ES Chapter3. Abre Chapter1 o Chapter2 como destino.", "OK");
return;
}
GameObject targetCanvas = active.GetRootGameObjects()
.FirstOrDefault(go => go.name == "PurgatoryCanvas");
if (targetCanvas == null)
{
EditorUtility.DisplayDialog("Copy End Screens",
$"No se encontró 'PurgatoryCanvas' como raíz de '{active.name}'.", "OK");
return;
}
Scene chapter3 = default;
int copied = 0;
StringBuilder report = new StringBuilder();
try
{
chapter3 = EditorSceneManager.OpenScene(Chapter3ScenePath, OpenSceneMode.Additive);
foreach (GameObject src in EnumerateSourcePanels(chapter3))
{
if (targetCanvas.transform.Find(src.name) != null)
{
report.AppendLine($"{src.name}: ya existe, omitido.");
continue;
}
GameObject copy = Object.Instantiate(src);
SceneManager.MoveGameObjectToScene(copy, active);
copy.transform.SetParent(targetCanvas.transform, false);
copy.name = src.name;
Undo.RegisterCreatedObjectUndo(copy, "Copy End Screen Panel");
copied++;
report.AppendLine($"{src.name}: copiado.");
}
}
finally
{
if (chapter3.IsValid())
EditorSceneManager.CloseScene(chapter3, true);
}
EditorSceneManager.MarkSceneDirty(active);
Debug.Log($"[PurgatoryCanvasMaintenance] Copy End Screens en '{active.name}': " +
$"{copied} paneles copiados desde Chapter3. Revisa y guarda la escena.");
EditorUtility.DisplayDialog("Copy End Screens",
$"'{active.name}': {copied} paneles copiados.\n\n{report}\n" +
"Revisa la jerarquía y guarda la escena (Ctrl+S).", "OK");
}
private static IEnumerable<GameObject> EnumerateSourcePanels(Scene chapter3)
{
foreach (var type in new[] { typeof(VictoryUI), typeof(DefeatUI), typeof(MemorialUI) })
{
foreach (Component comp in Object.FindObjectsByType(type, FindObjectsInactive.Include, FindObjectsSortMode.None))
{
if (comp != null && comp.gameObject.scene == chapter3)
yield return comp.gameObject;
}
}
}
// ------------------------------------------------------------------
// 3) Automatización headless (batchmode -executeMethod)
// ------------------------------------------------------------------
/// <summary>
/// Procesa Chapter1/2/3 sin interacción: deduplica paneles y copia las
/// pantallas finales de Chapter3 a Chapter1/2. Guarda cada escena.
/// Ejecutar con:
/// Unity -batchmode -projectPath . -executeMethod
/// AjedrezPurgatorio.EditorTools.PurgatoryCanvasMaintenance.BatchProcessChapterScenes
/// </summary>
public static void BatchProcessChapterScenes()
{
var chapterPaths = new[]
{
"Assets/Scenes/Chapter1.unity",
"Assets/Scenes/Chapter2.unity",
"Assets/Scenes/Chapter3.unity",
};
int totalRemoved = 0;
int totalCopied = 0;
try
{
foreach (string path in chapterPaths)
{
Scene scene = EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
int removed = RemoveDuplicatesHeadless(scene);
totalRemoved += removed;
int copied = 0;
if (scene.path != Chapter3ScenePath)
copied = CopyEndScreensHeadless(scene);
totalCopied += copied;
EditorSceneManager.SaveScene(scene);
Debug.Log($"[PurgatoryCanvasMaintenance][Batch] '{scene.name}': " +
$"{removed} duplicados eliminados, {copied} pantallas copiadas. Escena guardada.");
}
Debug.Log($"[PurgatoryCanvasMaintenance][Batch] COMPLETO: {totalRemoved} duplicados, {totalCopied} copias.");
EditorApplication.Exit(0);
}
catch (System.Exception ex)
{
Debug.LogError($"[PurgatoryCanvasMaintenance][Batch] FALLÓ: {ex}");
EditorApplication.Exit(1);
}
}
private static int RemoveDuplicatesHeadless(Scene scene)
{
HashSet<int> protectedAncestorIds = CollectProtectedAncestorIds(scene);
List<DuplicateGroup> groups = CollectDuplicateGroups(scene);
int removed = 0;
foreach (DuplicateGroup group in groups.OrderByDescending(g => g.Depth))
{
List<Transform> alive = group.Members.Where(t => t != null).ToList();
if (alive.Count < 2)
continue;
List<Transform> survivors =
alive.Where(t => IsProtected(t.gameObject, protectedAncestorIds)).ToList();
if (survivors.Count == 0)
survivors.Add(alive.OrderByDescending(t => t.GetSiblingIndex()).First());
foreach (Transform t in alive.Where(t => !survivors.Contains(t)))
{
GameObject go = t.gameObject;
if (go == null)
continue; // ya murió junto a un ancestro eliminado
Object.DestroyImmediate(go);
removed++;
}
}
if (removed > 0)
EditorSceneManager.MarkSceneDirty(scene);
return removed;
}
private static int CopyEndScreensHeadless(Scene active)
{
GameObject targetCanvas = active.GetRootGameObjects()
.FirstOrDefault(go => go.name == "PurgatoryCanvas");
if (targetCanvas == null)
{
Debug.LogWarning($"[PurgatoryCanvasMaintenance][Batch] '{active.name}' sin PurgatoryCanvas; se omiten las copias.");
return 0;
}
Scene chapter3 = default;
int copied = 0;
try
{
chapter3 = EditorSceneManager.OpenScene(Chapter3ScenePath, OpenSceneMode.Additive);
foreach (GameObject src in EnumerateSourcePanels(chapter3))
{
if (targetCanvas.transform.Find(src.name) != null)
continue;
GameObject copy = Object.Instantiate(src);
SceneManager.MoveGameObjectToScene(copy, active);
copy.transform.SetParent(targetCanvas.transform, false);
copy.name = src.name;
copied++;
}
}
finally
{
if (chapter3.IsValid())
EditorSceneManager.CloseScene(chapter3, true);
}
if (copied > 0)
EditorSceneManager.MarkSceneDirty(active);
return copied;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c50f0212759220b4990b34b8fe3c7ff9
+166
View File
@@ -0,0 +1,166 @@
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace AjedrezPurgatorio.EditorTools
{
/// <summary>
/// Puebla el SpeakerDatabase.asset con los retratos existentes y asigna el
/// asset a todos los DialogueUI de las escenas. Guarda los cambios.
/// Ejecutar con:
/// Unity -batchmode -projectPath . -executeMethod
/// AjedrezPurgatorio.EditorTools.SpeakerDatabaseSetup.PopulateAndAssign
///
/// Nota: 'muerte' y 'ricardo' no tienen PNG de retrato todavía; se registran
/// con portrait nulo para evitar warnings y facilitar añadir arte después.
/// </summary>
public static class SpeakerDatabaseSetup
{
private const string DatabasePath = "Assets/Game/Data/Resources/SpeakerDatabase.asset";
private const string PortraitsFolder = "Assets/Game/Data/Resources/Portraits";
private static readonly (string speaker, string portrait)[] Entries =
{
("narrator", "narrator"),
("muerte", null),
("ricardo", null),
("alfil", "alfil"),
("caballo", "caballo"),
("peon", "peon"),
("reina", "reina"),
("torre", "torre"),
("espejo", "espejo"),
("coleccionista", "coleccionista"),
("enemigo", "enemigo"),
("voz_nino", "voz_nino"),
};
private static readonly string[] SceneNames =
{
"MainMenu", "Chapter1", "Chapter2", "Chapter3",
};
public static void PopulateAndAssign()
{
try
{
int populated = PopulateDatabase();
int assigned = AssignToScenes();
Debug.Log($"[SpeakerDatabaseSetup] COMPLETO: {populated} speakers poblados, {assigned} DialogueUI actualizados.");
EditorApplication.Exit(0);
}
catch (System.Exception ex)
{
Debug.LogError($"[SpeakerDatabaseSetup] FALLÓ: {ex}");
EditorApplication.Exit(1);
}
}
private static int PopulateDatabase()
{
var db = AssetDatabase.LoadAssetAtPath<SpeakerDatabase>(DatabasePath);
if (db == null)
throw new System.IO.FileNotFoundException($"No se encontró {DatabasePath}");
// Los PNG de Portraits pueden no estar importados en la AssetDatabase
// (p.ej. Library regenerada). Importar explícitamente sin Refresh global.
int imported = 0;
foreach (var (_, portraitFile) in Entries)
{
if (string.IsNullOrEmpty(portraitFile))
continue;
string path = $"{PortraitsFolder}/{portraitFile}.png";
if (!System.IO.File.Exists(path))
{
Debug.LogError($"[SpeakerDatabaseSetup] No existe en disco: {path}");
continue;
}
try
{
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
imported++;
}
catch (System.Exception ex)
{
Debug.LogError($"[SpeakerDatabaseSetup] Fallo importando {path}: {ex.Message}");
}
}
Debug.Log($"[SpeakerDatabaseSetup] {imported} retratos re-importados.");
var so = new SerializedObject(db);
SerializedProperty list = so.FindProperty("_speakers");
if (list == null)
throw new System.MissingMemberException("_speakers no encontrado en SpeakerDatabase.");
list.ClearArray();
int count = 0;
foreach (var (speaker, portraitFile) in Entries)
{
list.InsertArrayElementAtIndex(list.arraySize);
SerializedProperty element = list.GetArrayElementAtIndex(list.arraySize - 1);
element.FindPropertyRelative("speakerName").stringValue = speaker;
Sprite sprite = null;
if (!string.IsNullOrEmpty(portraitFile))
sprite = AssetDatabase.LoadAssetAtPath<Sprite>($"{PortraitsFolder}/{portraitFile}.png");
if (!string.IsNullOrEmpty(portraitFile) && sprite == null)
Debug.LogWarning($"[SpeakerDatabaseSetup] Portrait no encontrado para '{speaker}': {portraitFile}.png");
element.FindPropertyRelative("portrait").objectReferenceValue = sprite;
count++;
}
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(db);
AssetDatabase.SaveAssets();
return count;
}
private static int AssignToScenes()
{
var db = AssetDatabase.LoadAssetAtPath<SpeakerDatabase>(DatabasePath);
int assignedTotal = 0;
// Nota: SerializedObject.ApplyModifiedProperties* no persiste este
// cambio en este proyecto (probado: el valor se pierde incluso en
// memoria tras Apply). Reflexión directa + SetDirty sí funciona.
var field = typeof(DialogueUI).GetField("_speakerDatabase",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
foreach (string sceneName in SceneNames)
{
Scene scene = EditorSceneManager.OpenScene(
$"Assets/Scenes/{sceneName}.unity", OpenSceneMode.Single);
bool dirty = false;
foreach (DialogueUI ui in Object.FindObjectsByType<DialogueUI>(
FindObjectsInactive.Include, FindObjectsSortMode.None))
{
if (ui == null || field.GetValue(ui) as SpeakerDatabase != null)
continue;
field.SetValue(ui, db);
EditorUtility.SetDirty(ui);
assignedTotal++;
dirty = true;
}
if (dirty)
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
}
return assignedTotal;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bdc614fe81279584a9cc6267514ff99c
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: 337871385a0b47dca76233089ebc113a
guid: 4c1965114e4cdad458cc690285ff2074
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: 1676898138316344188
second: alfil_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: alfil_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: c7b0cd2e8f9854710800000000000000
internalID: 1676898138316344188
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
alfil_0: 1676898138316344188
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: e993fcc745b44d74bdff78ca54ecae2f
guid: a579bb1b0b7e3d642a72c423e82aa57e
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: -8012410325477036581
second: caballo_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: caballo_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: bdde443b2433ec090800000000000000
internalID: -8012410325477036581
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
caballo_0: -8012410325477036581
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: 0aa3b637028c46aea491d5f19c5bf328
guid: bd525630b7cffb8478281a6102df7005
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: 434492148875499951
second: coleccionista_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: coleccionista_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: fa99b6fae40a70600800000000000000
internalID: 434492148875499951
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
coleccionista_0: 434492148875499951
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: 66d4d365262a43c48947a40f1ad7dc14
guid: bbbab8e1671274e4ba56866bfc33c2d6
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: -1092110371768519499
second: enemigo_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: enemigo_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 5b4b733376b08d0f0800000000000000
internalID: -1092110371768519499
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
enemigo_0: -1092110371768519499
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: f7197410ae7f45c4b45da28c2c8b4905
guid: 1ca7029a0492e9c40b6c8a99db8f0f1c
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: -6550897566918066470
second: espejo_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: espejo_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: adeb0ebfe598615a0800000000000000
internalID: -6550897566918066470
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
espejo_0: -6550897566918066470
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: 148fa96098ae472f9bbf4cde9fab00d6
guid: 737c4034b70db6d47978d8d288f51f59
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: 5364273658523971450
second: narrator_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: narrator_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: a7f6e46a87ab17a40800000000000000
internalID: 5364273658523971450
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
narrator_0: 5364273658523971450
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: 77143a27a96a42c281bb2d9d26385e71
guid: e1b4509d5eb75d54a8706bd0e7c1682f
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: 7304130777000316518
second: peon_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: peon_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 66e0a142e3c7d5560800000000000000
internalID: 7304130777000316518
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
peon_0: 7304130777000316518
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: d628de6a3bbf438ea574cef6994edc02
guid: efef67743c203fa418f587d7794ad737
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: 9131763069496480379
second: reina_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: reina_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: b725e5a212a8abe70800000000000000
internalID: 9131763069496480379
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
reina_0: 9131763069496480379
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: e26e45f73d204da189b4248ba0a84d7c
guid: c6c9116fbc38e4643ab2beb0d17759cc
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: -5933413741351382349
second: torre_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: torre_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 3b2545983a748ada0800000000000000
internalID: -5933413741351382349
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
torre_0: -5933413741351382349
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,14 +1,17 @@
fileFormatVersion: 2
guid: f2b9a076c55b47c992bc2c7cbefa9532
guid: 4358198be608bd747bce86a521d1d173
TextureImporter:
internalIDToNameTable: []
internalIDToNameTable:
- first:
213: 1191133803369584460
second: voz_nino_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
correctGamma: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
@@ -25,7 +28,7 @@ TextureImporter:
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -43,7 +46,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -63,11 +66,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecay: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,22 +83,74 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
sprites:
- serializedVersion: 2
name: voz_nino_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: c47ddcd12e1c78010800000000000000
internalID: 1191133803369584460
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
spriteID:
internalID: 0
vertices: []
indices:
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
spriteCustomMetadata:
entries: []
nameFileIdTable:
voz_nino_0: 1191133803369584460
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -11,5 +11,29 @@ MonoBehaviour:
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 281f13885be2fde46bc468d72ea1291a, type: 3}
m_Name: SpeakerDatabase
m_EditorClassIdentifier:
_speakers: []
m_EditorClassIdentifier:
_speakers:
- speakerName: narrator
portrait: {fileID: 5364273658523971450, guid: 737c4034b70db6d47978d8d288f51f59, type: 3}
- speakerName: muerte
portrait: {fileID: 0}
- speakerName: ricardo
portrait: {fileID: 0}
- speakerName: alfil
portrait: {fileID: 1676898138316344188, guid: 4c1965114e4cdad458cc690285ff2074, type: 3}
- speakerName: caballo
portrait: {fileID: -8012410325477036581, guid: a579bb1b0b7e3d642a72c423e82aa57e, type: 3}
- speakerName: peon
portrait: {fileID: 7304130777000316518, guid: e1b4509d5eb75d54a8706bd0e7c1682f, type: 3}
- speakerName: reina
portrait: {fileID: 9131763069496480379, guid: efef67743c203fa418f587d7794ad737, type: 3}
- speakerName: torre
portrait: {fileID: -5933413741351382349, guid: c6c9116fbc38e4643ab2beb0d17759cc, type: 3}
- speakerName: espejo
portrait: {fileID: -6550897566918066470, guid: 1ca7029a0492e9c40b6c8a99db8f0f1c, type: 3}
- speakerName: coleccionista
portrait: {fileID: 434492148875499951, guid: bd525630b7cffb8478281a6102df7005, type: 3}
- speakerName: enemigo
portrait: {fileID: -1092110371768519499, guid: bbbab8e1671274e4ba56866bfc33c2d6, type: 3}
- speakerName: voz_nino
portrait: {fileID: 1191133803369584460, guid: 4358198be608bd747bce86a521d1d173, type: 3}
+23
View File
@@ -229,6 +229,29 @@ public class CampaignState : ScriptableObject
return new List<string>(_alivePieces);
}
/// <summary>
/// Obtiene las PieceIdentity muertas (no presentes en _alivePieces).
/// Usado por el Duelo del Purgatorio para elegir al candidato de rescate.
/// </summary>
public List<PieceIdentity> GetDeadIdentities()
{
var result = new List<PieceIdentity>();
if (_allIdentities == null || _allIdentities.Count == 0)
return result;
foreach (var identity in _allIdentities)
{
if (identity != null && !string.IsNullOrEmpty(identity.characterName)
&& !_alivePieces.Contains(identity.characterName.ToLower()))
{
result.Add(identity);
}
}
return result;
}
/// <summary>
/// Obtiene la lista de PieceIdentity disponibles para el próximo tablero.
/// Compatibilidad con BoardManager y PieceIdentityManager.
@@ -44,6 +44,12 @@ public class CampaignManager : MonoBehaviour
// Propiedades
public ChapterData CurrentChapter { get; private set; }
public int CurrentChapterIndex => _currentChapterIndex;
/// <summary>
/// Estado de campaña activo (ScriptableObject). Expuesto para sistemas runtime
/// como el Duelo del Purgatorio que necesitan registrar piezas muertas/recuperadas.
/// </summary>
public CampaignState State => _campaignState;
public bool IsCampaignActive => _campaignActive;
public int TotalChapters => _activeConfig?.ChapterCount ?? 0;
public DeadKingData CurrentDeadKing => _currentDeadKing;
@@ -436,13 +442,61 @@ public class CampaignManager : MonoBehaviour
private void OnGameCheckmate(bool whiteWins)
{
if (!_campaignActive) return;
bool playerWins = whiteWins; // Player controls white
if (playerWins && PurgatoryDuelManager.TryStartFromCampaign(this))
{
return; // El duelo del Purgatorio decide el desenlace
}
if (playerWins)
OnChapterVictory();
else
OnChapterDefeat();
}
/// <summary>
/// El jugador capturó a La Muerte en el duelo: continuar como victoria de capítulo.
/// </summary>
public void ResumeAfterDuelVictory()
{
OnChapterVictory();
}
/// <summary>
/// El rescate por dados tuvo éxito: el capítulo continúa con el tablero restaurado.
/// </summary>
public void ResumeAfterDuelRescue()
{
Debug.Log("[CampaignManager] Rescate del duelo exitoso. El capítulo continúa.");
TransitionToPlaying();
}
/// <summary>
/// Detiene los sistemas de capítulo (niebla, malditas, reloj, jefes, triggers)
/// mientras el Duelo del Purgatorio está activo.
/// </summary>
public void SuspendChapterSystems()
{
EndChapterSystems();
}
/// <summary>
/// Derrota final en el duelo: reiniciar la campaña desde el primer tablero.
/// </summary>
public void RestartAtFirstChapter()
{
Debug.Log("[CampaignManager] El Purgatorio reclama la partida. Reinicio al Capítulo 1.");
if (DialogueSystem.Instance != null)
DialogueSystem.Instance.ForceEndDialogue();
if (GameStateManager.Instance != null)
GameStateManager.Instance.TransitionTo(GameState.Menu);
_campaignActive = true;
LoadChapterScene(0);
}
private void OnGameDraw(string reason)
{
@@ -58,9 +58,10 @@ public class GameStateManager : MonoBehaviour
public enum GameState
{
Menu, // Menú principal
Playing, // Partida de ajedrez en curso
Dialogue, // Diálogo (intro/outro de capítulo) — overlay en la escena del capítulo
Purgatory, // Mini-juego de dados — overlay en la escena del capítulo
GameOver // Dead King input / resultado — overlay en la escena del capítulo
Menu, // Menú principal
Playing, // Partida de ajedrez en curso
Dialogue, // Diálogo (intro/outro de capítulo) — overlay en la escena del capítulo
Purgatory, // Mini-juego de dados — overlay en la escena del capítulo
PurgatoryDuel, // Duelo final contra La Muerte tras dar jaque mate
GameOver // Dead King input / resultado — overlay en la escena del capítulo
}
@@ -99,7 +99,11 @@ namespace AjedrezPurgatorio.Meta
if (_isInitialized)
return;
_persistentFilePath = Path.Combine(Application.persistentDataPath, "dead_kings.json");
// Preservar una ruta ya asignada (inyección para tests); solo
// asignar la persistente por defecto si el campo está vacío.
if (string.IsNullOrEmpty(_persistentFilePath))
_persistentFilePath = Path.Combine(Application.persistentDataPath, "dead_kings.json");
LoadPool();
_isInitialized = true;
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ecc57bd50bd471047882bb2df0ed6097
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Frases de La Muerte durante el Duelo del Purgatorio.
/// Voz solemne según purgatorio-cosmology.md ("la Muerte", máscara formal).
/// </summary>
public static class MuerteQuotes
{
private static readonly string[] Intro =
{
"Mate perfecto... para el tablero. Aquí abajo, la partida sigue.",
"Llegaste al final del tablero. Bienvenido al principio del mío.",
"Todos los caminos del ajedrez terminan frente a mí. Tú llegaste caminando.",
"Tu reina no me mateó a mí. Solo despejó la mesa."
};
private static readonly string[] Milestone =
{
"El Purgatorio tiene hambre. Cada turno, una casilla menos.",
"El suelo que pisas ya fue. Apúrate.",
"No te apresuro. El vacío lo hace por mí.",
"Cada casilla que cae fue una partida más. Como la tuya."
};
private static readonly string[] PlayerWin =
{
"...Bien jugado. El tablero es tuyo. Por ahora.",
"Me capturas hoy. Mañana seré tu siguiente jaque.",
"Toma tu victoria. Yo me quedo con el tiempo.",
"Nadie me había alcanzado sin perder algo primero. Vuelve pronto."
};
private static readonly string[] PlayerLost =
{
"Te dije que nadie gana aquí. Solo algunos posponen.",
"Tu pieza descansa. ¿Quieres negociar por ella? Los dados no mienten.",
"El vacío también juega. Y siempre va conmigo.",
"Otra partida termina. Otra alma aprende."
};
public static string GetIntro() => Pick(Intro);
public static string GetMilestone() => Pick(Milestone);
public static string GetPlayerWin() => Pick(PlayerWin);
public static string GetPlayerLost() => Pick(PlayerLost);
private static string Pick(string[] pool)
{
if (pool == null || pool.Length == 0)
return string.Empty;
return pool[Random.Range(0, pool.Length)];
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c4d50f6d2066ae74fa6450df51918194
@@ -0,0 +1,613 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using AjedrezPurgatorio.Audio;
using UnityEngine;
/// <summary>
/// Orquesta el Duelo del Purgatorio: tras el jaque mate del jugador, su pieza
/// queda sola contra La Muerte mientras el tablero se devora a sí mismo.
/// Todo el flujo es runtime (sin edición de escenas) según purgatorio-final.md.
/// </summary>
public class PurgatoryDuelManager : MonoBehaviour
{
public static PurgatoryDuelManager Instance { get; private set; }
private enum Phase
{
Idle,
PlayerTurn,
Resolving,
Ended
}
private Phase _phase = Phase.Idle;
private int _turn;
private bool _rescueUsed;
private Piece _playerPiece;
private Piece _boss;
private readonly HashSet<Vector2Int> _devoured = new HashSet<Vector2Int>();
private readonly List<(Piece piece, Vector2Int pos)> _hiddenPieces = new List<(Piece, Vector2Int)>();
private readonly Dictionary<Renderer, Color> _originalTileColors = new Dictionary<Renderer, Color>();
private readonly System.Random _rng = new System.Random();
private CampaignManager _campaign;
private PurgatoryDuelUI _ui;
public bool IsActive => _phase != Phase.Idle && _phase != Phase.Ended;
// ══════════════════════════════════════════════════════
// INICIO
// ══════════════════════════════════════════════════════
/// <summary>
/// Punto de entrada llamado por CampaignManager al recibir OnCheckmate(playerWins).
/// Retorna false si el duelo no puede iniciarse y la campaña debe resolver victoria normal.
/// </summary>
public static bool TryStartFromCampaign(CampaignManager campaign)
{
if (Instance != null && Instance.IsActive)
return false;
if (campaign == null || GameManager.Instance == null || BoardManager.Instance == null)
return false;
Piece[,] board = GameManager.Instance.board;
if (board == null)
return false;
Piece matingPiece = FindMatingPiece(board);
if (matingPiece == null)
return false;
GameObject go = new GameObject(typeof(PurgatoryDuelManager).Name);
go.transform.SetParent(campaign.transform, worldPositionStays: false);
Instance = go.AddComponent<PurgatoryDuelManager>();
Instance.Begin(campaign, matingPiece);
return true;
}
/// <summary>
/// La pieza de mate es la pieza blanca de mayor valor que ataca al rey negro.
/// </summary>
private static Piece FindMatingPiece(Piece[,] board)
{
Vector2Int blackKingPos = default;
bool kingFound = false;
foreach (Piece piece in board)
{
if (piece is King && !piece.isWhite)
{
blackKingPos = piece.currentPos;
kingFound = true;
break;
}
}
if (!kingFound)
return null;
Piece best = null;
foreach (Piece piece in board)
{
if (piece == null || !piece.isWhite)
continue;
if (!piece.GetAttackSquares(board).Contains(blackKingPos))
continue;
if (best == null || piece.pieceValue > best.pieceValue)
best = piece;
}
return best;
}
private void Begin(CampaignManager campaign, Piece matingPiece)
{
_campaign = campaign;
_playerPiece = matingPiece;
_turn = 0;
_rescueUsed = false;
_devoured.Clear();
_hiddenPieces.Clear();
_originalTileColors.Clear();
_phase = Phase.PlayerTurn;
if (GameStateManager.Instance != null)
GameStateManager.Instance.TransitionTo(GameState.PurgatoryDuel);
var ai = FindObjectOfType<AIController>();
if (ai != null)
ai.SetEnabled(false);
if (_campaign != null)
_campaign.SuspendChapterSystems();
BuildArena();
BuildUI();
Debug.Log($"[PurgatoryDuel] Iniciado. Pieza del jugador: {_playerPiece.GetType().Name} en {_playerPiece.currentPos}.");
}
private void BuildArena()
{
Piece[,] board = GameManager.Instance.board;
for (int x = 0; x < 8; x++)
{
for (int y = 0; y < 8; y++)
{
Piece piece = board[x, y];
if (piece == null || piece == _playerPiece)
continue;
_hiddenPieces.Add((piece, new Vector2Int(x, y)));
board[x, y] = null;
piece.gameObject.SetActive(false);
}
}
_boss = SpawnBoss(board);
}
private Piece SpawnBoss(Piece[,] board)
{
GameObject prefab = BoardManager.Instance.blackKingPrefab != null
? BoardManager.Instance.blackKingPrefab
: BoardManager.Instance.kingPrefab;
if (prefab == null)
{
Debug.LogError("[PurgatoryDuel] Sin prefab de Rey para La Muerte.");
return null;
}
float tileSize = BoardManager.Instance.tileSize;
Vector2Int spawnPos = PickBossSpawn(board);
GameObject bossObj = Instantiate(prefab, new Vector3(spawnPos.x * tileSize, spawnPos.y * tileSize, -1f), Quaternion.identity);
bossObj.name = "LaMuerte";
Piece boss = bossObj.GetComponent<Piece>();
boss.isWhite = false;
boss.currentPos = spawnPos;
boss.hasMoved = true;
board[spawnPos.x, spawnPos.y] = boss;
foreach (var spriteRenderer in bossObj.GetComponentsInChildren<SpriteRenderer>(true))
spriteRenderer.color = new Color(0.55f, 0.07f, 0.07f);
return boss;
}
private static Vector2Int PickBossSpawn(Piece[,] board)
{
foreach (Vector2Int candidate in new[]
{
new Vector2Int(4, 4),
new Vector2Int(3, 3),
new Vector2Int(4, 3),
new Vector2Int(3, 4),
new Vector2Int(0, 7),
new Vector2Int(7, 7)
})
{
if (board[candidate.x, candidate.y] == null)
return candidate;
}
return new Vector2Int(0, 7);
}
private void BuildUI()
{
_ui = gameObject.AddComponent<PurgatoryDuelUI>();
_ui.Build();
_ui.ShowQuote(MuerteQuotes.GetIntro());
_ui.SetTurn(1);
}
// ══════════════════════════════════════════════════════
// INPUT DEL JUGADOR
// ══════════════════════════════════════════════════════
public void HandleTileClick(Vector2Int position)
{
if (_phase != Phase.PlayerTurn || _playerPiece == null)
return;
if (position == _playerPiece.currentPos)
{
ToggleHighlights();
return;
}
if (!GetLegalMoves().Contains(position))
return;
StartCoroutine(ExecutePlayerMove(position));
}
private void ToggleHighlights()
{
BoardManager.Instance.HideAllIndicators();
if (_highlightVisible)
{
_highlightVisible = false;
return;
}
foreach (Vector2Int move in GetLegalMoves())
BoardManager.Instance.ShowIndicator(move);
_highlightVisible = true;
}
private bool _highlightVisible;
/// <summary>
/// Movimientos legales de la pieza del jugador: reglas de ajedrez de la pieza,
/// sin casillas devoradas y solo hacia casillas vacías o con La Muerte.
/// No usa MoveValidator porque no hay rey blanco que dejar en jaque.
/// </summary>
private List<Vector2Int> GetLegalMoves()
{
Piece[,] board = GameManager.Instance.board;
var moves = new List<Vector2Int>();
foreach (Vector2Int move in _playerPiece.GetAvailableMoves(board))
{
if (_devoured.Contains(move))
continue;
Piece target = board[move.x, move.y];
if (target != null && target.isWhite)
continue;
moves.Add(move);
}
return moves;
}
private IEnumerator ExecutePlayerMove(Vector2Int targetPos)
{
_phase = Phase.Resolving;
BoardManager.Instance.HideAllIndicators();
_highlightVisible = false;
if (_boss != null && targetPos == _boss.currentPos)
{
CaptureBoss(targetPos);
yield break;
}
MovePieceOnBoard(_playerPiece, targetPos);
yield return ResolveTurn();
}
private void MovePieceOnBoard(Piece piece, Vector2Int newPos)
{
float tileSize = BoardManager.Instance.tileSize;
Vector2Int oldPos = piece.currentPos;
GameManager.Instance.board[oldPos.x, oldPos.y] = null;
GameManager.Instance.board[newPos.x, newPos.y] = piece;
piece.transform.position = new Vector3(newPos.x * tileSize, newPos.y * tileSize, -1f);
piece.currentPos = newPos;
piece.hasMoved = true;
}
private void CaptureBoss(Vector2Int bossPos)
{
Debug.Log("[PurgatoryDuel] ¡La Muerte capturada!");
_phase = Phase.Ended;
GameManager.Instance.board[bossPos.x, bossPos.y] = null;
Destroy(_boss.gameObject);
_boss = null;
MovePieceOnBoard(_playerPiece, bossPos);
if (AudioManager.Instance != null)
AudioManager.Instance.PlayCapture();
_ui.ShowQuote(MuerteQuotes.GetPlayerWin());
_ui.ShowVictory("EL TABLERO ES TUYO");
CleanupDuel(restorePieces: true);
StartCoroutine(FinishAfterDelay(() =>
{
if (_campaign != null)
_campaign.ResumeAfterDuelVictory();
TearDown();
}));
}
// ══════════════════════════════════════════════════════
// RESOLUCIÓN DE TURNO: COLAPSO + JEFE
// ══════════════════════════════════════════════════════
private IEnumerator ResolveTurn()
{
_turn++;
_ui.SetTurn(Mathf.Max(1, _turn));
_ui.ShowQuote(MuerteQuotes.GetMilestone());
yield return CollapseStep();
if (_phase == Phase.Ended)
yield break;
yield return BossStep();
if (_phase == Phase.Ended)
yield break;
if (GetLegalMoves().Count == 0)
{
Debug.Log("[PurgatoryDuel] Sin movimientos legales: turno pasado.");
StartCoroutine(ResolveTurn());
yield break;
}
_phase = Phase.PlayerTurn;
}
private IEnumerator CollapseStep()
{
int tiles = PurgatoryDuelRules.TilesForTurn(_turn);
if (tiles <= 0)
yield break;
var excluded = new HashSet<Vector2Int>(_devoured);
if (_boss != null)
excluded.Add(_boss.currentPos);
List<Vector2Int> victims = PurgatoryDuelRules.PickTilesToDevour(tiles, excluded, _rng);
foreach (Vector2Int tile in victims)
{
DevourTile(tile);
if (_playerPiece != null && tile == _playerPiece.currentPos)
{
Defeat("El vacío te tragó");
yield break;
}
yield return new WaitForSeconds(0.15f);
}
}
private void DevourTile(Vector2Int tile)
{
_devoured.Add(tile);
GameObject square = BoardManager.Instance.GetSquare(tile.x, tile.y);
if (square != null)
{
foreach (Renderer renderer in square.GetComponentsInChildren<Renderer>())
{
_originalTileColors[renderer] = renderer.material.color;
renderer.material.color = new Color(0.05f, 0.03f, 0.06f);
}
}
Debug.Log($"[PurgatoryDuel] Casilla devorada: ({tile.x}, {tile.y}).");
}
private IEnumerator BossStep()
{
if (_boss == null)
yield break;
for (int step = 0; step < PurgatoryDuelRules.BossStepsPerTurn; step++)
{
var blocked = new HashSet<Vector2Int>(_devoured);
Vector2Int next = PurgatoryDuelRules.NextChaseStep(_boss.currentPos, _playerPiece.currentPos, blocked);
if (next == _boss.currentPos)
yield break;
MovePieceOnBoard(_boss, next);
if (next == _playerPiece.currentPos)
{
Defeat("La Muerte te alcanzó");
yield break;
}
yield return new WaitForSeconds(0.25f);
}
}
// ══════════════════════════════════════════════════════
// DERROTA Y RESCATE
// ══════════════════════════════════════════════════════
private void Defeat(string reason)
{
Debug.LogWarning($"[PurgatoryDuel] Derrota: {reason}");
_phase = Phase.Ended;
BoardManager.Instance.HideAllIndicators();
PieceIdentity rescueCandidate = GetRescueCandidate();
if (!_rescueUsed && rescueCandidate != null)
{
_ui.ShowDefeatPanel(
reason,
() => StartRescue(rescueCandidate),
DeclineRescue);
}
else
{
FinalDefeat();
}
}
/// <summary>
/// Identidad muerta más valiosa disponible para el rescate por dados.
/// </summary>
private PieceIdentity GetRescueCandidate()
{
CampaignState state = _campaign != null ? _campaign.State : null;
if (state == null)
return null;
return state.GetDeadIdentities()
.OrderByDescending(IdentityValue)
.FirstOrDefault();
}
private static int IdentityValue(PieceIdentity identity)
{
return identity.pieceType switch
{
PieceType.Queen => 9,
PieceType.Rook => 5,
PieceType.Bishop => 3,
PieceType.Knight => 3,
PieceType.Pawn => 1,
_ => 0
};
}
private void StartRescue(PieceIdentity candidate)
{
_rescueUsed = true;
_ui.HideDefeatPanel();
StartCoroutine(ExecuteRescue(candidate));
}
private IEnumerator ExecuteRescue(PieceIdentity candidate)
{
DiceSystem diceSystem = FindObjectOfType<DiceSystem>();
if (diceSystem == null)
{
Debug.LogError("[PurgatoryDuel] DiceSystem no encontrado. Rescate imposible.");
FinalDefeat();
yield break;
}
DiceRollResult playerRoll = diceSystem.RollForPlayer(candidate, 0);
DiceRollResult deathRoll = diceSystem.RollForDeath();
PurgatoryWinner winner = diceSystem.DetermineWinner(playerRoll.finalResult, deathRoll.finalResult);
var rollUI = FindObjectOfType<DiceRollUI>(true);
var resultUI = FindObjectOfType<DiceResultUI>(true);
if (rollUI != null && resultUI != null)
{
rollUI.Show();
yield return rollUI.AnimatePlayerRoll(playerRoll);
yield return rollUI.AnimateDeathRoll(deathRoll);
rollUI.Hide();
resultUI.Show(winner, candidate, playerRoll.finalResult, deathRoll.finalResult);
yield return new WaitUntil(() => !resultUI.IsShowing);
}
else
{
Debug.Log($"[PurgatoryDuel] Rescate sin UI: jugador {playerRoll.finalResult} vs Muerte {deathRoll.finalResult}.");
}
if (winner == PurgatoryWinner.Player)
{
Debug.Log($"[PurgatoryDuel] Rescate exitoso: {candidate.characterName} vuelve al tablero.");
if (_campaign.State != null)
_campaign.State.MarkPieceRecovered(candidate);
_ui.ShowQuote("...Tómalas. El tablero te espera.");
CleanupDuel(restorePieces: true);
yield return FinishAfterDelay(() =>
{
if (_campaign != null)
_campaign.ResumeAfterDuelRescue();
TearDown();
});
}
else
{
Debug.Log("[PurgatoryDuel] Rescate fallido.");
FinalDefeat();
}
}
private void DeclineRescue()
{
_rescueUsed = true;
_ui.HideDefeatPanel();
FinalDefeat();
}
private void FinalDefeat()
{
_ui.ShowQuote(MuerteQuotes.GetPlayerLost());
CleanupDuel(restorePieces: false);
StartCoroutine(FinishAfterDelay(() =>
{
if (_campaign != null)
_campaign.RestartAtFirstChapter();
TearDown();
}));
}
private IEnumerator FinishAfterDelay(System.Action action)
{
yield return new WaitForSeconds(1.6f);
action?.Invoke();
}
// ══════════════════════════════════════════════════════
// LIMPIEZA
// ══════════════════════════════════════════════════════
/// <summary>
/// Devuelve las piezas ocultas a sus casillas originales y restaura el color
/// de las casillas devoradas. Con restorePieces=false (derrota final) las
/// piezas permanecen apartadas: la escena se recarga de inmediato.
/// </summary>
private void CleanupDuel(bool restorePieces)
{
Piece[,] board = GameManager.Instance.board;
if (board != null && restorePieces)
{
foreach (var entry in _hiddenPieces)
{
if (entry.piece == null)
continue;
board[entry.pos.x, entry.pos.y] = entry.piece;
entry.piece.gameObject.SetActive(true);
}
}
foreach (var pair in _originalTileColors)
{
if (pair.Key != null)
pair.Key.material.color = pair.Value;
}
if (BoardManager.Instance != null)
BoardManager.Instance.HideAllIndicators();
_hiddenPieces.Clear();
_originalTileColors.Clear();
}
private void TearDown()
{
_phase = Phase.Idle;
if (Instance == this)
Instance = null;
Destroy(gameObject);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d51ec1b9d6be556448a34c159b5f2c6e
@@ -0,0 +1,97 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Reglas puras del Duelo del Purgatorio: curva de colapso y persecución del Jefe.
/// Sin dependencias de escena para poder testear en EditMode.
/// </summary>
public static class PurgatoryDuelRules
{
public const int BoardSize = 8;
public const int CollapseGraceTurns = 2;
public const int DoubleCollapseFromTurn = 8;
public const int BossStepsPerTurn = 2;
/// <summary>
/// Casillas devoradas en el turno dado (1-indexed).
/// Turnos 1-2: 0 · Turnos 3-7: 1 · Turno 8+: 2.
/// </summary>
public static int TilesForTurn(int turn)
{
if (turn <= CollapseGraceTurns)
return 0;
if (turn < DoubleCollapseFromTurn)
return 1;
return 2;
}
/// <summary>
/// Elige casillas aleatorias a devorar, excluyendo vacíos previos y la casilla
/// del Jefe. La casilla del jugador SÍ es candidata (derrota por colapso).
/// </summary>
public static List<Vector2Int> PickTilesToDevour(int count, HashSet<Vector2Int> excluded, System.Random rng)
{
var candidates = new List<Vector2Int>(BoardSize * BoardSize);
for (int x = 0; x < BoardSize; x++)
{
for (int y = 0; y < BoardSize; y++)
{
var p = new Vector2Int(x, y);
if (!excluded.Contains(p))
candidates.Add(p);
}
}
for (int i = candidates.Count - 1; i > 0; i--)
{
int j = rng.Next(i + 1);
(candidates[i], candidates[j]) = (candidates[j], candidates[i]);
}
int n = Mathf.Min(count, candidates.Count);
return candidates.GetRange(0, n);
}
/// <summary>
/// Un paso greedy del Jefe hacia el jugador (movimiento de rey, 1 casilla),
/// evitando casillas bloqueadas (vacíos). Prefiere la diagonal; si está
/// bloqueada acepta un paso axial que no aumente la distancia Chebyshev
/// (permite rodear huecos). Devuelve 'from' si no hay paso válido.
/// </summary>
public static Vector2Int NextChaseStep(Vector2Int from, Vector2Int target, HashSet<Vector2Int> blocked)
{
int dx = Mathf.Clamp(target.x - from.x, -1, 1);
int dy = Mathf.Clamp(target.y - from.y, -1, 1);
if (dx == 0 && dy == 0)
return from;
Vector2Int diag = from + new Vector2Int(dx, dy);
if (dx != 0 && dy != 0 && !blocked.Contains(diag))
return diag;
Vector2Int stepX = from + new Vector2Int(dx, 0);
Vector2Int stepY = from + new Vector2Int(0, dy);
bool xOk = dx != 0 && !blocked.Contains(stepX) && Chebyshev(stepX, target) <= Chebyshev(from, target);
bool yOk = dy != 0 && !blocked.Contains(stepY) && Chebyshev(stepY, target) <= Chebyshev(from, target);
if (xOk && yOk)
{
return Mathf.Abs(target.x - from.x) >= Mathf.Abs(target.y - from.y) ? stepX : stepY;
}
if (xOk) return stepX;
if (yOk) return stepY;
return from;
}
public static int Chebyshev(Vector2Int a, Vector2Int b)
{
return Mathf.Max(Mathf.Abs(a.x - b.x), Mathf.Abs(a.y - b.y));
}
public static bool IsInBounds(Vector2Int p)
{
return p.x >= 0 && p.x < BoardSize && p.y >= 0 && p.y < BoardSize;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e27f0d42be82acf45a36399e09bb0102
@@ -0,0 +1,161 @@
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>
/// Overlay de UI del Duelo del Purgatorio. Se construye íntegramente en runtime
/// (sin edición de escenas) sobre su propio Canvas con sorting order alto.
/// </summary>
public class PurgatoryDuelUI : MonoBehaviour
{
private Canvas _canvas;
private TextMeshProUGUI _turnText;
private TextMeshProUGUI _quoteText;
private GameObject _defeatPanel;
private TextMeshProUGUI _defeatReasonText;
private Button _rescueButton;
private Button _surrenderButton;
private GameObject _victoryBanner;
public void Build()
{
EnsureEventSystem();
var canvasGO = new GameObject("PurgatoryDuelCanvas");
_canvas = canvasGO.AddComponent<Canvas>();
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
_canvas.sortingOrder = 500;
canvasGO.AddComponent<CanvasScaler>().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
canvasGO.AddComponent<GraphicRaycaster>();
canvasGO.transform.SetParent(transform, false);
TMP_FontAsset font = TMP_Settings.defaultFontAsset;
var title = CreateText("Title", "EL PURGATORIO", 42, new Color(0.85f, 0.12f, 0.12f), font);
Anchor(title.rectTransform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -70), new Vector2(900, 60));
_turnText = CreateText("Turn", "Turno 1", 26, new Color(0.9f, 0.88f, 0.8f), font);
Anchor(_turnText.rectTransform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -115), new Vector2(600, 40));
_quoteText = CreateText("Quote", string.Empty, 24, new Color(0.75f, 0.72f, 0.65f), font);
_quoteText.fontStyle = FontStyles.Italic;
Anchor(_quoteText.rectTransform, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -165), new Vector2(1100, 70));
BuildDefeatPanel(font);
BuildVictoryBanner(font);
HideDefeatPanel();
HideVictory();
}
private void BuildDefeatPanel(TMP_FontAsset font)
{
_defeatPanel = new GameObject("DefeatPanel");
_defeatPanel.transform.SetParent(_canvas.transform, false);
var bg = _defeatPanel.AddComponent<Image>();
bg.color = new Color(0f, 0f, 0f, 0.82f);
bg.raycastTarget = true;
Stretch(bg.rectTransform);
_defeatReasonText = CreateText("Reason", string.Empty, 34, new Color(0.9f, 0.88f, 0.8f), font);
_defeatReasonText.transform.SetParent(_defeatPanel.transform, false);
Anchor(_defeatReasonText.rectTransform, new Vector2(0.5f, 0.55f), new Vector2(0.5f, 0.55f), Vector2.zero, new Vector2(1000, 90));
_rescueButton = CreateButton("RescueButton", "Desafiar por rescate", font, new Color(0.45f, 0.08f, 0.08f));
_rescueButton.transform.SetParent(_defeatPanel.transform, false);
Anchor((RectTransform)_rescueButton.transform, new Vector2(0.5f, 0.32f), new Vector2(0.5f, 0.32f), new Vector2(-160, 0), new Vector2(300, 64));
_surrenderButton = CreateButton("SurrenderButton", "Rendirse", font, new Color(0.15f, 0.15f, 0.17f));
_surrenderButton.transform.SetParent(_defeatPanel.transform, false);
Anchor((RectTransform)_surrenderButton.transform, new Vector2(0.5f, 0.32f), new Vector2(0.5f, 0.32f), new Vector2(160, 0), new Vector2(240, 64));
}
private void BuildVictoryBanner(TMP_FontAsset font)
{
_victoryBanner = new GameObject("VictoryBanner");
_victoryBanner.transform.SetParent(_canvas.transform, false);
var text = CreateText("Text", string.Empty, 52, new Color(0.95f, 0.9f, 0.7f), font);
text.transform.SetParent(_victoryBanner.transform, false);
Stretch(text.rectTransform);
}
public void SetTurn(int turn) => _turnText.text = $"Turno {turn}";
public void ShowQuote(string quote) => _quoteText.text = quote;
public void ShowDefeatPanel(string reason, UnityEngine.Events.UnityAction onRescue, UnityEngine.Events.UnityAction onSurrender)
{
_defeatPanel.SetActive(true);
_defeatReasonText.text = reason;
_rescueButton.onClick.RemoveAllListeners();
_surrenderButton.onClick.RemoveAllListeners();
if (onRescue != null) _rescueButton.onClick.AddListener(onRescue);
if (onSurrender != null) _surrenderButton.onClick.AddListener(onSurrender);
}
public void HideDefeatPanel() => _defeatPanel.SetActive(false);
public void ShowVictory(string message)
{
_victoryBanner.SetActive(true);
_victoryBanner.GetComponentInChildren<TextMeshProUGUI>(true).text = message;
}
public void HideVictory() => _victoryBanner.SetActive(false);
private static TextMeshProUGUI CreateText(string name, string content, int size, Color color, TMP_FontAsset font)
{
var go = new GameObject(name);
var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = content;
tmp.fontSize = size;
tmp.color = color;
tmp.alignment = TextAlignmentOptions.Center;
tmp.raycastTarget = false;
if (font != null)
tmp.font = font;
return tmp;
}
private static Button CreateButton(string name, string label, TMP_FontAsset font, Color color)
{
var go = new GameObject(name);
var image = go.AddComponent<Image>();
image.color = color;
var button = go.AddComponent<Button>();
var text = CreateText("Label", label, 26, Color.white, font);
text.transform.SetParent(go.transform, false);
Stretch(text.rectTransform);
return button;
}
private static void Anchor(RectTransform rt, Vector2 anchorMin, Vector2 anchorMax, Vector2 offset, Vector2 size)
{
rt.anchorMin = anchorMin;
rt.anchorMax = anchorMax;
rt.pivot = new Vector2(0.5f, 0.5f);
rt.anchoredPosition = offset;
rt.sizeDelta = size;
}
private static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
private static void EnsureEventSystem()
{
if (Object.FindObjectOfType<EventSystem>() != null)
return;
var es = new GameObject("PurgatoryDuelEventSystem");
es.AddComponent<EventSystem>();
es.AddComponent<StandaloneInputModule>();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2f756a4d9093cc840a6474f22d9c483a
+8
View File
@@ -8,6 +8,14 @@ public class SquareClick : MonoBehaviour
{
if (GameManager.Instance == null) return;
// Duelo del Purgatorio: el manager del duelo maneja el input.
if (GameStateManager.Instance != null && GameStateManager.Instance.CurrentState == GameState.PurgatoryDuel)
{
if (PurgatoryDuelManager.Instance != null)
PurgatoryDuelManager.Instance.HandleTileClick(position);
return;
}
// Don't process clicks during dialogue, purgatory, or game over
if (GameStateManager.Instance != null && GameStateManager.Instance.CurrentState != GameState.Playing)
return;
@@ -154,16 +154,14 @@ namespace AjedrezPurgatorio.UI
/// <summary>
/// Called when "Options" button is clicked.
/// Opens options menu (placeholder for now).
/// Opens the runtime-built options menu (audio + graphics).
/// </summary>
private void OnOptionsClick()
{
if (_enableDebugLogging)
Debug.Log("[MainMenu] Options clicked.");
// TODO: Implement options menu (audio, graphics, controls)
// For now, just log
Debug.Log("[MainMenu] Options menu not yet implemented.");
OptionsMenuController.Toggle();
}
/// <summary>
@@ -0,0 +1,312 @@
using UnityEngine;
using UnityEngine.UI;
using AjedrezPurgatorio.Audio;
namespace AjedrezPurgatorio.UI
{
/// <summary>
/// Menú de opciones construido en runtime (mismo enfoque que el botón de
/// campañas alternativas del MainMenuController: no modifica escenas).
/// Ajustes: volumen master/música/SFX y pantalla completa.
/// Persistencia vía PlayerPrefs; los valores se aplican al abrir el menú
/// y al arrancar la aplicación.
/// Controles re-mapeables: fuera de alcance del slice actual.
/// </summary>
public class OptionsMenuController : MonoBehaviour
{
public static OptionsMenuController Instance { get; private set; }
private const string KeyMaster = "Options_MasterVolume";
private const string KeyMusic = "Options_MusicVolume";
private const string KeySFX = "Options_SFXVolume";
private const string KeyFullscreen = "Options_Fullscreen";
private const float DefaultVolume = 1f;
private GameObject _panel;
private Slider _masterSlider;
private Slider _musicSlider;
private Slider _sfxSlider;
private Toggle _fullscreenToggle;
private bool _suppressEvents;
private Font _font;
#region Unity Lifecycle
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void OnDestroy()
{
if (Instance == this)
Instance = null;
}
/// <summary>
/// Aplica las preferencias guardadas al arrancar el juego.
/// </summary>
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void ApplySavedPreferencesAtBoot()
{
AudioListener.volume = PlayerPrefs.GetFloat(KeyMaster, DefaultVolume);
var audio = FindObjectOfType<AudioManager>();
if (audio != null)
{
audio.SetMusicVolume(PlayerPrefs.GetFloat(KeyMusic, DefaultVolume));
audio.SetSFXVolume(PlayerPrefs.GetFloat(KeySFX, DefaultVolume));
}
}
#endregion
#region Public API
/// <summary>
/// Abre/cierra el menú de opciones, creándolo la primera vez.
/// Llamar desde MainMenuController.OnOptionsClick.
/// </summary>
public static void Toggle()
{
if (Instance == null)
{
var go = new GameObject("OptionsMenu");
go.AddComponent<OptionsMenuController>();
}
if (Instance._panel != null && Instance._panel.activeSelf)
Instance.Close();
else
Instance.Open();
}
public void Open()
{
if (_panel == null)
BuildPanel();
SyncControlsFromSettings();
_panel.SetActive(true);
}
public void Close()
{
if (_panel != null)
_panel.SetActive(false);
PlayerPrefs.Save();
}
#endregion
#region Settings Application
private void ApplyMasterVolume(float value)
{
AudioListener.volume = value;
PlayerPrefs.SetFloat(KeyMaster, value);
}
private void ApplyMusicVolume(float value)
{
if (AudioManager.Instance != null)
AudioManager.Instance.SetMusicVolume(value);
PlayerPrefs.SetFloat(KeyMusic, value);
}
private void ApplySFXVolume(float value)
{
if (AudioManager.Instance != null)
AudioManager.Instance.SetSFXVolume(value);
PlayerPrefs.SetFloat(KeySFX, value);
}
private void ApplyFullscreen(bool value)
{
Screen.fullScreen = value;
PlayerPrefs.SetInt(KeyFullscreen, value ? 1 : 0);
}
private void SyncControlsFromSettings()
{
_suppressEvents = true;
_masterSlider.value = PlayerPrefs.GetFloat(KeyMaster, DefaultVolume);
_musicSlider.value = PlayerPrefs.GetFloat(KeyMusic, DefaultVolume);
_sfxSlider.value = PlayerPrefs.GetFloat(KeySFX, DefaultVolume);
_fullscreenToggle.isOn = PlayerPrefs.GetInt(
KeyFullscreen, Screen.fullScreen ? 1 : 0) == 1;
_suppressEvents = false;
}
#endregion
#region UI Construction
private Font GetFont()
{
if (_font != null)
return _font;
// Unity 6: LegacyRuntime.ttf es el builtin; Arial.ttf era el nombre antiguo.
_font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
if (_font == null)
_font = Resources.GetBuiltinResource<Font>("Arial.ttf");
return _font;
}
private void BuildPanel()
{
Font font = GetFont();
// Canvas overlay propio para vivir sobre cualquier escena de menú.
var canvasGo = new GameObject("OptionsMenuCanvas");
canvasGo.transform.SetParent(transform, false);
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 500;
canvasGo.AddComponent<CanvasScaler>();
canvasGo.AddComponent<GraphicRaycaster>();
// Fondo oscurecido que bloquea clicks hacia el menú principal.
var backdrop = DefaultControls.CreatePanel(
new DefaultControls.Resources());
backdrop.name = "Backdrop";
backdrop.transform.SetParent(canvasGo.transform, false);
Stretch(backdrop.GetComponent<RectTransform>());
var backdropImage = backdrop.GetComponent<Image>();
backdropImage.color = new Color(0f, 0f, 0f, 0.75f);
// Panel central.
var window = new GameObject("Window", typeof(RectTransform), typeof(Image));
window.transform.SetParent(backdrop.transform, false);
var windowRect = window.GetComponent<RectTransform>();
windowRect.sizeDelta = new Vector2(460f, 400f);
window.GetComponent<Image>().color = new Color(0.09f, 0.07f, 0.11f, 0.97f);
CreateLabel(window.transform, "Purgatorio — Opciones", 26,
new Vector2(0f, -34f), font, TextAnchor.UpperCenter);
// Sliders: Master / Música / SFX.
_masterSlider = CreateVolumeRow(window.transform, "Master", 60f,
ApplyMasterVolume, font);
_musicSlider = CreateVolumeRow(window.transform, "Música", 120f,
ApplyMusicVolume, font);
_sfxSlider = CreateVolumeRow(window.transform, "SFX", 180f,
ApplySFXVolume, font);
// Pantalla completa.
_fullscreenToggle = CreateToggleRow(window.transform, "Pantalla completa", 240f,
value => ApplyFullscreen(value), font);
// Botón Volver.
var back = DefaultControls.CreateButton(new DefaultControls.Resources());
back.name = "BackButton";
back.transform.SetParent(window.transform, false);
var backRect = back.GetComponent<RectTransform>();
backRect.sizeDelta = new Vector2(160f, 42f);
backRect.anchoredPosition = new Vector2(0f, -158f);
SetText(back.transform.Find("Text")?.GetComponent<Text>(), "Volver", 18, font);
back.GetComponent<Button>().onClick.AddListener(Close);
_panel = canvasGo;
_panel.SetActive(false);
}
private Slider CreateVolumeRow(Transform parent, string label, float y,
UnityEngine.Events.UnityAction<float> onChanged, Font font)
{
CreateLabel(parent, label, 18, new Vector2(-150f, -y), font, TextAnchor.MiddleRight);
var slider = DefaultControls.CreateSlider(new DefaultControls.Resources());
slider.name = $"{label}Slider";
slider.transform.SetParent(parent, false);
var rect = slider.GetComponent<RectTransform>();
rect.sizeDelta = new Vector2(220f, 20f);
rect.anchoredPosition = new Vector2(40f, -y);
var s = slider.GetComponent<Slider>();
s.minValue = 0f;
s.maxValue = 1f;
s.onValueChanged.AddListener(v =>
{
if (!_suppressEvents)
onChanged(v);
});
return s;
}
private Toggle CreateToggleRow(Transform parent, string label, float y,
UnityEngine.Events.UnityAction<bool> onChanged, Font font)
{
CreateLabel(parent, label, 18, new Vector2(-150f, -y), font, TextAnchor.MiddleRight);
var toggleGo = DefaultControls.CreateToggle(new DefaultControls.Resources());
toggleGo.name = $"{label}Toggle";
toggleGo.transform.SetParent(parent, false);
var rect = toggleGo.GetComponent<RectTransform>();
rect.sizeDelta = new Vector2(160f, 20f);
rect.anchoredPosition = new Vector2(40f, -y);
var t = toggleGo.GetComponent<Toggle>();
t.onValueChanged.AddListener(v =>
{
if (!_suppressEvents)
onChanged(v);
});
return t;
}
private void CreateLabel(Transform parent, string content, int size,
Vector2 anchoredPosition, Font font, TextAnchor alignment)
{
var go = new GameObject($"Label_{content}", typeof(RectTransform), typeof(Text));
go.transform.SetParent(parent, false);
var rect = go.GetComponent<RectTransform>();
rect.sizeDelta = new Vector2(200f, 28f);
rect.anchoredPosition = anchoredPosition;
var text = go.GetComponent<Text>();
text.text = content;
text.font = font;
text.fontSize = size;
text.alignment = alignment;
text.color = Color.white;
}
private void SetText(Text target, string content, int size, Font font)
{
if (target == null)
return;
target.text = content;
target.fontSize = size;
target.font = font;
target.color = Color.black;
}
private static void Stretch(RectTransform rect)
{
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8dfbb2b4249216d4889a8418cd6d28a2
+4392 -3576
View File
File diff suppressed because it is too large Load Diff
+6677 -5940
View File
File diff suppressed because it is too large Load Diff
+6244 -10123
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -17603,12 +17603,12 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 4cce55b9d3e1ed740b1d680e1ee76597, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::AjedrezPurgatorio.Audio.AudioManager
_moveSFX: {fileID: 2102800, guid: bd7eca68231b48f198532c2a8f8b8ed3, type: 3}
_captureSFX: {fileID: 2102800, guid: bbee09e29e03488a87056057de3333d3, type: 3}
_checkSFX: {fileID: 2102800, guid: 39c30d32bdb442ee844d9c2204244d96, type: 3}
_checkmateSFX: {fileID: 2102800, guid: 4d305733468c4b7199722466abeeace9, type: 3}
_promotionSFX: {fileID: 2102800, guid: f7347cab1bf24c4a93f0594fab7b84d8, type: 3}
_invalidMoveSFX: {fileID: 0}
_moveSFX: {fileID: 8300000, guid: 294db75531d2c664da0b7cd6bc38c9de, type: 3}
_captureSFX: {fileID: 8300000, guid: d1116462f5aa2ed4faa8c787e3daecda, type: 3}
_checkSFX: {fileID: 8300000, guid: e23a5aff8454f13418bf5946c74c1b95, type: 3}
_checkmateSFX: {fileID: 8300000, guid: 82487001f2a9b4443a0d2a2ca1c05c41, type: 3}
_promotionSFX: {fileID: 8300000, guid: a5ac5d35758bfc741b5602b80736c4e8, type: 3}
_invalidMoveSFX: {fileID: 8300000, guid: 52b4cb0754a08f844ab08975a263ef77, type: 3}
_sfxVolume: 0.7
_musicVolume: 0.5
_enableDebugLogging: 0
@@ -32374,7 +32374,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.28, y: 0.04}
m_AnchorMax: {x: 0.72, y: 0.18}
m_AnchoredPosition: {x: 0, y: -0.000015258789}
m_AnchoredPosition: {x: 0, y: -0.000030517578}
m_SizeDelta: {x: 0, y: -0.000030517578}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1969906157
@@ -34160,7 +34160,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.28, y: 0.04}
m_AnchorMax: {x: 0.72, y: 0.18}
m_AnchoredPosition: {x: 0, y: -0.000015258789}
m_AnchoredPosition: {x: 0, y: -0.000030517578}
m_SizeDelta: {x: 0, y: -0.000030517578}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2083124479
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0ee8ffb7fec27f545b11d379314dcc6b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,191 @@
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace AjedrezPurgatorio.Tests.Unit.Campaign
{
/// <summary>
/// Tests de integración de CampaignManager — Suite 3 del plan de automatización.
///
/// Alcance EditMode (sin cargas de escena): registro de campañas desde Resources,
/// selección de campaña activa, propiedades derivadas y guard clauses de
/// LoadChapter que retornan antes de tocar SceneManager.
///
/// Nota: AddComponent en EditMode NO invoca Awake, así que invocamos
/// BuildCampaignRegistry por reflexión sin pasar por el singleton ni
/// DontDestroyOnLoad.
/// </summary>
[TestFixture]
[Category("Unit")]
[Category("Campaign")]
[Category("CampaignManager")]
[Category("FastTests")]
public class CampaignManagerConfigTests
{
private GameObject _root;
private CampaignManager _manager;
private MethodInfo _buildRegistryMethod;
[SetUp]
public void SetUp()
{
_root = new GameObject("CampaignManagerTestRoot");
_manager = _root.AddComponent<CampaignManager>();
_buildRegistryMethod = typeof(CampaignManager).GetMethod(
"BuildCampaignRegistry", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.IsNotNull(_buildRegistryMethod, "BuildCampaignRegistry no encontrado.");
// Carga el registry desde Resources/Campaigns igual que Awake,
// pero sin singleton ni DontDestroyOnLoad (incompatibles con EditMode).
// Sin _campaignConfig asignado, LoadFromJson registra un error esperado.
LogAssert.Expect(LogType.Error, "[CampaignConfig] No se asignó archivo JSON de campaña.");
_buildRegistryMethod.Invoke(_manager, null);
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(_root);
}
// ------------------------------------------------------------------
// Registro de campañas
// ------------------------------------------------------------------
[Test]
public void Registry_LoadsAtLeastOneCampaignFromResources()
{
Assert.GreaterOrEqual(_manager.CampaignCount, 1,
"Resources/Campaigns debe contener al menos la campaña Gambito.");
}
[Test]
public void Registry_IgnoresDuplicateCampaignIds_OnRebuild()
{
int countAfterFirstBuild = _manager.CampaignCount;
// BuildCampaignRegistry limpia y reconstruye; los duplicados se descartan.
// El rebuild repite el log de config sin JSON (el Expect del SetUp ya se consumió).
LogAssert.Expect(LogType.Error, "[CampaignConfig] No se asignó archivo JSON de campaña.");
_buildRegistryMethod.Invoke(_manager, null);
Assert.AreEqual(countAfterFirstBuild, _manager.CampaignCount,
"Reconstruir el registry no debe duplicar campañas.");
}
// ------------------------------------------------------------------
// Selección de campaña activa
// ------------------------------------------------------------------
[Test]
public void SetDefaultCampaign_SelectsFirstRegistered()
{
Assert.GreaterOrEqual(_manager.CampaignCount, 1);
Assert.IsTrue(_manager.SetDefaultCampaign());
Assert.IsNotNull(_manager.ActiveCampaignId);
Assert.IsNotEmpty(_manager.ActiveCampaignId);
}
[Test]
public void SetDefaultCampaign_WithoutRegistry_Fails()
{
// Manager recién creado SIN invocar BuildCampaignRegistry:
// el registro interno está vacío.
var emptyRoot = new GameObject("EmptyCampaignManagerRoot");
var empty = emptyRoot.AddComponent<CampaignManager>();
try
{
Assert.AreEqual(0, empty.CampaignCount);
Assert.IsFalse(empty.SetDefaultCampaign(),
"Sin campañas registradas debe retornar false.");
}
finally
{
Object.DestroyImmediate(emptyRoot);
}
}
[Test]
public void SetCampaign_WithGambitoId_Succeeds()
{
Assert.IsTrue(_manager.SetCampaign("gambito"));
Assert.AreEqual("gambito", _manager.ActiveCampaignId);
}
[Test]
public void SetCampaign_WithInvalidId_Fails_AndKeepsPreviousActive()
{
Assert.IsTrue(_manager.SetDefaultCampaign());
string previousId = _manager.ActiveCampaignId;
LogAssert.Expect(LogType.Error,
"[CampaignManager] Campaña no encontrada: campana-inexistente-xyz. Disponibles: gambito");
Assert.IsFalse(_manager.SetCampaign("campana-inexistente-xyz"));
Assert.AreEqual(previousId, _manager.ActiveCampaignId,
"Un fallo de selección no debe alterar la campaña activa.");
}
[Test]
public void TotalChapters_IsPositive_AfterValidSelection()
{
Assert.IsTrue(_manager.SetCampaign("gambito"));
Assert.Greater(_manager.TotalChapters, 0,
"La campaña Gambito debe declarar al menos un capítulo.");
}
[Test]
public void ActiveCampaignId_DefaultsToFirstRegistered_BeforeExplicitSelection()
{
// ValidateConfiguration/BuildCampaignRegistry deja _activeConfig = campaigns[0].
Assert.IsNotNull(_manager.ActiveCampaignId,
"Tras construir el registry hay una campaña activa por defecto.");
Assert.IsNotEmpty(_manager.ActiveCampaignId);
}
// ------------------------------------------------------------------
// Guard clauses de LoadChapter (retornan antes de cargar escena)
// ------------------------------------------------------------------
[Test]
public void LoadChapter_NegativeIndex_Rejected_WithoutActivatingCampaign()
{
LogAssert.Expect(LogType.Error, "[CampaignManager] Índice fuera de rango: -1");
_manager.LoadChapter(-1);
Assert.IsFalse(_manager.IsCampaignActive,
"Índice inválido no debe activar la campaña.");
}
[Test]
public void LoadChapter_IndexOutOfRange_Rejected_WithoutActivatingCampaign()
{
Assert.IsTrue(_manager.SetCampaign("gambito"));
int outOfRange = _manager.TotalChapters; // índices válidos: 0..TotalChapters-1
LogAssert.Expect(LogType.Error, $"[CampaignManager] Índice fuera de rango: {outOfRange}");
_manager.LoadChapter(outOfRange);
Assert.IsFalse(_manager.IsCampaignActive);
}
[Test]
public void StartCampaign_WithValidConfig_ActivatesCampaignState()
{
Assert.IsTrue(_manager.SetCampaign("gambito"));
// StartCampaign llama a LoadChapterScene → SceneTransitionManager o
// SceneManager.LoadScene. En EditMode eso es inviable, así que solo
// verificamos las guardas previas mediante TotalChapters > 0.
Assert.Greater(_manager.TotalChapters, 0,
"Con config válida StartCampaign superaría sus guardas iniciales.");
Assert.IsFalse(_manager.IsCampaignActive,
"En EditMode nunca llegamos a ejecutar la carga de escena.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 45b54ed0265849a4bb4696b8c3c01058
@@ -0,0 +1,462 @@
using NUnit.Framework;
using UnityEngine;
using System.Collections.Generic;
namespace AjedrezPurgatorio.Tests.Unit.Campaign
{
/// <summary>
/// Unit tests para CampaignState - Sistema de persistencia de campaña.
/// Cubre: persistencia de piezas, capítulos completados, contadores de pérdidas, reset.
/// </summary>
[TestFixture]
[Category("Unit")]
[Category("CampaignState")]
[Category("FastTests")]
public class CampaignStateTests
{
private CampaignState _campaignState;
private GameObject _mockRoot;
[SetUp]
public void Setup()
{
// Crear instancia fresca de CampaignState para cada test
_campaignState = ScriptableObject.CreateInstance<CampaignState>();
_mockRoot = new GameObject("CampaignStateMockRoot");
}
[TearDown]
public void Teardown()
{
// Cleanup
if (_campaignState != null)
{
Object.DestroyImmediate(_campaignState);
}
if (_mockRoot != null)
{
Object.DestroyImmediate(_mockRoot);
}
}
#region IsPieceAlive Tests
[Test]
public void IsPieceAlive_WhenPieceRegistered_ReturnsTrue()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
// Act
bool isAlive = _campaignState.IsPieceAlive("elena");
// Assert
Assert.IsTrue(isAlive, "Elena debería estar viva después de registrarla");
}
[Test]
public void IsPieceAlive_WhenPieceNotRegistered_ReturnsFalse()
{
// Act
bool isAlive = _campaignState.IsPieceAlive("elena");
// Assert
Assert.IsFalse(isAlive, "Elena no debería estar viva si nunca fue registrada");
}
[Test]
[TestCase("Elena")]
[TestCase("ELENA")]
[TestCase("elena")]
[TestCase("ElEnA")]
public void IsPieceAlive_CaseInsensitive(string pieceName)
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
// Act
bool isAlive = _campaignState.IsPieceAlive(pieceName);
// Assert
Assert.IsTrue(isAlive, $"IsPieceAlive debería ser case-insensitive para '{pieceName}'");
}
[Test]
public void RegisterPieceLost_RemovesFromAlivePieces()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
Assert.IsTrue(_campaignState.IsPieceAlive("elena"), "Precondición: Elena debe estar viva");
// Act
_campaignState.RegisterPieceLost("elena");
// Assert
Assert.IsFalse(_campaignState.IsPieceAlive("elena"), "Elena no debería estar viva después de RegisterPieceLost");
}
[Test]
public void RegisterPieceLost_IncrementsCounters()
{
// Arrange
_campaignState.StartChapter("ch1_factory");
int initialTotal = _campaignState.TotalPiecesLost;
int initialCurrent = _campaignState.CurrentChapterPiecesLost;
// Act
_campaignState.RegisterPieceLost("elena");
// Assert
Assert.AreEqual(initialTotal + 1, _campaignState.TotalPiecesLost, "TotalPiecesLost debería incrementar");
Assert.AreEqual(initialCurrent + 1, _campaignState.CurrentChapterPiecesLost, "CurrentChapterPiecesLost debería incrementar");
}
[Test]
public void RegisterPieceAlive_AddsToAlivePieces()
{
// Act
_campaignState.RegisterPieceAlive("ricardo");
// Assert
Assert.IsTrue(_campaignState.IsPieceAlive("ricardo"), "Ricardo debería estar vivo después de RegisterPieceAlive");
}
[Test]
public void RegisterPieceAlive_NoDuplicates()
{
// Act
_campaignState.RegisterPieceAlive("elena");
_campaignState.RegisterPieceAlive("elena"); // Doble registro
// Assert
var availableNames = _campaignState.GetAvailablePieceNames();
int elenaCount = 0;
foreach (var name in availableNames)
{
if (name.ToLower() == "elena")
elenaCount++;
}
Assert.AreEqual(1, elenaCount, "Elena no debería duplicarse en _alivePieces");
}
#endregion
#region IsChapterComplete Tests
[Test]
public void IsChapterComplete_InitiallyFalse()
{
// Act
bool isComplete = _campaignState.IsChapterComplete("ch1_factory");
// Assert
Assert.IsFalse(isComplete, "Los capítulos deberían empezar sin completar");
}
[Test]
public void CompleteChapter_MarksAsComplete()
{
// Act
_campaignState.CompleteChapter("ch1_factory", wasVictory: true);
// Assert
Assert.IsTrue(_campaignState.IsChapterComplete("ch1_factory"), "ch1_factory debería estar completado");
}
[Test]
public void CompleteChapter_StoresVictoryStatus()
{
// Act
_campaignState.CompleteChapter("ch1_factory", wasVictory: true);
// Assert
Assert.IsTrue(_campaignState.LastChapterWasVictory, "LastChapterWasVictory debería ser true");
}
[Test]
public void CompleteChapter_StoresDefeatStatus()
{
// Act
_campaignState.CompleteChapter("ch3_court", wasVictory: false);
// Assert
Assert.IsFalse(_campaignState.LastChapterWasVictory, "LastChapterWasVictory debería ser false después de derrota");
}
[Test]
public void LastChapterWasVictory_UpdatesCorrectly()
{
// Arrange
_campaignState.CompleteChapter("ch1_factory", wasVictory: true);
Assert.IsTrue(_campaignState.LastChapterWasVictory, "Precondición: primera victoria");
// Act
_campaignState.CompleteChapter("ch2_hospital", wasVictory: false);
// Assert
Assert.IsFalse(_campaignState.LastChapterWasVictory, "LastChapterWasVictory debería actualizarse con el último resultado");
}
#endregion
#region Counter Tests
[Test]
public void StartChapter_ResetsPiecesLostThisChapter()
{
// Arrange
_campaignState.StartChapter("ch1_factory");
_campaignState.RegisterPieceLost("elena");
Assert.AreEqual(1, _campaignState.CurrentChapterPiecesLost, "Precondición: 1 pieza perdida en ch1");
// Act
_campaignState.StartChapter("ch2_hospital");
// Assert
Assert.AreEqual(0, _campaignState.CurrentChapterPiecesLost, "CurrentChapterPiecesLost debería resetearse al iniciar nuevo capítulo");
}
[Test]
public void RegisterPieceLost_IncrementsTotal()
{
// Arrange
_campaignState.StartChapter("ch1_factory");
int initialTotal = _campaignState.TotalPiecesLost;
// Act
_campaignState.RegisterPieceLost("elena");
_campaignState.RegisterPieceLost("ricardo");
// Assert
Assert.AreEqual(initialTotal + 2, _campaignState.TotalPiecesLost, "TotalPiecesLost debería incrementar con cada pérdida");
}
[Test]
public void RegisterPieceLost_IncrementsCurrentChapter()
{
// Arrange
_campaignState.StartChapter("ch1_factory");
// Act
_campaignState.RegisterPieceLost("elena");
_campaignState.RegisterPieceLost("ricardo");
// Assert
Assert.AreEqual(2, _campaignState.CurrentChapterPiecesLost, "CurrentChapterPiecesLost debería ser 2");
}
[Test]
public void TotalPiecesLost_AccumulatesAcrossChapters()
{
// Arrange & Act
_campaignState.StartChapter("ch1_factory");
_campaignState.RegisterPieceLost("elena");
_campaignState.RegisterPieceLost("ricardo");
_campaignState.StartChapter("ch2_hospital");
_campaignState.RegisterPieceLost("carlos");
// Assert
Assert.AreEqual(3, _campaignState.TotalPiecesLost, "TotalPiecesLost debería acumular pérdidas de todos los capítulos");
Assert.AreEqual(1, _campaignState.CurrentChapterPiecesLost, "CurrentChapterPiecesLost debería ser solo del capítulo actual");
}
#endregion
#region Reset Tests
[Test]
public void Reset_ClearsAllData()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
_campaignState.RegisterPieceAlive("ricardo");
_campaignState.CompleteChapter("ch1_factory", wasVictory: true);
_campaignState.StartChapter("ch1_factory");
_campaignState.RegisterPieceLost("elena");
// Act
_campaignState.Reset();
// Assert
Assert.IsFalse(_campaignState.IsPieceAlive("elena"), "Elena no debería estar en _alivePieces después de Reset");
Assert.IsFalse(_campaignState.IsPieceAlive("ricardo"), "Ricardo no debería estar en _alivePieces después de Reset");
Assert.IsFalse(_campaignState.IsChapterComplete("ch1_factory"), "Capítulos completados deberían limpiarse");
}
[Test]
public void Reset_ResetsCounters()
{
// Arrange
_campaignState.StartChapter("ch1_factory");
_campaignState.RegisterPieceLost("elena");
_campaignState.CompleteChapter("ch1_factory", wasVictory: true);
// Act
_campaignState.Reset();
// Assert
Assert.AreEqual(0, _campaignState.TotalPiecesLost, "TotalPiecesLost debería resetearse a 0");
Assert.AreEqual(0, _campaignState.CurrentChapterPiecesLost, "CurrentChapterPiecesLost debería resetearse a 0");
Assert.IsFalse(_campaignState.LastChapterWasVictory, "LastChapterWasVictory debería resetearse a false");
}
#endregion
#region SyncFromBoard Tests
[Test]
public void SyncFromBoard_ReadsAllWhitePieces()
{
// Arrange
var board = CreateMockBoard();
AddMockPiece(board, 0, 0, isWhite: true, identity: CreateMockIdentity("elena"));
AddMockPiece(board, 1, 0, isWhite: true, identity: CreateMockIdentity("ricardo"));
// Act
_campaignState.SyncFromBoard(board);
// Assert
Assert.IsTrue(_campaignState.IsPieceAlive("elena"), "Elena debería sincronizarse del tablero");
Assert.IsTrue(_campaignState.IsPieceAlive("ricardo"), "Ricardo debería sincronizarse del tablero");
}
[Test]
public void SyncFromBoard_IgnoresBlackPieces()
{
// Arrange
var board = CreateMockBoard();
AddMockPiece(board, 0, 0, isWhite: false, identity: CreateMockIdentity("black_piece"));
// Act
_campaignState.SyncFromBoard(board);
// Assert
Assert.IsFalse(_campaignState.IsPieceAlive("black_piece"), "Piezas negras no deberían sincronizarse");
}
[Test]
public void SyncFromBoard_IgnoresPiecesWithoutIdentity()
{
// Arrange
var board = CreateMockBoard();
AddMockPiece(board, 0, 0, isWhite: true, identity: null);
// Act
_campaignState.SyncFromBoard(board);
// Assert
var availableNames = _campaignState.GetAvailablePieceNames();
Assert.AreEqual(0, availableNames.Count, "Piezas sin identidad no deberían sincronizarse");
}
[Test]
public void SyncFromBoard_ClearsOldDataBeforeSync()
{
// Arrange
_campaignState.RegisterPieceAlive("old_piece");
Assert.IsTrue(_campaignState.IsPieceAlive("old_piece"), "Precondición: old_piece existe");
var board = CreateMockBoard();
AddMockPiece(board, 0, 0, isWhite: true, identity: CreateMockIdentity("new_piece"));
// Act
_campaignState.SyncFromBoard(board);
// Assert
Assert.IsFalse(_campaignState.IsPieceAlive("old_piece"), "Datos antiguos deberían limpiarse antes de sync");
Assert.IsTrue(_campaignState.IsPieceAlive("new_piece"), "Nuevos datos deberían estar presentes");
}
#endregion
#region Compatibility Methods Tests
[Test]
public void MarkPieceDead_WithPieceIdentity_CallsRegisterPieceLost()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
var identity = CreateMockIdentity("elena");
// Act
_campaignState.MarkPieceDead(identity);
// Assert
Assert.IsFalse(_campaignState.IsPieceAlive("elena"), "Elena debería estar muerta después de MarkPieceDead");
}
[Test]
public void MarkPieceRecovered_WithPieceIdentity_CallsRegisterPieceAlive()
{
// Arrange
var identity = CreateMockIdentity("elena");
// Act
_campaignState.MarkPieceRecovered(identity);
// Assert
Assert.IsTrue(_campaignState.IsPieceAlive("elena"), "Elena debería estar viva después de MarkPieceRecovered");
}
[Test]
public void GetAvailablePieceNames_ReturnsCorrectList()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
_campaignState.RegisterPieceAlive("ricardo");
_campaignState.RegisterPieceAlive("carlos");
// Act
var names = _campaignState.GetAvailablePieceNames();
// Assert
Assert.AreEqual(3, names.Count, "Deberían haber 3 piezas vivas");
Assert.Contains("elena", names, "Elena debería estar en la lista");
Assert.Contains("ricardo", names, "Ricardo debería estar en la lista");
Assert.Contains("carlos", names, "Carlos debería estar en la lista");
}
#endregion
#region Helper Methods
private Piece[,] CreateMockBoard()
{
return new Piece[8, 8];
}
private void AddMockPiece(Piece[,] board, int x, int y, bool isWhite, PieceIdentity identity)
{
// Piece es MonoBehaviour: hay que crearlo con AddComponent (un
// 'new MockPiece()' produce un objeto Unity inválido cuyos campos
// no persisten y SyncFromBoard lo vería vacío).
var go = new GameObject($"MockPiece_{x}_{y}");
go.transform.SetParent(_mockRoot.transform);
var piece = go.AddComponent<MockPiece>();
piece.isWhite = isWhite;
piece.identity = identity;
board[x, y] = piece;
}
private PieceIdentity CreateMockIdentity(string characterName)
{
var identity = ScriptableObject.CreateInstance<PieceIdentity>();
identity.characterName = characterName;
return identity;
}
/// <summary>
/// Mock simple de Piece para tests.
/// </summary>
private class MockPiece : Piece
{
public override List<Vector2Int> GetAvailableMoves(Piece[,] board)
{
return new List<Vector2Int>();
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 687358a6e3df4a044b620112c7a8d77e
+164 -146
View File
@@ -2,185 +2,203 @@ using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
/// <summary>
/// Unit tests for CheckDetector — null safety, FindKing, basic check/checkmate detection.
/// </summary>
public class CheckDetectorTests
namespace AjedrezPurgatorio.Tests.Unit.Chess
{
private CheckDetector _detector;
[SetUp]
public void SetUp()
[TestFixture]
[Category("Unit")]
[Category("Chess")]
[Category("CheckDetector")]
[Category("FastTests")]
public class CheckDetectorTests
{
_detector = new CheckDetector();
}
private CheckDetector _detector;
private Piece[,] _board;
private GameObject _root;
[TearDown]
public void TearDown()
{
_detector = null;
}
[SetUp]
public void Setup()
{
_detector = new CheckDetector();
_board = new Piece[8, 8];
_root = new GameObject("TestRoot");
}
// ==================== NULL SAFETY ====================
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(_root);
}
[Test]
public void IsInCheck_NullBoard_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[CheckDetector] Board es null.");
Assert.IsFalse(_detector.IsInCheck(null, true));
}
private T CreatePiece<T>(int x, int y, bool isWhite) where T : Piece
{
var go = new GameObject($"Piece_{typeof(T).Name}_{x}_{y}");
go.transform.SetParent(_root.transform);
var piece = go.AddComponent<T>();
piece.currentPos = new Vector2Int(x, y);
piece.isWhite = isWhite;
piece.hasMoved = true; // avoid castling logic
_board[x, y] = piece;
return piece;
}
[Test]
public void IsCheckmate_NullBoard_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[CheckDetector] Board es null.");
Assert.IsFalse(_detector.IsCheckmate(null, true));
}
#region IsInCheck Tests
[Test]
public void WouldLeaveKingInCheck_NullBoard_ReturnsTrue()
{
var pawn = CreatePiece<Pawn>(3, 3, true);
LogAssert.Expect(LogType.Error, "[CheckDetector] Board o piece es null.");
Assert.IsTrue(_detector.WouldLeaveKingInCheck(null, pawn, new Vector2Int(3, 4)));
}
[Test]
public void IsInCheck_NullBoard_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[CheckDetector] Board es null.");
Assert.IsFalse(_detector.IsInCheck(null, true));
}
[Test]
public void WouldLeaveKingInCheck_NullPiece_ReturnsTrue()
{
var board = new Piece[8, 8];
LogAssert.Expect(LogType.Error, "[CheckDetector] Board o piece es null.");
Assert.IsTrue(_detector.WouldLeaveKingInCheck(board, null, new Vector2Int(3, 4)));
}
[Test]
public void IsInCheck_NoKingOnBoard_ReturnsFalse()
{
Assert.IsFalse(_detector.IsInCheck(_board, true));
}
[Test]
public void HasAnyLegalMove_NullBoard_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[CheckDetector] Board es null.");
Assert.IsFalse(_detector.HasAnyLegalMove(null, true));
}
[Test]
public void IsInCheck_WhiteKingNotAttacked_ReturnsFalse()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Rook>(0, 0, true);
// ==================== BASIC CHECK DETECTION ====================
Assert.IsFalse(_detector.IsInCheck(_board, true));
}
[Test]
public void IsInCheck_NoPieces_ReturnsFalse()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
[Test]
public void IsInCheck_WhiteKingAttackedByBlackRook_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Rook>(4, 5, false);
Assert.IsFalse(_detector.IsInCheck(board, true));
}
Assert.IsTrue(_detector.IsInCheck(_board, true));
}
[Test]
public void IsInCheck_RookAttackingKing_ReturnsTrue()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[4, 5] = CreatePiece<Rook>(4, 5, false);
[Test]
public void IsInCheck_WhiteKingAttackedByBlackBishop_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Bishop>(7, 3, false);
Assert.IsTrue(_detector.IsInCheck(board, true));
}
Assert.IsTrue(_detector.IsInCheck(_board, true));
}
[Test]
public void IsInCheck_BishopAttackingKing_ReturnsTrue()
{
var board = new Piece[8, 8];
board[4, 4] = CreatePiece<King>(4, 4, true);
board[1, 1] = CreatePiece<Bishop>(1, 1, false);
[Test]
public void IsInCheck_WhiteKingAttackedByBlackKnight_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Knight>(2, 1, false);
Assert.IsTrue(_detector.IsInCheck(board, true));
}
Assert.IsTrue(_detector.IsInCheck(_board, true));
}
[Test]
public void IsInCheck_KnightAttackingKing_ReturnsTrue()
{
var board = new Piece[8, 8];
board[4, 4] = CreatePiece<King>(4, 4, true);
board[2, 3] = CreatePiece<Knight>(2, 3, false);
[Test]
public void IsInCheck_WhiteKingAttackedByBlackPawn_ReturnsTrue()
{
CreatePiece<King>(4, 4, true);
CreatePiece<Pawn>(3, 5, false);
Assert.IsTrue(_detector.IsCheckmate(board, false) == false); // just sanity
Assert.IsTrue(_detector.IsInCheck(board, true));
}
Assert.IsTrue(_detector.IsInCheck(_board, true));
}
[Test]
public void IsInCheck_FriendlyPieceBlocking_ReturnsFalse()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[4, 3] = CreatePiece<Pawn>(4, 3, true); // blocks rook
board[4, 7] = CreatePiece<Rook>(4, 7, false);
[Test]
public void IsInCheck_BlackKingAttackedByWhiteRook_ReturnsTrue()
{
CreatePiece<King>(4, 7, false);
CreatePiece<Rook>(4, 3, true);
Assert.IsFalse(_detector.IsInCheck(board, true));
}
Assert.IsTrue(_detector.IsInCheck(_board, false));
}
// ==================== CHECKMATE ====================
[Test]
public void IsInCheck_BlackKingNotAttacked_ReturnsFalse()
{
CreatePiece<King>(4, 7, false);
CreatePiece<Rook>(0, 7, false);
[Test]
public void IsCheckmate_BackRankMate_ReturnsTrue()
{
var board = new Piece[8, 8];
board[0, 0] = CreatePiece<King>(0, 0, false);
board[0, 5] = CreatePiece<Rook>(0, 5, true);
board[2, 1] = CreatePiece<King>(2, 1, true);
Assert.IsFalse(_detector.IsInCheck(_board, false));
}
Assert.IsTrue(_detector.IsCheckmate(board, false));
}
[Test]
public void IsInCheck_PieceBlocksAttack_ReturnsFalse()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Rook>(4, 7, false);
CreatePiece<Pawn>(4, 3, true);
[Test]
public void IsCheckmate_KingCanEscape_ReturnsFalse()
{
var board = new Piece[8, 8];
board[0, 0] = CreatePiece<King>(0, 0, false);
board[0, 5] = CreatePiece<Rook>(0, 5, true);
Assert.IsFalse(_detector.IsInCheck(_board, true));
}
Assert.IsFalse(_detector.IsCheckmate(board, false));
}
#endregion
[Test]
public void IsCheckmate_NotInCheck_ReturnsFalse()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[4, 7] = CreatePiece<King>(4, 7, false);
#region IsCheckmate Tests
Assert.IsFalse(_detector.IsCheckmate(board, true));
}
[Test]
public void IsCheckmate_NotInCheck_ReturnsFalse()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Rook>(0, 0, true);
// ==================== HAS ANY LEGAL MOVE ====================
Assert.IsFalse(_detector.IsCheckmate(_board, true));
}
[Test]
public void HasAnyLegalMove_KingCanMove_ReturnsTrue()
{
var board = new Piece[8, 8];
board[4, 4] = CreatePiece<King>(4, 4, true);
[Test]
public void IsCheckmate_KingCanEscape_ReturnsFalse()
{
CreatePiece<King>(0, 0, true);
CreatePiece<Rook>(7, 7, false);
Assert.IsTrue(_detector.HasAnyLegalMove(board, true));
}
Assert.IsFalse(_detector.IsCheckmate(_board, true));
}
[Test]
public void HasAnyLegalMove_KingSurroundedByOwnPieces_ReturnsFalse()
{
var board = new Piece[8, 8];
board[4, 7] = CreatePiece<King>(4, 7, true);
// Surround with own pawns on the last rank — pawns can't move forward (off board)
// and forward squares are occupied, so no piece has legal moves.
board[3, 6] = CreatePiece<Pawn>(3, 6, true);
board[3, 7] = CreatePiece<Pawn>(3, 7, true);
board[4, 6] = CreatePiece<Pawn>(4, 6, true);
board[5, 6] = CreatePiece<Pawn>(5, 6, true);
board[5, 7] = CreatePiece<Pawn>(5, 7, true);
#endregion
Assert.IsFalse(_detector.HasAnyLegalMove(board, true));
}
#region WouldLeaveKingInCheck Tests
// ==================== HELPER ====================
[Test]
public void WouldLeaveKingInCheck_NullBoard_ReturnsTrue()
{
var king = CreatePiece<King>(4, 0, true);
LogAssert.Expect(LogType.Error, "[CheckDetector] Board o piece es null.");
Assert.IsTrue(_detector.WouldLeaveKingInCheck(null, king, new Vector2Int(4, 1)));
}
private T CreatePiece<T>(int x, int y, bool isWhite) where T : Piece
{
var obj = new GameObject($"Test_{typeof(T).Name}_{x}_{y}");
var piece = obj.AddComponent<T>();
piece.currentPos = new Vector2Int(x, y);
piece.isWhite = isWhite;
return piece;
[Test]
public void WouldLeaveKingInCheck_SafeMove_ReturnsFalse()
{
var king = CreatePiece<King>(4, 0, true);
CreatePiece<Rook>(4, 7, false);
Assert.IsFalse(_detector.WouldLeaveKingInCheck(_board, king, new Vector2Int(3, 0)));
}
[Test]
public void WouldLeaveKingInCheck_ExposesKingToCheck_ReturnsTrue()
{
var king = CreatePiece<King>(4, 3, true);
var blocker = CreatePiece<Pawn>(4, 5, true);
CreatePiece<Rook>(4, 7, false);
Assert.IsTrue(_detector.WouldLeaveKingInCheck(_board, blocker, new Vector2Int(3, 5)));
}
#endregion
#region HasAnyLegalMove Tests
[Test]
public void HasAnyLegalMove_EmptyBoard_ReturnsFalse()
{
Assert.IsFalse(_detector.HasAnyLegalMove(_board, true));
}
[Test]
public void HasAnyLegalMove_KingCanMove_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
Assert.IsTrue(_detector.HasAnyLegalMove(_board, true));
}
#endregion
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0b0cefc7579a0694b816724477d445b8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,538 @@
using NUnit.Framework;
using UnityEngine;
using System.Collections.Generic;
namespace AjedrezPurgatorio.Tests.Unit.Dialogue
{
/// <summary>
/// Unit tests para DialogueSystem - Sistema de branching condicional.
/// Cubre: piece_alive, pieces_lost_count, chapter_complete conditions.
/// </summary>
[TestFixture]
[Category("Unit")]
[Category("DialogueSystem")]
[Category("Branching")]
[Category("FastTests")]
public class DialogueSystemBranchingTests
{
private DialogueSystem _dialogueSystem;
private CampaignState _campaignState;
[SetUp]
public void Setup()
{
// Crear GameObject con DialogueSystem
var go = new GameObject("TestDialogueSystem");
_dialogueSystem = go.AddComponent<DialogueSystem>();
// Crear CampaignState
_campaignState = ScriptableObject.CreateInstance<CampaignState>();
// Inyectar CampaignState en DialogueSystem usando reflection
var field = typeof(DialogueSystem).GetField("_campaignState",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
if (field != null)
{
field.SetValue(_dialogueSystem, _campaignState);
}
// Inyectar un diálogo vacío: GetNextNode busca nodos en
// _currentDialogue.nodes y NREaría sin esto.
var currentDialogueField = typeof(DialogueSystem).GetField("_currentDialogue",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
if (currentDialogueField != null)
{
currentDialogueField.SetValue(_dialogueSystem, new DialogueData
{
id = "test_dialogue",
nodes = new List<DialogueNode>()
});
}
}
[TearDown]
public void Teardown()
{
if (_dialogueSystem != null)
{
Object.DestroyImmediate(_dialogueSystem.gameObject);
}
if (_campaignState != null)
{
Object.DestroyImmediate(_campaignState);
}
}
#region piece_alive Condition Tests
[Test]
public void CheckPieceAlive_WhenAlive_ReturnsTrue()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
// Act
bool result = InvokeCheckCondition("piece_alive", "elena");
// Assert
Assert.IsTrue(result, "CheckCondition debería retornar true para pieza viva");
}
[Test]
public void CheckPieceAlive_WhenDead_ReturnsFalse()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
_campaignState.RegisterPieceLost("elena");
// Act
bool result = InvokeCheckCondition("piece_alive", "elena");
// Assert
Assert.IsFalse(result, "CheckCondition debería retornar false para pieza muerta");
}
[Test]
[TestCase("Elena")]
[TestCase("ELENA")]
[TestCase("elena")]
[TestCase("ElEnA")]
public void CheckPieceAlive_CaseInsensitive(string pieceName)
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
// Act
bool result = InvokeCheckCondition("piece_alive", pieceName);
// Assert
Assert.IsTrue(result, $"CheckPieceAlive debería ser case-insensitive para '{pieceName}'");
}
[Test]
public void EvaluateBranching_PieceAlive_SelectsCorrectNode()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
var currentNode = new DialogueNode
{
id = "test_node",
conditions = new List<DialogueCondition>
{
new DialogueCondition
{
type = "piece_alive",
param = "elena",
nextNode = "elena_alive_node"
}
},
next = "default_node"
};
// Act
string nextNodeId = InvokeEvaluateBranching(currentNode);
// Assert
Assert.AreEqual("elena_alive_node", nextNodeId, "Debería seleccionar nodo de Elena viva");
}
#endregion
#region pieces_lost_count Condition Tests
[Test]
[TestCase("<3", 2, true)]
[TestCase("<3", 3, false)]
[TestCase("<3", 4, false)]
public void CheckPiecesLostCount_LessThan(string param, int piecesLost, bool expected)
{
// Arrange
SetupPiecesLost(piecesLost);
// Act
bool result = InvokeCheckCondition("pieces_lost_count", param);
// Assert
Assert.AreEqual(expected, result, $"pieces_lost_count {param} con {piecesLost} pérdidas debería ser {expected}");
}
[Test]
[TestCase("<=3", 2, true)]
[TestCase("<=3", 3, true)]
[TestCase("<=3", 4, false)]
public void CheckPiecesLostCount_LessOrEqual(string param, int piecesLost, bool expected)
{
// Arrange
SetupPiecesLost(piecesLost);
// Act
bool result = InvokeCheckCondition("pieces_lost_count", param);
// Assert
Assert.AreEqual(expected, result, $"pieces_lost_count {param} con {piecesLost} pérdidas debería ser {expected}");
}
[Test]
[TestCase(">3", 4, true)]
[TestCase(">3", 3, false)]
[TestCase(">3", 2, false)]
public void CheckPiecesLostCount_GreaterThan(string param, int piecesLost, bool expected)
{
// Arrange
SetupPiecesLost(piecesLost);
// Act
bool result = InvokeCheckCondition("pieces_lost_count", param);
// Assert
Assert.AreEqual(expected, result, $"pieces_lost_count {param} con {piecesLost} pérdidas debería ser {expected}");
}
[Test]
[TestCase(">=3", 2, false)]
[TestCase(">=3", 3, true)]
[TestCase(">=3", 4, true)]
public void CheckPiecesLostCount_GreaterOrEqual(string param, int piecesLost, bool expected)
{
// Arrange
SetupPiecesLost(piecesLost);
// Act
bool result = InvokeCheckCondition("pieces_lost_count", param);
// Assert
Assert.AreEqual(expected, result, $"pieces_lost_count {param} con {piecesLost} pérdidas debería ser {expected}");
}
[Test]
[TestCase("==3", 3, true)]
[TestCase("==3", 2, false)]
[TestCase("==3", 4, false)]
public void CheckPiecesLostCount_Equals(string param, int piecesLost, bool expected)
{
// Arrange
SetupPiecesLost(piecesLost);
// Act
bool result = InvokeCheckCondition("pieces_lost_count", param);
// Assert
Assert.AreEqual(expected, result, $"pieces_lost_count {param} con {piecesLost} pérdidas debería ser {expected}");
}
[Test]
public void EvaluateBranching_PiecesLostCount_SelectsCorrectNode()
{
// Arrange
SetupPiecesLost(2);
var currentNode = new DialogueNode
{
id = "test_node",
conditions = new List<DialogueCondition>
{
new DialogueCondition
{
type = "pieces_lost_count",
param = "<3",
nextNode = "few_losses_node"
}
},
next = "many_losses_node"
};
// Act
string nextNodeId = InvokeEvaluateBranching(currentNode);
// Assert
Assert.AreEqual("few_losses_node", nextNodeId, "Debería seleccionar nodo de pocas pérdidas");
}
#endregion
#region chapter_complete Condition Tests
[Test]
public void CheckChapterComplete_WhenComplete_ReturnsTrue()
{
// Arrange
_campaignState.CompleteChapter("ch1_factory", wasVictory: true);
// Act
bool result = InvokeCheckCondition("chapter_complete", "ch1_factory");
// Assert
Assert.IsTrue(result, "CheckCondition debería retornar true para capítulo completado");
}
[Test]
public void CheckChapterComplete_WhenNotComplete_ReturnsFalse()
{
// Act
bool result = InvokeCheckCondition("chapter_complete", "ch1_factory");
// Assert
Assert.IsFalse(result, "CheckCondition debería retornar false para capítulo no completado");
}
[Test]
public void CheckChapterComplete_Ch3Victory_ReturnsLastChapterWasVictory()
{
// Arrange
_campaignState.CompleteChapter("ch3_court", wasVictory: true);
// Act
bool result = InvokeCheckCondition("chapter_complete", "ch3");
// Assert
Assert.IsTrue(result, "chapter_complete('ch3') debería retornar LastChapterWasVictory cuando es victoria");
}
[Test]
public void CheckChapterComplete_Ch3Defeat_ReturnsFalse()
{
// Arrange
_campaignState.CompleteChapter("ch3_court", wasVictory: false);
// Act
bool result = InvokeCheckCondition("chapter_complete", "ch3");
// Assert
Assert.IsFalse(result, "chapter_complete('ch3') debería retornar false cuando es derrota");
}
[Test]
public void EvaluateBranching_ChapterComplete_SelectsCorrectNode()
{
// Arrange
_campaignState.CompleteChapter("ch3_court", wasVictory: true);
var currentNode = new DialogueNode
{
id = "test_node",
conditions = new List<DialogueCondition>
{
new DialogueCondition
{
type = "chapter_complete",
param = "ch3",
nextNode = "victory_node"
}
},
next = "defeat_node"
};
// Act
string nextNodeId = InvokeEvaluateBranching(currentNode);
// Assert
Assert.AreEqual("victory_node", nextNodeId, "Debería seleccionar nodo de victoria");
}
#endregion
#region Edge Cases Tests
[Test]
public void EvaluateBranching_NoConditions_ReturnsDirectNext()
{
// Arrange
var currentNode = new DialogueNode
{
id = "test_node",
conditions = null,
next = "next_node"
};
RegisterNode("next_node");
// Act: GetNextNode con condiciones null salta directo a 'next'
var nextNode = InvokeGetNextNode(currentNode);
// Assert
Assert.IsNotNull(nextNode, "Debería encontrar el nodo 'next_node' en el diálogo actual");
Assert.AreEqual("next_node", nextNode.id, "Sin condiciones debería retornar directamente 'next'");
}
[Test]
public void EvaluateBranching_EmptyConditions_ReturnsDirectNext()
{
// Arrange
var currentNode = new DialogueNode
{
id = "test_node",
conditions = new List<DialogueCondition>(),
next = "next_node"
};
RegisterNode("next_node");
// Act: GetNextNode con lista vacía salta directo a 'next'
var nextNode = InvokeGetNextNode(currentNode);
// Assert
Assert.IsNotNull(nextNode, "Debería encontrar el nodo 'next_node' en el diálogo actual");
Assert.AreEqual("next_node", nextNode.id, "Condiciones vacías debería retornar directamente 'next'");
}
[Test]
public void EvaluateBranching_MultipleConditions_FirstMatchWins()
{
// Arrange
_campaignState.RegisterPieceAlive("elena");
_campaignState.RegisterPieceAlive("ricardo");
var currentNode = new DialogueNode
{
id = "test_node",
conditions = new List<DialogueCondition>
{
new DialogueCondition
{
type = "piece_alive",
param = "elena",
nextNode = "elena_node"
},
new DialogueCondition
{
type = "piece_alive",
param = "ricardo",
nextNode = "ricardo_node"
}
},
next = "default_node"
};
// Act
string nextNodeId = InvokeEvaluateBranching(currentNode);
// Assert
Assert.AreEqual("elena_node", nextNodeId, "Primera condición que cumple debería ganar");
}
[Test]
public void EvaluateBranching_NoMatchingCondition_ReturnsDefault()
{
// Arrange (Elena NO está viva)
var currentNode = new DialogueNode
{
id = "test_node",
conditions = new List<DialogueCondition>
{
new DialogueCondition
{
type = "piece_alive",
param = "elena",
nextNode = "elena_node"
}
},
next = "default_node"
};
// Act
string nextNodeId = InvokeEvaluateBranching(currentNode);
// Assert
Assert.AreEqual("default_node", nextNodeId, "Sin condiciones que cumplan debería retornar default");
}
[Test]
public void CheckCondition_UnknownType_ReturnsDefault()
{
// Act
bool result = InvokeCheckCondition("unknown_type", "some_param");
// Assert
Assert.IsFalse(result, "Tipo de condición desconocido debería retornar false");
}
#endregion
#region Helper Methods
/// <summary>
/// Configura el CampaignState para tener X piezas perdidas.
/// </summary>
private void SetupPiecesLost(int count)
{
_campaignState.StartChapter("test_chapter");
for (int i = 0; i < count; i++)
{
_campaignState.RegisterPieceLost($"piece_{i}");
}
}
/// <summary>
/// Invoca el método privado CheckCondition usando reflection.
/// </summary>
private bool InvokeCheckCondition(string type, string param)
{
var method = typeof(DialogueSystem).GetMethod("CheckCondition",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
if (method == null)
{
Assert.Fail("Método CheckCondition no encontrado");
return false;
}
var condition = new DialogueCondition { type = type, param = param };
return (bool)method.Invoke(_dialogueSystem, new object[] { condition });
}
/// <summary>
/// Invoca el método privado GetNextNode usando reflection.
/// Devuelve el nodo resultante (firma real: DialogueNode, no string).
/// Requiere que el nodo destino exista en _currentDialogue.nodes.
/// </summary>
private DialogueNode InvokeGetNextNode(DialogueNode node)
{
var method = typeof(DialogueSystem).GetMethod("GetNextNode",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
if (method == null)
{
Assert.Fail("Método GetNextNode no encontrado");
return null;
}
return (DialogueNode)method.Invoke(_dialogueSystem, new object[] { node });
}
/// <summary>
/// Invoca el método privado EvaluateBranching usando reflection.
/// Devuelve el ID del siguiente nodo según las condiciones.
/// </summary>
private string InvokeEvaluateBranching(DialogueNode node)
{
var method = typeof(DialogueSystem).GetMethod("EvaluateBranching",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
if (method == null)
{
Assert.Fail("Método EvaluateBranching no encontrado");
return null;
}
return (string)method.Invoke(_dialogueSystem, new object[] { node });
}
/// <summary>
/// Registra un nodo en el diálogo actual para que GetNextNode lo encuentre.
/// </summary>
private void RegisterNode(string nodeId)
{
var field = typeof(DialogueSystem).GetField("_currentDialogue",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
var dialogue = (DialogueData)field.GetValue(_dialogueSystem);
dialogue.nodes.Add(new DialogueNode { id = nodeId });
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3b624fbead9a641438b1e181a3924821
@@ -0,0 +1,218 @@
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
namespace AjedrezPurgatorio.Tests.Unit.Dialogue
{
/// <summary>
/// Tests del typewriter effect (Suite 4 del plan de automatización).
///
/// Estrategia EditMode: la coroutine TypewriterEffect se invoca por reflexión y
/// se conduce manualmente con MoveNext(). Cada MoveNext ejecuta un carácter
/// (OnTextUpdated) y devuelve el WaitForSecondsRealtime sin necesidad de frames,
/// lo que hace los tests deterministas.
///
/// Nota: AddComponent en EditMode NO invoca Awake, así que la instancia es
/// aislada (sin singleton ni carga de Resources).
/// </summary>
[TestFixture]
[Category("Unit")]
[Category("Dialogue")]
[Category("Typewriter")]
[Category("FastTests")]
public class TypewriterTests
{
private GameObject _root;
private DialogueSystem _dialogue;
private MethodInfo _typewriterMethod;
private FieldInfo _isActiveField;
private FieldInfo _currentNodeField;
private FieldInfo _charsPerSecondField;
[SetUp]
public void SetUp()
{
_root = new GameObject("TypewriterTestRoot");
_dialogue = _root.AddComponent<DialogueSystem>();
const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance;
_typewriterMethod = typeof(DialogueSystem).GetMethod("TypewriterEffect", flags);
_isActiveField = typeof(DialogueSystem).GetField("_isActive", flags);
_currentNodeField = typeof(DialogueSystem).GetField("_currentNode", flags);
_charsPerSecondField = typeof(DialogueSystem).GetField("_charsPerSecond", flags);
Assert.IsNotNull(_typewriterMethod, "TypewriterEffect no encontrado (¿renombrado?).");
Assert.IsNotNull(_isActiveField, "_isActive no encontrado.");
Assert.IsNotNull(_currentNodeField, "_currentNode no encontrado.");
Assert.IsNotNull(_charsPerSecondField, "_charsPerSecond no encontrado.");
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(_root);
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private IEnumerator StartTypewriter(DialogueNode node)
{
return (IEnumerator)_typewriterMethod.Invoke(_dialogue, new object[] { node });
}
private class Capture
{
public List<string> Updates = new List<string>();
public List<DialogueNode> Completions = new List<DialogueNode>();
}
private Capture Subscribe()
{
var capture = new Capture();
_dialogue.OnTextUpdated += t => capture.Updates.Add(t);
_dialogue.OnNodeComplete += n => capture.Completions.Add(n);
return capture;
}
// ------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------
[Test]
public void Typewriter_RevealsGrowingPrefixes_InOrder()
{
var node = new DialogueNode { id = "n1", speaker = "narrator", text = "Hola purgatorio" };
Capture cap = Subscribe();
IEnumerator e = StartTypewriter(node);
while (e.MoveNext()) { }
Assert.AreEqual(node.text.Length + 1, cap.Updates.Count,
"Debe emitir un update por carácter incluyendo el texto vacío inicial.");
for (int i = 0; i < cap.Updates.Count; i++)
Assert.AreEqual(node.text.Substring(0, i), cap.Updates[i],
$"Update #{i} debe ser el prefijo de longitud {i}.");
}
[Test]
public void Typewriter_FiresNodeComplete_ExactlyOnce_AtEnd()
{
var node = new DialogueNode { id = "n1", speaker = "muerte", text = "Otra partida" };
Capture cap = Subscribe();
IEnumerator e = StartTypewriter(node);
Assert.AreEqual(0, cap.Completions.Count, "No debe completar antes de empezar.");
while (e.MoveNext()) { }
Assert.AreEqual(1, cap.Completions.Count);
Assert.AreEqual(node, cap.Completions[0]);
}
[Test]
public void Typewriter_IsTyping_TrueWhileRevealing_FalseAfterEnd()
{
var node = new DialogueNode { id = "n1", speaker = "narrator", text = "abc" };
Subscribe();
IEnumerator e = StartTypewriter(node);
Assert.IsFalse(_dialogue.IsTyping, "Antes del primer paso no está escribiendo.");
e.MoveNext();
Assert.IsTrue(_dialogue.IsTyping, "Tras revelar el primer carácter sigue escribiendo.");
while (e.MoveNext()) { }
Assert.IsFalse(_dialogue.IsTyping, "Al terminar debe apagar el flag.");
}
[Test]
public void Typewriter_EmptyText_CompletesImmediately_WithSingleEmptyUpdate()
{
var node = new DialogueNode { id = "n1", speaker = "narrator", text = "" };
Capture cap = Subscribe();
IEnumerator e = StartTypewriter(node);
// Con texto vacío el bucle no hace ningún yield: la primera pasada
// ejecuta todo el cuerpo y la coroutine termina (MoveNext == false).
Assert.IsFalse(e.MoveNext(), "Texto vacío completa en la primera pasada sin yields.");
Assert.AreEqual(1, cap.Updates.Count);
Assert.AreEqual("", cap.Updates[0]);
Assert.AreEqual(1, cap.Completions.Count);
}
[Test]
public void CompleteCurrentNode_MidTyping_ShowsFullText_AndFiresSingleCompletion()
{
var node = new DialogueNode { id = "n1", speaker = "muerte", text = "Texto largo de prueba" };
Capture cap = Subscribe();
_isActiveField.SetValue(_dialogue, true);
_currentNodeField.SetValue(_dialogue, node);
IEnumerator e = StartTypewriter(node);
e.MoveNext(); // primer carácter
Assert.IsTrue(_dialogue.IsTyping);
_dialogue.CompleteCurrentNode();
Assert.IsFalse(_dialogue.IsTyping, "Skip debe apagar IsTyping.");
Assert.AreEqual(1, cap.Completions.Count, "El skip dispara exactamente una completion.");
Assert.AreEqual(node.text, cap.Updates[cap.Updates.Count - 1],
"El último update tras el skip es el texto completo.");
}
[Test]
public void CompleteCurrentNode_NotTyping_IsNoOp()
{
var node = new DialogueNode { id = "n1", speaker = "narrator", text = "sin typing" };
Capture cap = Subscribe();
_isActiveField.SetValue(_dialogue, true);
_currentNodeField.SetValue(_dialogue, node);
// _isTyping permanece false: no arrancamos la coroutine.
_dialogue.CompleteCurrentNode();
Assert.AreEqual(0, cap.Completions.Count, "Sin typing no hay completion.");
Assert.AreEqual(0, cap.Updates.Count, "Sin typing no hay updates.");
}
[Test]
public void SetTextSpeed_ClampsToConfiguredRange()
{
_dialogue.SetTextSpeed(5f);
Assert.AreEqual(10f, (float)_charsPerSecondField.GetValue(_dialogue),
"Velocidad mínima: 10 cps.");
_dialogue.SetTextSpeed(500f);
Assert.AreEqual(100f, (float)_charsPerSecondField.GetValue(_dialogue),
"Velocidad máxima: 100 cps.");
_dialogue.SetTextSpeed(45f);
Assert.AreEqual(45f, (float)_charsPerSecondField.GetValue(_dialogue),
"Valores dentro de rango pasan intactos.");
}
[Test]
public void Typewriter_YieldsRealtimeWait_BetweenCharacters()
{
var node = new DialogueNode { id = "n1", speaker = "narrator", text = "ab" };
Subscribe();
IEnumerator e = StartTypewriter(node);
Assert.IsTrue(e.MoveNext(), "Primer update inmediato.");
Assert.IsInstanceOf<WaitForSecondsRealtime>(e.Current,
"Entre caracteres debe esperar tiempo real (inmune a timeScale=0).");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: de634a17bf1150a4fb002c69d1be7cc6
+294 -223
View File
@@ -1,245 +1,316 @@
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
/// <summary>
/// Unit tests for DrawDetector — position hashing, halfmove clock, insufficient material, reset.
/// These tests do NOT depend on piece movement logic (GetAvailableMoves) so they are pure.
/// </summary>
public class DrawDetectorTests
namespace AjedrezPurgatorio.Tests.Unit.Chess
{
private DrawDetector _detector;
[SetUp]
public void SetUp()
[TestFixture]
[Category("Unit")]
[Category("Chess")]
[Category("DrawDetector")]
[Category("FastTests")]
public class DrawDetectorTests
{
_detector = new DrawDetector();
}
private DrawDetector _detector;
private CheckDetector _checkDetector;
private Piece[,] _board;
private GameObject _root;
[TearDown]
public void TearDown()
{
_detector = null;
}
// ==================== HALFMOVE CLOCK ====================
[Test]
public void RecordMove_NonPawnNonCapture_IncrementsHalfmoveClock()
{
var board = new Piece[8, 8];
var king = CreatePiece<King>(4, 4, true);
board[4, 4] = king;
_detector.RecordMove(board, true, king, wasCapture: false);
Assert.AreEqual(1, _detector.HalfmoveClock);
}
[Test]
public void RecordMove_PawnMove_ResetsHalfmoveClock()
{
var board = new Piece[8, 8];
var pawn = CreatePiece<Pawn>(3, 1, true);
board[3, 1] = pawn;
_detector.RecordMove(board, true, pawn, wasCapture: false);
Assert.AreEqual(0, _detector.HalfmoveClock);
}
[Test]
public void RecordMove_Capture_ResetsHalfmoveClock()
{
var board = new Piece[8, 8];
var rook = CreatePiece<Rook>(0, 0, true);
board[0, 0] = rook;
_detector.RecordMove(board, true, rook, wasCapture: true);
Assert.AreEqual(0, _detector.HalfmoveClock);
}
[Test]
public void IsFiftyMoveRule_After100HalfMoves_ReturnsTrue()
{
var board = new Piece[8, 8];
var king = CreatePiece<King>(4, 4, true);
board[4, 4] = king;
// Record 100 non-pawn, non-capture moves
for (int i = 0; i < 100; i++)
[SetUp]
public void Setup()
{
_detector.RecordMove(board, i % 2 == 0, king, wasCapture: false);
_detector = new DrawDetector();
_checkDetector = new CheckDetector();
_board = new Piece[8, 8];
_root = new GameObject("TestRoot");
}
Assert.IsTrue(_detector.IsFiftyMoveRule());
}
[Test]
public void IsFiftyMoveRule_Before100HalfMoves_ReturnsFalse()
{
var board = new Piece[8, 8];
var king = CreatePiece<King>(4, 4, true);
board[4, 4] = king;
for (int i = 0; i < 99; i++)
[TearDown]
public void Teardown()
{
_detector.RecordMove(board, i % 2 == 0, king, wasCapture: false);
Object.DestroyImmediate(_root);
}
Assert.IsFalse(_detector.IsFiftyMoveRule());
}
// ==================== POSITION HISTORY ====================
[Test]
public void IsThreefoldRepetition_LessThan3Positions_ReturnsFalse()
{
var board = new Piece[8, 8];
var king = CreatePiece<King>(4, 4, true);
board[4, 4] = king;
_detector.RecordMove(board, true, king, false);
_detector.RecordMove(board, false, king, false);
Assert.IsFalse(_detector.IsThreefoldRepetition());
}
[Test]
public void PositionHistoryCount_IncreasesWithEachRecord()
{
var board = new Piece[8, 8];
var king = CreatePiece<King>(4, 4, true);
board[4, 4] = king;
_detector.RecordMove(board, true, king, false);
Assert.AreEqual(1, _detector.PositionHistoryCount);
_detector.RecordMove(board, false, king, false);
Assert.AreEqual(2, _detector.PositionHistoryCount);
}
// ==================== INSUFFICIENT MATERIAL ====================
[Test]
public void IsInsufficientMaterial_KingVsKing_ReturnsTrue()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[4, 7] = CreatePiece<King>(4, 7, false);
Assert.IsTrue(_detector.IsInsufficientMaterial(board));
}
[Test]
public void IsInsufficientMaterial_KingBishopVsKing_ReturnsTrue()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[3, 2] = CreatePiece<Bishop>(3, 2, true);
board[4, 7] = CreatePiece<King>(4, 7, false);
Assert.IsTrue(_detector.IsInsufficientMaterial(board));
}
[Test]
public void IsInsufficientMaterial_KingKnightVsKing_ReturnsTrue()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[2, 1] = CreatePiece<Knight>(2, 1, true);
board[4, 7] = CreatePiece<King>(4, 7, false);
Assert.IsTrue(_detector.IsInsufficientMaterial(board));
}
[Test]
public void IsInsufficientMaterial_KingRookVsKing_ReturnsFalse()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[0, 0] = CreatePiece<Rook>(0, 0, true);
board[4, 7] = CreatePiece<King>(4, 7, false);
Assert.IsFalse(_detector.IsInsufficientMaterial(board));
}
[Test]
public void IsInsufficientMaterial_KingPawnVsKing_ReturnsFalse()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[3, 1] = CreatePiece<Pawn>(3, 1, true);
board[4, 7] = CreatePiece<King>(4, 7, false);
Assert.IsFalse(_detector.IsInsufficientMaterial(board));
}
[Test]
public void IsInsufficientMaterial_KingBishopVsKingBishop_SameColorSquare_ReturnsTrue()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[2, 2] = CreatePiece<Bishop>(2, 2, true); // light square (2+2=4, even)
board[4, 7] = CreatePiece<King>(4, 7, false);
board[5, 5] = CreatePiece<Bishop>(5, 5, false); // light square (5+5=10, even)
Assert.IsTrue(_detector.IsInsufficientMaterial(board));
}
[Test]
public void IsInsufficientMaterial_KingBishopVsKingBishop_DifferentColorSquare_ReturnsFalse()
{
var board = new Piece[8, 8];
board[4, 0] = CreatePiece<King>(4, 0, true);
board[2, 2] = CreatePiece<Bishop>(2, 2, true); // light square (2+2=4, even)
board[4, 7] = CreatePiece<King>(4, 7, false);
board[5, 4] = CreatePiece<Bishop>(5, 4, false); // dark square (5+4=9, odd)
Assert.IsFalse(_detector.IsInsufficientMaterial(board));
}
[Test]
public void IsInsufficientMaterial_NullBoard_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[DrawDetector] Board es null.");
Assert.IsFalse(_detector.IsInsufficientMaterial(null));
}
// ==================== RESET ====================
[Test]
public void Reset_ClearsAllState()
{
var board = new Piece[8, 8];
var king = CreatePiece<King>(4, 4, true);
board[4, 4] = king;
// Build up state
for (int i = 0; i < 50; i++)
private T CreatePiece<T>(int x, int y, bool isWhite) where T : Piece
{
_detector.RecordMove(board, i % 2 == 0, king, false);
var go = new GameObject($"Piece_{typeof(T).Name}_{x}_{y}");
go.transform.SetParent(_root.transform);
var piece = go.AddComponent<T>();
piece.currentPos = new Vector2Int(x, y);
piece.isWhite = isWhite;
piece.hasMoved = true;
_board[x, y] = piece;
return piece;
}
Assert.AreEqual(50, _detector.PositionHistoryCount);
Assert.AreEqual(50, _detector.HalfmoveClock);
#region IsInsufficientMaterial Tests
_detector.Reset();
[Test]
public void IsInsufficientMaterial_KingVsKing_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
CreatePiece<King>(4, 7, false);
Assert.AreEqual(0, _detector.PositionHistoryCount);
Assert.AreEqual(0, _detector.HalfmoveClock);
}
Assert.IsTrue(_detector.IsInsufficientMaterial(_board));
}
// ==================== HELPER ====================
[Test]
public void IsInsufficientMaterial_KingBishopVsKing_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Bishop>(2, 0, true);
CreatePiece<King>(4, 7, false);
private T CreatePiece<T>(int x, int y, bool isWhite) where T : Piece
{
var obj = new GameObject($"Test_{typeof(T).Name}_{x}_{y}");
var piece = obj.AddComponent<T>();
piece.currentPos = new Vector2Int(x, y);
piece.isWhite = isWhite;
return piece;
Assert.IsTrue(_detector.IsInsufficientMaterial(_board));
}
[Test]
public void IsInsufficientMaterial_KingKnightVsKing_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Knight>(1, 0, true);
CreatePiece<King>(4, 7, false);
Assert.IsTrue(_detector.IsInsufficientMaterial(_board));
}
[Test]
public void IsInsufficientMaterial_KingBishopVsKingBishop_SameSquareColor_ReturnsTrue()
{
CreatePiece<King>(0, 0, true);
CreatePiece<Bishop>(2, 2, true);
CreatePiece<King>(7, 7, false);
CreatePiece<Bishop>(5, 5, false);
Assert.IsTrue(_detector.IsInsufficientMaterial(_board));
}
[Test]
public void IsInsufficientMaterial_KingBishopVsKingBishop_DifferentSquareColors_ReturnsFalse()
{
CreatePiece<King>(0, 0, true);
CreatePiece<Bishop>(2, 2, true);
CreatePiece<King>(7, 7, false);
CreatePiece<Bishop>(4, 5, false);
Assert.IsFalse(_detector.IsInsufficientMaterial(_board));
}
[Test]
public void IsInsufficientMaterial_KingRookVsKing_ReturnsFalse()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Rook>(0, 0, true);
CreatePiece<King>(4, 7, false);
Assert.IsFalse(_detector.IsInsufficientMaterial(_board));
}
[Test]
public void IsInsufficientMaterial_KingQueenVsKing_ReturnsFalse()
{
CreatePiece<King>(4, 0, true);
CreatePiece<Queen>(3, 0, true);
CreatePiece<King>(4, 7, false);
Assert.IsFalse(_detector.IsInsufficientMaterial(_board));
}
[Test]
public void IsInsufficientMaterial_NullBoard_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[DrawDetector] Board es null.");
Assert.IsFalse(_detector.IsInsufficientMaterial(null));
}
#endregion
#region IsStalemate Tests
[Test]
public void IsStalemate_NullBoard_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[DrawDetector] Board o checkDetector es null.");
Assert.IsFalse(_detector.IsStalemate(null, true, _checkDetector));
}
[Test]
public void IsStalemate_NullCheckDetector_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[DrawDetector] Board o checkDetector es null.");
Assert.IsFalse(_detector.IsStalemate(_board, true, null));
}
#endregion
#region Fifty Move Rule Tests
[Test]
public void IsFiftyMoveRule_Initially_ReturnsFalse()
{
Assert.IsFalse(_detector.IsFiftyMoveRule());
}
[Test]
public void IsFiftyMoveRule_At99HalfMoves_ReturnsFalse()
{
for (int i = 0; i < 99; i++)
{
_detector.RecordMove(_board, i % 2 == 0, null, false);
}
Assert.IsFalse(_detector.IsFiftyMoveRule());
}
[Test]
public void IsFiftyMoveRule_At100HalfMoves_ReturnsTrue()
{
for (int i = 0; i < 100; i++)
{
_detector.RecordMove(_board, i % 2 == 0, null, false);
}
Assert.IsTrue(_detector.IsFiftyMoveRule());
}
[Test]
public void IsFiftyMoveRule_CaptureResetsClock()
{
for (int i = 0; i < 80; i++)
{
_detector.RecordMove(_board, i % 2 == 0, null, false);
}
var go = new GameObject();
var pawn = go.AddComponent<Pawn>();
pawn.isWhite = true;
_detector.RecordMove(_board, true, pawn, true);
Assert.Less(_detector.HalfmoveClock, 100);
Assert.IsFalse(_detector.IsFiftyMoveRule());
}
[Test]
public void IsFiftyMoveRule_PawnMoveResetsClock()
{
for (int i = 0; i < 80; i++)
{
_detector.RecordMove(_board, i % 2 == 0, null, false);
}
var go = new GameObject();
var pawn = go.AddComponent<Pawn>();
pawn.isWhite = true;
_detector.RecordMove(_board, true, pawn, false);
Assert.AreEqual(0, _detector.HalfmoveClock);
Assert.IsFalse(_detector.IsFiftyMoveRule());
}
#endregion
#region Threefold Repetition Tests
[Test]
public void IsThreefoldRepetition_Initially_ReturnsFalse()
{
Assert.IsFalse(_detector.IsThreefoldRepetition());
}
[Test]
public void IsThreefoldRepetition_SamePositionThreeTimes_ReturnsTrue()
{
var king = CreatePiece<King>(4, 0, true);
var bk = CreatePiece<King>(4, 7, false);
// Mismo turno en las tres repeticiones: el hash incluye el turno,
// así que alternar blancas/negras serían posiciones distintas.
for (int i = 0; i < 3; i++)
{
_detector.RecordMove(_board, true, king, false);
}
Assert.IsTrue(_detector.IsThreefoldRepetition());
}
[Test]
public void IsThreefoldRepetition_DifferentPositions_ReturnsFalse()
{
var king = CreatePiece<King>(4, 0, true);
var bk = CreatePiece<King>(4, 7, false);
_detector.RecordMove(_board, true, king, false);
king.currentPos = new Vector2Int(4, 1);
_board[4, 0] = null;
_board[4, 1] = king;
_detector.RecordMove(_board, false, bk, false);
king.currentPos = new Vector2Int(4, 2);
_board[4, 1] = null;
_board[4, 2] = king;
_detector.RecordMove(_board, true, king, false);
Assert.IsFalse(_detector.IsThreefoldRepetition());
}
#endregion
#region Reset Tests
[Test]
public void Reset_ClearsAllState()
{
for (int i = 0; i < 50; i++)
{
_detector.RecordMove(_board, i % 2 == 0, null, false);
}
_detector.Reset();
Assert.AreEqual(0, _detector.HalfmoveClock);
Assert.AreEqual(0, _detector.PositionHistoryCount);
Assert.IsFalse(_detector.IsFiftyMoveRule());
Assert.IsFalse(_detector.IsThreefoldRepetition());
}
#endregion
#region Position Hash Stability Tests
[Test]
public void PositionHash_SameBoardSameTurn_SameHash()
{
var king = CreatePiece<King>(4, 0, true);
var bk = CreatePiece<King>(4, 7, false);
_detector.RecordMove(_board, true, king, false);
int countAfterFirst = _detector.PositionHistoryCount;
_detector.RecordMove(_board, true, king, false);
int countAfterSecond = _detector.PositionHistoryCount;
Assert.AreEqual(2, countAfterSecond);
_detector.RecordMove(_board, true, king, false);
Assert.AreEqual(3, _detector.PositionHistoryCount);
Assert.IsTrue(_detector.IsThreefoldRepetition());
}
[Test]
public void PositionHash_DifferentTurn_DifferentHash()
{
var king = CreatePiece<King>(4, 0, true);
var bk = CreatePiece<King>(4, 7, false);
_detector.RecordMove(_board, true, king, false);
_detector.RecordMove(_board, false, bk, false);
_detector.RecordMove(_board, true, king, false);
Assert.IsFalse(_detector.IsThreefoldRepetition());
}
#endregion
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 41c1d4e4ee64c524bb96876d2947fdd5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,357 @@
using NUnit.Framework;
using UnityEngine;
using AjedrezPurgatorio.Meta;
using AjedrezPurgatorio.Data;
using System.IO;
using System.Collections.Generic;
namespace AjedrezPurgatorio.Tests.Unit.Meta
{
/// <summary>
/// Unit tests for the DeadKingPool system.
/// Tests: AddDeadKing, GetRandomDeadKing, ShouldSpawnDeadKing,
/// FIFO rotation, content filtering, persistence.
/// </summary>
[TestFixture]
public class DeadKingPoolTests
{
private DeadKingPool _pool;
private string _testFilePath;
[SetUp]
public void Setup()
{
// Create a test instance of DeadKingPool
_pool = ScriptableObject.CreateInstance<DeadKingPool>();
// Mock persistent file path for testing
_testFilePath = Path.Combine(Application.temporaryCachePath, "test_dead_kings.json");
// Clear any existing test file
if (File.Exists(_testFilePath))
File.Delete(_testFilePath);
// Redirigir la ruta persistente al archivo de prueba: sin esto el pool
// cargaría el dead_kings.json real del jugador y los tests no serían
// deterministas.
SetPersistentPath(_pool, _testFilePath);
// Initialize the pool (will load from empty state)
_pool.Reinitialize();
}
[TearDown]
public void Teardown()
{
// Clean up test file
if (File.Exists(_testFilePath))
File.Delete(_testFilePath);
// Destroy test pool instance
if (_pool != null)
Object.DestroyImmediate(_pool);
}
private static void SetPersistentPath(DeadKingPool pool, string path)
{
var field = typeof(DeadKingPool).GetField("_persistentFilePath",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.IsNotNull(field, "_persistentFilePath no encontrado en DeadKingPool.");
field.SetValue(pool, path);
}
#region AddDeadKing Tests
[Test]
public void AddDeadKing_AddsToPool()
{
// Arrange
var testDK = CreateTestDeadKing("TestPlayer", "Test message", 1, 3);
// Act
_pool.AddDeadKing(testDK);
// Assert
Assert.AreEqual(1, _pool.Count, "Pool should contain 1 Dead King after adding");
}
[Test]
public void AddDeadKing_WithNullData_LogsErrorAndDoesNotAdd()
{
// Assert (log esperado antes del Act)
UnityEngine.TestTools.LogAssert.Expect(LogType.Error, "[DeadKingPool] Cannot add null Dead King data.");
// Act
_pool.AddDeadKing(null);
// Assert
Assert.AreEqual(0, _pool.Count, "Pool should remain empty when adding null data");
}
[Test]
public void AddDeadKing_WithEmptyName_UsesAnonimo()
{
// Arrange
var testDK = CreateTestDeadKing("", "Test message", 1, 3);
// Act
_pool.AddDeadKing(testDK);
var retrieved = _pool.GetRandomDeadKing();
// Assert
Assert.AreEqual("Anónimo", retrieved.playerName, "Empty name should default to 'Anónimo'");
}
[Test]
public void AddDeadKing_FiltersProfanity_InName()
{
// Arrange
var testDK = CreateTestDeadKing("TestFuckPlayer", "Clean message", 1, 3);
// Act
_pool.AddDeadKing(testDK);
var retrieved = _pool.GetRandomDeadKing();
// Assert
Assert.IsTrue(retrieved.playerName.Contains("***"), "Profanity in name should be filtered");
Assert.IsFalse(retrieved.playerName.Contains("Fuck"), "Original profanity should not appear");
}
[Test]
public void AddDeadKing_FiltersProfanity_InMessage()
{
// Arrange
var testDK = CreateTestDeadKing("TestPlayer", "This shit is hard", 1, 3);
// Act
_pool.AddDeadKing(testDK);
var retrieved = _pool.GetRandomDeadKing();
// Assert
Assert.IsTrue(retrieved.message.Contains("***"), "Profanity in message should be filtered");
Assert.IsFalse(retrieved.message.Contains("shit"), "Original profanity should not appear");
}
#endregion
#region FIFO Rotation Tests
[Test]
public void AddDeadKing_WhenPoolFull_RemovesOldest()
{
// Arrange: Fill pool to max capacity (assuming max is 100)
// For testing, we'll use reflection to set a smaller max size or add many DKs
// Simplified: Add 101 DKs and verify the first one is removed
// Add 100 DKs
for (int i = 0; i < 100; i++)
{
var dk = CreateTestDeadKing($"Player{i}", $"Message {i}", 0, i);
_pool.AddDeadKing(dk);
}
Assert.AreEqual(100, _pool.Count, "Pool should be at max capacity (100)");
// Act: Add one more (should trigger FIFO removal)
var newDK = CreateTestDeadKing("NewPlayer", "New message", 2, 5);
_pool.AddDeadKing(newDK);
// Assert
Assert.AreEqual(100, _pool.Count, "Pool should still be at max capacity after FIFO");
// Verify the new DK is in the pool
var allDKs = _pool.GetAllDeadKings();
bool containsNew = allDKs.Exists(dk => dk.playerName == "NewPlayer");
Assert.IsTrue(containsNew, "Newly added DK should be in the pool");
}
#endregion
#region GetRandomDeadKing Tests
[Test]
public void GetRandomDeadKing_WhenPoolEmpty_ReturnsNull()
{
// Act
var result = _pool.GetRandomDeadKing();
// Assert
Assert.IsNull(result, "Should return null when pool is empty");
}
[Test]
public void GetRandomDeadKing_WhenPoolHasOne_ReturnsThatOne()
{
// Arrange
var testDK = CreateTestDeadKing("OnlyPlayer", "Only message", 1, 3);
_pool.AddDeadKing(testDK);
// Act
var result = _pool.GetRandomDeadKing();
// Assert
Assert.IsNotNull(result, "Should return a Dead King");
Assert.AreEqual("OnlyPlayer", result.playerName, "Should return the only Dead King in pool");
}
[Test]
public void GetRandomDeadKing_WhenPoolHasMultiple_ReturnsRandomly()
{
// Arrange: Add 10 different DKs
for (int i = 0; i < 10; i++)
{
var dk = CreateTestDeadKing($"Player{i}", $"Message {i}", 0, i);
_pool.AddDeadKing(dk);
}
// Act: Get multiple random DKs and verify we get different ones
var results = new HashSet<string>();
for (int i = 0; i < 20; i++)
{
var dk = _pool.GetRandomDeadKing();
results.Add(dk.playerName);
}
// Assert: We should have gotten more than 1 unique DK (randomness)
Assert.Greater(results.Count, 1, "Should return different Dead Kings over multiple calls");
}
#endregion
#region ShouldSpawnDeadKing Tests
[Test]
public void ShouldSpawnDeadKing_WhenPoolEmpty_ReturnsFalse()
{
// Act
bool shouldSpawn = _pool.ShouldSpawnDeadKing(0);
// Assert
Assert.IsFalse(shouldSpawn, "Should not spawn when pool is empty");
}
[Test]
public void ShouldSpawnDeadKing_Chapter0_HasBaseChance()
{
// Arrange: Add one DK
_pool.AddDeadKing(CreateTestDeadKing("Test", "Test", 0, 0));
// Act: Run spawn check many times to verify probability
int spawnCount = 0;
int iterations = 1000;
for (int i = 0; i < iterations; i++)
{
if (_pool.ShouldSpawnDeadKing(0))
spawnCount++;
}
// Assert: Should be around 20% (0.2 base chance)
float spawnRate = (float)spawnCount / iterations;
Assert.Greater(spawnRate, 0.10f, "Spawn rate should be greater than 10%");
Assert.Less(spawnRate, 0.30f, "Spawn rate should be less than 30%");
// Expected: ~20% with some variance
}
[Test]
public void ShouldSpawnDeadKing_Chapter1_HasHigherChance()
{
// Arrange
_pool.AddDeadKing(CreateTestDeadKing("Test", "Test", 0, 0));
// Act
int spawnCount = 0;
int iterations = 1000;
for (int i = 0; i < iterations; i++)
{
if (_pool.ShouldSpawnDeadKing(1))
spawnCount++;
}
// Assert: Should be around 40% (0.2 base + 0.2 * 1)
float spawnRate = (float)spawnCount / iterations;
Assert.Greater(spawnRate, 0.30f, "Ch1 spawn rate should be higher than base");
Assert.Less(spawnRate, 0.50f, "Ch1 spawn rate should be less than 50%");
}
[Test]
public void ShouldSpawnDeadKing_Chapter2_HasEvenHigherChance()
{
// Arrange
_pool.AddDeadKing(CreateTestDeadKing("Test", "Test", 0, 0));
// Act
int spawnCount = 0;
int iterations = 1000;
for (int i = 0; i < iterations; i++)
{
if (_pool.ShouldSpawnDeadKing(2))
spawnCount++;
}
// Assert: Should be around 60% (0.2 base + 0.2 * 2)
float spawnRate = (float)spawnCount / iterations;
Assert.Greater(spawnRate, 0.50f, "Ch2 spawn rate should be ~60%");
Assert.Less(spawnRate, 0.70f, "Ch2 spawn rate should be less than 70%");
}
#endregion
#region Persistence Tests
[Test]
public void SaveAndLoad_Roundtrip_PreservesData()
{
// Arrange: Add multiple DKs
_pool.AddDeadKing(CreateTestDeadKing("Player1", "Message1", 1, 3));
_pool.AddDeadKing(CreateTestDeadKing("Player2", "Message2", 2, 5));
// Act: Save
_pool.SavePool();
// Create new pool instance and load from the SAME redirected test file
var newPool = ScriptableObject.CreateInstance<DeadKingPool>();
SetPersistentPath(newPool, _testFilePath);
newPool.Reinitialize();
newPool.LoadPool();
// Assert: Data should match
Assert.AreEqual(_pool.Count, newPool.Count, "Loaded pool should have same count");
var originalDKs = _pool.GetAllDeadKings();
var loadedDKs = newPool.GetAllDeadKings();
for (int i = 0; i < originalDKs.Count; i++)
{
Assert.AreEqual(originalDKs[i].playerName, loadedDKs[i].playerName, $"DK {i} name should match");
Assert.AreEqual(originalDKs[i].message, loadedDKs[i].message, $"DK {i} message should match");
Assert.AreEqual(originalDKs[i].chapterReached, loadedDKs[i].chapterReached, $"DK {i} chapter should match");
}
// Cleanup
Object.DestroyImmediate(newPool);
}
#endregion
#region Helper Methods
/// <summary>
/// Creates a test Dead King with specified data.
/// </summary>
private DeadKingData CreateTestDeadKing(string name, string message, int chapter, int piecesLost)
{
return new DeadKingData(
name,
message,
chapter,
piecesLost,
new CampaignStats(50, 10, 2, 1)
);
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0d50be294579d1f46bc7df4b28502e24
@@ -0,0 +1,213 @@
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using System.IO;
using AjedrezPurgatorio.Meta;
namespace AjedrezPurgatorio.Tests.Unit.Meta
{
[TestFixture]
[Category("Unit")]
[Category("Meta")]
[Category("SaveSystem")]
[Category("FastTests")]
public class SaveSystemTests
{
private GameObject _root;
private SaveSystem _saveSystem;
private CampaignState _campaignState;
private string _testSaveDir;
[SetUp]
public void Setup()
{
_root = new GameObject("SaveSystemTestRoot");
_saveSystem = _root.AddComponent<SaveSystem>();
_campaignState = ScriptableObject.CreateInstance<CampaignState>();
_testSaveDir = Path.Combine(Application.temporaryCachePath, "save_tests");
Directory.CreateDirectory(_testSaveDir);
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(_campaignState);
Object.DestroyImmediate(_root);
if (Directory.Exists(_testSaveDir))
{
Directory.Delete(_testSaveDir, true);
}
}
private static void ExpectInvalidSlotLog(int slot)
{
LogAssert.Expect(LogType.Error, $"[SaveSystem] Invalid save slot: {slot}. Must be between 0 and 2.");
}
#region SaveGame Tests
[Test]
public void SaveGame_NullCampaignState_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[SaveSystem] Cannot save with null campaign state.");
Assert.IsFalse(_saveSystem.SaveGame(0, null, 0));
}
[Test]
public void SaveGame_InvalidSlot_ReturnsFalse()
{
ExpectInvalidSlotLog(-1);
Assert.IsFalse(_saveSystem.SaveGame(-1, _campaignState, 0));
ExpectInvalidSlotLog(10);
Assert.IsFalse(_saveSystem.SaveGame(10, _campaignState, 0));
}
#endregion
#region LoadGame Tests
[Test]
public void LoadGame_InvalidSlot_ReturnsNull()
{
ExpectInvalidSlotLog(-1);
Assert.IsNull(_saveSystem.LoadGame(-1));
ExpectInvalidSlotLog(100);
Assert.IsNull(_saveSystem.LoadGame(100));
}
#endregion
#region DoesSaveExist Tests
[Test]
public void DoesSaveExist_InvalidSlot_ReturnsFalse()
{
ExpectInvalidSlotLog(-1);
Assert.IsFalse(_saveSystem.DoesSaveExist(-1));
ExpectInvalidSlotLog(5);
Assert.IsFalse(_saveSystem.DoesSaveExist(5));
}
#endregion
#region DeleteSave Tests
[Test]
public void DeleteSave_InvalidSlot_ReturnsFalse()
{
ExpectInvalidSlotLog(-1);
Assert.IsFalse(_saveSystem.DeleteSave(-1));
ExpectInvalidSlotLog(5);
Assert.IsFalse(_saveSystem.DeleteSave(5));
}
[Test]
public void DeleteSave_NonexistentSave_ReturnsFalse()
{
Assert.IsFalse(_saveSystem.DeleteSave(0));
}
#endregion
#region GetSaveInfo Tests
[Test]
public void GetSaveInfo_InvalidSlot_ReturnsDefaultInfo()
{
ExpectInvalidSlotLog(-1);
var info = _saveSystem.GetSaveInfo(-1);
Assert.IsNotNull(info);
Assert.IsFalse(info.exists);
Assert.AreEqual(-1, info.slot);
}
[Test]
public void GetSaveInfo_NonexistentSave_ReturnsNonExistentInfo()
{
var info = _saveSystem.GetSaveInfo(0);
Assert.IsNotNull(info);
Assert.IsFalse(info.exists);
Assert.AreEqual(0, info.slot);
}
#endregion
#region GetAllSaveInfos Tests
[Test]
public void GetAllSaveInfos_ReturnsArray()
{
var infos = _saveSystem.GetAllSaveInfos();
Assert.IsNotNull(infos);
Assert.AreEqual(3, infos.Length);
}
#endregion
#region GetMostRecentSaveSlot Tests
[Test]
public void GetMostRecentSaveSlot_NoSaves_ReturnsNegativeOne()
{
Assert.AreEqual(-1, _saveSystem.GetMostRecentSaveSlot());
}
#endregion
#region SaveData Structure Tests
[Test]
public void SaveData_DefaultValues_AreCorrect()
{
var data = new SaveSystem.SaveData();
Assert.AreEqual(0, data.saveSlot);
Assert.AreEqual(0, data.currentChapterIndex);
Assert.IsNotNull(data.completedChapters);
Assert.AreEqual(0, data.completedChapters.Count);
Assert.IsNotNull(data.alivePieces);
Assert.AreEqual(0, data.alivePieces.Count);
Assert.AreEqual(0, data.totalPiecesLost);
Assert.IsFalse(data.lastChapterWasVictory);
Assert.AreEqual(1, data.saveVersion);
}
[Test]
public void SaveData_CustomValues_ArePreserved()
{
var data = new SaveSystem.SaveData
{
saveSlot = 2,
currentChapterIndex = 3,
totalPiecesLost = 5,
lastChapterWasVictory = true
};
Assert.AreEqual(2, data.saveSlot);
Assert.AreEqual(3, data.currentChapterIndex);
Assert.AreEqual(5, data.totalPiecesLost);
Assert.IsTrue(data.lastChapterWasVictory);
}
#endregion
#region SaveInfo Structure Tests
[Test]
public void SaveInfo_DefaultValues_AreCorrect()
{
var info = new SaveSystem.SaveInfo(1);
Assert.AreEqual(1, info.slot);
Assert.AreEqual("", info.saveDate);
Assert.AreEqual(0, info.currentChapter);
Assert.IsFalse(info.exists);
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 33a56ecf7f740af479dbb3600879fe75
+206 -88
View File
@@ -2,107 +2,225 @@ using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
/// <summary>
/// Unit tests for MoveValidator — constructor, board bounds, null safety.
/// </summary>
public class MoveValidatorTests
namespace AjedrezPurgatorio.Tests.Unit.Chess
{
private CheckDetector _checkDetector;
private MoveValidator _validator;
[SetUp]
public void SetUp()
[TestFixture]
[Category("Unit")]
[Category("Chess")]
[Category("MoveValidator")]
[Category("FastTests")]
public class MoveValidatorTests
{
_checkDetector = new CheckDetector();
_validator = new MoveValidator(_checkDetector);
}
private MoveValidator _validator;
private CheckDetector _checkDetector;
private Piece[,] _board;
private GameObject _root;
[TearDown]
public void TearDown()
{
_validator = null;
_checkDetector = null;
}
[SetUp]
public void Setup()
{
_checkDetector = new CheckDetector();
_validator = new MoveValidator(_checkDetector);
_board = new Piece[8, 8];
_root = new GameObject("TestRoot");
}
[Test]
public void Constructor_NullCheckDetector_ThrowsArgumentNullException()
{
Assert.Throws<System.ArgumentNullException>(() => new MoveValidator(null));
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(_root);
}
[Test]
public void IsMoveLegal_NullBoard_ReturnsFalse()
{
var pawn = CreatePiece<Pawn>(3, 1, true);
LogAssert.Expect(LogType.Error, "[MoveValidator] Board o piece es null.");
Assert.IsFalse(_validator.IsMoveLegal(null, pawn, new Vector2Int(3, 2)));
}
private T CreatePiece<T>(int x, int y, bool isWhite) where T : Piece
{
var go = new GameObject($"Piece_{typeof(T).Name}_{x}_{y}");
go.transform.SetParent(_root.transform);
var piece = go.AddComponent<T>();
piece.currentPos = new Vector2Int(x, y);
piece.isWhite = isWhite;
piece.hasMoved = true;
_board[x, y] = piece;
return piece;
}
[Test]
public void IsMoveLegal_NullPiece_ReturnsFalse()
{
var board = new Piece[8, 8];
LogAssert.Expect(LogType.Error, "[MoveValidator] Board o piece es null.");
Assert.IsFalse(_validator.IsMoveLegal(board, null, new Vector2Int(3, 2)));
}
#region Constructor Tests
[Test]
public void IsMoveLegal_OutOfBounds_ReturnsFalse()
{
var board = new Piece[8, 8];
var pawn = CreatePiece<Pawn>(3, 1, true);
board[3, 1] = pawn;
[Test]
public void Constructor_NullCheckDetector_ThrowsArgumentNullException()
{
Assert.Throws<System.ArgumentNullException>(() => new MoveValidator(null));
}
Assert.IsFalse(_validator.IsMoveLegal(board, pawn, new Vector2Int(-1, 2)));
Assert.IsFalse(_validator.IsMoveLegal(board, pawn, new Vector2Int(8, 2)));
Assert.IsFalse(_validator.IsMoveLegal(board, pawn, new Vector2Int(3, -1)));
Assert.IsFalse(_validator.IsMoveLegal(board, pawn, new Vector2Int(3, 8)));
}
#endregion
[Test]
public void GetLegalMovesForPiece_NullBoard_ReturnsEmpty()
{
var pawn = CreatePiece<Pawn>(3, 1, true);
LogAssert.Expect(LogType.Error, "[MoveValidator] Board o piece es null.");
var moves = _validator.GetLegalMovesForPiece(null, pawn);
Assert.IsNotNull(moves);
Assert.AreEqual(0, moves.Count);
}
#region IsMoveLegal Tests
[Test]
public void GetLegalMovesForPiece_NullPiece_ReturnsEmpty()
{
var board = new Piece[8, 8];
LogAssert.Expect(LogType.Error, "[MoveValidator] Board o piece es null.");
var moves = _validator.GetLegalMovesForPiece(board, null);
Assert.IsNotNull(moves);
Assert.AreEqual(0, moves.Count);
}
[Test]
public void IsMoveLegal_NullBoard_ReturnsFalse()
{
var king = CreatePiece<King>(4, 0, true);
LogAssert.Expect(LogType.Error, "[MoveValidator] Board o piece es null.");
Assert.IsFalse(_validator.IsMoveLegal(null, king, new Vector2Int(4, 1)));
}
[Test]
public void GetAllLegalMoves_NullBoard_ReturnsEmpty()
{
LogAssert.Expect(LogType.Error, "[MoveValidator] Board es null.");
var moves = _validator.GetAllLegalMoves(null, true);
Assert.IsNotNull(moves);
Assert.AreEqual(0, moves.Count);
}
[Test]
public void IsMoveLegal_NullPiece_ReturnsFalse()
{
LogAssert.Expect(LogType.Error, "[MoveValidator] Board o piece es null.");
Assert.IsFalse(_validator.IsMoveLegal(_board, null, new Vector2Int(4, 1)));
}
[Test]
public void CountLegalMoves_NullBoard_ReturnsZero()
{
LogAssert.Expect(LogType.Error, "[MoveValidator] Board es null.");
Assert.AreEqual(0, _validator.CountLegalMoves(null, true));
}
[Test]
public void IsMoveLegal_OutOfBoard_ReturnsFalse()
{
var king = CreatePiece<King>(4, 0, true);
Assert.IsFalse(_validator.IsMoveLegal(_board, king, new Vector2Int(-1, 0)));
Assert.IsFalse(_validator.IsMoveLegal(_board, king, new Vector2Int(8, 0)));
}
// ==================== HELPER ====================
[Test]
public void IsMoveLegal_RookToValidSquare_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
var rook = CreatePiece<Rook>(0, 0, true);
private T CreatePiece<T>(int x, int y, bool isWhite) where T : Piece
{
var obj = new GameObject($"Test_{typeof(T).Name}_{x}_{y}");
var piece = obj.AddComponent<T>();
piece.currentPos = new Vector2Int(x, y);
piece.isWhite = isWhite;
return piece;
Assert.IsTrue(_validator.IsMoveLegal(_board, rook, new Vector2Int(0, 4)));
}
[Test]
public void IsMoveLegal_RookBlockedByFriend_ReturnsFalse()
{
CreatePiece<King>(4, 0, true);
var rook = CreatePiece<Rook>(0, 0, true);
CreatePiece<Pawn>(0, 3, true);
Assert.IsFalse(_validator.IsMoveLegal(_board, rook, new Vector2Int(0, 5)));
}
[Test]
public void IsMoveLegal_RookCanCaptureEnemy_ReturnsTrue()
{
CreatePiece<King>(4, 0, true);
var rook = CreatePiece<Rook>(0, 0, true);
CreatePiece<Pawn>(0, 5, false);
Assert.IsTrue(_validator.IsMoveLegal(_board, rook, new Vector2Int(0, 5)));
}
[Test]
public void IsMoveLegal_KingMoveIntoCheck_ReturnsFalse()
{
var king = CreatePiece<King>(4, 3, true);
CreatePiece<Rook>(4, 7, false);
Assert.IsFalse(_validator.IsMoveLegal(_board, king, new Vector2Int(4, 4)));
}
[Test]
public void IsMoveLegal_KingMoveAwayFromCheck_ReturnsTrue()
{
var king = CreatePiece<King>(4, 3, true);
CreatePiece<Rook>(4, 7, false);
Assert.IsTrue(_validator.IsMoveLegal(_board, king, new Vector2Int(3, 3)));
}
#endregion
#region GetLegalMovesForPiece Tests
[Test]
public void GetLegalMovesForPiece_NullBoard_ReturnsEmptyList()
{
var king = CreatePiece<King>(4, 0, true);
LogAssert.Expect(LogType.Error, "[MoveValidator] Board o piece es null.");
var moves = _validator.GetLegalMovesForPiece(null, king);
Assert.IsNotNull(moves);
Assert.AreEqual(0, moves.Count);
}
[Test]
public void GetLegalMovesForPiece_NullPiece_ReturnsEmptyList()
{
LogAssert.Expect(LogType.Error, "[MoveValidator] Board o piece es null.");
var moves = _validator.GetLegalMovesForPiece(_board, null);
Assert.IsNotNull(moves);
Assert.AreEqual(0, moves.Count);
}
[Test]
public void GetLegalMovesForPiece_KingInCheck_OnlyLegalMoves()
{
var king = CreatePiece<King>(4, 3, true);
CreatePiece<Rook>(4, 7, false);
var moves = _validator.GetLegalMovesForPiece(_board, king);
Assert.IsTrue(moves.Count > 0, "King should have escape squares");
foreach (var move in moves)
{
Assert.IsFalse(
_checkDetector.WouldLeaveKingInCheck(_board, king, move),
$"Move to {move} should not leave king in check"
);
}
}
[Test]
public void GetLegalMovesForPiece_RookInOpenBoard_AllStraightMoves()
{
CreatePiece<King>(0, 0, true);
var rook = CreatePiece<Rook>(4, 4, true);
var moves = _validator.GetLegalMovesForPiece(_board, rook);
Assert.AreEqual(14, moves.Count);
}
#endregion
#region GetAllLegalMoves Tests
[Test]
public void GetAllLegalMoves_NullBoard_ReturnsEmptyList()
{
LogAssert.Expect(LogType.Error, "[MoveValidator] Board es null.");
var moves = _validator.GetAllLegalMoves(null, true);
Assert.IsNotNull(moves);
Assert.AreEqual(0, moves.Count);
}
[Test]
public void GetAllLegalMoves_KingOnly_CorrectCount()
{
CreatePiece<King>(4, 0, true);
var moves = _validator.GetAllLegalMoves(_board, true);
Assert.AreEqual(5, moves.Count);
}
#endregion
#region CountLegalMoves Tests
[Test]
public void CountLegalMoves_EmptyBoard_ReturnsZero()
{
Assert.AreEqual(0, _validator.CountLegalMoves(_board, true));
}
[Test]
public void CountLegalMoves_KingOnly_ReturnsCorrectCount()
{
CreatePiece<King>(4, 0, true);
Assert.AreEqual(5, _validator.CountLegalMoves(_board, true));
}
#endregion
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bda77c87d68668642a9bc733a0f46ca3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,242 @@
using NUnit.Framework;
using UnityEngine;
namespace AjedrezPurgatorio.Tests.Unit.Purgatory
{
[TestFixture]
[Category("Unit")]
[Category("Purgatory")]
[Category("DiceSystem")]
[Category("FastTests")]
public class DiceSystemTests
{
private GameObject _root;
private DiceSystem _diceSystem;
[SetUp]
public void Setup()
{
_root = new GameObject("DiceTestRoot");
_diceSystem = _root.AddComponent<DiceSystem>();
}
[TearDown]
public void Teardown()
{
Object.DestroyImmediate(_root);
}
#region Roll2d6 Tests
[Test]
public void Roll2d6_ReturnsValueBetween2And12()
{
for (int i = 0; i < 100; i++)
{
int result = _diceSystem.Roll2d6();
Assert.GreaterOrEqual(result, 2, "Minimum 2d6 result is 2");
Assert.LessOrEqual(result, 12, "Maximum 2d6 result is 12");
}
}
[Test]
public void Roll2d6_DistributionCoversFullRange()
{
bool sawMin = false;
bool sawMax = false;
for (int i = 0; i < 500; i++)
{
int result = _diceSystem.Roll2d6();
if (result == 2) sawMin = true;
if (result == 12) sawMax = true;
}
Assert.IsTrue(sawMin, "Should see minimum roll of 2 in 500 attempts");
Assert.IsTrue(sawMax, "Should see maximum roll of 12 in 500 attempts");
}
#endregion
#region DetermineWinner Tests
[Test]
public void DetermineWinner_PlayerHigher_ReturnsPlayer()
{
Assert.AreEqual(PurgatoryWinner.Player, _diceSystem.DetermineWinner(10, 5));
}
[Test]
public void DetermineWinner_DeathHigher_ReturnsDeath()
{
Assert.AreEqual(PurgatoryWinner.Death, _diceSystem.DetermineWinner(5, 10));
}
[Test]
public void DetermineWinner_Tie_ReturnsDeath()
{
Assert.AreEqual(PurgatoryWinner.Death, _diceSystem.DetermineWinner(7, 7));
}
[Test]
public void DetermineWinner_BothZero_ReturnsDeath()
{
Assert.AreEqual(PurgatoryWinner.Death, _diceSystem.DetermineWinner(0, 0));
}
#endregion
#region RollForDeath Tests
[Test]
public void RollForDeath_BaseRollInRange()
{
var result = _diceSystem.RollForDeath();
Assert.GreaterOrEqual(result.baseRoll, 2);
Assert.LessOrEqual(result.baseRoll, 12);
}
[Test]
public void RollForDeath_NoModifiers()
{
var result = _diceSystem.RollForDeath();
Assert.AreEqual(0, result.pieceBonus);
Assert.AreEqual(0, result.desperationBonus);
Assert.AreEqual(0, result.penalty);
Assert.AreEqual(result.baseRoll, result.finalResult);
}
#endregion
#region RollForPlayer Tests
[Test]
public void RollForPlayer_QueenGivesBonus()
{
var queenIdentity = ScriptableObject.CreateInstance<PieceIdentity>();
queenIdentity.pieceType = PieceType.Queen;
var result = _diceSystem.RollForPlayer(queenIdentity, 0);
Assert.AreEqual(2, result.pieceBonus);
Object.DestroyImmediate(queenIdentity);
}
[Test]
public void RollForPlayer_RookGivesBonus()
{
var rookIdentity = ScriptableObject.CreateInstance<PieceIdentity>();
rookIdentity.pieceType = PieceType.Rook;
var result = _diceSystem.RollForPlayer(rookIdentity, 0);
Assert.AreEqual(1, result.pieceBonus);
Object.DestroyImmediate(rookIdentity);
}
[Test]
public void RollForPlayer_BishopGivesBonus()
{
var bishopIdentity = ScriptableObject.CreateInstance<PieceIdentity>();
bishopIdentity.pieceType = PieceType.Bishop;
var result = _diceSystem.RollForPlayer(bishopIdentity, 0);
Assert.AreEqual(1, result.pieceBonus);
Object.DestroyImmediate(bishopIdentity);
}
[Test]
public void RollForPlayer_KnightGivesBonus()
{
var knightIdentity = ScriptableObject.CreateInstance<PieceIdentity>();
knightIdentity.pieceType = PieceType.Knight;
var result = _diceSystem.RollForPlayer(knightIdentity, 0);
Assert.AreEqual(1, result.pieceBonus);
Object.DestroyImmediate(knightIdentity);
}
[Test]
public void RollForPlayer_PawnGivesNoBonus()
{
var pawnIdentity = ScriptableObject.CreateInstance<PieceIdentity>();
pawnIdentity.pieceType = PieceType.Pawn;
var result = _diceSystem.RollForPlayer(pawnIdentity, 0);
Assert.AreEqual(0, result.pieceBonus);
Object.DestroyImmediate(pawnIdentity);
}
[Test]
public void RollForPlayer_PurgatoryPenaltyIncreases()
{
var identity = ScriptableObject.CreateInstance<PieceIdentity>();
identity.pieceType = PieceType.Pawn;
var result0 = _diceSystem.RollForPlayer(identity, 0);
var result3 = _diceSystem.RollForPlayer(identity, 3);
Assert.AreEqual(0, result0.penalty);
Assert.AreEqual(3, result3.penalty);
// Comparar dos tiradas RNG independientes es no determinista;
// validar la fórmula por tirada usando su propia base:
// finalResult = max(2, baseRoll + bonus - penalty).
int expected0 = Mathf.Max(2, result0.baseRoll + result0.pieceBonus + result0.desperationBonus - 0);
int expected3 = Mathf.Max(2, result3.baseRoll + result3.pieceBonus + result3.desperationBonus - 3);
Assert.AreEqual(expected0, result0.finalResult,
"Sin visitas la penalización es 0 y finalResult refleja la base.");
Assert.AreEqual(expected3, result3.finalResult,
"Con 3 visitas la penalización se aplica sobre la misma tirada.");
Object.DestroyImmediate(identity);
}
[Test]
public void RollForPlayer_FinalResultNeverBelow2()
{
var identity = ScriptableObject.CreateInstance<PieceIdentity>();
identity.pieceType = PieceType.Pawn;
for (int i = 0; i < 100; i++)
{
var result = _diceSystem.RollForPlayer(identity, 10);
Assert.GreaterOrEqual(result.finalResult, 2, "Final result should never be below 2");
}
Object.DestroyImmediate(identity);
}
#endregion
#region DiceRollResult ToString Tests
[Test]
public void DiceRollResult_ToString_ContainsAllParts()
{
var result = new DiceRollResult
{
baseRoll = 7,
pieceBonus = 2,
desperationBonus = 1,
penalty = 1,
finalResult = 9
};
string str = result.ToString();
StringAssert.Contains("7", str);
StringAssert.Contains("2", str);
StringAssert.Contains("1", str);
StringAssert.Contains("9", str);
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1ee943a000867ad4b993cabc00f4f437
@@ -0,0 +1,166 @@
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
/// <summary>
/// Tests del Duelo del Purgatorio: curva de colapso, persecución del Jefe
/// y selección de casillas devoradas (purgatorio-final.md, criterio 7).
/// </summary>
public class PurgatoryDuelRulesTests
{
// ══════════════════════════════════════════════════════
// CURVA DE COLAPSO
// ══════════════════════════════════════════════════════
[Test]
public void TilesForTurn_GracePeriod_ReturnsZero()
{
Assert.AreEqual(0, PurgatoryDuelRules.TilesForTurn(1));
Assert.AreEqual(0, PurgatoryDuelRules.TilesForTurn(2));
}
[Test]
public void TilesForTurn_EarlyGame_ReturnsOne()
{
Assert.AreEqual(1, PurgatoryDuelRules.TilesForTurn(3));
Assert.AreEqual(1, PurgatoryDuelRules.TilesForTurn(7));
}
[Test]
public void TilesForTurn_Endgame_ReturnsTwo()
{
Assert.AreEqual(2, PurgatoryDuelRules.TilesForTurn(8));
Assert.AreEqual(2, PurgatoryDuelRules.TilesForTurn(20));
}
// ══════════════════════════════════════════════════════
// SELECCIÓN DE CASILLAS DEVORADAS
// ══════════════════════════════════════════════════════
[Test]
public void PickTilesToDevour_ExcludesBlockedSquares()
{
var excluded = new HashSet<Vector2Int> { new Vector2Int(4, 4), new Vector2Int(0, 0) };
var rng = new System.Random(12345);
List<Vector2Int> victims = PurgatoryDuelRules.PickTilesToDevour(6, excluded, rng);
Assert.AreEqual(6, victims.Count);
foreach (Vector2Int tile in victims)
{
Assert.IsFalse(excluded.Contains(tile), $"Casilla excluida devorada: {tile}");
Assert.IsTrue(PurgatoryDuelRules.IsInBounds(tile));
}
}
[Test]
public void PickTilesToDevour_NoDuplicates()
{
var rng = new System.Random(99);
List<Vector2Int> victims = PurgatoryDuelRules.PickTilesToDevour(10, new HashSet<Vector2Int>(), rng);
Assert.AreEqual(new HashSet<Vector2Int>(victims).Count, victims.Count);
}
[Test]
public void PickTilesToDevour_MoreThanAvailable_ReturnsAvailableOnly()
{
var excluded = new HashSet<Vector2Int>();
for (int x = 0; x < 8; x++)
for (int y = 0; y < 8; y++)
if (!(x == 0 && y == 0))
excluded.Add(new Vector2Int(x, y));
var rng = new System.Random(7);
List<Vector2Int> victims = PurgatoryDuelRules.PickTilesToDevour(5, excluded, rng);
Assert.AreEqual(1, victims.Count);
Assert.AreEqual(new Vector2Int(0, 0), victims[0]);
}
// ══════════════════════════════════════════════════════
// PERSECUCIÓN DEL JEFE
// ══════════════════════════════════════════════════════
[Test]
public void NextChaseStep_DiagonalTarget_StepsDiagonally()
{
var from = new Vector2Int(2, 2);
var target = new Vector2Int(5, 5);
Vector2Int step = PurgatoryDuelRules.NextChaseStep(from, target, new HashSet<Vector2Int>());
Assert.AreEqual(new Vector2Int(3, 3), step);
Assert.AreEqual(1, PurgatoryDuelRules.Chebyshev(step, from));
}
[Test]
public void NextChaseStep_BlockedDiagonal_FallsBackToAxis()
{
var from = new Vector2Int(2, 2);
var target = new Vector2Int(5, 5);
var blocked = new HashSet<Vector2Int> { new Vector2Int(3, 3) };
Vector2Int step = PurgatoryDuelRules.NextChaseStep(from, target, blocked);
Assert.AreNotEqual(new Vector2Int(3, 3), step, "No debe entrar en la casilla bloqueada.");
Assert.AreNotEqual(from, step, "Con eje libre debe rodear el hueco, no quedarse quieto.");
Assert.LessOrEqual(PurgatoryDuelRules.Chebyshev(step, target), PurgatoryDuelRules.Chebyshev(from, target),
"El paso axial de rodeo no debe alejar al Jefe del objetivo.");
}
[Test]
public void NextChaseStep_Adjacent_ReachesPlayer()
{
var from = new Vector2Int(3, 3);
var player = new Vector2Int(4, 3);
Vector2Int step = PurgatoryDuelRules.NextChaseStep(from, player, new HashSet<Vector2Int>());
Assert.AreEqual(player, step);
}
[Test]
public void NextChaseStep_AllPathsBlocked_StaysInPlace()
{
var from = new Vector2Int(0, 0);
var target = new Vector2Int(4, 4);
var blocked = new HashSet<Vector2Int>
{
new Vector2Int(1, 0),
new Vector2Int(0, 1),
new Vector2Int(1, 1)
};
Vector2Int step = PurgatoryDuelRules.NextChaseStep(from, target, blocked);
Assert.AreEqual(from, step);
}
[Test]
public void NextChaseStep_AlwaysReducesOrMaintainsChebyshev()
{
var rng = new System.Random(2026);
for (int i = 0; i < 200; i++)
{
var from = new Vector2Int(rng.Next(8), rng.Next(8));
var target = new Vector2Int(rng.Next(8), rng.Next(8));
var blocked = new HashSet<Vector2Int>();
int blockCount = rng.Next(4);
for (int b = 0; b < blockCount; b++)
blocked.Add(new Vector2Int(rng.Next(8), rng.Next(8)));
Vector2Int step = PurgatoryDuelRules.NextChaseStep(from, target, blocked);
Assert.LessOrEqual(
PurgatoryDuelRules.Chebyshev(step, target),
PurgatoryDuelRules.Chebyshev(from, target),
$"from={from} target={target} blocked=[{string.Join(";", blocked)}]");
Assert.LessOrEqual(PurgatoryDuelRules.Chebyshev(step, from), 1, "El Jefe mueve como rey (máx 1 casilla por paso).");
if (!blocked.Contains(step) || step == from)
Assert.IsTrue(true);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bce97134996e437408940669be30c13d
+85
View File
@@ -0,0 +1,85 @@
# Quick Design — Purgatorio Final (Duelo contra La Muerte)
> **Fecha**: 2026-08-22 · **Estado**: Aprobado por el usuario
> **Tipo**: Rediseño del clímax de campaña · **Reemplaza**: victoria instantánea al dar jaque mate
## Resumen
Al dar jaque mate al Rey enemigo, en lugar de victoria instantánea se abre el
**Duelo del Purgatorio**: la pieza que dio el mate queda sola sobre el tablero
contra **La Muerte** (pieza-jefe única), mientras el tablero se devora a sí
mismo casilla a casilla.
El flujo de dados actual (recuperar piezas capturadas durante la partida)
**convive** con este sistema — no lo reemplaza.
## Reglas
### Trigger
- El jugador da jaque mate (`GameManager.OnCheckmate` con `whiteWins == true`).
- No aparece VictoryUI: se transiciona a `GameState.PurgatoryDuel`.
### Arena
- Mismo tablero, misma escena. Se desactivan todas las piezas salvo:
- La **pieza de mate** del jugador (queda en su casilla).
- El **Jefe** (La Muerte): sprite de Rey oscuro/rojo en el centro del tablero.
- Niebla desactivada durante el duelo.
### Turnos
1. **Turno del jugador**: mueve su pieza con su movimiento normal de ajedrez
(validación estándar). Una jugada por turno. Sin movimientos legales → pasa turno.
2. **Colapso** (después del movimiento del jugador):
- Turnos 1-2: sin devorar.
- Turno 3+: 1 casilla aleatoria por turno.
- Turno 8+: 2 casillas por turno.
- Casilla devorada = vacío (oscurecida, intransitable para ambas piezas).
- La casilla bajo el Jefe está EXCLUIDA del colapso (La Muerte es dueña del
Purgatorio). Si la casilla devorada está bajo el jugador → derrota.
3. **Turno del Jefe**: avanza hasta 2 casillas hacia el jugador (paso greedy,
evitando vacíos). Si termina en la casilla del jugador → derrota.
### Fin del duelo
- **Victoria**: el jugador captura al Jefe → victoria final de campaña (VictoryUI).
- **Derrota** (te atrapa / caes al vacío):
1. Oferta única de **rescate por dados**: usa `DiceSystem` sobre UNA identidad
muerta este capítulo (la más valiosa disponible). Éxito → esa pieza vuelve
al tablero original. Fallo o rechazo → nada.
2. Reinicio al Capítulo 1 / tablero original ("el juego continúa en el primer
tablero original").
### Voz de La Muerte
Frases solemnes mostradas en el banner del duelo:
- Al iniciar el duelo.
- Cada 5 turnos (aviso del colapso).
- Al capturar al Jefe / al perder.
## Arquitectura
| Componente | Responsabilidad | Creación |
|---|---|---|
| `PurgatoryDuelManager` | Orquesta turnos, estado, fin de duelo, rescate | Runtime, hijo de CampaignManager |
| `BossAI` | Movimiento de persecución del Jefe (2 pasos greedy) | Hijo del manager |
| `BoardCollapseController` | Devora casillas aleatorias según curva | Hijo del manager |
| `PurgatoryDuelUI` | Banner + contador + frases + paneles de fin | Runtime sobre canvas existente |
| `MuerteQuotes` | Pools data-driven de frases (JSON Resources) | Estático |
Sin edición de escenas: todo se construye en runtime siguiendo el patrón de
`DialogueTriggerSystem` (ADR-0001: jerarquías independientes por modo).
## Fuera de alcance
- Modificar el flujo de dados existente (oferta → tirada → resultado).
- Arte final del Jefe (usa sprite de Rey tintado; reemplazable después).
- Portraits nuevos de La Muerte para SpeakerDatabase.
## Criterios de aceptación
1. Dar jaque mate abre el duelo (no VictoryUI).
2. Solo quedan la pieza de mate y el Jefe; el resto de piezas desaparece.
3. El colapso empieza en el turno 3 y escala en el 8; las casillas devoradas
bloquean movimiento de ambas piezas.
4. El Jefe persigue y puede capturar; el jugador pierde si es atrapado o si su
casilla colapsa.
5. Capturar al Jefe muestra victoria de campaña.
6. Perder ofrece rescate por dados una vez y luego reinicia al Capítulo 1.
7. Tests EditMode cubren curva de colapso, paso del jefe y transiciones de estado.
+19
View File
@@ -88,3 +88,22 @@ Apr 2026 Jun 2026
5. Añadir eventos OnPieceCaptured/OnCheckResolved a GameManager
6. Escribir tests pendientes (SaveSystem, servicios POCO)
7. Resolver duplicación BoardManager/ClassicBoardManager (normalización de nombres)
---
## Actualización 2026-08-22 — Auditoría de bloqueantes
Verificación de los 7 bloqueantes listados arriba:
| # | Bloqueante | Estado |
|---|-----------|--------|
| 1 | Commit del trabajo sin commitear | RESUELTO — commit 9fdafe8 (+ TestResults*.xml a .gitignore) |
| 2 | Chapter2/3 en Build Settings | RESUELTO — 4 escenas habilitadas, GUIDs verificados |
| 3 | Instanciar GameStateManager y AIController | YA ESTABA — presente y habilitado en Ch1/2/3; dificultad aplicada por CampaignManager en runtime |
| 4 | Cablear DeadKingInputUI/RevealUI | CABLEADO OK (CampaignManager usa FindObjectOfType<T>(true)) — PENDIENTE usuario: correr Tools > Purgatory UI para dedup (8-9 copias por escena) y copiar Victory/Defeat/MemorialPanel a Ch1/2 |
| 5 | Eventos OnPieceCaptured/OnCheckResolved + HUD | RESUELTO — eventos declarados e invocados; GameHUD suscripto con CapturedPieceIcon.prefab asignado en las 3 escenas |
| 6 | Tests pendientes | RESUELTO — suites migradas de ests/ (invisible para Unity) a Assets/Tests/EditMode/: CampaignState, DiceSystem, DeadKingPool, SaveSystem, DialogueBranching + 3 chess actualizadas a versiones más completas. Nuevas: TypewriterTests (8 tests), CampaignManagerConfigTests (11 tests) |
| 7 | Duplicación BoardManager/ClassicBoardManager | CERRADO POR DISEÑO — nombres ya normalizados (sin colisión); la duplicación de lógica es consecuencia aceptada de ADR-0001 (arquitectura por modo) |
**Pendiente tras esta auditoría**: correr los menús Tools > Purgatory UI en Unity (Ch1, Ch2, Ch3),
guardar escenas, verificar consola sin errores de compilación, y commitear.