diff --git a/contract/CONTRACT.md b/contract/CONTRACT.md index 60fe56f..f098028 100644 --- a/contract/CONTRACT.md +++ b/contract/CONTRACT.md @@ -349,6 +349,68 @@ incl. Pyfa's mass / capacity / volume / radius attributes), `effects` [{id, name (Pyfa Traits tab HTML), `required_skills` {skill id: level} (cases `type_*`; MKT-003, ENG-SHIP-006, CHR-008; a case's `_fields` param lists the fields it scores). +## `compute` (unified entry, `exfa/compute@1`) + +One input object produces one output object; the engine stays workspace/group-agnostic and only sees fully +resolved fits (no host-side `fit_id` / `character_id` references). Available as the `compute` RPC method +(`params` = the request envelope), the `exfa compute [FILE]` CLI (file or stdin → one JSON line on stdout, +exit 2 on an error envelope), and `exfa_core::compute_json` / `exfa_core::compute::compute` (WASM gets the RPC +method for free). + +```jsonc +// request +{"format": "exfa/compute@1", "operation": "calc", "fit": { /* FitSpec */ }} +{"format": "exfa/compute@1", "operation": "batch", "batch": { /* BatchSpec */ }} +// response +{"format": "exfa/compute-result@1", "operation": "calc", "result": { /* FitStats */ }} +{"format": "exfa/compute-result@1", "operation": "batch", "result": { /* BatchResponse */ }} +{"format": "exfa/compute-result@1", "operation": "calc", "error": {"code": "...", "message": "...", "path": "..."}} +``` + +- A missing or non-`"exfa/compute@1"` `format` → `UNSUPPORTED_FORMAT`; a missing or non-`"calc"`/`"batch"` + `operation` → `BAD_REQUEST`; inner calc/batch failures keep their `code`/`message`/`path` inside the error + envelope. A top-level JSON parse failure still answers with the error envelope (`BAD_JSON`; `operation` + echoed when recoverable from the raw text, else `"calc"`). `compute_json` never panics. +- `batch` is exactly the docs/23 BatchRequest (`batch_version`, exactly one of `fits`/`variants`/`product`/ + `sweep`, `fields`, `deltas`, `delta_ref`, `filter`, `sort_by`, `top_n`, `max_combinations`, price layers); + expansion, per-item errors and the `fits[]` item `id`/`label` echo behave as before. + +**FitSpec = FitRequest + stable ids + shorthand normalization.** + +- `modules[]`, `drones[]` and `fighters[]` entries take an optional string `id` (a stable caller-chosen id; + ignored everywhere except `select`, below). +- Shorthand defaults (applied by the `compute` entry point only — `calc`/`batch`/`graph` keep the existing + FitRequest defaults; explicit values always win, so `default_level: 0` stays 0): + - `character.skills.default_level` omitted → `5` (an omitted `character` is the same); + - `modules[].state` omitted → `"active"` when the type can be activated (has an active or target effect and + `activationBlocked` ≤ 0 — the `type` RPC `allowed_states` rule), else `"online"`; an unknown type is left + alone so the usual error reports it; + - `drones[].active` omitted → `quantity` (every drone active); `fighters[].active` already defaults true; + - everything else uses the existing FitRequest defaults at build time. +- Normalization recurses into every nested FitRequest (`projected[kind=fit].fit`, `fleet.booster_fits[]`, + `scenarios[].target.fit`). Under `operation=calc` each defaulted category is reported once as an + `adjustments[]` entry `{"code": "DEFAULTED", "path": "/character/skills/default_level" | "/modules/*/state" | + "/drones/*/active", "from": null, "to": , "message": "compute shorthand + default applied"}` (top-level fit only). Under `operation=batch` the `base` and each `fits[].fit` are + normalized silently *before* expansion, so patches act on a fully normalized base. + +**`select` on `projected[kind=fit]`** chooses which of the source fit's items project onto this fit: + +```jsonc +{"kind": "fit", "fit": { /* FitSpec */ }, "select": {"module_ids": ["rep-1"]}, "amount": 1, "distance_m": 8000} +``` + +- `select` omitted → the existing behaviour: every module at state ≥ active, every active drone (its `active` + count), every active fighter squadron. +- `select` present → an explicit whitelist per kind: a module projects only when its `id` is in `module_ids`, + a drone only in `drone_ids`, a fighter only in `fighter_ids` (the usual active-state rules still apply). A + kind whose list is absent contributes **nothing**; `{"module_ids": []}` projects no modules. +- The source fit is still computed completely first (all its modules, skills, implants, boosters and buffs + shape its attributes); `select` only filters which items' projection reaches the target. Every id in a + provided list that names no item of that kind in the source fit yields one `warnings[]` entry + (`projected fit select: no item with id ''`). +- `select` on a `projected` entry whose `kind` is not `"fit"` is ignored (a `warnings[]` entry notes it). + ## Changelog - v1 (2026-10-03): initial contract. - v1.1 (2026-10-03): `fleet.booster_fits` implemented (oracle-verified). `projected[kind=fit]` and charges on diff --git a/crates/exfa-cli/src/main.rs b/crates/exfa-cli/src/main.rs index 64fb083..a4606df 100644 --- a/crates/exfa-cli/src/main.rs +++ b/crates/exfa-cli/src/main.rs @@ -9,10 +9,11 @@ const USAGE: &str = "exfa [args] (dataset compiled in; --dataset PAT Commands: calc [FILE] FitRequest JSON (file or stdin) -> FitStats JSON + compute [FILE] exfa/compute@1 envelope (file or stdin) -> one exfa/compute-result@1 JSON line batch JSONL FitRequests on stdin -> JSONL FitStats on stdout (a BatchRequest line -> one BatchResponse line) batch --request FILE|- BatchRequest JSON (docs/23: fits / variants / product / sweep) -> BatchResponse JSON optimize [FILE] OptimizeRequest JSON (docs/21) -> ranked fits - serve-stdio JSONL RPC: {\"id\":..,\"method\":\"calc|batch|prices_load|version|sde_override|optimize|graph|search|type|meta|eft_parse|eft_export|format_import|format_export|fits.backup|item.variations|item.compare|market.group|market.search|implant_sets.list|character.import_evemon|names.resolve|pyfa_data_load|pyfa_data_status\",\"params\":..} + serve-stdio JSONL RPC: {\"id\":..,\"method\":\"calc|batch|compute|prices_load|version|sde_override|optimize|graph|search|type|meta|eft_parse|eft_export|format_import|format_export|fits.backup|item.variations|item.compare|market.group|market.search|implant_sets.list|character.import_evemon|names.resolve|pyfa_data_load|pyfa_data_status\",\"params\":..} eft [FILE] EFT text (file or stdin) -> FitRequest JSON (add --calc to compute, --skills N) search QUERY [--limit N] [--kinds ship,module,..] search types by name (exact > prefix > substring) type ID|NAME show type with base attributes @@ -119,6 +120,15 @@ fn main() { std::process::exit(2); } } + "compute" => { + let s = read_input(args.get(1)); + let res = exfa_core::compute_json(&s); + writeln!(out, "{res}").or_pipe(); + out.flush().or_pipe(); + if serde_json::from_str::(&res).ok().and_then(|v| v.get("error").cloned()).is_some() { + std::process::exit(2); + } + } "batch" => { if let Some(f) = take_flag(&mut args, "--request") { let res = exfa_core::batch_json(&read_input(Some(&f))); diff --git a/crates/exfa-core/src/compute.rs b/crates/exfa-core/src/compute.rs new file mode 100644 index 0000000..845dd1e --- /dev/null +++ b/crates/exfa-core/src/compute.rs @@ -0,0 +1,212 @@ +//! Unified compute envelope `exfa/compute@1` -> `exfa/compute-result@1` (docs/27 §5.3): ONE input object, +//! ONE output object. `operation=calc` takes a `FitSpec` and returns FitStats; `operation=batch` takes a +//! `BatchSpec` (the existing BatchRequest) and returns the BatchResponse. The workspace/group semantics and +//! host-side references stay outside; the engine only sees fully resolved fits. +//! +//! FitSpec = FitRequest + stable equipment `id`s (modules/drones/fighters, used by +//! `projected[kind=fit].select`) + shorthand normalization, applied by this entry point only: omitted +//! `character.skills.default_level` = 5, omitted module `state` = `active` when the type can activate else +//! `online`, omitted drone `active` = `quantity` (fighters already default active). Explicit values win +//! (`default_level: 0` stays 0). Normalization recurses into every nested FitRequest (`projected[kind=fit].fit`, +//! `fleet.booster_fits[]`, `scenarios[].target.fit`); for `operation=calc` the top-level defaults are reported +//! as `DEFAULTED` adjustments, for `operation=batch` the same rules apply silently before expansion. +use crate::data::{self as d, a}; +use crate::j::J; +use crate::jv; +use crate::request::{FitRequest, State}; +use serde_json::{json, Value}; +use std::ops::IndexMut; + +pub const FORMAT: &str = "exfa/compute@1"; +pub const RESULT_FORMAT: &str = "exfa/compute-result@1"; + +fn err_env(op: &str, code: &str, message: impl Into, path: &str) -> Value { + json!({"format": RESULT_FORMAT, "operation": op, "error": {"code": code, "message": message.into(), "path": path}}) +} + +/// Best-effort `operation` for the error envelope of an unparseable request. +fn recover_op(s: &str) -> Option { + let k = s.find("\"operation\"")? + "\"operation\"".len(); + let rest = s[k..].trim_start().strip_prefix(':')?.trim_start().strip_prefix('"')?; + rest.find('"').map(|e| rest[..e].to_string()) +} + +/// The engine-side `state` shorthand default (Pyfa isValidState, same rule as `type` RPC `allowed_states`): +/// `active` when the type has an activatable effect (category 1|2) and `activationBlocked` <= 0, else `online`. +/// Unknown types stay `None` so the existing error path reports them. +fn state_default(type_id: u32) -> Option { + let ty = d::type_index(type_id)?; + let can_active = d::type_effects(ty).iter().any(|&x| matches!(d::EFF_META[(x >> 1) as usize].cat, 1 | 2)) + && d::type_attr(ty, a::activationBlocked).unwrap_or(0.0) <= 0.0; + Some(if can_active { State::Active } else { State::Online }) +} + +/// Defaults applied to the top-level fit (calc reports them as `DEFAULTED` adjustments). +#[derive(Default)] +struct Notes { + default_level: bool, + module_states: Vec, + drone_actives: Vec, +} + +/// Normalize a FitSpec in place, recursing into every nested FitRequest. Notes are recorded for the +/// top-level fit only (the existing convention: `adjustments` reports the top-level request). +fn normalize(req: &mut FitRequest, top: bool, notes: &mut Notes) { + if req.character.skills.default_level.is_none() { + req.character.skills.default_level = Some(5); + if top { + notes.default_level = true; + } + } + for m in &mut req.modules { + if m.state.is_none() { + if let Some(st) = state_default(m.type_id) { + m.state = Some(st); + if top { + notes.module_states.push(st); + } + } + } + } + for dr in &mut req.drones { + if dr.active.is_none() { + dr.active = Some(dr.quantity); + if top { + notes.drone_actives.push(dr.quantity); + } + } + } + for bf in &mut req.fleet.booster_fits { + normalize(bf, false, notes); + } + for p in &mut req.projected { + if p.kind == "fit" { + if let Some(f) = p.fit.as_deref_mut() { + normalize(f, false, notes); + } + } + } + if let Some(Value::Array(list)) = req.scenarios.as_mut() { + for s in list.iter_mut() { + if let Some(v) = s.get_mut("target").and_then(|t| t.get_mut("fit")).filter(|v| v.is_object()) { + normalize_fit_json(v); + } + } + } +} + +/// Normalize a FitSpec held as JSON (batch base / fits[].fit / scenario target.fit); on a shape error the +/// value is left as-is so the downstream path reports it. +fn normalize_fit_json(v: &mut Value) { + if let Ok(mut req) = serde_json::from_value::(v.clone()) { + normalize(&mut req, false, &mut Notes::default()); + if let Ok(nv) = serde_json::to_value(&req) { + *v = nv; + } + } +} + +/// One `DEFAULTED` adjustment per defaulted category (wildcard paths cover every defaulted item; `to` lists +/// the applied values in request order). +fn defaulted_adjustments(n: &Notes) -> Vec { + let mut v = Vec::new(); + if n.default_level { + v.push(jv!({"code": "DEFAULTED", "path": "/character/skills/default_level", "from": J::Null, "to": 5u8, "message": "compute shorthand default applied"})); + } + if !n.module_states.is_empty() { + v.push(jv!({"code": "DEFAULTED", "path": "/modules/*/state", "from": J::Null, "to": n.module_states.clone(), "message": "compute shorthand default applied"})); + } + if !n.drone_actives.is_empty() { + v.push(jv!({"code": "DEFAULTED", "path": "/drones/*/active", "from": J::Null, "to": n.drone_actives.clone(), "message": "compute shorthand default applied"})); + } + v +} + +/// Wrap an inner `{"error": {...}}` output (calc or batch) into the error envelope, keeping its code/message/path. +fn inner_error(op: &str, e: &Value) -> Value { + let mut e = e.clone(); + if let Some(m) = e.as_object_mut() { + m.entry("path").or_insert(json!("")); + } + json!({"format": RESULT_FORMAT, "operation": op, "error": e}) +} + +fn calc_op(input: &Value) -> Value { + let fit_v = match input.get("fit") { + Some(v) if v.is_object() => v.clone(), + _ => return err_env("calc", "BAD_REQUEST", "fit must be a FitRequest object", "/fit"), + }; + let mut req: FitRequest = match serde_json::from_value(fit_v) { + Ok(r) => r, + Err(e) => return err_env("calc", "BAD_REQUEST", e.to_string(), "/fit"), + }; + let mut notes = Notes::default(); + normalize(&mut req, true, &mut notes); + let mut out = crate::calc(&req); + if let J::O(o) = &out { + if let Some((_, e)) = o.iter().find(|(k, _)| k.as_ref() == "error") { + return inner_error("calc", &e.to_value_raw()); + } + } + if let J::O(_) = &out { + let adj = out.index_mut("adjustments"); + if !matches!(adj, J::A(_)) { + *adj = J::A(Vec::new()); + } + if let J::A(a) = adj { + a.extend(defaulted_adjustments(¬es)); + } + } + let result = if req.options.full_precision { out.to_value_raw() } else { serde_json::to_value(&out).unwrap_or(Value::Null) }; + json!({"format": RESULT_FORMAT, "operation": "calc", "result": result}) +} + +fn batch_op(input: &Value) -> Value { + let mut b = match input.get("batch") { + Some(v) if v.is_object() => v.clone(), + _ => return err_env("batch", "BAD_REQUEST", "batch must be a BatchRequest object", "/batch"), + }; + // FitSpec shorthand applies to the base and every explicit fit BEFORE expansion, so patches act on a + // fully normalized base and there is no shorthand-vs-normalized ambiguity downstream. + if let Some(v) = b.get_mut("base").filter(|v| v.is_object()) { + normalize_fit_json(v); + } + if let Some(fits) = b.get_mut("fits").and_then(Value::as_array_mut) { + for it in fits.iter_mut() { + if let Some(v) = it.get_mut("fit").filter(|v| v.is_object()) { + normalize_fit_json(v); + } + } + } + let r = crate::batch::run(&b); + match r.get("error") { + Some(e) => inner_error("batch", e), + None => json!({"format": RESULT_FORMAT, "operation": "batch", "result": r}), + } +} + +/// `compute` params (`exfa/compute@1`) -> `exfa/compute-result@1`. +pub fn compute(input: &Value) -> Value { + if input.get("format").and_then(Value::as_str) != Some(FORMAT) { + let op = input.get("operation").and_then(Value::as_str).unwrap_or("calc"); + return err_env(op, "UNSUPPORTED_FORMAT", "format must be \"exfa/compute@1\"", "/format"); + } + let op = input.get("operation").and_then(Value::as_str).unwrap_or(""); + match op { + "calc" => calc_op(input), + "batch" => batch_op(input), + _ => err_env(if op.is_empty() { "calc" } else { op }, "BAD_REQUEST", "operation must be \"calc\" or \"batch\"", "/operation"), + } +} + +/// JSON string in, JSON string out; never panics and always answers with the result envelope. +pub fn compute_json(input: &str) -> String { + let v = match serde_json::from_str::(input) { + Ok(v) => compute(&v), + Err(e) => { + let op = recover_op(input).unwrap_or_else(|| "calc".into()); + err_env(&op, "BAD_JSON", e.to_string(), "") + } + }; + serde_json::to_string(&v).unwrap_or_default() +} diff --git a/crates/exfa-core/src/engine.rs b/crates/exfa-core/src/engine.rs index 03dd96f..cad1bdf 100644 --- a/crates/exfa-core/src/engine.rs +++ b/crates/exfa-core/src/engine.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use crate::data::{self as d, a, e}; use crate::j::J; -use crate::request::{FitRequest, ModuleReq, Slot, Spool, State}; +use crate::request::{FitRequest, ModuleReq, Select, Slot, Spool, State}; use std::cell::Cell; /// Source categories exempt from stacking penalties: Ship, Charge, Skill, Implant, Subsystem, Structure. @@ -120,6 +120,8 @@ pub struct Item { pub distance: Option, /// mutated (rolled) attributes: request overrides do not replace them (Pyfa reads mutators after overrides) pub rolled: Vec, + /// stable caller-chosen id (FitSpec `id` on modules/drones/fighters; projected-fit `select` refers to it) + pub id: Option, } impl Item { @@ -153,8 +155,8 @@ pub struct Fit { restrict: bool, /// published skills handled by compiled, level-folded code: (index into PUBLISHED_SKILLS, level) folded: Vec<(u16, u8)>, - /// projected fits: (fit, amount, distance) - ext: Vec<(Fit, u32, Option)>, + /// projected fits: (fit, amount, distance, item select whitelist) + ext: Vec<(Fit, u32, Option, Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/exfa-core/tests/compute.rs b/crates/exfa-core/tests/compute.rs new file mode 100644 index 0000000..e1b3e7e --- /dev/null +++ b/crates/exfa-core/tests/compute.rs @@ -0,0 +1,196 @@ +//! exfa/compute@1 -> exfa/compute-result@1 (docs/27 §5.3): envelope, FitSpec shorthand normalization, +//! batch normalization-first, projected-fit `select`, and error envelopes. +use serde_json::{json, Value}; + +fn compute(req: Value) -> Value { + serde_json::from_str(&exfa_core::compute_json(&req.to_string())).unwrap() +} +fn calc_result(fit: Value) -> Value { + compute(json!({"format": "exfa/compute@1", "operation": "calc", "fit": fit})) +} +fn calc_raw(fit: &Value) -> Value { + let r: exfa_core::FitRequest = serde_json::from_value(fit.clone()).unwrap(); + serde_json::to_value(exfa_core::calc(&r)).unwrap() +} +/// Stats equality ignoring `adjustments` (compute calc may append DEFAULTED entries). +fn stats_eq(a: &Value, b: &Value) -> bool { + let (mut a, mut b) = (a.clone(), b.clone()); + for v in [&mut a, &mut b] { + v.as_object_mut().unwrap().remove("adjustments"); + } + a == b +} +fn velocity(r: &Value) -> f64 { + r["result"]["navigation"]["max_velocity"].as_f64().unwrap() +} + +#[test] +fn shorthand_equals_explicit_and_defaulted_adjustments() { + // 439 = 1MN Afterburner I (activatable): omitted state -> "active", omitted skills -> all 5. + let short = calc_result(json!({"ship": {"type_id": 587}, "modules": [{"type_id": 439}]})); + let explicit = calc_result( + json!({"ship": {"type_id": 587}, "character": {"skills": {"default_level": 5}}, "modules": [{"type_id": 439, "state": "active"}]}), + ); + assert!(stats_eq(&short["result"], &explicit["result"]), "shorthand == explicit {}", short); + let adj = short["result"]["adjustments"].as_array().unwrap(); + assert!(adj.iter().any(|a| a["code"] == "DEFAULTED" && a["path"] == "/character/skills/default_level" && a["to"] == 5)); + assert!(adj.iter().any(|a| a["code"] == "DEFAULTED" && a["path"] == "/modules/*/state" && a["to"] == json!(["active"]))); + assert_eq!(explicit["result"]["adjustments"].as_array().unwrap().len(), 0); + // a module that cannot activate (519 = Gyrostabilizer II) defaults to "online", not "active" + let passive = calc_result(json!({"ship": {"type_id": 587}, "modules": [{"type_id": 519}]})); + assert_eq!(passive["result"]["modules"][0]["state"], "online"); + assert_eq!(velocity(&short), velocity(&explicit)); +} + +#[test] +fn explicit_values_always_win() { + let v_short = velocity(&calc_result(json!({"ship": {"type_id": 587}, "modules": [{"type_id": 439}]}))); + // explicit all-0 / online is preserved (not upgraded to 5 / active) + let zero = calc_result( + json!({"ship": {"type_id": 587}, "character": {"skills": {"default_level": 0}}, "modules": [{"type_id": 439, "state": "online"}]}), + ); + assert_eq!(zero["result"]["adjustments"].as_array().unwrap().len(), 0); + assert_eq!(zero["result"]["modules"][0]["state"], "online"); + assert!(velocity(&zero) < v_short, "online+0 skills is slower than active+5"); + // and equals the raw calc of the same explicit request + assert!(stats_eq( + &zero["result"], + &calc_raw(&json!({"ship": {"type_id": 587}, "character": {"skills": {"default_level": 0}}, "modules": [{"type_id": 439, "state": "online"}]})) + )); +} + +#[test] +fn drone_active_defaults_to_quantity() { + // 2185 = Hammerhead II + let short = calc_result(json!({"ship": {"type_id": 587}, "drones": [{"type_id": 2185, "quantity": 5}]})); + let explicit = calc_result(json!({"ship": {"type_id": 587}, "drones": [{"type_id": 2185, "quantity": 5, "active": 5}]})); + assert_eq!(short["result"]["drones"]["active"], 5); + assert!(stats_eq(&short["result"], &explicit["result"])); + assert!(short["result"]["adjustments"].as_array().unwrap().iter().any(|a| a["path"] == "/drones/*/active")); + let parked = calc_result(json!({"ship": {"type_id": 587}, "drones": [{"type_id": 2185, "quantity": 5, "active": 0}]})); + assert_eq!(parked["result"]["drones"]["active"], 0, "explicit active:0 preserved"); +} + +#[test] +fn batch_fits_normalize_and_echo_ids() { + let fit_a = json!({"ship": {"type_id": 587}, "modules": [{"type_id": 439}]}); + let fit_b = json!({"ship": {"type_id": 585}}); + let r = compute(json!({"format": "exfa/compute@1", "operation": "batch", "batch": { + "batch_version": 1, "fits": [{"id": "a", "label": "A", "fit": fit_a}, {"id": "b", "fit": fit_b}]}})); + assert_eq!(r["operation"], "batch"); + let res = r["result"]["results"].as_array().unwrap(); + assert_eq!(res.len(), 2); + assert_eq!(res[0]["id"], "a"); + assert_eq!(res[0]["label"], "A"); + assert_eq!(res[1]["id"], "b"); + assert_eq!(res[1]["label"], "b"); + // each untrimmed stats equals calc of the same normalized fit (with the shorthand defaults applied) + let norm_a = json!({"ship": {"type_id": 587}, "character": {"skills": {"default_level": 5}}, "modules": [{"type_id": 439, "state": "active"}]}); + let norm_b = json!({"ship": {"type_id": 585}, "character": {"skills": {"default_level": 5}}}); + assert_eq!(res[0]["stats"], calc_raw(&norm_a)); + assert_eq!(res[1]["stats"], calc_raw(&norm_b)); + // normalization is silent inside batch: no DEFAULTED adjustments on item stats + assert_eq!(res[0]["stats"]["adjustments"].as_array().unwrap().len(), 0); +} + +#[test] +fn batch_variants_on_normalized_base() { + let r = compute(json!({"format": "exfa/compute@1", "operation": "batch", "batch": { + "batch_version": 1, "base": {"ship": {"type_id": 587}, "modules": [{"type_id": 439}]}, + "variants": [ + {"id": "kept", "patch": []}, + {"id": "down", "patch": [{"op": "replace", "path": "/modules/0/state", "value": "online"}]} + ]}})); + let res = r["result"]["results"].as_array().unwrap(); + assert_eq!(res.len(), 2); + let norm = json!({"ship": {"type_id": 587}, "character": {"skills": {"default_level": 5}}, "modules": [{"type_id": 439, "state": "active"}]}); + let mut down = norm.clone(); + down["modules"][0]["state"] = json!("online"); + assert_eq!(res[0]["stats"], calc_raw(&norm)); + assert_eq!(res[1]["stats"], calc_raw(&down)); + assert!(res[0]["stats"]["navigation"]["max_velocity"].as_f64() > res[1]["stats"]["navigation"]["max_velocity"].as_f64()); +} + +/// Source fit: two active Stasis Webifier I (526), stable ids m1/m2. +fn web_source() -> Value { + json!({"ship": {"type_id": 587}, "character": {"skills": {"default_level": 5}}, + "modules": [{"id": "m1", "type_id": 526, "state": "active"}, {"id": "m2", "type_id": 526, "state": "active"}]}) +} +fn projected(select: Value) -> Value { + let mut e = json!({"kind": "fit", "fit": web_source()}); + if !select.is_null() { + e["select"] = select; + } + calc_result(json!({"ship": {"type_id": 587}, "character": {"skills": {"default_level": 5}}, "projected": [e]})) +} + +#[test] +fn projected_fit_select_whitelist() { + let v_none = velocity(&projected(Value::Null)); + let v_m1 = velocity(&projected(json!({"module_ids": ["m1"]}))); + let v_both = velocity(&projected(json!({"module_ids": ["m1", "m2"]}))); + let v_empty = velocity(&projected(json!({"module_ids": []}))); + assert!(v_none < v_m1 && v_m1 < v_empty, "one web slows less than two, none slows not at all: {v_none} {v_m1} {v_empty}"); + assert_eq!(v_both, v_none, "selecting both == no select"); + // select m1 == a source that only carries m1 + let only_m1 = calc_result(json!({"ship": {"type_id": 587}, "character": {"skills": {"default_level": 5}}, + "projected": [{"kind": "fit", "fit": {"ship": {"type_id": 587}, "character": {"skills": {"default_level": 5}}, + "modules": [{"id": "m1", "type_id": 526, "state": "active"}]}}]})); + assert_eq!(v_m1, velocity(&only_m1), "select m1 == source with only m1"); + // unselected kinds contribute nothing: drone list present-but-empty + no module list -> no modules + let v_drones_only = velocity(&projected(json!({"drone_ids": []}))); + assert_eq!(v_drones_only, v_empty, "absent module_ids contributes no modules"); + // unmatched id -> one deduplicated warning, the matched id still projects + let miss = projected(json!({"module_ids": ["m1", "nope"]})); + assert_eq!(velocity(&miss), v_m1); + let w = miss["result"]["warnings"].as_array().unwrap(); + assert_eq!(w.iter().filter(|x| **x == "projected fit select: no module item with id 'nope'").count(), 1); + // select on kind != fit is ignored (warning, no error) + let other = calc_result(json!({"ship": {"type_id": 587}, "projected": [ + {"kind": "module", "module": {"type_id": 526, "state": "active"}, "select": {"module_ids": ["x"]}}]})); + assert!(other["result"]["error"].is_null()); + assert!(other["result"]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("select"))); +} + +#[test] +fn error_envelopes() { + let bad_format = compute(json!({"format": "nope", "operation": "calc", "fit": {}})); + assert_eq!(bad_format["format"], "exfa/compute-result@1"); + assert_eq!(bad_format["error"]["code"], "UNSUPPORTED_FORMAT"); + for op in [json!("bogus"), json!(null)] { + let r = compute(json!({"format": "exfa/compute@1", "operation": op})); + assert_eq!(r["error"]["code"], "BAD_REQUEST", "{op}"); + } + // inner fit error propagates with its code/message/path + let inner = calc_result(json!({"ship": {"type_id": 999999999}})); + assert_eq!(inner["operation"], "calc"); + assert_eq!(inner["error"]["code"], "UNKNOWN_TYPE"); + assert_eq!(inner["error"]["path"], "/ship/type_id"); + // missing fit / batch payloads + assert_eq!(compute(json!({"format": "exfa/compute@1", "operation": "calc"}))["error"]["code"], "BAD_REQUEST"); + assert_eq!(compute(json!({"format": "exfa/compute@1", "operation": "batch"}))["error"]["code"], "BAD_REQUEST"); + // malformed JSON still returns the error envelope (operation recovered when possible) + let malformed: Value = serde_json::from_str(&exfa_core::compute_json("{bad \"operation\":\"batch\"}")).unwrap(); + assert_eq!(malformed["format"], "exfa/compute-result@1"); + assert_eq!(malformed["operation"], "batch"); + assert!(malformed["error"]["code"].is_string()); + let no_op: Value = serde_json::from_str(&exfa_core::compute_json("{bad")).unwrap(); + assert_eq!(no_op["operation"], "calc"); +} + +#[test] +fn rpc_method_and_full_precision() { + let p = json!({"format": "exfa/compute@1", "operation": "calc", "fit": {"ship": {"type_id": 587}, "modules": [{"type_id": 439}]}}); + let r = exfa_core::rpc(&json!({"id": 7, "method": "compute", "params": p}).to_string()); + assert_eq!(r["id"], 7); + assert_eq!(r["result"]["format"], "exfa/compute-result@1"); + assert!(r["result"]["result"]["navigation"]["max_velocity"].is_number()); + // options.full_precision inside the FitSpec still takes the raw path: unrounded floats + let p = json!({"format": "exfa/compute@1", "operation": "calc", "fit": {"ship": {"type_id": 587}, "options": {"full_precision": true}}}); + let v = compute(p)["result"]["capacitor"]["peak_recharge_gj_s"].as_f64().unwrap(); + let rounded = compute(json!({"format": "exfa/compute@1", "operation": "calc", "fit": {"ship": {"type_id": 587}}}))["result"]["capacitor"]["peak_recharge_gj_s"] + .as_f64() + .unwrap(); + assert_eq!(rounded, (v * 1e6).round() / 1e6, "raw path keeps >6 decimals: {v} vs {rounded}"); + assert!(v != rounded, "full_precision bypasses the 6-decimal rounding"); +} diff --git a/crates/exfa-formats/src/eft.rs b/crates/exfa-formats/src/eft.rs index eec4ad8..aa21479 100644 --- a/crates/exfa-formats/src/eft.rs +++ b/crates/exfa-formats/src/eft.rs @@ -125,8 +125,8 @@ pub fn parse(text: &str) -> Result { } let ix = type_ix(tid).ok_or(format!("unknown item '{name}'"))?; match d::ty(ix).category { - CAT_DRONE => req.drones.push(DroneReq { type_id: tid, quantity: n, active: Some(n), mutation: mutation.clone() }), - CAT_FIGHTER => req.fighters.push(FighterReq { type_id: tid, quantity: Some(n), active: true, abilities: None }), + CAT_DRONE => req.drones.push(DroneReq { type_id: tid, id: None, quantity: n, active: Some(n), mutation: mutation.clone() }), + CAT_FIGHTER => req.fighters.push(FighterReq { type_id: tid, id: None, quantity: Some(n), active: true, abilities: None }), _ => req.cargo.push(CargoReq { type_id: tid, quantity: n }), } continue; @@ -149,7 +149,7 @@ pub fn parse(text: &str) -> Result { req.implants.push(tid) } } - CAT_DRONE => req.drones.push(DroneReq { type_id: tid, quantity: 1, active: Some(1), mutation: mutation.clone() }), + CAT_DRONE => req.drones.push(DroneReq { type_id: tid, id: None, quantity: 1, active: Some(1), mutation: mutation.clone() }), CAT_CHARGE => req.cargo.push(CargoReq { type_id: tid, quantity: 1 }), _ => { if t.group == GROUP_T3D_MODE { @@ -171,7 +171,7 @@ pub fn parse(text: &str) -> Result { } else { crate::formats::import_state(tid) }; - req.modules.push(ModuleReq { type_id: tid, slot, state: Some(state), charge_type_id, mutation: mutation.clone(), spool: None }); + req.modules.push(ModuleReq { type_id: tid, id: None, slot, state: Some(state), charge_type_id, mutation: mutation.clone(), spool: None }); } } } diff --git a/crates/exfa-formats/src/formats.rs b/crates/exfa-formats/src/formats.rs index cef359e..25e2a8a 100644 --- a/crates/exfa-formats/src/formats.rs +++ b/crates/exfa-formats/src/formats.rs @@ -630,7 +630,7 @@ fn new_req(ship: u32) -> FitRequest { } fn module_req(t: u32, mutation: Option) -> ModuleReq { - ModuleReq { type_id: t, slot: ix(t).and_then(infer_slot), state: Some(import_state(t)), charge_type_id: None, mutation, spool: None } + ModuleReq { type_id: t, id: None, slot: ix(t).and_then(infer_slot), state: Some(import_state(t)), charge_type_id: None, mutation, spool: None } } /// An imported fit: the request plus Pyfa's fit name / notes. @@ -696,10 +696,10 @@ pub fn dna_import(text: &str, fit_name: Option<&str>, alt: bool) -> Result return Err("Passed item is not a Drone".into()), - CAT_DRONE => req.drones.push(DroneReq { type_id: id, quantity: amount, active: Some(0), mutation: None }), + CAT_DRONE => req.drones.push(DroneReq { type_id: id, id: None, quantity: amount, active: Some(0), mutation: None }), CAT_FIGHTER => { if fighter_fits(req.ship.type_id, id) { - req.fighters.push(FighterReq { type_id: id, quantity: Some(amount), active: true, abilities: None }) + req.fighters.push(FighterReq { type_id: id, id: None, quantity: Some(amount), active: true, abilities: None }) } } CAT_CHARGE => req.cargo.push(CargoReq { type_id: id, quantity: amount }), @@ -751,7 +751,7 @@ pub fn esi_import(text: &str) -> Result { match flag as u64 { FLAG_DRONE => { if category(t) == CAT_DRONE { - req.drones.push(DroneReq { type_id: t, quantity: q, active: Some(0), mutation: None }) + req.drones.push(DroneReq { type_id: t, id: None, quantity: q, active: Some(0), mutation: None }) } } FLAG_CARGO => req.cargo.push(CargoReq { type_id: t, quantity: q }), @@ -759,7 +759,7 @@ pub fn esi_import(text: &str) -> Result { if category(t) == CAT_FIGHTER { // ESI quantity is ignored: a full squadron let q = base_attr(t, d::a::fighterSquadronMaxSize).map(|v| v as u32); - req.fighters.push(FighterReq { type_id: t, quantity: q, active: true, abilities: None }) + req.fighters.push(FighterReq { type_id: t, id: None, quantity: q, active: true, abilities: None }) } } _ => { @@ -992,9 +992,9 @@ pub fn xml_import(text: &str) -> Result, String> { match category(t) { CAT_DRONE => { let tt = fitted_type(t, &mutation); - req.drones.push(DroneReq { type_id: tt, quantity: qty, active: Some(0), mutation }) + req.drones.push(DroneReq { type_id: tt, id: None, quantity: qty, active: Some(0), mutation }) } - CAT_FIGHTER => req.fighters.push(FighterReq { type_id: t, quantity: Some(qty), active: true, abilities: None }), + CAT_FIGHTER => req.fighters.push(FighterReq { type_id: t, id: None, quantity: Some(qty), active: true, abilities: None }), _ if attr(&ha, "slot").to_lowercase() == "cargo" => req.cargo.push(CargoReq { type_id: t, quantity: qty }), _ => { if !is_module_cat(t) || ix(t).and_then(infer_slot).is_none() { @@ -1197,11 +1197,11 @@ pub fn eft_import(text: &str) -> Result { let mutation = mutation_for(t, r); if mutation.is_some() { let tt = fitted_type(t, &mutation); - req.drones.push(DroneReq { type_id: tt, quantity: n, active: Some(0), mutation }); + req.drones.push(DroneReq { type_id: tt, id: None, quantity: n, active: Some(0), mutation }); } else { match req.drones.iter_mut().find(|x| x.type_id == t && x.mutation.is_none()) { Some(x) => x.quantity += n, - None => req.drones.push(DroneReq { type_id: t, quantity: n, active: Some(0), mutation: None }), + None => req.drones.push(DroneReq { type_id: t, id: None, quantity: n, active: Some(0), mutation: None }), } } }; @@ -1245,7 +1245,7 @@ pub fn eft_import(text: &str) -> Result { } else if all(&|x| spec_multi_cat(x, CAT_FIGHTER)) { for x in s.iter().flatten() { if let Spec::Multi(t, n, _) = x { - req.fighters.push(FighterReq { type_id: *t, quantity: Some(*n), active: true, abilities: None }); + req.fighters.push(FighterReq { type_id: *t, id: None, quantity: Some(*n), active: true, abilities: None }); } } } else if all(&|x| matches!(x, Some(Spec::Multi(..)))) { @@ -1273,7 +1273,7 @@ pub fn eft_import(text: &str) -> Result { if c == CAT_DRONE && !has_drone_bay { add_drone(*t, *n, *r, &mut req); } else if c == CAT_FIGHTER && !has_fighter_bay { - req.fighters.push(FighterReq { type_id: *t, quantity: Some(*n), active: true, abilities: None }); + req.fighters.push(FighterReq { type_id: *t, id: None, quantity: Some(*n), active: true, abilities: None }); } else { add_cargo(*t, *n, &mut cargo); } @@ -1371,8 +1371,8 @@ pub fn eftcfg_import(text: &str, ship_name: &str) -> Result, Strin let (n, amount) = num(v); let Some(t) = type_by_name(&n) else { continue }; match category(t) { - CAT_DRONE => req.drones.push(DroneReq { type_id: t, quantity: amount, active: Some(if k == "Drones_Active" { amount } else { 0 }), mutation: None }), - CAT_FIGHTER => req.fighters.push(FighterReq { type_id: t, quantity: Some(amount), active: true, abilities: None }), + CAT_DRONE => req.drones.push(DroneReq { type_id: t, id: None, quantity: amount, active: Some(if k == "Drones_Active" { amount } else { 0 }), mutation: None }), + CAT_FIGHTER => req.fighters.push(FighterReq { type_id: t, id: None, quantity: Some(amount), active: true, abilities: None }), _ => {} } } diff --git a/crates/exfa-optimizer/src/lib.rs b/crates/exfa-optimizer/src/lib.rs index 22992ce..1e76bb7 100644 --- a/crates/exfa-optimizer/src/lib.rs +++ b/crates/exfa-optimizer/src/lib.rs @@ -478,7 +478,7 @@ impl<'a, E: Evaluator> Ctx<'a, E> { } r.modules = mods; if let Some((t, q, act)) = s.drones { - r.drones = vec![DroneReq { type_id: t, quantity: q, active: Some(act), mutation: None }]; + r.drones = vec![DroneReq { type_id: t, id: None, quantity: q, active: Some(act), mutation: None }]; } (r, at) } @@ -492,7 +492,7 @@ impl<'a, E: Evaluator> Ctx<'a, E> { return m; } let state = if pos.slot == Slot::Rig { State::Online } else { pos.base_state.filter(|s| *s != State::Offline).unwrap_or(State::Active) }; - ModuleReq { type_id: pk.type_id, slot: Some(pos.slot), state: Some(state), charge_type_id: pk.charge, mutation: None, spool: None } + ModuleReq { type_id: pk.type_id, id: None, slot: Some(pos.slot), state: Some(state), charge_type_id: pk.charge, mutation: None, spool: None } } fn price_of(&self, r: &FitRequest) -> Result, OptError> {