Jump to content

MediaWiki:Gadget-fa-bid-calculator.js

From 1TC Rules
Revision as of 21:00, 7 October 2026 by Sam (talk | contribs) (Bid to beat must clear the standing bid by 0.2 points)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * 1TC RealGM — free agency bid calculator.
 * Implements the points system on [[NBA:Free Agency]]. If the two ever disagree,
 * the wiki page is the rule and this script is the bug.
 */
( function () {
	'use strict';

	var NEW_POST_URL = 'https://2teams1cup.com/forum/index.php?act=post&do=new_post&f=67';

	var MIN_OFFER = 500000,
		MIN_RAISE = 200000,
		MIN_RAISE_POINTS = 0.2,
		MAX_YEARS = 4,
		MAX_YEARS_35 = 2,
		YEAR_POINTS = 1.5;


	function money( n ) {
		return '$' + n.toLocaleString( 'en-US' );
	}

	/** Digits only, so "4,000,000" and "4000000" both read as 4000000. */
	function parseMoney( v ) {
		var digits = String( v ).replace( /[^0-9]/g, '' );
		return digits === '' ? NaN : Number( digits );
	}

	/**
	 * Reformat a text input with thousands separators, keeping the caret where
	 * the typist left it. A plain reformat would send it to the end, which makes
	 * editing the middle of a figure unusable.
	 */
	function formatWithCaret( el ) {
		var before = el.value.slice( 0, el.selectionStart ),
			digitsBefore = before.replace( /[^0-9]/g, '' ).length,
			n = parseMoney( el.value );

		el.value = isNaN( n ) ? '' : n.toLocaleString( 'en-US' );

		// Walk forward past the same number of digits to place the caret.
		var pos = 0, seen = 0;
		while ( pos < el.value.length && seen < digitsBefore ) {
			if ( /[0-9]/.test( el.value[ pos ] ) ) {
				seen++;
			}
			pos++;
		}
		el.setSelectionRange( pos, pos );
	}

	function pointWord( n ) {
		return '= ' + pts( n ) + ( pts( n ) === 1 ? ' point' : ' points' );
	}

	function points( years, salary ) {
		return ( years * YEAR_POINTS ) + ( salary / 1000000 );
	}

	/** Round a points total for display without trailing float noise. */
	function pts( n ) {
		return Math.round( n * 100 ) / 100;
	}


	/** A monospace post block with a copy button. */
	function postBlock( text ) {
		var wrap = document.createElement( 'div' );
		wrap.className = 'tc-calc-postwrap';

		var pre = document.createElement( 'div' );
		pre.className = 'tc-calc-post';
		pre.textContent = text;
		wrap.appendChild( pre );

		var btn = document.createElement( 'button' );
		btn.className = 'tc-calc-copy';
		btn.type = 'button';
		btn.textContent = 'Copy';
		btn.addEventListener( 'click', function () {
			function done( ok ) {
				btn.textContent = ok ? 'Copied' : 'Press \u2318C / Ctrl+C';
				setTimeout( function () { btn.textContent = 'Copy'; }, 2000 );
			}
			if ( navigator.clipboard && navigator.clipboard.writeText ) {
				navigator.clipboard.writeText( text ).then( function () { done( true ); },
					function () { selectFallback( pre ); done( false ); } );
			} else {
				selectFallback( pre );
				done( false );
			}
		} );
		wrap.appendChild( btn );

		var actions = document.createElement( 'div' );
		actions.className = 'tc-calc-actions';
		var link = document.createElement( 'a' );
		link.href = NEW_POST_URL;
		link.target = '_blank';
		link.rel = 'noopener';
		link.textContent = 'Copy and start a new topic in NBA: Free Agency';
		// IPB has no parameter for seeding the post body, so the next best thing
		// is to put it on the clipboard on the way out.
		link.addEventListener( 'click', function () {
			if ( navigator.clipboard && navigator.clipboard.writeText ) {
				navigator.clipboard.writeText( text );
			}
		} );
		actions.appendChild( link );

		var outer = document.createElement( 'div' );
		outer.appendChild( wrap );
		outer.appendChild( actions );
		return outer;
	}

	/** Clipboard API unavailable or refused: select the text so the reader can copy it. */
	function selectFallback( el ) {
		var range = document.createRange();
		range.selectNodeContents( el );
		var sel = window.getSelection();
		sel.removeAllRanges();
		sel.addRange( range );
	}

	function build( container ) {
		var form = document.createElement( 'div' );
		form.className = 'tc-calc';

		/**
		 * One labelled row. Controls live in their own flex wrapper so that on a
		 * narrow screen the label can stack above them while they stay in a row.
		 * Returns the wrapper, so callers can append a readout beside the control.
		 */
		function row( labelText, el ) {
			var r = document.createElement( 'div' );
			r.className = 'tc-calc-row';
			var l = document.createElement( 'label' );
			l.textContent = labelText;
			var c = document.createElement( 'div' );
			c.className = 'tc-calc-controls';
			c.appendChild( el );
			r.appendChild( l );
			r.appendChild( c );
			form.appendChild( r );
			return c;
		}

		var years = document.createElement( 'select' );
		// A number input cannot display separators — browsers reject a comma as
		// invalid number syntax — so this is a text input formatted by hand.
		var salary = document.createElement( 'input' );
		salary.type = 'text';
		salary.inputMode = 'numeric';
		salary.autocomplete = 'off';
		salary.className = 'tc-calc-money';
		salary.value = MIN_OFFER.toLocaleString( 'en-US' );
		salary.addEventListener( 'input', function () {
			formatWithCaret( salary );
		} );

		var age35 = document.createElement( 'input' );
		age35.type = 'checkbox';

		var beat = document.createElement( 'input' );
		beat.type = 'number';
		beat.step = '0.1';
		beat.placeholder = '8.5';

		var name = document.createElement( 'input' );
		name.type = 'text';
		name.placeholder = 'Caldwell Jones';
		name.className = 'tc-calc-wide';

		var team = document.createElement( 'input' );
		team.type = 'text';
		team.placeholder = 'Suns';
		team.className = 'tc-calc-wide';

		function fillYears() {
			var max = age35.checked ? MAX_YEARS_35 : MAX_YEARS,
				prev = Number( years.value ) || 1;
			years.textContent = '';
			for ( var y = 1; y <= max; y++ ) {
				var o = document.createElement( 'option' );
				o.value = String( y );
				o.textContent = y + ( y === 1 ? ' year' : ' years' );
				years.appendChild( o );
			}
			years.value = String( Math.min( prev, max ) );
		}
		fillYears();

		var yearPts = document.createElement( 'span' );
		yearPts.className = 'tc-calc-inline';
		row( 'Contract length', years ).appendChild( yearPts );

		var salaryPts = document.createElement( 'span' );
		salaryPts.className = 'tc-calc-inline';
		row( 'Salary', salary ).appendChild( salaryPts );
		row( 'Player is 35+', age35 ).parentNode
			.querySelector( 'label' ).style.fontWeight = 'normal';
		row( 'Bid to beat (optional)', beat );
		row( 'Your team (optional)', team );
		row( 'Player (optional)', name );

		var out = document.createElement( 'div' );
		out.className = 'tc-calc-out';
		form.appendChild( out );

		function render() {
			var y = Number( years.value ),
				s = parseMoney( salary.value ),
				max = age35.checked ? MAX_YEARS_35 : MAX_YEARS;

			out.textContent = '';

			if ( !s || isNaN( s ) ) {
				yearPts.textContent = '';
				salaryPts.textContent = '';
				var empty = document.createElement( 'p' );
				empty.className = 'tc-calc-note';
				empty.textContent = 'Enter a salary.';
				out.appendChild( empty );
				return;
			}

			var total = points( y, s );
			yearPts.textContent = pointWord( y * YEAR_POINTS );
			salaryPts.textContent = pointWord( s / 1000000 );

			var big = document.createElement( 'div' );
			big.className = 'tc-calc-big';
			big.textContent = pts( total ) + ' points';
			out.appendChild( big );


			// --- validity ---
			var problems = [];
			if ( s < MIN_OFFER ) {
				problems.push( 'Below the ' + money( MIN_OFFER ) + ' minimum offer.' );
			}
			if ( y > max ) {
				problems.push( 'Maximum length is ' + max + ' years' +
					( age35.checked ? ' for a player 35+' : '' ) + '.' );
			}
			if ( problems.length ) {
				var warn = document.createElement( 'div' );
				warn.className = 'tc-calc-warn';
				warn.textContent = problems.join( ' ' );
				out.appendChild( warn );
			}

			// --- what it takes to beat a standing bid ---
			var target = Number( beat.value );
			if ( target && !isNaN( target ) ) {
				var box = document.createElement( 'div' );
				box.className = 'tc-calc-note';

				// A bid must clear the standing one by at least MIN_RAISE_POINTS, so
				// the bar is target + 0.2, not merely more than target. Everything
				// here is counted in tenths of a point: 0.1 points is exactly
				// $100,000 and a year is exactly 15 tenths, so integer arithmetic
				// gets the answer that 0.2 + 2.0 in binary floating point does not.
				var requiredTenths = Math.round( target * 10 ) + ( MIN_RAISE_POINTS * 10 );

				if ( Math.round( total * 10 ) >= requiredTenths ) {
					box.textContent = 'This already beats ' + pts( target ) + ' points.';
				} else {
					var salaryTenths = requiredTenths - y * ( YEAR_POINTS * 10 ),
						newSalary = Math.max( salaryTenths * 100000, MIN_OFFER );
					box.textContent = 'To beat ' + pts( target ) + ' points at ' + y +
							( y === 1 ? ' year' : ' years' ) + ': ' + money( newSalary ) +
							' (' + pts( points( y, newSalary ) ) + ' points). ';

					var use = document.createElement( 'button' );
					use.type = 'button';
					use.className = 'tc-calc-use';
					use.textContent = 'Use ' + money( newSalary );
					use.addEventListener( 'click', function () {
						salary.value = newSalary.toLocaleString( 'en-US' );
						render();
					} );
					box.appendChild( use );
				}
				out.appendChild( box );
			}

			// --- ready-to-paste post ---
			// "Suns offer Caldwell Jones 2 years @ $500,000 = 3.5 points".
			// Whatever of team/player is missing simply drops out.
			var tm = team.value.trim(),
				pl = name.value.trim(),
				prefix = tm && pl ? tm + ' offer ' + pl : ( pl || ( tm ? tm + ' offer' : '' ) ),
				offer = y + ( y === 1 ? ' year' : ' years' ) + ' @ ' + money( s ) +
					' = ' + pts( total ) + ' points';
			out.appendChild( postBlock( prefix ? prefix + ' ' + offer : offer ) );

		}

		age35.addEventListener( 'change', function () { fillYears(); render(); } );
		[ years, salary, beat, name, team ].forEach( function ( el ) {
			el.addEventListener( 'input', render );
			el.addEventListener( 'change', render );
		} );

		container.appendChild( form );
		render();
	}

	mw.hook( 'wikipage.content' ).add( function () {
		var el = document.getElementById( 'tc-fa-bid-calculator' );
		if ( el && !el.dataset.tcBuilt ) {
			el.dataset.tcBuilt = '1';
			build( el );
		}
	} );
}() );