diff --git a/Mimante/Models/AuctionInfo.cs b/Mimante/Models/AuctionInfo.cs
index 46e9f30..58c7e00 100644
--- a/Mimante/Models/AuctionInfo.cs
+++ b/Mimante/Models/AuctionInfo.cs
@@ -565,6 +565,47 @@ namespace AutoBidder.Models
///
[JsonIgnore]
public bool IsInSoftRetreat { get; set; } = false;
+
+ // ── Duello con l'autopuntata avversaria ──────────────────────────
+ // Vedi Utilities/AutoBidDuel per il perche' e per i numeri misurati.
+
+ /// Istante dell'ultima puntata nostra, per misurare quanto tarda la risposta.
+ [JsonIgnore]
+ public DateTime? LastMyBidAtUtc { get; set; }
+
+ ///
+ /// Risposte avversarie di fila con la firma dell'autopuntata (~6 s). Oltre la
+ /// soglia si smette di puntare su quest'asta: vedi .
+ ///
+ [JsonIgnore]
+ public int AutoResponsesInARow { get; set; }
+
+ /// Riconosciuta un'autopuntata armata dall'altra parte.
+ [JsonIgnore]
+ public bool AutoBidDuelDetected { get; set; }
+
+ ///
+ /// Registra la risposta avversaria arrivata adesso e aggiorna il conteggio.
+ /// Restituisce true se proprio con questa risposta il duello viene riconosciuto,
+ /// cosi' chi chiama puo' scriverlo nel registro una volta sola.
+ ///
+ public bool NoteOpponentResponse(DateTime nowUtc, int soglia)
+ {
+ if (LastMyBidAtUtc is not { } miaUltima) return false;
+
+ var ritardo = (nowUtc - miaUltima).TotalSeconds;
+
+ // Consumata: la prossima risposta va misurata dalla prossima puntata nostra.
+ LastMyBidAtUtc = null;
+
+ AutoResponsesInARow = Utilities.AutoBidDuel.Aggiorna(AutoResponsesInARow, ritardo);
+
+ if (AutoBidDuelDetected) return false;
+ if (!Utilities.AutoBidDuel.DuelloRiconosciuto(AutoResponsesInARow, soglia)) return false;
+
+ AutoBidDuelDetected = true;
+ return true;
+ }
///
/// Contatore puntate effettuate in questa sessione su questa asta
diff --git a/Mimante/Models/BidHistoryEntry.cs b/Mimante/Models/BidHistoryEntry.cs
index 72b8502..6fd6207 100644
--- a/Mimante/Models/BidHistoryEntry.cs
+++ b/Mimante/Models/BidHistoryEntry.cs
@@ -14,11 +14,37 @@ namespace AutoBidder.Models
[JsonPropertyName("Price")]
public decimal Price { get; set; }
+ /// Puntata fatta dall'autopuntata del sito: scatta a 2 secondi dalla fine.
+ public const string TipoAuto = "Auto";
+
+ /// Puntata fatta a mano. Vale 3,6 volte una automatica: vedi .
+ public const string TipoManuale = "Manuale";
+
///
- /// Tipo di puntata (Auto/Manuale)
+ /// Il tipo non lo ha detto il server.
+ ///
+ /// Serve perché non tutte le puntate in elenco vengono dallo storico di
+ /// data.php: quando il motore si accorge di un cambio di prezzo fra due
+ /// interrogazioni ne ricostruisce una da sé, e di quella il tipo non lo sa. Prima
+ /// venivano marcate "Auto" comunque, il che faceva contare come automatiche delle
+ /// puntate su cui nessuno aveva detto niente.
+ ///
+ public const string TipoSconosciuto = "—";
+
+ ///
+ /// Tipo di puntata: , oppure
+ /// . Lo dichiara Bidoo nello storico dell'asta.
///
[JsonPropertyName("BidType")]
- public string BidType { get; set; } = "Auto";
+ public string BidType { get; set; } = TipoSconosciuto;
+
+ /// Vero solo se il server ha davvero detto che era automatica.
+ [JsonIgnore]
+ public bool IsAuto => string.Equals(BidType, TipoAuto, StringComparison.OrdinalIgnoreCase);
+
+ /// Vero solo se il server ha davvero detto che era manuale.
+ [JsonIgnore]
+ public bool IsManual => string.Equals(BidType, TipoManuale, StringComparison.OrdinalIgnoreCase);
///
/// Timestamp della puntata (Unix timestamp)
@@ -55,7 +81,7 @@ namespace AutoBidder.Models
}
///
- /// Indica se la puntata è stata fatta dall'utente corrente
+ /// Indica se la puntata � stata fatta dall'utente corrente
///
[JsonIgnore]
public bool IsMyBid { get; set; }
diff --git a/Mimante/Services/AuctionMonitor.cs b/Mimante/Services/AuctionMonitor.cs
index a5067ed..b6e514b 100644
--- a/Mimante/Services/AuctionMonitor.cs
+++ b/Mimante/Services/AuctionMonitor.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
@@ -620,7 +620,7 @@ namespace AutoBidder.Services
Username = state.LastBidder,
Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
- BidType = "Auto"
+ BidType = BidHistoryEntry.TipoSconosciuto
});
}
}
@@ -1193,10 +1193,20 @@ namespace AutoBidder.Services
Username = currentBidder,
Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
- BidType = "Auto"
+ BidType = BidHistoryEntry.TipoSconosciuto
});
}
+ // Una puntata avversaria: se arriva col ritardo tipico dell'autopuntata
+ // (~6 s dalla nostra) alimenta il conteggio del duello.
+ var impostazioni = SettingsManager.Load();
+ if (impostazioni.AutoBidDuelWithdrawEnabled &&
+ auction.NoteOpponentResponse(DateTime.UtcNow, impostazioni.AutoBidDuelResponses))
+ {
+ auction.AddLog(AutoBidDuel.Spiegazione(auction.AutoResponsesInARow),
+ AuctionLogLevel.Warning, AuctionLogCategory.Strategy);
+ }
+
// I conteggi per utente restano a carico di UpdateBidderStatsFromRecentBids,
// unico punto che li tocca: contarli anche qui li raddoppierebbe.
}
diff --git a/Mimante/Services/BidStrategyService.cs b/Mimante/Services/BidStrategyService.cs
index c13f9f9..26bcf44 100644
--- a/Mimante/Services/BidStrategyService.cs
+++ b/Mimante/Services/BidStrategyService.cs
@@ -169,7 +169,27 @@ namespace AutoBidder.Services
{
return decision;
}
-
+
+ // 0. DUELLO CON L'AUTOPUNTATA — il primo controllo, perché è quello che
+ // costa di più sbagliare.
+ //
+ // L'autopuntata del sito scatta a 2 secondi e si riarma ogni volta che il suo
+ // padrone perde la testa: risponde sempre, risponde in ~6 secondi, e non si
+ // stanca. Contro di lei non esiste attrito che si possa vincere — si
+ // scambiano puntate una a una finché uno dei due esaurisce il credito.
+ //
+ // Nelle prove reali il motore ci ha lasciato 191 puntate su tre aste per zero
+ // vittorie, con un ciclo regolare da 13,0 secondi (7 di attesa nostra più 6
+ // della sua risposta). Rigiocando i dossier con questa regola, quelle aste
+ // sarebbero costate una manciata di puntate senza perdere nessuna vittoria,
+ // perché vittorie non ce n'erano.
+ if (settings.AutoBidDuelWithdrawEnabled && auction.AutoBidDuelDetected)
+ {
+ decision.ShouldBid = false;
+ decision.Reason = Utilities.AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
+ return decision;
+ }
+
// ? RIMOSSO: Entry Point - Era sbagliato!
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
// Se l'utente imposta MaxPrice=2�, vuole puntare FINO A 2�, non fino al 70%!
@@ -475,6 +495,11 @@ namespace AutoBidder.Services
{
auction.SuccessfulBidCount++;
auction.ConsecutiveCollisions = 0;
+
+ // Da qui si misura quanto tarda la risposta avversaria: e' l'unico punto
+ // attraversato sia dal motore dal vivo sia dalla rigiocata sui dossier,
+ // quindi la regola del duello vede le stesse cose in entrambi.
+ auction.LastMyBidAtUtc = DateTime.UtcNow;
}
else
{
diff --git a/Mimante/Tests/AutoBidDuelTests.cs b/Mimante/Tests/AutoBidDuelTests.cs
new file mode 100644
index 0000000..f9b0c9d
--- /dev/null
+++ b/Mimante/Tests/AutoBidDuelTests.cs
@@ -0,0 +1,136 @@
+using System;
+using AutoBidder.Models;
+using AutoBidder.Utilities;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+///
+/// Riconoscimento dell'autopuntata avversaria.
+///
+/// La proprieta' da difendere non e' "riconosce le macchine": e' che non si ritira
+/// da un'asta che si poteva vincere. Sbagliare in quel verso costa un'asta; sbagliare
+/// nell'altro costa cinque puntate. Da qui la scelta di azzerare il conteggio a ogni
+/// risposta fuori firma invece di scalarlo.
+///
+/// I numeri vengono dai dossier: 2.799.681 intervalli fra puntate consecutive in 5037
+/// aste, mediana 6,0 s, quartili 5,3 e 7,1 s.
+///
+public class AutoBidDuelTests
+{
+ [Theory]
+ [InlineData(6.0, true)] // la mediana misurata
+ [InlineData(5.3, true)] // primo quartile
+ [InlineData(7.1, true)] // terzo quartile
+ [InlineData(3.4, false)] // troppo svelta: qualcuno che stava guardando
+ [InlineData(13.0, false)] // troppo lenta
+ [InlineData(0.0, false)] // nessun ritardo: non e' una risposta
+ [InlineData(-2.0, false)] // prima della nostra puntata: non e' una risposta
+ public void La_firma_e_sei_secondi_piu_o_meno_due_e_mezzo(double ritardo, bool atteso)
+ {
+ Assert.Equal(atteso, AutoBidDuel.ERispostaAutomatica(ritardo));
+ }
+
+ [Fact]
+ public void Le_risposte_in_firma_si_sommano()
+ {
+ var n = 0;
+ for (var i = 0; i < 5; i++) n = AutoBidDuel.Aggiorna(n, 6.0);
+
+ Assert.Equal(5, n);
+ Assert.True(AutoBidDuel.DuelloRiconosciuto(n));
+ }
+
+ [Fact]
+ public void Una_risposta_fuori_firma_azzera_tutto()
+ {
+ // Il punto della regola: quattro indizi piu' uno contrario non fanno quattro
+ // indizi. Serve una sequenza pulita, perche' ritirarsi per sbaglio costa l'asta.
+ var n = 0;
+ for (var i = 0; i < 4; i++) n = AutoBidDuel.Aggiorna(n, 6.0);
+ Assert.Equal(4, n);
+
+ n = AutoBidDuel.Aggiorna(n, 2.0);
+
+ Assert.Equal(0, n);
+ Assert.False(AutoBidDuel.DuelloRiconosciuto(n));
+ }
+
+ [Fact]
+ public void Sotto_la_soglia_non_ci_si_ritira()
+ {
+ var n = 0;
+ for (var i = 0; i < 4; i++) n = AutoBidDuel.Aggiorna(n, 6.0);
+
+ Assert.False(AutoBidDuel.DuelloRiconosciuto(n));
+ }
+
+ // ── Lo stato sull'asta ───────────────────────────────────────────────
+
+ private static AuctionInfo Asta() => new() { AuctionId = "1", Name = "prova" };
+
+ [Fact]
+ public void Il_duello_si_riconosce_una_volta_sola()
+ {
+ var a = Asta();
+ var t = DateTime.UtcNow;
+
+ var riconosciuto = 0;
+ for (var i = 0; i < 8; i++)
+ {
+ a.LastMyBidAtUtc = t;
+ t = t.AddSeconds(6);
+ if (a.NoteOpponentResponse(t, 5)) riconosciuto++;
+ t = t.AddSeconds(7);
+ }
+
+ Assert.True(a.AutoBidDuelDetected);
+ Assert.Equal(1, riconosciuto); // non una volta per ogni risposta successiva
+ }
+
+ [Fact]
+ public void Senza_una_nostra_puntata_non_si_misura_niente()
+ {
+ // Una risposta avversaria che non risponde a noi non dice nulla sulla loro
+ // autopuntata: puo' essere un terzo che punta per conto suo.
+ var a = Asta();
+
+ Assert.False(a.NoteOpponentResponse(DateTime.UtcNow, 5));
+ Assert.Equal(0, a.AutoResponsesInARow);
+ Assert.False(a.AutoBidDuelDetected);
+ }
+
+ [Fact]
+ public void Ogni_risposta_si_misura_dalla_puntata_sua()
+ {
+ // Consumato il riferimento, una seconda risposta di fila non deve contare due
+ // volte lo stesso intervallo.
+ var a = Asta();
+ var t = DateTime.UtcNow;
+
+ a.LastMyBidAtUtc = t;
+ a.NoteOpponentResponse(t.AddSeconds(6), 5);
+ Assert.Equal(1, a.AutoResponsesInARow);
+
+ a.NoteOpponentResponse(t.AddSeconds(12), 5);
+ Assert.Equal(1, a.AutoResponsesInARow);
+ }
+
+ [Fact]
+ public void Un_avversario_umano_irregolare_non_viene_scambiato_per_macchina()
+ {
+ var a = Asta();
+ var t = DateTime.UtcNow;
+
+ // Ritardi veri, presi dalla distribuzione misurata fuori dalla firma.
+ foreach (var ritardo in new[] { 6.0, 3.1, 6.2, 9.4, 5.9, 2.2, 6.0 })
+ {
+ a.LastMyBidAtUtc = t;
+ t = t.AddSeconds(ritardo);
+ a.NoteOpponentResponse(t, 5);
+ t = t.AddSeconds(7);
+ }
+
+ Assert.False(a.AutoBidDuelDetected);
+ }
+}
diff --git a/Mimante/Utilities/AutoBidDuel.cs b/Mimante/Utilities/AutoBidDuel.cs
new file mode 100644
index 0000000..df1dad5
--- /dev/null
+++ b/Mimante/Utilities/AutoBidDuel.cs
@@ -0,0 +1,80 @@
+using System;
+
+namespace AutoBidder.Utilities
+{
+ ///
+ /// Riconosce l'avversario che ha l'autopuntata di Bidoo armata, e dice quando smettere.
+ ///
+ /// Il meccanismo. L'autopuntata del sito scatta a 2 secondi dalla chiusura,
+ /// e si riarma ogni volta che il suo padrone non è più in testa. Il timer si azzera
+ /// intorno agli 8 secondi, quindi la risposta arriva circa 6 secondi dopo la puntata che
+ /// l'ha svegliata. Misurato su 2.799.681 intervalli fra puntate consecutive in 5037 aste:
+ /// mediana 6,0 s, primo quartile 5,3 s, terzo quartile 7,1 s.
+ ///
+ /// Perché serve ritirarsi. Contro un'autopuntata armata non esiste attrito
+ /// che si possa vincere: risponde sempre, risponde in sei secondi, non dorme e non si
+ /// distrae. Puntare di nuovo significa comprare un altro giro di sei secondi al prezzo
+ /// di una puntata a testa, all'infinito, finché uno dei due esaurisce il credito. Nelle
+ /// prove reali sono state 191 puntate su tre aste per zero vittorie, con un ciclo
+ /// perfettamente regolare da 13,0 secondi: 7 di attesa nostra più 6 della loro risposta.
+ ///
+ /// Perché non basta puntare tardi. Puntare a un secondo dalla chiusura non
+ /// evita l'autopuntata avversaria: la sveglia. Lei scatta quando il suo padrone perde la
+ /// testa della classifica, non a un orario fisso, quindi ogni nostra puntata tardiva è
+ /// esattamente ciò che la fa ripartire.
+ ///
+ /// Classe pura, senza stato e senza orologio: prende i numeri e restituisce un
+ /// giudizio. Così la stessa identica logica serve il motore dal vivo e la rigiocata sui
+ /// dossier, senza due versioni che divergono.
+ ///
+ public static class AutoBidDuel
+ {
+ /// Ritardo tipico della risposta automatica, in secondi.
+ public const double RispostaTipicaSecondi = 6.0;
+
+ ///
+ /// Quanto ci si allontana dai 6 secondi continuando a chiamarla automatica.
+ /// Coincide grosso modo con l'intervallo fra primo e terzo quartile misurati
+ /// (5,3–7,1 s), allargato per la latenza di rete e per l'imprecisione del polling.
+ ///
+ public const double TolleranzaSecondi = 2.5;
+
+ ///
+ /// Risposte automatiche di fila oltre le quali si smette. Cinque perché è il punto in
+ /// cui la coincidenza smette di essere credibile: cinque intervalli indipendenti
+ /// dentro la finestra dei 6±2,5 s non capitano per caso, e cinque puntate sono un
+ /// prezzo che si può pagare per avere la certezza.
+ ///
+ public const int RispostePerConcludere = 5;
+
+ ///
+ /// Questo intervallo ha la firma della risposta automatica?
+ ///
+ public static bool ERispostaAutomatica(double secondiDopoLaMiaPuntata) =>
+ secondiDopoLaMiaPuntata > 0 &&
+ Math.Abs(secondiDopoLaMiaPuntata - RispostaTipicaSecondi) <= TolleranzaSecondi;
+
+ ///
+ /// Il conteggio aggiornato dopo aver visto una risposta avversaria.
+ ///
+ /// Una risposta che non ha la firma azzera il conteggio invece di
+ /// scalarlo: serve una sequenza pulita per concludere che c'è una macchina
+ /// dall'altra parte, e un solo intervallo umano in mezzo basta a rimettere tutto in
+ /// discussione. È la scelta prudente, perché l'errore costoso è ritirarsi da
+ /// un'asta che si poteva vincere.
+ ///
+ public static int Aggiorna(int consecutiveFinora, double secondiDopoLaMiaPuntata) =>
+ ERispostaAutomatica(secondiDopoLaMiaPuntata) ? consecutiveFinora + 1 : 0;
+
+ ///
+ /// Si è capito che dall'altra parte c'è un'autopuntata armata?
+ ///
+ public static bool DuelloRiconosciuto(int consecutive, int soglia = RispostePerConcludere) =>
+ consecutive >= Math.Max(1, soglia);
+
+ /// Come spiegarlo nel registro, in una riga.
+ public static string Spiegazione(int consecutive) =>
+ $"autopuntata avversaria riconosciuta: {consecutive} risposte di fila a ~{RispostaTipicaSecondi:N0}s. " +
+ "Continuare significa scambiare puntate una a una con una macchina che non si stanca.";
+ }
+}
diff --git a/Mimante/Utilities/SettingsManager.cs b/Mimante/Utilities/SettingsManager.cs
index c148212..3c2df4e 100644
--- a/Mimante/Utilities/SettingsManager.cs
+++ b/Mimante/Utilities/SettingsManager.cs
@@ -38,7 +38,21 @@ namespace AutoBidder.Utilities
/// dossier non possono dire di piu', perche' Bidoo dichiara la scadenza al secondo
/// intero.
///
- public int DefaultBidBeforeDeadlineMs { get; set; } = 1000;
+ ///
+ /// Aggiornato a 500 ms. Le due ragioni per stare larghi sono cadute
+ /// entrambe. La prima — il p99,9 del ping a 444 ms — era misurata quando il tetto di
+ /// 40 richieste al secondo faceva accodare le chiamate: con il tetto tolto il ping
+ /// misurato sui dossier nuovi sta fra 47 e 78 ms, mediana 55. La seconda era la
+ /// paura della concorrenza fitta sotto i due secondi, ma i tipi di puntata dicono
+ /// il contrario: il 75,3% delle puntate avversarie è automatica e scatta a 2 s
+ /// esatti, quindi sotto quella soglia resta solo chi punta a mano.
+ ///
+ /// A 500 ms la puntata parte con circa nove volte il ping di margine, e cade
+ /// nella metà di secondo in cui l'autopuntata del sito ha già sparato. Più in là non
+ /// conviene andare: Bidoo dichiara la scadenza al secondo intero, quindi sotto i
+ /// 500 ms si starebbe scommettendo sull'arrotondamento.
+ ///
+ public int DefaultBidBeforeDeadlineMs { get; set; } = 500;
public double DefaultMinPrice { get; set; } = 0;
public double DefaultMaxPrice { get; set; } = 0;
public int DefaultMaxClicks { get; set; } = 0;
@@ -546,6 +560,19 @@ namespace AutoBidder.Utilities
/// Default: false (DISABILITATO - non blocca mai)
///
public bool HardcodedAntiCollisionEnabled { get; set; } = false;
+
+ ///
+ /// Ritirarsi quando dall'altra parte c'è un'autopuntata armata. Acceso di serie.
+ /// Vedi per il meccanismo e i numeri misurati.
+ ///
+ public bool AutoBidDuelWithdrawEnabled { get; set; } = true;
+
+ ///
+ /// Risposte automatiche di fila oltre le quali ci si ritira. Predefinito 5.
+ /// Più basso risparmia prima ma rischia di scambiare per macchina un avversario
+ /// umano regolare; più alto è prudente e costa puntate.
+ ///
+ public int AutoBidDuelResponses { get; set; } = AutoBidDuel.RispostePerConcludere;
// 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
// RILEVAMENTO COMPETIZIONE E HEAT METRIC