Compare commits
2 Commits
88b4cad780
...
5b8bee286c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b8bee286c | |||
| ba19954ffb |
@@ -33,8 +33,40 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::discard;
|
||||
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
|
||||
|
||||
/// The stack's `discardValue` (atom 0xd7) — the number the consumables screen
|
||||
/// DISPLAYS, per card.
|
||||
///
|
||||
/// This used to be hard-coded `0`, on the theory that the client would compute
|
||||
/// the price itself from `fcc_discardcoins` the way it does for a card whose
|
||||
/// `discardValue` we omit. That theory was wrong, and the screen showed
|
||||
/// "Quick sell for 0 coins" on a real production club (operator-observed,
|
||||
/// 2026-08-22) while Core would have paid 3/13/32 for those same contracts.
|
||||
///
|
||||
/// Why the old reasoning failed, from evidence rather than re-derivation:
|
||||
///
|
||||
/// * `item+0x38` (the `discardValue` we send) non-zero makes the client SKIP its
|
||||
/// local computation and display our number — live-proven again on the
|
||||
/// production client, 16/16 resident cards `SERVER-SHOWN`.
|
||||
/// * We send no `discardValue` inside a consumable's `item`, so `+0x38` is 0 and
|
||||
/// the client's local computation DOES run, filling `+0x3c` with the right
|
||||
/// value — Milestone 1 measured exactly that (3/3/32/38, matching this table).
|
||||
/// * The screen nonetheless showed 0. So the screen is not reading the item's
|
||||
/// computed `+0x3c`; it reads the STACK's atom 0xd7, which we were sending as
|
||||
/// 0.
|
||||
///
|
||||
/// So the value belongs here, and it is the SAME number
|
||||
/// [`discard::value_for_definition`] gives the quick-sell payout — one source, so
|
||||
/// the screen and the wallet cannot disagree. Per CARD, not per stack: FUT
|
||||
/// prices a card, and the stack is only a quantity badge over identical copies.
|
||||
///
|
||||
/// `None` (definition not priceable) stays `0` rather than inventing a number.
|
||||
fn stack_discard_value(id: &Fifa17ConsumableIdentity) -> i64 {
|
||||
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Build the consumables-screen body from the club's owned consumable copies.
|
||||
///
|
||||
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
|
||||
@@ -43,10 +75,10 @@ use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeSta
|
||||
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
|
||||
/// different item than the club owns is worse than omitting the stack.
|
||||
///
|
||||
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
|
||||
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
|
||||
/// live-proven oracle sends on this route. Inventing a price from the player
|
||||
/// quick-sell table would be a fabricated number the client does not need.
|
||||
/// `discardValue` carries the card's real quick-sell price — see
|
||||
/// [`stack_discard_value`]. It used to be `0` on the theory that the client
|
||||
/// priced the card itself; the production screen showed "Quick sell for 0 coins"
|
||||
/// instead, so the stack atom is what the screen reads.
|
||||
///
|
||||
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
|
||||
/// later item operation on the stack therefore addresses a card the club really
|
||||
@@ -78,7 +110,7 @@ pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, Shape
|
||||
order.push(id.resource_id);
|
||||
stacks.push(json!({
|
||||
"count": 1,
|
||||
"discardValue": 0,
|
||||
"discardValue": stack_discard_value(id),
|
||||
"item": shape_consumable_item(*id),
|
||||
"resourceId": id.resource_id,
|
||||
"untradeableCount": i64::from(id.untradeable),
|
||||
@@ -109,6 +141,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A contract card of the given subtype/rating — the family the production
|
||||
/// screen showed as "0 coins".
|
||||
fn contract(
|
||||
item_id: u32,
|
||||
resource_id: u32,
|
||||
subtype: i64,
|
||||
rating: u8,
|
||||
) -> Fifa17ConsumableIdentity {
|
||||
Fifa17ConsumableIdentity {
|
||||
item_id,
|
||||
resource_id,
|
||||
asset_id: resource_id,
|
||||
card_asset_id: 7,
|
||||
subtype,
|
||||
rareflag: 0,
|
||||
rating,
|
||||
amount: None,
|
||||
contract: Some(1),
|
||||
untradeable: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack atom the screen reads MUST carry the same number the quick-sell
|
||||
/// pays. A production club displayed "Quick sell for 0 coins" for contracts
|
||||
/// Core would have paid 3/13/32 for; nothing may reintroduce that gap.
|
||||
#[test]
|
||||
fn stack_discard_value_is_the_payout_and_never_a_silent_zero() {
|
||||
// The three contracts owned by the real production club.
|
||||
let items = vec![
|
||||
contract(1, 5_001_004, 201, 60),
|
||||
contract(2, 5_001_008, 202, 65),
|
||||
contract(3, 5_001_009, 202, 80),
|
||||
];
|
||||
let (body, _) = consumables_response(&items);
|
||||
let stacks = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stacks.len(), 3);
|
||||
for (stack, id) in stacks.iter().zip(items.iter()) {
|
||||
let shown = stack["discardValue"].as_i64().unwrap();
|
||||
let paid =
|
||||
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating)
|
||||
.expect("a contract definition is priceable");
|
||||
assert_eq!(
|
||||
shown, paid,
|
||||
"displayed must equal payout for {}",
|
||||
id.resource_id
|
||||
);
|
||||
assert!(
|
||||
shown > 0,
|
||||
"{} priced at 0 is the bug we just fixed",
|
||||
id.resource_id
|
||||
);
|
||||
}
|
||||
// The exact recovered values, so a table regression is visible here too.
|
||||
assert_eq!(stacks[0]["discardValue"], 3);
|
||||
assert_eq!(stacks[1]["discardValue"], 13);
|
||||
assert_eq!(stacks[2]["discardValue"], 32);
|
||||
}
|
||||
|
||||
/// Collapsing copies must not multiply the price: FUT prices a CARD, and the
|
||||
/// stack is a quantity badge over identical copies.
|
||||
#[test]
|
||||
fn stack_discard_value_is_per_card_not_per_stack() {
|
||||
let items = vec![
|
||||
contract(1, 5_001_009, 202, 80),
|
||||
contract(2, 5_001_009, 202, 80),
|
||||
contract(3, 5_001_009, 202, 80),
|
||||
];
|
||||
let (body, _) = consumables_response(&items);
|
||||
let stacks = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stacks.len(), 1);
|
||||
assert_eq!(stacks[0]["count"], 3);
|
||||
assert_eq!(stacks[0]["discardValue"], 32, "per card, not 3 x 32");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_copies_collapse_into_one_counted_stack() {
|
||||
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
|
||||
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report host-level packet interception affecting OpenFUT endpoints.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
During the 2026-08-22 Rust production cutover, four stale `openfut-switch` nft
|
||||
rules were still redirecting production-facing traffic to staging:
|
||||
|
||||
42127 -> :42227 8081 -> :8281 8094 -> :18094 8099 -> :18106
|
||||
|
||||
They matched `ip daddr 10.10.0.120`, so every server-side probe via 127.0.0.1 or
|
||||
the container IP passed while the CLIENT was refused or silently sent to
|
||||
staging. That cost an entire false-negative acceptance round: a retail
|
||||
quick-sell landed on staging while production sat untouched, and the launcher
|
||||
reported "OpenFUT server is not answering".
|
||||
|
||||
The lesson is mechanical, so the check is too: a connectivity gate that only
|
||||
probes loopback proves nothing about what the client reaches.
|
||||
|
||||
READ-ONLY. This tool never deletes a rule. Removing interception is a
|
||||
deliberate operator act (`openfut-switch.sh off --name <id>`).
|
||||
|
||||
Exit status: 0 CLEAN, 1 INTERCEPTION_PRESENT, 2 could not determine.
|
||||
|
||||
python3 scripts/openfut-interception-preflight.py
|
||||
python3 scripts/openfut-interception-preflight.py --advertise 10.10.0.120
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# The endpoints a FIFA 17 client actually dials, plus the staging twins that
|
||||
# stale rules historically pointed at.
|
||||
PRODUCTION_PORTS = {
|
||||
8099: "UTAS (Rust utas-host)",
|
||||
8081: "roster",
|
||||
8094: "POW api",
|
||||
8085: "POW content",
|
||||
4216: "LSX (client-side)",
|
||||
42127: "Blaze redirector",
|
||||
42130: "Blaze main",
|
||||
42131: "Nucleus",
|
||||
}
|
||||
|
||||
# LSX runs on the GAME machine, not here: the compose file publishes 4216 but
|
||||
# `OPENFUT_SERVERS` excludes lsx by default, so "published but not served" is
|
||||
# its normal state and must not be reported as interception. Every other port
|
||||
# above is expected to be served on this host.
|
||||
NOT_SERVED_HERE = {4216}
|
||||
STAGING_PORTS = {8299: "staging UTAS", 42327: "staging redirector",
|
||||
42330: "staging blaze main", 8281: "staging roster",
|
||||
18094: "staging POW", 18106: "retired season-shim"}
|
||||
ALL_PORTS = dict(PRODUCTION_PORTS)
|
||||
ALL_PORTS.update(STAGING_PORTS)
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> str:
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||
return r.stdout
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def switch_status() -> tuple[str, list[str]]:
|
||||
"""`openfut-switch.sh status`, which owns the redirect lifecycle."""
|
||||
for path in ("/home/alex/OpenFUT/openfut-blaze-host/openfut-switch.sh",
|
||||
"openfut-blaze-host/openfut-switch.sh"):
|
||||
if shutil.which("bash") and subprocess.run(
|
||||
["test", "-x", path], capture_output=True).returncode == 0:
|
||||
out = _run([path, "status"])
|
||||
active = [l.strip() for l in out.splitlines()
|
||||
if "->" in l and "openfut-switch" in l]
|
||||
return ("INACTIVE" if "INACTIVE" in out else
|
||||
("ACTIVE" if active else "UNKNOWN")), active
|
||||
return "UNKNOWN", []
|
||||
|
||||
|
||||
def nft_redirects(advertise: str) -> tuple[list[str], list[str]]:
|
||||
"""Split nft rules touching our ports into REDIRECTs (interception) and
|
||||
Docker's own DNAT (legitimate publishing)."""
|
||||
out = _run(["sudo", "-n", "nft", "list", "ruleset"])
|
||||
if not out:
|
||||
out = _run(["nft", "list", "ruleset"])
|
||||
redirects, dnats = [], []
|
||||
port_re = re.compile(r"dport (\d+)")
|
||||
for line in out.splitlines():
|
||||
s = line.strip()
|
||||
m = port_re.search(s)
|
||||
if not m or int(m.group(1)) not in ALL_PORTS:
|
||||
continue
|
||||
if "redirect to" in s:
|
||||
redirects.append(s)
|
||||
elif "dnat to" in s:
|
||||
dnats.append(s)
|
||||
return redirects, dnats
|
||||
|
||||
|
||||
def reachable(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||
s = socket.socket()
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--advertise", default="10.10.0.120",
|
||||
help="the address the CLIENT dials (not loopback)")
|
||||
args = ap.parse_args()
|
||||
adv = args.advertise
|
||||
|
||||
print("OpenFUT interception preflight — advertise=%s" % adv)
|
||||
print()
|
||||
|
||||
state, active = switch_status()
|
||||
print("openfut-switch : %s" % state)
|
||||
for a in active:
|
||||
print(" %s" % a)
|
||||
|
||||
redirects, dnats = nft_redirects(adv)
|
||||
print("\nnft REDIRECTs on OpenFUT ports : %d%s"
|
||||
% (len(redirects), " <-- INTERCEPTION" if redirects else ""))
|
||||
for r in redirects:
|
||||
print(" %s" % r[:150])
|
||||
print("nft DNAT (docker publishing) : %d (expected, not interception)"
|
||||
% len(dnats))
|
||||
|
||||
# The point of the whole tool: compare loopback with the address the client
|
||||
# actually dials. A redirect keyed on the LAN IP is invisible to loopback.
|
||||
print("\neffective endpoint, loopback vs advertised:")
|
||||
disagree = []
|
||||
for port, label in sorted(PRODUCTION_PORTS.items()):
|
||||
lo = reachable("127.0.0.1", port)
|
||||
wan = reachable(adv, port)
|
||||
if lo == wan:
|
||||
flag = ""
|
||||
elif port in NOT_SERVED_HERE:
|
||||
flag = " (not served here -- expected)"
|
||||
else:
|
||||
flag = " <-- DISAGREE"
|
||||
if lo != wan and port not in NOT_SERVED_HERE:
|
||||
disagree.append((port, label, lo, wan))
|
||||
print(" :%-6d %-24s loopback=%-5s advertised=%-5s%s"
|
||||
% (port, label, lo, wan, flag))
|
||||
|
||||
intercepted = bool(redirects) or state == "ACTIVE" or bool(disagree)
|
||||
print()
|
||||
if intercepted:
|
||||
print("RESULT: INTERCEPTION_PRESENT")
|
||||
if disagree:
|
||||
print(" loopback and the advertised address disagree on: %s"
|
||||
% ", ".join(":%d" % p for p, _, _, _ in disagree))
|
||||
print(" Nothing was changed. To clear a switch rule, run explicitly:")
|
||||
print(" openfut-blaze-host/openfut-switch.sh off --name <id>")
|
||||
return 1
|
||||
print("RESULT: CLEAN")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user