Files
probably-decentralized/src/Decentralized/PredictGame.cs
T

408 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using Decentralized.Domain;
namespace Decentralized
{
/// <summary>
/// 预测市场的接入层:按游戏当前状态给出候选盘口、公开信息下的合理概率(盘口夜里向它靠拢)、线索、情报贩子的情报,
/// 夜间读游戏事件、派系行动、水纯度、gas 和本 mod 的状态来结算。
/// </summary>
public static class PredictGame
{
public static readonly string[] Factions = { "FACTION_SECURITY", "FACTION_REVOLUTION", "FACTION_LOWER_LEVEL", "FACTION_UPPER_LEVEL", "FACTION_BLACK_MARKET" };
private static readonly double[] GutterCuts = { 8300, 8700 }, RustCuts = { 9050, 9100 };
private static readonly double[] PriceMult = { 0.9, 0.95, 1.0, 1.05, 1.1 };
private static readonly double[] GasCuts = { 0.8, 1.0, 1.2, 1.4 };
/// <summary>夜间结算开始时记下的收盘数据:行情盘读夜间更新前的价格,gas 盘读当天的 gas(接着链上夜间事件就会改成明天的)。</summary>
public static double ClosePrice, CloseGas, ClosePush;
public static bool Available(int day) => Session.Data != null && Unlock.IsUnlocked && day >= Tuning.PredictUnlockDay;
/// <summary>情报贩子的门路:Web3 黑市解锁,且声望够。</summary>
public static bool BrokerOpen => Unlock.BlackUnlocked && StoreReputation.GetBMReputation() >= Tuning.IntelRep;
/// <summary>夜间观测用的标识:生效中的事件,以及派系行动的 "F:派系"。</summary>
public static List<string> ActiveIds()
{
var list = new List<string>();
var mgr = StoreStation.Instance?.storeEventManager;
if (mgr == null) return list;
foreach (var e in mgr.GetActiveEvents())
{
list.Add(e.identifier);
if (e.isFactionOperation && !string.IsNullOrEmpty(e.sourceFaction)) list.Add("F:" + e.sourceFaction);
}
return list;
}
// ——— 游戏事件模型 ———
private static double DayChance(StoreEventManager mgr)
{
int c = mgr.GetEventCounter();
return c == 0 ? 0.50 : c == 1 ? 0.35 : c == 2 ? 0.25 : 0;
}
private static double EventProb(StoreEventManager mgr, string id, int days, bool ignoreCooldown = false)
{
var pool = StoreEventManager.normalEventBlueprints.Where(e => ignoreCooldown || !mgr.IsEventOnCooldown(e.identifier)).ToList();
var bp = pool.Find(e => e.identifier == id);
if (bp == null) return 0;
return PredictMath.EventProb(ignoreCooldown ? 0.35 : DayChance(mgr), bp.odd, pool.Sum(e => (double)e.odd), days);
}
/// <summary>已经排进日程的事件 / 派系行动在窗口里的开始日(known = 只看玩家已知的)。</summary>
private static int? QueuedStart(PredictMarket m, bool knownOnly)
{
var mgr = StoreStation.Instance?.storeEventManager;
if (mgr == null || (m.Kind != PredictKind.Event && m.Kind != PredictKind.Faction)) return null;
int today = StoreStation.GetDayCounter();
for (int i = 0; i < mgr.futurEvents.Count; i++)
{
var e = mgr.futurEvents[i];
if (knownOnly && !e.IsKnown) continue;
bool match = m.Kind == PredictKind.Event ? e.identifier == m.Key : e.isFactionOperation && e.sourceFaction == m.Key;
if (i >= mgr.futurEventsDay.Count) break;
int start = today + mgr.futurEventsDay[i];
if (match && start >= m.FromDay && start <= m.ToDay) return start;
}
return null;
}
private static bool FactionBusy(StoreEventManager mgr, string faction) => mgr.GetActiveEvents().Any(e => e.isFactionOperation && e.sourceFaction == faction);
// ——— 候选盘口 ———
public static List<PredictCandidate> Candidates(int day)
{
var d = Session.Data;
var list = new List<PredictCandidate>();
var mgr = StoreStation.Instance?.storeEventManager;
if (mgr == null) return list;
double dayChance = DayChance(mgr);
var pool = StoreEventManager.normalEventBlueprints.Where(e => !mgr.IsEventOnCooldown(e.identifier)).ToList();
double total = pool.Sum(e => (double)e.odd);
foreach (var bp in pool)
{
if (mgr.IsEventActive(bp.identifier)) continue;
string name;
try { name = bp.storeEventFunc().GetDisplayName(); } catch (Exception) { continue; }
double odd = bp.odd;
list.Add(new PredictCandidate { Kind = PredictKind.Event, Key = bp.identifier, Title = name, Prob = (from, to) => PredictMath.EventProb(dayChance, odd, total, to - from + 1) });
}
foreach (var f in Factions)
{
if (FactionBusy(mgr, f)) continue;
list.Add(new PredictCandidate { Kind = PredictKind.Faction, Key = f, Title = L.T("predict.faction." + f), Prob = (from, to) => PredictMath.FactionProb(to - from + 1) });
}
foreach (var t in GutterCuts)
{
double thr = t;
list.Add(new PredictCandidate { Kind = PredictKind.Purity, Key = "gutter", Title = L.T("predict.purity.gutter"), Param = thr, Tomorrow = true, Prob = (a, b) => PredictMath.PurityAtLeast(thr, 8000, 8999) });
}
foreach (var t in RustCuts)
{
double thr = t;
list.Add(new PredictCandidate { Kind = PredictKind.Purity, Key = "rust", Title = L.T("predict.purity.rust"), Param = thr, Tomorrow = true, Prob = (a, b) => PredictMath.PurityAtLeast(thr, 9000, 9150) });
}
double price = d.Market.Price, anchor = d.Market.Anchor(d.ActiveEvents);
foreach (var mult in PriceMult)
{
double thr = Math.Round(price * mult, price >= 100 ? 0 : 1);
list.Add(new PredictCandidate { Kind = PredictKind.Price, Key = "PSC", Title = "PSC", Param = thr, Prob = (from, to) => PredictMath.PriceAtLeast(price, anchor, Tuning.PscVol, Tuning.Revert, to - day, thr) });
}
if (d.GasPhase >= 0)
foreach (var t in GasCuts)
{
double thr = t;
list.Add(new PredictCandidate { Kind = PredictKind.Gas, Key = "gas", Title = "Gas", Param = thr, Tomorrow = true, Prob = (a, b) => GasPublic(d, day + 1, thr) });
}
if (d.Stable.CraWarnDay < 0 && d.Stable.CraCrashDay < 0)
list.Add(new PredictCandidate { Kind = PredictKind.Cra, Key = "cra", Title = "CRA", Prob = (from, to) => PredictMath.CraWarnProb(d.Stable.Cushion, CraDrain(d), day, from, to, CraHazard) });
if (!d.Network.EraDone && d.Network.EraDay > 0 && day + 4 >= (d.Network.EraWarned ? d.Network.EraWarnDay : Tuning.EraMin))
list.Add(new PredictCandidate { Kind = PredictKind.Era, Key = "era", Title = L.T("predict.era"), Prob = (from, to) => PredictMath.EraProb(day, from, to, d.Network.EraWarned ? d.Network.EraWarnDay : -1) });
return list;
}
/// <summary>gas 盘的公开价:绝大部分按真实相位、小部分按不知道相位(PredictGasBlind)——七天周期是链上公开的,盘口里的 NPC 多数看得见;数清周期的人只剩一点优势。</summary>
private static double GasPublic(SaveData d, int day, double thr) =>
d.GasPhase < 0 ? PredictMath.GasFlat(day, thr) : (1 - Tuning.PredictGasBlind) * PredictMath.GasAtLeast(day, d.GasPhase, thr) + Tuning.PredictGasBlind * PredictMath.GasFlat(day, thr);
private static double CraHazard => 1 - Math.Pow(1 - Tuning.CraSpiralWeekly, 1.0 / 7);
private static double CraDrain(SaveData d) => Stablecoins.CushionDrain(Tuning.CraNpcStake + d.Staking.Earning, d.Stable.CraRate);
/// <summary>开门后补盘。价格盘一次只留一个(阈值不同的盘口会互相对冲)。</summary>
public static void Morning(SaveData d, int day)
{
if (!Available(day)) return;
ForceKnown(d, day);
OpenTrial(d, day);
var made = d.Predict.Refill(day, d.Rng, Candidates(day));
foreach (var m in made) Log.Info($"预测盘口 #{m.Id}:{m.Kind} {m.Key} {m.Title} 阈值 {m.Param} 窗口 {m.FromDay}-{m.ToDay} 先验 {m.P0:0.000}");
}
/// <summary>
/// 柜台线人(游戏里卖消息的顾客)、传闻纸条和派系特权会把事件排进日程且标为已知:每个这样的事件保证有一个盘口(窗口 = 开始那天),
/// 开盘价按公开信息下的基础概率,不因为已经排期而抬高——知道的人能赚到价格追上来之前的差。开始日是明天的来不及,不开。
/// </summary>
private static void ForceKnown(SaveData d, int day)
{
var mgr = StoreStation.Instance?.storeEventManager;
if (mgr == null) return;
var bk = d.Predict;
for (int i = 0; i < mgr.futurEvents.Count; i++)
{
var e = mgr.futurEvents[i];
if (i >= mgr.futurEventsDay.Count) break;
int start = day + mgr.futurEventsDay[i];
if (!e.IsKnown || start < day + 2 || e.eventType == StoreEvent.EventType.COSMETIC) continue;
bool op = e.isFactionOperation;
var kind = op ? PredictKind.Faction : PredictKind.Event;
string key = op ? e.sourceFaction : e.identifier;
if (!op && !StoreEventManager.normalEventBlueprints.Any(b => b.identifier == key)) continue;
if (bk.Markets.Any(m => m.Outcome < 0 && m.Kind == kind && m.Key == key && m.FromDay <= start && m.ToDay >= start)) continue;
if (bk.Markets.Count(m => m.Outcome < 0) >= Tuning.PredictOpenMax + 2) break;
double p = op ? PredictMath.FactionProb(1) : EventProb(mgr, key, 1, true);
string title = op ? L.T("predict.faction." + key) : e.GetDisplayName();
var m2 = PredictMarket.Create(++bk.Seq, kind, key, title, 0, day, start, start, p);
bk.Markets.Add(m2);
Log.Info($"线人情报开盘 #{m2.Id}:{kind} {key} 第 {start} 天,先验 {p:0.000}");
}
}
/// <summary>举报 Tumbler 后的审判盘:开盘日起强制开(不进候选池),宣判那天结算,先验 = 定罪概率。</summary>
public static void OpenTrial(SaveData d, int day)
{
var a = d.Story.After;
if (a.Verdict < 0 || a.TrialMarket || day < a.TrialOpenDay || day >= a.TrialDay) return;
a.TrialMarket = true;
var bk = d.Predict;
var m = PredictMarket.Create(++bk.Seq, PredictKind.Trial, "tumbler", "Tumbler", 0, day, a.TrialDay, a.TrialDay, Tuning.TumblerTrialConvict);
bk.Markets.Add(m);
Log.Info($"审判盘开盘 #{m.Id}:第 {a.TrialDay} 天宣判");
}
// ——— 公开信息下的合理概率(盘口夜里向它靠拢) ———
/// <summary>
/// 市场里的 NPC 看得到的东西:报纸上已经排期的事件、正在进行的事件与派系行动、冷却、当前行情、gas 的周期、补贴池、矿机时代预警。
/// 看不到:没上报纸的排期(传闻、派系特权之外)、减半抬价和排期好的镇压对价格锚的未来影响。day 是今晚。
/// </summary>
public static double? PublicProb(PredictMarket m, SaveData d, int day)
{
var mgr = StoreStation.Instance?.storeEventManager;
int days = m.ToDay - m.FromDay + 1;
switch (m.Kind)
{
case PredictKind.Event:
if (mgr == null) return null;
if (QueuedStart(m, true) != null) return 0.92;
if (mgr.IsEventActive(m.Key) || mgr.IsEventOnCooldown(m.Key)) return 0.02;
return EventProb(mgr, m.Key, days);
case PredictKind.Faction:
if (mgr == null) return null;
if (QueuedStart(m, true) != null) return 0.92;
if (FactionBusy(mgr, m.Key)) return 0.02;
return PredictMath.FactionProb(days);
case PredictKind.Price:
return PredictMath.PriceAtLeast(d.Market.Price, d.Market.Anchor(d.ActiveEvents), Tuning.PscVol, Tuning.Revert, Math.Max(1, m.ToDay - day), m.Param);
case PredictKind.Gas:
return d.GasPhase < 0 ? (double?)null : GasPublic(d, m.ToDay, m.Param);
case PredictKind.Cra:
if (d.Stable.CraWarnDay >= 0 || d.Stable.CraCrashDay >= 0) return 0.02;
return PredictMath.CraWarnProb(d.Stable.Cushion, CraDrain(d), day + 1, m.FromDay, m.ToDay, CraHazard);
case PredictKind.Era:
return PredictMath.EraProb(day, m.FromDay, m.ToDay, d.Network.EraWarned ? d.Network.EraWarnDay : -1);
case PredictKind.Trial:
return d.Story.After.TrialPublic();
}
return null;
}
// ——— 结算 ———
/// <summary>夜间结算的结果:事件、派系读观测;纯度读当天的数值;行情读夜间更新之前的价格;gas 读当天的指数;CRA 预警与矿机时代读 mod 状态(要在这两项夜间逻辑之后结算)。</summary>
public static bool? Outcome(PredictMarket m, SaveData d)
{
switch (m.Kind)
{
case PredictKind.Event: case PredictKind.Faction: return d.Predict.Happened(m);
case PredictKind.Purity:
var st = StoreStation.Instance;
return st == null ? (bool?)null : (m.Key == "gutter" ? st.todayGutterflowPurity : st.todayRustwaterPurity) >= m.Param;
case PredictKind.Price: return ClosePrice >= m.Param;
case PredictKind.Gas: return CloseGas >= m.Param;
case PredictKind.Cra: return d.Stable.CraWarnDay >= m.FromDay && d.Stable.CraWarnDay <= m.ToDay;
case PredictKind.Era: return d.Network.EraDay < 0 ? (bool?)null : d.Network.EraDay >= m.FromDay && d.Network.EraDay <= m.ToDay;
case PredictKind.Trial: return d.Story.After.Verdict < 0 ? (bool?)null : d.Story.After.Verdict == 1;
}
return null;
}
// ——— 情报 ———
/// <summary>这个盘口的真相是不是已经定了(情报贩子只能卖已经定了的):派系行动看排期(包括没上报纸的),矿机时代看 mod 自己掷出的日子。</summary>
public static bool? Truth(PredictMarket m)
{
var d = Session.Data;
if (m.Kind == PredictKind.Faction) return QueuedStart(m, false) != null;
if (m.Kind == PredictKind.Era) return d.Network.EraDay > 0 ? d.Network.EraDay >= m.FromDay && d.Network.EraDay <= m.ToDay : (bool?)null;
if (m.Kind == PredictKind.Trial) return d.Story.After.Verdict < 0 ? (bool?)null : d.Story.After.Verdict == 1;
return null;
}
public static bool Tippable(PredictMarket m, int day) => m.IsOpen(day) && Truth(m) != null && (m.Kind != PredictKind.Faction || m.ToDay <= day + 4);
/// <summary>下注时玩家是否已经知道答案:这个事件(或派系行动)已经以玩家看得到的形式排进日程(传闻纸条、派系特权),或者买了情报贩子的内幕消息且说的就是这一边。</summary>
public static bool Known(PredictMarket m, bool yes)
{
var tip = Session.Data?.Predict.TipFor(m.Id);
if (tip != null && tip.Claim == yes) return true;
return yes && QueuedStart(m, true) != null;
}
public static string Question(PredictMarket m)
{
string win = m.FromDay == m.ToDay ? L.T("predict.win.one", m.FromDay) : L.T("predict.win.range", m.FromDay, m.ToDay);
switch (m.Kind)
{
case PredictKind.Event: return L.T("predict.q.event", win, m.Title);
case PredictKind.Faction: return L.T("predict.q.faction", win, m.Title);
case PredictKind.Purity: return L.T("predict.q.purity", win, m.Title, Fmt.Num(m.Param));
case PredictKind.Gas: return L.T("predict.q.gas", win, Fmt.Num(m.Param));
case PredictKind.Cra: return L.T("predict.q.cra", win);
case PredictKind.Era: return L.T("predict.q.era", win);
case PredictKind.Trial: return L.T("predict.q.trial", win);
default: return L.T("predict.q.price", win, m.Title, Fmt.Num(m.Param));
}
}
// ——— 线索 ———
/// <summary>盘口面板里的"线索":玩家手里已有的、和这个盘口有关的信息。每条尽量短。</summary>
public static List<string> Clues(PredictMarket m)
{
var d = Session.Data;
var list = new List<string>();
var mgr = StoreStation.Instance?.storeEventManager;
int today = StoreStation.GetDayCounter();
var tip = d.Predict.TipFor(m.Id);
if (tip != null) list.Add(L.T("predict.clue.tip", L.T("predict.tier." + tip.Tier), L.T(tip.Claim ? "predict.yes" : "predict.no")));
switch (m.Kind)
{
case PredictKind.Event:
{
int? s = QueuedStart(m, true);
if (s != null) list.Add(L.T("predict.clue.queued", s.Value));
else if (mgr != null && (mgr.IsEventActive(m.Key) || mgr.IsEventOnCooldown(m.Key))) list.Add(L.T("predict.clue.cool"));
break;
}
case PredictKind.Faction:
{
int? s = QueuedStart(m, true);
if (s != null) list.Add(L.T("predict.clue.queued", s.Value));
else if (mgr != null && FactionBusy(mgr, m.Key)) list.Add(L.T("predict.clue.busy"));
break;
}
case PredictKind.Price:
{
int toHalv = Tuning.HalvingDays - today % Tuning.HalvingDays;
if (toHalv <= m.ToDay - today + Tuning.HalvingRampDays) list.Add(L.T("predict.clue.halving", today + toHalv));
if (mgr != null)
for (int i = 0; i < mgr.futurEvents.Count; i++)
{
if (i >= mgr.futurEventsDay.Count) break;
var e = mgr.futurEvents[i];
if (e.IsKnown && (e.identifier == "crackdown" || e.identifier == "stationWideBlackout" || e.identifier == "powerShortage"))
list.Add(L.T("predict.clue.queued2", today + mgr.futurEventsDay[i], e.GetDisplayName()));
}
list.Add(L.T("predict.clue.anchor", Fmt.Num(d.Market.Anchor(d.ActiveEvents))));
if (Math.Abs(d.Market.PlayerPush) >= Tuning.ManipFree) list.Add(L.T("predict.clue.push", (d.Market.PlayerPush * 100).ToString("+0.0;-0.0", System.Globalization.CultureInfo.InvariantCulture)));
break;
}
case PredictKind.Gas:
if (d.GasHist.Count > 0) list.Add(L.T("predict.clue.gas", string.Join(" ", d.GasHist.Skip(Math.Max(0, d.GasHist.Count - 7)).Select(x => x.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)))));
break;
case PredictKind.Cra:
list.Add(L.T("predict.clue.subsidy", (d.Stable.Cushion / Stablecoins.Cushion0 * 100).ToString("0")));
list.Add(L.T("predict.clue.drain", Fmt.Num(CraDrain(d))));
break;
case PredictKind.Trial:
{
var a = d.Story.After;
if (a.Hints.Count > 0) list.Add(L.T("predict.clue.trial", a.Hints.Count(h => h), a.Hints.Count(h => !h)));
break;
}
case PredictKind.Era:
list.Add(d.Network.EraWarned ? L.T("predict.clue.era.warned", d.Network.EraWarnDay + Tuning.EraWarnMin, d.Network.EraWarnDay + Tuning.EraWarnMax) : L.T("predict.clue.era", Tuning.EraMin, Tuning.EraMax));
break;
}
return list;
}
// ——— 局势简报 ———
private static string OpName(string id)
{
var bp = StoreOperationManager.operationBlueprints.Find(b => b.identifier == id);
try { return bp != null ? bp.storeEventFunc().GetDisplayName() : id; } catch (Exception) { return id; }
}
/// <summary>按游戏的阈值列出每个派系现在能发起的行动。fake:假情报,数值是编的。</summary>
public static List<string> MakeBrief(bool fake, int day)
{
var mgr = StoreStation.Instance?.storeEventManager;
var om = StoreStation.Instance?.storeOperationManager;
var lines = new List<string>();
if (om == null || mgr == null) return lines;
int sec = om.secPower, rev = om.revPower, bm = om.blackMarketPower, ll = om.lowerLevelUnrest, ul = om.upperLevelFriendliness;
if (fake)
{
var rng = Session.Data.Rng;
sec = rng.Range(0, 100); rev = rng.Range(0, 100); bm = rng.Range(0, 100); ll = rng.Range(0, 100); ul = rng.Range(0, 100);
}
lines.Add(L.T("predict.brief.levels", sec, rev, bm, ll, ul));
Func<string, List<string>> ops = f =>
{
var l = new List<string>();
switch (f)
{
case "FACTION_SECURITY":
if (sec >= 20 && bm >= 40) l.Add("crackdown");
if (sec >= 20 && (rev >= 33 || ll >= 33 || bm >= 33)) l.Add("sec_increase_patrols");
if (sec >= 15 && rev >= 40) l.Add("sec_raid_revolution");
if (sec >= 15 && bm >= 40) l.Add("sec_raid_black_market");
if (sec >= 50 && (rev >= 65 || ll >= 65 || bm >= 65)) l.Add("sec_martial_law");
if (sec <= 5) l.Add("sec_budget_increase");
break;
case "FACTION_REVOLUTION":
if (day > 7) { if (rev <= 25) l.Add("rev_mobilization_secret"); if (rev >= 50) l.Add("rev_mobilization"); if (rev >= 15) l.Add("rev_raid_upper_level"); if (rev >= 25) l.Add("raid_security"); }
break;
case "FACTION_LOWER_LEVEL":
if (ll >= 85) l.Add("ll_riot"); if (ll >= 50) l.Add("ll_worker_strike");
break;
case "FACTION_UPPER_LEVEL":
if (ul >= 50 && ll >= 50) l.Add("ul_medical_aid"); if (ul >= 50 && ll <= 35) l.Add("ul_household_relief");
break;
case "FACTION_BLACK_MARKET":
if (day > 9) { if (bm >= 85) l.Add("bm_gang_war"); l.Add("bm_illegal_weapon_shipment"); }
break;
}
return l;
};
foreach (var f in Factions)
{
string name = L.T("predict.faction." + f);
if (FactionBusy(mgr, f)) { lines.Add(L.T("predict.brief.busy", name)); continue; }
if (mgr.futurEvents.Any(e => e.isFactionOperation && e.sourceFaction == f)) { lines.Add(L.T("predict.brief.queued", name)); continue; }
var l = ops(f);
lines.Add(l.Count == 0 ? L.T("predict.brief.none", name) : L.T("predict.brief.can", name, string.Join("、", l.Select(OpName))));
}
return lines;
}
}
}