Repository: foundryvtt/pf2e
Pull request #19065: Convert ClassSystemData to TypeDataModel
Author: login: CarlosFdez
Labels: name: pr: refactor
Created: 2025-05-11T04:50:46Z | Merged: 2025-05-12T08:03:22Z



Diff:
diff --git a/src/module/item/abc/data.ts b/src/module/item/abc/data.ts
index 357e6e37ac5..8430a4ca043 100644
--- a/src/module/item/abc/data.ts
+++ b/src/module/item/abc/data.ts
@@ -1,4 +1,7 @@
+import type { ImageFilePath } from "@common/constants.d.mts";
+import type { ItemUUID } from "@common/documents/_module.d.mts";
 import { ItemSystemData, ItemSystemSource } from "@item/base/data/system.ts";
+import fields = foundry.data.fields;
 
 interface ABCFeatureEntryData {
     uuid: string;
@@ -13,4 +16,23 @@ interface ABCSystemSource extends ItemSystemSource {
 
 interface ABCSystemData extends Omit<ABCSystemSource, "description">, ItemSystemData {}
 
+type ABCFeatureEntrySchema = {
+    uuid: fields.DocumentUUIDField<ItemUUID, true, false>;
+    img: fields.FilePathField<ImageFilePath, ImageFilePath, true, false>;
+    name: fields.StringField<string, string, true, false>;
+    level: fields.NumberField<number, number, true, false>;
+};
+
+class ABCFeatureEntryField extends fields.SchemaField<ABCFeatureEntrySchema> {
+    constructor() {
+        super({
+            uuid: new fields.DocumentUUIDField({ required: true, nullable: false }),
+            img: new fields.FilePathField({ categories: ["IMAGE"], required: true, nullable: false }),
+            name: new fields.StringField({ required: true, nullable: false }),
+            level: new fields.NumberField({ required: true, nullable: false, integer: true, min: 0 }),
+        });
+    }
+}
+
+export { ABCFeatureEntryField };
 export type { ABCFeatureEntryData, ABCSystemData, ABCSystemSource };
diff --git a/src/module/item/base/sheet/sheet.ts b/src/module/item/base/sheet/sheet.ts
index ca73fd7a573..8c1ec603535 100644
--- a/src/module/item/base/sheet/sheet.ts
+++ b/src/module/item/base/sheet/sheet.ts
@@ -1,6 +1,7 @@
 import type { FormSelectOption } from "@client/applications/forms/fields.d.mts";
 import type { ProseMirrorEditor } from "@client/applications/ux/_module.d.mts";
 import type { ApplicationV1HeaderButton, AppV1RenderOptions } from "@client/appv1/api/application-v1.d.mts";
+import type { DataField } from "@common/data/fields.d.mts";
 import { ItemPF2e } from "@item";
 import type { ItemSourcePF2e } from "@item/base/data/index.ts";
 import type { Rarity } from "@module/data.ts";
@@ -36,6 +37,7 @@ import { createSortable } from "@util/destroyables.ts";
 import { tagify } from "@util/tags.ts";
 import type { Plugin } from "prosemirror-state";
 import * as R from "remeda";
+import { ItemSystemModel } from "../data/model.ts";
 import { CodeMirror } from "./codemirror.ts";
 import { RULE_ELEMENT_FORMS, RuleElementForm } from "./rule-element-form/index.ts";
 
@@ -138,6 +140,7 @@ class ItemSheetPF2e<TItem extends ItemPF2e> extends fav1.sheets.ItemSheet<TItem,
             item,
             isPhysical: false,
             data: item.system,
+            systemFields: this.item.system instanceof ItemSystemModel ? this.item.system.schema.fields : {},
             fieldRootId: this.item.collection?.has(this.item.id) ? this.id : fu.randomID(),
             fieldIdPrefix: `field-${this.appId}-`,
             enrichedContent,
@@ -666,6 +669,7 @@ interface ItemSheetDataPF2e<TItem extends ItemPF2e> extends fav1.sheets.ItemShee
     detailsTemplate: string;
     item: TItem;
     data: TItem["system"];
+    systemFields: Record<string, DataField>;
     /** The leading part of IDs used for label-input/select matching */
     fieldRootId: string;
     /** Legacy value of the above */
diff --git a/src/module/item/class/data.ts b/src/module/item/class/data.ts
index f22619d66dd..0e69e6adb05 100644
--- a/src/module/item/class/data.ts
+++ b/src/module/item/class/data.ts
@@ -1,47 +1,180 @@
-import { AttributeString, SaveType, SkillSlug } from "@actor/types.ts";
-import { ABCSystemData, ABCSystemSource } from "@item/abc/data.ts";
-import { BaseItemSourcePF2e, RarityTraitAndOtherTags } from "@item/base/data/system.ts";
-import { ZeroToFour } from "@module/data.ts";
+import type { AttributeString, SkillSlug } from "@actor/types.ts";
+import { ATTRIBUTE_ABBREVIATIONS } from "@actor/values.ts";
+import type { ModelPropsFromSchema, SourceFromSchema } from "@common/data/fields.mjs";
+import { ABCFeatureEntryField } from "@item/abc/data.ts";
+import { ItemSystemModel, ItemSystemSchema } from "@item/base/data/model.ts";
+import type { BaseItemSourcePF2e, ItemSystemSource, RarityTraitAndOtherTags } from "@item/base/data/system.ts";
+import { getCompatSkills, ProficiencyRankField, RarityField } from "@module/model.ts";
+import { LaxArrayField, RecordField, SlugField } from "@system/schema-data-fields.ts";
+import type { ClassPF2e } from "./document.ts";
+import fields = foundry.data.fields;
 
 type ClassSource = BaseItemSourcePF2e<"class", ClassSystemSource>;
 
-interface ClassSystemSource extends ABCSystemSource {
-    traits: RarityTraitAndOtherTags;
-    keyAbility: { value: AttributeString[]; selected: AttributeString | null };
-    hp: number;
-    perception: ZeroToFour;
-    savingThrows: Record<SaveType, ZeroToFour>;
-    attacks: ClassAttackProficiencies;
-    defenses: ClassDefenseProficiencies;
-    /** Starting proficiency in "spell attack rolls and DCs" */
-    spellcasting: ZeroToFour;
-    trainedSkills: {
-        value: SkillSlug[];
-        additional: number;
-    };
-    ancestryFeatLevels: { value: number[] };
-    classFeatLevels: { value: number[] };
-    generalFeatLevels: { value: number[] };
-    skillFeatLevels: { value: number[] };
-    skillIncreaseLevels: { value: number[] };
-    level?: never;
-}
+class ClassSystemData extends ItemSystemModel<ClassPF2e, ClassSystemSchema> {
+    static override LOCALIZATION_PREFIXES = ["PF2E.Item.Class"];
 
-interface ClassSystemData extends Omit<ClassSystemSource, "description">, Omit<ABCSystemData, "level" | "traits"> {}
+    static override defineSchema(): ClassSystemSchema {
+        const createLevelLists = (
+            initial?: number[],
+        ): fields.SchemaField<{
+            value: fields.ArrayField<fields.NumberField<number, number, true, false>>;
+        }> =>
+            new fields.SchemaField({
+                value: new fields.ArrayField(new fields.NumberField({ required: true, min: 1, nullable: false }), {
+                    initial,
+                }),
+            });
 
-interface ClassAttackProficiencies {
-    simple: ZeroToFour;
-    martial: ZeroToFour;
-    advanced: ZeroToFour;
-    unarmed: ZeroToFour;
-    other: { name: string; rank: ZeroToFour };
+        return {
+            ...super.defineSchema(),
+            items: new RecordField(
+                new fields.StringField({ required: true, nullable: false }),
+                new ABCFeatureEntryField(),
+            ),
+            traits: new fields.SchemaField({
+                otherTags: new fields.ArrayField(
+                    new SlugField({ required: true, nullable: false, initial: undefined }),
+                ),
+                rarity: new RarityField(),
+            }),
+            keyAbility: new fields.SchemaField({
+                value: new fields.ArrayField(
+                    new fields.StringField({ choices: [...ATTRIBUTE_ABBREVIATIONS], required: true, nullable: false }),
+                ),
+                selected: new fields.StringField({
+                    choices: [...ATTRIBUTE_ABBREVIATIONS],
+                    required: true,
+                    nullable: true,
+                    initial: null,
+                }),
+            }),
+            hp: new fields.NumberField({
+                required: true,
+                nullable: false,
+                min: 6,
+                max: 12,
+                initial: 6,
+            }),
+            perception: new ProficiencyRankField(),
+            savingThrows: new fields.SchemaField({
+                fortitude: new ProficiencyRankField(),
+                reflex: new ProficiencyRankField(),
+                will: new ProficiencyRankField(),
+            }),
+            attacks: new fields.SchemaField({
+                simple: new ProficiencyRankField(),
+                martial: new ProficiencyRankField(),
+                advanced: new ProficiencyRankField(),
+                unarmed: new ProficiencyRankField(),
+                other: new fields.SchemaField({
+                    name: new fields.StringField(),
+                    rank: new ProficiencyRankField(),
+                }),
+            }),
+            defenses: new fields.SchemaField({
+                unarmored: new ProficiencyRankField(),
+                light: new ProficiencyRankField(),
+                medium: new ProficiencyRankField(),
+                heavy: new ProficiencyRankField(),
+            }),
+            spellcasting: new ProficiencyRankField(),
+            trainedSkills: new fields.SchemaField({
+                value: new LaxArrayField(
+                    new fields.StringField({ choices: getCompatSkills, required: true, nullable: false }),
+                ),
+                additional: new fields.NumberField({
+                    required: true,
+                    nullable: false,
+                    min: 0,
+                    integer: true,
+                    initial: 0,
+                }),
+            }),
+            ancestryFeatLevels: createLevelLists([1, 5, 9, 13, 17]),
+            classFeatLevels: createLevelLists([1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20]),
+            generalFeatLevels: createLevelLists([3, 7, 11, 15, 19]),
+            skillFeatLevels: createLevelLists([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]),
+            skillIncreaseLevels: createLevelLists([3, 5, 7, 9, 11, 13, 15, 17, 19]),
+        };
+    }
 }
 
-interface ClassDefenseProficiencies {
-    unarmored: ZeroToFour;
-    light: ZeroToFour;
-    medium: ZeroToFour;
-    heavy: ZeroToFour;
+interface ClassSystemData
+    extends ItemSystemModel<ClassPF2e, ClassSystemSchema>,
+        Omit<fields.ModelPropsFromSchema<ClassSystemSchema>, "description"> {
+    level?: never;
+    traits: RarityTraitAndOtherTags;
 }
 
-export type { ClassAttackProficiencies, ClassDefenseProficiencies, ClassSource, ClassSystemData, ClassSystemSource };
+type ClassSystemSchema = Omit<ItemSystemSchema, "traits"> & {
+    items: RecordField<fields.StringField<string, string, true, false>, ABCFeatureEntryField>;
+    traits: fields.SchemaField<{
+        otherTags: fields.ArrayField<SlugField<true, false, false>>;
+        rarity: RarityField;
+    }>;
+    keyAbility: fields.SchemaField<{
+        value: fields.ArrayField<fields.StringField<AttributeString, AttributeString, true, false>>;
+        selected: fields.StringField<AttributeString, AttributeString, true, true, true>;
+    }>;
+    hp: fields.NumberField<number, number, true, false, true>;
+    perception: ProficiencyRankField;
+    savingThrows: fields.SchemaField<{
+        fortitude: ProficiencyRankField;
+        reflex: ProficiencyRankField;
+        will: ProficiencyRankField;
+    }>;
+    attacks: fields.SchemaField<ClassAttackProficienciesSchema>;
+    defenses: fields.SchemaField<ClassDefenseProficienciesSchema>;
+    /** Starting proficiency in "spell attack rolls and DCs" */
+    spellcasting: ProficiencyRankField;
+    trainedSkills: fields.SchemaField<{
+        value: LaxArrayField<fields.StringField<SkillSlug, SkillSlug, true, false>>;
+        additional: fields.NumberField<number, number, true, false, true>;
+    }>;
+    ancestryFeatLevels: fields.SchemaField<{
+        value: fields.ArrayField<fields.NumberField<number, number, true, false>>;
+    }>;
+    classFeatLevels: fields.SchemaField<{
+        value: fields.ArrayField<fields.NumberField<number, number, true, false>>;
+    }>;
+    generalFeatLevels: fields.SchemaField<{
+        value: fields.ArrayField<fields.NumberField<number, number, true, false>>;
+    }>;
+    skillFeatLevels: fields.SchemaField<{
+        value: fields.ArrayField<fields.NumberField<number, number, true, false>>;
+    }>;
+    skillIncreaseLevels: fields.SchemaField<{
+        value: fields.ArrayField<fields.NumberField<number, number, true, false>>;
+    }>;
+};
+
+type ClassAttackProficienciesSchema = {
+    simple: ProficiencyRankField;
+    martial: ProficiencyRankField;
+    advanced: ProficiencyRankField;
+    unarmed: ProficiencyRankField;
+    other: fields.SchemaField<{
+        name: fields.StringField;
+        rank: ProficiencyRankField;
+    }>;
+};
+
+type ClassDefenseProficienciesSchema = {
+    unarmored: ProficiencyRankField;
+    light: ProficiencyRankField;
+    medium: ProficiencyRankField;
+    heavy: ProficiencyRankField;
+};
+
+type ClassAttackProficiencies = ModelPropsFromSchema<ClassAttackProficienciesSchema>;
+type ClassDefenseProficiencies = ModelPropsFromSchema<ClassDefenseProficienciesSchema>;
+
+type ClassSystemSource = SourceFromSchema<ClassSystemSchema> & {
+    level?: never;
+    schema?: ItemSystemSource["schema"];
+    traits: RarityTraitAndOtherTags;
+};
+
+export { ClassSystemData };
+export type { ClassAttackProficiencies, ClassDefenseProficiencies, ClassSource, ClassSystemSource };
diff --git a/src/module/item/class/sheet.ts b/src/module/item/class/sheet.ts
index a22e8bf2c20..abed712c8a7 100644
--- a/src/module/item/class/sheet.ts
+++ b/src/module/item/class/sheet.ts
@@ -1,6 +1,7 @@
 import { ItemSheetOptions } from "@item/base/sheet/sheet.ts";
 import type { ClassPF2e } from "@item/class/document.ts";
 import { createSheetTags, SheetOptions } from "@module/sheet/helpers.ts";
+import * as R from "remeda";
 import { ABCSheetData, ABCSheetPF2e } from "../abc/sheet.ts";
 
 export class ClassSheetPF2e extends ABCSheetPF2e<ClassPF2e> {
@@ -10,7 +11,7 @@ export class ClassSheetPF2e extends ABCSheetPF2e<ClassPF2e> {
 
         return {
             ...sheetData,
-            proficiencyChoices: CONFIG.PF2E.proficiencyLevels,
+            proficiencyChoices: R.mapValues(CONFIG.PF2E.proficiencyLevels, (v) => game.i18n.localize(v)),
             selectedKeyAbility: this.getLocalizedAbilities(itemData.system.keyAbility),
             trainedSkills: createSheetTags(CONFIG.PF2E.skills, itemData.system.trainedSkills),
             ancestryFeatLevels: createSheetTags(CONFIG.PF2E.levels, itemData.system.ancestryFeatLevels),
@@ -23,7 +24,7 @@ export class ClassSheetPF2e extends ABCSheetPF2e<ClassPF2e> {
 }
 
 interface ClassSheetData extends ABCSheetData<ClassPF2e> {
-    proficiencyChoices: typeof CONFIG.PF2E.proficiencyLevels;
+    proficiencyChoices: Record<number, string>;
     selectedKeyAbility: Record<string, string>;
     trainedSkills: SheetOptions;
     ancestryFeatLevels: SheetOptions;
diff --git a/src/module/model.ts b/src/module/model.ts
index 965ef678f57..9054415aa98 100644
--- a/src/module/model.ts
+++ b/src/module/model.ts
@@ -1,4 +1,5 @@
-import type { Rarity } from "./data.ts";
+import type { SkillSlug } from "@actor/types.ts";
+import type { Rarity, ZeroToFour } from "./data.ts";
 import fields = foundry.data.fields;
 
 class RarityField extends fields.StringField<Rarity, Rarity, true, false, true> {
@@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            min: 0,
+            max: 4,
+            integer: true,
+            required: true,
+            nullable: false,
+            initial: 0,
+        });
+    }
+}
+
 class PublicationField extends fields.SchemaField<
     PublicationSchema,
     fields.SourceFromSchema<PublicationSchema>,
@@ -39,4 +53,31 @@ type PublicationSchema = {
     remaster: fields.BooleanField;
 };
 
-export { PublicationField, RarityField };
+const SKILL_ABBREVIATIONS = [
+    "acr",
+    "arc",
+    "ath",
+    "cra",
+    "dec",
+    "dip",
+    "itm",
+    "med",
+    "nat",
+    "occ",
+    "prf",
+    "rel",
+    "soc",
+    "ste",
+    "sur",
+    "thi",
+];
+
+/**
+ * A function to generate choices for data models that include both old and new skill slugs, for compatibility purposes.
+ * @todo: remove once migrations are functional for structural changes
+ */
+function getCompatSkills(): SkillSlug[] {
+    return [...SKILL_ABBREVIATIONS, ...Object.keys(CONFIG.PF2E.skills)] as unknown as SkillSlug[];
+}
+
+export { getCompatSkills, ProficiencyRankField, PublicationField, RarityField };
diff --git a/src/scripts/hooks/load.ts b/src/scripts/hooks/load.ts
index fb0c5cae590..c4a483f8db2 100644
--- a/src/scripts/hooks/load.ts
+++ b/src/scripts/hooks/load.ts
@@ -12,6 +12,7 @@ import { ItemProxyPF2e } from "@item";
 import { AbilitySystemData } from "@item/ability/index.ts";
 import { AfflictionSystemData } from "@item/affliction/data.ts";
 import { CampaignFeatureSystemData } from "@item/campaign-feature/data.ts";
+import { ClassSystemData } from "@item/class/data.ts";
 import { ConditionSystemData } from "@item/condition/data.ts";
 import { EffectSystemData } from "@item/effect/data.ts";
 import { FeatSystemData } from "@item/feat/data.ts";
@@ -105,6 +106,7 @@ export const Load = {
         }
         CONFIG.Item.dataModels.action = AbilitySystemData;
         CONFIG.Item.dataModels.campaignFeature = CampaignFeatureSystemData;
+        CONFIG.Item.dataModels.class = ClassSystemData;
         CONFIG.Item.dataModels.condition = ConditionSystemData;
         CONFIG.Item.dataModels.effect = EffectSystemData;
         CONFIG.Item.dataModels.feat = FeatSystemData;
diff --git a/static/lang/en.json b/static/lang/en.json
index 91ea5ac4cac..699d9d430a9 100644
--- a/static/lang/en.json
+++ b/static/lang/en.json
@@ -1848,6 +1848,61 @@
             },
             "CannotAddType": "{type} items cannot be added to this actor.",
             "Class": {
+                "FIELDS": {
+                    "hp": {
+                        "label": "Hit Points"
+                    },
+                    "perception": {
+                        "label": "Perception"
+                    },
+                    "savingThrows": {
+                        "fortitude": {
+                            "label": "Fortitude"
+                        },
+                        "reflex": {
+                            "label": "Reflex"
+                        },
+                        "will": {
+                            "label": "Will"
+                        }
+                    },
+                    "attacks": {
+                        "simple": {
+                            "label": "Simple Weapons"
+                        },
+                        "martial": {
+                            "label": "Martial Weapons"
+                        },
+                        "advanced": {
+                            "label": "Advanced Weapons"
+                        },
+                        "unarmed": {
+                            "label": "Unarmed Attacks"
+                        }
+                    },
+                    "defenses": {
+                        "light": {
+                            "label": "Light Armor"
+                        },
+                        "medium": {
+                            "label": "Medium Armor"
+                        },
+                        "heavy": {
+                            "label": "Heavy Armor"
+                        },
+                        "unarmored": {
+                            "label": "Unarmored Defense"
+                        }
+                    },
+                    "spellcasting": {
+                        "label": "Spell Attack Modifiers & DCs"
+                    },
+                    "trainedSkills": {
+                        "additional": {
+                            "label": "Additional Skills"
+                        }
+                    }
+                },
                 "Plural": "Classes"
             },
             "Condition": {
diff --git a/static/template.json b/static/template.json
index b3b8b5a6956..8ee34b39e84 100644
--- a/static/template.json
+++ b/static/template.json
@@ -526,105 +526,6 @@
                 "lore": []
             }
         },
-        "class": {
-            "templates": [
-                "common",
-                "traits",
-                "rarity"
-            ],
-            "keyAbility": {
-                "value": []
-            },
-            "items": {},
-            "hp": 0,
-            "trainedSkills": {
-                "value": [],
-                "additional": 0
-            },
-            "perception": 0,
-            "savingThrows": {
-                "fortitude": 0,
-                "reflex": 0,
-                "will": 0
-            },
-            "attacks": {
-                "simple": 0,
-                "martial": 0,
-                "advanced": 0,
-                "unarmed": 0,
-                "other": {
-                    "name": "",
-                    "rank": 0
-                }
-            },
-            "defenses": {
-                "unarmored": 0,
-                "light": 0,
-                "medium": 0,
-                "heavy": 0
-            },
-            "spellcasting": 0,
-            "ancestryFeatLevels": {
-                "value": [
-                    1,
-                    5,
-                    9,
-                    13,
-                    17
-                ]
-            },
-            "classFeatLevels": {
-                "value": [
-                    1,
-                    2,
-                    4,
-                    6,
-                    8,
-                    10,
-                    12,
-                    14,
-                    16,
-                    18,
-                    20
-                ]
-            },
-            "generalFeatLevels": {
-                "value": [
-                    3,
-                    7,
-                    11,
-                    15,
-                    19
-                ]
-            },
-            "skillFeatLevels": {
-                "value": [
-                    2,
-                    4,
-                    6,
-                    8,
-                    10,
-                    12,
-                    14,
-                    16,
-                    18,
-                    20
-                ]
-            },
-            "skillIncreaseLevels": {
-                "value": [
-                    3,
-                    5,
-                    7,
-                    9,
-                    11,
-                    13,
-                    15,
-                    17,
-                    19
-                ]
-            }
-        },
         "lore": {
             "templates": [
                 "common"
diff --git a/static/templates/items/class-details.hbs b/static/templates/items/class-details.hbs
index b5137e386c1..654617faa05 100644
--- a/static/templates/items/class-details.hbs
+++ b/static/templates/items/class-details.hbs
@@ -1,8 +1,5 @@
 <fieldset class="basics">
-    <div class="form-group">
-        <label>{{localize "PF2E.HitPointsHeader"}}</label>
-        <input type="number" name="system.hp" min="6" max="12" value="{{data.hp}}">
-    </div>
+    {{formGroup systemFields.hp value=data.hp}}
 
     <div class="form-group form-group-trait">
         <label class="pf-title">
@@ -24,120 +21,43 @@
         </ul>
     </div>
 
-    <div class="form-group">
-        <label>{{localize "PF2E.PerceptionLabel"}}</label>
-        <select name="system.perception" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.perception localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.perception value=data.perception choices=proficiencyChoices}}
 </fieldset>
 
 <fieldset>
     <legend>{{localize "PF2E.SavesHeader"}}</legend>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesFortitude"}}</label>
-        <select name="system.savingThrows.fortitude" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.fortitude localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesReflex"}}</label>
-        <select name="system.savingThrows.reflex" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.reflex localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesWill"}}</label>
-        <select name="system.savingThrows.will" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.will localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.savingThrows.fields.fortitude value=data.savingThrows.fortitude choices=proficiencyChoices}}
+    {{formGroup systemFields.savingThrows.fields.reflex value=data.savingThrows.reflex choices=proficiencyChoices}}
+    {{formGroup systemFields.savingThrows.fields.will value=data.savingThrows.will choices=proficiencyChoices}}
 </fieldset>
 
 <fieldset>
     <legend>{{localize "PF2E.Actor.Character.Proficiency.Attack.Title"}}</legend>
 
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Simple"}}</label>
-        <select name="system.attacks.simple" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.simple localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Martial"}}</label>
-        <select name="system.attacks.martial" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.martial localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Advanced"}}</label>
-        <select name="system.attacks.advanced" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.advanced localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Unarmed"}}</label>
-        <select name="system.attacks.unarmed" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.unarmed localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.attacks.fields.simple value=data.attacks.simple choices=proficiencyChoices}}
+    {{formGroup systemFields.attacks.fields.martial value=data.attacks.martial choices=proficiencyChoices}}
+    {{formGroup systemFields.attacks.fields.advanced value=data.attacks.advanced choices=proficiencyChoices}}
+    {{formGroup systemFields.attacks.fields.unarmed value=data.attacks.unarmed choices=proficiencyChoices}}
 
     <div class="form-group other-weapons">
         <label>{{localize "PF2E.WeaponTypeSpecial"}}</label>
-        <input type="text" name="system.attacks.other.name" value="{{data.attacks.other.name}}">
-        <select name="system.attacks.other.rank" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.other.rank localize=true}}
-        </select>
+        {{formInput systemFields.attacks.fields.other.fields.name value=data.attacks.other.name}}
+        {{formInput systemFields.attacks.fields.other.fields.rank value=data.attacks.other.rank choices=proficiencyChoices}}
     </div>
 </fieldset>
 
 <fieldset>
     <legend>{{localize "PF2E.Actor.Character.Proficiency.Defense.Title"}}</legend>
 
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Defense.Light"}}</label>
-        <select name="system.defenses.light" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.defenses.light localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Defense.Medium"}}</label>
-        <select name="system.defenses.medium" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.defenses.medium localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Defense.Heavy"}}</label>
-        <select name="system.defenses.heavy" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.defenses.heavy localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Defense.Unarmored"}}</label>
-        <select name="system.defenses.unarmored" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.defenses.unarmored localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.defenses.fields.light value=data.defenses.light choices=proficiencyChoices}}
+    {{formGroup systemFields.defenses.fields.medium value=data.defenses.medium choices=proficiencyChoices}}
+    {{formGroup systemFields.defenses.fields.heavy value=data.defenses.heavy choices=proficiencyChoices}}
+    {{formGroup systemFields.defenses.fields.unarmored value=data.defenses.unarmored choices=proficiencyChoices}}
 </fieldset>
 
 <fieldset>
     <legend>{{localize "PF2E.Item.Spell.Plural"}}</legend>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Creature.Spellcasting.Label"}}</label>
-        <select name="system.spellcasting" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.spellcasting localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.spellcasting value=data.spellcasting choices=proficiencyChoices}}
 </fieldset>
 
 <fieldset>
@@ -178,10 +98,9 @@
         </ul>
     </div>
 
-
     <div class="form-group additional-language">
         <label>{{localize "PF2E.AdditionalSkills"}}</label>
-        <input type="number" name="system.trainedSkills.additional" value="{{data.trainedSkills.additional}}">
+        {{formInput systemFields.trainedSkills.fields.additional value=data.trainedSkills.additional}}
         <span>{{localize "PF2E.AdditionalLanguagePlusInt"}}</span>
     </div>
 </fieldset>
diff --git a/types/foundry/common/data/_types.d.mts b/types/foundry/common/data/_types.d.mts
index 3226b0e24e0..59240209458 100644
--- a/types/foundry/common/data/_types.d.mts
+++ b/types/foundry/common/data/_types.d.mts
@@ -238,7 +238,7 @@ interface NumberFieldOptions<
 
     choices?:
         | readonly TSourceProp[]
-        | Record<string, number>
+        | Record<string | number, string>
         | (() => readonly TSourceProp[] | Record<string | number, string>);
 }
 


Reviews:
user: login: stwlam
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T10:49:07Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T18:47:09Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T18:48:07Z
user: login: stwlam
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T18:49:25Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T21:50:45Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T21:51:09Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T21:59:17Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T22:05:51Z
user: login: stwlam
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T01:39:44Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T01:57:08Z
user: login: stwlam
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T03:01:04Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T05:35:35Z
user: login: stwlam
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T06:58:38Z
user: login: CarlosFdez
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T07:28:31Z
user: login: stwlam
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T07:36:34Z
user: login: stwlam
body: Needs localization fix
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T07:36:47Z
user: login: stwlam
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-12T08:02:43Z

Review comments:
diff_hunk: @@ -1,4 +1,7 @@
+import type { ImageFilePath } from "@common/constants.mjs";
+import type { ItemUUID } from "@common/documents/_module.mjs";
path: src/module/item/abc/data.ts
user: login: stwlam
body: ```suggestion
import type { ImageFilePath } from "@common/constants.d.mts";
import type { ItemUUID } from "@common/documents/_module.d.mts";
```
created_at: 2025-05-11T10:44:40Z
author_association: COLLABORATOR
diff_hunk: @@ -1,6 +1,7 @@
 import type { FormSelectOption } from "@client/applications/forms/fields.d.mts";
 import type { ProseMirrorEditor } from "@client/applications/ux/_module.d.mts";
 import type { ApplicationV1HeaderButton, AppV1RenderOptions } from "@client/appv1/api/application-v1.d.mts";
+import type { SchemaField } from "@common/data/fields.mjs";
path: src/module/item/base/sheet/sheet.ts
user: login: stwlam
body: ```suggestion
import type { SchemaField } from "@common/data/fields.d.mts";
```
created_at: 2025-05-11T10:45:02Z
author_association: COLLABORATOR
diff_hunk: @@ -1,47 +1,201 @@
-import { AttributeString, SaveType, SkillSlug } from "@actor/types.ts";
-import { ABCSystemData, ABCSystemSource } from "@item/abc/data.ts";
-import { BaseItemSourcePF2e, RarityTraitAndOtherTags } from "@item/base/data/system.ts";
-import { ZeroToFour } from "@module/data.ts";
+import type { AttributeString, SkillSlug } from "@actor/types.ts";
+import { ATTRIBUTE_ABBREVIATIONS } from "@actor/values.ts";
+import type { ModelPropsFromSchema, SourceFromSchema } from "@common/data/fields.mjs";
+import { ABCFeatureEntryField } from "@item/abc/data.ts";
+import { ItemSystemModel, ItemSystemSchema } from "@item/base/data/model.ts";
+import type { BaseItemSourcePF2e, ItemSystemSource, RarityTraitAndOtherTags } from "@item/base/data/system.ts";
+import { ProficiencyRankField, RarityField } from "@module/model.ts";
+import { LaxArrayField, RecordField, SlugField } from "@system/schema-data-fields.ts";
+import type { ClassPF2e } from "./document.ts";
+import fields = foundry.data.fields;
 
 type ClassSource = BaseItemSourcePF2e<"class", ClassSystemSource>;
 
-interface ClassSystemSource extends ABCSystemSource {
-    traits: RarityTraitAndOtherTags;
-    keyAbility: { value: AttributeString[]; selected: AttributeString | null };
-    hp: number;
-    perception: ZeroToFour;
-    savingThrows: Record<SaveType, ZeroToFour>;
-    attacks: ClassAttackProficiencies;
-    defenses: ClassDefenseProficiencies;
-    /** Starting proficiency in "spell attack rolls and DCs" */
-    spellcasting: ZeroToFour;
-    trainedSkills: {
-        value: SkillSlug[];
-        additional: number;
-    };
-    ancestryFeatLevels: { value: number[] };
-    classFeatLevels: { value: number[] };
-    generalFeatLevels: { value: number[] };
-    skillFeatLevels: { value: number[] };
-    skillIncreaseLevels: { value: number[] };
-    level?: never;
+/** A list of old skill abbreviations meant to allow the data models to be migrateable in the meantime. */
+function getCompatSkills(): SkillSlug[] {
+    return [
+        "acr",
+        "arc",
+        "ath",
+        "cra",
+        "dec",
+        "dip",
+        "itm",
+        "med",
+        "nat",
+        "occ",
+        "prf",
+        "rel",
+        "soc",
+        "ste",
+        "sur",
+        "thi",
+        ...Object.keys(CONFIG.PF2E.skills),
+    ] as unknown as SkillSlug[];
 }
path: src/module/item/class/data.ts
user: login: stwlam
body: maybe we can save some lines here by defining the short names separately?
created_at: 2025-05-11T10:45:56Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: stwlam
body: Can't localize because the field can be constructed before localization is initialized.
created_at: 2025-05-11T10:46:56Z
author_association: COLLABORATOR
diff_hunk: @@ -24,69 +21,23 @@
         </ul>
     </div>
 
-    <div class="form-group">
-        <label>{{localize "PF2E.PerceptionLabel"}}</label>
-        <select name="system.perception" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.perception localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.perception value=data.perception}}
 </fieldset>
 
 <fieldset>
     <legend>{{localize "PF2E.SavesHeader"}}</legend>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesFortitude"}}</label>
-        <select name="system.savingThrows.fortitude" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.fortitude localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesReflex"}}</label>
-        <select name="system.savingThrows.reflex" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.reflex localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesWill"}}</label>
-        <select name="system.savingThrows.will" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.will localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.savingThrows.fields.fortitude value=data.savingThrows.fortitude}}
+    {{formGroup systemFields.savingThrows.fields.reflex value=data.savingThrows.reflex}}
+    {{formGroup systemFields.savingThrows.fields.will value=data.savingThrows.will}}
 </fieldset>
 
 <fieldset>
     <legend>{{localize "PF2E.Actor.Character.Proficiency.Attack.Title"}}</legend>
 
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Simple"}}</label>
-        <select name="system.attacks.simple" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.simple localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Martial"}}</label>
-        <select name="system.attacks.martial" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.martial localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Advanced"}}</label>
-        <select name="system.attacks.advanced" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.advanced localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Unarmed"}}</label>
-        <select name="system.attacks.unarmed" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.unarmed localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.attacks.fields.simple value=data.attacks.simple}}
+    {{formGroup systemFields.attacks.fields.martial value=data.attacks.martial}}
+    {{formGroup systemFields.attacks.fields.advanced value=data.attacks.advanced}}
+    {{formGroup systemFields.attacks.fields.unarmed value=data.attacks.unarmed}}
 
     <div class="form-group other-weapons">
path: static/templates/items/class-details.hbs
user: login: stwlam
body: Try `{{formInput}}`
created_at: 2025-05-11T10:47:54Z
author_association: COLLABORATOR
diff_hunk: @@ -178,10 +100,9 @@
         </ul>
     </div>
 
-
     <div class="form-group additional-language">
         <label>{{localize "PF2E.AdditionalSkills"}}</label>
-        <input type="number" name="system.trainedSkills.additional" value="{{data.trainedSkills.additional}}">
+        {{formInput systemFields.trainedSkills.fields.additional value=data.trainedSkills.additional}}
         <span>{{localize "PF2E.AdditionalLanguagePlusInt"}}</span>
     </div>
path: static/templates/items/class-details.hbs
user: login: stwlam
body: Isn't this just a standard form group?
created_at: 2025-05-11T10:48:36Z
author_association: COLLABORATOR
diff_hunk: @@ -178,10 +100,9 @@
         </ul>
     </div>
 
-
     <div class="form-group additional-language">
         <label>{{localize "PF2E.AdditionalSkills"}}</label>
-        <input type="number" name="system.trainedSkills.additional" value="{{data.trainedSkills.additional}}">
+        {{formInput systemFields.trainedSkills.fields.additional value=data.trainedSkills.additional}}
         <span>{{localize "PF2E.AdditionalLanguagePlusInt"}}</span>
     </div>
path: static/templates/items/class-details.hbs
user: login: CarlosFdez
body: If it wasn't for the span afterwards, yeah. 
created_at: 2025-05-11T18:47:09Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: CarlosFdez
body: So go with localize=true in the proficiency inputs after all like I did initially?
created_at: 2025-05-11T18:48:07Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: stwlam
body: Localize them in the `getData` as needed
created_at: 2025-05-11T18:49:25Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: CarlosFdez
body: We need them localized in {{formGroup}} specifically. They're smart enough to render number fields as selects if a choices exists.
created_at: 2025-05-11T21:50:45Z
author_association: COLLABORATOR
diff_hunk: @@ -24,69 +21,23 @@
         </ul>
     </div>
 
-    <div class="form-group">
-        <label>{{localize "PF2E.PerceptionLabel"}}</label>
-        <select name="system.perception" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.perception localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.perception value=data.perception}}
 </fieldset>
 
 <fieldset>
     <legend>{{localize "PF2E.SavesHeader"}}</legend>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesFortitude"}}</label>
-        <select name="system.savingThrows.fortitude" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.fortitude localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesReflex"}}</label>
-        <select name="system.savingThrows.reflex" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.reflex localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.SavesWill"}}</label>
-        <select name="system.savingThrows.will" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.savingThrows.will localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.savingThrows.fields.fortitude value=data.savingThrows.fortitude}}
+    {{formGroup systemFields.savingThrows.fields.reflex value=data.savingThrows.reflex}}
+    {{formGroup systemFields.savingThrows.fields.will value=data.savingThrows.will}}
 </fieldset>
 
 <fieldset>
     <legend>{{localize "PF2E.Actor.Character.Proficiency.Attack.Title"}}</legend>
 
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Simple"}}</label>
-        <select name="system.attacks.simple" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.simple localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Martial"}}</label>
-        <select name="system.attacks.martial" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.martial localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Advanced"}}</label>
-        <select name="system.attacks.advanced" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.advanced localize=true}}
-        </select>
-    </div>
-
-    <div class="form-group">
-        <label>{{localize "PF2E.Actor.Character.Proficiency.Attack.Unarmed"}}</label>
-        <select name="system.attacks.unarmed" data-dtype="Number">
-            {{selectOptions proficiencyChoices selected=data.attacks.unarmed localize=true}}
-        </select>
-    </div>
+    {{formGroup systemFields.attacks.fields.simple value=data.attacks.simple}}
+    {{formGroup systemFields.attacks.fields.martial value=data.attacks.martial}}
+    {{formGroup systemFields.attacks.fields.advanced value=data.attacks.advanced}}
+    {{formGroup systemFields.attacks.fields.unarmed value=data.attacks.unarmed}}
 
     <div class="form-group other-weapons">
path: static/templates/items/class-details.hbs
user: login: CarlosFdez
body: For what? The form groups? Or the other weapons?
created_at: 2025-05-11T21:51:09Z
author_association: COLLABORATOR
diff_hunk: @@ -1,4 +1,7 @@
+import type { ImageFilePath } from "@common/constants.mjs";
+import type { ItemUUID } from "@common/documents/_module.mjs";
path: src/module/item/abc/data.ts
user: login: CarlosFdez
body: Done but we need a way to inform the editor to pick the right import somehow
created_at: 2025-05-11T21:59:17Z
author_association: COLLABORATOR
diff_hunk: @@ -1,47 +1,201 @@
-import { AttributeString, SaveType, SkillSlug } from "@actor/types.ts";
-import { ABCSystemData, ABCSystemSource } from "@item/abc/data.ts";
-import { BaseItemSourcePF2e, RarityTraitAndOtherTags } from "@item/base/data/system.ts";
-import { ZeroToFour } from "@module/data.ts";
+import type { AttributeString, SkillSlug } from "@actor/types.ts";
+import { ATTRIBUTE_ABBREVIATIONS } from "@actor/values.ts";
+import type { ModelPropsFromSchema, SourceFromSchema } from "@common/data/fields.mjs";
+import { ABCFeatureEntryField } from "@item/abc/data.ts";
+import { ItemSystemModel, ItemSystemSchema } from "@item/base/data/model.ts";
+import type { BaseItemSourcePF2e, ItemSystemSource, RarityTraitAndOtherTags } from "@item/base/data/system.ts";
+import { ProficiencyRankField, RarityField } from "@module/model.ts";
+import { LaxArrayField, RecordField, SlugField } from "@system/schema-data-fields.ts";
+import type { ClassPF2e } from "./document.ts";
+import fields = foundry.data.fields;
 
 type ClassSource = BaseItemSourcePF2e<"class", ClassSystemSource>;
 
-interface ClassSystemSource extends ABCSystemSource {
-    traits: RarityTraitAndOtherTags;
-    keyAbility: { value: AttributeString[]; selected: AttributeString | null };
-    hp: number;
-    perception: ZeroToFour;
-    savingThrows: Record<SaveType, ZeroToFour>;
-    attacks: ClassAttackProficiencies;
-    defenses: ClassDefenseProficiencies;
-    /** Starting proficiency in "spell attack rolls and DCs" */
-    spellcasting: ZeroToFour;
-    trainedSkills: {
-        value: SkillSlug[];
-        additional: number;
-    };
-    ancestryFeatLevels: { value: number[] };
-    classFeatLevels: { value: number[] };
-    generalFeatLevels: { value: number[] };
-    skillFeatLevels: { value: number[] };
-    skillIncreaseLevels: { value: number[] };
-    level?: never;
+/** A list of old skill abbreviations meant to allow the data models to be migrateable in the meantime. */
+function getCompatSkills(): SkillSlug[] {
+    return [
+        "acr",
+        "arc",
+        "ath",
+        "cra",
+        "dec",
+        "dip",
+        "itm",
+        "med",
+        "nat",
+        "occ",
+        "prf",
+        "rel",
+        "soc",
+        "ste",
+        "sur",
+        "thi",
+        ...Object.keys(CONFIG.PF2E.skills),
+    ] as unknown as SkillSlug[];
 }
path: src/module/item/class/data.ts
user: login: CarlosFdez
body: Done. Needed to be moved anyways since it seems deity needs the same treatment, and likely NPC as well.
created_at: 2025-05-11T22:05:51Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: stwlam
body: They can (and should) be passed to `{{formGroup}}` already localized
created_at: 2025-05-12T01:39:44Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: CarlosFdez
body: for that to happen, don't they need to be localized in the data model itself?

Anyways if `localize=true` in the handlebars helpers isn't an option here, and we need them to be pre-localized, I'll test if making choices a function will make it work with localization modules soon. Or if there's some method I can override in ProficiencyRankField to defer localization.
created_at: 2025-05-12T01:57:08Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: stwlam
body: Like I said, do it in the `getData` method of the sheet.
created_at: 2025-05-12T03:01:03Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: CarlosFdez
body: Actually, at least in spanish as is, its working fine (note: labels are new fields so they're not translated yet)? Is there a scenario you know of where it won't work?

![image](https://github.com/user-attachments/assets/8939dcfe-7067-4ba2-9d70-7fd20ea549b1)

![image](https://github.com/user-attachments/assets/5698a10d-6516-42a4-8205-6cc8fef46b70)

created_at: 2025-05-12T05:35:34Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: stwlam
body: Don't use `localize=true` because it'll double localize.
created_at: 2025-05-12T06:58:37Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: CarlosFdez
body: I don't understand what you're asking. If you're telling me not to localize in the data field, and keep the localization keys untranslated, then localize=true in the data field wouldn't double localize. If getData() is involved, are you asking me not to use choices at all in the proficiency field (min/max instead)?

But as is in this PR without any changes it seems like it works in the spanish translation.
created_at: 2025-05-12T07:28:31Z
author_association: COLLABORATOR
diff_hunk: @@ -8,6 +9,19 @@ class RarityField extends fields.StringField<Rarity, Rarity, true, false, true>
     }
 }
 
+class ProficiencyRankField extends fields.NumberField<ZeroToFour, ZeroToFour, true, false, true> {
+    constructor() {
+        super({
+            choices: R.mapValues({ ...CONFIG.PF2E.proficiencyLevels } as Record<ZeroToFour, string>, (v) =>
+                game.i18n.localize(v),
+            ),
path: src/module/model.ts
user: login: stwlam
body: Use `getData`, don't use `localize=true`
created_at: 2025-05-12T07:36:34Z
author_association: COLLABORATOR

PR comments:
user: login: stwlam
created_at: 2025-05-11T09:13:57Z
body: We can't safely convert this because of relatively recent breaking migrations (see migration 927).
author_association: COLLABORATOR
user: login: stwlam
created_at: 2025-05-11T09:29:41Z
body: If we're going to do this now, can we use the standard `DataModel.migrateData` method?
author_association: COLLABORATOR
user: login: CarlosFdez
created_at: 2025-05-11T09:42:52Z
body: 927 is fine for class, it changes from abbreviations to longform, and that's temporarily accommodated for. But we definitely cannot do backgrounds, that had a structural change.

Do we bother with the migrateData() now for the skills, even though that's going to be handled? We might have to do it with background though.
author_association: COLLABORATOR
user: login: stwlam
created_at: 2025-05-11T09:52:43Z
body: Yeah, I guess we can live with this solution for now.
author_association: COLLABORATOR