--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c @@ -85,10 +85,8 @@ int target = Cursor + 1; if (Sending) { - if (now < Due) - return 0; - // A transmission takes two seconds. The sender must keep its physical - // radio throughout that interval, including the final manpack-to-HQ leg. + // A transmission takes two seconds. Validate both endpoints on every + // observed tick; only delivery logging and cursor advancement wait for Due. if (Cursor >= 0 && !Nodes[Cursor].Valid(!Downlink && Cursor == Nodes.Count() - 1)) return Fail(journal, "transmitting carrier lost radio during transmission"); if (target < Nodes.Count()) @@ -97,6 +95,11 @@ return Fail(journal, "receiving carrier lost radio, died or changed ownership"); if (Cursor >= 0 && vector.DistanceXZ(SentFrom, Nodes[target].Carrier.GetOrigin()) > 500) return Fail(journal, "receiver moved beyond the 500m link"); + } + if (now < Due) + return 0; + if (target < Nodes.Count()) + { journal.Write("RECEIVE", Nodes[target].Label() + " received from " + SenderLabel(), MessageId); } else --- a/tests/hq_network_regression.py +++ b/tests/hq_network_regression.py @@ -89,6 +89,64 @@ assert(down->Advance(2000,&journal)==0);assert(down->Advance(4000,&journal)==0);assert(down->Advance(6000,&journal)==1); assert(!down->Originated&&down->OriginDue==-1);for(int j=beforeDown;jAdvance(0,&journal);c[0].captured=true;assert(captured->Advance(2000,&journal)==-1);c[0].captured=false; + // The sender must stay valid on EVERY observed tick of the two-second hop, + // not only at completion. Recovery before Due cannot revive an interrupted hop. + for(bool downlink:{false,true})for(int reason=0;reason<3;reason++){ + auto interrupted=network.Route(downlink?&c[1]:&c[0],downlink);ERR_HQJournal log; + assert(interrupted->Advance(0,&log)==0); + float sendingAt=3000; + if(downlink)sendingAt=2000; + assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending); + if(reason==0)c[0].alive=false; + if(reason==1)c[0].captured=true; + if(reason==2)c[0].radio=0; + assert(interrupted->Advance(sendingAt+1000,&log)==-1); + assert(interrupted->Failed&&log.events.back().find("LOST ")==0); + int terminalEvents=log.events.Count(); + c[0].alive=true;c[0].captured=false;c[0].radio=2; + assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents); + } + // Receiver interruption and leaving/re-entering the link also cancel the + // same physical short-range hop in either direction, before its due time. + for(bool downlink:{false,true})for(int reason=0;reason<4;reason++){ + auto interrupted=network.Route(downlink?&c[1]:&c[2],downlink);ERR_HQJournal log; + assert(interrupted->Advance(0,&log)==0); + float sendingAt=downlink?2000:3000; + assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending); + assert(interrupted->Nodes[interrupted->Cursor+1]->Carrier==&c[1]); + if(reason==0)c[1].alive=false; + if(reason==1)c[1].captured=true; + if(reason==2)c[1].radio=0; + if(reason==3)c[1].pos=V(3000); + assert(interrupted->Advance(sendingAt+1000,&log)==-1); + int terminalEvents=log.events.Count(); + c[1].alive=true;c[1].captured=false;c[1].radio=1;c[1].pos=V(500); + assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents); + } + // HQ's first downlink leg has no physical sender; its original receiving + // manpack must still survive while waiting, rather than use a late recovery. + auto firstDown=network.Route(&c[1],true);ERR_HQJournal firstDownLog; + assert(firstDown->Advance(0,&firstDownLog)==0&&firstDown->Cursor==-1); + c[0].radio=1;assert(firstDown->Advance(1000,&firstDownLog)==-1); + c[0].radio=2;assert(firstDown->Advance(2000,&firstDownLog)==-1); + // Healthy receiver-bearing hops keep the cursor and RECEIVE log unchanged + // during intermediate polling, with both 500m boundary links still valid. + for(bool downlink:{false,true}){ + auto healthy=network.Route(downlink?&c[1]:&c[2],downlink);ERR_HQJournal log; + assert(healthy->Advance(0,&log)==0); + float sendingAt=downlink?2000:3000; + assert(healthy->Advance(sendingAt,&log)==0&&healthy->Sending); + int cursor=healthy->Cursor,events=log.events.Count(); + assert(healthy->Advance(sendingAt+1000,&log)==0&&healthy->Cursor==cursor&&log.events.Count()==events); + assert(healthy->Advance(sendingAt+2000,&log)==(downlink?1:0)&&healthy->Cursor==cursor+1); + } + // Healthy intermediate polling does not transmit early or duplicate log entries. + auto waiting=network.Route(&c[0]);ERR_HQJournal waitingLog; + assert(waiting->Advance(0,&waitingLog)==0&&waiting->Advance(3000,&waitingLog)==0); + int transmittingEvents=waitingLog.events.Count(); + assert(waiting->Advance(4000,&waitingLog)==0&&waiting->Sending); + assert(waitingLog.events.Count()==transmittingEvents); + assert(waiting->Advance(5000,&waitingLog)==1&&waitingLog.events.Count()==transmittingEvents+1); auto timeout=network.Route(&c[2]);timeout->Advance(0,&journal);assert(timeout->Advance(30001,&journal)==-1); Network disconnected;disconnected.Add(&c[1],&group,1,1);disconnected.Build();assert(!disconnected.Route(&c[1])); Network boundary;c[1].pos=V(501);boundary.Add(&c[0],&group,1,2);boundary.Add(&c[1],&group,2,1);boundary.Build();assert(!boundary.Route(&c[1]));