MediaWiki:Gadget-fa-bid-calculator.js: Difference between revisions

FA bid calculator
 
The league is NBA RealGM
 
(11 intermediate revisions by the same user not shown)
Line 1: Line 1:
/**
/**
  * 1TC RealGM — free agency bid calculator.
  * NBA RealGM — free agency bid calculator.
  * Implements the points system on [[NBA:Free Agency]]. If the two ever disagree,
  * 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.
  * the wiki page is the rule and this script is the bug.
Line 6: Line 6:
( function () {
( function () {
'use strict';
'use strict';
var NEW_POST_URL = 'https://2teams1cup.com/forum/index.php?act=post&do=new_post&f=67';


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


function money( n ) {
function money( n ) {
return '$' + n.toLocaleString( 'en-US' );
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' );
}
}


Line 24: Line 61:
function pts( n ) {
function pts( n ) {
return Math.round( n * 100 ) / 100;
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 );
}
}


Line 30: Line 128:
form.className = 'tc-calc';
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 ) {
function row( labelText, el ) {
var r = document.createElement( 'div' );
var r = document.createElement( 'div' );
Line 35: Line 138:
var l = document.createElement( 'label' );
var l = document.createElement( 'label' );
l.textContent = labelText;
l.textContent = labelText;
var c = document.createElement( 'div' );
c.className = 'tc-calc-controls';
c.appendChild( el );
r.appendChild( l );
r.appendChild( l );
r.appendChild( el );
r.appendChild( c );
form.appendChild( r );
form.appendChild( r );
return r;
return c;
}
}


var years = document.createElement( 'select' );
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' );
var salary = document.createElement( 'input' );
salary.type = 'number';
salary.type = 'text';
salary.step = String( MIN_RAISE );
salary.inputMode = 'numeric';
salary.min = String( MIN_OFFER );
salary.autocomplete = 'off';
salary.value = String( MIN_OFFER );
salary.className = 'tc-calc-money';
salary.value = MIN_OFFER.toLocaleString( 'en-US' );
salary.addEventListener( 'input', function () {
formatWithCaret( salary );
} );


var age35 = document.createElement( 'input' );
var age35 = document.createElement( 'input' );
Line 54: Line 166:
beat.type = 'number';
beat.type = 'number';
beat.step = '0.1';
beat.step = '0.1';
beat.placeholder = 'e.g. 8.5';
beat.placeholder = '8.5';


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


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


function fillYears() {
function fillYears() {
Line 80: Line 192:
fillYears();
fillYears();


row( 'Years', years );
var yearPts = document.createElement( 'span' );
row( 'Salary per year', salary );
yearPts.className = 'tc-calc-inline';
var ageRow = row( 'Player is 35 or over', age35 );
row( 'Contract length', years ).appendChild( yearPts );
ageRow.querySelector( 'label' ).style.fontWeight = 'normal';
 
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( 'Bid to beat (optional)', beat );
row( 'Your team (optional)', team );
row( 'Your team (optional)', team );
Line 94: Line 211:
function render() {
function render() {
var y = Number( years.value ),
var y = Number( years.value ),
s = Number( salary.value ),
s = parseMoney( salary.value ),
max = age35.checked ? MAX_YEARS_35 : MAX_YEARS;
max = age35.checked ? MAX_YEARS_35 : MAX_YEARS;


Line 100: Line 217:


if ( !s || isNaN( s ) ) {
if ( !s || isNaN( s ) ) {
yearPts.textContent = '';
salaryPts.textContent = '';
var empty = document.createElement( 'p' );
var empty = document.createElement( 'p' );
empty.className = 'tc-calc-note';
empty.className = 'tc-calc-note';
Line 108: Line 227:


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


var big = document.createElement( 'div' );
var big = document.createElement( 'div' );
Line 114: Line 235:
out.appendChild( big );
out.appendChild( big );


var steps = document.createElement( 'ol' );
steps.className = 'tc-calc-steps';
[
y + ' × 1.5 = ' + pts( y * YEAR_POINTS ) + ' points for years',
money( s ) + ' = ' + pts( s / 1000000 ) + ' points for money',
'Total ' + pts( total )
].forEach( function ( t ) {
var li = document.createElement( 'li' );
li.textContent = t;
steps.appendChild( li );
} );
out.appendChild( steps );


// --- validity ---
// --- validity ---
Line 134: Line 243:
if ( y > max ) {
if ( y > max ) {
problems.push( 'Maximum length is ' + max + ' years' +
problems.push( 'Maximum length is ' + max + ' years' +
( age35.checked ? ' for a player 35 or over' : '' ) + '.' );
( age35.checked ? ' for a player 35+' : '' ) + '.' );
}
}
if ( problems.length ) {
if ( problems.length ) {
Line 149: Line 258:
box.className = 'tc-calc-note';
box.className = 'tc-calc-note';


if ( total > target ) {
// 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.';
box.textContent = 'This already beats ' + pts( target ) + ' points.';
} else {
} else {
// cheapest money-only raise, in $200,000 steps
var salaryTenths = requiredTenths - y * ( YEAR_POINTS * 10 ),
var needed = ( target - total ) * 1000000,
newSalary = Math.max( salaryTenths * 100000, MIN_OFFER );
raises = Math.max( 1, Math.ceil( needed / MIN_RAISE ) ),
box.textContent = 'To beat ' + pts( target ) + ' points at ' + y +
newSalary = s + ( raises * MIN_RAISE );
while ( points( y, newSalary ) <= target ) {
newSalary += MIN_RAISE;
}
var lines = [
'To beat ' + pts( target ) + ' points at ' + y +
( y === 1 ? ' year' : ' years' ) + ': ' + money( newSalary ) +
( y === 1 ? ' year' : ' years' ) + ': ' + money( newSalary ) +
' (' + pts( points( y, newSalary ) ) + ' points).'
' (' + pts( points( y, newSalary ) ) + ' points). ';
];
 
if ( y < max ) {
var use = document.createElement( 'button' );
var extraYear = points( y + 1, s );
use.type = 'button';
lines.push( 'Or add a year: ' + ( y + 1 ) + ' years at ' + money( s ) +
use.className = 'tc-calc-use';
' = ' + pts( extraYear ) + ' points' +
use.textContent = 'Use ' + money( newSalary );
( extraYear > target ? '.' : ' — still short.' ) );
use.addEventListener( 'click', function () {
}
salary.value = newSalary.toLocaleString( 'en-US' );
box.textContent = lines.join( ' ' );
render();
} );
box.appendChild( use );
}
}
out.appendChild( box );
out.appendChild( box );
Line 176: Line 288:


// --- ready-to-paste post ---
// --- ready-to-paste post ---
var post = document.createElement( 'div' );
// "Sonics offer Caldwell Jones 2 years @ $500,000 = 3.5 points".
post.className = 'tc-calc-post';
// Whatever of team/player is missing simply drops out.
post.textContent = ( team.value.trim() || 'Suns' ) + ' offer ' +
var tm = team.value.trim(),
( name.value.trim() || 'Player Name' ) + ' ' +
pl = name.value.trim(),
y + ' Yrs @ ' + money( s ) + ' A Year = ' + pts( total ) + ' Total Pts';
prefix = tm && pl ? tm + ' offer ' + pl : ( pl || ( tm ? tm + ' offer' : '' ) ),
out.appendChild( post );
offer = y + ( y === 1 ? ' year' : ' years' ) + ' @ ' + money( s ) +
' = ' + pts( total ) + ' points';
out.appendChild( postBlock( prefix ? prefix + ' ' + offer : offer ) );


var note = document.createElement( 'div' );
note.className = 'tc-calc-note';
note.textContent = 'Check this against your cap space before posting — a bid over your ' +
'space costs 40% of the contract for a year, and you do not get the player.';
out.appendChild( note );
}
}