Equip Legality (weapons, armor, techniques)
The key words MUST / MUST NOT / SHOULD are used as in RFC 2119. This page defines which gear a class can use — weapons (by type), armor (by body type: frame / armor / robe), and which techniques a class can learn (and to what level) — and the one ✕ "cannot use/equip" marker that surfaces it. It is the normative contract behind every screen that shows that marker or gates equipping: the Equip menu, the 3D field weapon picker, storage, and all of the shops.
The invariant: the ✕ marker and the actual equip action MUST agree everywhere. If a row shows no ✕ it MUST equip; if it shows a ✕ it MUST NOT equip. Both sides route through one predicate (EquipmentUtils.item_fits_slot), so they cannot drift — see UI surfaces for the full map.
Contract
- Single source of truth (weapons): a weapon's equippability for a class is determined by
ClassData.allowed_weapon_types(a list ofWeaponTypeids). It is the only input —WeaponData.usable_byMUST NOT be consulted for legality (it has drifted from the class lists; see Issues). - Single source of truth (armor): an armor's equippability for a class is a per-item class restriction,
ArmorData.usable_by(a list of "Type Race" strings, e.g. Robe =Hunter Newman/Force Human/Force Newman). This is the only armor-legality source — there is noClassDataarmor analog — so unlike the weapon list it is not dead data. An emptyusable_bymeans no restriction (every class). Both gates live behindEquipmentUtils.item_fits_slot(item_id, "weapon" | "frame"). - Technique disks (learnability): a disk is "usable" if the class can ever learn its technique. The ✕ marker uses
TechniqueManager.class_can_learn— a permanent capability (a CAST learns nothing; a Hunter can't learn a Force-only group). This is distinct fromTechniqueManager.can_learn, which is the temporary grey (already known, or below the level/class cap) — temporary blocks grey the row but carry no ✕. Disks are never "equipped"; the ✕ is the whole UX. - One shared predicate: every screen that asks "can this be used/equipped?" MUST route through the single gate —
EquipmentUtils.item_fits_slotfor weapons/armor (and its permanent-only ✕ wrapperShopNav.sell_cannot_use, which also folds in the disk rule), andclass_can_learnfor disks. No per-screen class checks (the Alpha pattern this replaces had four divergent copies). - Level gate: equipping a weapon also requires
character.level ≥ weapon.level. A failure here yields a distinct reason ("Requires Lv N"). - Reasons are surfaced: when a weapon can't be equipped, the UI MUST show why (greyed Equip option + reason), never a silent omission.
- Debug bypass:
DebugConfig.equip_all(System menu /PSZ_DEBUG_WEAPONS) bypasses the class + level gates so any weapon can be equipped to verify its model — it MUST affect only the legality gate, nothing else.
Class × weapon-type matrix
Current allowed_weapon_types per class. Columns: Sab·Swd·Dgr·Claw·DSab(Double Saber)·Spr(Spear)·Slc(Slicer)·Hgn(Handgun)·Mgn(Mechgun)·Rfl(Rifle)·Bzk(Bazooka)·Rod·Wnd. (Gun Blade, Shield, and Laser Cannon are omitted — no class allows them; see Issues.)
| Class | Sab | Swd | Dgr | Claw | DSab | Spr | Slc | Hgn | Mgn | Rfl | Bzk | Rod | Wnd |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
HUmar | ✓ | ✓ | ✓ | · | · | ✓ | · | ✓ | · | · | · | · | · |
HUmarl | ✓ | ✓ | ✓ | ✓ | · | · | · | ✓ | · | · | · | · | ✓ |
HUnewm | ✓ | ✓ | ✓ | · | ✓ | ✓ | · | ✓ | · | · | · | · | · |
HUnewearl | ✓ | ✓ | ✓ | ✓ | · | · | ✓ | ✓ | · | · | · | · | · |
HUcast | ✓ | ✓ | ✓ | · | · | ✓ | · | ✓ | · | · | · | · | · |
HUcaseal | ✓ | ✓ | ✓ | ✓ | · | · | · | ✓ | ✓ | · | · | · | · |
RAmar | ✓ | · | · | · | · | ✓ | · | ✓ | ✓ | ✓ | ✓ | · | · |
RAmarl | ✓ | · | ✓ | · | · | · | · | ✓ | ✓ | ✓ | ✓ | · | · |
RAcast | ✓ | ✓ | · | · | · | · | · | ✓ | ✓ | ✓ | ✓ | · | · |
RAcaseal | ✓ | · | · | · | ✓ | · | · | ✓ | ✓ | ✓ | ✓ | · | · |
FOmar | ✓ | · | · | · | · | ✓ | · | ✓ | · | · | · | ✓ | ✓ |
FOmarl | ✓ | · | · | · | · | · | ✓ | ✓ | · | · | · | ✓ | ✓ |
FOnewm | ✓ | · | · | · | · | · | · | ✓ | · | · | · | ✓ | ✓ |
FOnewearl | ✓ | · | · | · | · | · | · | ✓ | · | · | · | ✓ | ✓ |
Broad strokes hold (Hunters = melee + handgun, Rangers = guns + saber, Forces = rod/wand + handgun), and every class can use a Saber + Handgun.
Armor (frame · armor · robe)
Armor comes in three body types. Frames are universal; the heavy/light split is by race + role, not class alone:
| Body type | Equippable by | usable_by |
|---|---|---|
| Frame (universal) | Every class | (empty) |
| Armor (heavy) | Human & Cast Hunters and Rangers | Hunter Human, Hunter Cast, Ranger Human, Ranger Cast |
| Robe (light / caster) | Newman Hunters and all Forces | Hunter Newman, Force Human, Force Newman |
The low-DEF body types — Newmen and Forces — wear robes; Human and Cast Hunters/Rangers wear armor; everyone wears frames. (Rangers exist only as Human or Cast — there is no Newman Ranger — and Forces only as Human or Newman, so every class lands in exactly one of armor/robe.)
| Class | Frame | Armor | Robe |
|---|---|---|---|
HUmar | ✓ | ✓ | · |
HUmarl | ✓ | ✓ | · |
HUnewm | ✓ | · | ✓ |
HUnewearl | ✓ | · | ✓ |
HUcast | ✓ | ✓ | · |
HUcaseal | ✓ | ✓ | · |
RAmar | ✓ | ✓ | · |
RAmarl | ✓ | ✓ | · |
RAcast | ✓ | ✓ | · |
RAcaseal | ✓ | ✓ | · |
FOmar | ✓ | · | ✓ |
FOmarl | ✓ | · | ✓ |
FOnewm | ✓ | · | ✓ |
FOnewearl | ✓ | · | ✓ |
Mechanism. Each armor carries ArmorData.usable_by (a list of "Type Race" strings) — the only source of armor legality (there is no ClassData armor analog), evaluated through EquipmentUtils.item_fits_slot(id, "frame") + DebugConfig.equip_all. The three body types above are the broad rule; an individual piece can narrow it further via its own list (e.g. a cast-only armor, or a robe that also admits Ranger Humans) — that per-item list is the override mechanism. 27 of 50 armors carry a restriction; the rest are open frames.
Technique disks (learnability)
A technique disk (disk_<technique>_<level>) teaches a technique when used. Its ✕ marker means the class can never learn that technique — TechniqueManager.class_can_learn:
- ✕ (permanent): a CAST (learns no techniques at all), or a disk whose technique group the class is barred from (e.g. a Hunter and a Force-only group). This is the permanent block the ✕ marks — same tier as a class-illegal weapon/armor.
- Grey, no ✕ (temporary): the technique is already known, or the disk is above the class's level/level-cap for it —
can_learnreturns false butclass_can_learnstays true. A temporary block greys the row to signal "not useful right now" without the permanent ✕. - Affordability never greys a disk — being unable to pay is a buy-time block, surfaced at accept, not a capability marker.
Class × technique max level
What each class can learn, and the cap (max disk level) per group. — = the class can never learn that group (permanent ✕ on any such disk). The cap lives in ClassData.technique_limits, keyed by group. Columns:
- Atk — the attack lines (
foieBartaZondegroup): Foie/Gifoie/Rafoie, Barta/Gibarta/Rabarta, Zonde/Gizonde/Razonde - Grants (light) · Megid (dark)
- Support — Resta / Anti / Reverser (
restaReverser) - Buff — Shifta / Deband (
shiftaDeband) · Debuff — Jellen / Zalure (jellenZalure)
| Class | Atk | Grants | Megid | Support | Buff | Debuff |
|---|---|---|---|---|---|---|
HUmar | 10 | — | — | 10 | 10 | — |
HUmarl | 10 | — | — | 10 | 10 | — |
HUnewm | 10 | — | — | 10 | 10 | — |
HUnewearl | 10 | — | — | 10 | 10 | — |
HUcast | — | — | — | — | — | — |
HUcaseal | — | — | — | — | — | — |
RAmar | 10 | — | — | 10 | — | 10 |
RAmarl | 10 | — | — | 10 | — | 10 |
RAcast | — | — | — | — | — | — |
RAcaseal | — | — | — | — | — | — |
FOmar | 15 | 15 | 15 | 15 | 15 | 15 |
FOmarl | 15 | 15 | 15 | 15 | 15 | 15 |
FOnewm | 15 | 15 | 15 | 15 | 15 | 15 |
FOnewearl | 15 | 15 | 15 | 15 | 15 | 15 |
Reading it: Forces learn everything to Lv.15. Hunters get attack + Resta + Shifta/Deband to Lv.10 (no Grants/Megid, no Jellen/Zalure). Rangers get attack + Resta + Jellen/Zalure to Lv.10 (no Shifta/Deband). CASTs (all four) learn no techniques — they use traps instead (ClassData.trap_limits, a separate system). So every disk carries the ✕ for a CAST.
Two further gates on top of the class cap (both temporary — grey, no ✕): the player must meet the disk's required character level (get_disk_required_level: Lv.1–5 disks need that level; higher disks need level×2−5), and a disk at or below the technique's current level is rejected as a downgrade / already-known.
UI surfaces — one predicate, everywhere
Every surface below routes its ✕ marker and its equip/use action through the same gate, so marker == action by construction. Weapons/armor go through item_fits_slot (markers via the permanent-only wrapper ShopNav.sell_cannot_use); disks go through class_can_learn.
| Surface | Shows ✕ for | Predicate |
|---|---|---|
| Weapon shop — buy (weapons, armor) | weapon, armor | _check_equippability → item_fits_slot |
| Weapon shop — sell | weapon, armor, disk | ShopNav.sell_cannot_use |
| Item shop — sell | weapon, armor, disk | ShopNav.sell_cannot_use |
| Item shop — buy disks | disk | class_can_learn |
| Crafting / synthesis | weapon (output) | _output_unequippable → item_fits_slot |
| Storage | weapon, armor, disk | storage._item_cannot_use → sell_cannot_use |
| 3D field start menu — items list | weapon, armor, disk | start_menu_renderer._item_cannot_use → sell_cannot_use |
| 3D start menu — equip (action) | weapon, armor | _slot_key_for_inventory_item / _get_equip_candidates → item_fits_slot |
| 2D equipment screen (action) | weapon, armor | item_fits_slot |
| In-field weapon palette swap (action) | weapon | _QuickWeaponMenu._build_weapon_list → item_fits_slot |
Units, mags, materials, and consumables carry no permanent class block, so they never get a ✕ from this gate. (Consumables have their own ConsumableData.usable_by — a separate, legitimate source used only by the item shop's consumable rows.)
The grey tier routes through one predicate too. The temporary "grey, no ✕" disk block — a disk for a technique already known at that level or higher, or below the required player level — MUST mute the row on every item-list surface, not just the buy tab. All of them route it through the single companion predicate ShopNav.sell_disabled (the can_learn-but-not-class_can_learn slice), exactly as the ✕ tier routes through sell_cannot_use: the item and weapon shop SELL lists, the storage deposit/withdraw lists, and the field start-menu inventory. (The item shop's buy-disks tab computes the same slice inline from can_learn ∧ class_can_learn.) So owning five Lv.1 Foie disks and learning Foie Lv.1 greys the other four everywhere they appear — they are still sellable/depositable, just no longer useful to learn.
The grey tier gates the use action too — marker == action holds for grey, not just ✕. Wherever a disk row is greyed by sell_disabled, the surface's "use" affordance MUST also be withdrawn, exactly as a ✕ row withdraws its equip action. Concretely, the 3D field start-menu item modal builds its Use choice from the row's usable flag, and a disk is usable only when it can be learned right now — not sell_cannot_use(id) and not sell_disabled(id). So an already-known (or too-low-level / class-illegal) disk offers no Use, only Drop / Sort: the greyed row and the missing action can never disagree. The disabled row style MUST be clearly distinct from a normal row (a faded text colour and a dimmed icon), so "you can't use this" is unmissable without a ✕.
Beyond the tables: per-item exceptions (planned)
The tables above are the base layer and the right default, but legality won't always reduce to "class × type." Some gear needs a finer, per-item rule a class-wide table can't express:
- Gender-locked weapons — e.g. a particular sword usable by men only, or a slicer by women only. The base table grants the whole class a weapon type; the exception narrows one item to a gender. (
ClassData.genderis Male/Female, so the axis already exists.) - Per-item class bars (balance) — a single weapon barred from a class whose type it otherwise allows (or, rarely, granted to one that doesn't), without retyping or moving the whole weapon type.
The intended shape is tables + a per-item override: the class×type / class×technique-cap / armor-body-type tables decide the default, and an optional per-item allow/deny list (keyed by Type / Race / Gender) narrows or widens a single piece. Armor already works this way — ArmorData.usable_by is the live per-item override layered on the body-type rule. Weapons would gain the same: a cleaned per-item field on top of allowed_weapon_types. The shared predicate (EquipmentUtils.item_fits_slot) stays the single gate — the override is just one more input it folds in, so every UI surface inherits exceptions for free and marker == action still holds.
Techniques are different — the table is already fine-grained. The technique matrix is per-class-per-group, so balance exclusions are just a cap of 0 in ClassData.technique_limits, no new mechanism. RAmar/RAmarl already have shiftaDeband = 0 (no buffs). Note Hunters currently have shiftaDeband = 10 (they can buff) — if Hunters shouldn't, that's a one-value data change, not an exception system. Per-disk exceptions aren't anticipated; the class cap is the lever.
Issues to reconcile
The current data predates this contract and needs a pass (tracked with the legality refactor):
- Variant drift. Same class, different access by race/gender — e.g.
HUmarlacks Claw butHUmarlhas it (plus Wand, a Force weapon); Double Saber appears only onHUnewmandRAcaseal. These look unintended. - Orphan weapon types. Gun Blade (7), Shield (8), Laser Cannon (13) are in no class's list, so any weapon of those types is un-equippable by every class — including
ein-cannon(Laser Cannon). Either grant the type to a class or retype the weapon (as was done movingein-mazurkaGun Blade → Rifle). - Two weapon-legality sources (resolved).
WeaponData.usable_by(class-name strings) andClassData.allowed_weapon_types(type ids) disagree (e.g. Double Saber'susable_bylists all Hunters, but onlyHUnewmallows the type). The contract dropsusable_byfrom weapon legality; the class lists are canonical. (Armor is different — see the armor bullet in the Contract:ArmorData.usable_byis the canonical armor source, not dead.)- Migration status — complete (markers). Every screen that shows a "cannot equip/use" ✕ now routes through the shared gate: the weapon shop buy (
weapon_shop._check_equippability) and sell (ShopNav.sell_cannot_use) tabs, the item shop sell + disk tabs (sell_cannot_use/class_can_learn), the 3D field start menu (start_menu_renderer._item_cannot_use), storage (storage._item_cannot_use), and the crafting output marker (crafting_shop._output_unequippable). All callEquipmentUtils.item_fits_slot(directly or viasell_cannot_use); none callcan_be_used_byfor weapons. - Migration status — complete (equip actions). The ✕ marker is meaningless if the actual equip action disagrees, so the equip actions route through the same gate too: the 2D equipment screen (
EquipmentUtils.item_fits_slot), the 3D start-menu equip picker (_get_equip_candidates) and equip-from-inventory (_slot_key_for_inventory_item), and the in-field weapon palette swap (field_hud._QuickWeaponMenu._build_weapon_list). Two divergences were fixed here: the start menu equipped any armor unconditionally (so class-illegal armor showed a ✕ yet still equipped), and the weapon-swap list used a hand-rolled check that ignoredDebugConfig.equip_all(debug-equipped weapons vanished from the swap list). Marker and action now agree by construction — buying gear that shows no ✕ always equips, and gear that shows a ✕ never does. - Armor restriction restored canonically. An interim version of this contract declared armor unrestricted (
item_fits_slot("frame")checked only the registry), which silently dropped the deliberate per-item armor restrictions (27 of 50 armors carry ausable_bylist). That removed real class restrictions and is reverted:item_fits_slot("frame")now appliesArmorData.usable_by+DebugConfig.equip_all, so the robe-for-HUmar ✕ is back, consistently across all screens.ArmorData.usable_byis therefore the realized "explicit per-item override" model for armor — live, not dead. - Weapon
usable_by: repurpose, don't just delete. The currentWeaponData.usable_bydata (all 360 weapon.tres) is dead — drifted fromallowed_weapon_types, consulted by nothing — and MUST NOT be read by new code. It was originally slated for outright deletion, but the per-item exception need above (gender locks, single-item class bars) is exactly what a per-item weapon override provides. So the preferred path is to repurpose the field as that override — cleaned to a deliberate allow/deny list (Type / Race / Gender) layered onallowed_weapon_types, mirroring howArmorData.usable_byoverrides the armor body-type rule — rather than delete it. Either way the drifted values must be cleared (don't ship them as if meaningful) and a code-health guard should pin that no legality path reads the field until the override is designed. Do not deleteArmorData.usable_by— it is the live armor-legality source above. (ConsumableData.usable_byis also unaffected — consumables have noallowed_weapon_typesanalog, so it remains their legitimate source of truth.)
- Migration status — complete (markers). Every screen that shows a "cannot equip/use" ✕ now routes through the shared gate: the weapon shop buy (
Testing
Two layers (the project's frame/UI rule):
- Seeded
test_runnerunit tests over the shared predicate: a representative weapon per type and a class-restricted vs unrestricted armor against each class, plus level-gate andequip_alldebug-bypass cases. Each ✕-marker site and each equip-action site has its own test asserting it equals the shared predicate (single source of truth):test_humar_gear_unequippable,test_shop_sell_cannot_use_marker,test_synth_unequippable_marker,test_start_menu_cannot_use,test_storage_cannot_use,test_equip_action_matches_marker,test_field_weapon_swap_gate. - Autopilot UI probe (post-build, real scene tree) under the shops smoke (
PSZ_AUTOPILOT_SHOPS=1): on the live equipment screen it seeds a class-legal and a class-illegal weapon and armor and asserts the screen's built equip list (the action) includes exactly the gearitem_fits_slotpermits; and via the live start-menu renderer it asserts an unlearnable disk shows the ✕ while a learnable one does not.[sanity]checkpoints; any mismatch fails the run. This catches the marker/action divergence on the real built screens (load + render + Android export), which the.new()unit tests can't.