Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions IntelligenceDossiers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ public sealed record PlayerIntelligenceAgentSnapshot(
bool IsUnderInvestigation)
{
public string DisplayName => $"Agent {EntityId} (Name ID {NameId})";
/// <summary>Routine facts learned by the player team through sourced observations.</summary>
public string? KnownResidence { get; init; }
public string? KnownWorkplace { get; init; }
public string? KnownOccupation { get; init; }
}

/// <summary>
Expand Down Expand Up @@ -53,8 +57,31 @@ private PlayerIntelligenceDB(
/// <summary>Sanitized operative schedules, assignments, and task reports.</summary>
public OperativeManagementProjection? OperativeManagement { get; private set; }

public void Apply(OperativeManagementProjection projection) =>
OperativeManagement = projection ?? throw new ArgumentNullException(nameof(projection));
public void Apply(OperativeManagementProjection projection)
{
ArgumentNullException.ThrowIfNull(projection);
foreach (var discovery in projection.NewRoutineDiscoveries)
{
if (discovery.DiscoveredValue is null) continue;
var index = FindIndex(discovery.SubjectAgentId);
if (index < 0) continue;

var previous = _agents[index];
var updated = discovery.Kind switch
{
"residence" => previous with { KnownResidence = discovery.DiscoveredValue },
"workplace" => previous with { KnownWorkplace = discovery.DiscoveredValue },
"occupation" => previous with { KnownOccupation = discovery.DiscoveredValue },
_ => previous
};
if (updated != previous)
{
_agents[index] = updated;
Diagnostics.IncrementalUpdates++;
}
}
OperativeManagement = projection;
}

public PlayerIntelligenceProjectionDiagnostics Diagnostics { get; }

Expand Down Expand Up @@ -376,6 +403,12 @@ private static void DrawDetails(
}
ImGui.Text($"Under investigation: {(agent.IsUnderInvestigation ? "Yes" : "No")}");

ImGui.Separator();
ImGui.Text("Known routine");
ImGui.BulletText($"Residence: {agent.KnownResidence ?? "Unknown"}");
ImGui.BulletText($"Workplace: {agent.KnownWorkplace ?? "Unknown"}");
ImGui.BulletText($"Occupation: {agent.KnownOccupation ?? "Unknown"}");

ImGui.Separator();
ImGui.Text("Known traits");
foreach (var trait in traits)
Expand Down
59 changes: 56 additions & 3 deletions Simulation/OperativeManagement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ public readonly record struct OperativeSnapshot(
int TargetAgentId, long TaskEndMinute);

public readonly record struct IntelligenceEvidence(
long Minute, int SourceOperativeId, int SubjectAgentId, string Kind, string Detail);
long Minute, int SourceOperativeId, int SubjectAgentId, string Kind, string Detail,
string? DiscoveredValue = null);

public sealed class IntelligenceAssessment
{
Expand Down Expand Up @@ -101,14 +102,19 @@ public static string Format(long minute)
public sealed class OperativeManagementProjection
{
public OperativeManagementProjection(IEnumerable<OperativeSnapshot> operatives,
IEnumerable<IntelligenceAssessment> reports)
IEnumerable<IntelligenceAssessment> reports,
IEnumerable<IntelligenceEvidence>? newRoutineDiscoveries = null)
{
Operatives = Array.AsReadOnly(operatives.ToArray());
Reports = Array.AsReadOnly(reports.ToArray());
NewRoutineDiscoveries = Array.AsReadOnly(
(newRoutineDiscoveries ?? Enumerable.Empty<IntelligenceEvidence>()).ToArray());
}

public IReadOnlyList<OperativeSnapshot> Operatives { get; }
public IReadOnlyList<IntelligenceAssessment> Reports { get; }
/// <summary>New copied facts for immediate application to player dossiers.</summary>
public IReadOnlyList<IntelligenceEvidence> NewRoutineDiscoveries { get; }
}

public sealed record IntelligenceTaskSettings(
Expand Down Expand Up @@ -144,6 +150,7 @@ public sealed class OperativeManagementSystem
private readonly Dictionary<int, Entity> _agents;
private readonly List<IntelligenceAssessment> _reports = [];
private readonly Dictionary<int, List<IntelligenceEvidence>> _taskEvidence = [];
private readonly List<IntelligenceEvidence> _pendingRoutineDiscoveries = [];
private readonly Dictionary<int, long> _lastObservationMinute = [];
private long _lastUpdatedMinute = -1;

Expand Down Expand Up @@ -182,8 +189,10 @@ public OperativeManagementProjection Capture(long minute)
identity.IntelligenceRole.ToString(), job, rota.WorkDaysMask, rota.WorkStartMinute,
rota.WorkEndMinute, task.Kind, task.TargetAgentId, task.EndsAtMinute);
}).ToArray();
var discoveries = _pendingRoutineDiscoveries.ToArray();
_pendingRoutineDiscoveries.Clear();
return new OperativeManagementProjection(roster,
_reports.OrderByDescending(report => report.Minute));
_reports.OrderByDescending(report => report.Minute), discoveries);
}

public bool SetRota(OperativeRotaCommand command, long currentMinute)
Expand Down Expand Up @@ -293,6 +302,7 @@ public void Update(double elapsedMinutes, long minute)
var location = _catalog.World.Locations.FirstOrDefault(item => item.Hash == locationId)?.Name ?? "unknown location";
evidence.Add(new IntelligenceEvidence(minute, operative.Id, target.Id, "sighting",
$"Seen at {location}."));
RecordRoutineDiscoveries(target, locationId, minute, operative.Id, evidence);
if (target.TryGetComponent<CoordinationState>(out var interaction) && interaction.Active &&
_agents.ContainsKey(interaction.PartnerEntityId))
evidence.Add(new IntelligenceEvidence(minute, operative.Id, target.Id, "interaction",
Expand Down Expand Up @@ -411,6 +421,49 @@ private void Finish(Entity operative, int targetId, long minute, string summary,
_reports.Add(new IntelligenceAssessment(minute, operative.Id, targetId, summary,
Math.Clamp(confidence, 0f, 1f), evidence));

/// <summary>
/// Converts an actual co-located sighting into routine intelligence. The
/// simulation may compare the observed location with the target's assigned
/// home and work nodes; only the resulting sourced evidence leaves ECS.
/// </summary>
private void RecordRoutineDiscoveries(Entity target, int observedLocationId, long minute,
int operativeId, List<IntelligenceEvidence> evidence)
{
var location = target.GetComponent<AgentLocation>();
if (location.HomeLocationId != 0 && observedLocationId == location.HomeLocationId)
{
var homeName = LocationName(location.HomeLocationId);
AddRoutineEvidence(evidence, new IntelligenceEvidence(minute, operativeId, target.Id,
"residence", $"Identified residence: {homeName}.", homeName));
}

if (location.WorkLocationId == 0 || observedLocationId != location.WorkLocationId) return;

var workplaceName = LocationName(location.WorkLocationId);
AddRoutineEvidence(evidence, new IntelligenceEvidence(minute, operativeId, target.Id,
"workplace", $"Identified workplace: {workplaceName}.", workplaceName));

var occupationId = target.GetComponent<Identity>().OccupationId;
var occupation = _catalog.Jobs.FirstOrDefault(job => job.Hash == occupationId);
if (occupation is not null)
AddRoutineEvidence(evidence, new IntelligenceEvidence(minute, operativeId, target.Id,
"occupation", $"Identified occupation: {occupation.Name}.", occupation.Name));
}

private string LocationName(int locationId) =>
_catalog.World.Locations.FirstOrDefault(item => item.Hash == locationId)?.Name ?? "unknown location";

private void AddRoutineEvidence(List<IntelligenceEvidence> evidence, IntelligenceEvidence discovery)
{
// A target can be seen at home or work many times during one follow;
// retain one copy of each discovered fact in that assignment's report.
if (!evidence.Any(item => item.Kind == discovery.Kind && item.Detail == discovery.Detail))
{
evidence.Add(discovery);
_pendingRoutineDiscoveries.Add(discovery);
}
}

private bool TryOperative(int id, out Entity entity) =>
_agents.TryGetValue(id, out entity) && !entity.IsNull && entity.Tags.Has<OperativeTag>();

Expand Down
8 changes: 7 additions & 1 deletion docs/coreecs.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,13 @@ An active task pauses the operative's normal decision, movement, coordination,
and activity effects. Its travel follows the selected target through the
world's shortest-time routes. Follow assignments capture periodic sightings
and observed coordination partners only when the operative and target share a
location. Talk assignments end after the configured interview window; access
location. A sighting at the target's assigned home records a sourced residence
discovery. A sighting at the assigned workplace records the workplace and, when
the occupation resolves to a configured job, its occupation. Repeated sightings
in one assignment do not duplicate the same discovery. New routine facts are
copied through the management projection into the target's dossier as soon as
they are identified, and are retained as sourced report evidence when the task
ends. Talk assignments end after the configured interview window; access
and operative charisma versus target willpower determine whether views are
recorded. Successful and failed outcomes both produce an assessment.

Expand Down
19 changes: 16 additions & 3 deletions docs/datastructs.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,10 @@ seeded runs reproduce the same team membership.

`PlayerIntelligenceAgentSnapshot` contains an agent ID, name ID, Operative
marker, intelligence role, team-known trait mask, and sanitized investigation
flag. It intentionally omits the ground-truth secret state and all LOD details.
flag, plus nullable residence, workplace, and occupation facts learned by the
team. Those routine fields remain unknown until an operative reports a sourced
observation. It intentionally omits the ground-truth secret state and all LOD
details.
`PlayerIntelligenceDB` owns a sorted snapshot array exposed through a read-only
view plus the selected Operative IDs. Its one-time creation boundary scans
outgoing edges whose source has `OperativeTag` and combines their known trait
Expand Down Expand Up @@ -412,9 +415,19 @@ simulation side. The UI never receives the component or an ECS entity.

`OperativeSnapshot`, `IntelligenceEvidence`, `IntelligenceAssessment`, and
`OperativeManagementProjection` are immutable copies. An evidence row records
the simulation minute, source operative, subject, observation kind, and detail.
the simulation minute, source operative, subject, observation kind, detail, and
an optional copied value for a newly identified routine fact. The projection
carries new routine discoveries separately so `PlayerIntelligenceDB` can update
the matching dossier without rescanning reports or querying ECS.
An assessment keeps its summary and confidence separate from its read-only
evidence collection. `PlayerIntelligenceDB.OperativeManagement` carries the
evidence collection. Follow evidence may include `residence`, `workplace`, and
`occupation` discoveries when the operative and subject are co-located at the
subject's assigned home or workplace. Each discovery carries the observation
minute and operative/subject IDs, and repeated observations of the same fact
within one assignment are deduplicated. Missing home/work assignments or an
unknown occupation produce no corresponding discovery. The projection carries
only copied evidence values; ImGui never reads the target's location or identity
components. `PlayerIntelligenceDB.OperativeManagement` carries the
latest roster and reports to the standalone windows. These contracts contain
no `Entity` references, and report evidence is produced only by task simulation.
`SimulationTimeFormatter` converts elapsed report and evidence minutes to a
Expand Down
Loading