From 87d85ff02e9bc131bdf88b710e164c6b41134e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Tue, 20 Oct 2026 16:13:00 +0200 Subject: [PATCH] Preserve stolen intel evidence and safely clean up spent folders --- CHANGELOG.md | 6 + .../Game/ERR/00_Core/ERR_DiscoveryService.c | 109 +++++++++++++++--- tests/test_intel_body_inspection.py | 4 + tests/test_intel_cleanup.py | 106 +++++++++++++++++ .../test_mine_intel_discovery_integration.py | 16 ++- tests/test_progression_identity.py | 2 +- tools/check.py | 1 + 7 files changed, 221 insertions(+), 23 deletions(-) create mode 100644 tests/test_intel_cleanup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b9fe8fc..0466336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Latest published Workshop version: **0.0.115** +## 2026-10-20 — Safe intel cleanup + +- Removing used intel folders preserves enemies' ability to discover stolen documents. +- Used ordinary folders are cleaned up even when no new locations remain to reveal. +- Failed inventory cleanup retries safely without granting extra intel or deleting objective documents. + ## 2026-10-20 — Hunter recovery and intel cleanup - Hunters with a blocked driver seat or failed reboarding can abandon the vehicle and continue on foot. diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c index b1c72ab..68d4914 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c @@ -1,9 +1,34 @@ // Base mission discovery: recovering physical documents works without companions. // The Intel addon optionally assigns extra documents to enemy inventories. +class ERR_IntelDeleteCallback : ScriptedInventoryOperationCallback +{ + bool Finished; + bool Succeeded; + protected void Finish(bool success) { if (!Finished) { Finished = true; Succeeded = success; } } + protected override void OnComplete() { Finish(true); } + protected override void OnFailed() { Finish(false); } + protected override void OnAllComplete() { Finish(true); } + protected override void OnAllFailed() { Finish(false); } +} + +class ERR_SpentIntelCleanup +{ + IEntity Folder; + ref ERR_IntelDeleteCallback Operation; + int Attempts; + float NextAttempt; + float Deadline; + bool TimeoutReported; +} + class ERR_DiscoveryService { protected ERR_GameMode m_Mode; protected ref set m_Spent = new set(); + protected ref array m_Cleanup = {}; + protected int m_CleanupCursor; + protected float m_CleanupNow; + protected bool m_Stopped; void ERR_DiscoveryService(ERR_GameMode mode) { m_Mode = mode; } // Shared ledger for recovered and administratively retired documents. bool IsSpent(IEntity folder) { return folder && m_Spent.Contains(folder); } @@ -37,14 +62,74 @@ class ERR_DiscoveryService void Tick(float now) { - if (!Replication.IsServer()) return; + if (!Replication.IsServer() || m_Stopped || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return; array players = {}; ERR_Actors.Players(players); + m_CleanupNow = now; foreach (IEntity player : players) Recover(player); + // Pending callbacks are observed only; even a missing callback never + // permits a second operation against the same physical item. + int budget = Math.Min(8, m_Cleanup.Count()); + while (budget-- > 0) + { + if (m_CleanupCursor >= m_Cleanup.Count()) m_CleanupCursor = 0; + if (ProcessCleanup(m_Cleanup[m_CleanupCursor], players, now)) m_Cleanup.Remove(m_CleanupCursor); + else m_CleanupCursor++; + } + } + protected void QueueCleanup(IEntity folder, float now) + { + if (!folder || folder.FindComponent(ERR_DocumentComponent)) return; + // One request per newly spent folder; admission shares the spent-ledger + // bound. Administrative retirement does not enter this queue. + ERR_SpentIntelCleanup request = new ERR_SpentIntelCleanup(); + request.Folder = folder; + request.Deadline = now + 60000; + m_Cleanup.Insert(request); + } + protected bool ProcessCleanup(ERR_SpentIntelCleanup request, array players, float now) + { + if (!request.Folder) return true; + if (request.Operation) + { + if (!request.Operation.Finished) + { + if (now >= request.Deadline && !request.TimeoutReported) + { + request.TimeoutReported = true; + Print("Reintroduced [intel]: spent-folder deletion still pending; no duplicate request", LogLevel.WARNING); + } + return false; + } + if (request.Operation.Succeeded) return true; + request.Operation = null; + request.NextAttempt = now + 5000; + } + if (now >= request.Deadline || request.Attempts >= 3) + { + Print("Reintroduced [intel]: spent-folder cleanup expired; item retained without granting more intel", LogLevel.WARNING); + return true; + } + if (request.Folder.FindComponent(ERR_DocumentComponent)) return true; + if (now < request.NextAttempt) return false; + foreach (IEntity player : players) + { + if (!m_Mode.Players() || !m_Mode.Players().CurrentActor(player) || !ERR_Actors.Alive(player)) continue; + if (!m_Mode.PlayerFaction() || SCR_FactionManager.SGetFaction(player) != m_Mode.PlayerFaction()) continue; + if (!ERR_InfantryAmmoTransfer.StoredUnder(request.Folder, player)) continue; + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(player.FindComponent(InventoryStorageManagerComponent)); + if (!inventory || !inventory.Contains(request.Folder)) continue; + request.Attempts++; + request.NextAttempt = now + 5000; + request.Operation = new ERR_IntelDeleteCallback(); + if (!inventory.TryDeleteItem(request.Folder, request.Operation)) request.Operation = null; + return false; + } + return false; } protected void Recover(IEntity player) { - if (!Replication.IsServer() || !m_Mode || !m_Mode.Players() || !m_Mode.Players().CurrentActor(player) || !ERR_Actors.Alive(player)) return; + if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Players() || !m_Mode.Players().CurrentActor(player) || !ERR_Actors.Alive(player)) return; if (!m_Mode.PlayerFaction() || SCR_FactionManager.SGetFaction(player) != m_Mode.PlayerFaction()) return; InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(player.FindComponent(InventoryStorageManagerComponent)); if (!inventory) return; @@ -72,7 +157,11 @@ class ERR_DiscoveryService } if (eligible.IsEmpty()) { - if (!document && !m_Mode.WorldPreparing() && MarkSpent(folder) && !mineRecovered) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty()); + if (!document && !m_Mode.WorldPreparing() && MarkSpent(folder)) + { + QueueCleanup(folder, m_CleanupNow); + if (!mineRecovered) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty()); + } continue; } ERR_LocationComponent chosen = eligible.GetRandomElement(); @@ -80,17 +169,11 @@ class ERR_DiscoveryService m_Spent.Insert(folder); PrintFormat("Reintroduced [intel]: player %1 recovered folder; location=%2 knowledge=%3", GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(player), chosen.Id(), chosen.Knowledge()); - // Delete intel folder :) - if (!document) - { - m_Mode.Evidence().Retire(folder); - SCR_InventoryStorageManagerComponent scrInventory = SCR_InventoryStorageManagerComponent.Cast(inventory); - bool accepted = scrInventory && scrInventory.TryDeleteItem(folder); - PrintFormat("Reintroduced [intel]: folder delete accepted=%1", accepted); - if (!accepted) SCR_EntityHelper.DeleteEntityAndChildren(folder); - } + // Preserve issued evidence: a later corpse inspection must still + // report this site's missing document after the physical item is gone. + if (!document) QueueCleanup(folder, m_CleanupNow); } } - void Stop() { m_Spent.Clear(); } + void Stop() { m_Stopped = true; m_Cleanup.Clear(); m_Spent.Clear(); } } diff --git a/tests/test_intel_body_inspection.py b/tests/test_intel_body_inspection.py index 70cdb59..845f45a 100644 --- a/tests/test_intel_body_inspection.py +++ b/tests/test_intel_body_inspection.py @@ -63,6 +63,10 @@ int main(){ ERR_IssuedIntel a{&present,"radio-1"},b{&missing,"mortar-3"},c{nullptr,"depot-1"};ERR_BodyEvidence record{&body,{&a,&b,&c},false};Evidence service;service.record=&record;Array result; observer.position={3,0,0};assert(service.Inspect(&body,&observer,result));assert(result.size()==2&&result.Contains("mortar-3")&&result.Contains("depot-1")&&!result.Contains("radio-1")); assert(!service.Inspect(&body,&observer,result)); + // Engine deletion clears the weak entity reference, not the issued site. + // A consumed folder must remain reportable as missing after physical cleanup. + record.Searched=false;b.Folder=nullptr; + assert(service.Inspect(&body,&observer,result)&&result.Contains("mortar-3")); Entity wreck,seat,unrelated;body.parent=&seat;seat.parent=&wreck;service.m_Roster={&record}; assert(service.RetainedWithin(&body)&&service.RetainedWithin(&seat)&&service.RetainedWithin(&wreck)); assert(!service.RetainedWithin(&unrelated)&&!service.RetainedWithin(nullptr)); diff --git a/tests/test_intel_cleanup.py b/tests/test_intel_cleanup.py new file mode 100644 index 0000000..9ccbdc3 --- /dev/null +++ b/tests/test_intel_cleanup.py @@ -0,0 +1,106 @@ +"""Execute production spent-folder cleanup against explicit native API doubles.""" +import re +from policy_harness import ROOT, body, run + +p = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c' +callback = body(p, 'class ERR_IntelDeleteCallback') +callback = callback.replace('class ERR_IntelDeleteCallback : ScriptedInventoryOperationCallback', 'struct ERR_IntelDeleteCallback') +callback = callback.replace('protected ', '').replace('override ', '').replace('bool Finished;', 'bool Finished=false;').replace('bool Succeeded;', 'bool Succeeded=false;') +code = '\n'.join(body(p, s) for s in ('protected void QueueCleanup(', 'protected bool ProcessCleanup(', 'void Tick(float now)', 'void Stop()')) +code = code.replace('protected ', '').replace('foreach (', 'for (').replace('null', 'nullptr').replace('array', 'Array') +for typ in ('ERR_SpentIntelCleanup', 'InventoryStorageManagerComponent'): + code = re.sub(r'\b' + typ + r' (\w+)', typ + r'* \1', code) +for ns in ('ERR_GameMode', 'Replication', 'ERR_Actors', 'SCR_FactionManager', 'ERR_InfantryAmmoTransfer', 'InventoryStorageManagerComponent', 'Math', 'LogLevel'): + code = code.replace(ns + '.', ns + '::') +for obj in ('request', 'player', 'folder', 'inventory', 'm_Mode'): + code = re.sub(r'\b' + obj + r'\.', obj + '->', code) +code = code.replace('Folder.', 'Folder->').replace('Operation.', 'Operation->').replace('Players().', 'Players()->') +code = re.sub(r'FindComponent\((\w+)\)', r'FindComponent("\1")', code) +run(r''' +#include +#include +#include +#include +templatestruct Array:std::vector{using std::vector::vector;void Insert(T v){this->push_back(v);}int Count(){return this->size();}void Clear(){this->clear();}void Remove(int i){this->erase(this->begin()+i);}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}}; +''' + callback + r'''; +struct Entity;using IEntity=Entity*; +struct InventoryStorageManagerComponent{ + Arrayitems;bool accept=true;int calls=0,immediate=0;ERR_IntelDeleteCallback*last=nullptr; + static InventoryStorageManagerComponent*Cast(void*p){return (InventoryStorageManagerComponent*)p;} + bool Contains(IEntity item){return items.Contains(item);} + bool TryDeleteItem(IEntity item,ERR_IntelDeleteCallback*cb){assert(Contains(item));assert(!last||last->Finished);calls++;if(!accept)return false;last=cb;if(immediate==1)cb->OnComplete();if(immediate==2)cb->OnFailed();return true;} +}; +struct Entity{bool alive=true,canonical=true,objective=false;int faction=1;IEntity holder=nullptr;InventoryStorageManagerComponent*inventory=nullptr;void*FindComponent(std::string type){if(type=="ERR_DocumentComponent")return objective?this:nullptr;return inventory;}}; +struct Roster{IEntity CurrentActor(IEntity p){return p&&p->canonical?p:nullptr;}}; +struct ERR_GameMode{inline static ERR_GameMode*current=nullptr;Roster roster;bool active=true;int faction=1;static ERR_GameMode*Get(){return current;}bool Active(){return active;}Roster*Players(){return &roster;}int PlayerFaction(){return faction;}}; +namespace Replication{bool server=true;bool IsServer(){return server;}} +namespace ERR_Actors{Arrayplayers;void Players(Array&out){out=players;}bool Alive(IEntity p){return p&&p->alive;}} +namespace SCR_FactionManager{int SGetFaction(IEntity p){return p->faction;}} +namespace ERR_InfantryAmmoTransfer{bool StoredUnder(IEntity item,IEntity player){return item->holder==player;}} +namespace Math{int Min(int a,int b){return std::min(a,b);}} +namespace LogLevel{int WARNING=1;}int warnings=0;void Print(std::string,int){warnings++;} +struct ERR_SpentIntelCleanup{IEntity Folder=nullptr;ERR_IntelDeleteCallback*Operation=nullptr;int Attempts=0;float NextAttempt=0,Deadline=0;bool TimeoutReported=false;}; +struct Service{ERR_GameMode*m_Mode=nullptr;Arraym_Cleanup;Arraym_Spent;int m_CleanupCursor=0,recovers=0;float m_CleanupNow=0;bool m_Stopped=false;void Recover(IEntity){recovers++;} +''' + code + r''' +}; +struct Fixture{ERR_GameMode mode;Service service;Entity player,folder;InventoryStorageManagerComponent inventory; + Fixture(){Replication::server=true;ERR_GameMode::current=&mode;service.m_Mode=&mode;player.inventory=&inventory;folder.holder=&player;inventory.items.Insert(&folder);ERR_Actors::players={&player};service.QueueCleanup(&folder,1000);} + ERR_SpentIntelCleanup*job(){return service.m_Cleanup[0];} +}; +int main(){ + // Callback terminal state cannot be changed by duplicate/late notifications. + ERR_IntelDeleteCallback cb;cb.OnComplete();cb.OnAllFailed();assert(cb.Finished&&cb.Succeeded); + ERR_IntelDeleteCallback failed;failed.OnFailed();failed.OnAllComplete();assert(failed.Finished&&!failed.Succeeded); + {Fixture f;f.service.Tick(1000);auto cb=f.inventory.last;assert(f.inventory.calls==1&&f.job()->Attempts==1); + f.service.Tick(6000);assert(f.inventory.calls==1); // one outstanding native operation + cb->OnComplete();f.service.Tick(7000);assert(f.service.m_Cleanup.empty()&&f.inventory.calls==1);} + // Rejection is retried slowly and stops after three requests; no force deletion. + {Fixture f;f.inventory.accept=false;f.service.Tick(1000);f.service.Tick(5999);assert(f.inventory.calls==1); + f.service.Tick(6000);assert(f.inventory.calls==2);f.service.Tick(11000);assert(f.inventory.calls==3); + f.service.Tick(11001);assert(f.service.m_Cleanup.empty());} + // An accepted request can fail later; only a definitive callback permits retry. + {Fixture f;f.service.Tick(1000);f.inventory.last->OnFailed();f.service.Tick(2000); + f.service.Tick(6999);assert(f.inventory.calls==1);f.service.Tick(7000);assert(f.inventory.calls==2); + f.inventory.last->OnComplete();f.service.Tick(8000);assert(f.service.m_Cleanup.empty());} + // A missing callback stays quarantined after deadline and logs only once. + {Fixture f;f.service.Tick(1000);int before=warnings;f.service.Tick(61000);f.service.Tick(100000); + assert(f.inventory.calls==1&&warnings==before+1&&!f.service.m_Cleanup.empty()); + f.inventory.last->OnFailed();f.service.Tick(101000);assert(f.service.m_Cleanup.empty()&&f.inventory.calls==1);} + // Deleted native entity completes without dereferencing a stale item. + {Fixture f;f.job()->Folder=nullptr;f.service.Tick(1000);assert(f.service.m_Cleanup.empty()&&f.inventory.calls==0);} + // Moving a folder never authorizes removal from a corpse, NPC, or ground. + for(int fault=0;fault<6;fault++){Fixture f; + if(fault==0)f.folder.holder=nullptr; + if(fault==1)f.player.alive=false; + if(fault==2)f.player.canonical=false; + if(fault==3)f.player.faction=2; + if(fault==4)f.inventory.items.Clear(); + if(fault==5)f.player.inventory=nullptr; + f.service.Tick(1000);assert(f.inventory.calls==0);f.service.Tick(61000);assert(f.service.m_Cleanup.empty());} + {Fixture f;Entity next;InventoryStorageManagerComponent other;next.inventory=&other;other.items.Insert(&f.folder); + f.folder.holder=&next;f.inventory.items.Clear();ERR_Actors::players.Insert(&next);f.service.Tick(1000); + assert(f.inventory.calls==0&&other.calls==1);} + // Objective documents never enter the queue, even if a caller asks directly. + {Fixture f;Entity objective;objective.objective=true;f.service.QueueCleanup(&objective,1000);assert(f.service.m_Cleanup.size()==1); + f.folder.objective=true;f.service.Tick(1000);assert(f.inventory.calls==0&&f.service.m_Cleanup.empty());} + // Mode replacement, client side, shutdown and inactive rounds cannot mutate inventory. + for(int fault=0;fault<4;fault++){Fixture f; + if(fault==0)Replication::server=false; + if(fault==1)ERR_GameMode::current=nullptr; + if(fault==2)f.mode.active=false; + if(fault==3)f.service.Stop(); + f.service.Tick(1000);assert(f.inventory.calls==0&&f.service.recovers==0);} + {Fixture f;f.service.Tick(1000);auto late=f.inventory.last;f.service.Stop();late->OnComplete();f.service.Tick(61000); + assert(f.inventory.calls==1&&f.service.m_Cleanup.empty());} + // Synchronous completion is accepted; the next tick retires the request. + {Fixture f;f.inventory.immediate=1;f.service.Tick(1000);f.service.Tick(2000);assert(f.service.m_Cleanup.empty()&&f.inventory.calls==1);} + // Round-robin tick budget stays bounded and does not starve later requests. + {Fixture f;Entity folders[9];for(auto& item:folders){item.holder=&f.player;f.inventory.items.Insert(&item);f.service.QueueCleanup(&item,1000);} + f.inventory.accept=false;f.service.Tick(1000);assert(f.inventory.calls==8);f.service.Tick(1001);assert(f.inventory.calls==10);} +} +''') + +source = p.read_text() +assert 'Evidence().Retire(' not in source and 'DeleteEntityAndChildren' not in source +assert 'TryDeleteItem(request.Folder, request.Operation)' in source +print('PASS: native-callback cleanup, bounded retries and budget, pending quarantine, objective/ownership/lifecycle protection, no evidence retirement or forced entity deletion') diff --git a/tests/test_mine_intel_discovery_integration.py b/tests/test_mine_intel_discovery_integration.py index a3a6aa9..bf56f05 100644 --- a/tests/test_mine_intel_discovery_integration.py +++ b/tests/test_mine_intel_discovery_integration.py @@ -45,7 +45,7 @@ struct Mode{bool worldPreparing=false;bool WorldPreparing(){return worldPreparin Roster*Players(){return &roster;}Faction*PlayerFaction(){return faction;}struct Evidence*Evidence(){return &evidence;}struct Settings*Settings(){return &settings;}struct MineIntel*MineIntel(){return haveMine?&mine:nullptr;}void Locations(Array&out){out=locations;}void ERR_IntelExhausted(bool){exhausted++;}}; struct PlayerManager{int GetPlayerIdFromControlledEntity(IEntity){return 1;}};struct Game{PlayerManager manager;PlayerManager*GetPlayerManager(){return &manager;}}game;Game*GetGame(){return &game;} templatevoid PrintFormat(T...){} -struct Discovery{Mode*m_Mode=nullptr;Arraym_Spent;void Folders(InventoryStorageManagerComponent*i,Array&out){out=i->folders;}bool MarkSpent(IEntity e){if(!m_Spent.Contains(e))m_Spent.Insert(e);return true;} +struct Discovery{bool m_Stopped=false;float m_CleanupNow=0;Mode*m_Mode=nullptr;Arraym_Spent,cleanup;void QueueCleanup(IEntity item,float){cleanup.Insert(item);}void Folders(InventoryStorageManagerComponent*i,Array&out){out=i->folders;}bool MarkSpent(IEntity e){if(!m_Spent.Contains(e))m_Spent.Insert(e);return true;} '''+s+r''' }; struct Fixture{Faction friendly,foreign;Mode mode;Discovery discovery;Entity player,second,folder;SCR_InventoryStorageManagerComponent firstInv,secondInv;ERR_DocumentComponent objective;ERR_LocationComponent radio,other; @@ -56,19 +56,15 @@ int main(){ // advances ordinary discovery once across transfer between two valid players. Fixture f;f.objective.target="radio";f.folder.document=&f.objective; f.discovery.Recover(&f.player);assert(f.radio.knowledge==1&&f.other.knowledge==0&&f.discovery.m_Spent.Contains(&f.folder)); - assert(f.firstInv.deleteCalls==0&&!f.folder.deleted&&!f.mode.evidence.retired); // objective stays physical + assert(f.discovery.cleanup.empty()&&!f.mode.evidence.retired); // objective stays physical assert(f.mode.mine.calls==1&&f.mode.mine.lastPlayer==&f.player&&f.mode.mine.recovered[0]==&f.folder); f.firstInv.folders.clear();f.secondInv.folders.Insert(&f.folder);f.discovery.Recover(&f.second);assert(f.radio.calls==1&&f.other.calls==0&&f.mode.mine.calls==1); // Existing sealed generic-folder target survives the supplemental hook. Fixture sealed;sealed.mode.evidence.sealed=&sealed.folder;sealed.mode.evidence.target="radio";sealed.discovery.Recover(&sealed.player);assert(sealed.radio.knowledge==1&&sealed.other.knowledge==0); - assert(sealed.firstInv.deleted==&sealed.folder&&sealed.mode.evidence.retired==&sealed.folder&&!sealed.folder.deleted); - sealed.discovery.Recover(&sealed.player);assert(sealed.firstInv.deleteCalls==1); // never consume twice - Fixture refusal;refusal.firstInv.acceptDelete=false;refusal.discovery.Recover(&refusal.player); - assert(refusal.firstInv.deleteCalls==1&&refusal.folder.deleted&&refusal.mode.evidence.retired==&refusal.folder); - Fixture generic;InventoryStorageManagerComponent bare;bare.folders.Insert(&generic.folder);generic.player.inventory=&bare;generic.discovery.Recover(&generic.player); - assert(generic.folder.deleted&&generic.mode.evidence.retired==&generic.folder); // missing scripted manager fallback + assert(sealed.discovery.cleanup.size()==1&&sealed.discovery.cleanup[0]==&sealed.folder&&!sealed.mode.evidence.retired); + sealed.discovery.Recover(&sealed.player);assert(sealed.discovery.cleanup.size()==1); // never consume/queue twice Fixture failed;failed.other.allow=false;failed.discovery.Recover(&failed.player); - assert(failed.firstInv.deleteCalls==0&&!failed.folder.deleted&&!failed.mode.evidence.retired); // no clue, no deletion + assert(failed.discovery.cleanup.empty()&&!failed.mode.evidence.retired); // no clue, no deletion // Unknown authored target stays unspent; a supplement cannot consume it first. Fixture missing;missing.objective.target="absent";missing.folder.document=&missing.objective;missing.discovery.Recover(&missing.player);assert(missing.discovery.m_Spent.IsEmpty()&&missing.radio.calls==0&&missing.other.calls==0); // Failed site discovery also retains the original retry semantics. @@ -80,10 +76,12 @@ int main(){ assert(pending.radio.knowledge==1&&pending.discovery.m_Spent.Contains(&pending.folder)); // Generic exhausted clue retains the existing spent/exhaustion behavior. Fixture exhausted;exhausted.radio.knowledge=2;exhausted.other.knowledge=2;exhausted.discovery.Recover(&exhausted.player);assert(exhausted.discovery.m_Spent.Contains(&exhausted.folder)&&exhausted.mode.exhausted==1); + assert(exhausted.discovery.cleanup.size()==1&&!exhausted.mode.evidence.retired); // A newly published mine warning is useful even after site clues are exhausted. // Spend the original generic folder once without the contradictory exhaustion notice. Fixture mineOnly;mineOnly.mode.mine.newlyRecovered=true;mineOnly.radio.knowledge=2;mineOnly.other.knowledge=2; mineOnly.discovery.Recover(&mineOnly.player);assert(mineOnly.mode.mine.calls==1&&mineOnly.discovery.m_Spent.Contains(&mineOnly.folder)&&mineOnly.mode.exhausted==0); + assert(mineOnly.discovery.cleanup.size()==1&&!mineOnly.mode.evidence.retired); mineOnly.discovery.Recover(&mineOnly.player);assert(mineOnly.mode.mine.calls==1&&mineOnly.mode.exhausted==0); // New warning supplements, never replaces, an available site clue. Fixture both;both.mode.mine.newlyRecovered=true;both.discovery.Recover(&both.player);assert(both.other.knowledge==1&&both.discovery.m_Spent.Contains(&both.folder)&&both.mode.exhausted==0); diff --git a/tests/test_progression_identity.py b/tests/test_progression_identity.py index 94ee918..6a6157c 100644 --- a/tests/test_progression_identity.py +++ b/tests/test_progression_identity.py @@ -30,7 +30,7 @@ struct Mode { Point PrisonPosition(){return {0};}bool Transition(int phase){m_Phase=phase;transitions++;return true;} '''+cpp(unlock)+''' }; -struct Discovery {Mode* m_Mode=nullptr;int inventoryReads=0; +struct Discovery {Mode* m_Mode=nullptr;int inventoryReads=0;bool m_Stopped=false; '''+cpp(prefix)+''' }; int main(){ diff --git a/tools/check.py b/tools/check.py index 7d647bc..d2dd377 100644 --- a/tools/check.py +++ b/tools/check.py @@ -25,6 +25,7 @@ def check(): subprocess.run([sys.executable,str(ROOT/'tests/test_hunter_fallback.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_hunter_tactics.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_hunter_recovery_wait.py')],check=True) + subprocess.run([sys.executable,str(ROOT/'tests/test_intel_cleanup.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_hunter_gun_team.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_hunter_coordination.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_attack_coordination.py')],check=True)