- Introduce new narrative probes for episode grammar, ensemble balance, and prose redundancy - Implement narrative presentation coverage tracking for better event visibility - Update harness scenarios to include new narrative ingestion and persistence tests - Refactor event emission to include narrative development IDs and presentations - Expand InterestCandidate to support narrative presentation models and roles
526 lines
21 KiB
C#
526 lines
21 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Story-first scheduler and sole camera-story owner.
|
|
/// </summary>
|
|
public static class StoryScheduler
|
|
{
|
|
private const float EpisodeNoProgressSeconds = 14f;
|
|
private const float PayoffSettleSeconds = 8f;
|
|
private const float EpisodeMaxSeconds = 75f;
|
|
public const int EpisodeCombatShotBudget = 3;
|
|
private static readonly List<NarrativeThread> OpenThreads = new List<NarrativeThread>(32);
|
|
private static readonly List<InterestCandidate> Pending = new List<InterestCandidate>(96);
|
|
private static float _nextTickAt;
|
|
private static string _lastProposal = "";
|
|
|
|
public static EpisodePlan ActiveEpisode { get; private set; }
|
|
public static EpisodeShotProposal CurrentProposal { get; private set; }
|
|
public static string LastProposal => _lastProposal ?? "";
|
|
|
|
public static void Clear()
|
|
{
|
|
ActiveEpisode = null;
|
|
CurrentProposal = null;
|
|
OpenThreads.Clear();
|
|
Pending.Clear();
|
|
_nextTickAt = 0f;
|
|
_lastProposal = "";
|
|
NarrativeExposureLedger.Clear();
|
|
}
|
|
|
|
public static void Tick(float now)
|
|
{
|
|
if (now < _nextTickAt) return;
|
|
_nextTickAt = now + 0.5f;
|
|
NarrativeStoryStore.CopyOpenThreads(OpenThreads);
|
|
NarrativeThread best = null;
|
|
float bestScore = float.MinValue;
|
|
for (int i = 0; i < OpenThreads.Count; i++)
|
|
{
|
|
NarrativeThread thread = OpenThreads[i];
|
|
if (thread != null
|
|
&& (ActiveEpisode == null || ActiveEpisode.ThreadId != thread.Id))
|
|
{
|
|
thread.CoverageDebt = Mathf.Min(20f, thread.CoverageDebt + 0.035f);
|
|
}
|
|
float score = Score(thread, now);
|
|
if (score > bestScore)
|
|
{
|
|
best = thread;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
|
|
NarrativeThread activeThread = ActiveEpisode != null
|
|
? NarrativeStoryStore.Thread(ActiveEpisode.ThreadId)
|
|
: null;
|
|
if (ActiveEpisode != null && !EpisodeStillValid(ActiveEpisode, activeThread, now))
|
|
{
|
|
ActiveEpisode = null;
|
|
CurrentProposal = null;
|
|
activeThread = null;
|
|
}
|
|
|
|
if (best == null && activeThread == null)
|
|
{
|
|
ActiveEpisode = null;
|
|
CurrentProposal = null;
|
|
_lastProposal = "none";
|
|
return;
|
|
}
|
|
|
|
NarrativeThread selectedThread = RetainOrChoose(activeThread, best, bestScore, now);
|
|
if (selectedThread == null)
|
|
{
|
|
ActiveEpisode = null;
|
|
CurrentProposal = null;
|
|
_lastProposal = "none";
|
|
return;
|
|
}
|
|
|
|
if (ActiveEpisode == null || ActiveEpisode.ThreadId != selectedThread.Id)
|
|
{
|
|
ActiveEpisode = BuildEpisode(selectedThread, now);
|
|
}
|
|
|
|
else if (selectedThread.LatestMeaningfulChangeId != ActiveEpisode.EntryDevelopmentId)
|
|
{
|
|
ActiveEpisode.EntryDevelopmentId = selectedThread.LatestMeaningfulChangeId;
|
|
ActiveEpisode.LastProgressAt = now;
|
|
ActiveEpisode.CombatShotCount = 0;
|
|
ActiveEpisode.GoalMet = false;
|
|
ActiveEpisode.CompletionReason = "";
|
|
AddPendingForPurpose(ActiveEpisode, PurposeFor(selectedThread));
|
|
}
|
|
|
|
InterestRegistry.CopyPending(Pending);
|
|
CurrentProposal = EpisodeShotSelector.Pick(Pending, ActiveEpisode, selectedThread);
|
|
if (CurrentProposal != null)
|
|
{
|
|
NarrativeTelemetry.NoteProposal(ActiveEpisode, CurrentProposal, now);
|
|
}
|
|
if (CurrentProposal?.Candidate != null)
|
|
{
|
|
NarrativeTelemetry.NoteCurrentAlignment(
|
|
ActiveEpisode, selectedThread, InterestDirector.CurrentCandidate);
|
|
}
|
|
|
|
_lastProposal = selectedThread.Id + " protagonist=" + selectedThread.ProtagonistId
|
|
+ " phase=" + selectedThread.Phase + " score=" + Score(selectedThread, now).ToString("0.0")
|
|
+ " shot=" + (CurrentProposal?.Candidate?.Key ?? "none")
|
|
+ " reason=" + (CurrentProposal?.Reason ?? "none");
|
|
}
|
|
|
|
public static void NoteActualSelection(InterestCandidate actual)
|
|
{
|
|
if (ActiveEpisode == null || actual == null) return;
|
|
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
|
|
NarrativeTelemetry.NoteActual(ActiveEpisode, thread, actual);
|
|
NarrativeInterruptClass kind = InterruptPolicy.Classify(actual, ActiveEpisode, thread);
|
|
if (kind == NarrativeInterruptClass.RelatedEscalation)
|
|
{
|
|
NarrativeFunction function = NarrativeFunctionPolicy.Classify(actual);
|
|
EpisodeShotRole role = EpisodeShotSelector.RoleFor(
|
|
actual, function, kind, ActiveEpisode);
|
|
string semanticKey = EpisodeShotSelector.SemanticKey(actual, role);
|
|
if (IsCombatShot(actual))
|
|
{
|
|
ActiveEpisode.CombatShotCount++;
|
|
}
|
|
if (actual.SubjectId != 0 && actual.SubjectId == ActiveEpisode.LastSubjectId)
|
|
ActiveEpisode.RepeatedSubjectCount++;
|
|
else
|
|
ActiveEpisode.RepeatedSubjectCount = 1;
|
|
ActiveEpisode.LastSubjectId = actual.SubjectId;
|
|
ActiveEpisode.Cover(role);
|
|
if (!string.IsNullOrEmpty(semanticKey))
|
|
{
|
|
ActiveEpisode.RecentSemanticKeys.Remove(semanticKey);
|
|
ActiveEpisode.RecentSemanticKeys.Insert(0, semanticKey);
|
|
while (ActiveEpisode.RecentSemanticKeys.Count > 10)
|
|
ActiveEpisode.RecentSemanticKeys.RemoveAt(
|
|
ActiveEpisode.RecentSemanticKeys.Count - 1);
|
|
}
|
|
ActiveEpisode.LastProgressAt = UnityEngine.Time.unscaledTime;
|
|
ActiveEpisode.LastShotKey = actual.Key ?? "";
|
|
ActiveEpisode.InterruptedAt = -1f;
|
|
if (thread != null)
|
|
thread.CoverageDebt = Mathf.Max(0f, thread.CoverageDebt - 3f);
|
|
NarrativeExposureLedger.NoteCut(
|
|
ActiveEpisode.ProtagonistId,
|
|
ActiveEpisode.ThreadId,
|
|
SemanticFamily(actual),
|
|
UnityEngine.Time.unscaledTime);
|
|
ActiveEpisode.GoalMet = ActiveEpisode.PendingRoles.Count == 0
|
|
&& ActiveEpisode.CoveredRoles.Count > 0;
|
|
if (ActiveEpisode.GoalMet)
|
|
ActiveEpisode.CompletionReason = "grammar_complete";
|
|
}
|
|
else if (kind == NarrativeInterruptClass.WorldCritical || kind == NarrativeInterruptClass.StoryPayoff)
|
|
{
|
|
ActiveEpisode.InterruptedAt = UnityEngine.Time.unscaledTime;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A narrative episode may establish and develop a fight without following every new
|
|
/// opponent. A decisive world-critical combat signal still bypasses the budget.
|
|
/// </summary>
|
|
public static bool MayUseCombatShot(EpisodePlan episode, InterestCandidate candidate)
|
|
{
|
|
if (episode == null || candidate == null || !IsCombatShot(candidate))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (candidate.EventStrength >= 95f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return episode.CombatShotCount < EpisodeCombatShotBudget;
|
|
}
|
|
|
|
public static bool MayUseCombatShot(InterestCandidate candidate) =>
|
|
MayUseCombatShot(ActiveEpisode, candidate);
|
|
|
|
/// <summary>Camera-owner selection from candidates already admitted by director safety gates.</summary>
|
|
public static InterestCandidate SelectForCamera(
|
|
IList<InterestCandidate> eligible,
|
|
InterestCandidate current,
|
|
float now)
|
|
{
|
|
if (ActiveEpisode == null) return null;
|
|
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
|
|
if (!EpisodeStillValid(ActiveEpisode, thread, now))
|
|
{
|
|
ActiveEpisode = null;
|
|
CurrentProposal = null;
|
|
return null;
|
|
}
|
|
|
|
EpisodeShotProposal proposal = EpisodeShotSelector.Pick(eligible, ActiveEpisode, thread);
|
|
CurrentProposal = proposal;
|
|
if (proposal?.Candidate == null || proposal.Candidate == current) return null;
|
|
return proposal.Candidate;
|
|
}
|
|
|
|
public static bool ShouldHoldForEpisode(float now)
|
|
{
|
|
if (ActiveEpisode == null
|
|
|| CurrentProposal?.Candidate == null) return false;
|
|
NarrativeThread thread = NarrativeStoryStore.Thread(ActiveEpisode.ThreadId);
|
|
return EpisodeStillValid(ActiveEpisode, thread, now)
|
|
&& now - ActiveEpisode.LastProgressAt < EpisodeNoProgressSeconds;
|
|
}
|
|
|
|
public static bool HarnessProbePolicy(out string detail)
|
|
{
|
|
var episode = new EpisodePlan { ThreadId = "probe:a", ProtagonistId = 101 };
|
|
episode.EligibleCastIds.Add(102);
|
|
var thread = new NarrativeThread
|
|
{
|
|
Id = "probe:a",
|
|
ProtagonistId = 101,
|
|
AnchorKey = "family-a",
|
|
Status = NarrativeThreadStatus.Active
|
|
};
|
|
var candidates = new List<InterestCandidate>
|
|
{
|
|
new InterestCandidate { Key = "unrelated", Label = "Unrelated event", SubjectId = 201, EventStrength = 90f },
|
|
new InterestCandidate
|
|
{
|
|
Key = "related", Label = "Family develops", AssetId = "add_child",
|
|
SubjectId = 102, EventStrength = 60f
|
|
}
|
|
};
|
|
EpisodeShotProposal relatedPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false);
|
|
var episodeB = new EpisodePlan { ThreadId = "probe:b", ProtagonistId = 201 };
|
|
var threadB = new NarrativeThread
|
|
{
|
|
Id = "probe:b",
|
|
ProtagonistId = 201,
|
|
AnchorKey = "family-b",
|
|
Status = NarrativeThreadStatus.Active
|
|
};
|
|
EpisodeShotProposal concurrentPick = EpisodeShotSelector.Pick(
|
|
candidates, episodeB, threadB, requirePresentable: false);
|
|
candidates.Add(new InterestCandidate
|
|
{
|
|
Key = "critical", Label = "Kingdom shattered", SubjectId = 301,
|
|
Category = "Disaster", EventStrength = 100f
|
|
});
|
|
EpisodeShotProposal criticalPick = EpisodeShotSelector.Pick(candidates, episode, thread, requirePresentable: false);
|
|
NarrativeInterruptClass ordinary = InterruptPolicy.Classify(candidates[0], episode, thread);
|
|
var saturated = new EpisodePlan
|
|
{
|
|
ThreadId = "probe:combat",
|
|
ProtagonistId = 101,
|
|
CombatShotCount = EpisodeCombatShotBudget
|
|
};
|
|
var routineCombat = new InterestCandidate
|
|
{
|
|
SubjectId = 101,
|
|
Completion = InterestCompletionKind.CombatActive,
|
|
EventStrength = 80f
|
|
};
|
|
var criticalCombat = new InterestCandidate
|
|
{
|
|
SubjectId = 101,
|
|
Completion = InterestCompletionKind.CombatActive,
|
|
EventStrength = 100f
|
|
};
|
|
bool routineBlocked = !MayUseCombatShot(saturated, routineCombat);
|
|
bool criticalAllowed = MayUseCombatShot(saturated, criticalCombat);
|
|
bool nonCombatAllowed = MayUseCombatShot(saturated, candidates[1]);
|
|
bool pass = relatedPick?.Candidate?.Key == "related"
|
|
&& concurrentPick?.Candidate?.Key == "unrelated"
|
|
&& criticalPick?.Candidate?.Key == "critical"
|
|
&& ordinary == NarrativeInterruptClass.OrdinaryInteresting
|
|
&& !InterruptPolicy.MayInterrupt(ordinary)
|
|
&& routineBlocked && criticalAllowed && nonCombatAllowed;
|
|
detail = "related=" + (relatedPick?.Candidate?.Key ?? "none")
|
|
+ " concurrent=" + (concurrentPick?.Candidate?.Key ?? "none")
|
|
+ " critical=" + (criticalPick?.Candidate?.Key ?? "none")
|
|
+ " ordinary=" + ordinary
|
|
+ " combatBudget=" + routineBlocked + "/" + criticalAllowed + "/" + nonCombatAllowed
|
|
+ " pass=" + pass;
|
|
return pass;
|
|
}
|
|
|
|
public static bool HarnessProbeGrammar(out string detail)
|
|
{
|
|
NarrativeExposureLedger.Clear();
|
|
var episode = new EpisodePlan
|
|
{
|
|
ThreadId = "grammar:thread",
|
|
ProtagonistId = 501,
|
|
Purpose = EpisodePurpose.Develop
|
|
};
|
|
episode.CoveredRoles.Add(EpisodeShotRole.Development);
|
|
episode.PendingRoles.Add(EpisodeShotRole.Pressure);
|
|
episode.RecentSemanticKeys.Add("Development:routine:501");
|
|
var thread = new NarrativeThread
|
|
{
|
|
Id = episode.ThreadId,
|
|
ProtagonistId = 501,
|
|
Status = NarrativeThreadStatus.Active
|
|
};
|
|
var repeated = new InterestCandidate
|
|
{
|
|
Key = "repeated",
|
|
AssetId = "routine",
|
|
SubjectId = 501,
|
|
EventStrength = 92f,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.Development
|
|
};
|
|
var pressure = new InterestCandidate
|
|
{
|
|
Key = "pressure",
|
|
AssetId = "war_started",
|
|
SubjectId = 501,
|
|
EventStrength = 60f,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.Escalation
|
|
};
|
|
EpisodeShotProposal progressive = EpisodeShotSelector.Pick(
|
|
new List<InterestCandidate> { repeated, pressure },
|
|
episode,
|
|
thread,
|
|
requirePresentable: false);
|
|
|
|
var texture = new InterestCandidate
|
|
{
|
|
Key = "texture",
|
|
AssetId = "sleeping",
|
|
SubjectId = 501,
|
|
EventStrength = 80f,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.CharacterTexture
|
|
};
|
|
EpisodeShotProposal noForced = EpisodeShotSelector.Pick(
|
|
new List<InterestCandidate> { texture },
|
|
episode,
|
|
thread,
|
|
requirePresentable: false);
|
|
var critical = new InterestCandidate
|
|
{
|
|
Key = "critical",
|
|
AssetId = "earthquake",
|
|
SubjectId = 900,
|
|
Category = "Disaster",
|
|
EventStrength = 100f,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.WorldContext
|
|
};
|
|
EpisodeShotProposal interrupt = EpisodeShotSelector.Pick(
|
|
new List<InterestCandidate> { critical },
|
|
episode,
|
|
thread,
|
|
requirePresentable: false);
|
|
bool ok = progressive?.Candidate?.Key == "pressure"
|
|
&& progressive.Role == EpisodeShotRole.Pressure
|
|
&& noForced == null
|
|
&& interrupt?.Candidate?.Key == "critical"
|
|
&& interrupt.Role == EpisodeShotRole.WorldContext;
|
|
detail = "progressive=" + (progressive?.Candidate?.Key ?? "none")
|
|
+ "/" + (progressive?.Role.ToString() ?? "none")
|
|
+ " noForced=" + (noForced == null)
|
|
+ " interrupt=" + (interrupt?.Candidate?.Key ?? "none")
|
|
+ "/" + (interrupt?.Role.ToString() ?? "none")
|
|
+ " pass=" + ok;
|
|
return ok;
|
|
}
|
|
|
|
public static float StoryPotential(long characterId)
|
|
{
|
|
CharacterStoryState state = NarrativeStoryStore.Character(characterId);
|
|
return NarrativeStoryStore.EffectiveStoryPotential(state);
|
|
}
|
|
|
|
private static float Score(NarrativeThread thread, float now)
|
|
{
|
|
if (thread == null
|
|
|| (!thread.IsOpen
|
|
&& !(thread.Status == NarrativeThreadStatus.Resolved && now - thread.UpdatedAt < 20f)))
|
|
{
|
|
return float.MinValue;
|
|
}
|
|
CharacterStoryState state = NarrativeStoryStore.Character(thread.ProtagonistId);
|
|
float potential = NarrativeStoryStore.EffectiveStoryPotential(state, now);
|
|
float freshness = Mathf.Max(0f, 8f - (now - thread.UpdatedAt) / 15f);
|
|
float payoff = thread.Phase == NarrativePhase.TurningPoint ? 8f
|
|
: thread.Phase == NarrativePhase.Outcome ? 7f
|
|
: thread.Phase == NarrativePhase.Consequence ? 6f : 0f;
|
|
float prefer = LifeSagaRoster.IsPrefer(thread.ProtagonistId) ? 8f : 0f;
|
|
float mc = LifeSagaRoster.IsMc(thread.ProtagonistId) ? 4f : 0f;
|
|
float repeat = ActiveEpisode != null && ActiveEpisode.ThreadId == thread.Id ? 2f : 0f;
|
|
return potential + thread.Momentum + thread.Urgency + thread.CoverageDebt
|
|
+ NarrativeExposureLedger.CharacterDebt(thread.ProtagonistId)
|
|
+ NarrativeExposureLedger.ThreadDebt(thread.Id)
|
|
+ freshness + payoff + prefer + mc + repeat;
|
|
}
|
|
|
|
private static NarrativeThread RetainOrChoose(
|
|
NarrativeThread active,
|
|
NarrativeThread best,
|
|
float bestScore,
|
|
float now)
|
|
{
|
|
if (active == null) return best;
|
|
if (best == null || best.Id == active.Id) return active;
|
|
float activeScore = Score(active, now);
|
|
bool settled = ActiveEpisode != null && ActiveEpisode.GoalMet
|
|
&& now - ActiveEpisode.LastProgressAt >= PayoffSettleSeconds;
|
|
bool stale = ActiveEpisode != null
|
|
&& now - ActiveEpisode.LastProgressAt >= EpisodeNoProgressSeconds;
|
|
return settled || stale || bestScore >= activeScore + 20f ? best : active;
|
|
}
|
|
|
|
private static bool EpisodeStillValid(EpisodePlan episode, NarrativeThread thread, float now)
|
|
{
|
|
bool recentResolution = thread != null
|
|
&& thread.Status == NarrativeThreadStatus.Resolved
|
|
&& now - thread.UpdatedAt < 20f;
|
|
if (episode == null || thread == null || (!thread.IsOpen && !recentResolution)) return false;
|
|
if (now - episode.StartedAt >= EpisodeMaxSeconds) return false;
|
|
if (episode.GoalMet && now - episode.LastProgressAt >= PayoffSettleSeconds) return false;
|
|
return now - episode.LastProgressAt < EpisodeNoProgressSeconds
|
|
|| thread.LatestMeaningfulChangeId != episode.EntryDevelopmentId;
|
|
}
|
|
|
|
private static EpisodePlan BuildEpisode(NarrativeThread thread, float now)
|
|
{
|
|
var episode = new EpisodePlan
|
|
{
|
|
ThreadId = thread.Id,
|
|
ProtagonistId = thread.ProtagonistId,
|
|
EntryDevelopmentId = thread.LatestMeaningfulChangeId,
|
|
Purpose = PurposeFor(thread),
|
|
InformationGoal = thread.CentralQuestion ?? "",
|
|
StartedAt = now,
|
|
LastProgressAt = now
|
|
};
|
|
AddPendingForPurpose(episode, episode.Purpose);
|
|
for (int i = 0; i < thread.Cast.Count; i++)
|
|
{
|
|
NarrativeCastRole cast = thread.Cast[i];
|
|
if (cast != null && cast.CharacterId != 0) episode.EligibleCastIds.Add(cast.CharacterId);
|
|
}
|
|
|
|
return episode;
|
|
}
|
|
|
|
private static void AddPendingForPurpose(EpisodePlan episode, EpisodePurpose purpose)
|
|
{
|
|
if (episode == null) return;
|
|
void Add(EpisodeShotRole role)
|
|
{
|
|
if (!episode.CoveredRoles.Contains(role) && !episode.PendingRoles.Contains(role))
|
|
episode.PendingRoles.Add(role);
|
|
}
|
|
|
|
switch (purpose)
|
|
{
|
|
case EpisodePurpose.Establish:
|
|
Add(EpisodeShotRole.Establish);
|
|
Add(EpisodeShotRole.Development);
|
|
break;
|
|
case EpisodePurpose.Payoff:
|
|
Add(EpisodeShotRole.TurningPoint);
|
|
Add(EpisodeShotRole.Payoff);
|
|
break;
|
|
case EpisodePurpose.Consequence:
|
|
Add(EpisodeShotRole.Consequence);
|
|
break;
|
|
case EpisodePurpose.Reintroduce:
|
|
Add(EpisodeShotRole.Reintroduction);
|
|
Add(EpisodeShotRole.Development);
|
|
break;
|
|
default:
|
|
Add(EpisodeShotRole.Development);
|
|
Add(EpisodeShotRole.Pressure);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static bool IsCombatShot(InterestCandidate candidate)
|
|
{
|
|
return candidate != null
|
|
&& (candidate.Completion == InterestCompletionKind.CombatActive
|
|
|| string.Equals(candidate.AssetId, "live_combat", System.StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(candidate.AssetId, "live_battle", System.StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
internal static string SemanticFamily(InterestCandidate candidate)
|
|
{
|
|
if (candidate == null) return "";
|
|
NarrativeDevelopmentKind kind =
|
|
candidate.NarrativePresentation?.DevelopmentKind
|
|
?? NarrativeDevelopmentKind.Unknown;
|
|
if (kind != NarrativeDevelopmentKind.Unknown) return kind.ToString();
|
|
if (!string.IsNullOrEmpty(candidate.HappinessEffectId))
|
|
return candidate.HappinessEffectId;
|
|
if (!string.IsNullOrEmpty(candidate.Category))
|
|
return candidate.Category;
|
|
return candidate.AssetId ?? "";
|
|
}
|
|
|
|
private static EpisodePurpose PurposeFor(NarrativeThread thread)
|
|
{
|
|
if (thread == null) return EpisodePurpose.Develop;
|
|
switch (thread.Phase)
|
|
{
|
|
case NarrativePhase.Setup: return EpisodePurpose.Establish;
|
|
case NarrativePhase.TurningPoint:
|
|
case NarrativePhase.Outcome: return EpisodePurpose.Payoff;
|
|
case NarrativePhase.Consequence: return EpisodePurpose.Consequence;
|
|
default: return EpisodePurpose.Develop;
|
|
}
|
|
}
|
|
}
|