diff --git a/Cargo.lock b/Cargo.lock index cdc680e..134f362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "exfa-cli" -version = "0.1.3" +version = "0.2.0" dependencies = [ "exfa-core", "exfa-formats", @@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "exfa-codegen" -version = "0.1.3" +version = "0.2.0" dependencies = [ "flate2", "serde_json", @@ -43,7 +43,7 @@ dependencies = [ [[package]] name = "exfa-core" -version = "0.1.3" +version = "0.2.0" dependencies = [ "exfa-codegen", "flate2", @@ -55,7 +55,7 @@ dependencies = [ [[package]] name = "exfa-formats" -version = "0.1.3" +version = "0.2.0" dependencies = [ "exfa-core", "serde_json", @@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "exfa-formats-wasm" -version = "0.1.3" +version = "0.2.0" dependencies = [ "exfa-formats", "serde_json", @@ -71,7 +71,7 @@ dependencies = [ [[package]] name = "exfa-optimizer" -version = "0.1.3" +version = "0.2.0" dependencies = [ "exfa-core", "serde", @@ -80,9 +80,10 @@ dependencies = [ [[package]] name = "exfa-wasm" -version = "0.1.3" +version = "0.2.0" dependencies = [ "exfa-core", + "exfa-formats", "exfa-optimizer", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 850c5f7..345567d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ default-members = [ ] [workspace.package] -version = "0.1.3" +version = "0.2.0" edition = "2021" license = "LGPL-3.0-or-later" repository = "https://github.com/EX-CT/EXFA-Engine" diff --git a/ci/round1-base.sha256 b/ci/round1-base.sha256 index 0a8432a..a16addd 100644 --- a/ci/round1-base.sha256 +++ b/ci/round1-base.sha256 @@ -1 +1 @@ -15a7de5171113377fadf902d16fd6470f73431a4a03c7ab7943cc08f87eb7fe2 +09dda66a3dcb42bbf7bf1acd4dd2b9c7faf6102081df650811feecfc1c93c827 diff --git a/ci/round1-new-keys.txt b/ci/round1-new-keys.txt index b7a4ef3..3033df5 100644 --- a/ci/round1-new-keys.txt +++ b/ci/round1-new-keys.txt @@ -9,3 +9,4 @@ modules[].heat violations[].skill_type_id violations[].level provenance +adjustments diff --git a/ci/round1.sha256 b/ci/round1.sha256 index 6daebf0..729bfac 100644 --- a/ci/round1.sha256 +++ b/ci/round1.sha256 @@ -1 +1 @@ -2142f097d8c37d25ff094c1e436c687fd20d6ce797b37bdfabd4916b8e3a6b9c +97996e846a8e14bae8c10417671c53f9b6519b265f397cb9df56982d7026f761 diff --git a/contract/CONTRACT-GRAPHS.md b/contract/CONTRACT-GRAPHS.md index f0a2012..4203ef2 100644 --- a/contract/CONTRACT-GRAPHS.md +++ b/contract/CONTRACT-GRAPHS.md @@ -1,9 +1,9 @@ -# eve-dogma graphs contract (round 2, revision 0.3) +# eve-dogma graphs contract (round 2, revision 0.4) > **Released 2026-10-03 CST** (branch `graphs-round2`, tag `graphs-v0.3`). Changes against 0.2 are marked **(0.3)**. > Round 2 was scored on 0.2 (frozen at bench commit `0397d95`); 0.2 scores are not comparable with 0.3 scores. -Extension of the eve-dogma request/response contract (`CONTRACT.md`, revision 1.4.3) with **Pyfa's graph +Extension of the eve-dogma request/response contract (`CONTRACT.md`, revision 1.5.0) with **Pyfa's graph subsystem** (`graphs/data/*` in Pyfa). Same style and rules as the base contract: stateless, **one JSON `GraphRequest` in → one JSON `GraphResult` out**, no clocks, no network, byte-identical output for identical input on the same dataset, unknown request fields ignored. Expected values come from Pyfa itself @@ -136,6 +136,10 @@ For every module of the source fit, of `target.fit`, and of projected / booster | `tgt_speed_pct` | % | ≥ 0 | 0.2: % of the target's max velocity (Pyfa `('tgtSpeed', '%')` normaliser: x/100 × target maxVelocity — profile `max_velocity` or the target fit's calculated speed, before the source's webs), then as `tgt_speed_mps` | | `tgt_sig_m` | m | > 0 (else `null`) | target signature radius before the source's TPs | | `tgt_sig_pct` | % | > 0 (else `null`) | 0.2: % of the target's signature radius (x/100 × profile `signature_radius` or the target fit's signatureRadius), then as `tgt_sig_m`; `null` at every point for an infinite-signature target (profile `signature_radius: null`) | +| `atk_speed_mps` | m/s | ≥ 0 | attacker speed | +| `atk_speed_pct` | % | ≥ 0 | attacker speed as a percentage of its max velocity | +| `atk_angle_deg` | deg | 0 … 360 | attacker's movement angle | +| `tgt_angle_deg` | deg | 0 … 360 | target's movement angle | | y | unit | definition | |---|---|---| @@ -201,7 +205,12 @@ with the same application math and target parameters as `damage` (settings `igno `apply_projected` map to Pyfa's `ammoOptimal*` settings). Returns `_charge_type_id` per point: **informational only** — Pyfa breaks exact DPS ties between equal-stat faction charges (e.g. Dark Blood vs True Sansha) by set iteration order, so the id is not well defined. Params: `tgt_speed_*`, `atk_speed_*`, angles as for -`damage`. +`damage`. Axes `atk_speed_mps`, `atk_speed_pct`, `atk_angle_deg`, and `tgt_angle_deg` are also available; as in +`damage`, an axis value overrides the parameter of the same name. + +FitRequest scenarios evaluate the same damage point function used by the `damage` graph sweep, not a synthetic +x-axis. For identical fit, target, point parameters, and settings, scenario `dps` and `volley` equal the graph's +`dps` and `volley` at that point. **(0.3) Quality tiers** (Pyfa `filterChargesByQuality`; cumulative): - `t1`: charges with metaGroup 1 (Tech I) or no metaGroup. @@ -368,6 +377,8 @@ in group `errors`, correct when the response is `{"error":{"code":CODE,…}}`. C ## Changelog +- 0.4: add the attacker speed and attacker/target angle axes to `damage` and `application_profile`; document + parameter override behavior and equivalence between FitRequest scenarios and damage-graph points. - 0.3 (released 2026-10-03, tag `graphs-v0.3`): wording fixes from G2's probe findings (graphs/pending.md items 1–4, 6), following eve's rulings: - sentry drones never follow; diff --git a/contract/CONTRACT.md b/contract/CONTRACT.md index 96f332f..60fe56f 100644 --- a/contract/CONTRACT.md +++ b/contract/CONTRACT.md @@ -1,4 +1,4 @@ -# eve-dogma request/response contract (v1, revision 1.4.7) +# eve-dogma request/response contract (v1, revision 1.5.0) Stateless: **one JSON `FitRequest` in → one JSON `FitStats` out.** No hidden state, no clocks, no network. The same request with the same dataset must give byte-identical output. Unknown request fields are ignored. @@ -57,6 +57,13 @@ Library (Rust): `eve_dogma::calc(&Dataset, &FitRequest) -> serde_json::Value`, ` "environment": {"effect_type_ids": [30844], "system_security": "nullsec"}, // hisec | lowsec | nullsec (default) | wspace "damage_pattern": {"em": 25, "thermal": 25, "kinetic": 25, "explosive": 25}, // incoming, for EHP/RAH (default uniform) "target_profile": {"em": 0, "thermal": 0, "kinetic": 0, "explosive": 0, "signature_radius": 125, "max_velocity": 0, "radius": null}, + "scenarios": [{ + "id": "s1", + "target": {"profile": {"em": 0.2, "thermal": 0.3, "kinetic": 0.4, "explosive": 0.5, "signature_radius": 100, "max_velocity": 500, "radius": 10, "hp": 100000}}, + "params": {"distance_m": 15000, "time_s": null, "tgt_speed_mps": null, "tgt_speed_pct": 100, "tgt_sig_m": null, + "atk_speed_mps": null, "atk_speed_pct": 0, "atk_angle_deg": 90, "tgt_angle_deg": 90}, + "settings": {"ignore_resists": false} + }], "overrides": [{"type_id": 587, "attribute_id": 37, "value": 400}], "options": { "factor_reload": false, "default_spool": null, "rah": "adapt", // "disable" = unadapted RAH @@ -67,6 +74,14 @@ Library (Rust): `eve_dogma::calc(&Dataset, &FitRequest) -> serde_json::Value`, ` } ``` +`scenarios` is optional. Each entry uses the same `target` object as the damage graph (a `profile` or `fit`, with +optional `resist_mode`), the damage graph's parameter names/defaults, and its settings. Scenario +`settings.ignore_resists` defaults to **false** (damage graphs default to true); every other setting uses the graph +default. Each scenario returns an effective `dps` and `volley` at exactly that point. These values use the same +point-evaluation function as the damage graph and therefore equal its values for the same fit, target, parameters, +and settings. A scenario error is isolated to that result and does not fail or alter the fit calculation. Scenarios +are evaluated only for the top-level request; nested fits' scenarios are ignored. + `options` omitted entirely is the same as `"options": {}`: every option takes its default, so **`validate` defaults to true** either way (violations are reported unless `validate: false` is given). @@ -153,6 +168,21 @@ earlier "unusable state keeps requested value" ruling; principle: align with Pyf the corrected `modules[N].state` (or `type` RPC `allowed_states`, added alongside). - Projected modules (`projected[kind=module]` and modules inside projected / booster fits) follow the same correction. +**`adjustments[]`, `violations[]`, `warnings[]`, and errors** (revision 1.5.0): +- `adjustments[]` records a request value that was corrected or skipped and the value used instead; fitting + continues. It is always present, including when empty, and reports corrections made to the top-level fit only. +- `violations[]` describes a fit that cannot be fitted in game. They do not fail calculation. +- `warnings[]` describes input or behavior the engine could not handle; these are not corrections. +- `error` means the calculation itself failed. Per-scenario errors appear only in that scenario's result. + +| `adjustments[].code` | `path` | Meaning | +|---|---|---| +| `STATE_CLAMPED` | `/modules/{i}/state` | Requested `active` or `overheated` state was lowered to `online`. | +| `FIGHTER_QUANTITY_CLAMPED` | `/fighters/{i}/quantity` or `/projected/{i}/fighter/quantity` | Requested fighter quantity was clamped to the squadron's legal range. | +| `SLOT_OCCUPIED_SKIPPED` | `/implants/{i}` or `/boosters/{i}/type_id` | The requested implant or booster was skipped because its slot was already occupied. | +| `SECURITY_DEFAULTED` | `/environment/system_security` | An unknown, non-empty security value was treated as `nullsec`; an omitted value is not an adjustment. | +| `MODE_DEFAULTED` | `/ship/mode_type_id` | The default tactical mode was selected because no mode was supplied. | + ## Search (`search` RPC / CLI), interim Not part of dogma scoring; the formal spec is deferred to the MCP round. Interim behaviour: @@ -190,7 +220,9 @@ charges, mutations), for the fit as Pyfa's GUI holds it (after `fill()`): `capacitor` {capacity, recharge_time_s, peak_recharge_gj_s, use_gj_s, injected_gj_s, delta_gj_s, stable, stable_percent | depletes_in_s, eve_stable_percent, sim_iterations} · `navigation` {max_velocity, align_time_s, mass, agility, signature_radius, warp_speed_au_s, max_warp_distance_au, warp_scramble_status} · `targeting` {max_targets, max_range_m, scan_resolution, sensor_strength, sensor_type, probe_size, lock_time_s{…}} · -`drones` {active, max_active, control_range_m} · `violations[]` {code, message, module_index} · `warnings[]` · `attributes` (optional). +`drones` {active, max_active, control_range_m} · `violations[]` {code, message, module_index} · +`adjustments[]` {code, path, from, to, message} · `warnings[]` · `scenario_results[]` (when scenarios are present +and non-empty; each entry is `{id,dps,volley}` or `{id,error:{code,message,path}}`) · `attributes` (optional). Units are in key suffixes (`_m`, `_s`, `_ms`, `_gj_s`, `_au`); resonances are 0..1 (1 = no resist); DPS/HP are per second / absolute. Violation codes: `CPU_OVERLOAD POWER_OVERLOAD CALIBRATION_OVERLOAD DRONE_BANDWIDTH SLOTS_EXCEEDED TURRET_HARDPOINTS @@ -374,3 +406,5 @@ case's `_fields` param lists the fields it scores). buff ids, `nothing to optimize`). - v1.4.7 (2026-10-06, applies on top of 1.4.6): `violations[]` gains DRONE_BAY, FIGHTER_BAY, CARGO_OVERLOAD and FIGHTER_TUBES for resource-bar overloads (Pyfa shows these as red bars, not fit problems). +- v1.5.0: add top-level `adjustments[]` for silent request normalization and optional `scenarios[]` / + `scenario_results[]` for fixed-target effective damage; scenario failures are isolated per result. diff --git a/contract/schema/fit-request.schema.json b/contract/schema/fit-request.schema.json index 16b7938..ab017de 100644 --- a/contract/schema/fit-request.schema.json +++ b/contract/schema/fit-request.schema.json @@ -19,7 +19,31 @@ "mutation": {"$ref": "#/$defs/mutation"}, "spool": {"$ref": "#/$defs/spool"}}}, "resists": {"type": "object", "properties": { "em": {"type": "number"}, "thermal": {"type": "number"}, "kinetic": {"type": "number"}, "explosive": {"type": "number"}}}, - "fit": {"$ref": "#"} + "fit": {"$ref": "#"}, + "scenarioTarget": {"type": "object", "properties": { + "profile": {"type": "object", "properties": { + "em": {"type": "number"}, "thermal": {"type": "number"}, "kinetic": {"type": "number"}, "explosive": {"type": "number"}, + "max_velocity": {"type": "number"}, "signature_radius": {"type": ["number", "null"]}, + "radius": {"type": ["number", "null"]}, "hp": {"type": "number"}}}, + "fit": {"$ref": "#/$defs/fit"}, + "resist_mode": {"enum": ["auto", "shield", "armor", "hull", "weighted_average"]}}, + "oneOf": [{"required": ["profile"]}, {"required": ["fit"]}]}, + "scenario": {"type": "object", "required": ["id", "target"], "properties": { + "id": {"type": "string"}, "target": {"$ref": "#/$defs/scenarioTarget"}, + "params": {"type": "object", "properties": { + "distance_m": {"type": ["number", "null"], "minimum": 0}, + "time_s": {"type": ["number", "null"], "minimum": 0, "maximum": 2500}, + "tgt_speed_mps": {"type": ["number", "null"], "minimum": 0}, + "tgt_speed_pct": {"type": ["number", "null"], "minimum": 0}, + "tgt_sig_m": {"type": ["number", "null"], "exclusiveMinimum": 0}, + "atk_speed_mps": {"type": ["number", "null"], "minimum": 0}, + "atk_speed_pct": {"type": ["number", "null"], "minimum": 0}, + "atk_angle_deg": {"type": ["number", "null"], "minimum": 0, "maximum": 360}, + "tgt_angle_deg": {"type": ["number", "null"], "minimum": 0, "maximum": 360}}}, + "settings": {"type": "object", "properties": { + "ignore_resists": {"type": "boolean"}, "apply_projected": {"type": "boolean"}, + "ignore_lock_range": {"type": "boolean"}, "ignore_drone_control_range": {"type": "boolean"}, + "mobile_drone_mode": {"enum": ["auto", "follow_attacker", "follow_target"]}}}}} }, "properties": { "schema_version": {"const": 1}, @@ -56,6 +80,7 @@ "damage_pattern": {"$ref": "#/$defs/resists"}, "target_profile": {"allOf": [{"$ref": "#/$defs/resists"}], "properties": { "signature_radius": {"type": ["number", "null"]}, "max_velocity": {"type": ["number", "null"]}, "radius": {"type": ["number", "null"]}}}, + "scenarios": {"type": "array", "items": {"$ref": "#/$defs/scenario"}}, "overrides": {"type": "array", "items": {"type": "object", "required": ["type_id", "attribute_id", "value"]}}, "options": {"type": "object", "properties": { "factor_reload": {"type": "boolean"}, "default_spool": {"$ref": "#/$defs/spool"}, diff --git a/contract/schema/fit-stats.schema.json b/contract/schema/fit-stats.schema.json index 98b5cc9..64b2f42 100644 --- a/contract/schema/fit-stats.schema.json +++ b/contract/schema/fit-stats.schema.json @@ -3,7 +3,7 @@ "$id": "https://github.com/EX-CT/eve-fit-docs/schema/fit-stats.schema.json", "title": "EXCT FitStats v1", "type": "object", - "required": ["meta", "ship", "resources", "offense", "defense", "capacitor", "navigation", "targeting"], + "required": ["meta", "ship", "resources", "offense", "defense", "capacitor", "navigation", "targeting", "adjustments"], "$defs": { "dmg": {"type": "object", "properties": {"em": {"type": "number"}, "thermal": {"type": "number"}, "kinetic": {"type": "number"}, "explosive": {"type": "number"}, "total": {"type": "number"}}}, "usage": {"type": "object", "properties": {"used": {"type": "number"}, "total": {"type": "number"}}}, @@ -25,6 +25,14 @@ "remote": {"type": "object"}, "modules": {"type": "array"}, "violations": {"type": "array", "items": {"type": "object", "required": ["code", "message"]}}, + "adjustments": {"type": "array", "items": {"type": "object", "required": ["code", "path", "from", "to", "message"], + "properties": {"code": {"enum": ["STATE_CLAMPED", "FIGHTER_QUANTITY_CLAMPED", "SLOT_OCCUPIED_SKIPPED", "SECURITY_DEFAULTED", "MODE_DEFAULTED"]}, + "path": {"type": "string"}, "from": {}, "to": {}, "message": {"type": "string"}}}}, + "scenario_results": {"type": "array", "items": {"oneOf": [ + {"type": "object", "required": ["id", "dps", "volley"], "properties": {"id": {"type": "string"}, "dps": {"type": ["number", "null"]}, "volley": {"type": ["number", "null"]}}}, + {"type": "object", "required": ["id", "error"], "properties": {"id": {"type": ["string", "null"]}, + "error": {"type": "object", "required": ["code", "message", "path"], "properties": {"code": {"type": "string"}, "message": {"type": "string"}, "path": {"type": "string"}}}}} + ]}}, "attributes": {"type": "object"} } } diff --git a/crates/exfa-core/graphs.json b/crates/exfa-core/graphs.json index 951785a..4d38aa1 100644 --- a/crates/exfa-core/graphs.json +++ b/crates/exfa-core/graphs.json @@ -304,6 +304,22 @@ "unit": "%", "valid": "x >= 0" }, + "atk_speed_mps": { + "unit": "m/s", + "valid": "x >= 0" + }, + "atk_speed_pct": { + "unit": "%", + "valid": "x >= 0" + }, + "atk_angle_deg": { + "unit": "deg", + "valid": "x >= 0 && x <= 360" + }, + "tgt_angle_deg": { + "unit": "deg", + "valid": "x >= 0 && x <= 360" + }, "tgt_sig_pct": { "unit": "%", "valid": "x > 0" @@ -329,7 +345,11 @@ "tgt_speed_mps": "dmg_dps(p.time_s, p.distance_m, x, null())", "tgt_sig_m": "dmg_dps(p.time_s, p.distance_m, null(), x)", "tgt_speed_pct": "dmg_dps(p.time_s, p.distance_m, x / 100 * tgt_vmax(), null())", - "tgt_sig_pct": "isnull(tgt_sig()) ? null() : dmg_dps(p.time_s, p.distance_m, null(), x / 100 * tgt_sig())" + "tgt_sig_pct": "isnull(tgt_sig()) ? null() : dmg_dps(p.time_s, p.distance_m, null(), x / 100 * tgt_sig())", + "atk_speed_mps": "dmg_dps(p.time_s, p.distance_m, null(), null())", + "atk_speed_pct": "dmg_dps(p.time_s, p.distance_m, null(), null())", + "atk_angle_deg": "dmg_dps(p.time_s, p.distance_m, null(), null())", + "tgt_angle_deg": "dmg_dps(p.time_s, p.distance_m, null(), null())" } }, "volley": { @@ -340,7 +360,11 @@ "tgt_speed_mps": "dmg_volley(p.time_s, p.distance_m, x, null())", "tgt_sig_m": "dmg_volley(p.time_s, p.distance_m, null(), x)", "tgt_speed_pct": "dmg_volley(p.time_s, p.distance_m, x / 100 * tgt_vmax(), null())", - "tgt_sig_pct": "isnull(tgt_sig()) ? null() : dmg_volley(p.time_s, p.distance_m, null(), x / 100 * tgt_sig())" + "tgt_sig_pct": "isnull(tgt_sig()) ? null() : dmg_volley(p.time_s, p.distance_m, null(), x / 100 * tgt_sig())", + "atk_speed_mps": "dmg_volley(p.time_s, p.distance_m, null(), null())", + "atk_speed_pct": "dmg_volley(p.time_s, p.distance_m, null(), null())", + "atk_angle_deg": "dmg_volley(p.time_s, p.distance_m, null(), null())", + "tgt_angle_deg": "dmg_volley(p.time_s, p.distance_m, null(), null())" } }, "damage": { @@ -351,7 +375,11 @@ "tgt_speed_mps": "dmg_damage(p.time_s, p.distance_m, x, null())", "tgt_sig_m": "dmg_damage(p.time_s, p.distance_m, null(), x)", "tgt_speed_pct": "dmg_damage(p.time_s, p.distance_m, x / 100 * tgt_vmax(), null())", - "tgt_sig_pct": "isnull(tgt_sig()) ? null() : dmg_damage(p.time_s, p.distance_m, null(), x / 100 * tgt_sig())" + "tgt_sig_pct": "isnull(tgt_sig()) ? null() : dmg_damage(p.time_s, p.distance_m, null(), x / 100 * tgt_sig())", + "atk_speed_mps": "dmg_damage(p.time_s, p.distance_m, null(), null())", + "atk_speed_pct": "dmg_damage(p.time_s, p.distance_m, null(), null())", + "atk_angle_deg": "dmg_damage(p.time_s, p.distance_m, null(), null())", + "tgt_angle_deg": "dmg_damage(p.time_s, p.distance_m, null(), null())" } } } @@ -363,9 +391,26 @@ "distance_m": { "unit": "m", "valid": "x >= 0" + }, + "atk_speed_mps": { + "unit": "m/s", + "valid": "x >= 0" + }, + "atk_speed_pct": { + "unit": "%", + "valid": "x >= 0" + }, + "atk_angle_deg": { + "unit": "deg", + "valid": "x >= 0 && x <= 360" + }, + "tgt_angle_deg": { + "unit": "deg", + "valid": "x >= 0 && x <= 360" } }, - "params": { + "params": { + "distance_m": null, "ammo_quality": "all", "tgt_speed_mps": null, "tgt_speed_pct": 100, @@ -379,13 +424,21 @@ "dps": { "unit": "HP/s", "by_axis": { - "distance_m": "app_dps(x)" + "distance_m": "app_dps(p.distance_m)", + "atk_speed_mps": "app_dps(p.distance_m)", + "atk_speed_pct": "app_dps(p.distance_m)", + "atk_angle_deg": "app_dps(p.distance_m)", + "tgt_angle_deg": "app_dps(p.distance_m)" } }, "volley": { "unit": "HP", "by_axis": { - "distance_m": "app_volley(x)" + "distance_m": "app_volley(p.distance_m)", + "atk_speed_mps": "app_volley(p.distance_m)", + "atk_speed_pct": "app_volley(p.distance_m)", + "atk_angle_deg": "app_volley(p.distance_m)", + "tgt_angle_deg": "app_volley(p.distance_m)" } } } diff --git a/crates/exfa-core/src/engine.rs b/crates/exfa-core/src/engine.rs index 88149ce..03dd96f 100644 --- a/crates/exfa-core/src/engine.rs +++ b/crates/exfa-core/src/engine.rs @@ -5,6 +5,7 @@ //! Semantics follow the reference engine eve-dogma-rs (LGPL-3.0-or-later), which this file is derived from. 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 std::cell::Cell; @@ -138,6 +139,7 @@ pub struct Fit { pub ship: usize, pub char: usize, pub warnings: Vec, + pub adjustments: Vec, pub is_structure: bool, ship_items: Vec, char_items: Vec, @@ -168,6 +170,14 @@ pub struct Fit { cur_loc: bool, } +pub struct Adjustment { + pub code: &'static str, + pub path: String, + pub from: J, + pub to: J, + pub message: &'static str, +} + #[derive(Debug, Clone, Copy)] pub struct ExtDrain { pub cycle_ms: f64, @@ -854,6 +864,15 @@ impl Fit { it.spool = m.spool; it.state = m.state.unwrap_or(State::Online); if matches!(slot, Some(Slot::Rig) | Some(Slot::Subsystem)) && it.state != State::Offline { + if it.state != State::Online { + self.adjustments.push(Adjustment { + code: "STATE_CLAMPED", + path: format!("{path}/state"), + from: J::S(state_name(it.state)), + to: J::S("online"), + message: "module state clamped to online", + }); + } it.state = State::Online; } if let Some(mu) = &m.mutation { @@ -873,6 +892,13 @@ impl Fit { // same way) — a request asking for a state a module cannot use is a normalization // concern, not a user-facing problem, so no warnings[] entry. self.items[idx].state = State::Online; + self.adjustments.push(Adjustment { + code: "STATE_CLAMPED", + path: format!("{path}/state"), + from: J::S(state_name(st)), + to: J::S("online"), + message: "module state clamped to online", + }); } } } @@ -893,6 +919,7 @@ impl Fit { ship: 0, char: 0, warnings: Vec::new(), + adjustments: Vec::new(), is_structure: false, ship_items: Vec::new(), char_items: Vec::new(), @@ -954,11 +981,17 @@ impl Fit { } fit.skill_levels = levels; // T3D: default mode resolved at build time (lowest mode type id named after the hull) - let mode_id = req.ship.mode_type_id.or_else(|| { - let m = d::default_mode(req.ship.type_id)?; - fit.warnings.push(format!("no tactical mode given; defaulted to type {m}")); - Some(m) - }); + let default_mode = req.ship.mode_type_id.is_none().then(|| d::default_mode(req.ship.type_id)).flatten(); + let mode_id = req.ship.mode_type_id.or(default_mode); + if let Some(mode) = default_mode { + fit.adjustments.push(Adjustment { + code: "MODE_DEFAULTED", + path: "/ship/mode_type_id".into(), + from: J::Null, + to: J::from(mode), + message: "default tactical mode selected", + }); + } if let Some(mode) = mode_id { let idx = fit.new_item(mode, Kind::Mode, Loc::Nowhere, "/ship/mode_type_id")?; fit.items[idx].owned = false; @@ -980,8 +1013,19 @@ impl Fit { for (i, f) in req.fighters.iter().enumerate() { let idx = fit.new_item(f.type_id, Kind::Fighter, Loc::Space, &format!("/fighters/{i}"))?; let maxsq = fit.base_opt(idx, a::fighterSquadronMaxSize).map(|v| v as u32).unwrap_or(1); + let requested = f.quantity.unwrap_or(maxsq); + let quantity = requested.clamp(1, maxsq.max(1)); + if requested != quantity { + fit.adjustments.push(Adjustment { + code: "FIGHTER_QUANTITY_CLAMPED", + path: format!("/fighters/{i}/quantity"), + from: J::from(requested), + to: J::from(quantity), + message: "fighter quantity clamped to valid range", + }); + } let it = &mut fit.items[idx]; - it.quantity = f.quantity.unwrap_or(maxsq).clamp(1, maxsq.max(1)); + it.quantity = quantity; it.active_count = if f.active { it.quantity } else { 0 }; it.state = if f.active { State::Active } else { State::Offline }; it.fighter_abilities = f.abilities.clone().unwrap_or_else(|| d::fighter_default_abilities(f.type_id).to_vec()); @@ -1003,6 +1047,13 @@ impl Fit { for (i, imp) in implants.iter().enumerate() { if let Some(sl) = slot_of_type(*imp, a::implantness) { if taken.contains(&sl) { + fit.adjustments.push(Adjustment { + code: "SLOT_OCCUPIED_SKIPPED", + path: format!("/{ipath}/{i}"), + from: J::from(*imp), + to: J::Null, + message: "implant slot already occupied", + }); continue; } taken.push(sl); @@ -1015,6 +1066,13 @@ impl Fit { for (i, b) in req.boosters.iter().enumerate() { if let Some(sl) = slot_of_type(b.type_id, a::boosterness) { if taken.contains(&sl) { + fit.adjustments.push(Adjustment { + code: "SLOT_OCCUPIED_SKIPPED", + path: format!("/boosters/{i}/type_id"), + from: J::from(b.type_id), + to: J::Null, + message: "booster slot already occupied", + }); continue; } taken.push(sl); @@ -1064,10 +1122,21 @@ impl Fit { for _ in 0..p.amount.max(1) { let idx = fit.new_item(f.type_id, Kind::Projected, Loc::Nowhere, &format!("/projected/{i}"))?; let maxsq = fit.base_opt(idx, a::fighterSquadronMaxSize).map(|v| v as u32).unwrap_or(1).max(1); + let requested = f.quantity.unwrap_or(maxsq); + let quantity = requested.clamp(1, maxsq); + if requested != quantity { + fit.adjustments.push(Adjustment { + code: "FIGHTER_QUANTITY_CLAMPED", + path: format!("/projected/{i}/fighter/quantity"), + from: J::from(requested), + to: J::from(quantity), + message: "fighter quantity clamped to valid range", + }); + } let it = &mut fit.items[idx]; it.owned = false; it.state = if f.active { State::Active } else { State::Offline }; - it.quantity = f.quantity.unwrap_or(maxsq).clamp(1, maxsq); + it.quantity = quantity; it.active_count = it.quantity; it.distance = p.distance_m; it.req_index = Some(i); @@ -1096,6 +1165,17 @@ impl Fit { // system security -> securityModifier (default nullsec, like Pyfa) { let sec = req.environment.system_security.as_deref().unwrap_or("nullsec").to_lowercase(); + if let Some(requested) = req.environment.system_security.as_deref().filter(|s| !s.is_empty()) { + if !matches!(sec.as_str(), "hisec" | "highsec" | "high" | "lowsec" | "low" | "nullsec" | "null" | "wspace" | "wormhole" | "w-space") { + fit.adjustments.push(Adjustment { + code: "SECURITY_DEFAULTED", + path: "/environment/system_security".into(), + from: J::from(requested.to_string()), + to: J::S("nullsec"), + message: "unknown system security defaulted to nullsec", + }); + } + } let src = match sec.as_str() { "hisec" | "highsec" | "high" => a::hiSecModifier, "lowsec" | "low" => a::lowSecModifier, @@ -1861,6 +1941,15 @@ impl Fit { } } +fn state_name(state: State) -> &'static str { + match state { + State::Offline => "offline", + State::Online => "online", + State::Active => "active", + State::Overheated => "overheated", + } +} + use crate::data::PENALTY; /// Apply resolved modifiers (op, penalised, value) to `val` in dogma operator order (Pyfa-compatible float order). diff --git a/crates/exfa-core/src/graphs/dmg.rs b/crates/exfa-core/src/graphs/dmg.rs index 3b40f45..01ee185 100644 --- a/crates/exfa-core/src/graphs/dmg.rs +++ b/crates/exfa-core/src/graphs/dmg.rs @@ -822,7 +822,7 @@ pub fn tackle(ctx: &Ctx, tgt: &Target, cur_speed: f64, dist: Option, r: &Ra } /// mode: 0 dps, 1 volley, 2 damage. Arguments may be None (not set). -pub fn damage(ctx: &Ctx, mode: u8, time: Option, dist: Option, speed_x: Option, sig_x: Option) -> Result, String> { +fn damage_one(ctx: &Ctx, mode: u8, time: Option, dist: Option, speed_x: Option, sig_x: Option) -> Result, String> { if mode == 2 && time.is_none() { return Ok(None); } @@ -885,3 +885,17 @@ pub fn damage(ctx: &Ctx, mode: u8, time: Option, dist: Option, speed_x } Ok(Some(out)) } + +pub fn point_values( + ctx: &Ctx, + time: Option, + dist: Option, + speed_x: Option, + sig_x: Option, +) -> Result<[Option; 3], String> { + Ok([ + damage_one(ctx, 0, time, dist, speed_x, sig_x)?, + damage_one(ctx, 1, time, dist, speed_x, sig_x)?, + damage_one(ctx, 2, time, dist, speed_x, sig_x)?, + ]) +} diff --git a/crates/exfa-core/src/graphs/kernels.rs b/crates/exfa-core/src/graphs/kernels.rs index 5115c7d..d7e2a22 100644 --- a/crates/exfa-core/src/graphs/kernels.rs +++ b/crates/exfa-core/src/graphs/kernels.rs @@ -13,9 +13,16 @@ pub struct Caches { /// capsim history per starting cap (bits): sorted (t s, cap) pub cap_hist: Vec<(u64, Vec<(f64, f64)>)>, pub extra_series: Vec<(String, Vec)>, + damage: Option, +} +struct DamageCache { + key: [Option; 4], + values: Result<[Option; 3], String>, } impl Caches { - pub fn point_clear(&mut self) {} + pub fn point_clear(&mut self) { + self.damage = None; + } } pub fn call(ctx: &mut Ctx, name: &str, a: &[Option]) -> Result, String> { @@ -35,7 +42,14 @@ pub fn call(ctx: &mut Ctx, name: &str, a: &[Option]) -> Result, "dmg_dps" | "dmg_volley" | "dmg_damage" => { let mode = match name { "dmg_dps" => 0, "dmg_volley" => 1, _ => 2 }; let g = |k: usize| a.get(k).copied().flatten(); - super::dmg::damage(ctx, mode, g(0), g(1), g(2), g(3))? + let key = [g(0), g(1), g(2), g(3)].map(|v| v.map(f64::to_bits)); + if ctx.k.damage.as_ref().map(|c| c.key) != Some(key) { + ctx.k.damage = Some(DamageCache { + key, + values: super::dmg::point_values(ctx, g(0), g(1), g(2), g(3)), + }); + } + ctx.k.damage.as_ref().unwrap().values.as_ref().map(|v| v[mode]).map_err(Clone::clone)? } "app_dps" | "app_volley" => match a.first().copied().flatten() { Some(x) => { diff --git a/crates/exfa-core/src/graphs/mod.rs b/crates/exfa-core/src/graphs/mod.rs index a86ccb5..bdb52e2 100644 --- a/crates/exfa-core/src/graphs/mod.rs +++ b/crates/exfa-core/src/graphs/mod.rs @@ -12,6 +12,7 @@ pub mod rr; use crate::data as d; use crate::engine::Fit; +use crate::jv; use crate::request::FitRequest; use expr::{Env, Expr}; use serde_json::{json, Map, Value}; @@ -132,6 +133,9 @@ impl<'a> Ctx<'a> { self.params.get(k).filter(|v| !v.is_null()) } pub fn param_f(&self, k: &str) -> Option { + if self.axis == k { + return Some(self.x); + } self.param(k).and_then(|v| v.as_f64().or_else(|| v.as_bool().map(|b| b as u8 as f64))) } pub fn setting_b(&self, k: &str, default: bool) -> bool { @@ -279,6 +283,8 @@ pub fn graph(req: &Value) -> Value { } } let settings = req.get("settings").and_then(|s| s.as_object()).cloned().unwrap_or_default(); + let mut caches = kernels::Caches::default(); + caches.drains = drains; let mut ctx = Ctx { graph, req, @@ -290,7 +296,7 @@ pub fn graph(req: &Value) -> Value { params, settings, defs_memo: HashMap::new(), - k: kernels::Caches { drains, ..Default::default() }, + k: caches, }; let mut out: Vec> = vec![Vec::with_capacity(xs.len()); ys.len()]; for &x in &xs { @@ -328,6 +334,148 @@ pub fn graph(req: &Value) -> Value { json!({"graph": gname, "x_axis": axis, "x": req.pointer("/x/values").cloned().unwrap_or(Value::Null), "series": series}) } +fn scenario_error(id: Option<&str>, code: &str, message: impl Into, path: String) -> crate::j::J { + let message = message.into(); + let mut result = jv!({"error": {"code": code.to_string(), "message": message, "path": path}}); + result["id"] = id.map(|s| crate::j::J::from(s.to_string())).unwrap_or(crate::j::J::Null); + result +} + +fn scenario_point(fit_req: &FitRequest, scenario: &Value, index: usize) -> crate::j::J { + let base = format!("/scenarios/{index}"); + if scenario.as_object().is_none() { + return scenario_error(None, "BAD_REQUEST", "scenario must be an object", base); + } + let id = scenario.get("id").and_then(Value::as_str); + let Some(id) = id else { + return scenario_error(None, "BAD_REQUEST", "scenario id must be a string", format!("{base}/id")); + }; + let Some(target) = scenario.get("target").filter(|v| v.is_object()) else { + return scenario_error(Some(id), "BAD_REQUEST", "target must be an object", format!("{base}/target")); + }; + if !target.get("fit").map_or(false, Value::is_object) && !target.get("profile").map_or(false, Value::is_object) { + return scenario_error(Some(id), "BAD_REQUEST", "target must contain a profile or fit object", format!("{base}/target")); + } + if target.get("fit").map_or(false, Value::is_object) && target.get("profile").map_or(false, Value::is_object) { + return scenario_error(Some(id), "BAD_REQUEST", "target must contain either profile or fit", format!("{base}/target")); + } + if let Some(mode) = target.get("resist_mode").filter(|v| !v.is_null()) { + if !mode.as_str().map_or(false, |s| ["auto", "shield", "armor", "hull", "weighted_average"].contains(&s)) { + return scenario_error(Some(id), "BAD_REQUEST", "unrecognised target.resist_mode", format!("{base}/target/resist_mode")); + } + } + if let Some(profile) = target.get("profile").and_then(Value::as_object) { + for key in ["em", "thermal", "kinetic", "explosive", "max_velocity", "signature_radius", "radius", "hp"] { + if let Some(value) = profile.get(key).filter(|v| !v.is_null()) { + if value.as_f64().filter(|n| n.is_finite()).is_none() { + return scenario_error(Some(id), "BAD_REQUEST", format!("target.profile.{key} must be a finite number or null"), format!("{base}/target/profile/{key}")); + } + } + } + } + if let Some(target_fit) = target.get("fit").filter(|v| v.is_object()) { + match serde_json::from_value::(target_fit.clone()) { + Ok(target_req) => { + if let Err(e) = Fit::build(&target_req) { + return scenario_error(Some(id), e.code, e.message, format!("{base}/target/fit{}", e.path)); + } + } + Err(e) => return scenario_error(Some(id), "BAD_REQUEST", e.to_string(), format!("{base}/target/fit")), + } + } + + let empty = Map::new(); + let Some(params_obj) = scenario.get("params").map_or(Some(&empty), Value::as_object) else { + return scenario_error(Some(id), "BAD_REQUEST", "params must be an object", format!("{base}/params")); + }; + let graph = spec().graphs.get("damage").unwrap(); + let allowed: [&str; 9] = ["distance_m", "time_s", "tgt_speed_mps", "tgt_speed_pct", "tgt_sig_m", "atk_speed_mps", "atk_speed_pct", "atk_angle_deg", "tgt_angle_deg"]; + let mut params = graph.params.clone(); + for (key, value) in params_obj { + if !allowed.contains(&key.as_str()) { + return scenario_error(Some(id), "BAD_REQUEST", format!("unknown damage parameter '{key}'"), format!("{base}/params/{key}")); + } + if !value.is_null() && value.as_f64().filter(|n| n.is_finite()).is_none() { + return scenario_error(Some(id), "BAD_REQUEST", format!("params.{key} must be a finite number or null"), format!("{base}/params/{key}")); + } + if let Some(n) = value.as_f64() { + let valid = match key.as_str() { + "distance_m" | "tgt_speed_mps" | "tgt_speed_pct" | "atk_speed_mps" | "atk_speed_pct" => n >= 0.0, + "time_s" => (0.0..=2500.0).contains(&n), + "tgt_sig_m" => n > 0.0, + "atk_angle_deg" | "tgt_angle_deg" => (0.0..=360.0).contains(&n), + _ => true, + }; + if !valid { + return scenario_error(Some(id), "BAD_REQUEST", format!("params.{key} is outside its valid range"), format!("{base}/params/{key}")); + } + } + params.insert(key.clone(), value.clone()); + } + let Some(settings_obj) = scenario.get("settings").map_or(Some(&empty), Value::as_object) else { + return scenario_error(Some(id), "BAD_REQUEST", "settings must be an object", format!("{base}/settings")); + }; + let mut settings = settings_obj.clone(); + for key in ["ignore_resists", "apply_projected", "ignore_lock_range", "ignore_drone_control_range"] { + if settings.get(key).is_some_and(|v| !v.is_boolean()) { + return scenario_error(Some(id), "BAD_REQUEST", format!("settings.{key} must be a boolean"), format!("{base}/settings/{key}")); + } + } + if let Some(mode) = settings.get("mobile_drone_mode").filter(|v| !v.is_null()) { + if !mode.as_str().map_or(false, |s| ["auto", "follow_attacker", "follow_target"].contains(&s)) { + return scenario_error(Some(id), "BAD_REQUEST", "unrecognised settings.mobile_drone_mode", format!("{base}/settings/mobile_drone_mode")); + } + } + settings.entry("ignore_resists").or_insert(json!(false)); + + let graph_req = json!({"graph": "damage", "target": target, "params": params, "settings": settings}); + let fit = match Fit::build(fit_req) { + Ok(f) => f, + Err(e) => return scenario_error(Some(id), e.code, e.message, e.path), + }; + let stats = fit.compute_stats(fit_req).to_value_raw(); + let settings = graph_req.get("settings").and_then(Value::as_object).cloned().unwrap_or_default(); + let params = graph_req.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); + let ctx = Ctx { + graph, + req: &graph_req, + fit_req: fit_req.clone(), + fit, + stats, + axis: String::new(), + x: 0.0, + params, + settings, + defs_memo: HashMap::new(), + k: kernels::Caches::default(), + }; + match dmg::point_values( + &ctx, + ctx.param_f("time_s"), + ctx.param_f("distance_m"), + None, + None, + ) { + Ok(values) => jv!({ + "id": id.to_string(), + "dps": values[0].map(crate::j::J::RawF).unwrap_or(crate::j::J::Null), + "volley": values[1].map(crate::j::J::RawF).unwrap_or(crate::j::J::Null) + }), + Err(message) => scenario_error(Some(id), "GRAPH_EVAL", message, format!("{base}/target")), + } +} + +pub fn scenario_results(fit_req: &FitRequest) -> Option { + let scenarios = fit_req.scenarios.as_ref()?; + let Some(scenarios) = scenarios.as_array() else { + return Some(crate::j::J::A(vec![scenario_error(None, "BAD_REQUEST", "scenarios must be an array", "/scenarios".into())])); + }; + if scenarios.is_empty() { + return None; + } + Some(crate::j::J::A(scenarios.iter().enumerate().map(|(i, s)| scenario_point(fit_req, s, i)).collect())) +} + pub fn graph_json(line: &str) -> String { match serde_json::from_str::(line) { Ok(v) => graph(&v).to_string(), diff --git a/crates/exfa-core/src/j.rs b/crates/exfa-core/src/j.rs index af5bd32..8d424ee 100644 --- a/crates/exfa-core/src/j.rs +++ b/crates/exfa-core/src/j.rs @@ -12,6 +12,7 @@ pub enum J { U(u64), I(i64), F(f64), + RawF(f64), S(&'static str), Str(String), A(Vec), @@ -153,6 +154,13 @@ impl J { out.push_str("null"); } } + J::RawF(v) => { + if v.is_finite() { + out.push_str(zmij::Buffer::new().format_finite(*v)); + } else { + out.push_str("null"); + } + } J::S(s) => write_str(out, s), J::Str(s) => write_str(out, s), J::A(a) => { @@ -178,7 +186,16 @@ impl J { for (k, x) in idx.iter_mut().enumerate() { *x = k as u16; } - idx.sort_unstable_by(|&x, &y| o[x as usize].0.cmp(&o[y as usize].0)); + idx.sort_unstable_by(|&x, &y| { + let a = o[x as usize].0.as_ref(); + let b = o[y as usize].0.as_ref(); + match (a == "adjustments", b == "adjustments") { + (true, true) => std::cmp::Ordering::Equal, + (true, false) => "violations\0".cmp(b), + (false, true) => a.cmp("violations\0"), + (false, false) => a.cmp(b), + } + }); out.push('{'); for (n, &k) in idx.iter().enumerate() { if n > 0 { @@ -203,6 +220,7 @@ impl J { J::U(u) => V::from(*u), J::I(i) => V::from(*i), J::F(f) => serde_json::Number::from_f64(*f).map(V::Number).unwrap_or(V::Null), + J::RawF(f) => serde_json::Number::from_f64(*f).map(V::Number).unwrap_or(V::Null), J::S(s) => V::String((*s).to_string()), J::Str(s) => V::String(s.clone()), J::A(a) => V::Array(a.iter().map(|x| x.to_value_raw()).collect()), diff --git a/crates/exfa-core/src/lib.rs b/crates/exfa-core/src/lib.rs index 68a3785..a91879b 100644 --- a/crates/exfa-core/src/lib.rs +++ b/crates/exfa-core/src/lib.rs @@ -54,6 +54,11 @@ pub fn calc_priced(req: &FitRequest, variant: &[request::PriceOverride], batch_w match engine::Fit::build(req) { Ok(fit) => { let mut out = fit.compute_stats(req); + if let Some(results) = graphs::scenario_results(req) { + if let j::J::O(o) = &mut out { + o.push(("scenario_results".into(), results)); + } + } if let j::J::O(o) = &mut out { let (use_market, table) = price::request_state(req, batch_prices); o.push(("provenance".into(), prov::provenance_j(use_market, table))); diff --git a/crates/exfa-core/src/model.rs b/crates/exfa-core/src/model.rs index 5bbee4e..c8a69d1 100644 --- a/crates/exfa-core/src/model.rs +++ b/crates/exfa-core/src/model.rs @@ -1,6 +1,7 @@ //! The structured fit input (FitRequest v1, contract: eve-dogma-rs/docs/contract.md), shared by the //! engine and the fit formats (`exfa-formats`). Plain serde types; no data, no engine (was `exfa-model`). use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::BTreeMap; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] @@ -323,6 +324,8 @@ pub struct FitRequest { /// Injected price table (docs/23 §5.3). #[serde(default, skip_serializing_if = "Option::is_none")] pub prices: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scenarios: Option, } /// One price override entry: exactly one target and exactly one of `price` / `multiplier` (checked by the engine). diff --git a/crates/exfa-core/src/stats.rs b/crates/exfa-core/src/stats.rs index 0910086..80b3649 100644 --- a/crates/exfa-core/src/stats.rs +++ b/crates/exfa-core/src/stats.rs @@ -1136,6 +1136,12 @@ impl Fit { [tubes_used as f64, class_used("light"), class_used("support"), class_used("heavy")], ))); } + out.push_kv( + "adjustments".into(), + J::A(self.adjustments.iter().map(|a| jv!({ + "code": a.code, "path": a.path.clone(), "from": a.from.clone(), "to": a.to.clone(), "message": a.message + })).collect()), + ); if !self.warnings.is_empty() { out.push_kv("warnings".into(), jv!(self.warnings.clone())); } diff --git a/crates/exfa-core/tests/adjustments_scenarios.rs b/crates/exfa-core/tests/adjustments_scenarios.rs new file mode 100644 index 0000000..9df3e93 --- /dev/null +++ b/crates/exfa-core/tests/adjustments_scenarios.rs @@ -0,0 +1,254 @@ +use exfa_core::{data, graphs}; +use serde_json::{json, Value}; + +fn calc(req: Value) -> Value { + serde_json::from_str(&exfa_core::calc_json(&req.to_string())).unwrap() +} + +fn adjustment(req: Value, code: &str) -> Value { + let result = calc(req); + assert!(result.get("error").is_none(), "{result}"); + result["adjustments"] + .as_array() + .unwrap() + .iter() + .find(|a| a["code"] == code) + .unwrap() + .clone() +} + +#[test] +fn adjustments_are_always_emitted_after_violations_and_only_for_the_top_level_fit() { + let line = exfa_core::calc_json( + r#"{"ship":{"type_id":587},"projected":[{"kind":"fit","fit":{"ship":{"type_id":34317}}}]}"#, + ); + let result: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(result["adjustments"], json!([])); + assert!(line.find("\"adjustments\"").unwrap() > line.find("\"violations\"").unwrap()); +} + +#[test] +fn state_clamps_are_adjustments() { + let a = adjustment( + json!({"ship": {"type_id": 587}, "modules": [{"type_id": 26374, "slot": "rig", "state": "active"}]}), + "STATE_CLAMPED", + ); + assert_eq!( + a, + json!({"code":"STATE_CLAMPED","path":"/modules/0/state","from":"active","to":"online","message":"module state clamped to online"}) + ); +} + +#[test] +fn fighter_quantity_clamps_are_adjustments() { + let id = 40556; + let max = data::type_attr( + data::type_index(id).unwrap(), + data::a::fighterSquadronMaxSize, + ) + .unwrap() as u32; + let result = calc(json!({ + "ship": {"type_id": 587}, + "fighters": [{"type_id": id, "quantity": max + 10}], + "projected": [{"kind": "fighter", "fighter": {"type_id": id, "quantity": max + 10}}] + })); + let a = result["adjustments"].as_array().unwrap(); + assert!(a.iter().any(|x| x["code"] == "FIGHTER_QUANTITY_CLAMPED" + && x["path"] == "/fighters/0/quantity" + && x["from"] == max + 10 + && x["to"] == max)); + assert!(a.iter().any(|x| x["code"] == "FIGHTER_QUANTITY_CLAMPED" + && x["path"] == "/projected/0/fighter/quantity" + && x["from"] == max + 10 + && x["to"] == max)); +} + +#[test] +fn occupied_implant_and_booster_slots_are_adjustments() { + let result = calc(json!({ + "ship": {"type_id": 587}, + "implants": [19540, 19540], + "boosters": [{"type_id": 15466}, {"type_id": 15466}] + })); + let a = result["adjustments"].as_array().unwrap(); + assert!(a.iter().any(|x| x["code"] == "SLOT_OCCUPIED_SKIPPED" + && x["path"] == "/implants/1" + && x["from"] == 19540 + && x["to"].is_null())); + assert!(a.iter().any(|x| x["code"] == "SLOT_OCCUPIED_SKIPPED" + && x["path"] == "/boosters/1/type_id" + && x["from"] == 15466 + && x["to"].is_null())); +} + +#[test] +fn unknown_security_is_an_adjustment() { + let a = adjustment( + json!({"ship": {"type_id": 587}, "environment": {"system_security": "mystery"}}), + "SECURITY_DEFAULTED", + ); + assert_eq!(a["path"], "/environment/system_security"); + assert_eq!(a["from"], "mystery"); + assert_eq!(a["to"], "nullsec"); +} + +#[test] +fn default_tactical_mode_is_an_adjustment_not_a_warning() { + let result = calc(json!({"ship": {"type_id": 34317}})); + let a = result["adjustments"] + .as_array() + .unwrap() + .iter() + .find(|x| x["code"] == "MODE_DEFAULTED") + .unwrap(); + assert_eq!(a["path"], "/ship/mode_type_id"); + assert!(a["from"].is_null()); + assert!(a["to"].is_number()); + assert!(result + .get("warnings") + .map_or(true, |w| !w.to_string().contains("no tactical mode given"))); +} + +fn graph_point( + fit: Value, + target: Value, + params: Value, + axis: &str, + x: Value, + settings: Value, +) -> Value { + graphs::graph(&json!({ + "graph": "damage", "fit": fit, "target": target, "params": params, "settings": settings, + "x": {"axis": axis, "values": [x]}, "y": ["dps", "volley"] + })) +} + +#[test] +fn scenarios_match_damage_graph_points() { + let fit = json!({ + "ship": {"type_id": 24694}, + "character": {"skills": {"default_level": 5}}, + "modules": [{"type_id": 2961, "charge_type_id": 21894, "state": "active"}] + }); + let target_profile = json!({"profile": { + "em": 0.2, "thermal": 0.3, "kinetic": 0.4, "explosive": 0.5, + "max_velocity": 500, "signature_radius": 100, "radius": 10, "hp": 100000 + }}); + let profile_params = json!({ + "distance_m": 15000, "time_s": null, "tgt_speed_mps": 250, "tgt_speed_pct": 100, + "tgt_sig_m": 100, "atk_speed_mps": 200, "atk_speed_pct": 0, "atk_angle_deg": 45, "tgt_angle_deg": 110 + }); + let null_distance_params = json!({ + "distance_m": null, "time_s": null, "tgt_speed_mps": 300, "tgt_speed_pct": 100, + "tgt_sig_m": 100, "atk_speed_mps": 150, "atk_speed_pct": 0, "atk_angle_deg": 90, "tgt_angle_deg": 90 + }); + let target_fit = json!({"fit": {"ship": {"type_id": 587}, "character": {"skills": {"default_level": 5}}, "modules": []}, "resist_mode": "auto"}); + let fit_target_params = json!({ + "distance_m": 20000, "time_s": null, "tgt_speed_mps": 100, "tgt_speed_pct": 100, + "tgt_sig_m": 35, "atk_speed_mps": 250, "atk_speed_pct": 0, "atk_angle_deg": 30, "tgt_angle_deg": 145 + }); + let cases = [ + ( + target_profile.clone(), + profile_params.clone(), + "distance_m", + json!(15000), + ), + ( + target_profile, + null_distance_params.clone(), + "tgt_angle_deg", + json!(90), + ), + ( + target_fit.clone(), + fit_target_params.clone(), + "distance_m", + json!(20000), + ), + ]; + let scenarios: Vec = cases + .iter() + .enumerate() + .map(|(i, (target, params, _, _))| { + json!({ + "id": format!("s{i}"), "target": target, "params": params, + }) + }) + .collect(); + let result = calc( + json!({"ship": fit["ship"], "character": fit["character"], "modules": fit["modules"], "scenarios": scenarios}), + ); + let results = result["scenario_results"].as_array().unwrap(); + for (i, (target, params, axis, x)) in cases.iter().enumerate() { + let graph = graph_point( + fit.clone(), + target.clone(), + params.clone(), + axis, + x.clone(), + json!({"ignore_resists": false}), + ); + assert!(graph.get("error").is_none(), "{graph}"); + assert_eq!(results[i]["dps"], graph["series"]["dps"][0], "case {i}"); + assert_eq!( + results[i]["volley"], graph["series"]["volley"][0], + "case {i}" + ); + } +} + +#[test] +fn scenario_errors_are_isolated_and_absence_does_not_change_other_stats() { + let fit = json!({"ship": {"type_id": 587}, "modules": []}); + let base = calc(fit.clone()); + let result = calc(json!({ + "ship": fit["ship"], "scenarios": [ + {"id": "bad", "target": {"profile": {}}, "params": {"distance_m": -1}}, + {"id": "ok", "target": {"profile": {}}, "params": {"distance_m": null}} + ] + })); + assert_eq!(result["scenario_results"][0]["id"], "bad"); + assert_eq!( + result["scenario_results"][0]["error"]["path"], + "/scenarios/0/params/distance_m" + ); + assert_eq!(result["scenario_results"][1]["id"], "ok"); + let mut with_scenarios = result; + with_scenarios + .as_object_mut() + .unwrap() + .remove("scenario_results"); + assert_eq!(with_scenarios, base); +} + +#[test] +fn graph_axes_expose_and_apply_attacker_parameters() { + let axes = &graphs::specs_json()["graphs"]["damage"]["axes"]; + for axis in [ + "atk_speed_mps", + "atk_speed_pct", + "atk_angle_deg", + "tgt_angle_deg", + ] { + assert!(axes.get(axis).is_some()); + assert!( + graphs::specs_json()["graphs"]["application_profile"]["axes"] + .get(axis) + .is_some() + ); + } + let fit = json!({ + "ship": {"type_id": 24694}, "character": {"skills": {"default_level": 5}}, + "modules": [{"type_id": 2961, "charge_type_id": 21894, "state": "active"}] + }); + let graph = graphs::graph(&json!({ + "graph": "application_profile", "fit": fit, + "target": {"profile": {"max_velocity": 400, "signature_radius": 100, "radius": 10, "hp": 100000}}, + "params": {"distance_m": 10000, "tgt_speed_mps": 400, "tgt_angle_deg": 90, "atk_speed_mps": 100}, + "x": {"axis": "tgt_angle_deg", "values": [10, 90, 170]}, "y": ["dps"] + })); + let series = graph["series"]["dps"].as_array().unwrap(); + assert!(series[0].is_number() && series[1].is_number() && series[2].is_number()); + assert!(series.windows(2).any(|pair| pair[0] != pair[1])); +} diff --git a/crates/exfa-wasm/Cargo.toml b/crates/exfa-wasm/Cargo.toml index c2a6e83..5e23990 100644 --- a/crates/exfa-wasm/Cargo.toml +++ b/crates/exfa-wasm/Cargo.toml @@ -10,5 +10,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] exfa-core.workspace = true +exfa-formats.workspace = true exfa-optimizer.workspace = true serde_json.workspace = true diff --git a/crates/exfa-wasm/src/lib.rs b/crates/exfa-wasm/src/lib.rs index 8de0fe4..b1ffdd5 100644 --- a/crates/exfa-wasm/src/lib.rs +++ b/crates/exfa-wasm/src/lib.rs @@ -40,5 +40,19 @@ pub unsafe extern "C" fn rpc(ptr: *const u8, len: usize) -> u64 { return out(serde_json::to_string(&r).unwrap()); } } - out(serde_json::to_string(&exfa_core::rpc(s)).unwrap()) + let core = exfa_core::rpc(s); + if core.pointer("/result/error/code").and_then(serde_json::Value::as_str) == Some("UNKNOWN_METHOD") { + if let Ok(v) = serde_json::from_str::(s) { + let method = v.get("method").and_then(serde_json::Value::as_str).unwrap_or(""); + let params = v.get("params").cloned().unwrap_or(serde_json::Value::Null); + if let Some(result) = exfa_formats::rpc_method(method, ¶ms, None) { + let response = serde_json::json!({ + "id": v.get("id").cloned().unwrap_or(serde_json::Value::Null), + "result": result + }); + return out(serde_json::to_string(&response).unwrap()); + } + } + } + out(serde_json::to_string(&core).unwrap()) }