diff --git a/.claude/plans/building-capabilities-plan.md b/.claude/plans/building-capabilities-plan.md new file mode 100644 index 00000000..33bf140c --- /dev/null +++ b/.claude/plans/building-capabilities-plan.md @@ -0,0 +1,325 @@ +# Plan: Multi-role economic buildings via building capabilities + +## Context + +Today, "what a building can produce" is entirely encoded in +`Building.building_type` (a single string): `economy/tasks.py`, +`economy/services/capacity_services.py`, `locations/services/watabou_import.py` +and `locations/management/commands/spawn_villages.py` all `filter(building_type="mill")` +etc. A building can only ever do one economic job, and small villages +(see the Ashenford investigation) can end up missing a whole role +(no bakery) purely because of import ordering, with no way to say +"the communal building also bakes." + +Goal: let a building hold **multiple capabilities** (e.g. a `communal` +building can mill *and* bake), and let village-level economic planning +allocate demand across whatever capabilities actually exist, rather than +assuming one fixed building type per role forever. Worker assignment +(`CharacterLocation(role=WORK)`, `workers_present(building)`) is **not** +redesigned here - buildings still host workers per-building, not +per-capability, until a later plan needs that granularity. + +--- + +## 1. Conceptual model & terminology + +Five layers, kept distinct so "capability" (what a building *can* do) +never gets confused with `CharacterLocation.Role` (home/work) or +`RelationshipRole` (spouse/mentor), which already own the word "Role" in +this codebase: + +| Layer | Concept | Lives on | +|---|---|---| +| Physical infrastructure | `Building` / `building_type` | `locations.Building` (unchanged) | +| **Building capability** | e.g. `milling`, `baking`, `farming` | **new**: `BuildingCapability` (one row per building+activity) | +| Economic activity | the fixed vocabulary of production roles | **new**: `EconomicActivity` choices (module-level, not a model) | +| Village economic planning | demand → required capacity → allocation across capable buildings | `economy.services.capacity_services` (evolves) | +| Worker presence | who's physically at a building | `workers_present(building)` (unchanged for now) | + +Naming: use **"capability"** for what a building can do, and **"economic +activity"** (`EconomicActivity`) for the fixed enum of production roles +(`milling`, `baking`, `farming`). Avoid "role" entirely for this concept - +`CharacterLocation.Role` and `RelationshipRole` already mean something +specific and unrelated (home/work location, family/relationship role). +Avoid "job"/"occupation" too - `unify-work-flavor-with-jobs-plan.md` +already uses "job" loosely for the *flavor-text* work activity system +(`WORK_ACTIVITIES_BY_BUILDING_TYPE`), which is a different, presentational +concept from economic production capacity. + +`granary` (storage) and `field_shelter` (farming presence) are left out of +the capability model for now - see Design decisions. + +--- + +## 2. Files likely to change + +- `economy/models.py` - **existing**. Add `BuildingCapability` model + (FK to `locations.Building`, `activity` choice field). +- `economy/constants.py` - **existing**. Add `EconomicActivity` choices + (or a plain `TextChoices` class) alongside the existing goods/rate + constants - this is the shared vocabulary both the model and services + import. +- `economy/migrations/` - **new migration** for `BuildingCapability`, plus + a **new data migration** to backfill capabilities from every existing + building's current `building_type` (mill → milling, bakery → baking, + field_shelter → farming), so no existing village silently loses + capacity the day this ships. +- `economy/services/capacity_services.py` - **existing**. `find_mill`/ + `find_bakery` (and the `mills =`/`bakeries =` lookups inside + `population_capacity_report`) move from `building_type=` filters to + `buildings.filter(capabilities__activity=...)`. `workers_present` is + untouched. +- `economy/tasks.py` - **existing**. The three `Building.objects.filter( + building_type="mill"/"bakery")` loops in `advance_mill_economy_tick`/ + `advance_bakery_economy_tick` switch to filtering by capability instead, + so a communal building with both capabilities gets ticked for both. +- `economy/services/planning_services.py` - **existing**, mostly + unaffected. `RoleRequirement.building_type` becomes the activity name + it already conceptually is (see Design decisions - possibly just a + rename, no behaviour change) since it's already + building-type-agnostic in spirit (recommends a *count*, not a specific + building). +- `locations/services/watabou_import.py` / `spawn_villages.py` - + **existing**, changed last. Once capabilities exist, importers can + assign capabilities to a `communal`/generic building type when they run + out of dedicated special-building slots, instead of silently dropping + the role. (Left as a follow-up - see Recommended first step.) +- `economy/management/commands/economy_status.py` - **existing**. + `_print_building` gains a capabilities line; capacity lines already + read from `population_capacity_report`, so no logic change needed + there once capacity_services is updated. +- `economy/admin.py` (if it exists / registers `Building`-adjacent + models) - check and register `BuildingCapability` for visibility. +- New tests: `economy/tests/test_capacity_services.py` (extend), + `economy/tests/test_models.py` or a new `economy/tests/test_capabilities.py`. + +--- + +## 3. Implementation plan + +Small, sequential PRs, each independently shippable and behaviour-preserving +until the final step: + +1. **Add `EconomicActivity` vocabulary + `BuildingCapability` model.** + Model only, migration only, no callers changed yet. Include the + backfill data migration in the same PR so the schema and its + initial data land atomically for every environment (dev, staging, + prod all replay migrations identically). +2. **Point `capacity_services.find_mill`/`find_bakery`/`find_granary`- + equivalent-for-milling/baking and the `mills =`/`bakeries =` queries + in `population_capacity_report` at `BuildingCapability` instead of + `building_type`.** `find_granary`/farming still use `building_type` + (see Design decisions - granary/field_shelter deliberately out of + scope). Existing tests should pass unchanged since the backfill + migration guarantees identical query results on existing data. +3. **Point `economy/tasks.py`'s three `building_type="mill"/"bakery"` + queryset filters at capability instead.** This is the change that + actually lets a multi-capability building get ticked for both roles. +4. **Surface capabilities in `economy_status`** (diagnostic command) so + capability assignment is inspectable without a DB shell. +5. **(Follow-up, separate plan) Update `spawn_villages.py`/ + `watabou_import.py`** to assign capabilities to a generic/communal + building when dedicated building types run out, closing the + Ashenford-style gap this investigation started from. + +Steps 1-4 constitute this plan; step 5 is called out but intentionally +deferred (see Recommended first step). + +--- + +## 4. Design decisions + +**a. New `BuildingCapability` model vs. a JSON/array field on `Building`.** +Chosen: a separate model (one row per building+activity), not a +`JSONField` or `ArrayField` on `Building`. Alternative considered: an +`activities = ArrayField(CharField)` column directly on `Building`. +Rejected because `GoodsStock`/`GoodsConversionState`/`FieldCrop` already +establish the pattern of small satellite models keyed by `building` +(`economy/models.py`) rather than composite fields on `locations.Building` +- economy concerns stay in the `economy` app, not `locations`. A real +row also gets a normal FK/queryset filter (`buildings__filter( +capabilities__activity="baking")`), works with `select_related`/ +`prefetch_related` the same way the rest of this codebase already +queries, and leaves room for a later per-capability field (e.g. a +capability-level enabled/disabled flag) without a schema rewrite of +`Building` itself. + +**b. Keep `building_type` on `Building`, don't remove or replace it yet.** +Chosen: `BuildingCapability` is additive; `building_type` keeps driving +`BUILDING_TYPE_HOURS`/`open_time`/`close_time` and display/flavor-text +concerns (`WORK_ACTIVITIES_BY_BUILDING_TYPE`), which have nothing to do +with production capacity. Alternative: deprecate `building_type` in favour +of "primary capability" immediately. Rejected - `building_type` already +carries meaning unrelated to production (working hours, flavor text, +map/UI display), so collapsing it now would be a much larger, riskier +change than this plan's scope, and the CLAUDE.md planning principles +favour extending over replacing. The plan's docstring intent ("prefer +introducing the capability abstraction before changing `building_type`") +matches this directly. + +**c. `granary` and `field_shelter` stay `building_type`-only, not modeled +as capabilities.** Storage (granary) isn't a *production* activity - it +has no worker/labor cap, no `convert_goods` call, nothing a capability +would add. `field_shelter` is already structurally tied to `FieldCrop` +(a `FieldCrop.shelter_building` FK, not a lookup by type) and to land +(`Subzone`), so its "capability" is really "has an attached crop", not a +generic tag. Including them would blur the model without a concrete need +- can be added later if a real use case appears (e.g. a communal +building doubling as storage). + +**d. Multiple buildings with the same capability.** +`capacity_services.population_capacity_report` already sums across +`list(population_centre.buildings.filter(building_type="mill"))` for +`workers_present`/`building_count` today - moving to +`filter(capabilities__activity="milling")` preserves exactly this +"sum across every building with the capability" semantic with no new +allocation logic needed. Rejected alternative: making the report pick +a single "primary" building per activity - unnecessary, current behaviour +already handles N buildings per role and multi-capability buildings are +just N buildings potentially overlapping across roles. + +**e. `RoleRequirement.building_type` naming in `planning_services.py`.** +Chosen: rename the field to `activity` (or leave it as-is if the rename +churn isn't worth it) once `EconomicActivity` exists, since +`_recommended_buildings` already returns a building *count*, not a type - +the field was already conceptually "which activity does this recommend +building for," `building_type` was just the closest existing vocabulary +at the time it was written. Low-risk, mechanical, can be folded into +step 2 or done separately - flagged as an open question below rather +than decided outright, since it's a pure rename with no behavioural +stake either way. + +--- + +## 5. Edge cases + +- **Building with zero capabilities.** A `residential`/`hall`/`market` + building has no `BuildingCapability` rows - `capacity_services` queries + simply return nothing for it, same as today's `building_type` filter + excluding it. No special-casing needed. +- **Building with the same activity added twice.** Add a + `UniqueConstraint(fields=["building", "activity"])` on + `BuildingCapability`, mirroring `GoodsStock`'s + `uniq_goods_stock_per_building` pattern - prevents duplicate rows + double-counting a building in `mills = list(...)`-style queries. +- **Backfill migration correctness.** Must map every existing + `building_type` value that has a production meaning today (`mill` → + `milling`, `bakery` → `baking`) and explicitly do nothing for types + that don't (`residential`, `hall`, `market`, `communal`, `inn`, + `granary`, `field_shelter`) - a reversible data migration, tested by + running it against a representative fixture (e.g. Ashenford/ + Bramblewick-shaped data) and asserting `population_capacity_report` + output is byte-identical before/after. +- **`GoodsConversionState` is still per-building, not per-capability.** + A communal building with both milling and baking capabilities shares + *one* `GoodsConversionState.last_processed_on` row today + (`OneToOneField(building)`). If `advance_mill_economy_tick` and + `advance_bakery_economy_tick` both run against the same building on + the same day, the second tick to run will find `last_processed_on == + today` already set by the first and skip - silently not baking (or + milling). **This needs to become + `OneToOneField` → per-(building, activity) before step 3 ships**. Call + this out explicitly as a required fix, not deferred, since it would + otherwise be an immediate regression the day a multi-capability + building exists. Add a `UniqueConstraint(fields=["building", + "activity"])`-shaped tracking row (or add an `activity` field to + `GoodsConversionState`) alongside the `BuildingCapability` migration. +- **Workers shared across two capabilities in the same building.** + `workers_present(building)` counts everyone physically at the building, + with no split between "here to mill" vs. "here to bake" - a + multi-capability building's milling *and* baking capacity will both be + computed from the *same* worker count today (each activity sees the + full headcount, not a fair share). This double-counts labor across + activities. Documented as a known, deliberate simplification for this + plan (see Worker allocation below) - not fixed here, but must be called + out in `capacity_services`/`tasks.py` docstrings so it isn't mistaken + for a bug later. +- **Migration reversibility.** The backfill data migration should have a + working `reverse_code` (delete `BuildingCapability` rows created by the + forward migration) so `migrate economy ` doesn't dead-end. + +--- + +## 6. Tests + +- **New**: `BuildingCapability` model - uniqueness constraint, + `str()`, cascade delete when a `Building` is deleted. +- **New**: backfill data migration - apply to a fixture with mill/bakery/ + field_shelter/residential buildings, assert the right capability rows + (and only those) are created. +- **Modify**: `economy/tests/test_capacity_services.py` - existing + `find_mill`/`find_bakery`-adjacent tests should keep passing unchanged + (backfill preserves behaviour); add a new test where a single + `communal`-type building holds both `milling` and `baking` + capabilities and confirm `population_capacity_report` counts it in + *both* `milling.building_count` and `baking.building_count`. +- **New**: `economy/tests/test_tasks.py` (or wherever `advance_mill_economy_tick`/ + `advance_bakery_economy_tick` are tested today) - a building with both + capabilities gets processed by both tasks on the same day (this is the + test that would have caught the `GoodsConversionState` collision in + Edge cases above - write it before the fix, watch it fail, then fix). +- **Existing**: `economy_status` output/`test_economy_status`-equivalent + (if one exists) - extend to assert capabilities print correctly. +- Explicitly **not** covered here (deferred to the worker-allocation + follow-up): any test asserting labor is split fairly between two + capabilities on the same building - that behaviour doesn't exist yet. + +--- + +## 7. Risks + +- Forgetting the `GoodsConversionState` per-building limitation (Edge + cases above) is the single most likely mistake - it's easy to add + `BuildingCapability` and update the query filters without noticing the + existing idempotency guard silently breaks multi-capability ticking. +- Updating `capacity_services` queries but forgetting `economy/tasks.py`'s + separate `Building.objects.filter(building_type="mill")` / + `"bakery"` queries (they don't currently go through + `capacity_services.find_mill`/`find_bakery` for the *iteration* loop, + only for cross-references like `find_granary`/`find_mill` inside the + loop body) - both call sites need to move together or the tick tasks + and the diagnostic report will disagree about which buildings are + active. +- Writing the backfill migration as a schema-only migration and forgetting + the data migration, leaving every existing village with zero + capabilities post-deploy (a total regression, not just Ashenford-style + partial loss). +- Over-scoping into worker allocation ("while I'm here, let me also split + workers per-capability") - explicitly out of scope per the prompt; + resist doing it as part of this plan. + +--- + +## 8. Open questions + +- Should `RoleRequirement.building_type` in `planning_services.py` be + renamed to `activity` now, or left for a later cleanup? (Design + decision e - low stakes, purely mechanical either way.) +- Should `BuildingCapability` support a per-capability enabled/disabled + toggle now (e.g. temporarily disable milling at a building without + deleting the row), or is delete-the-row sufficient until a real need + appears? Leaning toward deferring - no current caller needs it. +- Is `GoodsConversionState` best fixed by adding an `activity` field + (simple, but changes its uniqueness semantics) or by introducing a + separate per-activity idempotency row that reuses the existing model + shape? Needs a decision before step 3 ships, not before step 1. +- Should the backfill migration also handle any *manually created* + buildings in existing dev/staging data that have a non-standard + `building_type` (e.g. hand-edited via admin) - worth a quick data audit + (`economy_status` or a DB query) on staging before writing the migration, + not just reasoning from the model code. + +--- + +## Recommended first step + +**Step 1 alone**: add the `EconomicActivity` vocabulary, the +`BuildingCapability` model, its migration, and the backfill data +migration - with no callers changed yet. This is the smallest reviewable +PR that establishes the abstraction, is fully additive (zero behaviour +change, verifiable by re-running `economy_status` before/after and diffing +output), and unblocks every later step without committing to the riskier +`GoodsConversionState` fix or the tasks.py/capacity_services query changes +in the same PR. It also gives a concrete place (the backfill migration) to +validate the `EconomicActivity` naming and mapping against real +Ashenford/Bramblewick-shaped data before anything depends on it. diff --git a/.claude/plans/village-capacity-sizing-plan.md b/.claude/plans/village-capacity-sizing-plan.md new file mode 100644 index 00000000..6f19dda4 --- /dev/null +++ b/.claude/plans/village-capacity-sizing-plan.md @@ -0,0 +1,293 @@ +# Plan: Size new villages to minimum-viable production capacity, with link-scaled worker output + +## Context + +Goal (from the user, verbatim intent): a freshly-generated population +centre should start with just enough production capacity to feed its +residents - not comfortably staffed, not starving. As players link to +characters and play, `link_points` accrue, and that should raise the +linked character's work output, visibly pulling the village's production +capacity from "barely enough" toward "comfortable" - the mechanism that +makes linking a player to a character matter economically. This is +**explicitly not** about `PopulationCentre.state` (the existing +`total_ap_earned`/`village_points`-driven Struggling/Recovering/Stable/ +Thriving property) - that stays untouched and is being worked on +separately. Using `link_points` (not `Character.level`/`total_ap_earned`) +keeps the two signals genuinely distinct rather than double-counting the +same number for two different UI/economy purposes. + +This builds directly on the already-shipped `BuildingCapability` work +(`.claude/plans/building-capabilities-plan.md`, steps 1-3): a village no +longer needs one dedicated building per production role, so a small +village can size a single multi-capability building instead of silently +missing a role (the original Ashenford bug). + +Two existing pieces already point at this goal but aren't wired together: +- `economy/services/planning_services.py.settlement_plan()` already + computes "how many workers/buildings does a population need" from a + resident count - built for exactly this, per its own docstring, but + never called by the generation pipeline. +- `locations/services/population_estimation.py`'s docstring literally + says: *"Feeding population_capacity() into + planning_services.settlement_plan(population=...) to size economy + infrastructure ... is not wired up yet - a separate follow-up."* This + plan is that follow-up. + +**Explicitly out of scope** (per direct answers already given): +- `PopulationCentre.state` / `village_points` - untouched. +- `locations/management/commands/assign_workers.py` (WORK assignment) - + stays as-is: every non-residential building gets a flat random 2-3 + workers, independent of role/demand. **This is a real tension, not + ignored** - see Risks below, since it means building/capability sizing + alone cannot *guarantee* a "just enough" starting state while worker + *counts* stay demand-blind. Flagged as a required follow-up. + +--- + +## 1. High-level strategy + +Three additive pieces, each independently shippable: + +**A. Link-scaled worker productivity.** Replace the flat headcount in the +capacity-consuming call sites of `capacity_services.workers_present()` +with a productivity-weighted sum, where each present character +contributes `1 + f(total_link_points)` instead of a flat `1`. `f` is a +diminishing-returns curve (not linear - `link_points` is cumulative and +unbounded, so a flat linear multiplier would let productivity grow +without limit forever). An unlinked NPC has zero `link_points` and +contributes exactly `1` - the existing baseline behaviour is unchanged +until a character actually gets linked and played. + +**B. Wire `population_estimation` into `settlement_plan`.** Give +`settlement_plan()` a population figure at generation time (before real +residents exist) via `population_estimation.population_capacity()` / +`estimate_population_from_footprint_areas()`, so building/capability +counts are sized to the settlement's actual scale instead of the +generator's current fixed lists (`SPECIAL_BUILDINGS` in +`generate_villages.py`, `SPECIAL_BUILDING_TYPES` in `watabou_import.py`). + +**C. Generate `BuildingCapability` rows from the plan, not a fixed type +list.** `generate_villages.py`/`watabou_import.py` stop hardcoding "one +granary, one inn, one mill, one bakery" and instead ask +`settlement_plan()` how many milling/baking/farming-supporting buildings +are recommended, assigning capabilities accordingly - including packing +multiple capabilities onto one building (e.g. `communal`) for small +villages, closing the original Ashenford gap by construction instead of +by luck of import ordering. + +--- + +## 2. Files likely to change + +- `character/models/character.py` - **existing**. Add a + `Character.total_link_points` property (mirrors the existing + `Player.total_link_points` at `users/models.py:439`), summing + `PlayerCharacterLink.total_link_points(self.links.all())` over every + link the character has ever had (active or historical) - the natural + input to the productivity formula. +- `economy/services/capacity_services.py` - **existing**. `workers_present` + returns a plain count today and stays as-is (still needed for pure + presence checks). Add a new weighted function (e.g. + `worker_capacity_present(building)`) used wherever a headcount + currently feeds a `capacity_per_day`/labor-cap calculation. +- `economy/constants.py` - **existing**. Add the productivity curve + constant(s) (e.g. `LINK_POINTS_PRODUCTIVITY_SCALE`, + `MAX_PRODUCTIVITY_BONUS`), following the existing pattern of every + other economy constant being a single named, commented, reasoned value. +- `economy/tasks.py` - **existing**. `_harvest`, `advance_mill_economy_tick`, + `advance_bakery_economy_tick` swap `workers_present(...)` for the new + weighted function where it feeds a labor-cap calculation (presence-only + checks elsewhere are unaffected). +- `economy/services/planning_services.py` - **existing, likely no + change**. Already population-driven; step B only changes *what + population figure* generation code passes in, not this service. +- `locations/services/population_estimation.py` - **existing**. Docstring + already anticipates this; likely just gains the actual call site + elsewhere, not internal changes. +- `locations/services/watabou_import.py` - **existing**. + `_assign_building_types`/`SPECIAL_BUILDING_TYPES` replaced by + capability-aware sizing from `settlement_plan`. +- `locations/management/commands/generate_villages.py` - **existing**. + `SPECIAL_BUILDINGS` fixed list replaced the same way. +- New tests: `economy/tests/test_capacity_services.py` (productivity + weighting), `character/tests/*` (`total_link_points`), + `locations/tests/test_watabou_import.py` / + `test_generate_villages.py`-equivalent (capability-aware generation). + +--- + +## 3. Implementation plan + +Independent, sequential PRs: + +1. **`Character.total_link_points` property.** Small, isolated, no + economy-side change yet - just exposes the input the productivity + formula needs. +2. **Link-scaled worker capacity.** Add the productivity curve to + `economy/constants.py` and the weighted-sum function to + `capacity_services.py`. Wire it into `economy/tasks.py`'s three + conversion ticks and `population_capacity_report`. No generation + changes yet - this alone makes existing villages' capacity respond to + linked characters playing. +3. **Wire population into settlement_plan at generation time.** Add a + call path from `generate_villages.py`/`watabou_import.py` to + `population_estimation` + `settlement_plan`, without yet changing what + buildings get created - just compute and log/print the recommended + plan, to validate the numbers against real village files before + changing generation behaviour. +4. **Generate capabilities from the plan.** Replace the fixed + `SPECIAL_BUILDINGS`/`SPECIAL_BUILDING_TYPES` allocation with + `settlement_plan`-driven capability assignment - including the + multi-capability packing case for small villages. +5. **(Flagged, not built here) Make `assign_workers.py` demand-aware.** + Needed to actually *guarantee* the "struggling at spawn" outcome end + to end - called out as the next real follow-up once 1-4 land, not + bundled in here per the existing scope boundary. + +--- + +## 4. Design decisions + +**a. Add a new weighted-capacity function vs. changing `workers_present` +in place.** Chosen: add alongside (e.g. `worker_capacity_present`), don't +change `workers_present`'s return type. Alternative: make +`workers_present` itself productivity-weighted. Rejected - +`workers_present` is also used for pure presence checks (deciding +*whether* production happens at all, not *how much*); silently turning +its return value from "headcount" into "weighted capacity units" would +be a subtle unit-confusion bug risk for any caller doing `if +workers_present(building):`. A differently-named function makes the unit +explicit at every call site. + +**b. `link_points`-based, diminishing-returns curve, not linear.** +`link_points` accrues daily (`days_linked * 20 + login_points + +time_points`, see `PlayerCharacterLink.link_points`) with no ceiling, so +a flat linear multiplier (`1 + link_points * k`) would make a +long-linked character's output grow forever, eventually dwarfing every +other constant in the economy - a balance and realism problem (a single +baker shouldn't out-produce ten fresh workers after a year). Recommend a +capped or diminishing-returns shape instead (e.g. `1 + +min(link_points / SCALE, MAX_BONUS)`, or a square-root curve) so +early linking gives a clear, visible boost and further play has +naturally shrinking marginal effect. Exact shape/constants are a +balancing question, not decided here (see Open Questions) - but "must +not be unbounded linear" is a firm constraint from this reasoning alone. + +**c. Sum `link_points` over every link a character has ever had, not just +the active one.** Chosen: `Character.total_link_points` mirrors +`Player.total_link_points`'s existing pattern of summing across all +links (`self.links.all()`), not filtering to `is_active=True`. Alternative: +only count the character's current active link, dropping accrued +productivity the moment a player unlinks. Rejected - would make +unlinking actively punish the village (a sudden capacity drop) rather +than just stopping further growth, which reads as a harsh, arguably +unintended consequence; retaining past investment while a character is +between links is the friendlier default and mirrors how `link_points` +itself already behaves as a permanent, cumulative figure once earned. + +**d. Multi-capability packing for small villages.** Reuse +`BuildingCapability`'s existing per-building multiplicity - a +`settlement_plan.milling.recommended_buildings == 1` and +`baking.recommended_buildings == 1` for a small village both resolve to +capabilities added to the *same* generated building (e.g. `communal`) +rather than two half-empty dedicated buildings, matching the user's +original framing ("small village: communal building -> milling + +baking"). Alternative: always generate one dedicated building per role +regardless of size. Rejected - defeats the purpose of the capability +work and reproduces the original small-village building-count pressure +that caused the Ashenford gap in the first place. + +--- + +## 5. Edge cases + +- **Zero starting population.** `settlement_plan(population=0)` already + returns `milling`/`baking`/`granaries` recommended at a floor of 1 (see + existing `planning_services` tests) - generation must still create at + least minimal capability coverage for a population-0 village, not skip + entirely. +- **`workers_present` vs. the new weighted function returning + inconsistent "is anyone here" signals.** Needs a clear contract (e.g. + weighted function returns exactly `0` iff headcount is `0`, since every + present character contributes at least `1`) so callers can't get a + false "someone's working" signal from an empty building, and an empty + building never accidentally produces a nonzero weighted value. +- **`assign_workers.py` still not demand-aware (see Risks).** Concretely: + after this plan, a tiny village could get a `communal` building sized + by `settlement_plan` for exactly 1 milling + 1 baking worker, but + `assign_workers.py` still hands it 2-3 random workers regardless - + overshooting "struggling" on day one. Document this explicitly rather + than let it look silently resolved. +- **Existing villages (already generated) don't retroactively resize.** + This plan only changes generation for *new* villages; nothing here + touches already-seeded population centres' existing buildings/ + capabilities (consistent with how the `BuildingCapability` backfill + migration handled existing data - additive, not retroactive resizing). + Existing characters do, however, immediately benefit from step 2's + productivity weighting the moment it ships, since it reads + `total_link_points` live. + +--- + +## 6. Tests + +- **New**: `Character.total_link_points` - sums across multiple links + (active and historical/unlinked), zero for a never-linked character. +- **New**: productivity-weighted capacity - a character with nonzero + `link_points` contributes more than an unlinked character to + `capacity_per_day`; the bonus is capped/diminishing, not unbounded + (assert the curve's ceiling behaviour, not just "more is more"), + formula-derived from the new constants (mirroring + `test_capacity_services.py`'s existing "never hardcode a literal" + convention). +- **New**: `settlement_plan`-driven generation - a small imported village + (few buildings) ends up with both milling and baking capabilities on + one building rather than missing one; a larger village gets dedicated + buildings per role, matching `recommended_buildings` from the plan. +- **Modify**: existing `generate_villages`/`watabou_import` tests + (`test_watabou_import.py`'s `test_leftover_after_every_special_type_ + falls_back_to_residential` etc.) will need to change or be replaced, + since the fixed-order special-type allocation they test is exactly + what's being removed. +- **Not covered here** (deferred with `assign_workers.py`): any test + asserting a freshly-generated village's *actual* worker headcount + matches its `settlement_plan` recommendation end-to-end - that + guarantee doesn't exist until `assign_workers.py` becomes demand-aware. + +--- + +## 7. Risks + +- **The "struggling at spawn" outcome isn't actually guaranteed by this + plan alone.** Without also making `assign_workers.py` demand-aware, + sizing buildings/capabilities correctly doesn't control how many + workers actually get assigned to them. The user's scope answer keeps + this out of the current plan deliberately - worth re-confirming before + implementation starts, since it means steps 3-4 alone won't visibly + deliver the stated goal without step 5 eventually following. +- **Unbounded productivity if the curve is implemented as linear by + mistake** - the single most important constraint from design decision + (b) to not lose during implementation; write the capped-behaviour test + before the implementation, not after. +- Conflating "productivity multiplier" (step 2) with "worker count" + (steps 3-4) in the same PR would make it hard to isolate which change + affected a given capacity number during testing/balancing - keep them + as the separate steps laid out above. + +--- + +## 8. Open questions + +- Exact productivity curve shape and constants (`SCALE`, `MAX_BONUS` or + equivalent) need real playtesting/balancing, not just a + plausible-sounding default - flag for a balance pass once visible, + consistent with how every other economy constant in this codebase is + annotated as approximate pending real tuning. +- Should the productivity bonus apply per-capability (a character could + theoretically be more "trained" at baking than milling) or uniformly + per-character regardless of which building they're assigned to? + Recommend uniform-per-character for this plan - per-capability skill + is a much larger character-progression feature on its own. +- Should `assign_workers.py` becoming demand-aware be scoped as the + *immediate* next plan after this one, given it's required to actually + deliver the user's stated goal end-to-end? diff --git a/.github/release.yml b/.github/release.yml index efdf47d1..dae62707 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -15,10 +15,10 @@ changelog: labels: - bug - known-issue - - Severity: critical - - Severity: high - - Severity: medium - - Severity: low + - "Severity: critical" + - "Severity: high" + - "Severity: medium" + - "Severity: low" - title: Developer experience and quality labels: diff --git a/.github/workflows/discord-release-notification.yml b/.github/workflows/discord-release-notification.yml new file mode 100644 index 00000000..3d4a18c3 --- /dev/null +++ b/.github/workflows/discord-release-notification.yml @@ -0,0 +1,57 @@ +name: Announce release on Discord + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + notify-discord: + runs-on: ubuntu-latest + steps: + - name: Post release to Discord + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} + RELEASE_NAME: ${{ github.event.release.name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_URL: ${{ github.event.release.html_url }} + RELEASE_BODY: ${{ github.event.release.body }} + RELEASE_AUTHOR: ${{ github.event.release.author.login }} + run: | + python3 - <<'PYEOF' + import json + import os + import urllib.request + + webhook_url = os.environ["DISCORD_WEBHOOK_URL"] + title = os.environ.get("RELEASE_NAME") or os.environ["RELEASE_TAG"] + body = os.environ.get("RELEASE_BODY") or "" + + # Discord embed descriptions are capped at 4096 characters. + max_len = 4000 + if len(body) > max_len: + body = body[:max_len].rsplit("\n", 1)[0] + "\n…" + + payload = { + "embeds": [ + { + "title": f"🚀 New release: {title}", + "url": os.environ["RELEASE_URL"], + "description": body, + "color": 0x5865F2, + "footer": {"text": f"Published by {os.environ.get('RELEASE_AUTHOR', 'unknown')}"}, + } + ] + } + + req = urllib.request.Request( + webhook_url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + print(resp.status) + PYEOF diff --git a/character/models/character.py b/character/models/character.py index e70d2c5e..fab8ffc6 100644 --- a/character/models/character.py +++ b/character/models/character.py @@ -446,6 +446,15 @@ def assign_work(self, building: Building): def has_available(cls): return character_services.character_has_available(cls) + @property + def total_link_points(self): + """ + Sum of link_points across every player link this character has ever + had (past and current) - the character-side symmetric counterpart to + Player.total_link_points. + """ + return PlayerCharacterLink.total_link_points(self.links.all()) + ######################################################################## #### PLAYER CHARACTER LINK MODEL diff --git a/character/services/behaviour_services.py b/character/services/behaviour_services.py index 5f441594..8e2b07ed 100644 --- a/character/services/behaviour_services.py +++ b/character/services/behaviour_services.py @@ -7,6 +7,7 @@ from django.utils import timezone from character.utils import window_for_date, work_activities_for +from locations.services.schedule import work_hours_for from progression.models import ActivityDefinition, CharacterActivity _FIXED_KINDS = [ @@ -54,32 +55,39 @@ def aware(dt_date, t: time): def jitter_minutes(base_dt, minutes): return base_dt + timedelta(minutes=rng.randint(-minutes, minutes)) - sleep_start = aware(date, time(23, 0)) wake = aware(date, time(7, 0)) wake = jitter_minutes(wake, 15) morning_start = wake morning_end = morning_start + timedelta(hours=1) - work1_start = morning_end - work1_end = aware(date, time(12, 0)) - - lunch_start = work1_end - lunch_start = jitter_minutes(lunch_start, 10) + # The work window comes from the character's actual assigned work + # building's hours (same source movement uses - see + # locations.services.schedule.target_role_for) rather than a fixed + # 8-17 assumption, so e.g. an inn open until 23:00 keeps its workers' + # scheduled activity as "working" that late instead of falling through + # to the fixed evening leisure block. + default_work_start, default_work_end = work_hours_for(behaviour.character) + work_start = max(morning_end, aware(date, default_work_start)) + work_end = aware(date, default_work_end) + + lunch_midpoint = work_start + (work_end - work_start) / 2 + lunch_start = jitter_minutes(lunch_midpoint, 10) lunch_end = lunch_start + timedelta(hours=1) + work1_start = work_start + work1_end = lunch_start work2_start = lunch_end - work2_end = aware(date, time(17, 0)) + work2_end = work_end - dinner_start = aware(date, time(17, 30)) - dinner_start = jitter_minutes(dinner_start, 10) + dinner_start = jitter_minutes(max(work_end, aware(date, time(17, 30))), 10) dinner_end = dinner_start + timedelta(hours=1) leisure_start = dinner_end - leisure_end = aware(date, time(22, 30)) + leisure_end = max(leisure_start, aware(date, time(22, 30))) wind_start = leisure_end - wind_end = aware(date, time(23, 0)) + wind_end = max(wind_start, aware(date, time(23, 0))) day_window(behaviour, date) @@ -87,7 +95,7 @@ def jitter_minutes(base_dt, minutes): next_wake = aware(next_day, time(7, 0)) next_wake = jitter_minutes(next_wake, 15) - sleep_start = aware(date, time(23, 0)) + sleep_start = wind_end sleep_end = next_wake fixed = _fixed_activity_definitions() diff --git a/character/services/relationship_services.py b/character/services/relationship_services.py index ba37b8cd..b924aa56 100644 --- a/character/services/relationship_services.py +++ b/character/services/relationship_services.py @@ -37,7 +37,7 @@ def relationship_create(relationship_type, members, variant=""): relationship_type=relationship_type, variant=variant ) - counts = {} + counts: dict[RelationshipRole, int] = {} for character, role in members: role = RelationshipRole(role) CharacterRelationshipMembership.objects.create( diff --git a/character/tests/test_behaviour_services.py b/character/tests/test_behaviour_services.py index 4138d3c5..cdbeb2d8 100644 --- a/character/tests/test_behaviour_services.py +++ b/character/tests/test_behaviour_services.py @@ -1,11 +1,13 @@ -from datetime import date +from datetime import date, datetime, time from django.contrib.gis.geos import Point from django.test import TestCase +from django.utils import timezone -from character.models import Character +from character.models import Character, CharacterLocation from character.services.behaviour_services import _FIXED_KINDS from character.utils import work_activities_for +from locations.models import Building from progression.models import ( ActivityDefinition, CharacterActivity, @@ -129,6 +131,34 @@ def test_generating_the_same_day_twice_is_deterministic(self): self.assertEqual(first_ids, second_ids) + def test_late_building_hours_extend_the_work_block_past_the_default_workday(self): + # Inn hours run 06:00-23:00 (see Building.BUILDING_TYPE_HOURS) - well + # past generate_day's old fixed 17:00 work cutoff. An inn worker + # should still be scheduled as "working" in the evening instead of + # falling through to the fixed leisure block (issue: characters + # assigned to the inn showed as "Relaxing" during their shift). + inn = Building.objects.create( + name="The Tipsy Griffin", + building_type="inn", + location=Point(0, 0, srid=3857), + ) + CharacterLocation.objects.create( + character=self.character, + location=inn, + role=CharacterLocation.Role.WORK, + is_primary=True, + ) + + self.character.behaviour.generate_day(date(2026, 1, 5)) + + evening = timezone.make_aware(datetime.combine(date(2026, 1, 5), time(21, 0))) + activity_at_evening = CharacterActivity.objects.get( + character=self.character, + scheduled_start__lte=evening, + scheduled_end__gt=evening, + ) + self.assertEqual(activity_at_evening.activity_definition.kind, "work") + class DeleteDayTests(TestCase): def setUp(self): diff --git a/character/tests/test_filters.py b/character/tests/test_filters.py index f55b8762..de73f859 100644 --- a/character/tests/test_filters.py +++ b/character/tests/test_filters.py @@ -5,7 +5,7 @@ from character.models import Character, PlayerCharacterLink from character.filters import CharacterFilter -from users.models import CustomUser +from users.tests import user_factory class CharacterFilterTests(TestCase): @@ -32,9 +32,7 @@ def setUp(self): # Create player characters # User creation auto-assigns characters, so we need to handle that - self.user1 = CustomUser.objects.create_user( - email="user1@example.com", password="testpass123" - ) + self.user1 = user_factory(with_player=True) # Deactivate auto-assigned character auto_links = PlayerCharacterLink.objects.filter( player=self.user1.player, is_active=True @@ -55,9 +53,7 @@ def setUp(self): player=self.user1.player, character=self.player_char1, is_active=True ) - self.user2 = CustomUser.objects.create_user( - email="user2@example.com", password="testpass123" - ) + self.user2 = user_factory(with_player=True) # Deactivate auto-assigned character auto_links = PlayerCharacterLink.objects.filter( player=self.user2.player, is_active=True diff --git a/character/tests/test_models.py b/character/tests/test_models.py index 9892b819..45704cff 100644 --- a/character/tests/test_models.py +++ b/character/tests/test_models.py @@ -18,6 +18,8 @@ RELATIONSHIP_SPECS, ) +from users.tests import user_factory + class CharacterRelationshipTests(TestCase): def setUp(self): @@ -423,9 +425,7 @@ def setUp(self): # Create a player-linked character # When creating a user, signals automatically create a player and assign a character # We need to deactivate the auto-assigned link first - self.user = CustomUser.objects.create_user( - email="test@example.com", password="testpass123" - ) + self.user = user_factory(with_player=True) self.player = self.user.player # Deactivate any auto-assigned character links @@ -489,11 +489,10 @@ def test_has_available_no_linkable_characters(self): def test_has_available_all_linked(self): """Test has_available returns False when all linkable characters are linked""" - from users.models import CustomUser from character.models import PlayerCharacterLink - user1 = CustomUser.objects.create_user(email="user1@test.com", password="pass") - user2 = CustomUser.objects.create_user(email="user2@test.com", password="pass") + user1 = user_factory(with_player=True) + user2 = user_factory(with_player=True) PlayerCharacterLink.assign_character(player=user1.player, character=self.npc1) PlayerCharacterLink.assign_character(player=user2.player, character=self.npc2) @@ -511,13 +510,10 @@ class PlayerCharacterLinkPointsTodayTests(TestCase): """Tests for PlayerCharacterLink.player_time_today/points_today (issue #673).""" def setUp(self): - from users.models import CustomUser from progression.models import PlayerActivity self.PlayerActivity = PlayerActivity - self.user = CustomUser.objects.create_user( - email="today-points@example.com", password="pass12345" - ) + self.user = user_factory(with_player=True) self.player = self.user.player character = Character.objects.create(given_name="Hero") self.link = PlayerCharacterLink.objects.create( @@ -567,3 +563,49 @@ def test_points_today_excludes_activity_before_link_started(self): def test_points_today_zero_with_no_activities(self): self.assertEqual(self.link.player_time_today, 0) self.assertEqual(self.link.points_today, 0) + + +class CharacterTotalLinkPointsTests(TestCase): + """Tests for Character.total_link_points (the character-side counterpart + to Player.total_link_points).""" + + def setUp(self): + from users.tests.factories import user_factory + + self.character = Character.objects.create(given_name="Hero") + # DecimalField's string default isn't coerced to Decimal until a real + # DB round-trip, so refresh before any test computes link_points + # directly (as opposed to via the DB-backed total_link_points query). + self.character.refresh_from_db() + self.user1 = user_factory(with_player=True) + self.user1.player.refresh_from_db() + self.user2 = user_factory(with_player=True) + self.user2.player.refresh_from_db() + + def _make_link(self, player, *, days_linked, unlinked=False): + linked_at = now() - timedelta(days=days_linked) + link = PlayerCharacterLink.objects.create( + player=player, character=self.character, linked_at=linked_at + ) + if unlinked: + link.unlinked_at = now() + link.is_active = False + link.save(update_fields=["unlinked_at", "is_active"]) + return link + + def test_zero_for_a_never_linked_character(self): + never_linked = Character.objects.create(given_name="Loner") + self.assertEqual(never_linked.total_link_points, 0) + + def test_sums_a_single_active_link(self): + link = self._make_link(self.user1.player, days_linked=3) + self.assertEqual(self.character.total_link_points, link.link_points) + + def test_sums_across_historical_and_active_links(self): + old_link = self._make_link(self.user1.player, days_linked=10, unlinked=True) + current_link = self._make_link(self.user2.player, days_linked=2) + + self.assertEqual( + self.character.total_link_points, + old_link.link_points + current_link.link_points, + ) diff --git a/character/utils.py b/character/utils.py index 7d818332..5e80995a 100644 --- a/character/utils.py +++ b/character/utils.py @@ -57,7 +57,7 @@ def work_activities_for(character): for activity in ActivityDefinition.objects.filter( kind=ActivityDefinition.Kind.WORK ).select_related("skill", "skill__role") - if activity.skill_id is None + if activity.skill is None or ( (activity.skill.role_id is None or activity.skill.role_id in held_role_ids) and activity.skill.is_unlocked_for(character) diff --git a/dev-requirements.txt b/dev-requirements.txt index 55bd2ec9..2a359b1e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,13 +2,13 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile dev-requirements.in +# pip-compile --output-file=dev-requirements.txt dev-requirements.in # amqp==5.3.1 # via # -r requirements.in # kombu -asgiref==3.11.1 +asgiref==3.12.1 # via # -r requirements.in # channels @@ -17,7 +17,7 @@ asgiref==3.11.1 # django # django-allauth # django-cors-headers -ast-serialize==0.6.0 +ast-serialize==0.8.0 # via mypy astral==3.2 # via -r requirements.in @@ -27,7 +27,7 @@ attrs==26.1.0 # referencing # service-identity # twisted -autobahn==26.6.2 +autobahn==26.7.1 # via daphne automat==25.4.16 # via twisted @@ -44,19 +44,20 @@ brotli==1.2.0 build==1.5.0 # via # -r dev-requirements.in + # nab-python # pip-tools -cbor2==6.1.2 +cbor2==6.1.4 # via autobahn celery==5.6.3 # via # -r requirements.in # django-celery-beat -certifi==2026.6.17 +certifi==2026.7.22 # via # geventhttpclient # requests # sentry-sdk -cffi==2.0.0 +cffi==2.1.1 # via # autobahn # cryptography @@ -68,7 +69,7 @@ channels==4.3.2 # channels-redis channels-redis==4.3.0 # via -r requirements.in -charset-normalizer==3.4.7 +charset-normalizer==3.4.9 # via requests click==8.4.2 # via @@ -99,20 +100,20 @@ cron-descriptor==1.4.5 # via # -r requirements.in # django-celery-beat -cryptography==49.0.0 +cryptography==50.0.0 # via # -r requirements.in # autobahn # pyopenssl # sendgrid # service-identity -daphne==4.2.2 +daphne==4.2.3 # via -r requirements.in defusedxml==0.7.1 # via # -r requirements.in # python3-openid -disposable-email-domains==0.0.217 +disposable-email-domains==0.0.237 # via -r requirements.in distlib==0.4.3 # via virtualenv @@ -120,7 +121,7 @@ dj-database-url==3.1.2 # via -r requirements.in dj-rest-auth==7.2.0 # via -r requirements.in -django==5.2.16 +django==5.2.17 # via # -r requirements.in # channels @@ -143,7 +144,7 @@ django==5.2.16 # drf-spectacular django-admin-sortable2==2.3.1 # via -r requirements.in -django-allauth==65.18.0 +django-allauth==65.19.0 # via -r requirements.in django-celery-beat==2.9.0 # via -r requirements.in @@ -165,7 +166,7 @@ django-stubs==5.2.9 # via # -r dev-requirements.in # djangorestframework-stubs -django-stubs-ext==5.2.9 +django-stubs-ext==6.0.9 # via # -r dev-requirements.in # django-stubs @@ -175,7 +176,7 @@ django-timezone-field==7.2.2 # django-celery-beat django-vite==3.1.0 # via -r requirements.in -djangorestframework==3.17.1 +djangorestframework==3.18.0 # via # -r requirements.in # dj-rest-auth @@ -185,9 +186,9 @@ djangorestframework-simplejwt==5.5.1 # via -r requirements.in djangorestframework-stubs==3.16.9 # via -r dev-requirements.in -drf-spectacular==0.29.0 +drf-spectacular==0.30.0 # via -r requirements.in -filelock==3.29.5 +filelock==3.32.2 # via # python-discovery # virtualenv @@ -202,13 +203,13 @@ flask-login==0.6.3 # via locust freezegun==1.5.5 # via -r dev-requirements.in -gevent==25.9.1 +gevent==26.7.0 # via # geventhttpclient # locust geventhttpclient==2.3.9 # via locust -greenlet==3.5.3 +greenlet==3.5.4 # via gevent h11==0.16.0 # via wsproto @@ -229,6 +230,8 @@ inflection==0.5.1 # via drf-spectacular iniconfig==2.3.0 # via pytest +installer==1.0.1 + # via nab-python itsdangerous==2.2.0 # via flask jinja2==3.1.6 @@ -243,7 +246,7 @@ kombu==5.6.2 # via # -r requirements.in # celery -librt==0.12.0 +librt==0.15.0 # via mypy locust==2.46.3 # via -r dev-requirements.in @@ -258,21 +261,29 @@ msgpack==1.2.1 # autobahn # channels-redis # locust -mypy==2.1.0 +mypy==2.3.0 # via -r dev-requirements.in mypy-extensions==1.1.0 # via mypy +nab-index==0.0.12 + # via + # nab-python + # pipdeptree +nab-python==0.0.12 + # via pipdeptree +nab-resolver==0.0.12 + # via nab-python nodeenv==1.10.0 # via pre-commit -numpy==2.5.1 +numpy==2.5.2 # via -r requirements.in -packaging==26.2 +packaging==26.3 # via # build # incremental # kombu + # nab-index # pip-review - # pipdeptree # pytest # wheel pathspec==1.1.1 @@ -281,19 +292,17 @@ pillow==12.3.0 # via -r requirements.in pip-review==1.3.0 # via -r dev-requirements.in -pip-tools==7.5.3 +pip-tools==7.6.0 # via -r dev-requirements.in -pipdeptree==3.1.1 +pipdeptree==4.2.0 # via -r dev-requirements.in -platformdirs==4.10.0 - # via - # python-discovery - # virtualenv +platformdirs==4.11.1 + # via virtualenv pluggy==1.6.0 # via pytest pre-commit==4.6.1 # via -r dev-requirements.in -prompt-toolkit==3.0.52 +prompt-toolkit==3.0.53 # via click-repl psutil==7.2.2 # via locust @@ -309,11 +318,12 @@ pyjwt==2.13.0 # via # -r requirements.in # djangorestframework-simplejwt -pyopenssl==26.3.0 +pyopenssl==26.4.0 # via twisted pyproject-hooks==1.2.0 # via # build + # nab-python # pip-tools pytest==9.1.1 # via locust @@ -326,17 +336,17 @@ python-dateutil==2.9.0.post0 # freezegun python-decouple==3.8 # via -r requirements.in -python-discovery==1.4.3 +python-discovery==1.5.1 # via virtualenv python-dotenv==1.2.2 # via -r requirements.in -python-engineio==4.13.3 +python-engineio==4.13.4 # via # locust # python-socketio python-http-client==3.3.7 # via sendgrid -python-socketio[client]==5.16.3 +python-socketio[client]==5.16.4 # via locust python3-openid==3.2.0 # via -r requirements.in @@ -368,7 +378,7 @@ rpds-py==2026.6.3 # referencing sendgrid==6.12.5 # via -r requirements.in -sentry-sdk==2.64.0 +sentry-sdk==2.66.1 # via -r requirements.in service-identity==26.1.0 # via twisted @@ -380,15 +390,21 @@ sqlparse==0.5.5 # via # -r requirements.in # django -stripe==15.3.0 +stripe==15.4.0 # via -r requirements.in +tomli==2.4.1 + # via nab-python +tomli-w==1.2.0 + # via nab-python +truststore==0.10.4 + # via nab-index twisted[tls]==26.4.0 # via # -r requirements.in # daphne txaio==26.6.1 # via autobahn -types-pyyaml==6.0.12.20260518 +types-pyyaml==6.0.12.20260724 # via # -r dev-requirements.in # django-stubs @@ -401,11 +417,14 @@ typing-extensions==4.16.0 # django-stubs-ext # djangorestframework-stubs # mypy + # nab-index + # nab-python + # nab-resolver # pyopenssl # referencing # stripe # twisted -tzdata==2026.2 +tzdata==2026.3 # via # -r requirements.in # django-celery-beat @@ -421,6 +440,7 @@ uritemplate==4.2.0 urllib3==2.7.0 # via # geventhttpclient + # nab-index # requests # sentry-sdk vine==5.1.0 @@ -429,7 +449,7 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==21.5.1 +virtualenv==21.7.3 # via pre-commit wcwidth==0.8.2 # via prompt-toolkit diff --git a/docs/architecture/repo-structure.md b/docs/architecture/repo-structure.md index a8408ee6..20f7e720 100644 --- a/docs/architecture/repo-structure.md +++ b/docs/architecture/repo-structure.md @@ -138,7 +138,7 @@ locations/ │ ├── setup_world.py │ ├── show_map.py │ ├── generate_characters.py -│ └── spawn_villages.py +│ └── generate_villages.py ├── models.py ├── serializers.py ├── services/ diff --git a/docs/index.md b/docs/index.md index 1b31aed5..b6fd41b2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,5 +10,5 @@ This documentation covers the project's architecture, development processes, ope * **[Operations](operations/index.md)** — Configuration, deployment, and maintenance guides. * **[Development](development/CONTRIBUTING.md)** — Contributor resources and development practices. * **[Design Notes](design-notes/index.md)** — Ideas and explorations for future features. -* **[Storybook (opens in new tab)](https://progressrpg.github.io/ProgressRPG/){target=_blank}** — Browse reusable UI components, states, and visual examples. +* **[Storybook (opens in new tab)](https://progressrpg.github.io/ProgressRPG/storybook/){target=_blank}** — Browse reusable UI components, states, and visual examples. - **[API Reference (opens in new tab)](https://web-acgr.onrender.com/api/docs/){target=_blank rel=noopener}** — Explore available endpoints, request formats, and responses using the interactive Swagger documentation. diff --git a/economy/constants.py b/economy/constants.py index a561149f..6d131bbe 100644 --- a/economy/constants.py +++ b/economy/constants.py @@ -116,6 +116,31 @@ class UnitKind(Enum): # unbounded. HUNGER_MAX = 100.0 +# Link-scaled worker productivity: a physically-present worker contributes +# 1 (baseline - matches today's flat headcount) plus a bonus derived from +# their character's total_link_points, instead of a flat 1 per worker. +# Capped, not linear, since link_points itself is cumulative and unbounded +# (see PlayerCharacterLink.link_points) - an uncapped multiplier would let a +# long-linked character's output grow forever. ~100 days of a link +# (100 * 20 = 2000 link points from days_linked alone, before any +# login/activity points) reaches the cap - a rough "several months of +# regular play" outer bound pending a real balance pass. +LINK_POINTS_PRODUCTIVITY_SCALE = 2000 + +# Maximum productivity bonus a single worker's link_points can contribute - +# a fully-maxed worker produces at most 1 + MAX_PRODUCTIVITY_BONUS times the +# unlinked baseline (2x at the default below). +MAX_PRODUCTIVITY_BONUS = 1.0 + +# A settlement at or below this resident count shares one building for both +# milling and baking (see planning_services.SettlementPlan. +# combine_milling_and_baking) rather than getting a dedicated building per +# role, even when there'd be enough building slots for two - two half-empty +# production buildings isn't a better outcome than one shared one for a +# small village. 30 is an approximate "small village" cutoff pending a real +# balance pass, not derived from another constant. +SMALL_SETTLEMENT_POPULATION_THRESHOLD = 30 + def unit_suffix(good_type): """Display suffix ("kg"/"L") for a good's quantity, per GOOD_TYPE_UNIT.""" @@ -123,6 +148,16 @@ def unit_suffix(good_type): return "L" if unit == UnitKind.VOLUME else "kg" +# Grain and flour quantities/rates switch from kg to tonnes past this many +# kg, rather than staying in kg indefinitely - a granary/mill dealing in +# tonnes of wheat or flour is a common, expected scale (unlike bread, which +# stays loaf-counted regardless of size - see _format_bread), so kg alone +# gets unreadable there. Not applied to signed deltas (see _format_default), +# which stay in the plain kg figure. +TONNE_DISPLAY_THRESHOLD_KG = 1000 +TONNE_DISPLAY_GOOD_TYPES = {"wheat", "flour"} + + # Bread naturally exists as discrete loaves, so it's displayed as a loaf # count rather than a weight - 1 loaf = 1kg is an exact, not approximate, # conversion for display purposes. @@ -140,10 +175,19 @@ def _format_default(good_type, value, signed=False): Format a quantity as a plain weight/volume figure. Weight-kind goods are stored in grams but displayed in kilograms for readability; volume-kind goods (litres) keep one decimal place, since fractional litres are - meaningful. + meaningful. Unsigned wheat/flour quantities past TONNE_DISPLAY_THRESHOLD_KG + switch to tonnes instead (see TONNE_DISPLAY_GOOD_TYPES). """ unit = GOOD_TYPE_UNIT.get(good_type, UnitKind.WEIGHT) display_value = value if unit == UnitKind.VOLUME else value / 1000 + + if ( + not signed + and good_type in TONNE_DISPLAY_GOOD_TYPES + and display_value >= TONNE_DISPLAY_THRESHOLD_KG + ): + return f"{display_value / 1000:,.1f}t" + sign = "+" if signed else "" return f"{display_value:{sign},.1f}{unit_suffix(good_type)}" diff --git a/economy/management/commands/economy_forecast.py b/economy/management/commands/economy_forecast.py index fdb09f0e..7666012e 100644 --- a/economy/management/commands/economy_forecast.py +++ b/economy/management/commands/economy_forecast.py @@ -124,25 +124,33 @@ def handle(self, *args, **options): ) ) - # Column per building, in a fixed order matching the row - # printing below - building ids are baked into the headers so - # multiple granaries/mills/bakeries (multiple villages) don't - # collide under one shared label. - columns = [f"G{g.id}(kg)" for g in granaries] - for m in mills: - columns += [f"M{m.id} made(kg)", f"M{m.id} stock(kg)"] - for b in bakeries: - columns += [f"B{b.id} made(kg)", f"B{b.id} stock(kg)"] - - self.stdout.write( - f"{'day':>5} {'date':>10} " - + " ".join(f"{c:>14}" for c in columns) - + f" {'unfed':>7}" - ) + # Grouped by population centre rather than one column per + # building - a column-per-building table gets unreadably wide + # once a world has more than a couple of villages, and the + # per-village total is what's actually useful for tuning. + centre_names: dict[int | None, str] = {} + centre_granaries: dict[int | None, list] = {} + centre_mills: dict[int | None, list] = {} + centre_bakeries: dict[int | None, list] = {} + for buildings, bucket in ( + (granaries, centre_granaries), + (mills, centre_mills), + (bakeries, centre_bakeries), + ): + for building in buildings: + cid = building.population_centre_id + centre_names[cid] = ( + building.population_centre.name + if building.population_centre + else "(no centre)" + ) + bucket.setdefault(cid, []).append(building) + all_centre_ids = sorted(centre_names, key=lambda cid: centre_names[cid]) first_bread_day = None min_bread = None worst_hunger = 0.0 + any_unfed = False # advance_bread_consumption_tick logs a warning per unfed # character - useful for the real Celery task, but pure noise @@ -204,6 +212,9 @@ def handle(self, *args, **options): if hunger_after > hunger_before.get(needs_id, 0.0) ) + if unfed_today > 0: + any_unfed = True + if bread_produced_today > 0 and first_bread_day is None: first_bread_day = day_offset if min_bread is None or bread_produced_today < min_bread: @@ -226,35 +237,49 @@ def handle(self, *args, **options): GoodsStock.GoodType.BREAD, [b.id for b in bakeries] ) - row_values = [ - f"{wheat_now.get(g.id, 0.0) / 1000:,.1f}" for g in granaries - ] - for mill in mills: - made = flour_after.get(mill.id, 0.0) - flour_before.get( - mill.id, 0.0 - ) - row_values.append(f"{made / 1000:,.1f}") - row_values.append( - f"{flour_after.get(mill.id, 0.0) / 1000:,.1f}" - ) - for bakery in bakeries: - made = bread_after_baking.get( - bakery.id, 0.0 - ) - bread_before.get(bakery.id, 0.0) - row_values.append(f"{made / 1000:,.1f}") - row_values.append( - f"{bread_now.get(bakery.id, 0.0) / 1000:,.1f}" + self.stdout.write(f"\nDay {day_offset} ({today.isoformat()})") + for cid in all_centre_ids: + parts = [] + g_list = centre_granaries.get(cid, []) + if g_list: + wheat_qty = sum( + wheat_now.get(g.id, 0.0) for g in g_list + ) + parts.append( + f"wheat {format_quantity('wheat', wheat_qty)}" + ) + m_list = centre_mills.get(cid, []) + if m_list: + made = sum( + flour_after.get(m.id, 0.0) + - flour_before.get(m.id, 0.0) + for m in m_list + ) + stock = sum(flour_after.get(m.id, 0.0) for m in m_list) + parts.append( + f"flour +{format_quantity('flour', made)}/" + f"{format_quantity('flour', stock)}" + ) + b_list = centre_bakeries.get(cid, []) + if b_list: + made = sum( + bread_after_baking.get(b.id, 0.0) + - bread_before.get(b.id, 0.0) + for b in b_list + ) + stock = sum(bread_now.get(b.id, 0.0) for b in b_list) + parts.append( + f"bread +{format_quantity('bread', made)}/" + f"{format_quantity('bread', stock)}" + ) + self.stdout.write( + f" {centre_names[cid]}: " + ", ".join(parts) ) - - self.stdout.write( - f"{day_offset:>5} {today.isoformat():>10} " - + " ".join(f"{v:>14}" for v in row_values) - + f" {unfed_today:>7}" - ) + self.stdout.write(f" unfed today: {unfed_today}") finally: economy_logger.setLevel(previous_log_level) - self._print_verdict(first_bread_day, min_bread, worst_hunger) + self._print_verdict(first_bread_day, min_bread, worst_hunger, any_unfed) if not options["commit"]: transaction.set_rollback(True) @@ -321,7 +346,10 @@ def _print_storage_capacities(self): Storage capacity is derived per-building from its InteriorSpace area (see GoodsStock.capacity), not a flat constant - random building footprints mean it varies building to building, so it's worth - surfacing here rather than only discoverable via the shell. + surfacing here rather than only discoverable via the shell. Summed + per population centre (across every granary/mill/bakery it has, + rather than one line per building) so this stays readable as a + village gains multiple buildings of the same role. """ buildings = Building.objects.filter( building_type__in=BUILDING_STORAGE_GOODS @@ -329,29 +357,43 @@ def _print_storage_capacities(self): if not buildings: return - self.stdout.write(self.style.MIGRATE_HEADING("\nWorking building storage")) + centre_names: dict[int | None, str] = {} + totals_by_centre: dict[int | None, dict[str, list[float]]] = {} for building in buildings: + centre_id = building.population_centre_id + centre_names[centre_id] = ( + building.population_centre.name + if building.population_centre + else "(no centre)" + ) + totals = totals_by_centre.setdefault(centre_id, {}) for good_type in BUILDING_STORAGE_GOODS[building.building_type]: stock, _ = GoodsStock.objects.get_or_create( building=building, good_type=good_type ) - fill_percent = ( - (stock.quantity / stock.capacity * 100) if stock.capacity else 0.0 + quantity, capacity = totals.get(good_type, [0.0, 0.0]) + totals[good_type] = [ + quantity + stock.quantity, + capacity + stock.capacity, + ] + + self.stdout.write(self.style.MIGRATE_HEADING("\nGoods stored")) + for centre_id in sorted(totals_by_centre, key=lambda cid: centre_names[cid]): + parts = [] + for good_type in ( + GoodsStock.GoodType.WHEAT, + GoodsStock.GoodType.FLOUR, + GoodsStock.GoodType.BREAD, + ): + if good_type not in totals_by_centre[centre_id]: + continue + quantity, capacity = totals_by_centre[centre_id][good_type] + fill_percent = (quantity / capacity * 100) if capacity else 0.0 + parts.append( + f"{good_type} {format_quantity(good_type, quantity)}/" + f"{format_quantity(good_type, capacity)} ({fill_percent:.0f}%)" ) - self.stdout.write( - f" {self._building_label(building)} {good_type}: " - f"{format_quantity(good_type, stock.quantity)} / " - f"{format_quantity(good_type, stock.capacity)} " - f"({fill_percent:.0f}%)" - ) - - def _building_label(self, building): - centre_name = ( - building.population_centre.name - if building.population_centre - else "(no centre)" - ) - return f"{building.building_type.title()} {building.id} ({centre_name})" + self.stdout.write(f" {centre_names[centre_id]}: " + ", ".join(parts)) def _quantities_by_building(self, good_type, building_ids): if not building_ids: @@ -361,7 +403,7 @@ def _quantities_by_building(self, good_type, building_ids): ).values_list("building_id", "quantity") return dict(rows) - def _print_verdict(self, first_bread_day, min_bread, worst_hunger): + def _print_verdict(self, first_bread_day, min_bread, worst_hunger, any_unfed): self.stdout.write(self.style.MIGRATE_HEADING("\nVerdict")) if first_bread_day is None: self.stdout.write(" Bread never became available in this window.") @@ -377,10 +419,18 @@ def _print_verdict(self, first_bread_day, min_bread, worst_hunger): self.stdout.write( f" Minimum bread baked in a single day: {format_quantity('bread', min_bread)}" ) + # worst_hunger is the peak hunger value observed, which can include + # hunger a character already had entering the simulation (e.g. from + # the real Celery beat schedule ticking this same economy in the + # background - see progress_rpg/celery.py) being paid down during + # the window, not necessarily hunger caused by it. any_unfed (did + # any character actually miss a meal on any simulated day) is the + # correct signal for whether *this* simulation run caused a + # shortfall. self.stdout.write( f" Worst hunger value reached by any character: {worst_hunger:.1f}" ) - if worst_hunger > 0: + if any_unfed: self.stdout.write( self.style.WARNING( " Some characters went unfed at least once - consider a larger " diff --git a/economy/management/commands/economy_status.py b/economy/management/commands/economy_status.py index 8952e917..b619b786 100644 --- a/economy/management/commands/economy_status.py +++ b/economy/management/commands/economy_status.py @@ -47,11 +47,10 @@ def handle(self, *args, **options): self.stdout.write("No population centres found.") return - buildings_by_centre = {} + buildings_by_centre: dict[int | None, list[Building]] = {} buildings = ( Building.objects.filter(population_centre__in=centres) - .select_related("conversion_state") - .prefetch_related("goods_stocks") + .prefetch_related("goods_stocks", "conversion_states") .order_by("building_type", "name") ) for building in buildings: @@ -81,9 +80,10 @@ def _print_building(self, building): else: self.stdout.write(" (no goods stored)") - state = getattr(building, "conversion_state", None) - if state is not None: - self.stdout.write(f" last processed: {state.last_processed_on}") + for state in building.conversion_states.all(): + self.stdout.write( + f" {state.activity} last processed: {state.last_processed_on}" + ) def _print_population_capacity(self, centre): """ diff --git a/economy/migrations/0004_buildingcapability.py b/economy/migrations/0004_buildingcapability.py new file mode 100644 index 00000000..02fbc369 --- /dev/null +++ b/economy/migrations/0004_buildingcapability.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.16 on 2026-08-09 15:47 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("economy", "0003_alter_goodsstock_good_type"), + ("locations", "0010_building_open_time_override_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="BuildingCapability", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "activity", + models.CharField( + choices=[ + ("milling", "Milling"), + ("baking", "Baking"), + ("farming", "Farming"), + ], + max_length=20, + ), + ), + ( + "building", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="capabilities", + to="locations.building", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("building", "activity"), + name="uniq_capability_per_building", + ) + ], + }, + ), + ] diff --git a/economy/migrations/0005_backfill_building_capabilities.py b/economy/migrations/0005_backfill_building_capabilities.py new file mode 100644 index 00000000..87c4bf29 --- /dev/null +++ b/economy/migrations/0005_backfill_building_capabilities.py @@ -0,0 +1,44 @@ +from django.db import migrations + +# Maps every existing building_type with a production meaning today onto +# the new capability it should get, per +# .claude/plans/building-capabilities-plan.md. Types not listed +# (residential, hall, market, communal, inn, granary) get no capability - +# granary is storage, not a labor-capped production activity (see +# BuildingCapability's docstring), and the rest have no production role at +# all today. +BUILDING_TYPE_TO_ACTIVITY = { + "mill": "milling", + "bakery": "baking", + "field_shelter": "farming", +} + + +def backfill_capabilities(apps, schema_editor): + Building = apps.get_model("locations", "Building") + BuildingCapability = apps.get_model("economy", "BuildingCapability") + + capabilities = [ + BuildingCapability(building=building, activity=activity) + for building_type, activity in BUILDING_TYPE_TO_ACTIVITY.items() + for building in Building.objects.filter(building_type=building_type) + ] + BuildingCapability.objects.bulk_create(capabilities) + + +def remove_backfilled_capabilities(apps, schema_editor): + BuildingCapability = apps.get_model("economy", "BuildingCapability") + BuildingCapability.objects.filter( + activity__in=BUILDING_TYPE_TO_ACTIVITY.values() + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("economy", "0004_buildingcapability"), + ] + + operations = [ + migrations.RunPython(backfill_capabilities, remove_backfilled_capabilities), + ] diff --git a/economy/migrations/0006_goodsconversionstate_activity.py b/economy/migrations/0006_goodsconversionstate_activity.py new file mode 100644 index 00000000..0e58bacc --- /dev/null +++ b/economy/migrations/0006_goodsconversionstate_activity.py @@ -0,0 +1,36 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("economy", "0005_backfill_building_capabilities"), + ("locations", "0010_building_open_time_override_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="goodsconversionstate", + name="building", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="conversion_states", + to="locations.building", + ), + ), + migrations.AddField( + model_name="goodsconversionstate", + name="activity", + field=models.CharField( + blank=True, + choices=[ + ("milling", "Milling"), + ("baking", "Baking"), + ("farming", "Farming"), + ], + max_length=20, + null=True, + ), + ), + ] diff --git a/economy/migrations/0007_backfill_conversion_state_activity.py b/economy/migrations/0007_backfill_conversion_state_activity.py new file mode 100644 index 00000000..45ede73d --- /dev/null +++ b/economy/migrations/0007_backfill_conversion_state_activity.py @@ -0,0 +1,41 @@ +from django.db import migrations + +# Every existing GoodsConversionState row was created by +# advance_mill_economy_tick or advance_bakery_economy_tick (see +# economy/tasks.py), so its activity is fully determined by its +# building's building_type at the time this migration runs. +BUILDING_TYPE_TO_ACTIVITY = { + "mill": "milling", + "bakery": "baking", +} + + +def backfill_activity(apps, schema_editor): + GoodsConversionState = apps.get_model("economy", "GoodsConversionState") + + for state in GoodsConversionState.objects.select_related("building"): + activity = BUILDING_TYPE_TO_ACTIVITY.get(state.building.building_type) + if activity is None: + # No known production activity for this building - the state + # row is meaningless without one, and none should exist today. + state.delete() + continue + state.activity = activity + state.save(update_fields=["activity"]) + + +def noop_reverse(apps, schema_editor): + # Reversing 0006/0008 already drops the activity column/constraint; + # nothing extra to undo here. + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("economy", "0006_goodsconversionstate_activity"), + ] + + operations = [ + migrations.RunPython(backfill_activity, noop_reverse), + ] diff --git a/economy/migrations/0008_alter_goodsconversionstate_activity.py b/economy/migrations/0008_alter_goodsconversionstate_activity.py new file mode 100644 index 00000000..a0452040 --- /dev/null +++ b/economy/migrations/0008_alter_goodsconversionstate_activity.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("economy", "0007_backfill_conversion_state_activity"), + ] + + operations = [ + migrations.AlterField( + model_name="goodsconversionstate", + name="activity", + field=models.CharField( + choices=[ + ("milling", "Milling"), + ("baking", "Baking"), + ("farming", "Farming"), + ], + max_length=20, + ), + ), + migrations.AddConstraint( + model_name="goodsconversionstate", + constraint=models.UniqueConstraint( + fields=("building", "activity"), + name="uniq_conversion_state_per_activity", + ), + ), + ] diff --git a/economy/models.py b/economy/models.py index 8c5a4d7a..0ed3afe8 100644 --- a/economy/models.py +++ b/economy/models.py @@ -127,18 +127,74 @@ def capacity(self): return round(storage_area * STORAGE_CAPACITY_PER_AREA_WEIGHT) +class BuildingCapability(models.Model): + """ + A production activity a `Building` is capable of - e.g. a `mill` + building has a `milling` capability, but a `communal` building could + hold both `milling` and `baking`. Additive alongside + `Building.building_type`, which keeps driving unrelated concerns + (working hours, flavor text, map/UI display) - see + .claude/plans/building-capabilities-plan.md. + + Deliberately excludes storage (`granary`) and farming presence + (`field_shelter`, which already tracks its crop via + `FieldCrop.shelter_building` rather than a type lookup) - neither is a + labor-capped production activity a capability would add anything to. + """ + + class Activity(models.TextChoices): + MILLING = "milling", "Milling" + BAKING = "baking", "Baking" + FARMING = "farming", "Farming" + + building = models.ForeignKey( + "locations.Building", on_delete=models.CASCADE, related_name="capabilities" + ) + activity = models.CharField(max_length=20, choices=Activity.choices) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["building", "activity"], name="uniq_capability_per_building" + ), + ] + + def __str__(self): + return f"{self.activity} @ {self.building_id}" + + class GoodsConversionState(models.Model): """ - Per-building idempotency guard for a daily goods-conversion task (e.g. - milling). Deliberately thin - unlike FieldCrop, conversion has no growth - stages, just a daily "did we already process this building today" check - - kept generic so bakery can reuse it unmodified later. + Per-(building, activity) idempotency guard for a daily goods-conversion + task (e.g. milling). Keyed by activity, not just building, because a + single building can hold multiple capabilities (e.g. a communal + building that both mills and bakes) - each needs its own "did we + already process this today" flag, or the first tick to run would mark + the building processed and the second would silently skip (see + .claude/plans/building-capabilities-plan.md). Deliberately thin - + unlike FieldCrop, conversion has no growth stages, just the daily flag. """ - building = models.OneToOneField( - "locations.Building", on_delete=models.CASCADE, related_name="conversion_state" + building = models.ForeignKey( + "locations.Building", + on_delete=models.CASCADE, + related_name="conversion_states", + ) + activity = models.CharField( + max_length=20, choices=BuildingCapability.Activity.choices ) last_processed_on = models.DateField(null=True, blank=True) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["building", "activity"], + name="uniq_conversion_state_per_activity", + ), + ] + def __str__(self): - return f"GoodsConversionState({self.building_id}, {self.last_processed_on})" + return ( + f"GoodsConversionState({self.building_id}, {self.activity}, " + f"{self.last_processed_on})" + ) diff --git a/economy/services/capacity_services.py b/economy/services/capacity_services.py index bd163973..0b807234 100644 --- a/economy/services/capacity_services.py +++ b/economy/services/capacity_services.py @@ -16,6 +16,8 @@ from economy.constants import ( BREAD_PER_CHARACTER_DAILY_CONSUMPTION, FLOUR_TO_BREAD_RATIO, + LINK_POINTS_PRODUCTIVITY_SCALE, + MAX_PRODUCTIVITY_BONUS, PER_WORKER_DAILY_BAKING_CAPACITY, PER_WORKER_DAILY_CAPACITY, PER_WORKER_DAILY_MILLING_CAPACITY, @@ -36,6 +38,31 @@ def workers_present(building): ).count() +def _productivity_bonus(character): + return min( + character.total_link_points / LINK_POINTS_PRODUCTIVITY_SCALE, + MAX_PRODUCTIVITY_BONUS, + ) + + +def worker_capacity_present(building): + """ + Weighted labor capacity physically present at `building` right now - + like workers_present, but each present character contributes + 1 + _productivity_bonus(character) instead of a flat 1, so a linked + character with accrued total_link_points produces more than an + unlinked NPC (which contributes exactly 1, unchanged from today's + behaviour). Zero iff workers_present(building) is zero, so callers that + branch on "is anyone here" can safely use either function. + """ + from character.models import Character + + characters = Character.objects.filter( + current_node__building=building, is_moving=False + ) + return sum(1 + _productivity_bonus(character) for character in characters) + + def find_granary(population_centre): if population_centre is None: return None @@ -50,7 +77,9 @@ def find_mill(population_centre): if population_centre is None: return None return ( - population_centre.buildings.filter(building_type="mill").order_by("id").first() + population_centre.buildings.filter(capabilities__activity="milling") + .order_by("id") + .first() ) @@ -58,7 +87,7 @@ def find_bakery(population_centre): if population_centre is None: return None return ( - population_centre.buildings.filter(building_type="bakery") + population_centre.buildings.filter(capabilities__activity="baking") .order_by("id") .first() ) @@ -168,36 +197,45 @@ def population_capacity_report(population_centre): ) field_count = FieldCrop.objects.filter(shelter_building__in=field_shelters).count() farming_workers = sum(workers_present(shelter) for shelter in field_shelters) + farming_capacity = sum( + worker_capacity_present(shelter) for shelter in field_shelters + ) farming = CapacityLine( building_count=field_count, workers_present=farming_workers, # Harvesting yields wheat directly - no input good/yield ratio. - capacity_per_day=farming_workers * PER_WORKER_DAILY_CAPACITY, + # capacity_per_day is weighted by link-scaled productivity + # (worker_capacity_present), not the raw headcount above. + capacity_per_day=farming_capacity * PER_WORKER_DAILY_CAPACITY, demand_per_day=wheat_demand, ) - mills = list(population_centre.buildings.filter(building_type="mill")) + mills = list(population_centre.buildings.filter(capabilities__activity="milling")) milling_workers = sum(workers_present(mill) for mill in mills) + milling_capacity = sum(worker_capacity_present(mill) for mill in mills) milling = CapacityLine( building_count=len(mills), workers_present=milling_workers, # PER_WORKER_DAILY_MILLING_CAPACITY caps grain milled (input); the # flour actually produced is that, run through WHEAT_TO_FLOUR_RATIO - # - see convert_goods, which this mirrors. - capacity_per_day=milling_workers + # - see convert_goods, which this mirrors. Weighted by link-scaled + # productivity (worker_capacity_present), not the raw headcount. + capacity_per_day=milling_capacity * PER_WORKER_DAILY_MILLING_CAPACITY * WHEAT_TO_FLOUR_RATIO, demand_per_day=flour_demand, ) - bakeries = list(population_centre.buildings.filter(building_type="bakery")) + bakeries = list(population_centre.buildings.filter(capabilities__activity="baking")) baking_workers = sum(workers_present(bakery) for bakery in bakeries) + baking_capacity = sum(worker_capacity_present(bakery) for bakery in bakeries) baking = CapacityLine( building_count=len(bakeries), workers_present=baking_workers, # Same shape as milling: PER_WORKER_DAILY_BAKING_CAPACITY caps # flour baked (input), FLOUR_TO_BREAD_RATIO yields bread (output). - capacity_per_day=baking_workers + # Weighted by link-scaled productivity, not the raw headcount. + capacity_per_day=baking_capacity * PER_WORKER_DAILY_BAKING_CAPACITY * FLOUR_TO_BREAD_RATIO, demand_per_day=bread_demand, diff --git a/economy/services/planning_services.py b/economy/services/planning_services.py index 7f261966..e3f1638d 100644 --- a/economy/services/planning_services.py +++ b/economy/services/planning_services.py @@ -24,6 +24,7 @@ PER_WORKER_DAILY_BAKING_CAPACITY, PER_WORKER_DAILY_CAPACITY, PER_WORKER_DAILY_MILLING_CAPACITY, + SMALL_SETTLEMENT_POPULATION_THRESHOLD, WHEAT_TO_FLOUR_RATIO, ) from economy.services.capacity_services import ( @@ -64,6 +65,13 @@ class SettlementPlan: # population centre should have grain storage... even if small" rule # below. recommended_granaries: int + # Population-driven signal (not a building-slot-scarcity one) for + # whether a settlement should share one building for milling and + # baking rather than getting a dedicated building per role - see + # SMALL_SETTLEMENT_POPULATION_THRESHOLD. Consumers (e.g. + # watabou_import) still fall back to sharing when there genuinely + # isn't room for two dedicated buildings, regardless of this flag. + combine_milling_and_baking: bool def _workers_needed(demand_per_day, per_worker_capacity, yield_ratio=1.0): @@ -152,6 +160,8 @@ def _settlement_plan_for_residents(resident_count): milling=milling, baking=baking, recommended_granaries=1, + combine_milling_and_baking=resident_count + <= SMALL_SETTLEMENT_POPULATION_THRESHOLD, ) diff --git a/economy/tasks.py b/economy/tasks.py index 86ec5936..5560bc3d 100644 --- a/economy/tasks.py +++ b/economy/tasks.py @@ -25,7 +25,7 @@ find_bakery, find_granary, find_mill, - workers_present, + worker_capacity_present, ) from locations.models import Building @@ -99,7 +99,7 @@ def _harvest(crop): crop.stage = FieldCrop.Stage.FALLOW return - present = workers_present(crop.shelter_building) + present = worker_capacity_present(crop.shelter_building) today_yield = min(remaining, present * PER_WORKER_DAILY_CAPACITY) if today_yield <= 0: return @@ -150,16 +150,18 @@ def advance_mill_economy_tick(today=None): """ today = today or timezone.localdate() - for mill in Building.objects.filter(building_type="mill").select_related( - "population_centre" - ): - state, _ = GoodsConversionState.objects.get_or_create(building=mill) + for mill in Building.objects.filter( + capabilities__activity="milling" + ).select_related("population_centre"): + state, _ = GoodsConversionState.objects.get_or_create( + building=mill, activity="milling" + ) if state.last_processed_on == today: continue granary = find_granary(mill.population_centre) if granary is not None: - present = workers_present(mill) + present = worker_capacity_present(mill) flour_buffer_target = ( daily_flour_demand(mill.population_centre) * FLOUR_BUFFER_DAYS ) @@ -201,16 +203,18 @@ def advance_bakery_economy_tick(today=None): """ today = today or timezone.localdate() - for bakery in Building.objects.filter(building_type="bakery").select_related( - "population_centre" - ): - state, _ = GoodsConversionState.objects.get_or_create(building=bakery) + for bakery in Building.objects.filter( + capabilities__activity="baking" + ).select_related("population_centre"): + state, _ = GoodsConversionState.objects.get_or_create( + building=bakery, activity="baking" + ) if state.last_processed_on == today: continue mill = find_mill(bakery.population_centre) if mill is not None: - present = workers_present(bakery) + present = worker_capacity_present(bakery) demand = daily_bread_demand(bakery.population_centre) bread_stock = GoodsStock.objects.filter( building=bakery, good_type=GoodsStock.GoodType.BREAD diff --git a/economy/tests/test_capacity_services.py b/economy/tests/test_capacity_services.py index 7393ab14..04a3b818 100644 --- a/economy/tests/test_capacity_services.py +++ b/economy/tests/test_capacity_services.py @@ -20,12 +20,14 @@ from economy.constants import ( BREAD_PER_CHARACTER_DAILY_CONSUMPTION, FLOUR_TO_BREAD_RATIO, + LINK_POINTS_PRODUCTIVITY_SCALE, + MAX_PRODUCTIVITY_BONUS, PER_WORKER_DAILY_BAKING_CAPACITY, PER_WORKER_DAILY_CAPACITY, PER_WORKER_DAILY_MILLING_CAPACITY, WHEAT_TO_FLOUR_RATIO, ) -from economy.models import FieldCrop +from economy.models import BuildingCapability, FieldCrop from economy.services.capacity_services import ( daily_bread_demand, daily_flour_demand, @@ -34,6 +36,7 @@ find_granary, find_mill, population_capacity_report, + worker_capacity_present, workers_present, ) from locations.models import Building, LandArea, Node, PopulationCentre, Subzone @@ -65,6 +68,9 @@ def _make_centre(name="Testville", resident_count=0): return centre, patcher +BUILDING_TYPE_TO_ACTIVITY = {"mill": "milling", "bakery": "baking"} + + def _make_building(centre, building_type, x): building = Building.objects.create( name=f"{building_type} at {x}", @@ -72,6 +78,9 @@ def _make_building(centre, building_type, x): location=Point(x, 0, srid=3857), population_centre=centre, ) + activity = BUILDING_TYPE_TO_ACTIVITY.get(building_type) + if activity: + BuildingCapability.objects.create(building=building, activity=activity) node = Node.objects.create( name=f"Node for {building.name}", location=building.location, @@ -310,3 +319,79 @@ def test_find_granary_mill_bakery_return_none_for_none_centre(self): self.assertIsNone(find_granary(None)) self.assertIsNone(find_mill(None)) self.assertIsNone(find_bakery(None)) + + +class WorkerCapacityPresentTests(TestCase): + """ + worker_capacity_present - the link-scaled counterpart to workers_present + used everywhere a headcount feeds a capacity_per_day labor cap (see + economy.tasks and population_capacity_report). Expected values are + derived from LINK_POINTS_PRODUCTIVITY_SCALE/MAX_PRODUCTIVITY_BONUS + rather than hardcoded, matching this module's "never hardcode" rule. + """ + + def test_zero_iff_workers_present_is_zero(self): + centre, patcher = _make_centre() + self.addCleanup(patcher.stop) + bakery, node = _make_building(centre, "bakery", 10) + + self.assertEqual(worker_capacity_present(bakery), 0) + + def test_unlinked_characters_contribute_exactly_one_each(self): + centre, patcher = _make_centre() + self.addCleanup(patcher.stop) + bakery, node = _make_building(centre, "bakery", 10) + _add_workers(bakery, node, count=3) + + self.assertEqual(worker_capacity_present(bakery), 3) + + def test_linked_character_contributes_more_than_baseline(self): + centre, patcher = _make_centre() + self.addCleanup(patcher.stop) + bakery, node = _make_building(centre, "bakery", 10) + Character.objects.create( + given_name="Linked", + location=bakery.location, + current_node=node, + is_moving=False, + ) + link_points = LINK_POINTS_PRODUCTIVITY_SCALE / 2 + + with patch.object( + Character, + "total_link_points", + new_callable=PropertyMock, + return_value=link_points, + ): + capacity = worker_capacity_present(bakery) + + expected = 1 + (link_points / LINK_POINTS_PRODUCTIVITY_SCALE) + self.assertEqual(capacity, expected) + self.assertGreater(capacity, 1) + + def test_productivity_bonus_is_capped_not_unbounded(self): + centre, patcher = _make_centre() + self.addCleanup(patcher.stop) + bakery, node = _make_building(centre, "bakery", 10) + Character.objects.create( + given_name="MaxedOut", + location=bakery.location, + current_node=node, + is_moving=False, + ) + # Vastly more link_points than the scale constant - if the curve + # were linear rather than capped, this would blow past + # 1 + MAX_PRODUCTIVITY_BONUS, which is exactly the bug this test + # guards against (see plan Risk: "unbounded productivity if the + # curve is implemented as linear by mistake"). + huge_link_points = LINK_POINTS_PRODUCTIVITY_SCALE * 1000 + + with patch.object( + Character, + "total_link_points", + new_callable=PropertyMock, + return_value=huge_link_points, + ): + capacity = worker_capacity_present(bakery) + + self.assertEqual(capacity, 1 + MAX_PRODUCTIVITY_BONUS) diff --git a/economy/tests/test_constants.py b/economy/tests/test_constants.py index f22fdef3..a6e77098 100644 --- a/economy/tests/test_constants.py +++ b/economy/tests/test_constants.py @@ -25,6 +25,21 @@ def test_signed_deltas_always_use_plain_weight_regardless_of_good_type(self): self.assertEqual(format_quantity("flour", -500, signed=True), "-0.5kg") self.assertEqual(format_quantity("bread", 2000, signed=True), "+2.0kg") + def test_wheat_over_threshold_displays_as_tonnes(self): + # Exactly at the threshold still switches - TONNE_DISPLAY_THRESHOLD_KG + # is inclusive. + self.assertEqual(format_quantity("wheat", 1_000_000), "1.0t") + self.assertEqual(format_quantity("wheat", 2_500_000), "2.5t") + + def test_wheat_under_threshold_stays_in_kg(self): + self.assertEqual(format_quantity("wheat", 999_000), "999.0kg") + + def test_wheat_signed_deltas_never_switch_to_tonnes(self): + self.assertEqual(format_quantity("wheat", 2_500_000, signed=True), "+2,500.0kg") + + def test_bread_never_switches_to_tonnes_regardless_of_size(self): + self.assertEqual(format_quantity("bread", 5_000_000), "5,000.0 loaves") + class FormatRateTest(SimpleTestCase): """ @@ -47,3 +62,9 @@ def test_wheat_and_unlisted_goods_use_plain_kg(self): def test_signed_surplus_shows_sign(self): self.assertEqual(format_rate("flour", -12500, signed=True), "-12.5kg") self.assertEqual(format_rate("bread", 5000, signed=True), "+5.0kg") + + def test_flour_rate_over_threshold_displays_as_tonnes(self): + self.assertEqual(format_rate("flour", 1_500_000), "1.5t") + + def test_bread_rate_never_switches_to_tonnes_regardless_of_size(self): + self.assertEqual(format_rate("bread", 5_000_000), "5,000.0kg") diff --git a/economy/tests/test_planning_services.py b/economy/tests/test_planning_services.py index f316aab4..79c3c943 100644 --- a/economy/tests/test_planning_services.py +++ b/economy/tests/test_planning_services.py @@ -105,6 +105,21 @@ def test_milling_and_baking_and_granaries_always_recommended_even_at_zero_popula # shelter recommended. self.assertEqual(plan.farming.recommended_buildings, 0) + def test_combine_milling_and_baking_at_and_below_threshold(self): + from economy.constants import SMALL_SETTLEMENT_POPULATION_THRESHOLD + + at_threshold = settlement_plan(population=SMALL_SETTLEMENT_POPULATION_THRESHOLD) + below_threshold = settlement_plan( + population=SMALL_SETTLEMENT_POPULATION_THRESHOLD - 1 + ) + above_threshold = settlement_plan( + population=SMALL_SETTLEMENT_POPULATION_THRESHOLD + 1 + ) + + self.assertTrue(at_threshold.combine_milling_and_baking) + self.assertTrue(below_threshold.combine_milling_and_baking) + self.assertFalse(above_threshold.combine_milling_and_baking) + def test_farming_recommended_once_workers_are_needed(self): plan = settlement_plan(population=5000) diff --git a/economy/tests/test_tasks.py b/economy/tests/test_tasks.py index 6c2ef53c..9d3f004e 100644 --- a/economy/tests/test_tasks.py +++ b/economy/tests/test_tasks.py @@ -18,7 +18,12 @@ WHEAT_TO_FLOUR_RATIO, YIELD_PER_AREA, ) -from economy.models import FieldCrop, GoodsConversionState, GoodsStock +from economy.models import ( + BuildingCapability, + FieldCrop, + GoodsConversionState, + GoodsStock, +) from economy.tasks import ( advance_bakery_economy_tick, advance_bread_consumption_tick, @@ -131,6 +136,7 @@ def _make_mill(centre, grain_area=1000.0, flour_area=1000.0): footprint=_square(80, 0, 5), population_centre=centre, ) + BuildingCapability.objects.create(building=mill, activity="milling") mill_node = Node.objects.create( name=f"Node for {mill.name}", location=mill.location, @@ -156,6 +162,7 @@ def _make_bakery(centre, storage_area=1000.0): footprint=_square(60, 0, 5), population_centre=centre, ) + BuildingCapability.objects.create(building=bakery, activity="baking") bakery_node = Node.objects.create( name=f"Node for {bakery.name}", location=bakery.location, @@ -453,6 +460,7 @@ def test_multiple_mills_in_one_centre_are_both_processed(self): footprint=_square(70, 0, 5), population_centre=centre, ) + BuildingCapability.objects.create(building=mill_b_building, activity="milling") node_b = Node.objects.create( name="Node for Second Mill", location=mill_b_building.location, @@ -591,6 +599,7 @@ def test_multiple_bakeries_in_one_centre_are_both_processed(self): footprint=_square(50, 0, 5), population_centre=centre, ) + BuildingCapability.objects.create(building=bakery_b, activity="baking") node_b = Node.objects.create( name="Node for Second Bakery", location=bakery_b.location, @@ -629,6 +638,82 @@ def test_multiple_bakeries_in_one_centre_are_both_processed(self): ) +class MultiCapabilityBuildingTests(TestCase): + """ + A building holding more than one capability (e.g. a communal building + that both mills and bakes) must be ticked for every one of them on the + same day. Regression coverage for the GoodsConversionState collision + described in .claude/plans/building-capabilities-plan.md: before + GoodsConversionState was keyed by (building, activity), the first tick + to run would mark the building "processed today" and the second would + silently skip it. + """ + + def test_milling_and_baking_both_run_on_the_same_day_for_one_building(self): + centre = PopulationCentre.objects.create( + name="Communalville", + location=Point(0, 0, srid=3857), + boundary=_square(0, 0, 50), + ) + granary = _make_granary(centre) + GoodsStock.objects.create( + building=granary, + good_type=GoodsStock.GoodType.WHEAT, + quantity=500_000.0, + ) + + communal = Building.objects.create( + name="Communal Hall", + building_type="communal", + location=Point(80, 0, srid=3857), + footprint=_square(80, 0, 5), + population_centre=centre, + ) + BuildingCapability.objects.create(building=communal, activity="milling") + BuildingCapability.objects.create(building=communal, activity="baking") + InteriorSpace.objects.create( + building=communal, name="Flour store", usage="flour_storage", area=1000.0 + ) + InteriorSpace.objects.create( + building=communal, name="Bread store", usage="storage", area=1000.0 + ) + node = Node.objects.create( + name="Node for Communal Hall", + location=communal.location, + kind=Node.Kind.BUILDING, + building=communal, + ) + Character.objects.create( + given_name="Worker1", + location=communal.location, + current_node=node, + is_moving=False, + ) + + with _unlimited_demand(): + advance_mill_economy_tick() + advance_bakery_economy_tick() + + # Bread can only exist if baking found flour to consume - and the + # only source of flour is the same building's milling tick having + # already run today, so a positive bread quantity is proof both + # ticks actually ran rather than the second silently no-opping. + # (Flour itself may end up fully consumed here, since milling and + # baking share one building with no minimum retention between + # them - that's expected, not asserted on directly.) + bread = GoodsStock.objects.get(building=communal, good_type="bread") + self.assertGreater(bread.quantity, 0) + + milling_state = GoodsConversionState.objects.get( + building=communal, activity="milling" + ) + baking_state = GoodsConversionState.objects.get( + building=communal, activity="baking" + ) + self.assertEqual(milling_state.last_processed_on, timezone.localdate()) + self.assertEqual(baking_state.last_processed_on, timezone.localdate()) + + class AdvanceBreadConsumptionTickTests(TestCase): def _make_centre_with_home(self, name="Eatville"): centre_point = Point(0, 0, srid=3857) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7244b5ce..02eff701 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,6 @@ "@tanstack/react-query": "^5.90.12", "@tanstack/react-query-devtools": "^5.91.1", "@vitejs/plugin-react": "^5.2.0", - "axios": "^1.18.0", "classnames": "^2.5.1", "framer-motion": "^12.42.2", "fuse.js": "^7.0.0", @@ -146,6 +145,16 @@ "playwright-core": ">= 1.0.0" } }, + "node_modules/@axe-core/playwright/node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -574,6 +583,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -585,6 +595,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -595,705 +606,306 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ - "arm" + "x64" ], "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@floating-ui/utils": "^0.2.12" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "@humanfs/types": "^0.15.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18.18.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18.18.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18.18.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", + "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==", "dev": true, "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" + "dependencies": { + "glob": "^13.0.1", + "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { - "eslint": "^10.0.0" + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "eslint": { + "typescript": { "optional": true } } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@floating-ui/core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", - "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.8.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT" - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", - "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^13.0.1", - "react-docgen-typescript": "^2.2.2" - }, - "peerDependencies": { - "typescript": ">= 4.3.x", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/remapping": { @@ -1362,1003 +974,219 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", - "license": "BSD-3-Clause", - "dependencies": { - "@mapbox/point-geometry": "~1.1.0", - "@types/geojson": "^7946.0.16", - "pbf": "^5.0.0" - } - }, - "node_modules/@maplibre/geojson-vt": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", - "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", - "license": "ISC", - "dependencies": { - "kdbush": "^4.1.0" - } - }, - "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "26.2.1", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", - "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", - "license": "ISC", - "dependencies": { - "@mapbox/jsonlint-lines-primitives": "^2.0.3", - "@mapbox/unitbezier": "^1.0.0", - "json-stringify-pretty-compact": "^4.0.0", - "minimist": "^1.2.8", - "quickselect": "^3.0.0", - "tinyqueue": "^3.0.0" - }, - "bin": { - "gl-style-format": "dist/gl-style-format.mjs", - "gl-style-migrate": "dist/gl-style-migrate.mjs", - "gl-style-validate": "dist/gl-style-validate.mjs" - } - }, - "node_modules/@maplibre/mlt": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", - "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", - "license": "(MIT OR Apache-2.0)", - "dependencies": { - "@mapbox/point-geometry": "^1.1.0" - } - }, - "node_modules/@maplibre/vt-pbf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", - "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", - "license": "MIT", - "dependencies": { - "@mapbox/point-geometry": "^1.1.0", - "@types/geojson": "^7946.0.16", - "pbf": "^5.1.0" - } - }, - "node_modules/@mdx-js/react": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", - "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdx": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", - "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" - } - }, - "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", - "integrity": "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz", - "integrity": "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz", - "integrity": "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz", - "integrity": "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz", - "integrity": "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz", - "integrity": "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz", - "integrity": "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz", - "integrity": "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz", - "integrity": "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz", - "integrity": "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz", - "integrity": "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz", - "integrity": "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz", - "integrity": "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz", - "integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz", - "integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz", - "integrity": "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz", - "integrity": "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz", - "integrity": "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz", - "integrity": "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz", - "integrity": "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", - "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", - "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", - "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", - "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", - "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", - "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", - "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", - "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", - "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", - "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", - "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", - "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", - "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", - "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", - "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", - "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", - "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-3-Clause", "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": ">=14.0.0" + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" } }, - "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" + "kdbush": "^4.1.0" } }, - "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", + "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", + "license": "ISC", "dependencies": { - "tslib": "^2.4.0" + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", "dependencies": { - "tslib": "^2.4.0" + "@mapbox/point-geometry": "^1.1.0" } }, - "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", - "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } }, - "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.24.2", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", - "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", - "cpu": [ - "x64" - ], + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@parcel/watcher": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", - "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">= 10.0.0" + "@types/mdx": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.6.0", - "@parcel/watcher-darwin-arm64": "2.6.0", - "@parcel/watcher-darwin-x64": "2.6.0", - "@parcel/watcher-freebsd-x64": "2.6.0", - "@parcel/watcher-linux-arm-glibc": "2.6.0", - "@parcel/watcher-linux-arm-musl": "2.6.0", - "@parcel/watcher-linux-arm64-glibc": "2.6.0", - "@parcel/watcher-linux-arm64-musl": "2.6.0", - "@parcel/watcher-linux-x64-glibc": "2.6.0", - "@parcel/watcher-linux-x64-musl": "2.6.0", - "@parcel/watcher-win32-arm64": "2.6.0", - "@parcel/watcher-win32-x64": "2.6.0" + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" } }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", - "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", - "cpu": [ - "arm64" - ], + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" + "dependencies": { + "@tybys/wasm-util": "^0.10.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", - "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 10.0.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", - "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", - "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz", + "integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==", "cpu": [ "x64" ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", - "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", - "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", - "cpu": [ - "arm" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", - "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz", + "integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==", "cpu": [ - "arm64" + "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", - "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", - "cpu": [ - "arm64" - ], + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", - "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + ] }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", - "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], + ] + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, "engines": { "node": ">= 10.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" } }, - "node_modules/@parcel/watcher-win32-arm64": { + "node_modules/@parcel/watcher-linux-x64-glibc": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", - "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", "cpu": [ - "arm64" + "x64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">= 10.0.0" @@ -2368,17 +1196,17 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher-win32-x64": { + "node_modules/@parcel/watcher-linux-x64-musl": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", - "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">= 10.0.0" @@ -3201,321 +2029,67 @@ "peerDependenciesMeta": { "@types/react": { "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", - "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", - "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.10" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", - "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", - "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", - "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", - "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", - "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", - "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", - "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", - "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", - "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", - "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", - "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", - "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", - "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", - "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "2.0.0-alpha.3", - "@emnapi/runtime": "2.0.0-alpha.3", - "@napi-rs/wasm-runtime": "^1.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + } } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", - "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", - "cpu": [ - "arm64" - ], + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", - "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": "^20.19.0 || >=22.12.0" @@ -3558,9 +2132,9 @@ "license": "MIT" }, "node_modules/@storybook/addon-a11y": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.6.tgz", - "integrity": "sha512-pMqbmtvkIgb7/kVE2BTNovGhSerRXWsVag62zpwQe0SwYYkgN+1Q9h4TYuIe2+npGkxgwALCjwtqkcnpOoND+A==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.7.tgz", + "integrity": "sha512-I30rsNz6aA3xg3811MEry40uJDHP3l5SOkfqtmNkp7y4NdqTDdKhmbhhDAZQX1WWEk6GeMBGr3AJ3TCz7r7JmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3572,20 +2146,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.5.6" + "storybook": "^10.5.7" } }, "node_modules/@storybook/addon-docs": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.6.tgz", - "integrity": "sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz", + "integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.5.6", + "@storybook/csf-plugin": "10.5.7", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.5.6", + "@storybook/react-dom-shim": "10.5.7", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -3596,7 +2170,7 @@ }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.6" + "storybook": "^10.5.7" }, "peerDependenciesMeta": { "@types/react": { @@ -3605,9 +2179,9 @@ } }, "node_modules/@storybook/addon-vitest": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.6.tgz", - "integrity": "sha512-oxq7Qi4Vujc8Etoi1TZBurMs4RiKoGnvAOCXePOLglXSJMpy95gEb7iu/hvj8E21lV+vVtQYmFvj9Z7gJeMtdg==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.7.tgz", + "integrity": "sha512-7NK7Kzazc2vb2h8nGlH15QlUn5J6/LV0xF70VziAK0bxu3r1JupLCoBvckX39vHzFXiAZljXZtt1Wq/OidBxow==", "dev": true, "license": "MIT", "dependencies": { @@ -3622,7 +2196,7 @@ "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", - "storybook": "^10.5.6", + "storybook": "^10.5.7", "vitest": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { @@ -3641,13 +2215,13 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.6.tgz", - "integrity": "sha512-Ts8EohKPj8okDPCkueeKVN+IRGNpI3LuddsFGupqraRvK6aRWawDKA28uc0PlsLCLWbMkMsGVw+IpFXfmoLJgQ==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.7.tgz", + "integrity": "sha512-fShF/aQaITqcJuMCLr42BGNUAbhDi4IboqvlbZqXAwgrrTslnZEUnY8GcEcvpZmjl11VwlmazhMJdH50fIgBPg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.5.6", + "@storybook/csf-plugin": "10.5.7", "ts-dedent": "^2.0.0" }, "funding": { @@ -3655,14 +2229,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.5.6", + "storybook": "^10.5.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.6.tgz", - "integrity": "sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz", + "integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==", "dev": true, "license": "MIT", "dependencies": { @@ -3675,7 +2249,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.5.6", + "storybook": "^10.5.7", "vite": "*", "webpack": "*" }, @@ -3712,14 +2286,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.6.tgz", - "integrity": "sha512-dXSdNoc9yAvpa4hiegQhmZPXOKunAxkPX94DxvRw/kM6+wujVFAGlZjYygKrWw357KOjPRK7SO1LRTc70mgrhQ==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz", + "integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.5.6", + "@storybook/react-dom-shim": "10.5.7", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, @@ -3732,7 +2306,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.6", + "storybook": "^10.5.7", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -3748,9 +2322,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz", - "integrity": "sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz", + "integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==", "dev": true, "license": "MIT", "funding": { @@ -3762,7 +2336,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.6" + "storybook": "^10.5.7" }, "peerDependenciesMeta": { "@types/react": { @@ -3774,16 +2348,16 @@ } }, "node_modules/@storybook/react-vite": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.6.tgz", - "integrity": "sha512-DCTfNZWhQUH4Zf8LDE4zdLn6+C26QNW0KETdnFUkdrqRADlwS6oExtGWC5f/uP/GDbKb9jrGbC+/ap8nWEH/vQ==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.7.tgz", + "integrity": "sha512-eEo3eVa2pvqrzQukKxAzx7YvswDAA1s6k/y+tdMxmRvWyHX6QEOsb9Tda6wcVaa7c8BeJM7Ggq+289cRMTH6Iw==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.5.6", - "@storybook/react": "10.5.6", + "@storybook/builder-vite": "10.5.7", + "@storybook/react": "10.5.7", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.2", @@ -3797,7 +2371,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.6", + "storybook": "^10.5.7", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, @@ -3936,9 +2510,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", + "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", "dev": true, "license": "MIT", "engines": { @@ -3953,6 +2527,7 @@ "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4128,17 +2703,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", - "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/type-utils": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4151,7 +2726,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.65.0", + "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4167,16 +2742,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", - "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3" }, "engines": { @@ -4192,14 +2767,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", - "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.65.0", - "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "engines": { @@ -4214,14 +2789,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", - "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0" + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4232,9 +2807,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", - "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", "dev": true, "license": "MIT", "engines": { @@ -4249,15 +2824,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", - "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4274,9 +2849,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", "dev": true, "license": "MIT", "engines": { @@ -4288,16 +2863,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", - "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.65.0", - "@typescript-eslint/tsconfig-utils": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4329,16 +2904,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", - "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0" + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4353,13 +2928,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", - "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4739,6 +3314,19 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -4847,9 +3435,9 @@ "license": "MIT" }, "node_modules/axe-core": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", - "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -4912,9 +3500,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.9", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.9.tgz", - "integrity": "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg==", + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4972,9 +3560,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -4991,11 +3579,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -5047,9 +3635,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "funding": [ { "type": "opencollective", @@ -5408,9 +3996,9 @@ "license": "ISC" }, "node_modules/electron-to-chromium": { - "version": "1.5.399", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", - "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "version": "1.5.403", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", + "integrity": "sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==", "license": "ISC" }, "node_modules/empathic": { @@ -5489,9 +4077,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "devOptional": true, "hasInstallScript": true, "license": "MIT", @@ -5502,32 +4090,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -5553,9 +4141,9 @@ } }, "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -5642,9 +4230,9 @@ } }, "node_modules/eslint-plugin-storybook": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.6.tgz", - "integrity": "sha512-uOXhNkIH+iTdyViSmWnCrwtapasL57M3nq5yfST1H7y9djRLyuAIfNcf9cPBedc2G1oqI8jn3up/VHdN3y3Btw==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz", + "integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==", "dev": true, "license": "MIT", "dependencies": { @@ -5653,7 +4241,7 @@ }, "peerDependencies": { "eslint": ">=8", - "storybook": "^10.5.6" + "storybook": "^10.5.7" } }, "node_modules/eslint-scope": { @@ -5955,21 +4543,6 @@ } } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6110,9 +4683,9 @@ } }, "node_modules/happy-dom": { - "version": "20.11.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", - "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "version": "20.11.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz", + "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==", "dev": true, "license": "MIT", "dependencies": { @@ -6690,146 +5263,6 @@ "lightningcss-win32-x64-msvc": "1.33.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", @@ -6870,46 +5303,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/local-pkg": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", @@ -7029,9 +5422,9 @@ } }, "node_modules/maplibre-gl": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.1.0.tgz", - "integrity": "sha512-vLRukjvbUai4SXW2/jKo8rPsw6YMXuA/b4fKFVSulZKFVRHkOvk4ZmNlDSVZpTYPV0/7/iTqq8ltYdyr764b7w==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.2.0.tgz", + "integrity": "sha512-PaNYtxWmYgIdDHshXsnU3Pho+H9IPme9H6dTjZbFWNazi+Q5QgKgIlu2GiSGVtOZ77/Fz3n5/1/kGM6ETzu0Lg==", "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "^1.1.0", @@ -7230,9 +5623,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -7262,9 +5655,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "license": "MIT", "engines": { "node": ">=18" @@ -7632,9 +6025,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -7651,7 +6044,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7690,19 +6083,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/protocol-buffers-schema": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", @@ -7937,9 +6317,9 @@ } }, "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "devOptional": true, "license": "MIT", "engines": { @@ -8035,12 +6415,12 @@ } }, "node_modules/rolldown": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", - "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.142.0", + "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -8050,27 +6430,26 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.1", - "@rolldown/binding-darwin-arm64": "1.2.1", - "@rolldown/binding-darwin-x64": "1.2.1", - "@rolldown/binding-freebsd-x64": "1.2.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", - "@rolldown/binding-linux-arm64-gnu": "1.2.1", - "@rolldown/binding-linux-arm64-musl": "1.2.1", - "@rolldown/binding-linux-ppc64-gnu": "1.2.1", - "@rolldown/binding-linux-s390x-gnu": "1.2.1", - "@rolldown/binding-linux-x64-gnu": "1.2.1", - "@rolldown/binding-linux-x64-musl": "1.2.1", - "@rolldown/binding-openharmony-arm64": "1.2.1", - "@rolldown/binding-wasm32-wasi": "1.2.1", - "@rolldown/binding-win32-arm64-msvc": "1.2.1", - "@rolldown/binding-win32-x64-msvc": "1.2.1" + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -8222,9 +6601,9 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "10.5.6", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.6.tgz", - "integrity": "sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz", + "integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==", "dev": true, "license": "MIT", "dependencies": { @@ -8366,9 +6745,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -8554,16 +6933,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", - "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0" + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8662,9 +7041,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", + "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", "funding": [ { "type": "opencollective", @@ -8768,15 +7147,15 @@ } }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -8957,20 +7336,6 @@ "node": ">=8.10.0" } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3138ab9e..47d3ad7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,6 @@ "@tanstack/react-query": "^5.90.12", "@tanstack/react-query-devtools": "^5.91.1", "@vitejs/plugin-react": "^5.2.0", - "axios": "^1.18.0", "classnames": "^2.5.1", "framer-motion": "^12.42.2", "fuse.js": "^7.0.0", diff --git a/frontend/src/api/auth.test.ts b/frontend/src/api/auth.test.ts new file mode 100644 index 00000000..35ecbd2e --- /dev/null +++ b/frontend/src/api/auth.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; + +import { loginUser } from "./auth"; +import { apiFetch } from "../utils/api"; + +vi.mock("../utils/api", () => ({ + apiFetch: vi.fn(), +})); + +describe("loginUser", () => { + it("posts credentials to the JWT create endpoint via apiFetch with skipAuth", async () => { + (apiFetch as ReturnType).mockResolvedValue({ + access_token: "a", + refresh_token: "r", + }); + + const result = await loginUser("alice", "hunter2"); + + expect(apiFetch).toHaveBeenCalledWith("/auth/jwt/create/", { + method: "POST", + body: JSON.stringify({ username: "alice", password: "hunter2" }), + skipAuth: true, + }); + expect(result).toEqual({ access_token: "a", refresh_token: "r" }); + }); + + it("propagates a rejected login (401) as-is", async () => { + const error = new Error("rejected"); + (apiFetch as ReturnType).mockRejectedValue(error); + + await expect(loginUser("alice", "wrong")).rejects.toThrow("rejected"); + }); +}); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 0a5df90f..205e07b3 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,11 +1,18 @@ // src/api/auth.ts import type { TokenPair } from "../types"; -import axios from './axios'; +import { apiFetch } from "../utils/api"; +/** + * Logs in with a username/password pair. Unauthenticated by design: the + * request must not go through the getValidAccessToken() refresh path (there + * is no session yet to refresh), and a rejected login (401) is a normal + * "wrong credentials" outcome, not a dead session — see apiFetch's skipAuth + * option. + */ export const loginUser = async (username: string, password: string): Promise => { - const response = await axios.post('/auth/jwt/create/', { - username, - password, + return apiFetch("/auth/jwt/create/", { + method: "POST", + body: JSON.stringify({ username, password }), + skipAuth: true, }); - return response.data; }; diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts deleted file mode 100644 index ab209bc5..00000000 --- a/frontend/src/api/axios.ts +++ /dev/null @@ -1,11 +0,0 @@ -// src/api/axios.ts -import axios from 'axios'; - -import { API_BASE_URL } from '../config'; - -const instance = axios.create({ - baseURL: API_BASE_URL, - withCredentials: true, // if using cookies/auth -}); - -export default instance; diff --git a/frontend/src/components/BuildingDetail/BuildingDetail.module.scss b/frontend/src/components/BuildingDetail/BuildingDetail.module.scss index d708d178..3504d7a3 100644 --- a/frontend/src/components/BuildingDetail/BuildingDetail.module.scss +++ b/frontend/src/components/BuildingDetail/BuildingDetail.module.scss @@ -9,38 +9,27 @@ gap: sp.$spacing-md; } -.facts { +.section { display: flex; flex-direction: column; gap: sp.$spacing-sm; +} + +.sectionHeading { margin: 0; + font-size: 0.95rem; + @include c.apply-text-tone(map.get(c.$text-tone, muted)); } -.fact { +.sectionHeadingRow { display: flex; justify-content: space-between; + align-items: baseline; gap: sp.$spacing-gap; - - dt { - @include c.apply-text-tone(map.get(c.$text-tone, muted)); - } - - dd { - margin: 0; - text-align: right; - } -} - -.section { - display: flex; - flex-direction: column; - gap: sp.$spacing-sm; } -.sectionHeading { - margin: 0; - font-size: 0.95rem; - @include c.apply-text-tone(map.get(c.$text-tone, muted)); +.sectionCount { + text-align: right; } .goodsList { diff --git a/frontend/src/components/BuildingDetail/BuildingDetail.tsx b/frontend/src/components/BuildingDetail/BuildingDetail.tsx index 8405a6d6..c14c6aef 100644 --- a/frontend/src/components/BuildingDetail/BuildingDetail.tsx +++ b/frontend/src/components/BuildingDetail/BuildingDetail.tsx @@ -54,27 +54,6 @@ export default function BuildingDetail({ return (
-
- {isResidential ? ( -
-
Residents
-
- {residents.length} - {residentialCapacity != null && residentialCapacity > 0 - ? ` / ${residentialCapacity}` - : ""} -
-
- ) : ( - workerCount != null && ( -
-
Workers
-
{workerCount}
-
- ) - )} -
- {stockedGoods.length > 0 && (

Inventory

@@ -88,30 +67,49 @@ export default function BuildingDetail({
)} - {isResidential && residents.length > 0 && ( + {isResidential ? (
-

Residents

- - items={residents} - className={styles.residentsList} - canSelect={Boolean(onSelectResident)} - onSelect={(resident) => onSelectResident?.(resident.id)} - renderItem={(resident) => {residentLine(resident)}} - /> -
- )} - - {!isResidential && workers.length > 0 && ( -
-

Workers

- - items={workers} - className={styles.residentsList} - canSelect={Boolean(onSelectWorker)} - onSelect={(worker) => onSelectWorker?.(worker.id)} - renderItem={(worker) => {residentLine(worker)}} - /> +
+

Residents

+ + {residents.length} + {residentialCapacity != null && residentialCapacity > 0 + ? ` / ${residentialCapacity}` + : ""} + +
+ {residents.length > 0 && ( + + items={residents} + className={styles.residentsList} + compact + canSelect={Boolean(onSelectResident)} + canHover={Boolean(onSelectResident)} + onSelect={(resident) => onSelectResident?.(resident.id)} + renderItem={(resident) => {residentLine(resident)}} + /> + )}
+ ) : ( + workerCount != null && ( +
+
+

Workers

+ {workerCount} +
+ {workers.length > 0 && ( + + items={workers} + className={styles.residentsList} + compact + canSelect={Boolean(onSelectWorker)} + canHover={Boolean(onSelectWorker)} + onSelect={(worker) => onSelectWorker?.(worker.id)} + renderItem={(worker) => {residentLine(worker)}} + /> + )} +
+ ) )}
); diff --git a/frontend/src/components/Button/Button.module.scss b/frontend/src/components/Button/Button.module.scss index 2923823b..00dfe521 100644 --- a/frontend/src/components/Button/Button.module.scss +++ b/frontend/src/components/Button/Button.module.scss @@ -97,3 +97,22 @@ .fullWidth { width: 100%; } + +.small { + padding: 0.125rem sp.$spacing-sm; + font-size: 0.75rem; + gap: sp.$spacing-xs; + + .icon svg { + width: 0.875rem; + height: 0.875rem; + } +} + +.icon { + display: flex; + svg { + width: 1.25rem; + height: 1.25rem; + } +} diff --git a/frontend/src/components/Button/Button.tsx b/frontend/src/components/Button/Button.tsx index 54e6a5de..8fd5fa9f 100644 --- a/frontend/src/components/Button/Button.tsx +++ b/frontend/src/components/Button/Button.tsx @@ -5,6 +5,7 @@ import styles from './Button.module.scss'; interface ButtonProps extends React.ButtonHTMLAttributes { children?: React.ReactNode; variant?: string; + size?: 'default' | 'small'; icon?: React.ReactNode; as?: 'button' | 'a'; href?: string; @@ -21,6 +22,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { export default function Button({ children, variant = 'primary', + size = 'default', icon = null, as = 'button', href, @@ -39,6 +41,7 @@ export default function Button({ className={classNames( styles.button, styles[variant], + { [styles.small]: size === 'small' }, { [styles.disabled]: disabled }, className )} diff --git a/frontend/src/components/CharacterDetail/CharacterDetail.tsx b/frontend/src/components/CharacterDetail/CharacterDetail.tsx index 53ed020b..76ef7151 100644 --- a/frontend/src/components/CharacterDetail/CharacterDetail.tsx +++ b/frontend/src/components/CharacterDetail/CharacterDetail.tsx @@ -44,18 +44,19 @@ export default function CharacterDetail({ return (
-
-
Age
-
- {data.age}, {data.sex} -
-
{activityLabel && (
Currently
{activityLabel}
)} +
+
Age
+
+ {data.age}, {data.sex} +
+
+ {home && (
Home
@@ -101,6 +102,7 @@ export default function CharacterDetail({ items={relationshipItems} className={styles.relationshipsList} canSelect={Boolean(onSelectRelationship)} + canHover={Boolean(onSelectRelationship)} onSelect={(relationship) => onSelectRelationship?.(relationship.character_id)} renderItem={(relationship) => ( diff --git a/frontend/src/components/DetailCard/DetailCard.module.scss b/frontend/src/components/DetailCard/DetailCard.module.scss index c53657f0..399bcc2c 100644 --- a/frontend/src/components/DetailCard/DetailCard.module.scss +++ b/frontend/src/components/DetailCard/DetailCard.module.scss @@ -36,28 +36,6 @@ } } -// A portrait side panel docked to the right edge, instead of a -// floating centered card - see the `placement` prop comment in -// DetailCard.tsx for why (Map covers itself otherwise). Only takes effect at -// `sm` and up; below that it's still the full-width bottom sheet, where a -// side panel has no room to mean anything. -.right { - @include m.respond-to(sm) { - left: auto; - right: 0; - top: 50%; - bottom: auto; - transform: translateY(-50%); - width: 90%; - max-width: min(90vw, 280px); - max-height: 75vh; - // No dimming overlay behind this variant (it's non-modal - see the - // `modal` prop comment in DetailSurface.tsx), so a border is the only - // thing separating it from the map underneath. - border: 1px solid c.$color-border-primary; - } -} - .header { display: flex; align-items: center; @@ -74,8 +52,11 @@ font-size: 1.1rem; } -.closeButton { +.headerButton { flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + padding: 0; } .content { diff --git a/frontend/src/components/DetailCard/DetailCard.stories.tsx b/frontend/src/components/DetailCard/DetailCard.stories.tsx index f4d47e7d..282c958e 100644 --- a/frontend/src/components/DetailCard/DetailCard.stories.tsx +++ b/frontend/src/components/DetailCard/DetailCard.stories.tsx @@ -57,33 +57,3 @@ export const WithLongerContent: Story = { ), }, }; - -/** - * `placement="right"` - the docked side-panel variant Map uses, so a click - * on a character/building stays open and browsable alongside the map - * instead of covering it. Non-modal (no dimming overlay, map underneath - * stays interactive), narrower, capped at 75vh with its own scroll. - */ -export const RightPlacement: Story = { - args: { - placement: 'right', - title: 'Rose Cottage', - children: ( - <> -

House

-

Residents: 4

-
    -
  • Alice (idle)
  • -
  • Thomas (delivering goods to neighbours)
  • -
  • Emily (idle)
  • -
  • James (idle)
  • -
- - ), - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - const dialog = await body.findByRole('dialog', { name: 'Rose Cottage' }); - await expect(dialog).toBeVisible(); - }, -}; diff --git a/frontend/src/components/DetailCard/DetailCard.tsx b/frontend/src/components/DetailCard/DetailCard.tsx index ac45bdc8..8afc8026 100644 --- a/frontend/src/components/DetailCard/DetailCard.tsx +++ b/frontend/src/components/DetailCard/DetailCard.tsx @@ -3,6 +3,78 @@ import DetailSurface from "../DetailSurface/DetailSurface"; import Button from "../Button/Button"; import styles from "./DetailCard.module.scss"; +function TargetIcon() { + return ( + + ); +} + +interface DetailCardBodyProps { + title: string; + onClose: () => void; + children: React.ReactNode; + className: string; + // Omit to render the header with no fly-to affordance (e.g. entities + // with no map position to fly to yet). + onFlyTo?: () => void; +} + +// Header/content layout shared by DetailCard (centered modal) and Map's +// MapDetailCard (docked panel, positioned relative to the map itself) - only +// the outer wrapper's positioning differs between the two, so this owns +// everything else: title, fly-to/close buttons, scrollable content area. +export function DetailCardBody({ + title, + onClose, + children, + className, + onFlyTo, +}: DetailCardBodyProps) { + return ( +
+
+ {/* Plain text, not a heading element - DetailSurface already + renders an sr-only

with the same text as the dialog's + accessible name/heading; a second real heading here would + duplicate it for screen reader users navigating by heading. */} +
{title}
+ {onFlyTo && ( + +

+
{children}
+
+ ); +} + // Reusable, entity-agnostic detail-card shell (see Map's "click a tooltip to // open a richer detail card" flow) - provides the header/title/close/content // layout every entity type shares; CharacterDetail/BuildingDetail (and later @@ -10,16 +82,17 @@ import styles from "./DetailCard.module.scss"; // Deliberately has no Radix/Tamagui import of its own - DetailSurface is the // only piece of this feature that talks to a UI library primitive, so // swapping it out later doesn't touch this file or its callers. +// Always a centered floating modal (matching Modal's own layout), falling +// back to the same full-width bottom sheet on mobile. Map's docked side +// panel is MapDetailCard, not a variant of this component - it needs +// different positioning (relative to the map, not the viewport) and portal +// target, not just different CSS. interface DetailCardProps { open: boolean; title: string; onClose: () => void; children: React.ReactNode; - // "center" (default) matches Modal's floating-card layout; "right" docks - // the card to the viewport's right edge instead (e.g. Map, where a - // centered card would sit on top of the very content it describes). - // Both still fall back to the same full-width bottom sheet on mobile. - placement?: "center" | "right"; + onFlyTo?: () => void; } export default function DetailCard({ @@ -27,7 +100,7 @@ export default function DetailCard({ title, onClose, children, - placement = "center", + onFlyTo, }: DetailCardProps) { return ( -
-
- {/* Plain text, not a heading element - DetailSurface already - renders an sr-only

with the same text as the dialog's - accessible name/heading; a second real heading here would - duplicate it for screen reader users navigating by heading. */} -
{title}
- -

-
{children}
-
+ + {children} +
); } diff --git a/frontend/src/components/DetailSurface/DetailSurface.tsx b/frontend/src/components/DetailSurface/DetailSurface.tsx index 657507a2..2af53ed9 100644 --- a/frontend/src/components/DetailSurface/DetailSurface.tsx +++ b/frontend/src/components/DetailSurface/DetailSurface.tsx @@ -23,6 +23,11 @@ interface DetailSurfaceProps { * Map) stays fully interactive - for a docked side panel, which is meant * to keep browsing alongside rather than block it. */ modal?: boolean; + /** DOM node to portal into instead of document.body (Radix's default) - + * e.g. Map's own wrapper element, so a non-modal docked panel (see + * MapDetailCard) positions relative to the map rather than the + * viewport. */ + container?: HTMLElement | null; } export default function DetailSurface({ @@ -31,10 +36,11 @@ export default function DetailSurface({ title, children, modal = true, + container, }: DetailSurfaceProps) { return ( - + {modal && } (null); + + // Dismiss on outside click — the hook exposes the semantic action, this + // component owns the DOM listener that detects the gesture. + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (!rootRef.current?.contains(event.target as Node)) { + onDismiss(); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onDismiss]); + + const handleKeyDown = (event: KeyboardEvent) => { + if (disabled) return; + + switch (event.key) { + case "ArrowDown": + if (isDropdownOpen) event.preventDefault(); + onSelectNext(); + return; + case "ArrowUp": + if (isDropdownOpen) event.preventDefault(); + onSelectPrevious(); + return; + case "Escape": + if (isDropdownOpen) event.preventDefault(); + onDismiss(); + return; + case "Enter": + if (onCommit()) event.preventDefault(); + return; + default: + return; + } + }; + const renderOption = (entity: SearchEntity, index: number) => { const isHighlighted = index === activeHighlightedIndex; return ( diff --git a/frontend/src/components/EntitySearchInput/useEntitySearchInput.ts b/frontend/src/components/EntitySearchInput/useEntitySearchInput.ts index 13b146bf..7856b952 100644 --- a/frontend/src/components/EntitySearchInput/useEntitySearchInput.ts +++ b/frontend/src/components/EntitySearchInput/useEntitySearchInput.ts @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { KeyboardEvent } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Fuse from "fuse.js"; import { useEntitySearchCache } from "../../hooks/useEntitySearchCache"; @@ -63,11 +62,11 @@ function useEntitySearchResults({ ); useEffect(() => { - const timeoutId = window.setTimeout(() => { + const timeoutId = setTimeout(() => { setDebouncedQuery(value); }, DEBOUNCE_MS); - return () => window.clearTimeout(timeoutId); + return () => clearTimeout(timeoutId); }, [value]); const fuse = useMemo( @@ -162,27 +161,21 @@ function useEntitySearchResults({ }; } -function useEntitySearchDropdown(rootRef: React.RefObject) { +function useEntitySearchDropdown() { const [isFocused, setIsFocused] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (!rootRef.current?.contains(event.target as Node)) { - setIsFocused(false); - setHighlightedIndex(-1); - } - } - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [rootRef]); + const dismiss = useCallback(() => { + setIsFocused(false); + setHighlightedIndex(-1); + }, []); return { isFocused, setIsFocused, highlightedIndex, setHighlightedIndex, + dismiss, }; } @@ -198,13 +191,12 @@ export function useEntitySearchInput({ alwaysOpen = false, maxVisibleRows, }: UseEntitySearchInputProps) { - const rootRef = useRef(null); const { entities, addEntityToCache } = useEntitySearchCache(type); const canSearch = searchEnabled && !disabled; - const { isFocused, setIsFocused, highlightedIndex, setHighlightedIndex } = - useEntitySearchDropdown(rootRef); + const { isFocused, setIsFocused, highlightedIndex, setHighlightedIndex, dismiss } = + useEntitySearchDropdown(); const { results, taskItems, activityItems } = useEntitySearchResults({ entities, @@ -225,10 +217,9 @@ export function useEntitySearchInput({ (entity: SearchEntity) => { onChange?.(entity.name); onSelect?.(entity); - setIsFocused(false); - setHighlightedIndex(-1); + dismiss(); }, - [onChange, onSelect, setHighlightedIndex, setIsFocused] + [onChange, onSelect, dismiss] ); const commitCreate = useCallback(async () => { @@ -238,9 +229,8 @@ export function useEntitySearchInput({ addEntityToCache(nextName); onChange?.(nextName); await onCreate?.(nextName); - setIsFocused(false); - setHighlightedIndex(-1); - }, [addEntityToCache, onChange, onCreate, setHighlightedIndex, setIsFocused, value]); + dismiss(); + }, [addEntityToCache, onChange, onCreate, dismiss, value]); const handleInputFocus = useCallback(() => { setIsFocused(true); @@ -253,73 +243,49 @@ export function useEntitySearchInput({ [onChange] ); - const handleKeyDown = useCallback( - async (event: KeyboardEvent) => { - if (disabled) return; - - if (event.key === "ArrowDown" && isDropdownOpen) { - event.preventDefault(); - setHighlightedIndex( - activeHighlightedIndex < results.length - 1 ? activeHighlightedIndex + 1 : 0 - ); - return; - } - - if (event.key === "ArrowUp" && isDropdownOpen) { - event.preventDefault(); - setHighlightedIndex( - activeHighlightedIndex > 0 ? activeHighlightedIndex - 1 : results.length - 1 - ); - return; - } - - if (event.key === "Escape") { - if (isDropdownOpen) { - event.preventDefault(); - } - setIsFocused(false); - setHighlightedIndex(-1); - return; - } - - if (event.key !== "Enter") return; - - if (!canSearch) { - return; - } - - const hasHighlightedResult = - isDropdownOpen && - activeHighlightedIndex >= 0 && - activeHighlightedIndex < results.length; + /** Move the highlight to the next result, wrapping to the top. No-op while closed. */ + const onSelectNext = useCallback(() => { + if (!isDropdownOpen) return; + setHighlightedIndex(activeHighlightedIndex < results.length - 1 ? activeHighlightedIndex + 1 : 0); + }, [activeHighlightedIndex, isDropdownOpen, results.length, setHighlightedIndex]); + + /** Move the highlight to the previous result, wrapping to the bottom. No-op while closed. */ + const onSelectPrevious = useCallback(() => { + if (!isDropdownOpen) return; + setHighlightedIndex(activeHighlightedIndex > 0 ? activeHighlightedIndex - 1 : results.length - 1); + }, [activeHighlightedIndex, isDropdownOpen, results.length, setHighlightedIndex]); + + /** Close the dropdown and clear the highlight, e.g. on Escape or an outside click/tap. */ + const onDismiss = useCallback(() => { + dismiss(); + }, [dismiss]); + + /** + * Commit the highlighted result, or create a new entity from the typed + * value when nothing is highlighted. Returns whether it took action, so + * callers translating a "commit" gesture (e.g. Enter) know whether to + * suppress its default behaviour. + */ + const onCommit = useCallback(() => { + if (!canSearch) return false; + + const hasHighlightedResult = + isDropdownOpen && activeHighlightedIndex >= 0 && activeHighlightedIndex < results.length; + + if (hasHighlightedResult) { + commitSelection(results[activeHighlightedIndex]); + return true; + } - if (hasHighlightedResult) { - event.preventDefault(); - commitSelection(results[activeHighlightedIndex]); - return; - } + if (normalizeQuery(value)) { + void commitCreate(); + return true; + } - if (normalizeQuery(value)) { - event.preventDefault(); - await commitCreate(); - } - }, - [ - activeHighlightedIndex, - canSearch, - commitCreate, - commitSelection, - disabled, - isDropdownOpen, - results, - setHighlightedIndex, - setIsFocused, - value, - ] - ); + return false; + }, [activeHighlightedIndex, canSearch, commitCreate, commitSelection, isDropdownOpen, results, value]); return { - rootRef, canSearch, results, taskItems, @@ -327,10 +293,12 @@ export function useEntitySearchInput({ showGroupLabels, isDropdownOpen, activeHighlightedIndex, - highlightedIndex, handleInputFocus, handleInputChange, - handleKeyDown, commitSelection, + onSelectNext, + onSelectPrevious, + onDismiss, + onCommit, }; } diff --git a/frontend/src/components/List/List.module.scss b/frontend/src/components/List/List.module.scss index ce34db9d..09ae1119 100644 --- a/frontend/src/components/List/List.module.scss +++ b/frontend/src/components/List/List.module.scss @@ -31,7 +31,7 @@ gap: sp.$spacing-sm; width: 100%; padding: sp.$padding-base; - //border: 1px solid rgba(c.$color-border-primary, 0.2); + border: 1px solid transparent; //border-radius: sp.$border-radius; background: rgba(c.$color-bg, 0.4); transition: background 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; @@ -60,8 +60,8 @@ } .canHover .listItem:hover { - background: rgba(c.$color-border-primary, 0.06); - border-color: rgba(c.$color-border-primary, 0.5); + background: rgba(c.$color-border-primary, 0.18); + border-color: rgba(c.$color-border-primary, 0.7); @include m.box-shadow(md); z-index: 1; cursor: default; @@ -70,3 +70,8 @@ .canSelect .listItem:hover { cursor: pointer; } + +.compact .listItem { + padding: sp.$padding-sm; + gap: sp.$spacing-xs; +} diff --git a/frontend/src/components/List/List.tsx b/frontend/src/components/List/List.tsx index 920481b3..7fe79790 100644 --- a/frontend/src/components/List/List.tsx +++ b/frontend/src/components/List/List.tsx @@ -26,6 +26,8 @@ interface ListProps { /** Also exposed as selectable for legacy callers */ selectable?: boolean; canHover?: boolean; + /** Tighter item padding/gap - for lists nested inside an already-dense panel (e.g. BuildingDetail's residents/workers). */ + compact?: boolean; itemTone?: 'neutral' | 'player' | 'character'; className?: string; sectionClass?: string; @@ -43,6 +45,7 @@ export default function List({ canSelect = false, selectable = false, canHover = false, + compact = false, itemTone = 'neutral', className, sectionClass, @@ -75,6 +78,7 @@ export default function List({ className={classNames(styles.list, { [styles.canSelect]: isSelectable, [styles.canHover]: canHover, + [styles.compact]: compact, }, className)} role={isSelectable ? 'listbox' : 'list'} aria-label={ariaLabel} diff --git a/frontend/src/components/Map/Map.module.scss b/frontend/src/components/Map/Map.module.scss index ebf54700..7e0111db 100644 --- a/frontend/src/components/Map/Map.module.scss +++ b/frontend/src/components/Map/Map.module.scss @@ -70,7 +70,12 @@ // content (e.g. the "View details" button opening the entity detail // card) is actually clickable. pointer-events: auto; - transform: translate(-50%, calc(-100% - 12px)); + // 32px clears the tallest character icon (interpolates up to ~44px on + // screen at max zoom - see the icon-size expression in layers.ts - so + // half above its center-anchored point is ~22px) plus a small gap; for + // polygon features the anchor is already the shape's own top edge (see + // polygonAnchorLngLat in geojson.tsx), so this is pure breathing room. + transform: translate(-50%, calc(-100% - 32px)); background: white; border: 1px solid #888; border-radius: 4px; diff --git a/frontend/src/components/Map/Map.test.tsx b/frontend/src/components/Map/Map.test.tsx index 63bec42f..33f39f40 100644 --- a/frontend/src/components/Map/Map.test.tsx +++ b/frontend/src/components/Map/Map.test.tsx @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ComponentProps } from 'react'; import { fromLngLat, toLngLat } from './utils'; import { colourForCharacter } from './characters/placement'; +import { TOOLTIP_ONLY_SELECTION_OPACITY } from './layers'; const mockFetchMapCharacterDetail = vi.fn(); vi.mock('../../api/map', async (importOriginal) => { @@ -68,6 +69,16 @@ class FakeMap { this.layers.push(layer); } + setFilterCalls: { layerId: string; filter: unknown }[] = []; + setFilter(layerId: string, filter: unknown) { + this.setFilterCalls.push({ layerId, filter }); + } + + setPaintPropertyCalls: { layerId: string; name: string; value: unknown }[] = []; + setPaintProperty(layerId: string, name: string, value: unknown) { + this.setPaintPropertyCalls.push({ layerId, name, value }); + } + on(event: string, a: string | ((e?: unknown) => void), b?: (e?: unknown) => void) { const layerId = typeof a === 'string' ? a : undefined; const handler = typeof a === 'string' ? (b as (e?: unknown) => void) : a; @@ -113,8 +124,12 @@ class FakeMap { return { style: {} as CSSStyleDeclaration }; } + // Tests that need queryRenderedFeatures to report a hit (e.g. simulating + // the cursor being over a character standing inside a building) set this + // directly rather than modelling real spatial hit-testing. + queryRenderedFeaturesResult: unknown[] = []; queryRenderedFeatures() { - return []; + return this.queryRenderedFeaturesResult; } project([lng, lat]: [number, number]) { @@ -541,7 +556,7 @@ describe('PopulationCentreMap', () => { geometry: { type: 'Polygon', coordinates: [[[50, 50], [50, 60], [60, 60], [60, 50], [50, 50]]] }, properties: { feature_type: 'building', - name: 'House 2 of (Driftmoor village)', + name: 'House 2', building_type: 'residential', }, }, @@ -646,7 +661,7 @@ describe('PopulationCentreMap', () => { geometry: { type: 'Polygon', coordinates: [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]] }, properties: { feature_type: 'building', - name: 'House 2 of (Driftmoor village)', + name: 'House 2', building_type: 'residential', }, }, @@ -680,7 +695,7 @@ describe('PopulationCentreMap', () => { geometry: { type: 'Polygon', coordinates: [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]] }, properties: { feature_type: 'building', - name: 'House 2 of (Driftmoor village)', + name: 'House 2', building_type: 'residential', }, }, @@ -741,7 +756,7 @@ describe('PopulationCentreMap', () => { geometry: { type: 'Polygon', coordinates: [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]] }, properties: { feature_type: 'building', - name: 'House 2 of (Driftmoor village)', + name: 'House 2', building_type: 'residential', workers: 0, residents: 3, @@ -761,7 +776,7 @@ describe('PopulationCentreMap', () => { expect(tooltip).not.toHaveTextContent('Workers'); }); - it('shows home, workplace, and current activity in a character tooltip', async () => { + it('shows the current activity and location in a character tooltip', async () => { const geojsonWithCharacter = { ...baseGeojson, features: [ @@ -771,9 +786,8 @@ describe('PopulationCentreMap', () => { feature_type: 'character', id: 1, name: 'Alice', - home_type: 'residential', - work_type: 'bakery', current_activity: 'delivering goods to neighbours', + current_location_type: 'bakery', }, }, ], @@ -787,15 +801,40 @@ describe('PopulationCentreMap', () => { const tooltip = await screen.findByRole('tooltip'); expect(tooltip).toHaveTextContent('Alice'); - // home_type/work_type are the building_type ("residential"/"bakery"), - // not the building's bookkeeping name - resolved to the same plain - // label ("House"/"Bakery") shown on that building's own tooltip. - expect(tooltip).toHaveTextContent('Lives at: House'); - expect(tooltip).toHaveTextContent('Works at: Bakery'); - expect(tooltip).toHaveTextContent('delivering goods to neighbours'); + // current_location_type is the building_type ("bakery"), resolved to + // the plain label used elsewhere ("Bakery") - the activity gets an + // initial capital, the location word stays lower-case. + expect(tooltip).toHaveTextContent('Delivering goods to neighbours at bakery'); }); - it('shows "walking" in a character tooltip when the character is moving, overriding their scheduled activity', async () => { + it('shows "outside" in a character tooltip when their current location has no building', async () => { + const geojsonWithCharacter = { + ...baseGeojson, + features: [ + { + geometry: { type: 'Point', coordinates: [10, 10] }, + properties: { + feature_type: 'character', + id: 1, + name: 'Alice', + current_activity: 'foraging', + current_location_type: null, + }, + }, + ], + }; + renderMap({ geojson: geojsonWithCharacter }); + const feature = villageSourceFeatures().find((f) => f.properties.feature_type === 'character'); + + act(() => { + currentMap().trigger('click', { features: [feature], lngLat: { lng: 10, lat: 10 } }, 'characters'); + }); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Foraging outside'); + }); + + it('shows "Walking to [destination]" in a character tooltip when the character is moving, instead of their scheduled activity', async () => { const geojsonWithMovingCharacter = { ...baseGeojson, features: [ @@ -807,6 +846,7 @@ describe('PopulationCentreMap', () => { name: 'Alice', current_activity: 'delivering goods to neighbours', is_moving: true, + destination_location_type: 'bakery', }, }, ], @@ -819,11 +859,66 @@ describe('PopulationCentreMap', () => { }); const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent('Currently: walking'); + expect(tooltip).toHaveTextContent('Walking to bakery'); expect(tooltip).not.toHaveTextContent('delivering goods to neighbours'); }); - it('omits the current activity line when a character has none scheduled', async () => { + it('shows "home" instead of "house" when a character is at or walking to their residence', async () => { + const geojsonWithCharacter = { + ...baseGeojson, + features: [ + { + geometry: { type: 'Point', coordinates: [10, 10] }, + properties: { + feature_type: 'character', + id: 1, + name: 'Alice', + current_activity: 'sleeping', + current_location_type: 'residential', + }, + }, + ], + }; + renderMap({ geojson: geojsonWithCharacter }); + const feature = villageSourceFeatures().find((f) => f.properties.feature_type === 'character'); + + act(() => { + currentMap().trigger('click', { features: [feature], lngLat: { lng: 10, lat: 10 } }, 'characters'); + }); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Sleeping at home'); + expect(tooltip).not.toHaveTextContent('house'); + }); + + it('shows "Walking outside" when a moving character\'s destination has no building', async () => { + const geojsonWithMovingCharacter = { + ...baseGeojson, + features: [ + { + geometry: { type: 'Point', coordinates: [10, 10] }, + properties: { + feature_type: 'character', + id: 1, + name: 'Alice', + is_moving: true, + destination_location_type: null, + }, + }, + ], + }; + renderMap({ geojson: geojsonWithMovingCharacter }); + const feature = villageSourceFeatures().find((f) => f.properties.feature_type === 'character'); + + act(() => { + currentMap().trigger('click', { features: [feature], lngLat: { lng: 10, lat: 10 } }, 'characters'); + }); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Walking outside'); + }); + + it('omits the status line when a character has no activity or destination', async () => { const geojsonWithCharacter = { ...baseGeojson, features: [ @@ -847,7 +942,8 @@ describe('PopulationCentreMap', () => { const tooltip = await screen.findByRole('tooltip'); expect(tooltip).toHaveTextContent('Alice'); - expect(tooltip).not.toHaveTextContent('Currently:'); + expect(tooltip).not.toHaveTextContent(' at '); + expect(tooltip).not.toHaveTextContent('Walking to'); }); it('shows the crop stage in a field tooltip instead of the literal word "Crops"', async () => { @@ -1038,7 +1134,7 @@ describe('PopulationCentreMap entity detail card', () => { properties: { feature_type: 'building', id: 42, - name: 'House 2 of (Driftmoor village)', + name: 'House 2', building_type: 'residential', residents: 1, residential_capacity: 4, @@ -1066,7 +1162,7 @@ describe('PopulationCentreMap entity detail card', () => { await user.click(screen.getByRole('button', { name: 'View details' })); - const dialog = await screen.findByRole('dialog', { name: 'House' }); + const dialog = await screen.findByRole('dialog', { name: 'House 2' }); expect(dialog).toHaveTextContent('1 / 4'); expect(dialog).toHaveTextContent('Alice'); expect(dialog).toHaveTextContent('idle'); @@ -1082,7 +1178,7 @@ describe('PopulationCentreMap entity detail card', () => { properties: { feature_type: 'building', id: 42, - name: 'House 2 of (Driftmoor village)', + name: 'House 2', building_type: 'residential', residents: 1, }, @@ -1101,7 +1197,7 @@ describe('PopulationCentreMap entity detail card', () => { }); await screen.findByRole('tooltip'); await user.click(screen.getByRole('button', { name: 'View details' })); - await screen.findByRole('dialog', { name: 'House' }); + await screen.findByRole('dialog', { name: 'House 2' }); await user.click(screen.getByText('Alice')); @@ -1134,6 +1230,164 @@ describe('PopulationCentreMap entity detail card', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); + + it('outlines the selected building on the map while its detail card is open', async () => { + const user = userEvent.setup(); + const geojsonWithHouse = { + ...baseGeojson, + features: [ + { + geometry: { type: 'Polygon', coordinates: [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]] }, + properties: { feature_type: 'building', id: 42, name: 'House', building_type: 'residential' }, + }, + ], + }; + renderMap({ geojson: geojsonWithHouse }); + const feature = villageSourceFeatures().find((f) => f.properties.feature_type === 'building'); + + act(() => { + currentMap().trigger('click', { features: [feature], lngLat: { lng: 0, lat: 0 } }, 'buildings-fill'); + }); + await screen.findByRole('tooltip'); + await user.click(screen.getByRole('button', { name: 'View details' })); + await screen.findByRole('dialog', { name: 'House' }); + + const buildingCalls = currentMap().setFilterCalls.filter( + (c) => c.layerId === 'selected-building-outline' + ); + expect(buildingCalls.at(-1)?.filter).toEqual([ + 'all', + ['==', ['get', 'feature_type'], 'building'], + ['==', ['get', 'id'], 42], + ]); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + const afterClose = currentMap().setFilterCalls.filter( + (c) => c.layerId === 'selected-building-outline' + ); + expect(afterClose.at(-1)?.filter).toEqual([ + 'all', + ['==', ['get', 'feature_type'], 'building'], + ['==', ['get', 'id'], -1], + ]); + }); + + it('outlines a building at reduced opacity while just its tooltip is open, then at full opacity once its detail card opens', async () => { + const user = userEvent.setup(); + const geojsonWithHouse = { + ...baseGeojson, + features: [ + { + geometry: { type: 'Polygon', coordinates: [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]] }, + properties: { feature_type: 'building', id: 42, name: 'House', building_type: 'residential' }, + }, + ], + }; + renderMap({ geojson: geojsonWithHouse }); + const feature = villageSourceFeatures().find((f) => f.properties.feature_type === 'building'); + + act(() => { + currentMap().trigger('click', { features: [feature], lngLat: { lng: 0, lat: 0 } }, 'buildings-fill'); + }); + await screen.findByRole('tooltip'); + + const buildingCalls = currentMap().setFilterCalls.filter( + (c) => c.layerId === 'selected-building-outline' + ); + expect(buildingCalls.at(-1)?.filter).toEqual([ + 'all', + ['==', ['get', 'feature_type'], 'building'], + ['==', ['get', 'id'], 42], + ]); + await waitFor(() => { + const opacityCalls = currentMap().setPaintPropertyCalls.filter( + (c) => c.layerId === 'selected-building-outline' && c.name === 'line-opacity' + ); + expect(opacityCalls.at(-1)?.value).toBe(TOOLTIP_ONLY_SELECTION_OPACITY); + }); + + await user.click(screen.getByRole('button', { name: 'View details' })); + await screen.findByRole('dialog', { name: 'House' }); + + await waitFor(() => { + const opacityCalls = currentMap().setPaintPropertyCalls.filter( + (c) => c.layerId === 'selected-building-outline' && c.name === 'line-opacity' + ); + expect(opacityCalls.at(-1)?.value).toBe(1); + }); + }); + + it('does not show the building hover outline when the cursor is over a character standing inside it', async () => { + const geojsonWithHouse = { + ...baseGeojson, + features: [ + { + geometry: { type: 'Polygon', coordinates: [[[5, 5], [5, 10], [10, 10], [10, 5], [5, 5]]] }, + properties: { feature_type: 'building', id: 42, name: 'House', building_type: 'residential' }, + }, + ], + }; + renderMap({ geojson: geojsonWithHouse }); + const feature = villageSourceFeatures().find((f) => f.properties.feature_type === 'building'); + + // First move over the building with nothing else under the cursor, so + // its hover outline is showing (establishes a non-null baseline). + act(() => { + currentMap().trigger('mousemove', { features: [feature], point: { x: 0, y: 0 } }, 'buildings-fill'); + }); + const hoverCalls = () => + currentMap().setFilterCalls.filter((c) => c.layerId === 'hover-building-outline'); + expect(hoverCalls().at(-1)?.filter).toEqual([ + 'all', + ['==', ['get', 'feature_type'], 'building'], + ['==', ['get', 'id'], 42], + ]); + + // Now simulate the cursor also hitting the "characters" layer at this + // point, as it would when a character is standing inside the building. + currentMap().queryRenderedFeaturesResult = [{ properties: { id: 7 } }]; + act(() => { + currentMap().trigger('mousemove', { features: [feature], point: { x: 0, y: 0 } }, 'buildings-fill'); + }); + + expect(hoverCalls().at(-1)?.filter).toEqual([ + 'all', + ['==', ['get', 'feature_type'], 'building'], + ['==', ['get', 'id'], -1], + ]); + }); + + it('highlights the selected character on the map while its detail card is open', async () => { + const user = userEvent.setup(); + const geojsonWithCharacter = { + ...baseGeojson, + features: [ + { + geometry: { type: 'Point', coordinates: [10, 10] }, + properties: { feature_type: 'character', id: 1, name: 'Alice' }, + }, + ], + }; + renderMap({ geojson: geojsonWithCharacter }); + const feature = villageSourceFeatures().find((f) => f.properties.feature_type === 'character'); + + act(() => { + currentMap().trigger('click', { features: [feature], lngLat: { lng: 10, lat: 10 } }, 'characters'); + }); + await screen.findByRole('tooltip'); + await user.click(screen.getByRole('button', { name: 'View details' })); + await screen.findByRole('dialog', { name: 'Alice' }); + + const characterCalls = currentMap().setFilterCalls.filter( + (c) => c.layerId === 'selected-character-highlight' + ); + expect(characterCalls.at(-1)?.filter).toEqual([ + 'all', + ['==', ['get', 'feature_type'], 'character'], + ['==', ['get', 'id'], 1], + ]); + }); }); describe('PopulationCentreMap path-aware interpolation (#615)', () => { diff --git a/frontend/src/components/Map/Map.tsx b/frontend/src/components/Map/Map.tsx index 7ead12d6..a901897e 100644 --- a/frontend/src/components/Map/Map.tsx +++ b/frontend/src/components/Map/Map.tsx @@ -26,16 +26,25 @@ import { buildingFootprintRings, buildingTypeLabel, cropSubzoneRingsByShelterBuilding, + polygonAnchorLngLat, polygonTooltipContent, } from "./geojson"; import { addCharacterImage, addVillageLayers, + BUILDINGS_FILL_LAYER, CLICKABLE_LAYERS, + HOVER_BUILDING_OUTLINE_LAYER, + HOVER_CHARACTER_HIGHLIGHT_LAYER, + HOVER_OPACITY, + SELECTED_BUILDING_OUTLINE_LAYER, + SELECTED_CHARACTER_HIGHLIGHT_LAYER, + setFilterWithFade, + TOOLTIP_ONLY_SELECTION_OPACITY, VILLAGE_LABEL_LAYER, } from "./layers"; import { buildVillageSourceData, type WalkerState } from "./sourceData"; -import DetailCard from "../DetailCard/DetailCard"; +import MapDetailCard from "../MapDetailCard/MapDetailCard"; import CharacterDetail from "../CharacterDetail/CharacterDetail"; import BuildingDetail from "../BuildingDetail/BuildingDetail"; import styles from "./Map.module.scss"; @@ -124,12 +133,6 @@ const VIEWPORT_DEBOUNCE_MS = 400; // regardless of how far apart the two villages are. const FLY_TO_DURATION_MS = 1200; -interface TooltipOverlayState { - key: string; - content: React.ReactNode; - lngLat: [number, number]; -} - // The map's second level of progressive disclosure (tooltip -> click "View // details" -> DetailCard). Only character/building are wired up yet // (population centres are a later follow-up - see the map entity detail @@ -138,6 +141,16 @@ type DetailSelection = | { type: "character"; id: number } | { type: "building"; id: number }; +interface TooltipOverlayState { + key: string; + content: React.ReactNode; + lngLat: [number, number]; + // Which building/character (if any) this tooltip belongs to, so the + // selection-outline effect below can show a lower-intensity preview of + // the outline while just the tooltip - not the full DetailCard - is open. + entity?: DetailSelection; +} + export default function PopulationCentreMap({ geojson, onViewportChange, @@ -151,10 +164,20 @@ export default function PopulationCentreMap({ ); const containerRef = useRef(null); + // Passed to MapDetailCard as DetailSurface's portal `container` so the + // docked detail panel positions relative to the map itself, not the + // viewport (see MapDetailCard.module.scss). State (not a plain ref) so + // the value is available for render once the wrapper mounts. + const [mapWrapperEl, setMapWrapperEl] = useState(null); const mapRef = useRef(null); const sourceRef = useRef(null); const [mapReady, setMapReady] = useState(false); const initialFitDoneRef = useRef(false); + // Tracks which building/character the pointer is currently over, so the + // mousemove handlers below only call setFilterWithFade on an actual + // change rather than on every pointer movement within the same feature. + const hoveredBuildingIdRef = useRef(null); + const hoveredCharacterIdRef = useRef(null); useImperativeHandle( ref, @@ -301,26 +324,42 @@ export default function PopulationCentreMap({ const feature = e.features?.[0]; if (!feature) return; e.originalEvent?.stopPropagation?.(); - // home/work carry the building_type (e.g. "residential", "bakery"), - // not the building's bookkeeping name - resolved to the same plain - // label ("House", "Bakery") shown on that building's own tooltip, - // via buildingTypeLabel. - const homeType = feature.properties?.home_type as string | null | undefined; - const workType = feature.properties?.work_type as string | null | undefined; + // current_location_type/destination_location_type carry the + // building_type (e.g. "residential", "bakery") of where the + // character currently is / is walking to, not a bookkeeping name - + // resolved to the same plain label ("House", "Bakery") shown on + // that building's own tooltip, via buildingTypeLabel. null means + // that spot isn't inside a building at all ("outside"). + const currentLocationType = feature.properties?.current_location_type as + | string + | null + | undefined; + const destinationType = feature.properties?.destination_location_type as + | string + | null + | undefined; const characterId = Number(feature.properties?.id); setTooltip({ key: `character-${feature.id ?? JSON.stringify(feature.properties)}`, content: ( openDetail({ type: "character", id: characterId })} /> ), - lngLat: [e.lngLat.lng, e.lngLat.lat], + // Anchored to the character's own point, not the click position - + // MapLibre's hit-testing for icon layers uses the full image + // bounding box (see createCharacterIcon's comment in layers.ts), + // so a click near an edge of a large (zoomed-in) icon would + // otherwise leave the tooltip's fixed offset still overlapping it. + lngLat: feature.geometry.coordinates as [number, number], + entity: { type: "character", id: characterId }, }); }); map.on("mouseenter", "characters", () => { @@ -328,7 +367,37 @@ export default function PopulationCentreMap({ }); map.on("mouseleave", "characters", () => { map.getCanvas().style.cursor = ""; + if (hoveredCharacterIdRef.current === null) return; + hoveredCharacterIdRef.current = null; + setFilterWithFade( + map, + HOVER_CHARACTER_HIGHLIGHT_LAYER, + "circle-stroke-opacity", + ["all", ["==", ["get", "feature_type"], "character"], ["==", ["get", "id"], -1]], + HOVER_OPACITY + ); }); + map.on( + "mousemove", + "characters", + (e: MapMouseEvent & { features?: MapGeoJSONFeature[] }) => { + const feature = e.features?.[0]; + const characterId = feature ? Number(feature.properties?.id) : null; + if (characterId === hoveredCharacterIdRef.current) return; + hoveredCharacterIdRef.current = characterId; + setFilterWithFade( + map, + HOVER_CHARACTER_HIGHLIGHT_LAYER, + "circle-stroke-opacity", + [ + "all", + ["==", ["get", "feature_type"], "character"], + ["==", ["get", "id"], characterId ?? -1], + ], + HOVER_OPACITY + ); + } + ); // Tapping/selecting a village's name label expands it into its // progress bar + state (issue #673) - the label itself is coloured by @@ -403,7 +472,11 @@ export default function PopulationCentreMap({ setTooltip({ key: `${layerId}-${feature.id ?? JSON.stringify(feature.properties)}`, content, - lngLat: [e.lngLat.lng, e.lngLat.lat], + lngLat: polygonAnchorLngLat(feature.geometry), + entity: + feature.properties?.feature_type === "building" + ? { type: "building", id: buildingId } + : undefined, }); }); map.on("mouseenter", layerId, () => { @@ -411,7 +484,52 @@ export default function PopulationCentreMap({ }); map.on("mouseleave", layerId, () => { map.getCanvas().style.cursor = ""; + if (layerId !== BUILDINGS_FILL_LAYER) return; + if (hoveredBuildingIdRef.current === null) return; + hoveredBuildingIdRef.current = null; + setFilterWithFade( + map, + HOVER_BUILDING_OUTLINE_LAYER, + "line-opacity", + ["all", ["==", ["get", "feature_type"], "building"], ["==", ["get", "id"], -1]], + HOVER_OPACITY + ); }); + if (layerId === BUILDINGS_FILL_LAYER) { + map.on( + "mousemove", + layerId, + (e: MapMouseEvent & { features?: MapGeoJSONFeature[] }) => { + // A character standing inside a building still sits over its + // fill layer, so this mousemove keeps firing alongside the + // "characters" layer's own hover handler - the character's + // hover outline should take priority rather than showing + // both at once (mirrors the click-priority check above). + const overCharacter = + map.queryRenderedFeatures(e.point, { layers: ["characters"] }).length > + 0; + const feature = e.features?.[0]; + const buildingId = overCharacter + ? null + : feature + ? Number(feature.properties?.id) + : null; + if (buildingId === hoveredBuildingIdRef.current) return; + hoveredBuildingIdRef.current = buildingId; + setFilterWithFade( + map, + HOVER_BUILDING_OUTLINE_LAYER, + "line-opacity", + [ + "all", + ["==", ["get", "feature_type"], "building"], + ["==", ["get", "id"], buildingId ?? -1], + ], + HOVER_OPACITY + ); + } + ); + } }); map.on("click", (e: MapMouseEvent) => { @@ -584,6 +702,41 @@ export default function PopulationCentreMap({ return () => window.clearInterval(intervalId); }, [mapReady, refreshVillageSource]); + // Outlines whichever building/character the detail card currently has + // open (see SELECTED_BUILDING_OUTLINE_LAYER/SELECTED_CHARACTER_HIGHLIGHT_LAYER + // in layers.ts) - driven off `detail` rather than a per-feature style + // expression, since only one selection ever exists at a time. A tooltip + // alone (without the detail card open) shows the same outline at reduced + // intensity, as a preview of the same affordance. + useEffect(() => { + const map = mapRef.current; + if (!map || !mapReady) return; + const active = detail ?? tooltip?.entity ?? null; + const opacity = detail ? 1 : TOOLTIP_ONLY_SELECTION_OPACITY; + setFilterWithFade( + map, + SELECTED_BUILDING_OUTLINE_LAYER, + "line-opacity", + [ + "all", + ["==", ["get", "feature_type"], "building"], + ["==", ["get", "id"], active?.type === "building" ? active.id : -1], + ], + opacity + ); + setFilterWithFade( + map, + SELECTED_CHARACTER_HIGHLIGHT_LAYER, + "circle-stroke-opacity", + [ + "all", + ["==", ["get", "feature_type"], "character"], + ["==", ["get", "id"], active?.type === "character" ? active.id : -1], + ], + opacity + ); + }, [detail, tooltip, mapReady]); + // Unmount cleanup for the tooltip root when the whole component goes away. useEffect(() => { return () => { @@ -646,33 +799,68 @@ export default function PopulationCentreMap({ return (feature?.properties?.name as string | undefined) ?? "Character"; }, [detail, characterFeatures]); + // Detail card's "fly to" header button - reuses the same flyTo call as the + // imperative flyToPoint handle, but resolves the point from whichever + // entity is currently selected rather than a caller-supplied one. + // idleCharacterPositions gives the exact scattered dot for a stationary + // character; a walking character falls back to their last known raw node + // position (close enough to their building - not worth reaching into the + // walker animation ref for a "fly near" convenience button). + const handleFlyToDetail = useCallback(() => { + const map = mapRef.current; + if (!map || !detail) return; + let rawPoint: [number, number] | null = null; + if (detail.type === "character") { + const feature = characterFeatures.find((f) => Number(f.properties?.id) === detail.id); + if (feature) { + rawPoint = + idleCharacterPositions.get(String(detail.id)) ?? + (feature.geometry.coordinates as [number, number]); + } + } else if (detail.type === "building" && selectedBuildingFeature) { + rawPoint = polygonAnchorLngLat(selectedBuildingFeature.geometry); + } + if (!rawPoint) return; + map.flyTo({ + center: toLngLat(rawPoint), + zoom: 14, + duration: FLY_TO_DURATION_MS, + essential: true, + }); + }, [detail, characterFeatures, idleCharacterPositions, selectedBuildingFeature]); + return ( -
+
{children &&
{children}
} {detail?.type === "character" && ( - setDetail(null)} + onFlyTo={handleFlyToDetail} + container={mapWrapperEl} > openDetail({ type: "building", id: buildingId })} onSelectRelationship={(characterId) => openDetail({ type: "character", id: characterId })} /> - + )} {detail?.type === "building" && selectedBuildingFeature && ( - setDetail(null)} + onFlyTo={handleFlyToDetail} + container={mapWrapperEl} > openDetail({ type: "character", id: characterId })} onSelectWorker={(characterId) => openDetail({ type: "character", id: characterId })} /> - + )}
); diff --git a/frontend/src/components/Map/MapTooltips.tsx b/frontend/src/components/Map/MapTooltips.tsx index 424c09ff..2e602707 100644 --- a/frontend/src/components/Map/MapTooltips.tsx +++ b/frontend/src/components/Map/MapTooltips.tsx @@ -2,6 +2,7 @@ // of Map.tsx since the trigger elements (polygons/glyphs) already carry a // lot of pan/zoom/rendering logic. import ProgressBar from "../ProgressBar/ProgressBar"; +import Button from "../Button/Button"; import { VILLAGE_STATE_PROGRESS_COLORS } from "./layers"; interface GoodEntry { @@ -57,9 +58,9 @@ export function BuildingTooltipContent({ )} {onViewDetails && ( - + )}
); @@ -67,35 +68,55 @@ export function BuildingTooltipContent({ interface CharacterTooltipProps { name?: string; - home?: string | null; - work?: string | null; currentActivity?: string | null; isMoving?: boolean | null; + /** Plain label ("House", "Bakery") for the building the character is + * currently in, or null/undefined if they're not inside a building. */ + currentLocationLabel?: string | null; + /** Plain label for the building the character is walking to, or + * null/undefined if their destination isn't inside a building. Only + * relevant while isMoving. */ + destinationLabel?: string | null; /** Second level of progressive disclosure (issue: map entity detail card) - * omit to render the tooltip with no "View details" affordance. */ onViewDetails?: () => void; } +// Tooltip location words read lower-case ("at bakery", "to the mill"), and +// "house" reads as "home" here - a character's own residence, not a +// building type label. +function tooltipLocationWord(label: string): string { + const lower = label.toLowerCase(); + return lower === "house" ? "home" : lower; +} + export function CharacterTooltipContent({ name, - home, - work, currentActivity, isMoving, + currentLocationLabel, + destinationLabel, onViewDetails, }: CharacterTooltipProps) { - const activityLabel = isMoving ? "walking" : currentActivity; + const activityLabel = + currentActivity && currentActivity.charAt(0).toUpperCase() + currentActivity.slice(1); + const statusLine = isMoving + ? destinationLabel + ? `Walking to ${tooltipLocationWord(destinationLabel)}` + : "Walking outside" + : activityLabel && + (currentLocationLabel + ? `${activityLabel} at ${tooltipLocationWord(currentLocationLabel)}` + : `${activityLabel} outside`); return (
{name}
- {activityLabel &&
Currently: {activityLabel}
} - {home &&
Lives at: {home}
} - {work &&
Works at: {work}
} + {statusLine &&
{statusLine}
} {onViewDetails && ( - + )}
); diff --git a/frontend/src/components/Map/geojson.tsx b/frontend/src/components/Map/geojson.tsx index 3f655272..a2960472 100644 --- a/frontend/src/components/Map/geojson.tsx +++ b/frontend/src/components/Map/geojson.tsx @@ -66,13 +66,39 @@ export function buildingTypeLabel(buildingType: string | null | undefined): stri return (buildingType && BUILDING_TYPE_LABELS[buildingType]) || "Building"; } +// Anchors a polygon/multipolygon feature's tooltip to the top-center of its +// bounding box rather than wherever inside the shape it was clicked - +// otherwise a click near the middle of a large building/subzone footprint +// would leave the tooltip's own fixed offset (see .floatingTooltip in +// Map.module.scss) still overlapping the shape's own top edge. +export function polygonAnchorLngLat(geometry: { + type: string; + coordinates: unknown; +}): [number, number] { + const rings: number[][][] = + geometry.type === "MultiPolygon" + ? (geometry.coordinates as number[][][][]).flat() + : (geometry.coordinates as number[][][]); + let minLng = Infinity; + let maxLng = -Infinity; + let maxLat = -Infinity; + for (const ring of rings) { + for (const [lng, lat] of ring) { + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + if (lat > maxLat) maxLat = lat; + } + } + return [(minLng + maxLng) / 2, maxLat]; +} + export function polygonTooltipContent( properties: GeoJSONFeatureProperties | null | undefined, onViewDetails?: () => void ): React.ReactNode | undefined { if (properties?.feature_type === "building") { const buildingType = properties?.building_type as string | undefined; - const label = buildingTypeLabel(buildingType); + const label = (properties?.name as string | undefined) ?? buildingTypeLabel(buildingType); return ( { + map.setPaintProperty(layerId, opacityProperty, targetOpacity); + }); +} + +// The selected-* layers' own paint opacity (see addVillageLayers below) is +// the "detail card open" intensity; a tooltip alone (lower rung of the +// tooltip -> DetailCard progressive disclosure - see DetailSelection in +// Map.tsx) uses this reduced intensity instead, via the same layers/effect. +export const TOOLTIP_ONLY_SELECTION_OPACITY = 0.5; + +// Resting opacity for the HOVER_* layers below - matches their static +// paint.*-opacity, but setFilterWithFade's zero-then-restore fade needs +// this passed explicitly as targetOpacity, since the function's own +// default (1) is for the SELECTED_* layers' "detail card open" case. +export const HOVER_OPACITY = 0.5; + // Village name-label colour per PopulationCentre.state (see // locations/models.py) - a placeholder palette (issue #673 explicitly leaves // the exact colour set as an open design question). Mirrors @@ -121,6 +176,38 @@ export function addVillageLayers(map: MapLibreMap): void { filter: ["==", ["get", "feature_type"], "building"], paint: { "fill-color": ["get", "fillColor"], "fill-outline-color": "#333" }, }); + map.addLayer({ + id: HOVER_BUILDING_OUTLINE_LAYER, + type: "line", + source: "village", + filter: [ + "all", + ["==", ["get", "feature_type"], "building"], + ["==", ["get", "id"], -1], + ], + paint: { + "line-color": SELECTION_HIGHLIGHT_COLOR, + "line-width": 2, + "line-opacity": 0.5, + "line-opacity-transition": { duration: 150, delay: 0 }, + }, + }); + map.addLayer({ + id: SELECTED_BUILDING_OUTLINE_LAYER, + type: "line", + source: "village", + filter: [ + "all", + ["==", ["get", "feature_type"], "building"], + ["==", ["get", "id"], -1], + ], + paint: { + "line-color": SELECTION_HIGHLIGHT_COLOR, + "line-width": 3, + "line-opacity": 1, + "line-opacity-transition": { duration: 250, delay: 0 }, + }, + }); map.addLayer({ id: PATHS_LINE_LAYER, type: "line", @@ -209,6 +296,58 @@ export function addVillageLayers(map: MapLibreMap): void { ], }, }); + map.addLayer({ + id: HOVER_CHARACTER_HIGHLIGHT_LAYER, + type: "circle", + source: "village", + // Added before CHARACTERS_LAYER so the highlight ring sits beneath the + // character icon rather than covering it. + filter: [ + "all", + ["==", ["get", "feature_type"], "character"], + ["==", ["get", "id"], -1], + ], + paint: { + "circle-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 12, 6, + 16, 18, + ], + "circle-color": "transparent", + "circle-stroke-color": SELECTION_HIGHLIGHT_COLOR, + "circle-stroke-width": 2, + "circle-stroke-opacity": 0.5, + "circle-stroke-opacity-transition": { duration: 150, delay: 0 }, + }, + }); + map.addLayer({ + id: SELECTED_CHARACTER_HIGHLIGHT_LAYER, + type: "circle", + source: "village", + // Added before CHARACTERS_LAYER so the highlight ring sits beneath the + // character icon rather than covering it. + filter: [ + "all", + ["==", ["get", "feature_type"], "character"], + ["==", ["get", "id"], -1], + ], + paint: { + "circle-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 12, 6, + 16, 18, + ], + "circle-color": "transparent", + "circle-stroke-color": SELECTION_HIGHLIGHT_COLOR, + "circle-stroke-width": 3, + "circle-stroke-opacity": 1, + "circle-stroke-opacity-transition": { duration: 250, delay: 0 }, + }, + }); map.addLayer({ id: CHARACTERS_LAYER, type: "symbol", diff --git a/frontend/src/components/MapDetailCard/MapDetailCard.module.scss b/frontend/src/components/MapDetailCard/MapDetailCard.module.scss new file mode 100644 index 00000000..95b8ac83 --- /dev/null +++ b/frontend/src/components/MapDetailCard/MapDetailCard.module.scss @@ -0,0 +1,48 @@ +// components/MapDetailCard/MapDetailCard.module.scss +@use '../../styles/base/variables' as v; +@use '../../styles/semantic/spacing' as sp; +@use '../../styles/semantic/colors' as c; +@use '../../styles/utilities/mixins' as m; + +// Mobile-first: full-width bottom sheet fallback (same as DetailCard's +// centered variant - the map fills the screen at that size, so +// viewport-fixed is fine there too), but only below a custom, narrower +// breakpoint than the shared `sm` (576px) token - a docked side panel still +// has room to mean something down to a fairly small phone width, so this +// keeps the top-right dock through more of the "mobile" range and reserves +// the bottom sheet for genuinely narrow screens. +$dock-breakpoint: 420px; + +.card { + position: fixed; + z-index: v.$z-index-modal; + display: flex; + flex-direction: column; + background: c.$color-bg; + box-shadow: 0 sp.$spacing-sm sp.$spacing-md rgba(0, 0, 0, 0.2); + + left: 0; + right: 0; + bottom: 0; + max-height: 80vh; + border-radius: sp.$border-radius sp.$border-radius 0 0; + + @include m.respond-to($dock-breakpoint) { + position: absolute; + left: auto; + // Clear of MapLibre's own NavigationControl (zoom buttons, ~58px tall + // with the compass hidden - see Map.tsx's addControl call) plus its + // own 10px margin, with only a small extra gap above it. + top: 78px; + right: 10px; + bottom: auto; + width: 90%; + max-width: min(90vw, 200px); + max-height: 75vh; + border-radius: sp.$border-radius; + // No dimming overlay behind this panel (it's non-modal - see the + // `modal` prop comment in DetailSurface.tsx), so a border is the only + // thing separating it from the map underneath. + border: 1px solid c.$color-border-primary; + } +} diff --git a/frontend/src/components/MapDetailCard/MapDetailCard.stories.tsx b/frontend/src/components/MapDetailCard/MapDetailCard.stories.tsx new file mode 100644 index 00000000..5f9202d2 --- /dev/null +++ b/frontend/src/components/MapDetailCard/MapDetailCard.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import MapDetailCard from './MapDetailCard'; + +/** + * `MapDetailCard` is the map's docked side-panel variant of DetailCard's + * header/content layout - a click on a character/building stays open and + * browsable alongside the map instead of covering it. Non-modal (no dimming + * overlay, the map underneath stays interactive), narrower than DetailCard, + * and positions relative to the map itself (via the `container` prop) + * rather than the viewport. + */ +const meta: Meta = { + title: 'Shared/MapDetailCard', + component: MapDetailCard, + tags: ['autodocs'], + // Renders via a Radix Portal into document.body - escapes the story + // canvas with inline docs rendering, so use an iframe like AlertDialog. + parameters: { + docs: { + story: { inline: false, iframeHeight: 320 }, + }, + }, + args: { + open: true, + title: 'Rose Cottage', + onClose: () => {}, + children: ( + <> +

House

+

Residents: 4

+
    +
  • Alice (idle)
  • +
  • Thomas (delivering goods to neighbours)
  • +
  • Emily (idle)
  • +
  • James (idle)
  • +
+ + ), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole('dialog', { name: 'Rose Cottage' }); + await expect(dialog).toBeVisible(); + }, +}; diff --git a/frontend/src/components/MapDetailCard/MapDetailCard.tsx b/frontend/src/components/MapDetailCard/MapDetailCard.tsx new file mode 100644 index 00000000..5a4bd4f6 --- /dev/null +++ b/frontend/src/components/MapDetailCard/MapDetailCard.tsx @@ -0,0 +1,47 @@ +import type React from "react"; +import DetailSurface from "../DetailSurface/DetailSurface"; +import { DetailCardBody } from "../DetailCard/DetailCard"; +import styles from "./MapDetailCard.module.scss"; + +interface MapDetailCardProps { + open: boolean; + title: string; + onClose: () => void; + children: React.ReactNode; + onFlyTo?: () => void; + /** Map's own wrapper element, passed through to DetailSurface so this + * portals (and positions) relative to the map instead of the viewport - + * see the `container` prop comment on DetailSurface. */ + container?: HTMLElement | null; +} + +// Map's docked side panel (see Map's "click a tooltip -> detail card" flow) - +// reuses DetailCard's header/content layout (DetailCardBody) but, unlike +// DetailCard's centered viewport-fixed modal, docks to the map's own corner +// and stays non-modal so the map underneath stays interactive. Kept as its +// own component rather than a DetailCard variant because it needs a +// different portal target and positioning scheme, not just different CSS. +export default function MapDetailCard({ + open, + title, + onClose, + children, + onFlyTo, + container, +}: MapDetailCardProps) { + return ( + { + if (!next) onClose(); + }} + title={title} + modal={false} + container={container} + > + + {children} + + + ); +} diff --git a/frontend/src/context/GameContext.tsx b/frontend/src/context/GameContext.tsx index 93a28b08..3560e6b4 100644 --- a/frontend/src/context/GameContext.tsx +++ b/frontend/src/context/GameContext.tsx @@ -6,6 +6,7 @@ import { useBootstrapGameData } from '../hooks/useBootstrapGameData'; import { useEventCallback } from '../hooks/useEventCallback'; import { apiFetch } from "../utils/api"; import useActivityTimer from '../hooks/useActivityTimer'; +import useUnloadWarning from '../hooks/useUnloadWarning'; import { useAuth } from './AuthContext'; import { GameContext, type GameContextValue } from './gameContext'; import type { @@ -82,6 +83,8 @@ export const GameProvider = ({ children }: ProviderProps): ReactElement => { const activityTimer = useActivityTimer(); const { loadFromServer } = activityTimer; + useUnloadWarning(activityTimer.status === 'active'); + // ---------------------------------------- // STABLE CALLBACKS diff --git a/frontend/src/hooks/useActivityTimer.ts b/frontend/src/hooks/useActivityTimer.ts index f4c501c7..2b51b787 100644 --- a/frontend/src/hooks/useActivityTimer.ts +++ b/frontend/src/hooks/useActivityTimer.ts @@ -333,14 +333,6 @@ export default function useActivityTimer(): ActivityTimerReturn { // ---------------------------- - // Block tab close / refresh / external navigation while timer is active - useEffect(() => { - if (status !== 'active') return; - const handler = (e: BeforeUnloadEvent): void => { e.preventDefault(); e.returnValue = ''; }; - window.addEventListener('beforeunload', handler); - return () => window.removeEventListener('beforeunload', handler); - }, [status]); - // Cleanup on unmount useEffect(() => { //console.log(`[useActivityTimer] mounted`); diff --git a/frontend/src/hooks/useUnloadWarning.test.tsx b/frontend/src/hooks/useUnloadWarning.test.tsx new file mode 100644 index 00000000..ac88f633 --- /dev/null +++ b/frontend/src/hooks/useUnloadWarning.test.tsx @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import useUnloadWarning from './useUnloadWarning'; + +describe('useUnloadWarning', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registers a beforeunload handler while active and removes it on deactivation', () => { + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + + const { rerender } = renderHook(({ active }) => useUnloadWarning(active), { + initialProps: { active: true }, + }); + + expect(addSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + + rerender({ active: false }); + + expect(removeSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); + + it('does not register a handler when inactive', () => { + const addSpy = vi.spyOn(window, 'addEventListener'); + + renderHook(() => useUnloadWarning(false)); + + expect(addSpy).not.toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); + + it('prevents default and clears returnValue on the beforeunload event', () => { + renderHook(() => useUnloadWarning(true)); + + const event = new Event('beforeunload') as BeforeUnloadEvent; + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + window.dispatchEvent(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(event.returnValue).toBe(''); + }); +}); diff --git a/frontend/src/hooks/useUnloadWarning.ts b/frontend/src/hooks/useUnloadWarning.ts new file mode 100644 index 00000000..abe82a21 --- /dev/null +++ b/frontend/src/hooks/useUnloadWarning.ts @@ -0,0 +1,18 @@ +// hooks/useUnloadWarning.ts +import { useEffect } from "react"; + +// Warns the user before closing/refreshing/navigating away from the tab +// while `active` is true. No-op when `window` isn't available (e.g. native). +export default function useUnloadWarning(active: boolean): void { + useEffect(() => { + if (!active) return; + if (typeof window === "undefined") return; + + const handler = (e: BeforeUnloadEvent): void => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [active]); +} diff --git a/frontend/src/styles/base/_global.scss b/frontend/src/styles/base/_global.scss index 0b285341..2dc65df2 100644 --- a/frontend/src/styles/base/_global.scss +++ b/frontend/src/styles/base/_global.scss @@ -23,6 +23,16 @@ body { align-items: stretch; min-width: 260px; flex: 1; + // Cursor is inherited, so without this any plain text (tooltip copy, + // list items, etc.) shows the browser's default text-selection I-beam + // on hover - only actual text inputs should look editable. + cursor: default; +} + +input:not([type="checkbox"], [type="radio"], [type="button"], [type="submit"], [type="reset"], [type="range"], [type="color"], [type="file"]), +textarea, +[contenteditable="true"] { + cursor: text; } main { diff --git a/frontend/src/styles/semantic/_typography.scss b/frontend/src/styles/semantic/_typography.scss index 9ff46ca1..595cf4b5 100644 --- a/frontend/src/styles/semantic/_typography.scss +++ b/frontend/src/styles/semantic/_typography.scss @@ -19,12 +19,6 @@ $text-body: ( letter-spacing: token(t.$letter-spacing, normal), text-transform: token(t.$text-transform, none) ), - md: ( - font-size: 1.125rem - ), - lg: ( - //font-size: 1.25rem - ), ); // Body text styles @@ -37,9 +31,6 @@ $text-list: ( letter-spacing: token(t.$letter-spacing, normal), text-transform: token(t.$text-transform, none) ), - md: ( - font-size: token(t.$font-size, base) - ) ); // Heading 1 styles @@ -52,12 +43,6 @@ $text-heading-1: ( letter-spacing: token(t.$letter-spacing, tight), text-transform: token(t.$text-transform, none) ), - md: ( - font-size: 2.25rem - ), - lg: ( - font-size: 2.5rem - ) ); // Heading 2 styles @@ -70,12 +55,6 @@ $text-heading-2: ( letter-spacing: token(t.$letter-spacing, tight), text-transform: token(t.$text-transform, none) ), - md: ( - font-size: 1.75rem - ), - lg: ( - font-size: 2rem - ) ); $text-heading-3: ( @@ -87,12 +66,6 @@ $text-heading-3: ( letter-spacing: token(t.$letter-spacing, normal), text-transform: token(t.$text-transform, none) ), - md: ( - font-size: 1.5rem - ), - lg: ( - font-size: 1.75rem - ) ); @@ -106,9 +79,6 @@ $text-button: ( letter-spacing: token(t.$letter-spacing, normal), text-transform: token(t.$text-transform, none) ), - md: ( - font-size: 1.125rem - ) ); // Link styles @@ -121,9 +91,6 @@ $text-link : ( letter-spacing: token(t.$letter-spacing, normal), text-transform: token(t.$text-transform, none), ), - md: ( - font-size: 1.125rem - ) ); // Caption styles @@ -136,9 +103,6 @@ $text-caption: ( letter-spacing: token(t.$letter-spacing, normal), text-transform: token(t.$text-transform, none), ), - md: ( - font-size: 0.75rem - ), ); // Label styles diff --git a/frontend/src/utils/api.test.ts b/frontend/src/utils/api.test.ts index 39e2ad2a..224e26e2 100644 --- a/frontend/src/utils/api.test.ts +++ b/frontend/src/utils/api.test.ts @@ -183,4 +183,47 @@ describe("apiFetch", () => { expect(maintenanceHandler).toHaveBeenCalledTimes(1); }); + + describe("skipAuth", () => { + afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it("does not attach an Authorization header or call getValidAccessToken", async () => { + // No tokens in storage at all — getValidAccessToken() would throw if called. + (globalThis.fetch as ReturnType).mockResolvedValueOnce(okResponse); + + await apiFetch("/auth/jwt/create/", { method: "POST", skipAuth: true }); + + const [, requestInit] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(requestInit.headers).not.toHaveProperty("Authorization"); + }); + + it("rejects with an unauthorized ApiFetchError on 401, without clearing storage or invoking the handler", async () => { + storeAuthTokens("still-valid-access-token", "still-valid-refresh-token", true); + (globalThis.fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 401 }); + const unauthorizedHandler = vi.fn(); + setUnauthorizedHandler(unauthorizedHandler); + + await expect(apiFetch("/auth/jwt/create/", { method: "POST", skipAuth: true })).rejects.toMatchObject({ + kind: "unauthorized", + } satisfies Partial); + + expect(unauthorizedHandler).not.toHaveBeenCalled(); + // A rejected login shouldn't log out whatever session was already stored. + expect(getStoredAuthTokens().accessToken).toBe("still-valid-access-token"); + + setUnauthorizedHandler(null); + }); + + it("takes priority over an explicitAccessToken — no Authorization header either way", async () => { + (globalThis.fetch as ReturnType).mockResolvedValueOnce(okResponse); + + await apiFetch("/me/", { skipAuth: true }, "explicit-token"); + + const [, requestInit] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(requestInit.headers).not.toHaveProperty("Authorization"); + }); + }); }); diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index db7ca12e..c1ad341e 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -35,6 +35,15 @@ type ResponseType = "json" | "blob" | "text" | "raw"; interface ApiFetchOptions extends Omit { responseType?: ResponseType; headers?: Record; + /** + * Skip the Authorization header and the getValidAccessToken() refresh path + * entirely, for endpoints that are unauthenticated by design (login, + * registration, password reset). A 401 from a skipAuth call means "these + * credentials were rejected", not "your session died" — it does not clear + * storage or invoke the unauthorized handler, unlike a 401 on an + * authenticated call. + */ + skipAuth?: boolean; } function isTokenExpiringSoon(token: string, bufferSeconds = 60): boolean { @@ -171,22 +180,27 @@ export async function apiFetch( explicitAccessToken: string | null = null ): Promise { try { - const { responseType = "json", ...fetchOptions } = options; - const accessToken = explicitAccessToken || (await getValidAccessToken()); + const { responseType = "json", skipAuth = false, ...fetchOptions } = options; const headers: Record = { ...(fetchOptions.headers || {}), - Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }; + if (!skipAuth) { + const accessToken = explicitAccessToken || (await getValidAccessToken()); + headers.Authorization = `Bearer ${accessToken}`; + } + const response = await fetchWithRetry(`${API_URL}${path}`, { ...fetchOptions, headers, }); if (response.status === 401) { - handleUnauthorized(); + if (!skipAuth) { + handleUnauthorized(); + } throw new ApiFetchError("unauthorized", "Unauthorized"); } diff --git a/locations/management/commands/assign_workers.py b/locations/management/commands/assign_workers.py index 8b8c63bc..835d4e81 100644 --- a/locations/management/commands/assign_workers.py +++ b/locations/management/commands/assign_workers.py @@ -3,15 +3,25 @@ from django.core.management.base import BaseCommand from character.models import Character, CharacterLocation -from locations.models import Building +from economy.services.planning_services import settlement_plan +from locations.models import Building, PopulationCentre -# Every building type is a work site except residential - kept derived from -# Building.BUILDING_TYPES (the single source of truth) so a new building -# type added there doesn't also need remembering here. +# Buildings assigned demand-aware worker counts from settlement_plan +# (farming via field_shelter, milling/baking via BuildingCapability) rather +# than the flat random fallback below. Granary is deliberately excluded +# from both paths - no economy tick reads workers_present(granary), so +# staffing it would just be wasted headcount. +ECONOMY_ROLE_ACTIVITIES = ("milling", "baking") + +# Every other work building type (inn, market, hall, communal buildings +# without a capability, etc.) keeps the original flat, demand-blind +# assignment - kept derived from Building.BUILDING_TYPES (the single source +# of truth) so a new building type added there doesn't also need +# remembering here. WORK_BUILDING_TYPES = [ building_type for building_type, _label in Building.BUILDING_TYPES - if building_type != "residential" + if building_type not in ("residential", "granary") ] MIN_WORKING_AGE = 16 MAX_WORKING_AGE = 65 @@ -21,53 +31,126 @@ class Command(BaseCommand): help = ( - "Assign a handful of working-age characters to work in the village's " - "non-residential buildings. " - "Not every character gets a job - children and elders are excluded, " - "and each building only takes on 2-3 workers, scoped to its own " - "population centre." + "Assign working-age characters to work in the village's " + "non-residential, non-granary buildings. Farming/milling/baking " + "buildings are staffed to meet settlement_plan's demand estimate, " + "greedily filling one building to MAX_WORKERS_PER_BUILDING before " + "moving to the next of the same role (so a village with too few " + "eligible workers ends up understaffed rather than evenly thin " + "everywhere) - a village can fall short of settlement_plan's " + "recommendation, which is the point: this is what makes the " + "'struggling at spawn' arc real rather than guaranteed by " + "construction. Every other work building keeps the original flat " + "2-3 random workers per building, since there's no demand model " + "for those roles." ) def handle(self, *args, **options): - work_buildings = list( - Building.objects.filter(building_type__in=WORK_BUILDING_TYPES) - ) - if not work_buildings: - self.stdout.write(self.style.WARNING("No work buildings found")) - return - assigned_ids: set[int] = set() + processed_building_ids: set[int] = set() - for building in work_buildings: - if not building.nodes.exists(): - self.stdout.write( - self.style.WARNING( - f"Building {building.id} has no nodes – skipping work assignment" - ) - ) - continue + for centre in PopulationCentre.objects.all(): + self.assign_economy_role_workers( + centre, assigned_ids, processed_building_ids + ) - eligible = [ - char - for char in Character.objects.filter( - population_centre=building.population_centre, - locations__role=CharacterLocation.Role.HOME, - locations__is_primary=True, - ) - if char.id not in assigned_ids - and MIN_WORKING_AGE <= (char.get_age() // 365) <= MAX_WORKING_AGE + self.assign_fallback_workers(assigned_ids, processed_building_ids) + + self.stdout.write(self.style.SUCCESS("Workers have been assigned")) + + # ------------------------------------------------------ + + def assign_economy_role_workers(self, centre, assigned_ids, processed_building_ids): + plan = settlement_plan(population_centre=centre) + remaining = { + "farming": plan.farming.workers_needed, + "milling": plan.milling.workers_needed, + "baking": plan.baking.workers_needed, + } + + field_shelters = Building.objects.filter( + population_centre=centre, building_type="field_shelter" + ).order_by("id") + for building in field_shelters: + processed_building_ids.add(building.id) + target = min(remaining["farming"], MAX_WORKERS_PER_BUILDING) + assigned_count = self.fill_building(building, target, assigned_ids) + remaining["farming"] -= assigned_count + + capability_buildings = ( + Building.objects.filter( + population_centre=centre, + capabilities__activity__in=ECONOMY_ROLE_ACTIVITIES, + ) + .distinct() + .order_by("id") + .prefetch_related("capabilities") + ) + for building in capability_buildings: + processed_building_ids.add(building.id) + activities = [ + capability.activity + for capability in building.capabilities.all() + if capability.activity in ECONOMY_ROLE_ACTIVITIES ] - if not eligible: + if not activities: continue - slots = random.randint(MIN_WORKERS_PER_BUILDING, MAX_WORKERS_PER_BUILDING) - workers = random.sample(eligible, min(slots, len(eligible))) + # A present worker counts fully toward every activity their + # building holds at once (see capacity_services. + # worker_capacity_present, called independently per tick) - so + # a shared (e.g. "communal") building's target is the largest + # of its activities' remaining demand, not their sum, and + # filling it counts against every activity it holds, not just + # one. + target = min( + max(remaining[a] for a in activities), MAX_WORKERS_PER_BUILDING + ) + assigned_count = self.fill_building(building, target, assigned_ids) + for activity in activities: + remaining[activity] = max(0, remaining[activity] - assigned_count) - for char in workers: - char.assign_work(building) - assigned_ids.add(char.id) - self.stdout.write( - f"{char.name} now works at {building.name} (ID {building.id})" + def fill_building(self, building, target_count, assigned_ids): + if target_count <= 0: + return 0 + if not building.nodes.exists(): + self.stdout.write( + self.style.WARNING( + f"Building {building.id} has no nodes – skipping work assignment" ) + ) + return 0 - self.stdout.write(self.style.SUCCESS("Workers have been assigned")) + eligible = self.eligible_characters(building.population_centre, assigned_ids) + if not eligible: + return 0 + + workers = random.sample(eligible, min(target_count, len(eligible))) + for char in workers: + char.assign_work(building) + assigned_ids.add(char.id) + self.stdout.write( + f"{char.name} now works at {building.name} (ID {building.id})" + ) + return len(workers) + + def eligible_characters(self, population_centre, assigned_ids): + return [ + char + for char in Character.objects.filter( + population_centre=population_centre, + locations__role=CharacterLocation.Role.HOME, + locations__is_primary=True, + ) + if char.id not in assigned_ids + and MIN_WORKING_AGE <= (char.get_age() // 365) <= MAX_WORKING_AGE + ] + + def assign_fallback_workers(self, assigned_ids, processed_building_ids): + work_buildings = Building.objects.filter( + building_type__in=WORK_BUILDING_TYPES + ).exclude(id__in=processed_building_ids) + + for building in work_buildings: + slots = random.randint(MIN_WORKERS_PER_BUILDING, MAX_WORKERS_PER_BUILDING) + self.fill_building(building, slots, assigned_ids) diff --git a/locations/management/commands/generate_fields.py b/locations/management/commands/generate_fields.py index 1501f607..a425b420 100644 --- a/locations/management/commands/generate_fields.py +++ b/locations/management/commands/generate_fields.py @@ -7,13 +7,13 @@ from economy.models import FieldCrop from locations.models import Building, Node, Subzone -from locations.management.commands.spawn_villages import ( +from locations.management.commands.generate_villages import ( compute_building_entrance_point, create_building_footprint, ) # Shelter buildings are a small work-site, not the field itself - sized like -# a house (matches spawn_villages' residential footprint range), not the +# a house (matches generate_villages' residential footprint range), not the # crops Subzone's own (much larger) area. SHELTER_MIN_SIZE = 10 SHELTER_MAX_SIZE = 25 diff --git a/locations/management/commands/generate_landarea.py b/locations/management/commands/generate_landarea.py index 64e6d360..19fb1f9d 100644 --- a/locations/management/commands/generate_landarea.py +++ b/locations/management/commands/generate_landarea.py @@ -18,7 +18,7 @@ MIN_MARGIN_BEYOND_BOUNDARY = 10 MAX_MARGIN_BEYOND_BOUNDARY = 40 -# Same jitter magnitude used for building footprints (spawn_villages.py's +# Same jitter magnitude used for building footprints (generate_villages.py's # create_building_footprint irregularity param) - reused as-is for v1 rather # than tuning a separate constant, per issue #656. FIELD_IRREGULARITY = 0.15 diff --git a/locations/management/commands/generate_paths.py b/locations/management/commands/generate_paths.py index c6234e4d..6ce10a83 100644 --- a/locations/management/commands/generate_paths.py +++ b/locations/management/commands/generate_paths.py @@ -151,11 +151,13 @@ def reroute_paths_around_buildings(self, paths, centre): Not full pathfinding - a light heuristic, since character movement doesn't use these paths (visual only, issue #656). """ - footprints = list( - Building.objects.filter( + footprints = [ + fp + for fp in Building.objects.filter( population_centre=centre, footprint__isnull=False ).values_list("footprint", flat=True) - ) + if fp is not None + ] if not footprints: return diff --git a/locations/management/commands/spawn_villages.py b/locations/management/commands/generate_villages.py similarity index 85% rename from locations/management/commands/spawn_villages.py rename to locations/management/commands/generate_villages.py index 151484b6..b331a0cb 100644 --- a/locations/management/commands/spawn_villages.py +++ b/locations/management/commands/generate_villages.py @@ -2,13 +2,26 @@ import random from django.core.management.base import BaseCommand from django.contrib.gis.geos import Point, Polygon, MultiPolygon +from economy.models import BuildingCapability +from economy.services.planning_services import settlement_plan from locations.models import PopulationCentre, Building, InteriorSpace, Node, Path +from locations.services import population_estimation from locations.utils import perturb_quad_corners, rotate_point from locations.village_names import VILLAGE_NAMES from math import sqrt SPECIAL_BUILDINGS = ["granary", "inn", "mill", "bakery", "communal"] +# Which of SPECIAL_BUILDINGS get a BuildingCapability row - granary/inn/ +# communal aren't labor-capped production activities (see +# economy.models.BuildingCapability's docstring); mill/bakery are, and +# without this the mill/bakery buildings this command creates would be +# invisible to capacity_services.find_mill/find_bakery, which look up +# capabilities__activity rather than building_type. +SPECIAL_BUILDING_CAPABILITIES = { + "mill": BuildingCapability.Activity.MILLING, + "bakery": BuildingCapability.Activity.BAKING, +} RESIDENTIAL_PER_VILLAGE = 5 IRREGULARITY = 0 BUILDING_BUFFER = 2 @@ -331,6 +344,11 @@ def handle(self, *args, **options): footprint=footprint, population_centre=None, ) + activity = SPECIAL_BUILDING_CAPABILITIES.get(building_type) + if activity: + BuildingCapability.objects.create( + building=building, activity=activity + ) placed_building_points.append(building_point) created_buildings.append(building) @@ -369,17 +387,34 @@ def handle(self, *args, **options): "kind": Node.Kind.BUILDING, }, ) - entrance_point = compute_building_entrance_point( - building.footprint, building.location - ) - Node.objects.get_or_create( - building=building, - kind=Node.Kind.BUILDING_ENTRANCE, - defaults={ - "name": f"Entrance for {building.name}", - "location": entrance_point, - }, - ) + if building.building_type != "granary": + entrance_point = compute_building_entrance_point( + building.footprint, building.location + ) + Node.objects.get_or_create( + building=building, + kind=Node.Kind.BUILDING_ENTRANCE, + defaults={ + "name": f"Entrance for {building.name}", + "location": entrance_point, + }, + ) + + # Compute-and-log only for now (see population_estimation's + # module docstring and + # .claude/plans/village-capacity-sizing-plan.md step 3) - this + # doesn't yet change which buildings get created. It's here to + # validate the recommended plan against real generated villages + # before generation behaviour changes in a later step. + estimated_population = population_estimation.starting_population(centre) + recommended_plan = settlement_plan(population=estimated_population) + self.stdout.write( + f" Estimated starting population {estimated_population} -> " + f"recommended plan (granaries={recommended_plan.recommended_granaries}, " + f"milling buildings={recommended_plan.milling.recommended_buildings}, " + f"baking buildings={recommended_plan.baking.recommended_buildings}, " + f"farming buildings={recommended_plan.farming.recommended_buildings})" + ) if not existing_centre: centres_positions.append(new_point) diff --git a/locations/management/commands/place_characters.py b/locations/management/commands/place_characters.py index a68d8c1a..c2e7f21f 100644 --- a/locations/management/commands/place_characters.py +++ b/locations/management/commands/place_characters.py @@ -70,7 +70,7 @@ def handle(self, *args, **options): # character; group_population_centre records, per family group, the # population centre its first-placed member landed in. family_groups = relationship_services.relationship_get_family_groups(characters) - group_population_centre = {} + group_population_centre: dict[int, int] = {} for char in characters: available = [b for b in buildings if occupancy[b.id] < max_per_building] diff --git a/locations/management/commands/populate_interiors.py b/locations/management/commands/populate_interiors.py index e4f103a7..07f8b2d2 100644 --- a/locations/management/commands/populate_interiors.py +++ b/locations/management/commands/populate_interiors.py @@ -98,7 +98,7 @@ def handle(self, *args, **options): p.population_centre = building.population_centre Path.objects.bulk_update(paths, ["population_centre"]) - if not entrance_node and nodes: + if not entrance_node and nodes and building.building_type != "granary": self.stdout.write( self.style.WARNING( f"Building {building.name} has no entrance node; interior nodes not connected" diff --git a/locations/management/commands/seed_village_view.py b/locations/management/commands/seed_village_view.py index 0094d57d..1461c5d9 100644 --- a/locations/management/commands/seed_village_view.py +++ b/locations/management/commands/seed_village_view.py @@ -11,7 +11,7 @@ class Command(BaseCommand): def handle(self, *args, **options): self.stdout.write("=== Spawning village ===") - call_command("spawn_villages", num_centres=1) + call_command("generate_villages", num_centres=1) self.stdout.write("=== Generating fields ===") # Must run before generate_paths, which needs the field's entrance diff --git a/locations/management/commands/setup_world.py b/locations/management/commands/setup_world.py index 3af0ed08..506d5b56 100644 --- a/locations/management/commands/setup_world.py +++ b/locations/management/commands/setup_world.py @@ -33,7 +33,7 @@ def handle(self, *args, **options): call_command("import_villages") else: self.stdout.write("=== Spawning villages ===") - call_command("spawn_villages", num_centres=1) + call_command("generate_villages", num_centres=1) self.stdout.write("=== Generating characters ===") # Must run after buildings exist (residential capacity drives how @@ -54,7 +54,7 @@ def handle(self, *args, **options): self.stdout.write("=== Generating land areas ===") # Must run before generate_fields, which attaches each FieldCrop to # the "crops" Subzone this creates - only needs boundary/location/ - # residents, all already set by spawn_villages. + # residents, all already set by generate_villages. call_command("generate_landarea") # Unlike generate_landarea, generate_fields is safe (and needed) for diff --git a/locations/models.py b/locations/models.py index 73da4019..30b77cf9 100644 --- a/locations/models.py +++ b/locations/models.py @@ -295,6 +295,11 @@ class Journey(models.Model): finished_at = models.DateTimeField(null=True, blank=True) status = models.CharField(max_length=20, default="active") # e.g., active, complete + # Transient, non-persisted cache of {node_id: Node}, set by callers that + # batch-fetch nodes across many journeys (e.g. move_characters_tick) to + # avoid a per-call query in _get_node. Not a model field. + _node_cache: dict[int, "Node"] | None = None + @property def is_complete(self): return self.status == "complete" diff --git a/locations/serializers.py b/locations/serializers.py index 005ce295..167b24c9 100644 --- a/locations/serializers.py +++ b/locations/serializers.py @@ -124,6 +124,18 @@ def _active_journey(self, obj): return journeys[0] if journeys else None return obj.journeys.filter(status="active").first() + def _building_for_node(self, node): + # Node.building and Node.interior_space are mutually exclusive (see + # the node_building_or_interior constraint) - an indoor node (kind + # INTERIOR) only sets interior_space, so building alone misses it. + # InteriorSpace.building is required, so this is the full building + # for any node the character could actually be standing at. + if node is None: + return None + return node.building or ( + node.interior_space.building if node.interior_space else None + ) + def _current_activity_name(self, obj): # current_activity_list is a Prefetch (see PopulationCentreMapView/ # MapViewportView) filtered to the one CharacterActivity active right @@ -152,6 +164,16 @@ def get_properties(self, obj): ) ] + # current_building/destination building_type feed the map tooltip's + # "[Activity] at [building]" / "Walking to [building]" copy - None + # means the node has no building (tooltip reads "outside" instead). + current_building = self._building_for_node(obj.current_node) + destination_building = ( + self._building_for_node(journey.destination_node) + if journey is not None + else None + ) + return { "id": obj.id, "name": obj.name, @@ -164,6 +186,12 @@ def get_properties(self, obj): "is_moving": obj.is_moving, "effective_speed": obj.movement_speed, "path": path, + "current_location_type": ( + current_building.building_type if current_building else None + ), + "destination_location_type": ( + destination_building.building_type if destination_building else None + ), } diff --git a/locations/services/schedule.py b/locations/services/schedule.py index 8e990d39..c2fffbba 100644 --- a/locations/services/schedule.py +++ b/locations/services/schedule.py @@ -21,18 +21,17 @@ def _stagger_offset_seconds(character_id: int) -> int: return (character_id % span) - MAX_STAGGER_SECONDS -def target_role_for(character, now=None) -> str: - """Which role (home/work) a character should currently be at. - - The work window comes from the character's assigned work building's - open_time/close_time if set, else falls back to the fixed WORK_START/ - WORK_END constants. A per-character stagger is applied to whichever - window is resolved, so the whole village doesn't flip in lockstep.""" +def work_hours_for(character) -> tuple[time, time]: + """The (open, close) window a character should be at work, from their + assigned work building's open_time/close_time if set, else the fixed + WORK_START/WORK_END constants. Shared by target_role_for (drives + physical movement) and behaviour_services.generate_day (drives the + scheduled CharacterActivity blocks), so a character's actual work + building's hours - e.g. an inn open until 23:00 - govern both rather + than generate_day assuming a fixed 8-17 workday that leaves late + building hours showing as an unrelated leisure/"Relaxing" block.""" from character.models import CharacterLocation - now = now or timezone.localtime() - seconds_since_midnight = now.hour * 3600 + now.minute * 60 + now.second - work_start, work_end = WORK_START, WORK_END work_location = ( CharacterLocation.objects.filter( @@ -45,6 +44,22 @@ def target_role_for(character, now=None) -> str: building = work_location.location if building.open_time is not None and building.close_time is not None: work_start, work_end = building.open_time, building.close_time + return work_start, work_end + + +def target_role_for(character, now=None) -> str: + """Which role (home/work) a character should currently be at. + + The work window comes from the character's assigned work building's + open_time/close_time if set, else falls back to the fixed WORK_START/ + WORK_END constants. A per-character stagger is applied to whichever + window is resolved, so the whole village doesn't flip in lockstep.""" + from character.models import CharacterLocation + + now = now or timezone.localtime() + seconds_since_midnight = now.hour * 3600 + now.minute * 60 + now.second + + work_start, work_end = work_hours_for(character) offset = _stagger_offset_seconds(character.id) work_start_seconds = work_start.hour * 3600 + work_start.minute * 60 + offset diff --git a/locations/services/watabou_import.py b/locations/services/watabou_import.py index b6643e19..567992db 100644 --- a/locations/services/watabou_import.py +++ b/locations/services/watabou_import.py @@ -13,7 +13,8 @@ locations/ already uses (see MAX_BBOX_AREA_SQ_M in locations/utils.py). This only creates static geometry - PopulationCentre, Building, Road, the -Node graph's CENTRE/BUILDING/BUILDING_ENTRANCE points, and (if the export +Node graph's CENTRE/BUILDING points plus BUILDING_ENTRANCE for non-granary +buildings, and (if the export has a "fields" feature) a LandArea/Subzone pair per field polygon. It deliberately does not generate Path edges: Path is the movement/pathfinding graph and Road is just the drawn street, so wiring the graph is left to the @@ -22,13 +23,21 @@ walkable edges from arbitrary imported road geometry. """ +import logging +from typing import cast + from django.contrib.gis.geos import LineString, Point, Polygon from django.db import transaction -from locations.management.commands.spawn_villages import ( +from economy.models import BuildingCapability +from economy.services.planning_services import settlement_plan +from locations.management.commands.generate_villages import ( compute_building_entrance_point, ) from locations.models import Building, LandArea, Node, PopulationCentre, Road, Subzone +from locations.services import population_estimation + +logger = logging.getLogger("general") # GEOS areas in this module are in SRID 3857 coordinates, which - per the # "just metres, no real georeferencing" convention described above - are @@ -42,28 +51,123 @@ DEFAULT_ROAD_WIDTH = 6.0 # watabou doesn't tag buildings with a structured type, so one is assigned -# per import: ~75% of buildings become "residential", then one each of the -# "special" (work) types below is assigned from the remainder, in this -# fixed order. There's no catch-all type for anything left over once every -# special type has its one instance - those buildings become "residential" +# per import: ~75% of buildings become "residential"; the remainder is +# split between the always-present economy chain (granary, milling, +# baking - see _assign_building_types_and_capabilities) and these purely +# decorative types, which fill any slots left over after the economy chain +# is satisfied. There's no catch-all type for anything left over once every +# type here has its one instance - those buildings become "residential" # too. RESIDENTIAL_BUILDING_RATIO = 0.75 -SPECIAL_BUILDING_TYPES = ["granary", "inn", "mill", "bakery", "market", "hall"] +OPTIONAL_BUILDING_TYPES = ["inn", "market", "hall"] -def _assign_building_types(count: int) -> list[str]: - residential_count = round(count * RESIDENTIAL_BUILDING_RATIO) +def _polygon_area(polygon_coords) -> float: + """ + Area of a raw (untranslated) watabou building polygon - translation + doesn't affect area, so this works ahead of picking an origin/offset, + unlike _translate_polygon. + """ + return Polygon(*(_close_ring(ring) for ring in polygon_coords)).area + +def _assign_building_types_and_capabilities( + building_coordinates: list, +) -> tuple[list[str], dict[int, list[BuildingCapability.Activity]]]: + """ + Decide each imported building's building_type, and which + BuildingCapability activities (if any) it should get - driven by + settlement_plan instead of a fixed one-of-each-special-type list (see + .claude/plans/village-capacity-sizing-plan.md step 4). Returns + (building_types, capabilities_by_index), the second a map from index + in building_types/building_coordinates to a list of + economy.models.BuildingCapability.Activity values to attach. + + Granary/milling/baking are guaranteed a slot ahead of the purely + decorative OPTIONAL_BUILDING_TYPES, since they're the always_present + economy chain (see planning_services._recommended_buildings). Milling + and baking are packed onto a single shared "communal" building + whenever the settlement is small (plan.combine_milling_and_baking - + see SMALL_SETTLEMENT_POPULATION_THRESHOLD) even if there'd be enough + slots for two dedicated buildings, and as a fallback whenever there + genuinely isn't room for two regardless of population - this is what + actually fixes the original Ashenford bug (a small village silently + missing a bakery because the old fixed-order allocation ran out of + slots before reaching it). + + The population figure fed to settlement_plan is a rough pre-creation + estimate (from the footprint areas of whichever buildings this same + ratio split would leave residential), not the more accurate post- + creation estimate logged at the end of import_watabou_village - good + enough here since it only ever changes *how many* buildings each role + recommends (always exactly 1 today - see _recommended_buildings' "no + per-building labor cap yet" note), not whether it's needed at all. + """ + count = len(building_coordinates) + residential_count = round(count * RESIDENTIAL_BUILDING_RATIO) remaining = count - residential_count - types = [] - for building_type in SPECIAL_BUILDING_TYPES: + + residential_areas = [ + _polygon_area(coords) + for coords in ( + building_coordinates[-residential_count:] if residential_count else [] + ) + ] + estimated_population = ( + population_estimation.estimate_population_from_footprint_areas( + residential_areas + ) + ) + plan = settlement_plan(population=estimated_population) + + building_types: list[str] = [] + capabilities_by_index: dict[int, list[BuildingCapability.Activity]] = {} + + if remaining > 0 and plan.recommended_granaries > 0: + building_types.append("granary") + remaining -= 1 + + needs_milling = plan.milling.recommended_buildings > 0 + needs_baking = plan.baking.recommended_buildings > 0 + if needs_milling and needs_baking: + combine = plan.combine_milling_and_baking or remaining < 2 + if combine and remaining >= 1: + capabilities_by_index[len(building_types)] = [ + BuildingCapability.Activity.MILLING, + BuildingCapability.Activity.BAKING, + ] + building_types.append("communal") + remaining -= 1 + elif remaining >= 2: + capabilities_by_index[len(building_types)] = [ + BuildingCapability.Activity.MILLING + ] + building_types.append("mill") + remaining -= 1 + capabilities_by_index[len(building_types)] = [ + BuildingCapability.Activity.BAKING + ] + building_types.append("bakery") + remaining -= 1 + elif remaining >= 1 and (needs_milling or needs_baking): + activities = [] + if needs_milling: + activities.append(BuildingCapability.Activity.MILLING) + if needs_baking: + activities.append(BuildingCapability.Activity.BAKING) + capabilities_by_index[len(building_types)] = activities + building_types.append("communal") + remaining -= 1 + + for building_type in OPTIONAL_BUILDING_TYPES: if remaining <= 0: break - types.append(building_type) + building_types.append(building_type) remaining -= 1 - types.extend(["residential"] * (residential_count + remaining)) - return types + building_types.extend(["residential"] * (residential_count + remaining)) + + return building_types, capabilities_by_index def _feature_by_id(data: dict, feature_id: str) -> dict | None: @@ -113,8 +217,12 @@ def _import_fields( combined = polygons[0] for polygon in polygons[1:]: - combined = combined.union(polygon) - boundary = combined if combined.geom_type == "Polygon" else combined.convex_hull + combined = cast(Polygon, combined.union(polygon)) + boundary = ( + combined + if combined.geom_type == "Polygon" + else cast(Polygon, combined.convex_hull) + ) land_area = LandArea.objects.create( name=f"Fields of ({population_centre.name})", @@ -162,11 +270,11 @@ def import_watabou_village(data: dict, *, name: str, origin: Point) -> Populatio if district_polygons: raw_boundary = district_polygons[0] for polygon in district_polygons[1:]: - raw_boundary = raw_boundary.union(polygon) + raw_boundary = cast(Polygon, raw_boundary.union(polygon)) if raw_boundary.geom_type != "Polygon": # Districts aren't guaranteed to touch - fall back to their # convex hull so the boundary stays a single Polygon. - raw_boundary = raw_boundary.convex_hull + raw_boundary = cast(Polygon, raw_boundary.convex_hull) elif earth_feature: raw_boundary = Polygon(_close_ring(earth_feature["coordinates"][0])) else: @@ -189,20 +297,36 @@ def import_watabou_village(data: dict, *, name: str, origin: Point) -> Populatio ) building_coordinates = buildings_feature.get("coordinates", []) - building_types = _assign_building_types(len(building_coordinates)) + building_types, capabilities_by_index = _assign_building_types_and_capabilities( + building_coordinates + ) + residential_index = 0 for i, (polygon_coords, building_type) in enumerate( zip(building_coordinates, building_types) ): footprint = _translate_polygon(polygon_coords, offset) + # Mirrors generate_villages' naming (residential buildings numbered, + # every other type unique per village so its capitalized type name + # alone is unambiguous) - see BUILDING_TYPE_LABELS in geojson.tsx, + # which the frontend used to derive this same label from + # building_type before this became the stored name directly. + if building_type == "residential": + residential_index += 1 + building_name = f"House {residential_index}" + else: + building_name = building_type.capitalize() + building = Building.objects.create( - name=f"Building {i + 1} of ({name})", + name=building_name, building_type=building_type, location=footprint.centroid, footprint=footprint, population_centre=population_centre, ) + for activity in capabilities_by_index.get(i, []): + BuildingCapability.objects.create(building=building, activity=activity) Node.objects.get_or_create( building=building, kind=Node.Kind.BUILDING, @@ -211,17 +335,18 @@ def import_watabou_village(data: dict, *, name: str, origin: Point) -> Populatio "location": building.location, }, ) - entrance_point = compute_building_entrance_point( - building.footprint, building.location - ) - Node.objects.get_or_create( - building=building, - kind=Node.Kind.BUILDING_ENTRANCE, - defaults={ - "name": f"Entrance for {building.name}", - "location": entrance_point, - }, - ) + if building.building_type != "granary": + entrance_point = compute_building_entrance_point( + building.footprint, building.location + ) + Node.objects.get_or_create( + building=building, + kind=Node.Kind.BUILDING_ENTRANCE, + defaults={ + "name": f"Entrance for {building.name}", + "location": entrance_point, + }, + ) for geometry in roads_feature.get("geometries", []): if geometry.get("type") != "LineString": @@ -235,4 +360,23 @@ def import_watabou_village(data: dict, *, name: str, origin: Point) -> Populatio if fields_feature and fields_feature.get("type") == "MultiPolygon": _import_fields(fields_feature, population_centre, offset) + # Compute-and-log only for now (see population_estimation's module + # docstring and .claude/plans/village-capacity-sizing-plan.md step 3) - + # this doesn't yet change which buildings get created. It's here to + # validate the recommended plan against real imported village files + # before generation behaviour changes in a later step. + estimated_population = population_estimation.starting_population(population_centre) + recommended_plan = settlement_plan(population=estimated_population) + logger.info( + "%s: estimated starting population %s -> recommended plan " + "(granaries=%s, milling buildings=%s, baking buildings=%s, " + "farming buildings=%s)", + population_centre.name, + estimated_population, + recommended_plan.recommended_granaries, + recommended_plan.milling.recommended_buildings, + recommended_plan.baking.recommended_buildings, + recommended_plan.farming.recommended_buildings, + ) + return population_centre diff --git a/locations/tasks.py b/locations/tasks.py index 94f2b02c..8f0dbc6d 100644 --- a/locations/tasks.py +++ b/locations/tasks.py @@ -187,8 +187,8 @@ def commute_tick(): @shared_task -def spawn_villages_task(): - call_command("spawn_villages") +def generate_villages_task(): + call_command("generate_villages") @shared_task diff --git a/locations/tests/factories.py b/locations/tests/factories.py index 88528c8f..b1782c49 100644 --- a/locations/tests/factories.py +++ b/locations/tests/factories.py @@ -1,6 +1,6 @@ from django.contrib.gis.geos import Point, Polygon -from locations.management.commands.spawn_villages import create_building_footprint +from locations.management.commands.generate_villages import create_building_footprint from locations.models import Building, PopulationCentre # Shared village boundary used by tests that need a character to move/wander @@ -11,7 +11,7 @@ def make_centre_with_building(name, centre_point: Point) -> PopulationCentre: - """Minimal PopulationCentre + one Building, boundary sized like spawn_villages.""" + """Minimal PopulationCentre + one Building, boundary sized like generate_villages.""" footprint = create_building_footprint(centre_point, min_size=10, max_size=20) boundary = footprint.buffer(10) centre = PopulationCentre.objects.create( diff --git a/locations/tests/test_assign_workers.py b/locations/tests/test_assign_workers.py new file mode 100644 index 00000000..cb681d3b --- /dev/null +++ b/locations/tests/test_assign_workers.py @@ -0,0 +1,148 @@ +from datetime import date, timedelta + +from django.contrib.gis.geos import Point +from django.core.management import call_command +from django.test import TestCase + +from character.models import Character, CharacterLocation +from economy.models import BuildingCapability +from locations.management.commands.generate_villages import create_building_footprint +from locations.models import Building, PopulationCentre + +WORKING_AGE_BIRTH_DATE = date.today() - timedelta(days=25 * 365) + + +def _make_centre(name="Testville"): + return PopulationCentre.objects.create(name=name, location=Point(0, 0, srid=3857)) + + +def _make_building(centre, building_type, x, *, activities=()): + footprint = create_building_footprint( + Point(x, 0, srid=3857), min_size=5, max_size=10 + ) + building = Building.objects.create( + name=f"{building_type} at {x}", + building_type=building_type, + location=Point(x, 0, srid=3857), + footprint=footprint, + population_centre=centre, + ) + for activity in activities: + BuildingCapability.objects.create(building=building, activity=activity) + Node = building.nodes.model + Node.objects.create( + name=f"Node for {building.name}", + location=building.location, + kind=Node.Kind.BUILDING, + building=building, + ) + return building + + +def _make_resident(centre, index): + character = Character.objects.create( + given_name=f"Resident{index}", + birth_date=WORKING_AGE_BIRTH_DATE, + population_centre=centre, + location=centre.location, + ) + home_building = Building.objects.filter( + population_centre=centre, building_type="residential" + ).first() + if home_building is None: + home_building = _make_building(centre, "residential", 0) + CharacterLocation.objects.create( + character=character, + location=home_building, + role=CharacterLocation.Role.HOME, + is_primary=True, + ) + return character + + +class AssignWorkersGranaryTests(TestCase): + def test_granary_never_gets_workers(self): + centre = _make_centre() + granary = _make_building(centre, "granary", 10) + for i in range(5): + _make_resident(centre, i) + + call_command("assign_workers") + + self.assertFalse( + CharacterLocation.objects.filter( + location=granary, role=CharacterLocation.Role.WORK + ).exists() + ) + + +class AssignWorkersEconomyRoleTests(TestCase): + def test_fills_first_building_before_second_for_same_role(self): + centre = _make_centre() + mill1 = _make_building(centre, "mill", 10, activities=["milling"]) + mill2 = _make_building(centre, "mill", 20, activities=["milling"]) + for i in range(10): + _make_resident(centre, i) + + call_command("assign_workers") + + mill1_workers = CharacterLocation.objects.filter( + location=mill1, role=CharacterLocation.Role.WORK + ).count() + mill2_workers = CharacterLocation.objects.filter( + location=mill2, role=CharacterLocation.Role.WORK + ).count() + + # mill1 (lower id, processed first) should be filled to its cap + # before mill2 gets anyone - a greedy fill, not an even split. + self.assertGreaterEqual(mill1_workers, mill2_workers) + self.assertLessEqual(mill1_workers, 3) + + def test_communal_building_gets_workers_for_both_capabilities(self): + centre = _make_centre() + communal = _make_building( + centre, "communal", 10, activities=["milling", "baking"] + ) + for i in range(10): + _make_resident(centre, i) + + call_command("assign_workers") + + self.assertTrue( + CharacterLocation.objects.filter( + location=communal, role=CharacterLocation.Role.WORK + ).exists() + ) + + def test_village_with_too_few_eligible_workers_ends_up_understaffed(self): + centre = _make_centre() + _make_building(centre, "mill", 10, activities=["milling"]) + _make_building(centre, "bakery", 20, activities=["baking"]) + # Only one eligible worker for a village whose settlement_plan will + # recommend more - this is the "struggling at spawn" case: fewer + # workers assigned than settlement_plan would want, not an evenly + # thin spread across every building. + _make_resident(centre, 0) + + call_command("assign_workers") + + total_assigned = CharacterLocation.objects.filter( + location__population_centre=centre, role=CharacterLocation.Role.WORK + ).count() + self.assertLessEqual(total_assigned, 1) + + +class AssignWorkersFallbackTests(TestCase): + def test_non_economy_building_gets_flat_random_workers(self): + centre = _make_centre() + inn = _make_building(centre, "inn", 10) + for i in range(6): + _make_resident(centre, i) + + call_command("assign_workers") + + inn_workers = CharacterLocation.objects.filter( + location=inn, role=CharacterLocation.Role.WORK + ).count() + self.assertGreaterEqual(inn_workers, 2) + self.assertLessEqual(inn_workers, 3) diff --git a/locations/tests/test_character_serializers.py b/locations/tests/test_character_serializers.py index 4be381fd..b7bbd1b8 100644 --- a/locations/tests/test_character_serializers.py +++ b/locations/tests/test_character_serializers.py @@ -1,7 +1,7 @@ from django.contrib.gis.geos import Point from django.test import TestCase -from locations.models import Node, Path, Journey +from locations.models import Building, InteriorSpace, Node, Path, Journey from locations.serializers import ( CharacterPointFeatureSerializer, JOURNEY_PATH_PREVIEW_LIMIT, @@ -85,3 +85,113 @@ def test_path_is_capped_to_preview_limit(self): props = CharacterPointFeatureSerializer(character).data["properties"] self.assertEqual(len(props["path"]), JOURNEY_PATH_PREVIEW_LIMIT) + + +class CharacterPointFeatureSerializerLocationTypeTest(TestCase): + """current_location_type/destination_location_type feed the map + tooltip's "[Activity] at [building]" / "Walking to [building]" copy + (issue: concise contextual character tooltip) - None means the node + isn't inside a building at all, so the tooltip reads "outside".""" + + def setUp(self): + self.bakery = Building.objects.create( + name="Bakery 1", building_type="bakery", location=Point(0, 0, srid=3857) + ) + self.building_node = Node.objects.create( + name="Bakery entrance", + location=Point(0, 0, srid=3857), + building=self.bakery, + ) + self.outside_node = Node.objects.create( + name="Field", location=Point(50, 50, srid=3857) + ) + + def test_current_location_type_reflects_the_building_the_character_is_in(self): + character = Character.objects.create( + given_name="Baker", + location=Point(0, 0, srid=3857), + current_node=self.building_node, + ) + + props = CharacterPointFeatureSerializer(character).data["properties"] + + self.assertEqual(props["current_location_type"], "bakery") + + def test_current_location_type_reflects_the_building_of_an_interior_node(self): + # Interior nodes (kind=INTERIOR) only set interior_space, not + # building directly (Node.building/interior_space are mutually + # exclusive - see the node_building_or_interior constraint), so a + # character in a room deep inside the bakery still needs to resolve + # back to that building rather than reading as "outside". + kitchen = InteriorSpace.objects.create( + name="Kitchen", building=self.bakery, area=20, usage="kitchen" + ) + interior_node = Node.objects.create( + name="Kitchen floor", + location=Point(0, 0, srid=3857), + interior_space=kitchen, + ) + character = Character.objects.create( + given_name="Kneader", + location=Point(0, 0, srid=3857), + current_node=interior_node, + ) + + props = CharacterPointFeatureSerializer(character).data["properties"] + + self.assertEqual(props["current_location_type"], "bakery") + + def test_current_location_type_is_none_when_the_character_is_outside(self): + character = Character.objects.create( + given_name="Forager", + location=Point(50, 50, srid=3857), + current_node=self.outside_node, + ) + + props = CharacterPointFeatureSerializer(character).data["properties"] + + self.assertIsNone(props["current_location_type"]) + + def test_destination_location_type_reflects_the_journeys_destination_building( + self, + ): + character = Character.objects.create( + given_name="Walker", + location=Point(50, 50, srid=3857), + current_node=self.outside_node, + is_moving=True, + ) + Journey.objects.create( + character=character, + start_node=self.outside_node, + destination_node=self.building_node, + path_nodes=[self.outside_node.pk, self.building_node.pk], + current_index=0, + status="active", + ) + + props = CharacterPointFeatureSerializer(character).data["properties"] + + self.assertEqual(props["destination_location_type"], "bakery") + + def test_destination_location_type_is_none_when_walking_to_a_spot_with_no_building( + self, + ): + character = Character.objects.create( + given_name="Wanderer", + location=Point(0, 0, srid=3857), + current_node=self.building_node, + is_moving=True, + ) + Journey.objects.create( + character=character, + start_node=self.building_node, + destination_node=self.outside_node, + path_nodes=[self.building_node.pk, self.outside_node.pk], + current_index=0, + status="active", + ) + + props = CharacterPointFeatureSerializer(character).data["properties"] + + self.assertIsNone(props["destination_location_type"]) diff --git a/locations/tests/test_generate_fields.py b/locations/tests/test_generate_fields.py index ffcb7a41..9eb54726 100644 --- a/locations/tests/test_generate_fields.py +++ b/locations/tests/test_generate_fields.py @@ -5,7 +5,7 @@ from django.test import TestCase from locations.management.commands.generate_fields import SHELTER_MIN_SPACING -from locations.management.commands.spawn_villages import create_building_footprint +from locations.management.commands.generate_villages import create_building_footprint from locations.models import Node, Building, LandArea, Subzone from locations.tests.factories import make_centre_with_building from economy.models import FieldCrop diff --git a/locations/tests/test_spawn_villages.py b/locations/tests/test_generate_villages.py similarity index 94% rename from locations/tests/test_spawn_villages.py rename to locations/tests/test_generate_villages.py index f4a81d96..25fbd2d1 100644 --- a/locations/tests/test_spawn_villages.py +++ b/locations/tests/test_generate_villages.py @@ -4,7 +4,7 @@ from django.core.management import call_command from django.test import TestCase -from locations.management.commands.spawn_villages import ( +from locations.management.commands.generate_villages import ( BUILDING_ZONES, create_building_footprint, ) @@ -42,7 +42,7 @@ def test_zero_rotation_keeps_axis_aligned_footprint(self): class SpawnVillagesZoneGroupingTest(TestCase): def test_same_zone_buildings_cluster_closer_than_different_zone_buildings(self): random.seed(42) - call_command("spawn_villages", num_centres=1, grid_size=5000, min_distance=3) + call_command("generate_villages", num_centres=1, grid_size=5000, min_distance=3) centre = PopulationCentre.objects.get() buildings = list(centre.buildings.all()) diff --git a/locations/tests/test_map_serializers.py b/locations/tests/test_map_serializers.py index 6208a2dc..824d87ab 100644 --- a/locations/tests/test_map_serializers.py +++ b/locations/tests/test_map_serializers.py @@ -6,6 +6,8 @@ from economy.models import FieldCrop, GoodsStock from progression.models import ActivityDefinition, CharacterActivity +from users.tests import user_factory + from ..models import Building, LandArea, PopulationCentre, Subzone from ..serializers import ( BuildingFeatureSerializer, @@ -371,14 +373,11 @@ def test_includes_state_and_progress_with_no_residents(self): def test_reflects_village_points_derived_state(self): from character.models import Character, PlayerCharacterLink - from users.models import CustomUser resident = Character.objects.create( given_name="Res", population_centre=self.centre ) - user = CustomUser.objects.create_user( - email="villager@example.com", password="x" - ) + user = user_factory(with_player=True) # Deactivate the auto-assigned link/character so only `resident` # (with a controllable link_points via days_linked) counts here. for link in PlayerCharacterLink.objects.filter( diff --git a/locations/tests/test_watabou_import.py b/locations/tests/test_watabou_import.py index 99c47bc3..ea8dfe25 100644 --- a/locations/tests/test_watabou_import.py +++ b/locations/tests/test_watabou_import.py @@ -1,6 +1,7 @@ from django.contrib.gis.geos import Point, Polygon from django.test import TestCase +from economy.models import BuildingCapability from locations.models import LandArea, Node, PopulationCentre, Road, Subzone from locations.services.watabou_import import import_watabou_village @@ -19,6 +20,11 @@ # Building coordinates are a list of rings (one ring each here), same shape # as watabou's own "buildings" feature - not a bare list of points. TRADE_BUILDING = [[[2, 2], [8, 2], [8, 8], [2, 8]]] +# A much larger footprint than TRADE_BUILDING (100 sqm vs 36 sqm) - used +# where a test needs population_estimation to produce a population above +# SMALL_SETTLEMENT_POPULATION_THRESHOLD, since TRADE_BUILDING alone rounds +# down to 0 estimated residents per building. +LARGE_BUILDING = [[[0, 0], [10, 0], [10, 10], [0, 10]]] EARTH = {"coordinates": [[[-500, -500], [500, -500], [500, 500], [-500, 500]]]} @@ -88,8 +94,12 @@ def test_single_building_defaults_to_residential(self): self.assertEqual(building.building_type, "residential") def test_roughly_three_quarters_residential_with_one_of_each_special(self): - # 8 buildings: 75% -> 6 residential, remaining 2 -> one each of the - # first two special types (granary, inn), in fixed order. + # 8 buildings: 75% -> 6 residential, remaining 2 -> granary plus one + # shared "communal" building packing both milling and baking (only + # one non-residential slot is left once granary takes the other), + # rather than a decorative "inn" - the always-present economy chain + # is guaranteed a slot ahead of decorative types (see + # _assign_building_types_and_capabilities). data = _make_export(districts=None, buildings=[TRADE_BUILDING] * 8) origin = Point(0, 0, srid=3857) @@ -98,25 +108,109 @@ def test_roughly_three_quarters_residential_with_one_of_each_special(self): types = [b.building_type for b in centre.buildings.order_by("id")] self.assertEqual(types.count("residential"), 6) self.assertEqual(types.count("granary"), 1) - self.assertEqual(types.count("inn"), 1) - for untouched_type in ["mill", "bakery", "market", "hall", "communal"]: + self.assertEqual(types.count("communal"), 1) + for untouched_type in ["inn", "mill", "bakery", "market", "hall"]: self.assertEqual(types.count(untouched_type), 0) + communal = centre.buildings.get(building_type="communal") + activities = set(communal.capabilities.values_list("activity", flat=True)) + self.assertEqual(activities, {"milling", "baking"}) + def test_leftover_after_every_special_type_falls_back_to_residential(self): - # 30 buildings: 75% -> 22 residential (Python's round-half-to-even), - # remaining 8 -> one each of all six special types, and the 2 slots - # left over after that fold back into residential (no catch-all). + # 30 small (TRADE_BUILDING) buildings: 75% -> 22 residential + # (Python's round-half-to-even), remaining 8. TRADE_BUILDING's tiny + # footprint rounds down to 0 estimated residents per building, so + # the settlement is well under SMALL_SETTLEMENT_POPULATION_THRESHOLD + # and milling+baking still share one communal building even though + # there'd be enough slots for two dedicated ones - granary and + # communal take 2 of the 8 remaining slots, inn/market/hall take + # the next 3, and the final 3 fold back into residential (no + # catch-all). data = _make_export(districts=None, buildings=[TRADE_BUILDING] * 30) origin = Point(0, 0, srid=3857) centre = import_watabou_village(data, name="Thirty Buildings", origin=origin) types = [b.building_type for b in centre.buildings.order_by("id")] - self.assertEqual(types.count("residential"), 24) - for special_type in ["granary", "inn", "mill", "bakery", "market", "hall"]: + self.assertEqual(types.count("residential"), 25) + self.assertEqual(types.count("granary"), 1) + self.assertEqual(types.count("communal"), 1) + for special_type in ["inn", "market", "hall"]: self.assertEqual(types.count(special_type), 1) + for untouched_type in ["mill", "bakery"]: + self.assertEqual(types.count(untouched_type), 0) + + communal = centre.buildings.get(building_type="communal") + activities = set(communal.capabilities.values_list("activity", flat=True)) + self.assertEqual(activities, {"milling", "baking"}) + + def test_large_population_gets_dedicated_mill_and_bakery(self): + # 24 large (LARGE_BUILDING) buildings: 75% -> 18 residential, + # remaining 6. LARGE_BUILDING's footprint is big enough that the + # estimated population clears SMALL_SETTLEMENT_POPULATION_THRESHOLD, + # so milling and baking get dedicated buildings rather than sharing + # a communal one, even though sharing would also fit. + data = _make_export(districts=None, buildings=[LARGE_BUILDING] * 24) + origin = Point(0, 0, srid=3857) + + centre = import_watabou_village(data, name="Large Village", origin=origin) + + types = [b.building_type for b in centre.buildings.order_by("id")] + self.assertEqual(types.count("granary"), 1) + self.assertEqual(types.count("mill"), 1) + self.assertEqual(types.count("bakery"), 1) self.assertEqual(types.count("communal"), 0) + mill = centre.buildings.get(building_type="mill") + bakery = centre.buildings.get(building_type="bakery") + self.assertEqual( + list(mill.capabilities.values_list("activity", flat=True)), ["milling"] + ) + self.assertEqual( + list(bakery.capabilities.values_list("activity", flat=True)), ["baking"] + ) + + def test_small_village_packs_milling_and_baking_onto_one_communal_building(self): + # 6 buildings: 75% -> 4 residential, remaining 2 - not enough for a + # granary plus one dedicated building per role, so milling and + # baking share a single "communal" building instead of one losing + # out to a fixed allocation order (the original Ashenford bug: a + # small village silently missing a bakery). + data = _make_export(districts=None, buildings=[TRADE_BUILDING] * 6) + origin = Point(0, 0, srid=3857) + + centre = import_watabou_village(data, name="Small Village", origin=origin) + + types = [b.building_type for b in centre.buildings.order_by("id")] + self.assertEqual(types.count("residential"), 4) + self.assertEqual(types.count("granary"), 1) + self.assertEqual(types.count("communal"), 1) + for untouched_type in ["inn", "mill", "bakery", "market", "hall"]: + self.assertEqual(types.count(untouched_type), 0) + + communal = centre.buildings.get(building_type="communal") + activities = set(communal.capabilities.values_list("activity", flat=True)) + self.assertEqual(activities, {"milling", "baking"}) + + +class WatabouImportPopulationPlanLoggingTest(TestCase): + """ + Step 3 of .claude/plans/village-capacity-sizing-plan.md: import logs the + population-estimation-driven settlement_plan recommendation, but doesn't + yet act on it - no building/capability assignment changes here. This + just checks the compute-and-log call doesn't crash and reports a + sensible number, not any generation-behaviour change. + """ + + def test_import_logs_recommended_settlement_plan(self): + data = _make_export(districts=None, buildings=[TRADE_BUILDING] * 8) + origin = Point(0, 0, srid=3857) + + with self.assertLogs("general", level="INFO") as logs: + import_watabou_village(data, name="Logged Village", origin=origin) + + self.assertTrue(any("recommended plan" in message for message in logs.output)) + class WatabouImportGraphTest(TestCase): def test_creates_centre_node_and_building_nodes(self): @@ -143,6 +237,26 @@ def test_creates_centre_node_and_building_nodes(self): ).exists() ) + def test_granary_has_no_entrance_node_but_other_buildings_do(self): + data = _make_export(districts=None, buildings=[TRADE_BUILDING] * 8) + origin = Point(0, 0, srid=3857) + + centre = import_watabou_village(data, name="Granary Entrances", origin=origin) + + granary = centre.buildings.get(building_type="granary") + self.assertFalse( + Node.objects.filter( + building=granary, kind=Node.Kind.BUILDING_ENTRANCE + ).exists() + ) + + self.assertEqual( + centre.buildings.exclude(building_type="granary") + .exclude(nodes__kind=Node.Kind.BUILDING_ENTRANCE) + .count(), + 0, + ) + def test_imports_roads_with_width_fallback(self): roads = [ {"type": "LineString", "coordinates": [[0, 0], [10, 0]], "width": 4}, diff --git a/locations/utils.py b/locations/utils.py index 5bf8f6ce..3d45c45f 100644 --- a/locations/utils.py +++ b/locations/utils.py @@ -8,7 +8,7 @@ class InvalidBBoxError(ValueError): # Villages are seeded 1000-2000 units (metres, srid 3857) apart from each -# other (see min_centre_distance/max_centre_distance in spawn_villages.py). +# other (see min_centre_distance/max_centre_distance in generate_villages.py). # 10km per side comfortably covers many villages in one viewport while still # rejecting a request for "the entire world" in one query, since every # feature in the bbox gets fully serialized (no vector-tile-style paging). diff --git a/locations/views.py b/locations/views.py index 22964749..943196b7 100644 --- a/locations/views.py +++ b/locations/views.py @@ -90,12 +90,15 @@ def get(self, request, pk): ) roads = population_centre.roads.all() characters = population_centre.residents.select_related( - "needs" + "needs", "current_node__building", "current_node__interior_space__building" ).prefetch_related( "locations__location", Prefetch( "journeys", - queryset=Journey.objects.filter(status="active"), + queryset=Journey.objects.filter(status="active").select_related( + "destination_node__building", + "destination_node__interior_space__building", + ), to_attr="active_journey_list", ), _current_activity_prefetch(), @@ -184,12 +187,19 @@ def get(self, request): roads = Road.objects.filter(geom__bboverlaps=bbox) characters = ( Character.objects.filter(location__contained=bbox) - .select_related("needs") + .select_related( + "needs", + "current_node__building", + "current_node__interior_space__building", + ) .prefetch_related( "locations__location", Prefetch( "journeys", - queryset=Journey.objects.filter(status="active"), + queryset=Journey.objects.filter(status="active").select_related( + "destination_node__building", + "destination_node__interior_space__building", + ), to_attr="active_journey_list", ), _current_activity_prefetch(), @@ -234,11 +244,18 @@ class MapCharacterDetailView(APIView): def get(self, request, pk): character = get_object_or_404( - Character.objects.select_related("needs").prefetch_related( + Character.objects.select_related( + "needs", + "current_node__building", + "current_node__interior_space__building", + ).prefetch_related( "locations__location", Prefetch( "journeys", - queryset=Journey.objects.filter(status="active"), + queryset=Journey.objects.filter(status="active").select_related( + "destination_node__building", + "destination_node__interior_space__building", + ), to_attr="active_journey_list", ), _current_activity_prefetch(), diff --git a/progression/mixins.py b/progression/mixins.py index a1e7ee92..f6e8444d 100644 --- a/progression/mixins.py +++ b/progression/mixins.py @@ -69,9 +69,20 @@ class LevelProgressionMixin(models.Model): that up to the instance's own state and persistence. """ + # Declared here (not as model fields) so mypy knows the types provided + # by concrete subclasses (Player, Character) - this mixin is abstract + # and never instantiated directly. + level: int + xp: int + xp_next_level: int + class Meta: abstract = True + @property + def name(self) -> str | None: + raise NotImplementedError + def add_xp(self, amount: int): """ Add experience points (XP) to the instance and handle level-up logic. diff --git a/progression/models.py b/progression/models.py index 9a37cbf4..6de4ee2e 100644 --- a/progression/models.py +++ b/progression/models.py @@ -233,7 +233,7 @@ def __str__(self): return self.name def is_unlocked_for(self, character) -> bool: - if self.gate_group_id is None or self.min_proficiency is None: + if self.gate_group is None or self.min_proficiency is None: return True return self.gate_group.proficiency_for(character) >= self.min_proficiency @@ -1074,6 +1074,8 @@ def clean(self): return if self.parent_id == self.id: raise ValidationError({"parent": "A task cannot be its own parent."}) + if self.parent is None: + return if self.parent.parent_id is not None: raise ValidationError({"parent": "Cannot nest more than one level deep."}) if self.parent.player_id != self.player_id: diff --git a/progression/serializers.py b/progression/serializers.py index cbe0daa7..5e66d0e2 100644 --- a/progression/serializers.py +++ b/progression/serializers.py @@ -1,5 +1,7 @@ # progression/serializers.py +from typing import cast + from django.core.exceptions import ValidationError as DjangoValidationError from django.utils import timezone from django.utils.html import strip_tags @@ -258,18 +260,20 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) request = self.context.get("request") player = getattr(getattr(request, "user", None), "player", None) - self.fields["skill"].queryset = ( + skill_field = cast(serializers.PrimaryKeyRelatedField, self.fields["skill"]) + skill_field.queryset = ( PlayerSkill.objects.filter(player=player) if player else PlayerSkill.objects.none() ) - self.fields["skill"].required = False - self.fields["skill"].allow_null = True - self.fields["task"].queryset = ( + skill_field.required = False + skill_field.allow_null = True + task_field = cast(serializers.PrimaryKeyRelatedField, self.fields["task"]) + task_field.queryset = ( Task.objects.filter(player=player) if player else Task.objects.none() ) - self.fields["task"].required = False - self.fields["task"].allow_null = True + task_field.required = False + task_field.allow_null = True def validate(self, attrs): if not attrs.get("name") and not attrs.get("task"): diff --git a/progression/tests/test_activities.py b/progression/tests/test_activities.py index 12f42872..836ebb04 100644 --- a/progression/tests/test_activities.py +++ b/progression/tests/test_activities.py @@ -1,10 +1,8 @@ -from django.contrib.auth import get_user_model - from progression.models import Activity, Category, PlayerActivity, PlayerSkill from users.models import Player from .base import BaseTestCase -User = get_user_model() +from users.tests import user_factory class ActivityModelTests(BaseTestCase): @@ -79,7 +77,7 @@ def test_renaming_a_session_re_resolves_its_activity(self): self.assertEqual(session.activity_id, first.activity_id) def test_activities_are_not_shared_across_players(self): - other_user = User.objects.create_user(email="other@test.com", password="pass") + other_user = user_factory(with_player=True) other_player, _ = Player.objects.get_or_create(user=other_user) mine = PlayerActivity.objects.create(player=self.player, name="Deep Work") diff --git a/progression/tests/test_ap.py b/progression/tests/test_ap.py index 4763b545..94f95b40 100644 --- a/progression/tests/test_ap.py +++ b/progression/tests/test_ap.py @@ -1,7 +1,6 @@ from datetime import timedelta from decimal import Decimal -from django.contrib.auth import get_user_model from django.test import SimpleTestCase, TestCase from django.utils import timezone @@ -9,7 +8,7 @@ from gameplay.models import XpModifier from progression import ap -User = get_user_model() +from users.tests import user_factory class ThresholdForLevelTests(SimpleTestCase): @@ -130,9 +129,7 @@ def test_defaults_to_current_time_when_now_not_passed(self): self.assertEqual(ap.get_multiplier(self.character), Decimal("2")) def test_works_for_player_scope_too(self): - user = User.objects.create_user( - email="ap-test@example.com", password="pass12345" - ) + user = user_factory(with_player=True) player = user.player XpModifier.objects.create( scope=XpModifier.Scope.PLAYER, diff --git a/progression/tests/test_note_api.py b/progression/tests/test_note_api.py index 92de7794..df818346 100644 --- a/progression/tests/test_note_api.py +++ b/progression/tests/test_note_api.py @@ -1,26 +1,21 @@ """API tests for the standalone Notes feature (issue #632).""" -from django.contrib.auth import get_user_model from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase from progression.models import Activity, Note, Task -User = get_user_model() +from users.tests import user_factory class NoteViewSetTests(APITestCase): """CRUD, ownership scoping and sanitization on the notes endpoint.""" def setUp(self): - self.user = User.objects.create_user( - email="note-owner@example.com", password="pass" - ) + self.user = user_factory(with_player=True) self.player = self.user.player - self.other_user = User.objects.create_user( - email="note-intruder@example.com", password="pass" - ) + self.other_user = user_factory(with_player=True) self.client.force_authenticate(user=self.user) def test_create_assigns_requesting_player(self): diff --git a/progression/tests/test_offline_activity_logging.py b/progression/tests/test_offline_activity_logging.py index 62e9222c..997d49eb 100644 --- a/progression/tests/test_offline_activity_logging.py +++ b/progression/tests/test_offline_activity_logging.py @@ -10,7 +10,6 @@ from datetime import datetime, time, timedelta -from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test import TestCase from django.urls import reverse @@ -25,7 +24,7 @@ log_offline_activity, ) -User = get_user_model() +from users.tests import user_factory def past_local_day_start(days_ago: int = 1): @@ -41,9 +40,7 @@ def past_local_day_start(days_ago: int = 1): class OfflineLoggingTestBase(TestCase): def setUp(self): - self.user = User.objects.create_user( - email="offline-log@example.com", password="testpass123" - ) + self.user = user_factory(with_player=True) self.player = self.user.player def grant_premium(self): @@ -248,13 +245,9 @@ def test_uses_same_xp_multiplier_rules_as_timer_completion(self): class PlayerActivityLogOfflineApiTests(APITestCase): def setUp(self): - self.user = User.objects.create_user( - email="offline-api@example.com", password="testpass123" - ) + self.user = user_factory(with_player=True) self.player = self.user.player - self.other_user = User.objects.create_user( - email="offline-intruder@example.com", password="testpass123" - ) + self.other_user = user_factory(with_player=True) self.client.force_authenticate(user=self.user) def _log(self, **overrides): diff --git a/progression/tests/test_task_api.py b/progression/tests/test_task_api.py index b5aa8f7f..388cc62f 100644 --- a/progression/tests/test_task_api.py +++ b/progression/tests/test_task_api.py @@ -8,7 +8,6 @@ from datetime import timedelta -from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -17,16 +16,14 @@ from core.models import GameSettings from progression.models import PlayerActivity, Task -User = get_user_model() +from users.tests import user_factory class TaskXpMultiplierTests(APITestCase): """``get_xp_reward_summary`` applies the task multiplier only when linked.""" def setUp(self): - self.user = User.objects.create_user( - email="task-xp@example.com", password="pass" - ) + self.user = user_factory(with_player=True) self.player = self.user.player self.task = Task.objects.create(player=self.player, name="Write docs") # Pin the relevant GameSettings so the expected numbers are explicit. @@ -69,13 +66,9 @@ class TaskViewSetTests(APITestCase): """CRUD, ownership scoping and filtering on the tasks endpoint.""" def setUp(self): - self.user = User.objects.create_user( - email="task-owner@example.com", password="pass" - ) + self.user = user_factory(with_player=True) self.player = self.user.player - self.other_user = User.objects.create_user( - email="task-intruder@example.com", password="pass" - ) + self.other_user = user_factory(with_player=True) self.client.force_authenticate(user=self.user) def test_create_assigns_requesting_player(self): @@ -228,9 +221,7 @@ class TaskCompletionBonusTests(APITestCase): """First mark-complete awards the bonus exactly once (issue #432).""" def setUp(self): - self.user = User.objects.create_user( - email="task-bonus@example.com", password="pass" - ) + self.user = user_factory(with_player=True) self.player = self.user.player self.task = Task.objects.create(player=self.player, name="Ship it") self.client.force_authenticate(user=self.user) diff --git a/requirements.txt b/requirements.txt index d91e8102..c175b6ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,13 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile requirements.in +# pip-compile --output-file=requirements.txt requirements.in # amqp==5.3.1 # via # -r requirements.in # kombu -asgiref==3.11.1 +asgiref==3.12.1 # via # -r requirements.in # channels @@ -25,7 +25,7 @@ attrs==26.1.0 # referencing # service-identity # twisted -autobahn==26.6.2 +autobahn==26.7.1 # via daphne automat==25.4.16 # via twisted @@ -33,17 +33,17 @@ billiard==4.2.4 # via # -r requirements.in # celery -cbor2==6.1.2 +cbor2==6.1.4 # via autobahn celery==5.6.3 # via # -r requirements.in # django-celery-beat -certifi==2026.6.17 +certifi==2026.7.22 # via # requests # sentry-sdk -cffi==2.0.0 +cffi==2.1.1 # via # autobahn # cryptography @@ -53,7 +53,7 @@ channels==4.3.2 # channels-redis channels-redis==4.3.0 # via -r requirements.in -charset-normalizer==3.4.7 +charset-normalizer==3.4.9 # via requests click==8.4.2 # via @@ -80,26 +80,26 @@ cron-descriptor==1.4.5 # via # -r requirements.in # django-celery-beat -cryptography==49.0.0 +cryptography==50.0.0 # via # -r requirements.in # autobahn # pyopenssl # sendgrid # service-identity -daphne==4.2.2 +daphne==4.2.3 # via -r requirements.in defusedxml==0.7.1 # via # -r requirements.in # python3-openid -disposable-email-domains==0.0.217 +disposable-email-domains==0.0.237 # via -r requirements.in dj-database-url==3.1.2 # via -r requirements.in dj-rest-auth==7.2.0 # via -r requirements.in -django==5.2.16 +django==5.2.17 # via # -r requirements.in # channels @@ -120,7 +120,7 @@ django==5.2.16 # drf-spectacular django-admin-sortable2==2.3.1 # via -r requirements.in -django-allauth==65.18.0 +django-allauth==65.19.0 # via -r requirements.in django-celery-beat==2.9.0 # via -r requirements.in @@ -144,7 +144,7 @@ django-timezone-field==7.2.2 # django-celery-beat django-vite==3.1.0 # via -r requirements.in -djangorestframework==3.17.1 +djangorestframework==3.18.0 # via # -r requirements.in # dj-rest-auth @@ -152,7 +152,7 @@ djangorestframework==3.17.1 # drf-spectacular djangorestframework-simplejwt==5.5.1 # via -r requirements.in -drf-spectacular==0.29.0 +drf-spectacular==0.30.0 # via -r requirements.in hyperlink==21.0.0 # via @@ -184,15 +184,15 @@ msgpack==1.2.1 # -r requirements.in # autobahn # channels-redis -numpy==2.5.1 +numpy==2.5.2 # via -r requirements.in -packaging==26.2 +packaging==26.3 # via # incremental # kombu pillow==12.3.0 # via -r requirements.in -prompt-toolkit==3.0.52 +prompt-toolkit==3.0.53 # via click-repl psycopg2-binary==2.9.12 # via -r requirements.in @@ -202,7 +202,7 @@ pyjwt==2.13.0 # via # -r requirements.in # djangorestframework-simplejwt -pyopenssl==26.3.0 +pyopenssl==26.4.0 # via twisted python-crontab==3.3.0 # via django-celery-beat @@ -241,7 +241,7 @@ rpds-py==2026.6.3 # referencing sendgrid==6.12.5 # via -r requirements.in -sentry-sdk==2.64.0 +sentry-sdk==2.66.1 # via -r requirements.in service-identity==26.1.0 # via twisted @@ -251,7 +251,7 @@ sqlparse==0.5.5 # via # -r requirements.in # django -stripe==15.3.0 +stripe==15.4.0 # via -r requirements.in twisted[tls]==26.4.0 # via @@ -267,7 +267,7 @@ typing-extensions==4.16.0 # referencing # stripe # twisted -tzdata==2026.2 +tzdata==2026.3 # via # -r requirements.in # django-celery-beat