text: "use strict";
  
  class StatGenUi extends BaseComponent {
  	/**
  	 * @param opts
  	 * @param opts.races
  	 * @param opts.feats
  	 * @param [opts.tabMetasAdditional]
  	 * @param [opts.isCharacterMode] Disables some functionality (e.g. changing number of ability scores)
  	 * @param [opts.isFvttMode]
  	 * @param [opts.modalFilterRaces]
  	 * @param [opts.modalFilterFeats]
  	 * @param [opts.existingScores]
  	 */
  	constructor (opts) {
  		super();
  		opts = opts || {};
  
  		TabUiUtilSide.decorate(this);
  
  		this.__meta = {};
  		this._meta = null;
  		this._resetHooks("meta");
  		this._meta = this._getProxy("meta", this.__meta);
  
  		this._races = opts.races;
  		this._feats = opts.feats;
  		this._tabMetasAdditional = opts.tabMetasAdditional;
  		this._isCharacterMode = opts.isCharacterMode;
  		this._isFvttMode = opts.isFvttMode;
  
  		this._modalFilterRaces = opts.modalFilterRaces || new ModalFilterRaces({namespace: "statgen.races", isRadio: true, allData: this._races});
  		this._modalFilterFeats = opts.modalFilterFeats || new ModalFilterFeats({namespace: "statgen.feats", isRadio: true, allData: this._feats});
  
  		this._isLevelUp = !!opts.existingScores;
  		this._existingScores = opts.existingScores;
  
  		// region Rolled
  		this._$rollIptFormula = null;
  		// endregion
  
  		// region Point buy
  		this._compAsi = new StatGenUi.CompAsi({parent: this});
  		this._pbRaceHookMetas = [];
  		// endregion
  	}
  
  	get ixActiveTab () { return this._meta.ixActiveTab; }
  	set ixActiveTab (ix) { this._meta.ixActiveTab = ix; }
  
  	// region Expose for external use
  	addHookAbilityScores (hook) { Parser.ABIL_ABVS.forEach(ab => this._addHookBase(`common_export_${ab}`, hook)); }
  	addHookPulseAsi (hook) { this._addHookBase("common_pulseAsi", hook); }
  	getFormDataAsi () { return this._compAsi.getFormData(); }
  
  	getMode (ix, namespace) {
  		const {propMode} = this.getPropsAsi(ix, namespace);
  		return this._state[propMode];
  	}
  
  	setIxFeat (ix, namespace, ixFeat) {
  		const {propMode, propIxFeat} = this.getPropsAsi(ix, namespace);
  
  		if (ixFeat == null && (this._state[propMode] === "asi" || this._state[propMode] == null)) {
  			this._state[propIxFeat] = null;
  			return;
  		}
  
  		this._state[propMode] = "feat";
  		this._state[propIxFeat] = ixFeat;
  	}
  
  	set common_cntAsi (val) { this._state.common_cntAsi = val; }
  
  	addHookIxRace (hook) { this._addHookBase("common_ixRace", hook); }
  	get ixRace () { return this._state.common_ixRace; }
  	set ixRace (ixRace) { this._state.common_ixRace = ixRace; }
  
  	set common_cntFeatsCustom (val) { this._state.common_cntFeatsCustom = val; }
  	// endregion
  
  	// region Expose for ASI component
  	get isCharacterMode () { return this._isCharacterMode; }
  	get state () { return this._state; }
  	get modalFilterFeats () { return this._modalFilterFeats; }
  	get feats () { return this._feats; }
  	addHookBase (prop, hook) { return this._addHookBase(prop, hook); }
  	removeHookBase (prop, hook) { return this._removeHookBase(prop, hook); }
  	proxyAssignSimple (hookProp, toObj, isOverwrite) { return this._proxyAssignSimple(hookProp, toObj, isOverwrite); }
  	get race () { return this._races[this._state.common_ixRace]; }
  	// endregion
  
  	getTotals () {
  		if (this._isLevelUp) {
  			return {
  				mode: "levelUp",
  				totals: {
  					levelUp: this._getTotals_levelUp(),
  				},
  			};
  		}
  
  		return {
  			mode: StatGenUi.MODES[this._meta.ixActiveTab || 0],
  			totals: {
  				rolled: this._getTotals_rolled(),
  				array: this._getTotals_array(),
  				pointbuy: this._getTotals_pb(),
  				manual: this._getTotals_manual(),
  			},
  		}
  	}
  
  	_getTotals_rolled () { return Parser.ABIL_ABVS.mergeMap(ab => ({[ab]: this._rolled_getTotalScore(ab)})); }
  	_getTotals_array () { return Parser.ABIL_ABVS.mergeMap(ab => ({[ab]: this._array_getTotalScore(ab)})); }
  	_getTotals_pb () { return Parser.ABIL_ABVS.mergeMap(ab => ({[ab]: this._pb_getTotalScore(ab)})); }
  	_getTotals_manual () { return Parser.ABIL_ABVS.mergeMap(ab => ({[ab]: this._manual_getTotalScore(ab)})); }
  	_getTotals_levelUp () { return Parser.ABIL_ABVS.mergeMap(ab => ({[ab]: this._levelUp_getTotalScore(ab)})); }
  
  	addHook (hookProp, prop, hook) { return this._addHook(hookProp, prop, hook); }
  	addHookAll (hookProp, hook) {
  		this._addHookAll(hookProp, hook);
  		this._compAsi._addHookAll(hookProp, hook);
  	}
  
  	async pInit () {
  		await this._modalFilterRaces.pPreloadHidden();
  		await this._modalFilterFeats.pPreloadHidden();
  	}
  
  	getPropsAsi (ix, namespace) {
  		return {
  			prefix: `common_asi_${namespace}_${ix}_`,
  			propMode: `common_asi_${namespace}_${ix}_mode`,
  			propIxAsiPointOne: `common_asi_${namespace}_${ix}_asiPointOne`,
  			propIxAsiPointTwo: `common_asi_${namespace}_${ix}_asiPointTwo`,
  			propIxFeat: `common_asi_${namespace}_${ix}_ixFeat`,
  			propIxFeatAbility: `common_asi_${namespace}_${ix}_ixFeatAbility`,
  			propFeatAbilityChooseFrom: `common_asi_${namespace}_${ix}_featAbilityChooseFrom`,
  		};
  	}
  
  	_roll_getRolledStats () {
  		const wrpTree = Renderer.dice.lang.getTree3(this._state.rolled_formula);
  		if (!wrpTree) return this._$rollIptFormula.addClass("form-control--error");
  
  		const rolls = [];
  		for (let i = 0; i < this._state.rolled_rollCount; i++) {
  			const meta = {};
  			meta.total = wrpTree.tree.evl(meta);
  			rolls.push(meta);
  		}
  		rolls.sort((a, b) => SortUtil.ascSort(b.total, a.total));
  
  		return rolls.map(r => ({total: r.total, text: (r.text || []).join("")}));
  	}
  
  	render ($parent) {
  		$parent.empty().addClass("statgen");
  
  		const iptTabMetas = this._isLevelUp
  			? [
  				{
  					name: "Existing",
  					icon: this._isFvttMode ? `fas fa-user` : `far fa-user`,
  				},
  				...this._tabMetasAdditional || [],
  			]
  			: [
  				{
  					name: "掷骰",
  					icon: this._isFvttMode ? `fas fa-dice` : `far fa-dice`,
  				},
  				{
  					name: "标准数组",
  					icon: this._isFvttMode ? `fas fa-signal` : `far fa-signal-alt`,
  				},
  				{
  					name: "购点",
  					icon: this._isFvttMode ? `fas fa-chart-bar` : `far fa-chart-bar`,
  				},
  				{
  					name: "手动",
  					icon: this._isFvttMode ? `fas fa-tools` : `far fa-tools`,
  				},
  				...this._tabMetasAdditional || [],
  			]
  
  		const tabMetas = this._renderTabs($parent, "meta", iptTabMetas);
  
  		const $wrpAll = $(`<div class="flex-col w-100 h-100"></div>`);
  		this._render_all($wrpAll);
  
  		const hkTab = () => {
  			tabMetas[this._meta.ixActiveTab || 0].$wrpTab.append($wrpAll);
  		};
  		this._addHook("meta", "ixActiveTab", hkTab);
  		hkTab();
  
  		this._addHookBase("common_cntAsi", () => this._state.common_pulseAsi = !this._state.common_pulseAsi);
  		this._addHookBase("common_cntFeatsRace", () => this._state.common_pulseAsi = !this._state.common_pulseAsi);
  		this._addHookBase("common_cntFeatsCustom", () => this._state.common_pulseAsi = !this._state.common_pulseAsi);
  	}
  
  	_render_$getStgRolledHeader () {
  		this._$rollIptFormula = ComponentUiUtil.$getIptStr(this, "rolled_formula")
  			.addClass("text-center max-w-100p")
  			.keydown(evt => {
  				if (evt.key === "Enter") setTimeout(() => $btnRoll.click()); // Defer to allow `.change` to fire first
  			})
  			.change(() => this._$rollIptFormula.removeClass("form-control--error"));
  
  		const $iptRollCount = this._isCharacterMode ? null : ComponentUiUtil.$getIptInt(this, "rolled_rollCount", 1, {min: 1, fallbackOnNaN: 1, html: `<input type="text" class="form-control input-xs form-control--minimal text-center max-w-100p">`})
  			.keydown(evt => {
  				if (evt.key === "Enter") setTimeout(() => $btnRoll.click()); // Defer to allow `.change` to fire first
  			})
  			.change(() => this._$rollIptFormula.removeClass("form-control--error"));
  
  		const $btnRoll = $(`<button class="btn btn-primary bold">掷骰</button>`)
  			.click(() => {
  				this._state.rolled_rolls = this._roll_getRolledStats();
  			});
  
  		const $btnRandom = $(`<button class="btn btn-xs btn-default mt-2">随机分配</button>`)
  			.hideVe()
  			.click(() => {
  				const abs = [...Parser.ABIL_ABVS].shuffle();
  				abs.forEach((ab, i) => {
  					const {propAbilSelectedRollIx} = this.constructor._rolled_getProps(ab);
  					this._state[propAbilSelectedRollIx] = i;
  				});
  			});
  
  		const $wrpRolled = $(`<div class="flex-v-center mr-auto statgen-rolled__wrp-results py-1"></div>`);
  		const $wrpRolledOuter = $$`<div class="flex-v-center"><div class="mr-2">=</div>${$wrpRolled}</div>`;
  
  		const hkRolled = () => {
  			$wrpRolledOuter.toggleVe(this._state.rolled_rolls.length);
  			$btnRandom.toggleVe(this._state.rolled_rolls.length);
  
  			$wrpRolled.html(this._state.rolled_rolls.map((it, i) => {
  				const cntPrevRolls = this._state.rolled_rolls.slice(0, i).filter(r => r.total === it.total).length;
  				return `<div class="px-3 py-1 help-subtle flex-vh-center" title="${it.text}"><div class="ve-muted">[</div><div class="flex-vh-center statgen-rolled__disp-result">${it.total}${cntPrevRolls ? Parser.numberToSubscript(cntPrevRolls) : ""}</div><div class="ve-muted">]</div></div>`;
  			}));
  		};
  		this._addHookBase("rolled_rolls", hkRolled);
  		hkRolled();
  
  		return $$`<div class="flex-col mb-3 mr-auto">
  			<div class="flex mb-2">
  				<div class="flex-col flex-h-center mr-3">
  					<label class="flex-v-center"><div class="mr-2 no-shrink w-100p">公式：</div>${this._$rollIptFormula}</label>
  
  					${this._isCharacterMode ? null : $$`<label class="flex-v-center mt-2"><div class="mr-2 no-shrink w-100p">掷骰次数：</div>${$iptRollCount}</label>`}
  				</div>
  				${$btnRoll}
  			</div>
  
  			${$wrpRolledOuter}
  
  			<div class="flex-v-center">${$btnRandom}</div>
  		</div>`;
  	}
  
  	_render_$getStgArrayHeader () {
  		const $btnRandom = $(`<button class="btn btn-xs btn-default">随机分配</button>`)
  			.click(() => {
  				const abs = [...Parser.ABIL_ABVS].shuffle();
  				abs.forEach((ab, i) => {
  					const {propAbilSelectedScoreIx} = this.constructor._array_getProps(ab);
  					this._state[propAbilSelectedScoreIx] = i;
  				});
  			});
  
  		return $$`<div class="flex-col mb-3 mr-auto">
  			<div class="mb-2">将这些数值任意分配到你的属性点上：</div>
  			<div class="bold mb-2">${StatGenUi._STANDARD_ARRAY.join(", ")}</div>
  			<div class="flex">${$btnRandom}</div>
  		</div>`;
  	}
  
  	_render_$getStgManualHeader () {
  		return $$`<div class="flex-col mb-3 mr-auto">
  			<div>在下方的&quot;基础&quot;列中输入你想要的属性值。</div>
  		</div>`;
  	}
  
  	_doReset () {
  		if (this._isLevelUp) return; // Should never occur
  
  		const nxtState = this._getDefaultStateCommonResettable();
  
  		switch (this._meta.ixActiveTab) {
  			case StatGenUi._IX_TAB_ROLLED: Object.assign(nxtState, this._getDefaultStateRolledResettable()); break;
  			case StatGenUi._IX_TAB_ARRAY: Object.assign(nxtState, this._getDefaultStateArrayResettable()); break;
  			case StatGenUi._IX_TAB_PB: Object.assign(nxtState, this._getDefaultStatePointBuyResettable()); break;
  			case StatGenUi._IX_TAB_MANUAL: Object.assign(nxtState, this._getDefaultStateManualResettable()); break;
  		}
  
  		this._proxyAssignSimple("state", nxtState);
  	}
  
  	_render_$getStgPbHeader () {
  		const $iptBudget = ComponentUiUtil.$getIptInt(
  			this,
  			"pb_budget",
  			0,
  			{
  				html: `<input type="text" class="form-control statgen-pb__ipt-budget text-center statgen-shared__ipt">`,
  				min: 0,
  				fallbackOnNaN: 0,
  			},
  		);
  		const hkIsCustom = () => {
  			$iptBudget.attr("readonly", !this._state.pb_isCustom);
  		};
  		this._addHookBase("pb_isCustom", hkIsCustom);
  		hkIsCustom();
  
  		const $iptRemaining = ComponentUiUtil.$getIptInt(
  			this,
  			"pb_points",
  			0,
  			{
  				html: `<input type="text" class="form-control statgen-pb__ipt-budget text-center statgen-shared__ipt">`,
  				min: 0,
  				fallbackOnNaN: 0,
  			},
  		).attr("readonly", true);
  
  		const hkPoints = () => {
  			this._state.pb_points = this._pb_getPointsRemaining(this._state);
  			$iptRemaining.toggleClass(`statgen-pb__ipt-budget--error`, this._state.pb_points < 0);
  		};
  		this._addHookAll("state", hkPoints);
  		hkPoints();
  
  		const $btnReset = $(`<button class="btn btn-default">重置</button>`)
  			.click(() => this._doReset());
  
  		const $btnRandom = $(`<button class="btn btn-default">随机</button>`)
  			.click(() => {
  				this._doReset();
  
  				let canIncrease = Parser.ABIL_ABVS.map(it => `pb_${it}`);
  				const cpyBaseState = canIncrease.mergeMap(it => ({[it]: this._state[it]}));
  				const cntRemaining = this._pb_getPointsRemaining(cpyBaseState);
  				if (cntRemaining <= 0) return;
  
  				for (let step = 0; step < 10000; ++step) {
  					if (!canIncrease.length) break;
  
  					const prop = RollerUtil.rollOnArray(canIncrease);
  					if (!this._state.pb_rules.some(rule => rule.entity.score === cpyBaseState[prop] + 1)) {
  						canIncrease = canIncrease.filter(it => it !== prop);
  						continue;
  					}
  
  					const draftCpyBaseState = MiscUtil.copy(cpyBaseState);
  					draftCpyBaseState[prop]++;
  
  					const cntRemaining = this._pb_getPointsRemaining(draftCpyBaseState);
  
  					if (cntRemaining > 0) {
  						Object.assign(cpyBaseState, draftCpyBaseState);
  					} else if (cntRemaining === 0) {
  						this._proxyAssignSimple("state", draftCpyBaseState);
  						break;
  					} else {
  						canIncrease = canIncrease.filter(it => it !== prop);
  					}
  				}
  			});
  
  		return $$`<div class="flex mobile__flex-col mb-2">
  			<div class="flex-v-center">
  				<div class="statgen-pb__cell mr-4 mobile__hidden"></div>
  
  				<label class="flex-col mr-2">
  					<div class="mb-1 text-center">总点数</div>
  					${$iptBudget}
  				</label>
  
  				<label class="flex-col mr-2">
  					<div class="mb-1 text-center">剩余点</div>
  					${$iptRemaining}
  				</label>
  			</div>
  
  			<div class="flex-v-center mobile__mt-2">
  				<div class="flex-col mr-2">
  					<div class="mb-1 text-center mobile__hidden">&nbsp;</div>
  					${$btnReset}
  				</div>
  
  				<div class="flex-col">
  					<div class="mb-1 text-center mobile__hidden">&nbsp;</div>
  					${$btnRandom}
  				</div>
  			</div>
  		</div>`;
  	}
  
  	_render_$getStgPbCustom () {
  		const $btnAddLower = $(`<button class="btn btn-default btn-xs">添加更低的属性值</button>`)
  			.click(() => {
  				const prevLowest = this._state.pb_rules[0];
  				const score = prevLowest.entity.score - 1;
  				const cost = prevLowest.entity.cost;
  				this._state.pb_rules = [this._getDefaultState_pb_rule(score, cost), ...this._state.pb_rules];
  			});
  
  		const $btnAddHigher = $(`<button class="btn btn-default btn-xs">添加更高的属性值</button>`)
  			.click(() => {
  				const prevHighest = this._state.pb_rules.last();
  				const score = prevHighest.entity.score + 1;
  				const cost = prevHighest.entity.cost;
  				this._state.pb_rules = [...this._state.pb_rules, this._getDefaultState_pb_rule(score, cost)];
  			});
  
  		const $btnResetRules = $(`<button class="btn btn-default btn-xs">重置</button>`)
  			.click(() => {
  				this._state.pb_rules = this._getDefaultStatePointBuyCosts().pb_rules;
  			});
  
  		const $stgCustomCostControls = $$`<div class="flex-col mb-auto ml-2 mobile__ml-0 mobile__mt-3">
  			<div class="btn-group-vertical flex-col mb-2">${$btnAddLower}${$btnAddHigher}</div>
  			<div class="flex-v-center">${$btnResetRules}</div>
  		</div>`;
  
  		const $stgCostRows = $$`<div class="flex-col"></div>`;
  
  		const renderableCollectionRules = new StatGenUi.RenderableCollectionPbRules(
  			this,
  			$stgCostRows,
  		);
  		const hkRules = () => {
  			renderableCollectionRules.render();
  
  			// region Clamp values between new min/max scores
  			const {min: minScore, max: maxScore} = this._pb_getMinMaxScores();
  			Parser.ABIL_ABVS.forEach(it => {
  				const prop = `pb_${it}`;
  				this._state[prop] = Math.min(maxScore, Math.max(minScore, this._state[prop]));
  			});
  			// endregion
  		};
  		this._addHookBase("pb_rules", hkRules);
  		hkRules();
  
  		let lastIsCustom = this._state.pb_isCustom;
  		const hkIsCustomReset = () => {
  			$stgCustomCostControls.toggleVe(this._state.pb_isCustom);
  
  			if (lastIsCustom === this._state.pb_isCustom) return;
  			lastIsCustom = this._state.pb_isCustom;
  
  			// On resetting to non-custom, reset the rules
  			if (!this._state.pb_isCustom) this._state.pb_rules = this._getDefaultStatePointBuyCosts().pb_rules;
  		};
  		this._addHookBase("pb_isCustom", hkIsCustomReset);
  		hkIsCustomReset();
  
  		return $$`<div class="flex-col">
  			<h4>属性值点数花费</h4>
  
  			<div class="flex-col">
  				<div class="flex mobile__flex-col">
  					<div class="flex-col mr-3mobile__mr-0">
  						<div class="flex-v-center mb-1">
  							<div class="statgen-pb__col-cost flex-vh-center bold">属性值</div>
  							<div class="statgen-pb__col-cost flex-vh-center bold">调整值</div>
  							<div class="statgen-pb__col-cost flex-vh-center bold">花费点数</div>
  							<div class="statgen-pb__col-cost-delete"></div>
  						</div>
  
  						${$stgCostRows}
  					</div>
  
  					${$stgCustomCostControls}
  				</div>
  			</div>
  
  			<hr class="hr-4 mb-2">
  
  			<label class="flex-v-center">
  				<div class="mr-2">自定规则</div>
  				${ComponentUiUtil.$getCbBool(this, "pb_isCustom")}
  			</label>
  		</div>`;
  	}
  
  	_render_all ($wrpTab) {
  		if (this._isLevelUp) return this._render_isLevelUp($wrpTab);
  		this._render_isLevelOne($wrpTab);
  	}
  
  	_render_isLevelOne ($wrpTab) {
  		const $elesRolled = [];
  		const $elesArray = [];
  		const $elesPb = [];
  		const $elesManual = [];
  
  		// region Rolled header
  		const $stgRolledHeader = this._render_$getStgRolledHeader();
  		const hkStgRolled = () => $stgRolledHeader.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_ROLLED);
  		this._addHook("meta", "ixActiveTab", hkStgRolled)
  		hkStgRolled();
  		// endregion
  
  		// region Point Buy stages
  		const $stgPbHeader = this._render_$getStgPbHeader();
  		const $stgPbCustom = this._render_$getStgPbCustom();
  		const $vrPbCustom = $(`<div class="vr-5 mobile-ish__hidden"></div>`);
  		const $hrPbCustom = $(`<hr class="hr-5 mobile-ish__visible">`);
  		const hkStgPb = () => {
  			$stgPbHeader.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_PB);
  			$stgPbCustom.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_PB);
  			$vrPbCustom.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_PB);
  			$hrPbCustom.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_PB);
  		}
  		this._addHook("meta", "ixActiveTab", hkStgPb)
  		hkStgPb();
  		// endregion
  
  		// region Array header
  		const $stgArrayHeader = this._render_$getStgArrayHeader();
  		const hkStgArray = () => $stgArrayHeader.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_ARRAY);
  		this._addHook("meta", "ixActiveTab", hkStgArray)
  		hkStgArray();
  		// endregion
  
  		// region Manual header
  		const $stgManualHeader = this._render_$getStgManualHeader();
  		const hkStgManual = () => $stgManualHeader.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_MANUAL);
  		this._addHook("meta", "ixActiveTab", hkStgManual)
  		hkStgManual();
  		// endregion
  
  		// region Other elements
  		const hkElesMode = () => {
  			$elesRolled.forEach($ele => $ele.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_ROLLED));
  			$elesArray.forEach($ele => $ele.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_ARRAY));
  			$elesPb.forEach($ele => $ele.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_PB));
  			$elesManual.forEach($ele => $ele.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_MANUAL));
  		}
  		this._addHook("meta", "ixActiveTab", hkElesMode)
  		// endregion
  
  		const $btnResetRolledOrArrayOrManual = $(`<button class="btn btn-default btn-xxs relative statgen-shared__btn-reset" title="Reset"><span class="glyphicon glyphicon-refresh"></span></button>`)
  			.click(() => this._doReset());
  		const hkRolledOrArray = () => $btnResetRolledOrArrayOrManual.toggleVe(this._meta.ixActiveTab === StatGenUi._IX_TAB_ROLLED || this._meta.ixActiveTab === StatGenUi._IX_TAB_ARRAY || this._meta.ixActiveTab === StatGenUi._IX_TAB_MANUAL);
  		this._addHook("meta", "ixActiveTab", hkRolledOrArray);
  		hkRolledOrArray();
  
  		const $wrpsBase = Parser.ABIL_ABVS.map(ab => {
  			// region Rolled
  			const {propAbilSelectedRollIx} = this.constructor._rolled_getProps(ab);
  
  			const $selRolled = $(`<select class="form-control input-xs form-control--minimal statgen-shared__ipt statgen-shared__ipt--sel"></select>`)
  				.change(() => {
  					const ix = Number($selRolled.val());
  
  					const nxtState = {
  						...Parser.ABIL_ABVS
  							.map(ab => this.constructor._rolled_getProps(ab).propAbilSelectedRollIx)
  							.filter(prop => ix != null && this._state[prop] === ix)
  							.mergeMap(prop => ({[prop]: null})),
  						[propAbilSelectedRollIx]: ~ix ? ix : null,
  					};
  					this._proxyAssignSimple("state", nxtState);
  				});
  			$(`<option/>`, {value: -1, text: "\u2014"}).appendTo($selRolled);
  
  			let $optionsRolled = [];
  			const hkRolls = () => {
  				$optionsRolled.forEach($opt => $opt.remove());
  
  				this._state.rolled_rolls.forEach((it, i) => {
  					const cntPrevRolls = this._state.rolled_rolls.slice(0, i).filter(r => r.total === it.total).length;
  					const $opt = $(`<option/>`, {value: i, text: `${it.total}${cntPrevRolls ? Parser.numberToSubscript(cntPrevRolls) : ""}`}).appendTo($selRolled);
  					$optionsRolled.push($opt);
  				});
  
  				let nxtSelIx = this._state[propAbilSelectedRollIx];
  				if (nxtSelIx >= this._state.rolled_rolls.length) nxtSelIx = null;
  				$selRolled.val(`${nxtSelIx == null ? -1 : nxtSelIx}`);
  				if ((nxtSelIx) !== this._state[propAbilSelectedRollIx]) this._state[propAbilSelectedRollIx] = nxtSelIx;
  			};
  			this._addHookBase("rolled_rolls", hkRolls)
  			hkRolls();
  
  			const hookIxRolled = () => {
  				const ix = this._state[propAbilSelectedRollIx] == null ? -1 : this._state[propAbilSelectedRollIx];
  				$selRolled.val(`${ix}`);
  			};
  			this._addHookBase(propAbilSelectedRollIx, hookIxRolled);
  			hookIxRolled();
  
  			$elesRolled.push($selRolled);
  			// endregion
  
  			// region Array
  			const {propAbilSelectedScoreIx} = this.constructor._array_getProps(ab);
  
  			const $selArray = $(`<select class="form-control input-xs form-control--minimal statgen-shared__ipt statgen-shared__ipt--sel"></select>`)
  				.change(() => {
  					const ix = Number($selArray.val());
  
  					const nxtState = {
  						...Parser.ABIL_ABVS
  							.map(ab => this.constructor._array_getProps(ab).propAbilSelectedScoreIx)
  							.filter(prop => ix != null && this._state[prop] === ix)
  							.mergeMap(prop => ({[prop]: null})),
  						[propAbilSelectedScoreIx]: ~ix ? ix : null,
  					};
  					this._proxyAssignSimple("state", nxtState);
  				});
  			$(`<option/>`, {value: -1, text: "\u2014"}).appendTo($selArray);
  
  			StatGenUi._STANDARD_ARRAY.forEach((it, i) => $(`<option/>`, {value: i, text: it}).appendTo($selArray));
  
  			const hookIxArray = () => {
  				const ix = this._state[propAbilSelectedScoreIx] == null ? -1 : this._state[propAbilSelectedScoreIx];
  				$selArray.val(`${ix}`);
  			};
  			this._addHookBase(propAbilSelectedScoreIx, hookIxArray);
  			hookIxArray();
  
  			$elesArray.push($selArray);
  			// endregion
  
  			// region Point buy
  			const propPb = `pb_${ab}`;
  			const $iptPb = ComponentUiUtil.$getIptInt(
  				this,
  				propPb,
  				0,
  				{
  					fallbackOnNaN: 0,
  					min: 0,
  					html: `<input class="form-control form-control--minimal statgen-shared__ipt text-right" type="number">`,
  				},
  			);
  
  			const hkPb = () => {
  				const {min: minScore, max: maxScore} = this._pb_getMinMaxScores();
  				this._state[propPb] = Math.min(maxScore, Math.max(minScore, this._state[propPb]));
  			};
  			this._addHookBase(propPb, hkPb);
  			hkPb();
  
  			$elesPb.push($iptPb);
  			// endregion
  
  			// region Manual
  			const {propAbilValue} = this.constructor._manual_getProps(ab);
  			const $iptManual = ComponentUiUtil.$getIptInt(
  				this,
  				propAbilValue,
  				0,
  				{
  					fallbackOnNaN: 0,
  					html: `<input class="form-control form-control--minimal statgen-shared__ipt text-right" type="number">`,
  				},
  			);
  
  			$elesManual.push($iptManual);
  			// endregion
  
  			return $$`<label class="my-1 statgen-pb__cell">
  				${$selRolled}
  				${$selArray}
  				${$iptPb}
  				${$iptManual}
  			</label>`
  		});
  
  		const $wrpsUser = this._render_$getWrpsUser();
  
  		const metasTotalAndMod = this._render_getMetasTotalAndMod();
  
  		const $wrpRace = $(`<div class="flex"></div>`);
  		const $wrpRaceOuter = $$`<div class="flex-col">
  			<div class="my-1 statgen-pb__header statgen-pb__header--group mr-3 text-center italic">种族</div>
  
  			${$wrpRace}
  		</div>`;
  		const hkIxRace = () => {
  			this._pb_unhookRaceRender();
  			this._state.common_raceChoiceMetasFrom = [];
  			this._state.common_raceChoiceMetasWeighted = [];
  			const isAnyRacial = this._render_pointBuy_races($wrpRace);
  			$wrpRaceOuter.toggleVe(isAnyRacial);
  
  			const race = this._races[this._state.common_ixRace];
  			this._state.common_cntFeatsRace = race?.feats || 0;
  		};
  		this._addHookBase("common_ixRace", hkIxRace);
  		this._addHookBase("common_isTashas", hkIxRace);
  		hkIxRace();
  
  		const {$wrp: $selRace, fnUpdateHidden: fnUpdateSelRaceHidden} = ComponentUiUtil.$getSelSearchable(
  			this,
  			"common_ixRace",
  			{
  				values: this._races.map((_, i) => i),
  				isAllowNull: true,
  				fnDisplay: ix => {
  					const r = this._races[ix];
  					return `${r.name} ${r.source !== SRC_PHB ? `[${Parser.sourceJsonToAbv(r.source)}]` : ""}`;
  				},
  				asMeta: true,
  			},
  		);
  
  		const doApplyFilterToSelRace = () => {
  			const f = this._modalFilterRaces.pageFilter.filterBox.getValues();
  			const isHiddenPerRace = this._races.map(it => !this._modalFilterRaces.pageFilter.toDisplay(f, it));
  			fnUpdateSelRaceHidden(isHiddenPerRace, false);
  		};
  
  		this._modalFilterRaces.pageFilter.filterBox.on(FilterBox.EVNT_VALCHANGE, () => doApplyFilterToSelRace());
  		doApplyFilterToSelRace();
  
  		const $btnFilterForRace = $(`<button class="btn btn-xs btn-default br-0 pr-2" title="筛选种族"><span class="glyphicon glyphicon-filter"></span> 筛选</button>`)
  			.click(async () => {
  				const selected = await this._modalFilterRaces.pGetUserSelection();
  				if (selected == null || !selected.length) return;
  
  				const selectedRace = selected[0];
  				const ixRace = this._races.findIndex(it => it.name === selectedRace.name && it.source === selectedRace.values.sourceJson);
  				if (!~ixRace) throw new Error(`Could not find selected race: ${JSON.stringify(selectedRace)}`); // Should never occur
  				this._state.common_ixRace = ixRace;
  			});
  
  		const $btnPreviewRace = ComponentUiUtil.$getBtnBool(
  			this,
  			"common_isPreviewRace",
  			{
  				html: `<button class="btn btn-xs btn-default" title="Toggle Race Preview"><span class="glyphicon glyphicon-eye-open"></span></button>`,
  			},
  		);
  		const hkBtnPreviewRace = () => $btnPreviewRace.toggleVe(this._state.common_ixRace != null);
  		this._addHookBase("common_ixRace", hkBtnPreviewRace)
  		hkBtnPreviewRace();
  
  		const $dispPreviewRace = $(`<div class="flex-col mb-2"></div>`);
  		const hkPreviewRace = () => {
  			if (!this._state.common_isPreviewRace) return $dispPreviewRace.hideVe();
  
  			const race = this._state.common_ixRace != null ? this._races[this._state.common_ixRace] : null;
  			if (!race) return $dispPreviewRace.hideVe();
  
  			$dispPreviewRace.empty().showVe().append(Renderer.hover.$getHoverContent_stats(UrlUtil.PG_RACES, race));
  		};
  		this._addHookBase("common_ixRace", hkPreviewRace);
  		this._addHookBase("common_isPreviewRace", hkPreviewRace);
  		hkPreviewRace();
  
  		const $btnToggleTashasPin = ComponentUiUtil.$getBtnBool(
  			this,
  			"common_isShowTashasRules",
  			{
  				html: `<button class="btn btn-xxs btn-default ve-small p-0 statgen-shared__btn-toggle-tashas-rules flex-vh-center" title="Toggle &quot;Customizing Your Origin&quot; Section"><span class="glyphicon glyphicon-eye-open"></span></button>`,
  			},
  		);
  
  		const $dispTashas = $(`<div class="flex-col"><div class="italic ve-muted">加载中...</div></div>`);
  		Renderer.hover.pCacheAndGet(UrlUtil.PG_VARIANTRULES, SRC_TCE, UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_VARIANTRULES]({name: "Customizing Your Origin", source: SRC_TCE}))
  			.then(rule => {
  				$$($dispTashas.empty())`${Renderer.hover.$getHoverContent_stats(UrlUtil.PG_VARIANTRULES, rule)}<hr class="hr-3">`;
  			});
  		const hkIsShowTashas = () => {
  			$dispTashas.toggleVe(this._state.common_isShowTashasRules);
  		};
  		this._addHookBase("common_isShowTashasRules", hkIsShowTashas);
  		hkIsShowTashas();
  
  		const $hrPreviewRaceTashas = $(`<hr class="hr-3">`);
  		const hkPreviewAndTashas = () => $hrPreviewRaceTashas.toggleVe(this._state.common_isPreviewRace && this._state.common_isShowTashasRules);
  		this._addHookBase("common_isPreviewRace", hkPreviewAndTashas);
  		this._addHookBase("common_isShowTashasRules", hkPreviewAndTashas);
  		hkPreviewAndTashas();
  
  		const $wrpAsi = this._render_$getWrpAsi();
  
  		hkElesMode();
  
  		$$($wrpTab)`
  			${$stgRolledHeader}
  			${$stgArrayHeader}
  			${$stgManualHeader}
  
  			<div class="flex mobile-ish__flex-col w-100 px-3">
  				<div class="flex-col">
  					${$stgPbHeader}
  
  					<div class="flex">
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header"></div>
  							<div class="my-1 statgen-pb__header flex-h-right">${$btnResetRolledOrArrayOrManual}</div>
  
  							${Parser.ABIL_ABVS.map(it => `<div class="my-1 bold statgen-pb__cell flex-v-center flex-h-right" title="${Parser.attAbvToFull(it)}">${it.toUpperCase()}</div>`)}
  						</div>
  
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header"></div>
  							<div class="my-1 bold statgen-pb__header flex-vh-center">基础</div>
  							${$wrpsBase}
  						</div>
  
  						${$wrpRaceOuter}
  
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header"></div>
  							<div class="my-1 statgen-pb__header flex-vh-center help text-muted" title="此处输入额外/自定义的加值">用户</div>
  							${$wrpsUser}
  						</div>
  
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header"></div>
  							<div class="my-1 statgen-pb__header flex-vh-center">总计</div>
  							${metasTotalAndMod.map(it => it.$wrpIptTotal)}
  						</div>
  
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header"></div>
  							<div class="my-1 statgen-pb__header flex-vh-center" title="调整值">调整值</div>
  							${metasTotalAndMod.map(it => it.$wrpIptMod)}
  						</div>
  					</div>
  
  					<div class="flex-col">
  						<div class="mb-1">选择种族</div>
  						<div class="flex-v-center mb-2">
  							<div class="flex-v-center btn-group w-100 mr-2">${$btnFilterForRace}${$selRace}</div>
  							<div>${$btnPreviewRace}</div>
  						</div>
  						<label class="flex-v-center mb-1">
  							<div class="mr-1">允许起源自定义</div>
  							${ComponentUiUtil.$getCbBool(this, "common_isTashas")}
  						</label>
  						<div class="flex">
  							<div class="ve-small ve-muted italic mr-1">${Renderer.get().render(`一条{@variantrule Customizing Your Origin|TCE|可选规则}`)}</div>
  							${$btnToggleTashasPin}
  							<div class="ve-small ve-muted italic ml-1">${Renderer.get().render(`来自塔莎的万象坩埚，第 8 页。`)}</div>
  						</div>
  					</div>
  				</div>
  
  				${$vrPbCustom}
  				${$hrPbCustom}
  
  				${$stgPbCustom}
  			</div>
  
  			<hr class="hr-3">
  
  			${$dispPreviewRace}
  			${$hrPreviewRaceTashas}
  			${$dispTashas}
  
  			${$wrpAsi}
  		`;
  	}
  
  	_render_isLevelUp ($wrpTab) {
  		const $wrpsExisting = Parser.ABIL_ABVS.map(ab => {
  			const $iptExisting = $(`<input class="form-control form-control--minimal statgen-shared__ipt text-right" type="number" readonly>`)
  				.val(this._existingScores[ab]);
  
  			return $$`<label class="my-1 statgen-pb__cell">
  				${$iptExisting}
  			</label>`
  		});
  
  		const $wrpsUser = this._render_$getWrpsUser();
  
  		const metasTotalAndMod = this._render_getMetasTotalAndMod();
  
  		const $wrpAsi = this._render_$getWrpAsi();
  
  		$$($wrpTab)`
  			<div class="flex mobile-ish__flex-col w-100 px-3">
  				<div class="flex-col">
  					<div class="flex">
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header"></div>
  
  							${Parser.ABIL_ABVS.map(it => `<div class="my-1 bold statgen-pb__cell flex-v-center flex-h-right" title="${Parser.attAbvToFull(it)}">${it.toUpperCase()}</div>`)}
  						</div>
  
  						<div class="flex-col mr-3">
  							<div class="my-1 bold statgen-pb__header flex-vh-center" title="当前">当前</div>
  							${$wrpsExisting}
  						</div>
  
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header flex-vh-center help text-muted" title="此处输入额外/自定义的加值">用户</div>
  							${$wrpsUser}
  						</div>
  
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header flex-vh-center">总计</div>
  							${metasTotalAndMod.map(it => it.$wrpIptTotal)}
  						</div>
  
  						<div class="flex-col mr-3">
  							<div class="my-1 statgen-pb__header flex-vh-center" title="调整值">调整值</div>
  							${metasTotalAndMod.map(it => it.$wrpIptMod)}
  						</div>
  					</div>
  				</div>
  			</div>
  
  			<hr class="hr-3">
  
  			${$wrpAsi}
  		`;
  	}
  
  	_render_$getWrpsUser () {
  		return Parser.ABIL_ABVS.map(ab => {
  			const {propUserBonus} = this.constructor._common_getProps(ab);
  			const $ipt = ComponentUiUtil.$getIptInt(
  				this,
  				propUserBonus,
  				0,
  				{
  					fallbackOnNaN: 0,
  					html: `<input class="form-control form-control--minimal statgen-shared__ipt text-right" type="number">`,
  				},
  			);
  			return $$`<label class="my-1 statgen-pb__cell">${$ipt}</label>`
  		});
  	}
  
  	_render_getMetasTotalAndMod () {
  		return Parser.ABIL_ABVS.map(ab => {
  			const $iptTotal = $(`<input class="form-control form-control--minimal statgen-shared__ipt text-center" type="text" readonly>`);
  			const $iptMod = $(`<input class="form-control form-control--minimal statgen-shared__ipt text-center" type="text" readonly>`);
  
  			const $wrpIptTotal = $$`<label class="my-1 statgen-pb__cell">${$iptTotal}</label>`;
  			const $wrpIptMod = $$`<label class="my-1 statgen-pb__cell">${$iptMod}</label>`;
  
  			const exportedStateProp = `common_export_${ab}`;
  
  			const hk = () => {
  				const totalScore = this._isLevelUp
  					? this._levelUp_getTotalScore(ab)
  					: this._meta.ixActiveTab === StatGenUi._IX_TAB_ROLLED
  						? this._rolled_getTotalScore(ab)
  						: this._meta.ixActiveTab === StatGenUi._IX_TAB_ARRAY
  							? this._array_getTotalScore(ab)
  							: this._meta.ixActiveTab === StatGenUi._IX_TAB_PB
  								? this._pb_getTotalScore(ab)
  								: this._manual_getTotalScore(ab);
  
  				const isOverLimit = totalScore > 20;
  				$iptTotal
  					.val(totalScore)
  					.toggleClass("form-control--error", isOverLimit)
  					.title(isOverLimit ? `In general, you can't increase an ability score above 20.` : "");
  				$iptMod.val(Parser.getAbilityModifier(totalScore));
  
  				this._state[exportedStateProp] = totalScore;
  			};
  			this._addHookAll("state", hk);
  			this._addHook("meta", "ixActiveTab", hk);
  			hk();
  
  			return {
  				$wrpIptTotal,
  				$wrpIptMod,
  			}
  		});
  	}
  
  	_render_$getWrpAsi () {
  		const $wrpAsi = $(`<div class="flex-col w-100"></div>`);
  		this._compAsi.render($wrpAsi);
  		return $wrpAsi;
  	}
  
  	static _common_getProps (ab) {
  		return {
  			propUserBonus: `${StatGenUi._PROP_PREFIX_COMMON}${ab}_user`,
  		}
  	}
  
  	static _rolled_getProps (ab) {
  		return {
  			propAbilSelectedRollIx: `${StatGenUi._PROP_PREFIX_ROLLED}${ab}_abilSelectedRollIx`,
  		};
  	}
  
  	static _array_getProps (ab) {
  		return {
  			propAbilSelectedScoreIx: `${StatGenUi._PROP_PREFIX_ARRAY}${ab}_abilSelectedScoreIx`,
  		};
  	}
  
  	static _manual_getProps (ab) {
  		return {
  			propAbilValue: `${StatGenUi._PROP_PREFIX_MANUAL}${ab}_abilValue`,
  		};
  	}
  
  	_pb_unhookRaceRender () {
  		this._pbRaceHookMetas.forEach(it => it.unhook())
  		this._pbRaceHookMetas = [];
  	}
  
  	_pb_getRacialAbility () {
  		const race = this._state.common_ixRace != null ? this._races[this._state.common_ixRace] : null;
  		if (!race) return null;
  
  		// (Always use the first set of ability scores, for simplicity)
  		let fromRace = race.ability ? race.ability[0] : null;
  		if (!fromRace) return null;
  
  		if (this._state.common_isTashas) {
  			const weights = [];
  
  			if (fromRace.choose && fromRace.choose.weighted && fromRace.choose.weighted.weights) {
  				weights.push(...fromRace.choose.weighted.weights);
  			}
  
  			Parser.ABIL_ABVS.forEach(it => {
  				if (fromRace[it]) weights.push(fromRace[it]);
  			});
  
  			if (fromRace.choose && fromRace.choose.from) {
  				const count = fromRace.choose.count || 1;
  				const amount = fromRace.choose.amount || 1;
  				for (let i = 0; i < count; ++i) weights.push(amount);
  			}
  
  			weights.sort((a, b) => SortUtil.ascSort(b, a));
  
  			fromRace = {
  				choose: {
  					weighted: {
  						from: [...Parser.ABIL_ABVS],
  						weights,
  					},
  				},
  			};
  		}
  
  		return fromRace;
  	}
  
  	_pb_getPointsRemaining (baseState) {
  		const spent = Parser.ABIL_ABVS.map(it => {
  			const prop = `pb_${it}`;
  			const score = baseState[prop];
  			const rule = this._state.pb_rules.find(it => it.entity.score === score);
  			if (!rule) return 0;
  			return rule.entity.cost;
  		}).reduce((a, b) => a + b, 0);
  
  		return this._state.pb_budget - spent;
  	}
  
  	_render_pointBuy_races ($wrpRaces) {
  		$wrpRaces.empty();
  
  		const fromRace = this._pb_getRacialAbility();
  		if (fromRace == null) return false;
  
  		let $ptBase = null;
  		if (Parser.ABIL_ABVS.some(it => fromRace[it])) {
  			const $wrpsRace = Parser.ABIL_ABVS.map(ab => {
  				return $$`<div class="my-1 statgen-pb__cell">
  					<input class="form-control form-control--minimal statgen-shared__ipt text-right" type="number" readonly value="${fromRace[ab] || 0}">
  				</div>`;
  			});
  
  			$ptBase = $$`<div class="flex-col mr-3">
  				<div class="my-1 statgen-pb__header flex-vh-center">静态</div>
  				${$wrpsRace}
  			</div>`
  		}
  
  		let $ptChooseFrom = null;
  		if (fromRace.choose && fromRace.choose.from) {
  			const amount = fromRace.choose.amount || 1;
  			const count = fromRace.choose.count || 1;
  
  			const $wrpsChoose = Parser.ABIL_ABVS.map(ab => {
  				if (!fromRace.choose.from.includes(ab)) return `<div class="my-1 statgen-pb__cell"></div>`;
  
  				const $cb = $(`<input type="checkbox">`)
  					.change(() => {
  						const existing = this._state.common_raceChoiceMetasFrom.find(it => it.ability === ab);
  						if (existing) {
  							this._state.common_raceChoiceMetasFrom = this._state.common_raceChoiceMetasFrom.filter(it => it !== existing);
  							return;
  						}
  
  						// If we're already at the max number of choices, remove the oldest one
  						if (this._state.common_raceChoiceMetasFrom.length >= count) {
  							while (this._state.common_raceChoiceMetasFrom.length >= count) this._state.common_raceChoiceMetasFrom.shift();
  							this._state.common_raceChoiceMetasFrom = [...this._state.common_raceChoiceMetasFrom];
  						}
  
  						this._state.common_raceChoiceMetasFrom = [
  							...this._state.common_raceChoiceMetasFrom,
  							{ability: ab, amount},
  						];
  					});
  
  				const hk = () => $cb.prop("checked", this._state.common_raceChoiceMetasFrom.some(it => it.ability === ab));
  				this._addHookBase("common_raceChoiceMetasFrom", hk);
  				this._pbRaceHookMetas.push({unhook: () => this._removeHookBase("common_raceChoiceMetasFrom", hk)});
  				hk();
  
  				return $$`<label class="my-1 statgen-pb__cell flex-vh-center">${$cb}</label>`;
  			});
  
  			$ptChooseFrom = $$`<div class="flex-col mr-3">
  				<div class="my-1 statgen-pb__header statgen-pb__header--choose-from flex-vh-center">
  					<div class="${count !== 1 ? `mr-1` : ""}">${UiUtil.intToBonus(amount)}</div>${count !== 1 ? `<div class="ve-small ve-muted">(x${count})</div>` : ""}
  				</div>
  				${$wrpsChoose}
  			</div>`
  		}
  
  		let $ptsChooseWeighted = null;
  		if (fromRace.choose && fromRace.choose.weighted && fromRace.choose.weighted.weights) {
  			$ptsChooseWeighted = fromRace.choose.weighted.weights.map((weight, ixWeight) => {
  				const $wrpsChoose = Parser.ABIL_ABVS.map(ab => {
  					if (!fromRace.choose.weighted.from.includes(ab)) return `<div class="my-1 statgen-pb__cell"></div>`;
  
  					const $cb = $(`<input type="checkbox">`)
  						.change(() => {
  							const existing = this._state.common_raceChoiceMetasWeighted.find(it => it.ability === ab && it.ix === ixWeight);
  							if (existing) {
  								this._state.common_raceChoiceMetasWeighted = this._state.common_raceChoiceMetasWeighted.filter(it => it !== existing);
  								return;
  							}
  
  							// Remove other selections for the same ability score, or selections for the same weight
  							const withSameAbil = this._state.common_raceChoiceMetasWeighted.filter(it => it.ability === ab || it.ix === ixWeight);
  							if (withSameAbil.length) {
  								this._state.common_raceChoiceMetasWeighted = this._state.common_raceChoiceMetasWeighted.filter(it => it.ability !== ab && it.ix !== ixWeight);
  							}
  
  							this._state.common_raceChoiceMetasWeighted = [
  								...this._state.common_raceChoiceMetasWeighted,
  								{ability: ab, amount: weight, ix: ixWeight},
  							];
  						});
  
  					const hk = () => {
  						$cb.prop("checked", this._state.common_raceChoiceMetasWeighted.some(it => it.ability === ab && it.ix === ixWeight));
  					};
  					this._addHookBase("common_raceChoiceMetasWeighted", hk);
  					this._pbRaceHookMetas.push({unhook: () => this._removeHookBase("common_raceChoiceMetasWeighted", hk)});
  					hk();
  
  					return $$`<label class="my-1 statgen-pb__cell flex-vh-center">${$cb}</label>`;
  				});
  
  				return $$`<div class="flex-col mr-3">
  					<div class="my-1 statgen-pb__header statgen-pb__header--choose-from flex-vh-center">${UiUtil.intToBonus(weight)}</div>
  					${$wrpsChoose}
  				</div>`
  			});
  		}
  
  		$$($wrpRaces)`
  			${$ptBase}
  			${$ptChooseFrom}
  			${$ptsChooseWeighted}
  		`;
  
  		return $ptBase || $ptChooseFrom || $ptsChooseWeighted;
  	}
  
  	_rolled_getTotalScore (ab) {
  		const {propAbilSelectedRollIx} = this.constructor._rolled_getProps(ab);
  		const {propUserBonus} = this.constructor._common_getProps(ab);
  		return (this._state.rolled_rolls[this._state[propAbilSelectedRollIx]] || {total: 0}).total + this._state[propUserBonus] + this._getTotalScore_getBonuses(ab);
  	}
  
  	_array_getTotalScore (ab) {
  		const {propAbilSelectedScoreIx} = this.constructor._array_getProps(ab);
  		const {propUserBonus} = this.constructor._common_getProps(ab);
  		return (StatGenUi._STANDARD_ARRAY[this._state[propAbilSelectedScoreIx]] || 0) + this._state[propUserBonus] + this._getTotalScore_getBonuses(ab);
  	}
  
  	_pb_getTotalScore (ab) {
  		const prop = `pb_${ab}`;
  		const {propUserBonus} = this.constructor._common_getProps(ab);
  		return this._state[prop] + this._state[propUserBonus] + this._getTotalScore_getBonuses(ab);
  	}
  
  	_manual_getTotalScore (ab) {
  		const {propAbilValue} = this.constructor._manual_getProps(ab);
  		const {propUserBonus} = this.constructor._common_getProps(ab);
  		return (this._state[propAbilValue] || 0) + this._state[propUserBonus] + this._getTotalScore_getBonuses(ab);
  	}
  
  	_levelUp_getTotalScore (ab) {
  		const {propUserBonus} = this.constructor._common_getProps(ab);
  		return (this._existingScores[ab] || 0) + this._state[propUserBonus] + this._getTotalScore_getBonuses(ab);
  	}
  
  	_getTotalScore_getBonuses (ab) {
  		let total = 0;
  
  		if (!this._isLevelUp) {
  			const fromRace = this._pb_getRacialAbility();
  			if (fromRace) {
  				if (fromRace[ab]) total += fromRace[ab];
  
  				if (fromRace.choose && fromRace.choose.from) {
  					total += this._state.common_raceChoiceMetasFrom
  						.filter(it => it.ability === ab)
  						.map(it => it.amount)
  						.reduce((a, b) => a + b, 0);
  				}
  
  				if (fromRace.choose && fromRace.choose.weighted && fromRace.choose.weighted.weights) {
  					total += this._state.common_raceChoiceMetasWeighted
  						.filter(it => it.ability === ab)
  						.map(it => it.amount)
  						.reduce((a, b) => a + b, 0);
  				}
  			}
  		}
  
  		const formDataAsi = this._compAsi.getFormData();
  		if (formDataAsi) total += formDataAsi.data[ab] || 0;
  
  		return total;
  	}
  
  	getSaveableState () {
  		const out = super.getSaveableState();
  
  		if (out.common_ixRace != null) {
  			out._pb_raceHash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_RACES](this._races[out.common_ixRace]);
  			delete out.common_ixRace;
  		}
  
  		return out;
  	}
  
  	setStateFrom (saved, isOverwrite = false) {
  		saved = MiscUtil.copy(saved);
  
  		MiscUtil.getOrSet(saved, "state", {});
  
  		if (saved._pb_raceHash) {
  			const ixRace = this._races.findIndex(it => {
  				const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_RACES](it);
  				return hash === saved._pb_raceHash;
  			});
  			if (~ixRace) saved.common_ixRace = ixRace;
  		}
  
  		const validKeys = new Set(Object.keys(this._getDefaultState()));
  		const validKeyPrefixes = [
  			StatGenUi._PROP_PREFIX_COMMON,
  			StatGenUi._PROP_PREFIX_ROLLED,
  			StatGenUi._PROP_PREFIX_ARRAY,
  			StatGenUi._PROP_PREFIX_MANUAL,
  		];
  
  		Object.keys(saved.state).filter(k => !validKeys.has(k) && !validKeyPrefixes.some(it => k.startsWith(it))).forEach(k => delete saved.state[k]);
  
  		// region Trim the ASI/feat state to the max count of ASIs/feats
  		for (let i = saved.state.common_cntAsi || 0; i < 1000; ++i) {
  			const {propMode, prefix} = this.getPropsAsi(i, "ability");
  			if (saved.state[propMode]) Object.keys(saved.state).filter(k => k.startsWith(prefix)).forEach(k => delete saved.state[k]);
  		}
  
  		for (let i = saved.state.common_cntFeatsRace || 0; i < 1000; ++i) {
  			const {propMode, prefix} = this.getPropsAsi(i, "race");
  			if (saved.state[propMode]) Object.keys(saved.state).filter(k => k.startsWith(prefix)).forEach(k => delete saved.state[k]);
  		}
  
  		for (let i = saved.state.common_cntFeatsCustom || 0; i < 1000; ++i) {
  			const {propMode, prefix} = this.getPropsAsi(i, "custom");
  			if (saved.state[propMode]) Object.keys(saved.state).filter(k => k.startsWith(prefix)).forEach(k => delete saved.state[k]);
  		}
  		// endregion
  
  		super.setStateFrom(saved, isOverwrite);
  	}
  
  	_pb_getMinMaxScores () {
  		return {
  			min: Math.min(...this._state.pb_rules.map(it => it.entity.score)),
  			max: Math.max(...this._state.pb_rules.map(it => it.entity.score)),
  		};
  	}
  
  	_getDefaultStateCommonResettable () {
  		return {
  			...Parser.ABIL_ABVS.mergeMap(ab => ({[this.constructor._common_getProps(ab).propUserBonus]: 0})),
  
  			common_raceChoiceMetasFrom: [],
  			common_raceChoiceMetasWeighted: [],
  		};
  	}
  
  	_getDefaultStateRolledResettable () {
  		return {
  			...Parser.ABIL_ABVS.mergeMap(ab => ({[this.constructor._rolled_getProps(ab).propAbilSelectedRollIx]: null})),
  		};
  	}
  
  	_getDefaultStateArrayResettable () {
  		return {
  			...Parser.ABIL_ABVS.mergeMap(ab => ({[this.constructor._array_getProps(ab).propAbilSelectedScoreIx]: null})),
  		};
  	}
  
  	_getDefaultStatePointBuyResettable () {
  		return {
  			pb_str: 8,
  			pb_dex: 8,
  			pb_con: 8,
  			pb_int: 8,
  			pb_wis: 8,
  			pb_cha: 8,
  		};
  	}
  
  	_getDefaultStatePointBuyCosts () {
  		return {
  			pb_rules: [
  				{score: 8, cost: 0},
  				{score: 9, cost: 1},
  				{score: 10, cost: 2},
  				{score: 11, cost: 3},
  				{score: 12, cost: 4},
  				{score: 13, cost: 5},
  				{score: 14, cost: 7},
  				{score: 15, cost: 9},
  			].map(({score, cost}) => this._getDefaultState_pb_rule(score, cost)),
  		};
  	}
  
  	_getDefaultState_pb_rule (score, cost) {
  		return {
  			id: CryptUtil.uid(),
  			entity: {
  				score,
  				cost,
  			},
  		}
  	}
  
  	_getDefaultStateManualResettable () {
  		return {
  			...Parser.ABIL_ABVS.mergeMap(ab => ({[this.constructor._manual_getProps(ab).propAbilValue]: null})),
  		};
  	}
  
  	_getDefaultState () {
  		return {
  			// region Common
  			common_isPreviewRace: false,
  			common_isTashas: false,
  			common_isShowTashasRules: false,
  			common_ixRace: null,
  
  			common_pulseAsi: false,
  			common_cntAsi: 0,
  			common_cntFeatsRace: 0,
  			common_cntFeatsCustom: 0,
  
  			// region Used to allow external components to hook onto score changes
  			common_export_str: null,
  			common_export_dex: null,
  			common_export_con: null,
  			common_export_int: null,
  			common_export_wis: null,
  			common_export_cha: null,
  			// endregion
  
  			...this._getDefaultStateCommonResettable(),
  			// endregion
  
  			// region Rolled stats
  			rolled_formula: "4d6dl1",
  			rolled_rollCount: 6,
  			rolled_rolls: [],
  			...this._getDefaultStateRolledResettable(),
  			// endregion
  
  			// region Standard array
  			...this._getDefaultStateArrayResettable(),
  			// endregion
  
  			// region Point buy
  			...this._getDefaultStatePointBuyResettable(),
  			...this._getDefaultStatePointBuyCosts(),
  
  			pb_points: 27,
  			pb_budget: 27,
  
  			pb_isCustom: false,
  			// endregion
  
  			// region Manual
  			...this._getDefaultStateManualResettable(),
  			// endregion
  		}
  	}
  }
  
  StatGenUi._STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
  StatGenUi._PROP_PREFIX_COMMON = "common_";
  StatGenUi._PROP_PREFIX_ROLLED = "rolled_";
  StatGenUi._PROP_PREFIX_ARRAY = "array_";
  StatGenUi._PROP_PREFIX_MANUAL = "manual_";
  StatGenUi.MODES = [
  	"rolled",
  	"array",
  	"pointbuy",
  	"manual",
  ];
  [StatGenUi._IX_TAB_ROLLED, StatGenUi._IX_TAB_ARRAY, StatGenUi._IX_TAB_PB, StatGenUi._IX_TAB_MANUAL] = StatGenUi.MODES.map((_, i) => i);
  
  StatGenUi.CompAsi = class extends BaseComponent {
  	constructor ({parent}) {
  		super();
  		this._parent = parent;
  
  		this._metasAsi = {ability: [], race: [], custom: []};
  		this._lastMetasFeatsFnsCleanup = {ability: [], race: [], custom: []};
  		this._lastMetasFeatsAsiChooseFrom = {ability: [], race: [], custom: []};
  	}
  
  	/**
  	 * Add this to UI interactions rather than state hooks, as there is a copy of this component per tab.
  	 */
  	_doPulse () { this._parent.state.common_pulseAsi = !this._parent.state.common_pulseAsi; }
  
  	_render_renderAsiFeatSection (propCnt, namespace, $wrpRows) {
  		const hk = () => {
  			let ix = 0;
  
  			for (; ix < this._parent.state[propCnt]; ++ix) {
  				const ix_ = ix;
  				const {propMode, propIxFeat, propIxAsiPointOne, propIxAsiPointTwo, propIxFeatAbility, propFeatAbilityChooseFrom} = this._parent.getPropsAsi(ix_, namespace);
  
  				if (!this._metasAsi[namespace][ix_]) {
  					this._parent.state[propMode] = this._parent.state[propMode] || (namespace === "ability" ? "asi" : "feat");
  
  					const $btnAsi = namespace !== "ability" ? null : $(`<button class="btn btn-xs btn-default w-60p">属性增长</button>`)
  						.click(() => {
  							this._parent.state[propMode] = "asi";
  							this._doPulse();
  						});
  
  					const $btnFeat = namespace !== "ability" ? $(`<div class="w-100p text-center">专长</div>`) : $(`<button class="btn btn-xs btn-default w-50p">专长</button>`)
  						.click(() => {
  							this._parent.state[propMode] = "feat";
  							this._doPulse();
  						});
  
  					const $btnChooseFeat = $(`<button class="btn btn-xxs btn-default mr-2" title="选择一项专长"><span class="glyphicon glyphicon-search"></span></button>`)
  						.click(async () => {
  							const selecteds = await this._parent.modalFilterFeats.pGetUserSelection();
  							if (selecteds == null || !selecteds.length) return;
  
  							const selected = selecteds[0];
  							const ix = this._parent.feats.findIndex(it => it.name === selected.name && it.source === selected.values.sourceJson);
  							if (!~ix) throw new Error(`Could not find selected entity: ${JSON.stringify(selected)}`); // Should never occur
  							this._parent.state[propIxFeat] = ix;
  
  							this._doPulse();
  						});
  
  					// region ASI
  					let $stgAsi;
  					if (namespace === "ability") {
  						const $colsAsi = Parser.ABIL_ABVS.map((it, ixAsi) => {
  							const updateDisplay = () => $ipt.val(Number(this._parent.state[propIxAsiPointOne] === ixAsi) + Number(this._parent.state[propIxAsiPointTwo] === ixAsi));
  
  							const $ipt = $(`<input class="form-control form-control--minimal text-right input-xs statgen-shared__ipt" type="number" style="width: 42px;">`)
  								.disableSpellcheck()
  								.keydown(evt => { if (evt.key === "Escape") $ipt.blur(); })
  								.change(() => {
  									const raw = $ipt.val().trim();
  									const asNum = Number(raw);
  
  									const activeProps = [propIxAsiPointOne, propIxAsiPointTwo].filter(prop => this._parent.state[prop] === ixAsi);
  
  									if (isNaN(asNum) || asNum <= 0) {
  										this._parent.proxyAssignSimple(
  											"state",
  											{
  												...activeProps.mergeMap(prop => ({[prop]: null})),
  											},
  										);
  										updateDisplay();
  										return this._doPulse();
  									}
  
  									if (asNum >= 2) {
  										this._parent.proxyAssignSimple(
  											"state",
  											{
  												[propIxAsiPointOne]: ixAsi,
  												[propIxAsiPointTwo]: ixAsi,
  											},
  										);
  										updateDisplay();
  										return this._doPulse();
  									}
  
  									if (activeProps.length === 2) {
  										this._parent.state[propIxAsiPointTwo] = null;
  										updateDisplay();
  										return this._doPulse();
  									}
  
  									if (this._parent.state[propIxAsiPointOne] == null) {
  										this._parent.state[propIxAsiPointOne] = ixAsi;
  										updateDisplay();
  										return this._doPulse();
  									}
  
  									this._parent.state[propIxAsiPointTwo] = ixAsi;
  									updateDisplay();
  									this._doPulse();
  								});
  
  							const hkSelected = () => updateDisplay();
  							this._parent.addHookBase(propIxAsiPointOne, hkSelected);
  							this._parent.addHookBase(propIxAsiPointTwo, hkSelected);
  							hkSelected();
  
  							return $$`<div class="flex-col h-100 mr-2">
  							<div class="statgen-asi__cell text-center pb-1" title="${Parser.attAbvToFull(it)}">${it.toUpperCase()}</div>
  							<div class="flex-vh-center statgen-asi__cell relative">
  								<div class="absolute no-events statgen-asi__disp-plus">+</div>
  								${$ipt}
  							</div>
  						</div>`;
  						});
  
  						$stgAsi = $$`<div class="flex-v-center">
  						${$colsAsi}
  					</div>`;
  					}
  					// endregion
  
  					// region Feat
  					const $dispFeat = $(`<div class="flex-v-center mr-2"></div>`)
  					const $stgSelectAbilitySet = $$`<div class="flex-v-center mr-2"></div>`
  					const $stgFeatNoChoice = $$`<div class="flex-v-center mr-2"></div>`
  					const $stgFeatChooseAsiFrom = $$`<div class="flex-v-end"></div>`;
  					const $stgFeatChooseAsiWeighted = $$`<div class="flex-v-center"></div>`;
  
  					const $stgFeat = $$`<div class="flex-v-center">
  						${$btnChooseFeat}
  						${$dispFeat}
  						${$stgSelectAbilitySet}
  						${$stgFeatNoChoice}
  						${$stgFeatChooseAsiFrom}
  						${$stgFeatChooseAsiWeighted}
  					</div>`;
  
  					const hkIxFeat = () => {
  						const nxtState = Object.keys(this._parent.state).filter(it => it.startsWith(propFeatAbilityChooseFrom)).mergeMap(it => ({[it]: null}));
  						this._parent.proxyAssignSimple("state", nxtState);
  
  						const feat = this._parent.feats[this._parent.state[propIxFeat]];
  
  						$stgFeat.removeClass("flex-v-end").addClass("flex-v-center");
  						$dispFeat.toggleClass("italic ve-muted", !feat);
  						$dispFeat.html(feat ? Renderer.get().render(`{@feat ${feat.name.toLowerCase()}|${feat.source}}`) : `（选择一项专长）`);
  
  						if (this._lastMetasFeatsFnsCleanup[namespace][ix_]) this._lastMetasFeatsFnsCleanup[namespace][ix_].forEach(fn => fn());
  						this._lastMetasFeatsFnsCleanup[namespace][ix_] = null;
  
  						if (this._lastMetasFeatsAsiChooseFrom[namespace][ix_]) this._lastMetasFeatsAsiChooseFrom[namespace][ix_].cleanup();
  						this._lastMetasFeatsAsiChooseFrom[namespace][ix_] = null;
  
  						this._parent.state[propIxFeatAbility] = 0;
  
  						$stgSelectAbilitySet.hideVe();
  						if (feat) {
  							this._lastMetasFeatsFnsCleanup[namespace][ix_] = [];
  
  							if (feat.ability && feat.ability.length > 1) {
  								const metaChooseAbilitySet = ComponentUiUtil.$getSelEnum(
  									this._parent,
  									propIxFeatAbility,
  									{
  										values: feat.ability.map((_, i) => i),
  										fnDisplay: ix => Renderer.getAbilityData([feat.ability[ix]]).asText,
  										asMeta: true,
  									},
  								);
  
  								$stgSelectAbilitySet.showVe().append(metaChooseAbilitySet.$sel);
  								metaChooseAbilitySet.$sel.change(() => this._doPulse());
  								this._lastMetasFeatsFnsCleanup[namespace][ix_].push(() => metaChooseAbilitySet.unhook());
  							}
  
  							const hkAbilitySet = () => {
  								if (this._lastMetasFeatsAsiChooseFrom[namespace][ix_]) this._lastMetasFeatsAsiChooseFrom[namespace][ix_].cleanup();
  								this._lastMetasFeatsAsiChooseFrom[namespace][ix_] = null;
  
  								if (!feat.ability) {
  									$stgFeatNoChoice.empty().hideVe();
  									$stgFeatChooseAsiFrom.empty().hideVe();
  									return;
  								}
  
  								const abilitySet = feat.ability[this._parent.state[propIxFeatAbility]];
  
  								// region Static/no choices
  								const ptsNoChoose = Parser.ABIL_ABVS.filter(ab => abilitySet[ab]).map(ab => `${Parser.attAbvToFull(ab)} ${UiUtil.intToBonus(abilitySet[ab])}`);
  								$stgFeatNoChoice.empty().toggleVe(ptsNoChoose.length).html(`<div><span class="mr-2">\u2014</span>${ptsNoChoose.join(", ")}</div>`);
  								// endregion
  
  								// region Choices
  								if (abilitySet.choose && abilitySet.choose.from) {
  									$stgFeat.removeClass("flex-v-center").addClass("flex-v-end")
  									$stgFeatChooseAsiFrom.showVe().empty();
  									$stgFeatChooseAsiWeighted.empty().hideVe();
  
  									const count = abilitySet.choose.count || 1;
  									const amount = abilitySet.choose.amount || 1;
  
  									this._lastMetasFeatsAsiChooseFrom[namespace][ix_] = ComponentUiUtil.getMetaWrpMultipleChoice(
  										this._parent,
  										propFeatAbilityChooseFrom,
  										{
  											values: abilitySet.choose.from,
  											fnDisplay: v => `${Parser.attAbvToFull(v)} ${UiUtil.intToBonus(amount)}`,
  											count,
  										},
  									);
  
  									$stgFeatChooseAsiFrom.append(`<div><span class="mr-2">\u2014</span>choose ${count > 1 ? `${count} ` : ""}${UiUtil.intToBonus(amount)}</div>`);
  
  									this._lastMetasFeatsAsiChooseFrom[namespace][ix_].rowMetas.forEach(meta => {
  										meta.$cb.change(() => this._doPulse());
  
  										$$`<label class="flex-col no-select">
  											<div class="flex-vh-center statgen-asi__cell-feat" title="${Parser.attAbvToFull(meta.value)}">${meta.value.toUpperCase()}</div>
  											<div class="flex-vh-center statgen-asi__cell-feat">${meta.$cb}</div>
  										</label>`.appendTo($stgFeatChooseAsiFrom);
  									});
  								} else if (abilitySet.choose && abilitySet.choose.weighted) {
  									// TODO(Future) unsupported, for now
  									$stgFeatChooseAsiFrom.empty().hideVe();
  									$stgFeatChooseAsiWeighted.showVe().html(`<i class="ve-muted">The selected ability score format is currently unsupported. Please check back later!</i>`);
  								} else {
  									$stgFeatChooseAsiFrom.empty().hideVe();
  									$stgFeatChooseAsiWeighted.empty().hideVe();
  								}
  								// endregion
  							};
  							this._lastMetasFeatsFnsCleanup[namespace][ix_].push(() => this._parent.removeHookBase(propIxFeatAbility, hkAbilitySet));
  							this._parent.addHookBase(propIxFeatAbility, hkAbilitySet);
  							hkAbilitySet();
  						} else {
  							$stgFeatNoChoice.empty().hideVe();
  							$stgFeatChooseAsiFrom.empty().hideVe();
  							$stgFeatChooseAsiWeighted.empty().hideVe();
  						}
  					};
  					this._parent.addHookBase(propIxFeat, hkIxFeat);
  					// endregion
  
  					const hkMode = () => {
  						if (namespace === "ability") {
  							$btnAsi.toggleClass("active", this._parent.state[propMode] === "asi");
  							$btnFeat.toggleClass("active", this._parent.state[propMode] === "feat");
  						}
  
  						$btnChooseFeat.toggleVe(this._parent.state[propMode] === "feat");
  
  						if (namespace === "ability") $stgAsi.toggleVe(this._parent.state[propMode] === "asi");
  						$stgFeat.toggleVe(this._parent.state[propMode] === "feat");
  
  						hkIxFeat();
  					};
  					this._parent.addHookBase(propMode, hkMode);
  					hkMode();
  
  					const $row = $$`<div class="flex-v-end py-3 px-1">
  						<div class="btn-group">${$btnAsi}${$btnFeat}</div>
  						<div class="vr-4"></div>
  						${$stgAsi}
  						${$stgFeat}
  					</div>`.appendTo($wrpRows);
  
  					this._metasAsi[namespace][ix_] = {
  						$row,
  					};
  				}
  
  				this._metasAsi[namespace][ix_].$row.showVe().addClass("statgen-asi__row");
  			}
  
  			// Remove border styling from the last visible row
  			if (this._metasAsi[namespace][ix - 1]) this._metasAsi[namespace][ix - 1].$row.removeClass("statgen-asi__row");
  
  			for (; ix < this._metasAsi[namespace].length; ++ix) {
  				if (!this._metasAsi[namespace][ix]) continue;
  				this._metasAsi[namespace][ix].$row.hideVe().removeClass("statgen-asi__row");
  			}
  		};
  		this._parent.addHookBase(propCnt, hk);
  		hk();
  	}
  
  	render ($wrpAsi) {
  		const $wrpRowsAsi = $(`<div class="flex-col w-100 overflow-y-auto"></div>`);
  		const $wrpRowsRace = $(`<div class="flex-col w-100 overflow-y-auto"></div>`);
  		const $wrpRowsCustom = $(`<div class="flex-col w-100 overflow-y-auto"></div>`);
  
  		this._render_renderAsiFeatSection("common_cntAsi", "ability", $wrpRowsAsi);
  		this._render_renderAsiFeatSection("common_cntFeatsRace", "race", $wrpRowsRace);
  		this._render_renderAsiFeatSection("common_cntFeatsCustom", "custom", $wrpRowsCustom);
  
  		const $stgRace = $$`<div class="flex-col">
  			<hr class="hr-3 hr--dotted">
  			<h4 class="my-2 bold">Racial Feats</h4>
  			${$wrpRowsRace}
  		</div>`;
  		const hkIxRace = () => {
  			const race = this._parent.race;
  			$stgRace.toggleVe(!!race?.feats);
  		};
  		this._parent.addHookBase("common_ixRace", hkIxRace);
  		hkIxRace();
  
  		const $iptCountFeatsCustom = ComponentUiUtil.$getIptInt(this._parent, "common_cntFeatsCustom", 0, {min: 0, max: 20})
  			.addClass("w-100p text-center");
  
  		$$($wrpAsi)`
  			<h4 class="my-2 bold">属性值增长</h4>
  			${this._render_$getStageCntAsi()}
  			${$wrpRowsAsi}
  
  			${$stgRace}
  
  			<hr class="hr-3 hr--dotted">
  			<h4 class="my-2 bold">额外特性</h4>
  			<label class="w-100 flex-v-center mb-2">
  				<div class="mr-2 no-shrink">额外特性个数：</div>${$iptCountFeatsCustom}
  			</label>
  			${$wrpRowsCustom}
  		`;
  	}
  
  	_render_$getStageCntAsi () {
  		if (!this._parent.isCharacterMode) {
  			const $iptCountAsi = ComponentUiUtil.$getIptInt(this._parent, "common_cntAsi", 0, {min: 0, max: 20})
  				.addClass("w-100p text-center");
  			return $$`<label class="w-100 flex-v-center mb-2"><div class="mr-2 no-shrink">属性值增长应用次数：</div>${$iptCountAsi}</label>`;
  		}
  
  		const $out = $$`<div class="w-100 flex-v-center mb-2 italic ve-muted">No ability score increases available.</div>`;
  		const hkCntAsis = () => $out.toggleVe(this._parent.state.common_cntAsi === 0);
  		this._parent.addHookBase("common_cntAsi", hkCntAsis);
  		hkCntAsis();
  		return $out;
  	}
  
  	_getFormData_getForNamespace (outs, outIsFormCompletes, outFeats, propCnt, namespace) {
  		for (let i = 0; i < this._parent.state[propCnt]; ++i) {
  			const out = {};
  
  			const {propMode, propIxFeat, propIxAsiPointOne, propIxAsiPointTwo, propIxFeatAbility, propFeatAbilityChooseFrom} = this._parent.getPropsAsi(i, namespace);
  
  			let isFormComplete = true;
  
  			if (this._parent.state[propMode] === "asi") {
  				let ttlChosen = 0;
  
  				Parser.ABIL_ABVS.forEach((ab, abI) => {
  					const increase = [this._parent.state[propIxAsiPointOne] === abI, this._parent.state[propIxAsiPointTwo] === abI].filter(Boolean).length;
  					if (increase) out[ab] = increase;
  					ttlChosen += increase;
  				});
  
  				isFormComplete = ttlChosen === 2;
  
  				outFeats[namespace].push(null); // Pad the array
  			} else if (this._parent.state[propMode] === "feat") {
  				const feat = this._parent.feats[this._parent.state[propIxFeat]];
  
  				let featMeta;
  				if (feat) featMeta = {ix: this._parent.state[propIxFeat], uid: `${feat.name}|${feat.source}`};
  				else featMeta = {ix: -1, uid: null};
  				outFeats[namespace].push(featMeta)
  
  				if (feat && feat.ability) {
  					const abilitySet = feat.ability[this._parent.state[propIxFeatAbility] || 0];
  
  					// Add static values
  					Parser.ABIL_ABVS.forEach(ab => { if (abilitySet[ab]) out[ab] = abilitySet[ab]; });
  
  					if (abilitySet.choose) {
  						// Track any bonuses chosen so we can use `"inherit"` when handling a feats "additionalSpells" elsewhere
  						featMeta.abilityChosen = {};
  
  						if (abilitySet.choose.from) {
  							isFormComplete = !!this._parent.state[ComponentUiUtil.getMetaWrpMultipleChoice_getPropIsAcceptable(propFeatAbilityChooseFrom)];
  
  							const ixs = ComponentUiUtil.getMetaWrpMultipleChoice_getSelectedIxs(this._parent, propFeatAbilityChooseFrom);
  							ixs.map(it => abilitySet.choose.from[it]).forEach(ab => {
  								const amount = abilitySet.choose.amount || 1;
  								out[ab] = (out[ab] || 0) + amount;
  								featMeta.abilityChosen[ab] = amount;
  							});
  						}
  					}
  				}
  			}
  
  			outs.push(out);
  			outIsFormCompletes.push(isFormComplete);
  		}
  	}
  
  	getFormData () {
  		const outs = [];
  		const isFormCompletes = [];
  		const feats = {ability: [], race: [], custom: []};
  
  		this._getFormData_getForNamespace(outs, isFormCompletes, feats, "common_cntAsi", "ability");
  		this._getFormData_getForNamespace(outs, isFormCompletes, feats, "common_cntFeatsRace", "race");
  		this._getFormData_getForNamespace(outs, isFormCompletes, feats, "common_cntFeatsCustom", "custom");
  
  		const data = {};
  		outs.filter(Boolean).forEach(abilBonuses => Object.entries(abilBonuses).forEach(([ab, bonus]) => data[ab] = (data[ab] || 0) + bonus));
  
  		return {
  			isFormComplete: isFormCompletes.every(Boolean),
  			dataPerAsi: outs,
  			data,
  			feats,
  		};
  	}
  }
  
  StatGenUi.RenderableCollectionPbRules = class extends RenderableCollectionBase {
  	constructor (statGenUi, $wrp) {
  		super(statGenUi, "pb_rules");
  
  		this._$wrp = $wrp;
  	}
  
  	getNewRender (rule) {
  		const parentComp = this._comp;
  
  		const comp = BaseComponent.fromObject(rule.entity);
  		comp._addHookAll("state", () => {
  			rule.entity = comp.toObject();
  			parentComp._triggerCollectionUpdate("pb_rules");
  		});
  
  		const $dispCost = $(`<div class="flex-vh-center"></div>`);
  		const hkCost = () => $dispCost.text(comp._state.cost);
  		comp._addHookBase("cost", hkCost);
  		hkCost();
  
  		const $iptCost = ComponentUiUtil.$getIptInt(comp, "cost", 0, {html: `<input class="form-control input-xs form-control--minimal text-center">`, fallbackOnNaN: 0});
  
  		const hkIsCustom = () => {
  			$dispCost.toggleVe(!parentComp.state.pb_isCustom);
  			$iptCost.toggleVe(parentComp.state.pb_isCustom);
  		};
  		parentComp._addHookBase("pb_isCustom", hkIsCustom);
  		hkIsCustom();
  
  		const $btnDelete = $(`<button class="btn btn-xxs btn-danger" title="Delete"><span class="glyphicon glyphicon-trash"></span></button>`)
  			.click(() => {
  				if (parentComp.state.pb_rules.length === 1) return; // Never delete the final item
  				parentComp.state.pb_rules = parentComp.state.pb_rules.filter(it => it !== rule);
  			});
  
  		const $wrpRow = $$`<div class="flex py-1 stripe-even statgen-pb__row-cost">
  			<div class="statgen-pb__col-cost flex-vh-center">${comp._state.score}</div>
  			<div class="statgen-pb__col-cost flex-vh-center">${Parser.getAbilityModifier(comp._state.score)}</div>
  			<div class="statgen-pb__col-cost flex-vh-center px-3">
  				${$dispCost}
  				${$iptCost}
  			</div>
  			<div class="statgen-pb__col-cost-delete">${$btnDelete}</div>
  		</div>`.appendTo(this._$wrp);
  
  		const hkRules = () => {
  			$btnDelete.toggleVe((parentComp.state.pb_rules[0] === rule || parentComp.state.pb_rules.last() === rule) && parentComp.state.pb_isCustom);
  		};
  		parentComp._addHookBase("pb_rules", hkRules);
  		parentComp._addHookBase("pb_isCustom", hkRules);
  		hkRules();
  
  		return {
  			comp,
  			$wrpRow,
  			fnCleanup: () => {
  				parentComp._removeHookBase("pb_isCustom", hkIsCustom);
  				parentComp._removeHookBase("pb_isCustom", hkRules);
  				parentComp._removeHookBase("pb_rules", hkRules);
  			},
  		}
  	}
  
  	doUpdateExistingRender (renderedMeta, rule) {
  		renderedMeta.comp._proxyAssignSimple("state", rule.entity, true);
  	}
  
  	doDeleteExistingRender (renderedMeta) {
  		renderedMeta.fnCleanup();
  	}
  
  	doReorderExistingComponent (renderedMeta, rule) {
  		const parent = this._comp;
  
  		const ix = parent.state.pb_rules.map(it => it.id).indexOf(rule.id);
  		const curIx = this._$wrp.find(`.statgen-pb__row-cost`).index(renderedMeta.$wrpRow)
  
  		const isMove = !this._$wrp.length || curIx !== ix;
  		if (isMove) renderedMeta.$wrpRow.detach().appendTo(this._$wrp);
  	}
  }
  