Skip to content

Refactor - #143

Merged
KrisCris merged 68 commits into
developfrom
refactor
Sep 16, 2026
Merged

KrisCris merged 68 commits into
developfrom
refactor

Conversation

@KrisCris

@KrisCris KrisCris commented Sep 6, 2026

Copy link
Copy Markdown
Owner

REFACTOR

PalEntity now binds two stable parent dicts (identity key and the dict
owning SaveParameter) instead of caching the SaveParameter value dict.
save_parameter/pal_param are read live, so replacing a whole native
property stays visible to the entity.

InstanceId/PlayerUId setters mutate the existing Guid property in place
via PalObjects.set_BaseType rather than swapping the property dict out
from under any native reference to it.

SaveManager gains reset() plus an RLock. open() resets on entry and
again on every failure exit, so a save that fails to parse no longer
leaves the previous save's players and containers behind next to the
half-read new one.

Deletes PalEntity.__hash__/__eq__ (InstanceId-based, unsound once World
and GPS share InstanceIds; nothing keyed a set or dict on the entity)
and SaveManager._save_parameter(), which was pal.save_parameter
reaching through the World envelope.
F2 was planned as one task and did not fit, so it is split at the green
boundary its own entry proposed. F2a is the model plus the repositories;
F2b is the storage adapters and the load-path rewrite.

PalRecord replaces PalRecordRef and now carries the real native record for
all three formats -- the World CharacterSaveParameterMap record as well as
the DPS/GPS SaveParameterArray entry, so `external_record` is gone. It uses
object identity for eq/hash because relocate mutates record_key, storage_key
and slot_index, and identity sets have to survive that.

PalRepository is the single runtime authority: `_records` plus owner,
instance and storageKey+slot_index indexes. SaveManager's `_record_mapping`,
`_records_by_instance` and `player_mapping` are deleted rather than kept in
sync beside it. Registration indexes incrementally; every removal or restore
rebuilds the secondary indexes, so a stale index is not a state the code can
reach. A duplicate storageKey+slot_index logs an explicit error instead of
silently overwriting the earlier record.

Deletes the fake `world-anomaly` storage key. A World Pal whose recorded
ContainerId resolves to no real container now has storage_key=None and
slot_index=None and stays out of the storage index, instead of being given a
fake storage nothing can look up and a -1 slot.

That in turn deletes the getattr(manager, ...)/hasattr(manager, ...)
scaffolding in api/player.py and api/pal.py, including the hand-built
fallback record and the fallback location dict. Both existed only because
test stubs lacked the methods, so the stubs answer for themselves now via
the new tests/fakes.py; production no longer guesses where a Pal is.

Also removes the dead `group_id = pal_entity.group_id` local in
_load_entities that spec 3.5 names, and folds api/pal.py's add_pal
post-hoc record patching into the normal _pal_data path.
PalEntity now takes the two real parent dicts a storage adapter found in its
own native format, instead of a World-shaped envelope. That is the F1-deferred
signature change: World hands over record["key"] and the SaveParameter owner,
DPS/GPS hand over entry["InstanceId"]["value"] and the entry itself. The fake
World envelope stops being a binding mechanism entirely; what remains of it is
one explicit WorldPalAdapter.envelope() for raw JSON export, template save and
duplicate, which S3c deletes.

Adds core/pal_storage_adapters/ (WorldPalAdapter, DpsPalAdapter, GpsPalAdapter)
and a plain SaveManager.storage_adapters routing dict, no StorageCatalog. All
three formats now register through their adapter, so DPS/GPS Pals are no longer
a separately maintained collection. FixedPalStorage shrinks to the save file and
its slot array.

_load_entities() splits into _load_players() and _register_world_records().
Players load first, so temp_player_pal_mapping is gone. World location checking
moves into the adapter and reads only the Pal's own recorded ContainerId +
SlotIndex against that one slot, rather than scanning every container.

PalEntity.group_id moves to PalRecord.group_id, a read-through property over the
Pal's own native envelope, so the value and the bytes that get serialized cannot
drift apart. DPS/GPS entries carry no native guild id and read the owning
player's, which is what the old code was writing into a throwaway fake envelope.
FixedPalStorage -> PalStorageSaveFile, FixedStoragePalAdapter ->
StoragePalAdapter. The save data never calls either shape "fixed" -- both
files are Pal{Dimension,Global}PalStorageSaveGame with a SaveParameterArray
payload -- and `Fixed` was already taken in this codebase by
PalObjects.FixedPoint64, the numeric type behind Hp.

Modules follow their class: pal_storage_adapters/fixed.py ->
storage_pal_adapter.py and world.py -> world_pal_adapter.py.

The slot semantics the old name carried implicitly are now stated in
PalStorageSaveFile.__doc__ instead.
PalRepository owns which Pals this session created; PlayerEntity stops being a
Pal container. Deleted: _palbox, _new_palbox, PalEntity.is_new_pal,
save_new_pal_records(), and PlayerEntity's add_pal/pop_pal/get_pal/get_pals/
get_sorted_pals. The four game rules that lived inside save_new_pal_records()
-- skip invalid, skip Human or no sorting key, skip missing paldeck id, warn on
a missing SkillUnlock_<Pal> -- are carried over verbatim into
PlayerEntity.settle_captured_pal(), which settles the one Pal it is handed.

Fixes a real data-loss bug. The old settlement ran inside save()'s player loop
and consumed its own tracker there: it set is_new_pal = False and cleared
_new_palbox before the files were written. A save that failed afterwards had
already applied the capture count and paldeck flag to the in-memory PlayerGVAS
while forgetting that it had, so the retry wrote a save that under-counted,
silently and permanently. Settlement now happens once before any output is
built, the created set is cleared only after every file lands, and the failure
path restores each touched player's RecordData and UnlockedRecipeTechnologyNames.
tests/test_created_settlement.py covers both halves; each was mutation-checked
against the old behaviour.

Also fixes a staleness bug F2a left: move_pal updated only _palbox, so a
cross-player move left the roster and the repository owner index pointing at the
previous owner. It now re-files the record and reindexes, and rolls both back.

Every creation entry point registers its own record with created=True, including
SaveManager.add_pal(), whose CLI callers previously got back a record that was
never registered at all.

_modified_records is deliberately not added: spec 4.2 lists it but nothing in
this task writes or reads it, and an empty set no code touches is the
speculative abstraction the working rules forbid. S2a adds it with its first
reader.

heal_all_pals() is now one pass over the repository and therefore also heals
Global Palbox Pals. The old scan skipped them only because they sit in no
player's palbox -- an artifact of the structure being deleted, not a rule.
The exported union and the DPS/GPS base class `StoragePalAdapter` differed only
in word order, in the same package. The union is exactly the three concrete
`*PalAdapter` classes with the qualifier dropped, so it takes the unqualified
name and the collision goes away.
Deletes the last two Pal authorities spec 3.1 names besides the dangling set:
`baseworker_mapping` and the `_roster_record_keys` compat layer, along with
`_register_record`, `_refile_record`, `_drop_from_rosters`, `_world_roster_key`
and the callerless `get_working_pal()`.

A roster was a hand-maintained list of record keys that every create, move and
transfer had to remember to re-file; forgetting was silent. It is now computed
per call from the repository's own indexes. A base worker in particular is not a
kind of record but a place -- an unowned Pal in a container some camp owns --
so `working_records()` reads the camp container ids and the storage index and
has nothing to keep in sync.

Fixes a bug this exposed: only the world-to-world transfer path patched a moved
record's `storage_key`/`slot_index` afterwards, so a plain `move_pal` left the
repository's storage index, and the DTO's StorageKey, on the container the Pal
had just left. `move_pal` now re-locates the record beside `pal_entity.SlotId`
and restores both on rollback; `_transfer` dropped its fix-up lines. Both halves
are covered by a new movement test and were mutation-checked.

Backend -32 lines net. Suite unchanged at the two documented baseline failures
in test_pal_storage_codec.py.
Deletes the last Pal authority spec 3.1 names, `_dangling_pals`, together with
the ghost/unref naming and the whole anomaly UI: `Is_Unref_Pal`,
`LocationStatus`, `LocationAnomaly`, `ActualContainerId`, `ActualSlotIndex`,
`ActualLocations`, `SHOW_UNREF_PAL_FLAG` and their styles, badge, note and two
i18n strings.

A container location anomaly is now one question with two answers. Resolve the
Pal's own recorded ContainerId + SlotIndex to one container and one slot and
compare instance_id; a World record that fails keeps no storage key, and load
finishes by logging one WARNING per such record with the spec section 9 fields.
What the old resolver split into missing / slot_mismatch / unknown_container is
that single answer, and `duplicate` goes with the all-container scan that found
it. `resolve_pal_location()` and `ContainerData.find_pal_slots()` are gone.

The move precondition everywhere -- backend and frontend -- is now that the
record still has a storage key, which is the same claim the status check made,
read off the record instead of recomputed.

Collapses the SlotIndex-versus-list-index conflation in ContainerData:
`ContainerSlot.SlotIndex` replaces `inv_idx`, `get_free_slot_index()` replaces
`get_empty_slot()`, `get_empty_inv_slot()` and the `available_inv_idx_set` heap,
and `_find_entry_index()` replaces `get_pal_idx()` under a name that says it is
a Python position. `has_pal()` shares it, which fixes a latent mismatch: the old
`get_pal_idx` compared UUID to a possibly-str argument while `has_pal` compared
str to str.

Backend -92 lines net, frontend -50. Suite unchanged at the two documented
baseline failures in test_pal_storage_codec.py; 205 frontend tests pass.
Adds tests/test_session_load.py: a load that fails at any point must leave an
empty session, not the previous save mixed with half of this one. The session is
compared field-by-field against a manager that has never opened anything, read
out of vars(), so a field reset() forgets fails the test rather than escaping it.

The design's other high-risk regression -- a failed save must not consume the
created settlement, and the retry must settle exactly once -- already exists as
tests/test_created_settlement.py from F3, so it is not duplicated here.

test_pal_identity.py 23 -> 19 tests: two /api/player/player_pals repeats whose
assertions all hold at entity level (the DTO copies the location keys straight
out of resolve_record_location), one DTO echo of the FavoriteIndex setter test,
and the two alpha-localization tests merged into one.
…rces

Adds the first vertical slice's backend surface beside the old routes, which keep
serving until S1c and S2b move the UI off them:

  GET/PUT /api/session
  GET     /api/players, /api/players/{playerUid}
  GET     /api/rosters, /api/rosters/{rosterKey}/pals
  GET     /api/pals/{recordKey}

Rosters are named rather than sentinel-keyed (player:<uid>, base-workers,
global-palbox) and answer with PalSummary -- a fixed 27-key row carrying what a
list needs to render, sort, group and badge itself. Everything the editor page
reads arrives only when one Pal is asked for, as PalDetail.

Failures use the spec's envelope: a stable code the frontend translates, and a
full traceback on anything unexpected, since the person who can act on it is the
user filing the report. Handlers are registered per blueprint, so the old routes
keep their existing bodies.

SaveManager._lock becomes session_lock and _file_path becomes file_path: every
new GET holds the one and the session resource reports the other, and both were
already being read from outside through the underscore.
…earch as REST resources

Adds the app-level half of the first vertical slice beside the routes it will
replace: /api/app-config, /api/save-paths, /api/releases/latest and
/api/guild-research. Nothing is deleted here -- S1d moves the frontend over and
takes the old RPC routes with it.

The behaviour change is /api/save-paths: browsing is a read. The route it
replaces wrote the browsed directory into Config.path, which made "go up one
level" a cursor every client shared. The listing now carries parentPath, so the
client walks the tree by asking for what it was last told, and only
PUT /api/session decides what loads.

PATCH /api/app-config writes two named preferences rather than reflecting onto
Config attributes, and applies them in the server order because the donation
prompt is remembered per locale.
…nteraction gate

Split from S1c, which does not fit one session: the store is 2477 lines across 31
consumers and the Pal data is four dicts, not one. The plan forbids two Pal dicts at
any boundary, so the split is drawn where no Pal state is duplicated -- this task
moves the session and the interaction gate, S1c-b moves the Pal authority.

Adds src/api/: one http client that returns a parsed body or throws
ApiError {httpStatus, code, message, details}, plus a thin module per resource.
Callers stop reading response.status == 0/1/2. A request that was never sent is
reported as our own fault with its stack, not as a connection failure.

Adds stores/session: the session resource, the app state, the session epoch and
abort that S1c-b's reads will carry, and the operation gate. editorOpen is now
distinct from session.loaded -- PUT /api/session makes a save loaded before its
rosters exist, and conflating the two would refresh rosters mid-hydration.

Spec 8.8's gate is one inert region over the app: 89 per-control :disabled bindings
and 108 lines of hand-rolled flag bookkeeping are gone, replaced by one wrapper that
holds the gate for a whole operation and releases it in finally. The old flag was
left raised by any branch that returned early.

Deletes POST /api/save/load and GET /api/save/status, replaced by PUT/GET
/api/session. test_entry_view_ui.py goes with them; its one real invariant, that
every Entry_* key is translated, moves into i18n.test.js and now covers all 17
locales instead of 4.
…pals stores

Splits `stores/players`, `stores/rosters` and `stores/pals` out of the mega-store
and deletes all four frontend Pal dicts together: `PAL_MAP`, `BASE_PAL_MAP`,
`GLOBAL_PAL_MAP` and the `pals` Map inside every `Player`. A roster holds
`recordKey[]`; `palsByRecordKey` holds the Pals; detail is fetched on click.

`CREATED_PAL_IDS` goes with them -- `changeState` is the backend's own answer.
`EDITED_PAL_IDS` survives as `pals.editedRecordKeys` only until S2a can write
`modified`.

The frontend now speaks the backend's roster vocabulary (`player:<uid>`,
`base-workers`, `global-palbox`); `legacyRosterId()` translates back for the
create/duplicate routes S3b and S4a still own.

Deletes `POST /api/player/player_pals`, `GET /api/player/players_data`,
`POST /api/player/player_data` and `POST /api/pal/paldata`, and migrates the
tests bound to them -- including `test_pal_container_api.py`'s shared-InstanceId
case, which is the only coverage of the premise the design rests on.
`stores/app` takes the four things that are about the app rather than the
save -- startup config, the save-path picker, the language preference and
the update check -- and `stores/research` takes the BaseCamp editor's tree.
Both go through new `api/app` and `api/research` clients onto the resources
S1b built, which lets `/api/save/{fetch_config,i18n,path,donate,update,
basecamp/research}` go: ten routes and `Config.set_shown_donate_info()`.

Browsing for a save no longer moves a cursor on the server -- "up" is the
client asking for the `parentPath` it was just handed -- and the donation
prompt is a field of the app config rather than a request of its own, which
is what lets it stay remembered per locale.

Two bugs the migration exposed, both fixed here: the backend origin reached
`api/http` through an async watcher, so `bootstrap()`'s first request went
to the previous backend; and a startup connection failure was reported as an
application error rather than as the connection failure the error screen has
a dedicated shape for.
Six `/api/save/*_data` RPC routes become the five §8.4 catalog resources in a
new `api/catalogs.py` blueprint, none of which touch SaveManager or the session
lock. Each answers with a list only: the old routes sent every row twice, as an
array and again as an InternalName dict, so the client held two objects that had
to agree. `stores/catalogs` builds that index from the list it was given.

The mega-store loses PAL_STATIC_DATA, ITEM_STATIC_DATA, PASSIVE_SKILLS,
ACTIVE_SKILLS, SKIN_DATA_LIST and TECH_LV_DICT along with the six copied
status ladders that filled them; getTechName() had no caller and is gone rather
than moved. `api/save.py` is down to POST /save alone.
Spec §8.2 lists three player resources no task in the plan owned; S1a built only
the two GETs. They exist now, and `api/player.py` is deleted with them.

`PATCH /api/players/{playerUid}` writes an explicit field allowlist and answers
with the player resource. The route it replaces took a {key, value} pair and fell
through to setattr() for any key naming a property with a setter -- which is
every settable field PlayerEntity has, chosen by the client. Because the PATCH
answers with the resource, the read that used to follow every write is gone, and
with it stores/players.refreshPlayer().

UnlockedRecipeTechnologyNames is a replacement field per §8.3's rule for skills,
compared case-insensitively so a technology already unlocked keeps the spelling
the save has for it. unlock_all_techs() is deleted: unlocking everything is the
client sending the union of the player's list and the catalog's -- the union, so
that a replacement field can never take a technology away.
…heal

Spec §8.3's Pal write resources, backend only -- the frontend still calls
`/paldata` until S2b moves it.

`PATCH /api/pals/{recordKey}` names twenty writable scalar fields and one
`Suitabilities` writer, following the shape S1f built for players: no type
re-validation, because the entity setters already coerce. The three skill
groups become `PUT .../skills/{passive,equipped,mastered}` taking the final
list; a name the game cannot resolve is refused before it reaches the save.
Maximize and the three heal buttons become one operation resource each.

Every write answers with §8.3's operation result carrying a full PalDetail, so
`PalRepository` gains the `_modified_records` set F3 deferred to this task and
`changeState` can finally say `modified`.

`heal_all_pals()` never normalized the records it healed, so healing everything
silently discarded the heal for Global Palbox and DPS Pals. It does now.
…paldata

Every Pal edit now goes through `stores/pals`: a scalar PATCH, a skill group
submitted whole, one maximization and one heal. `paleditor.js` keeps the DOM
events and the reporting, and `runPalWrite` is the single wrapper around all
of them.

`PATCH /api/pal/paldata` is deleted with its whole `match` ladder and the
`getattr`/`setattr` fallthrough that let a client write any settable field.
`POST /api/pal/maximize` goes too.

`changeState` is the only edited marker now, so `pals.editedRecordKeys` and
`markEdited` are gone. Because a second frontend tracker across slices is what
spec §12 forbids, the two remaining in-place mutations on the old blueprint --
`/transfer` and skill-template apply -- register themselves; S4a and S3b delete
those lines with their routes.

Two behaviours the `add_*` RPCs performed server-side are now explicit in the
client: equipping a move also learns it, and learning one with an active slot
free also equips it.

`test_webui_bootstrap.py` is down to its JWT expiry case per spec §11; its
heal-all, batch-suitability and `FavoriteIndex` rules, and `test_pal_maximize`'s
two endpoint cases, live in `test_rest_pal_writes.py`.
…e templates

A Pal entering this editor from outside a save is now recognised strictly, per
format, and reduced to one thing: its complete SaveParameter. The target adapter
builds its own envelope from that, so creating a Global Palbox export into a
player's Palbox is the same code path as creating a default Pal, and no fake World
record is built to carry a Pal that never lived in the world save.

Templates store that native DOM instead of JSON encoded a second time into a
string, and old string templates are upgraded once at startup -- by shape, with no
format marker, and never at the cost of an entry that cannot be read.
… the UI on it

Nine endpoints from spec §8.3 and §8.5, and the add dialog, import/export,
template UI and one-click duplicate migrated onto them.

Creating a Pal is now addressed to the storage it lands in and carries only a
source -- default, a saved template, or a record pasted in as JSON -- so the
target builds its own native record and a Global Palbox export can be pasted
into a player's Palbox. Duplicating takes no body: the backend picks the target
and the roster comes from the record, not from the list the client had open.

`applyOperationResult()` is the one place the frontend acts on an operation
result, which is what deletes `addPal`'s guess at which roster a new Pal landed
in. Templates move to `stores/templates` and store the native DOM S3a wrote.
…d create chain

`api/pal.py` 641 -> 144: only the container registry, creation targets and the
transfer survive, which are S4a/S4b's. `/add_pal`, `/dupe_pal`, `/dump_data`,
`DELETE /pal/<pal_id>` and the nine template routes are gone, and `_pal_data`
with them -- `pals.pal_detail` is the only Pal DTO now.

`delete_pal` takes a record key and nothing else; `get_unique_world_record`,
which existed to guess which Pal a bare InstanceId meant, is deleted. The CLI
was a third create/duplicate chain and a broken one: `dupe_pal` handed `add_pal`
a whole World record where S3a's pipeline expects a `SaveParameter`, nesting a
record inside its own SaveParameter. It goes through `duplicate_pal` now.

The two `test_pal_storage_codec.py` failures this plan has carried as baseline
were the tests: the fixture occupies DPS slots [1, 9, 24] and both tests
hardcoded slot 0. The round-trip assertion beside them passed throughout. The
suite is green for the first time in this plan -- 235 passed, 0 failed.

Tests: `test_skill_templates.py` and `test_pal_delete.py` deleted, their
surviving rules moved into `test_rest_templates.py` and `test_pal_creation.py`;
`test_pal_templates.py` 381 -> 123, keeping the §6.2 migration regression;
`test_pal_creation.py` rewritten onto the real fixture, fake harness deleted.

be +29/-535, py test +340/-769.
…d_rekey

`core/pal_operations.py` is the one owner of moving a Pal. `plan()` decides what a
source and a target mean -- relocate, replicate or update-existing -- and both the
capability the move dialog reads and the transfer that follows come from that one
answer, so the UI cannot offer a target the backend will refuse.

A relocate keeps the same `PalRecord` object throughout `rebind_and_rekey()`, which
is what lets a Pal created this session survive a move to another storage format and
still settle its capture count on save. Nothing snapshots the session any more: each
executor validates first, converts on a detached copy, and deep-copies only the
native parents its short commit writes into.

Ownership settles once, in `set_owner()`. `PalEntity.owner_player_entity`,
`set_owner_player_entity()`, `set_owner_player_uid()` and `in_owner_palbox` are gone.

The old transfer chain and `/api/pal/transfer` stay until S4b has moved the dialogs
across and S4c deletes them.
The move dialog no longer decides anything about a target. Which storages exist,
how they are grouped and what each is called comes from `GET /api/storages`;
whether this Pal may go into that one, and what would happen if it did, comes
from `GET .../pal-transfer-capability` for the pair. `containerMoveDisabledReason`
and its `MovableInto`/`ContainerKind` rules are deleted.

The storage descriptor's name is split so the frontend keeps i18n without a kind
switch: `label` is the data half, `labelKey`/`labelArgs` the translated half --
the rule `GET /api/rosters` already documents. The Global Palbox group heading
follows it too.

The add dialog asks `GET /api/rosters/{rosterKey}/pal-creation-targets` instead
of filtering the directory itself, which is where it duplicated
`SaveManager.creation_targets`.

`applyOperationResult` migrates the selection: a relocate answers with a new key
for the same Pal, so the selection follows the result record; only a real
deletion leaves it cleared.
…pshot

`POST /api/pal/transfer` was the last Pal route outside the REST resources and
the only caller of `SaveManager.transfer_pal`, so the file and the chain behind
it go together: `_transfer_global`, `transfer_pal`, `move_pal`, `_make_world_pal`,
`_validate_world_target` and `_global_collision_candidates`.

`delete_pal` and `create_pal` were the only other users of the whole-session
`_snapshot_external_mutation`. `delete_pal` is now one body over the service's
`release_record()` -- the old `_release_source`, made public and made tolerant of
a World Pal whose container is already gone -- instead of three format-specific
ones, and `create_pal` watches the entry, the dirty flag and the locker it
actually writes. The snapshot is deleted rather than kept for one caller.

Two behaviours the deletion exposed, both spec §7 rules the new service was
supposed to inherit rather than reinvent:

- a shared storage has no guild of its own, so the target-guild check refused
  every move into a Viewing Cage. `TransferPlan` now carries `group_id` beside
  `owner_uid` and the planner settles both at once.
- a confirmed overwrite was accepted even when a second candidate had appeared
  since the dialog opened. A changed candidate count is a stale conflict.

Tests follow the plan's disposition: one case per §7 semantic instead of
fourteen keyed to the old entry points, and everything that only the real save
files can answer stays on them.
The backup was of the wrong directory. `_save` ran
`copytree(self.file_path, backup_dir)` while logging `output_path`, so a
save-as copied the one folder that was never in danger and left the folder
being overwritten with no way back. The backup is now taken from the target,
and restoring it is the exact inverse: every backed-up .sav goes back, every
file this save created is deleted. No manifest, no transaction log.

Staging each output to a temp file and reading it back to "verify" it goes
with them. Producing bytes the game can load is palworld-save-tools' job, and
a file that decompresses again proves nothing about the save inside it. What
replaces it is a plain write_bytes per file with the backup behind it.

Settlement now sits inside the same try block as serializing and writing, so
a failure anywhere between the first deepcopy and the last file rolls the
capture counts back exactly once and the retry settles exactly once.

save() raises save_io.SaveFailed instead of returning False, so the backup
path can reach the user when restoring failed too. POST /api/session/saves
is the REST endpoint for it; /api/save/save stays until S5b takes its caller.

save_player_sav had no callers and is gone.
`stores/session.js` posts to `POST /api/session/saves` and returns the path
the backend says it wrote, so a save that fell back to the loaded path is
reported as where it actually went. The `{status, msg}` envelope check and
the ApiError it faked are gone with it, and so is `api/save.py`: its one
route had exactly this caller.

A successful save clears `changeState` on every cached summary and detail,
in place, before the interaction gate is released -- the objects stay as
they are because the editor binds v-model into the selected detail. Spec 10
makes the backend's `changeState` the only authority for the new/edited
markers, and a save makes all of them stale at once.

One failure gets its own message: SAVE_FAILED with `restored: false` means
the save is half written and the backup folder is the only complete copy
left, which no error code says on its own.
…er names

Every ban grep in the plan is empty. `ban "StorageKind"` stays frontend-scoped
as S4c documented: 0 in the frontend, 32 in the backend where it is the
descriptor key spec §7 reads.

The three old UI button ids are gone from the core. `records_for_roster`,
`creation_targets`, `create_pal` and `duplicate_pal` now take `base-workers`,
`global-palbox` and `unrostered` directly, so the only difference left across
the boundary is the `player:` prefix: `FIXED_ROSTERS` collapses from a
translation table to a frozenset and `legacy_roster_id` to `core_roster_key`,
one `removeprefix` inverting what `roster_key_for_record` builds. A player
roster is still the bare uid inside the core, which identifies a player by uid
and has no reason to know a URL prefix.

Twelve methods defined once and called nowhere are deleted, along with four
unused store members -- `app.browseParent` was `browseParentPath` minus the
error reporting. Removing `Logger.api_logger` also drops `from flask import
request` out of `utils/logger.py`, a utils module importing the web framework,
which is spec §13's direction backwards.

Six comments named plan tasks (S4a, S2a, F3b, F4, S1c, S2b); they now state
the fact without the task id.

Backend +55/-145, frontend +2/-14.
…a traceback

`register_error_handlers` gives every REST blueprint a catch-all so an unexpected
exception comes back as spec §8.7's envelope. Flask resolves a blueprint handler
ahead of an app-level one across the whole MRO, so that catch-all was also claiming
the JWT failures `webui.py`'s three `@jwt` loaders exist to answer -- and
`NoAuthorizationError` is not an `HTTPException`, so a missing, expired or malformed
token fell to the 500 branch and came back as `UNEXPECTED_ERROR` with a server
traceback in the body.

Two things were wrong with that. `ApiError.isAuthFailure` reads `httpStatus === 401`
and nothing else, so an expired session showed a generic failure instead of the login
prompt; and an unauthenticated caller got absolute filesystem paths back from software
whose README supports remote access.

Each blueprint now handles `JWTExtendedException` and `PyJWTError` in front of the
catch-all, answering `reply(status=2, ...), 401` -- the same helper, status and shape
those loaders use, because spec §12 leaves auth's behaviour alone this round. The
catch-all is narrowed, not removed.

`tests/test_rest_auth_rejection.py` reads the route list out of `app.url_map` rather
than writing it down: all 45 guarded `/api` routes must answer 401 with neither
`Traceback` nor `UNEXPECTED_ERROR` in the body, an expired and a malformed token are
refused the same way, the two reads the login screen needs still answer without a
token, and a provider that raises still produces the §8.7 500.
A dead-code sweep over the backend and the frontend. Definitions came back
clean -- 0 unreferenced of 402 -- and all 45 API routes have callers, so what
was left was the two things a definition scan does not see.

Nine imports no module used: PalObjects, overload, clamp, Any, sys, and four
typing names in utils/util.py that outlived the annotations that needed them.

Thirty-nine i18n keys across every locale, in three groups. The entry page was
rewritten to Entry_* and left its EntryView_Greet_*/Note_* block behind. Three
belong to features that are gone, each with a test already asserting the source
no longer mentions them: TopBar_Btn_Pal_OOB, TopBar_Btn_Pal_Ghost and
BackendSelector_Mixed_Content. The rest were superseded in place --
SupportDialog_QR_Instruction by _Description, Operation_Load_Player by _Data.
Four of them survived only because two test lists asserted they were translated
in every locale, so those assertions go too.

Editor_Container_* stays: the frontend never spells those keys because the
backend sends them as `labelKey` over the wire (api/storages.py).
Two defects found by driving every operation through the UI.

Six reads answered 500 with a server traceback when no save was open:
/api/rosters, /api/storages, /api/storages/{key}, base-workers/pals,
pal-creation-targets and players/{uid}/inventory. Spec 8.7 keeps 500 for
unexpected exceptions, and a session with nothing loaded is not one -- it is
the state every run starts in and the state a failed open() resets back to.
SaveManager already answers it emptily where it was asked to, so the two reads
that walked base camps and containers now say the same thing rather than
reaching through None. The inventory route was the same class of bug written
differently: it read item_container_data before require_player ran, so it
raised instead of answering 404, and now resolves the player first as the PATCH
route beside it always did.

The item picker opened underneath the roster rails, its left ~380px covered and
unclickable. `.editor-canvas` sets `isolation: isolate`, so a backdrop rendered
inside it cannot rise above the rails, which are the canvas's siblings -- no
z-index fixes that from within, which is why 1000 was not enough. Every other
overlay in the app already teleports to the body; this one now does too.

Both regression tests were checked red before green. The REST one enumerates
routes from app.url_map, as test_rest_auth_rejection does, so a route added
later is covered the day it lands; the overlay one pins MessageCenter and
SupportDialog as the only exemptions, so moving one into the canvas trips it.
The shipped item data was extracted from build 24467282 and the game has been
patched since. Item count is unchanged at 2466, with nothing added and nothing
removed, and 794 rows differ:

  792  localization only, and they read like real patch content -- a Spanish
       description for a cold-resistance accessory that said "resistencia al
       calor" now says "al frio", and a batch of Turkish names that were still
       English placeholders now have Turkish
    1  SkillCard_Psychokinesis is no longer Disabled and is now Legal
    1  WaterBuildKit Rank 4 -> 2
    1  WorldTreeHolyWater Weight 1.0 -> 0.1

Not one MaxDurability or MagazineSize changed. That settles the open question
from the restore work: DT_ItemDataTable_Common really does record Durability 0
for the grappling guns and sphere launchers, in the newest build as in the old
one. The value is not stale and it is not an extraction fault, so the code's
refusal to invent a maximum for them stands.

lab_research_labels.json is a pure reordering: same 17 languages, same keys, same
values. That is the extractor's fault rather than the game's, and it is fixed
here. `item_types` was a bare set, and set order for strings follows
PYTHONHASHSEED, so the labels file and its sha were rewritten on every run
whether or not anything had changed. `categories` on the line above was already
sorted; now both are. Two runs in separate processes now produce the same file.

Not updated, and not forced: pal_data, human_data, the three skill files and
tech_data. Those domains are gated behind a reviewed deletion proposal, and
provenance.json records them at build 24181527 with no toolchain identity at all,
so `--write` refuses each one as a toolchain mismatch against every other domain.
`--domain all --write` refuses separately, and the character publisher says of
itself that it is a one-shot audit artifact bound to a 2026-07-26 credential. All
of that is the approval workflow working as designed; re-baselining it is a
maintainer decision, not one to take from here.
Ninety-eight comments cited spec sections -- "(spec §8.2)", "per §8.7", "Naming
follows §8.3". The specs live under docs/, which this repository does not commit,
so every one of those pointed at nothing a reader could open. Seventy were
trailing parentheticals and simply go; the other twenty-eight carried the
sentence and are reworded to state the rule instead of citing it. "Naming follows
§8.3: a field the save file itself has keeps the game's name" becomes "Naming: a
field the save file itself has keeps the game's name" -- the rule was always the
useful half.

Fourteen more described a refactor rather than the code. "Six sequential requests
before this task", "the routes this plan has not reached yet", "Before this store
there were four", "the class this replaces carried a `pals` Map". That is
progress reporting: true while the work was in flight, noise afterwards, and
misleading once the thing being compared against is gone. Where the history was
carrying a real reason the reason is kept and put in the present tense -- the Pal
store's four maps become "splitting Pals across per-roster maps lets the same Pal
sit in two of them after a move", which is why the store is shaped this way and
stays true whatever came before.

What was left alone: comments that read as history but are not. "Unequip whatever
is no longer on it" and "a guild the save no longer has" describe runtime
behaviour. `pal_templates.py`'s TODO(3.0+) is a forward-looking note about how
long 1.0.x template upgrades must keep working, which is exactly the kind of
decision a comment should carry.

Comment-only: the AST of all 24 changed Python files is identical ignoring
docstrings, and every changed line in the 24 JS and Vue files is a `//` comment.
Ten of the modules under core/ opened straight into their imports, so the only
way to learn what one held was to read it. Each now says what it is and what it
deliberately is not, in the style the modules that already had one use.

Two carry more than the rest. pal_objects says that the Pal in its name means
Palworld rather than a Pal, which is the whole of a rename this codebase is not
going to do; and pal_record says that a record is a Pal plus the physical place
it lives, and that its StorageKind is the canonical one.

No code changes -- every diff here is an added docstring and nothing else.
core/__init__.py star-imported nine modules. That made Config, LOGGER and
DataProvider reachable as core.Config and friends although none of them is in
core, and it made core.StorageKind resolve to pal_storage's two-value literal
rather than pal_record's three-value one -- the last star import to define the
name won, and it happens to be the one that cannot describe a Pal in the world
save. Anyone importing StorageKind from the package would have got a type that
silently excludes world Pals.

core/__init__.py now re-exports only PalEntity and SaveManager, which are what
the api layer actually asks the package for. cli.py names the nine it uses; its
threading, traceback and Optional were already imported and merely shadowed.

    python -c "import palworld_pal_editor.core as c; c.StorageKind"

now raises AttributeError, which is the proof the shadowing is gone rather than
hidden.
The modules under core/ were nearly all prefixed pal_, and the word after the
prefix did not distinguish them: pal_sources, pal_record, pal_storage and
pal_templates could not be told apart by name. Four are renamed, each with three
importers or fewer:

    group_data    -> guild_data          only EPalGroupType::Guild is read here,
                                         and PalGroup/GroupData -> Guild/GuildData
    pal_sources   -> pal_import          it reads a Pal that is not in the save
    pal_storage   -> pal_storage_file    one class, and it is one file on disk
    pal_templates -> templates           it holds skill templates too, so the
                                         pal_ prefix was false about half of it

pal_storage_file's two-value StorageKind becomes ExternalStorageKind, leaving
pal_record's three-value one as the only type by that name.

The storage descriptor was a dict of twenty PascalCase keys, half of them
invented by this editor rather than read from a save, with Size and Capacity
always written from the same value and OwnerPlayerUId duplicated as
StorageOwnerPlayerUid. It is now a frozen StorageDescriptor of seventeen fields;
the location dict beside it is a RecordLocation. ContainerId and SlotIndex keep
the save's spelling because they are the save's fields, and the rest are
snake_case. ContainerKind/ContainerLabel become storage_role/storage_label,
because both were also set on the two storages that carry ContainerId: None and
are not containers at all. Anomaly is dropped: written at three sites, read at
none. PalContainer.size becomes SlotNum after the field the save calls it, which
also settles size-versus-len; PalStorageSaveFile.capacity becomes slot_count.

pal_transactions is merged back into pal_mutations. The split had put both
exceptions in one file and all four of their raise sites in the other.

api/pals.py gives up what was not routing: pal_serializers.py holds the two
response levels, operations.py the four things every Pal-changing route owes the
session. require_pal_template moves there too, so storages.py no longer imports
from a sibling route module.

The REST wire format is unchanged -- api/storages.py still answers the same
camelCase keys, and the REST tests pin that.

These parts are one commit because they depend on each other: the api split
needs the dataclasses, the dataclasses need the canonical StorageKind, and the
renames run through all of it. Split further, no intermediate commit builds.
…irectory

config.json, templates and logs were written next to the program: the frozen
executable's own folder, or the installed package directory. That is somewhere a
normal install cannot write. On Windows it is silently redirected into the
VirtualStore, on macOS writing inside a .app invalidates its signature, and on
Linux site-packages belongs to root. This repository has src/palworld_pal_editor/
logs/ with real logs in it, which is the same bug leaving evidence.

Each platform's own location is used instead, with no new dependency:

    Windows   %LOCALAPPDATA%\Palworld-Pal-Editor
    macOS     ~/Library/Application Support/Palworld-Pal-Editor
    otherwise $XDG_CONFIG_HOME and $XDG_DATA_HOME, else ~/.config and
              ~/.local/share

Templates move out of config.json into templates.json of their own. They are
user data, not configuration, and a Pal template carries a whole GVAS payload --
so changing the interface language was rewriting every saved Pal, and the same
file holds the password hash and the JWT secret. core/templates.py now owns that
file rather than reaching into Config.

On first launch after this change, an existing config.json beside the program is
read, split into the two new files, and left exactly where it is. Nothing is
deleted or rewritten, so a downgrade still finds it, and the migration does not
run again once the new config exists.

Writes go through a temporary file and a rename, so a failed write leaves the
previous file intact rather than a truncated one.

tests/test_user_data_paths.py covers the path for each platform, both XDG
variables, and the migration -- including that it never touches the old file and
never runs twice.
The error screen renders a code and a log, and BackendErrorView still bound
both, but nothing had filled them since the REST migration. Every migrated route
reported startup failures through reportStartupFailure, which threw the error
away and passed a sentence:

    setBackendError(getTranslatedText("BackendError_Request_Failed", [error.message]))

ApiError was already carrying the code and details.traceback from the backend's
error envelope. A user hitting a save that fails to open got "Backend request
failed" and nothing to paste into a bug report.

startupErrorDetails now maps the error to what the screen shows, and is exported
so the five cases that were the specification for the old axios-shaped path stay
tested against the new one. A request that was never sent has no server
traceback and reports the frontend stack instead, which is what ApiError's own
comment always said should happen.

With that path complete, the legacy axios transport goes: GET, POST,
handleRequestError and backendErrorDetails, and auth()/unlock() move onto
request() through a new api/auth.js. Those two were the last callers. axios
survives for exactly one call -- connectBackend's probe, which asks a candidate
origin the store has not adopted yet, with no token and its own timeout, and
that is precisely what api/http.js cannot express.

This corrected a test that was wrong about the backend: bootstrap.test.js
stubbed a rejected password as a resolved {status: 2} body, but
POST /api/auth/login answers 401 (api/auth.py:23). The old code read the body,
so the wrong stub passed.
components/modules/ had collected two different kinds of thing: reusable UI
primitives with no idea what a Pal is, and components that are entirely about
one. PalBriefPanel, PalPortrait, PalSpeciesSelector and TechCard move up beside
the other domain components; NumberSliderField moves in, being a labelled
NumberStepper and nothing more.

The extracted sidecars followed their components. What settled it is who imports
them: pal-list-order, pal-species-selector, pal-storage-label and
pal-storage-move are imported only from components/, while search-select is
imported by modules/SearchSelect.vue itself. So the domain sidecars are domain
files and go up, and search-select stays.

That leaves one rule for the directory: modules/ holds primitives that do not
know what a Pal is. backend-server-selector-keys.js was already beside its
component and stays there.

File moves and import paths only -- no component changed behaviour.
The app shell owned three unrelated things: what the user is told, which
backend this client talks to, and the domain actions. That made it the
store every other store would have to import to report a failed request,
which is backwards -- reporting is the bottom of the graph, not the top.

`stores/messages` owns the one queue that toasts, dialogs and
confirmations share, and imports nothing but the app store for the
locale. `stores/backend` owns the origin, the token that goes with it,
and what a failed request means; `reportApiFailure` lives there rather
than with the messages because two of its four cases are decisions about
the connection and only the last one is a message.

The translation lookup moves to a pure `translate(locale, key, args)` in
the i18n module so both stores share one implementation. The shell keeps
`getTranslatedText` -- it is the documented convention at 367 call sites,
and it is now one line.

The store graph after this is acyclic and layered:

    app, session, catalogs, templates  ->  nothing
    messages                           ->  app
    pals, research                     ->  session
    backend                            ->  app messages session
    rosters, storages                  ->  pals session
    players                            ->  pals rosters session
    paleditor (the shell)              ->  all of the above

One test was wrong about the backend and is corrected here: a refused
password comes back as a 401, not as a body with `status: 2`. The old
code read the body and let the wrong stub pass.
Both stores answered raw and left the shell to say what a failure meant,
which is how the shell came to own an action per operation that did
nothing but wrap one. The reporting moves to the store that made the
request, and the wrapper disappears with it -- seven template actions and
two research ones, gone from the shell.

The interaction gate moves with them. `gated` is exported from
`stores/session` now, so a store applies it to its own surface; nested
calls still share one gate, because `runOperation` counts depth.

Two things read differently as a result and are better for it. Saving a
template asks `stores/pals` which Pal is open, rather than taking a
record key from a dialog that has no other use for one. And
`research.complete` answers a boolean and raises its own toast, instead
of answering a count that only the toast ever read.
`MAX_LEVEL` and its four companions were state on the app shell that
nothing ever wrote, which meant every control that needed a number had to
reach through a store to read one. They are constants, so `game-limits.js`
holds them and the controls import them directly.

The module says which of them are the game's limits and which is not:
`MAX_INVALID_LEVEL` is how far the editor will still go once the user has
turned validation off, and the game promises nothing past its own ceiling.

`HIDE_INVALID_OPTIONS` -- the toggle that decides which of the two applies
-- moves to `stores/app`, next to the locale. It is a preference of this
browser and not of the save, and putting it there is what lets the domain
stores read it without importing the shell.
Eleven actions on the app shell did nothing the players store could not
do for itself: read the open player, build a patch, send it, and say what
happened when it failed. They move, and the shell no longer knows that a
player has a level or a technology list.

`update` and `loadInventory` answered `null` for "no player is open" and
left the caller to turn that into a message. There is one caller and one
message, so the store raises it and every action answers a boolean.

Two arguments disappear on the way. `allowOverstack` was the shell
forwarding its own toggle, which the store now reads from `stores/app`;
`levelCeiling` is the same reading, made once instead of at three call
sites.
Eleven functions on the app shell asked nothing of it: they take a Pal or
a skill and answer a question about it. Being exported from a store meant
a control had to hold a store to call one, and put them on the same
surface as the actions that write to the save.

`skill-rules.js` is which skills a Pal may be given and what a badge on
one means -- the game's rules, which are also the backend's, which is why
the picker greys out exactly what a request would be refused for.
`pal-traits.js` is reading a Pal for display: element, gender, what makes
it special, which skins fit it, and which suitabilities "max" applies to.

`palElementKeys` is the one that was not pure -- it looks a species up --
so it becomes a getter on `stores/catalogs`, which owns that index.
Twenty-eight actions on the app shell all did the same three things: read
the Pal that is open, send one request about it, and say what happened.
The store that holds the Pal can do all three, so it does.

`runWrite` replaces `runPalWrite` and takes the record key as an argument
rather than closing over the store, which is what lets the six former
raw writes collapse into their reporting halves -- there is now one
`heal`, one `maximize`, one `applyTemplate`, not a pair of each.

Three pieces of state come with them, renamed for what they are: the two
skill pickers' current choice, the counter the list watches to scroll a
written row back into view, and whether the save-details disclosure is
open.

Creating, deleting and moving stay behind for now: each of them changes
which list the Pal is in, and that question is not this store's.
Duplicating is the exception and moves -- the copy lands beside the
original, so nothing else has to be opened.

One behaviour changes: selecting a Pal whose session has already been
replaced no longer raises "could not select". Nobody is looking at that
list any more, and the toast was reporting the app's own bookkeeping.
The five transfer actions, the delete and the add all ended the same way:
read the reply for the list the Pal is now in, open that list, select the
Pal. Written in the app shell, that ending was the reason the shell knew
anything about Pals at all.

They go to `stores/rosters`, which is the store the ending is about -- it
already owns which list is open, and it can call the Pal store and the
storage store without either of them needing it back. `followTransfer` is
now written once, next to the three callers that share it.

Selecting a roster absorbs the shell's `selectPlayer`, including the base
camp's laboratory read, so the sidebar calls one action instead of one
that wrapped another. `loadMoveTargets` goes to `stores/storages`, which
is the only one of these that asks about a Pal and a place rather than
about a list.

The shell keeps its local `gated` no longer, and its docstring now says
what is actually left: bringing the app up and putting a save in front of
the user. It is 527 lines, from 1409.
…ection

Fifteen names on the app shell were proxies for `stores/messages` and
`stores/backend` that existed only because the components had nowhere
else to reach them. The four call sites left -- the error screen, the
dialog gate, the global error handlers -- now hold the store that owns
what they read.

`getTranslatedText` stays: it is the documented way a component asks for
interface text, at 368 call sites, and it is one line here.
`getTranslatedText` was on the app shell because the components were
already holding the shell for everything else. It needs the locale and
nothing more, so it belongs with the locale, and `stores/messages` had a
second copy of it for exactly the same reason -- there is now one.

368 call sites move from `palStore` to `appStore`, and twenty-one
components stop importing the shell entirely: interface text was the only
thing most of them still asked it for. Fifteen files that were holding
both stores now hold one.

AGENTS.md names the convention, so its wording moves with it.
`stores/paleditor` has not been the Pal editor for several commits, and
`palStore` sat in the same components as `palsStore` -- one letter apart,
one of them the Pal cache and the other the app shell. It is
`stores/app-shell` now, `useAppShellStore`, held as `shell`, which is the
term its own comments and every other store's already used for it.

Two names go with it. The donate flag becomes `donationPromptOpen`, and
`shownDonate` becomes `donationPromptSeen` -- the one says what it shows,
the other what it records. `HAS_WORKING_PAL_FLAG` becomes
`rosters.hasBaseCamp`, which is where the question belongs now that the
roster store can see the storages.
`sorryandfuckyou` raised the anti-scam dialog once per run of the app,
and `CN_WARNING_ON_LOAD` was named for a time when it was shown only in
Chinese -- which it has not been for a while, and which `i18n.test.js`
already pins. It is `warnAboutResale` now, guarded by a plain `let`
rather than a `ref` nothing renders, and it sits next to the language
cascade that is its only caller.

The comment says why it hangs off that cascade: it runs both when a save
opens and when the language changes, so the warning is raised once the
user has a language and can read it.

A test covers the once-per-run part, which nothing did.
`pip install -e .` was only putting `src` on the path -- nothing reads
distribution metadata and `pyproject.toml` declares no console script -- so
PYTHONPATH says the same thing without a build step.

The venv is no longer activated either. Activation only edits PATH, while the
interpreter finds its own environment from the `pyvenv.cfg` beside it; and an
activation that fails, which `Activate.ps1` does under a restrictive execution
policy, left every later `pip` and `python` in the script quietly working
against system Python. Naming the interpreter fails loudly instead.

The shell script also stops building its launch line as a string for `eval`,
which was splitting any argument that contained a space.
Same reasoning as the setup scripts: naming the interpreter says which
environment a command runs in, and an activation that fails no longer leaves the
rest of the script building against system Python. `python -m PyInstaller` is
what replaces the bare `pyinstaller` on PATH that activation was there to
provide.

Nothing needed the editable install for this: PyInstaller walks up from
`src/palworld_pal_editor/__main__.py` through the package's `__init__.py` and
adds `src` itself, verified by building in a venv that has the requirements and
no install of the project.

The packaging test looked for a line starting with `pyinstaller`, so it now
matches the interpreter call instead.
`build_executable.ps1` and `build_executable.sh` pass `--name
palworld-pal-editor` with no `--specpath`, so PyInstaller writes
`palworld-pal-editor.spec` into the repository root -- on top of the tracked
spec the AppImage build reads, losing its `webview.platforms.qt` hidden import
and the `libgbm.so.1` filter. CI never noticed because each platform builds on
its own runner, but a local build of both leaves the AppImage spec silently
wrong and the working tree dirty.

The AppImage spec is now `appimage.spec`, which says what it is and is not a
name PyInstaller will generate, and the generated one is ignored.
Two feature sections that had no entry at all: moving Pals between every
container a save has, including Dimensional Pal Storage and Global Pal Storage,
and editing the guild's Pal Labor Research Laboratory. Smaller additions where
the behaviour already changed: passive skills split into Pal, regular and
Partner groups, skill internals under cheat mode, restoring a worn item, the
WebUI as a progressive web app, and settings and templates living in the
platform's user data directory.

The screenshots were shot on 2026-08-14 and predate the Move Pal button, the
editor's section polish and the skill split, so all of them are retaken against
the tracked test save -- the hero previously showed a path under Downloads.
Same as before: lossless WEBP, 1600x900 for the feature sections and 1600x1000
for the two full-page shots, in both languages.
A save can hold the same passive twice -- one Pal in a real save carried
ElementBoost_Earth_1_PAL at two positions of a 45-entry list. The editor
submits a skill group whole, so adding anything to such a Pal sent
`[...stored, chosen]`, and the backend refuses a list that names a skill
twice. Every add on that Pal failed with SKILL_LIST_INVALID whichever
skill was picked, and nothing in the editor could get it out of that
state.

The repeat is dropped where the list is submitted rather than in each of
the six callers, because it is not the caller that puts it there. The
next add both adds the skill and drops the stray copy.

Applying a skill template did the same thing in reverse: a template is
captured from a Pal verbatim, so one saved from such a Pal would carry
the repeat onto every Pal it was applied to. It drops the repeat rather
than answering 400, since a template is data already on disk and not a
request the user can build again.

Skill cards are keyed by position too -- `:key="skill"` is a duplicate
key for exactly this data.
The passive picker lists several hundred options and opened at the top
every time, so reopening it never showed the current selection.

The popover now scrolls its selected option into view when it opens,
centred so the neighbours either side are visible too. An option already
on screen is left where it is, so reopening does not shuffle rows under a
pointer that is about to click.

Offsets are measured against the scrolling viewport rather than asked of
the button: the popover is teleported to the body, so scrollIntoView
would move whatever is behind it instead. The rows are not all one height
and the group headers between them are not rows, so the offset comes from
the rendered element rather than from the index of the selection.
Completing research logged only the request that asked for it --
`research=None category=ProductMedicine all=True changed=153` -- so the
log said what was asked and never what changed. Every other edit in the
editor logs the field, the value before and the value after.

`LOGGER.change_logger` could not be used as it stands: it reads one
property of one object before and after the call, and `GuildLabData`
holds every guild's lab at once with the guild to edit arriving as an
argument. `GuildLab` is that missing object, a view of the one guild
named, so the write is a change to one property of it and logs itself
the way the rest of the editor does. The mutation loop moves across
unchanged and stops carrying a change count for the log's benefit.

The property is a mapping of research to work amount, and `log_change`
now diffs a mapping the way it already diffed a list: one line per entry
that moved, silent about the ones neither side changed. A research the
save has no row for reads as `no record` rather than `0.0`, because
there was nothing to read and completing it appends a row rather than
raising one that was there. The other two mapping properties,
WorkSuitabilities and AddedWorkSuitabilities, gain the same per-entry
lines in place of a dump of both whole dicts.

The request line stays as the last line of the edit. It is the one thing
the per-research lines cannot say: a category whose research is already
done reports the same nothing as one the request never named.
`replace_MasteredWaza` is decorated with `change_logger("MasteredWaza")`
and also logged the same edit by hand, so every replacement printed
twice: once as the skills added and removed, once as both whole lists.

The hand-written line goes, and the snapshot it needed goes with it.
`replace_EquipWaza` above it keeps its own line -- that one is not
decorated, so it is the only log of that edit.
Highlighting mastered skills with full equip slots (b51ea8a) moved the
three-slot cap from `showEquipMasteredAction` into the equip button's
`disabled`, and dropped the `|| !HIDE_INVALID_OPTIONS` escape on the way.
The cap became unconditional, so cheat options could no longer equip a
fourth active skill -- nothing else enforces three: neither the store nor
`replace_EquipWaza` caps the list.

`isEquipSkillFull` now holds only while cheat options are hidden, which
keeps the greyed-out button and its tooltip for normal editing and gives
the limit the same escape every other game limit in this editor has.

Verified: npm run build and node --test "tests/*.test.js" (229 pass).
@KrisCris
KrisCris merged commit 1b40062 into develop Sep 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant